tmux-team 4.0.0 → 4.1.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
@@ -327,7 +377,9 @@ describe('basic commands', () => {
327
377
  it('cmdConfig set errors when not enough args', () => {
328
378
  const ctx = createCtx(testDir);
329
379
  expect(() => cmdConfig(ctx, ['set', 'mode'])).toThrow(`exit(${ExitCodes.ERROR})`);
330
- expect(ctx.ui.error).toHaveBeenCalledWith('Usage: tmux-team config set <key> <value> [--global]');
380
+ expect(ctx.ui.error).toHaveBeenCalledWith(
381
+ 'Usage: tmux-team config set <key> <value> [--global]'
382
+ );
331
383
  });
332
384
 
333
385
  it('cmdConfig errors on unknown subcommand', () => {
@@ -360,6 +412,230 @@ describe('basic commands', () => {
360
412
  );
361
413
  });
362
414
 
415
+ it('cmdMigrate dry-run reports legacy entries without writing tmux metadata', () => {
416
+ const ctx = createCtx(testDir, { flags: { json: true } });
417
+ fs.writeFileSync(
418
+ ctx.paths.localConfig,
419
+ JSON.stringify({ claude: { pane: '1.1', remark: 'review' } }, null, 2)
420
+ );
421
+
422
+ cmdMigrate(ctx, ['--dry-run']);
423
+
424
+ expect(ctx.tmux.setAgentRegistration).not.toHaveBeenCalled();
425
+ expect((ctx.ui as any).jsonCalls[0]).toMatchObject({
426
+ dryRun: true,
427
+ migrated: 0,
428
+ items: [{ agent: 'claude', fromPane: '1.1', pane: '1.1', status: 'ready' }],
429
+ });
430
+ });
431
+
432
+ it('cmdMigrate writes tmux metadata and can clean legacy entries', () => {
433
+ const ctx = createCtx(testDir);
434
+ fs.writeFileSync(
435
+ ctx.paths.localConfig,
436
+ JSON.stringify({
437
+ $config: { mode: 'wait' },
438
+ claude: { pane: '1.1', remark: 'review', preamble: 'Be helpful' },
439
+ })
440
+ );
441
+
442
+ cmdMigrate(ctx, ['--cleanup']);
443
+
444
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
445
+ '1.1',
446
+ expect.objectContaining({ type: 'workspace' }),
447
+ { name: 'claude', remark: 'review', preamble: 'Be helpful' }
448
+ );
449
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
450
+ expect(saved.$config.mode).toBe('wait');
451
+ expect(saved.claude).toBeUndefined();
452
+ });
453
+
454
+ it('cmdMigrate errors when a legacy pane cannot be resolved', () => {
455
+ const ctx = createCtx(testDir);
456
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
457
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: 'missing' } }));
458
+
459
+ expect(() => cmdMigrate(ctx, [])).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
460
+ });
461
+
462
+ it('cmdMigrate reports when no legacy agents exist', () => {
463
+ const ctx = createCtx(testDir);
464
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
465
+
466
+ cmdMigrate(ctx, []);
467
+
468
+ expect(ctx.ui.info).toHaveBeenCalledWith(`No legacy agents found in ${ctx.paths.localConfig}`);
469
+ });
470
+
471
+ it('cmdTeam lists all pane team/workspace scopes', () => {
472
+ const ctx = createCtx(testDir, { flags: { json: true } });
473
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
474
+ egp: ['claude', 'codex'],
475
+ });
476
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
477
+ {
478
+ pane: '%1',
479
+ target: 'main:1.0',
480
+ cwd: '/repo',
481
+ command: 'claude',
482
+ suggestedName: 'claude',
483
+ registrations: [
484
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
485
+ { scopeType: 'team', scope: 'egp', agent: 'claude' },
486
+ ],
487
+ },
488
+ ]);
489
+
490
+ cmdTeam(ctx, ['ls']);
491
+
492
+ expect((ctx.ui as any).jsonCalls).toEqual([
493
+ {
494
+ teams: { egp: ['claude', 'codex'] },
495
+ panes: [
496
+ {
497
+ pane: '%1',
498
+ target: 'main:1.0',
499
+ cwd: '/repo',
500
+ command: 'claude',
501
+ suggestedName: 'claude',
502
+ registrations: [
503
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
504
+ { scopeType: 'team', scope: 'egp', agent: 'claude' },
505
+ ],
506
+ },
507
+ ],
508
+ },
509
+ ]);
510
+ });
511
+
512
+ it('cmdTeam shows empty state and errors on unknown subcommands', () => {
513
+ const ctx = createCtx(testDir);
514
+
515
+ cmdTeam(ctx, ['ls']);
516
+ expect(ctx.ui.info).toHaveBeenCalledWith('No tmux panes found.');
517
+
518
+ expect(() => cmdTeam(ctx, ['wat'])).toThrow(`exit(${ExitCodes.ERROR})`);
519
+ });
520
+
521
+ it('cmdTeam summary keeps shared-team aggregate view', () => {
522
+ const ctx = createCtx(testDir);
523
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
524
+
525
+ cmdTeam(ctx, ['ls', '--summary']);
526
+
527
+ expect(ctx.ui.table).toHaveBeenCalledWith(['TEAM', 'AGENTS'], [['egp', 'claude']]);
528
+ });
529
+
530
+ it('cmdTeam table groups by team/workspace scope before pane order', () => {
531
+ const ctx = createCtx(testDir);
532
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
533
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
534
+ {
535
+ pane: '%3',
536
+ target: 'main:3.0',
537
+ cwd: '/tmp',
538
+ command: 'zsh',
539
+ suggestedName: null,
540
+ registrations: [],
541
+ },
542
+ {
543
+ pane: '%1',
544
+ target: 'main:1.0',
545
+ cwd: '/repo',
546
+ command: 'claude',
547
+ suggestedName: 'claude',
548
+ registrations: [{ scopeType: 'workspace', scope: '/repo', agent: 'claude' }],
549
+ },
550
+ {
551
+ pane: '%2',
552
+ target: 'main:2.0',
553
+ cwd: '/repo',
554
+ command: 'codex',
555
+ suggestedName: 'codex',
556
+ registrations: [{ scopeType: 'team', scope: 'beta', agent: 'codex' }],
557
+ },
558
+ {
559
+ pane: '%4',
560
+ target: 'main:4.0',
561
+ cwd: '/repo',
562
+ command: 'gemini',
563
+ suggestedName: 'gemini',
564
+ registrations: [{ scopeType: 'team', scope: 'alpha', agent: 'gemini' }],
565
+ },
566
+ ]);
567
+
568
+ cmdTeam(ctx, ['ls']);
569
+
570
+ expect(logSpy.mock.calls.map((call) => call[0])).toEqual([
571
+ 'Team: alpha (gemini)',
572
+ '',
573
+ 'Team: beta (codex)',
574
+ '',
575
+ 'Workspace: /repo (claude)',
576
+ '',
577
+ 'Unregistered panes',
578
+ ]);
579
+ expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])).toEqual([
580
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
581
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
582
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
583
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
584
+ ]);
585
+ expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0][1]).toEqual([
586
+ ['%4', 'main:4.0', '/repo', 'gemini'],
587
+ ]);
588
+ logSpy.mockRestore();
589
+ });
590
+
591
+ it('cmdTeam rm supports dry-run and force removal', () => {
592
+ const ctx = createCtx(testDir);
593
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
594
+ egp: ['claude'],
595
+ });
596
+ (ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
597
+ removed: 1,
598
+ agents: ['claude'],
599
+ });
600
+
601
+ cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
602
+ expect(ctx.tmux.removeTeam).not.toHaveBeenCalled();
603
+
604
+ ctx.flags.force = true;
605
+ cmdTeam(ctx, ['rm', 'egp']);
606
+ expect(ctx.tmux.removeTeam).toHaveBeenCalledWith('egp');
607
+ });
608
+
609
+ it('cmdTeam rm requires --force and errors for missing teams', () => {
610
+ const ctx = createCtx(testDir);
611
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
612
+
613
+ expect(() => cmdTeam(ctx, ['rm', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
614
+
615
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({});
616
+ expect(() => cmdTeam(ctx, ['rm', 'missing', '--force'])).toThrow(
617
+ `exit(${ExitCodes.PANE_NOT_FOUND})`
618
+ );
619
+ });
620
+
621
+ it('cmdTeam rm outputs JSON in dry-run and force modes', () => {
622
+ const ctx = createCtx(testDir, { flags: { json: true } });
623
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
624
+ (ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
625
+ removed: 1,
626
+ agents: ['claude'],
627
+ });
628
+
629
+ cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
630
+ ctx.flags.force = true;
631
+ cmdTeam(ctx, ['rm', 'egp']);
632
+
633
+ expect((ctx.ui as any).jsonCalls).toEqual([
634
+ { team: 'egp', dryRun: true, agents: ['claude'], removed: 0 },
635
+ { team: 'egp', removed: 1, agents: ['claude'] },
636
+ ]);
637
+ });
638
+
363
639
  it('cmdCompletion prints scripts', () => {
364
640
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
365
641
  cmdCompletion('bash');
@@ -16,15 +16,15 @@ _tmux-team() {
16
16
  'add:Add a new agent'
17
17
  'update:Update agent config'
18
18
  'remove:Remove an agent'
19
- 'init:Create empty tmux-team.json'
19
+ 'migrate:Copy legacy tmux-team.json registry into tmux metadata'
20
+ 'team:Manage shared teams'
21
+ 'init:Create empty legacy tmux-team.json'
20
22
  'completion:Output shell completion script'
21
23
  'help:Show help message'
22
24
  )
23
25
 
24
26
  _get_agents() {
25
- if [[ -f ./tmux-team.json ]]; then
26
- agents=(\${(f)"$(node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('./tmux-team.json'))).join('\\\\n'))" 2>/dev/null)"})
27
- fi
27
+ agents=(\${(f)"$(tmux-team list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const j=JSON.parse(s); console.log(Object.keys(j.agents||{}).join("\\n"))}catch{}})')"})
28
28
  }
29
29
 
30
30
  if (( CURRENT == 2 )); then
@@ -64,16 +64,14 @@ const bashCompletion = `_tmux_team() {
64
64
  cur="\${COMP_WORDS[COMP_CWORD]}"
65
65
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
66
66
 
67
- commands="talk check list add update remove init completion help"
67
+ commands="talk check list add update remove migrate team init completion help"
68
68
 
69
69
  if [[ \${COMP_CWORD} -eq 1 ]]; then
70
70
  COMPREPLY=( $(compgen -W "\${commands}" -- \${cur}) )
71
71
  elif [[ \${COMP_CWORD} -eq 2 ]]; then
72
72
  case "\${prev}" in
73
73
  talk|check|update|remove|rm)
74
- if [[ -f ./tmux-team.json ]]; then
75
- agents=$(node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('./tmux-team.json'))).join(' '))" 2>/dev/null)
76
- fi
74
+ agents=$(tmux-team list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const j=JSON.parse(s); console.log(Object.keys(j.agents||{}).join(" "))}catch{}})')
77
75
  if [[ "\${prev}" == "talk" ]]; then
78
76
  agents="\${agents} all"
79
77
  fi
@@ -51,6 +51,13 @@ function createCtx(
51
51
  capture: vi.fn(),
52
52
  listPanes: vi.fn(() => []),
53
53
  getCurrentPaneId: vi.fn(() => null),
54
+ resolvePaneTarget: vi.fn((target: string) => target),
55
+ getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
56
+ setAgentRegistration: vi.fn(),
57
+ clearAgentRegistration: vi.fn(() => false),
58
+ listTeams: vi.fn(() => ({})),
59
+ listTeamPanes: vi.fn(() => []),
60
+ removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
54
61
  };
55
62
  return {
56
63
  argv: [],
@@ -167,10 +174,7 @@ describe('cmdConfig', () => {
167
174
 
168
175
  it('shows sources in table mode with local settings', () => {
169
176
  const ctx = createCtx(testDir);
170
- fs.writeFileSync(
171
- ctx.paths.localConfig,
172
- JSON.stringify({ $config: { mode: 'wait' } })
173
- );
177
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
174
178
  cmdConfig(ctx, ['show']);
175
179
  expect(ctx.ui.table).toHaveBeenCalled();
176
180
  // The table call should include (local) source
@@ -181,10 +185,7 @@ describe('cmdConfig', () => {
181
185
  it('shows sources in table mode with global settings', () => {
182
186
  const ctx = createCtx(testDir);
183
187
  fs.mkdirSync(ctx.paths.globalDir, { recursive: true });
184
- fs.writeFileSync(
185
- ctx.paths.globalConfig,
186
- JSON.stringify({ mode: 'wait' })
187
- );
188
+ fs.writeFileSync(ctx.paths.globalConfig, JSON.stringify({ mode: 'wait' }));
188
189
  cmdConfig(ctx, ['show']);
189
190
  expect(ctx.ui.table).toHaveBeenCalled();
190
191
  const tableCall = (ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0];
@@ -114,11 +114,7 @@ function showConfig(ctx: Context): void {
114
114
  ['mode', ctx.config.mode, modeSource],
115
115
  ['preambleMode', ctx.config.preambleMode, preambleSource],
116
116
  ['preambleEvery', String(ctx.config.defaults.preambleEvery), preambleEverySource],
117
- [
118
- 'pasteEnterDelayMs',
119
- String(ctx.config.defaults.pasteEnterDelayMs),
120
- pasteEnterDelaySource,
121
- ],
117
+ ['pasteEnterDelayMs', String(ctx.config.defaults.pasteEnterDelayMs), pasteEnterDelaySource],
122
118
  ['defaults.timeout', String(ctx.config.defaults.timeout), '(global)'],
123
119
  ['defaults.pollInterval', String(ctx.config.defaults.pollInterval), '(global)'],
124
120
  ['defaults.captureLines', String(ctx.config.defaults.captureLines), '(global)'],
@@ -52,6 +52,8 @@ ${colors.yellow('COMMANDS')}
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
+ ${colors.green('migrate')} [--dry-run] Copy legacy JSON registry to tmux metadata
56
+ ${colors.green('team')} [ls|rm] Inspect pane scopes and manage teams
55
57
  ${colors.green('install')} [claude|codex] Install tmux-team for an AI agent
56
58
  ${colors.green('init')} Create empty tmux-team.json
57
59
  ${colors.green('config')} [show|set|clear] View/modify settings
@@ -89,9 +91,10 @@ ${colors.yellow('EXAMPLES')}${
89
91
  tmux-team add codex 10.1 "Code review specialist"
90
92
 
91
93
  ${colors.yellow('CONFIG')}
92
- Local: ./tmux-team.json (pane registry + $config override)
94
+ Runtime: tmux pane metadata (agent registry)
95
+ Local: ./tmux-team.json (legacy registry + $config override)
93
96
  Global: ~/.config/tmux-team/config.json (settings)
94
- Teams: ~/.config/tmux-team/teams/<name>.json (shared teams)
97
+ Teams: tmux pane metadata; team ls shows pane cwd + workspace/team scopes
95
98
 
96
99
  ${colors.yellow('CHANGE MODE')}
97
100
  tmux-team config set mode wait ${colors.dim('Enable wait mode (local)')}
@@ -26,7 +26,14 @@ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags>
26
26
  const config: ResolvedConfig = {
27
27
  mode: 'polling',
28
28
  preambleMode: 'always',
29
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
29
+ defaults: {
30
+ timeout: 180,
31
+ pollInterval: 1,
32
+ captureLines: 100,
33
+ maxCaptureLines: 2000,
34
+ preambleEvery: 3,
35
+ pasteEnterDelayMs: 500,
36
+ },
30
37
  agents: {},
31
38
  paneRegistry: {},
32
39
  };
@@ -36,6 +43,13 @@ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags>
36
43
  capture: vi.fn(),
37
44
  listPanes: vi.fn(() => []),
38
45
  getCurrentPaneId: vi.fn(() => null),
46
+ resolvePaneTarget: vi.fn((target: string) => target),
47
+ getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
48
+ setAgentRegistration: vi.fn(),
49
+ clearAgentRegistration: vi.fn(() => false),
50
+ listTeams: vi.fn(() => ({})),
51
+ listTeamPanes: vi.fn(() => []),
52
+ removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
39
53
  };
40
54
  return {
41
55
  argv: [],
@@ -15,7 +15,9 @@ export function cmdList(ctx: Context): void {
15
15
 
16
16
  if (agents.length === 0) {
17
17
  if (flags.team) {
18
- ui.info(`No agents in team "${flags.team}". Use 'tmt this <name> --team ${flags.team}' to add one.`);
18
+ ui.info(
19
+ `No agents in team "${flags.team}". Use 'tmt this <name> --team ${flags.team}' to add one.`
20
+ );
19
21
  } else {
20
22
  ui.info("No agents configured. Use 'tmux-team add <name> <pane>' to add one.");
21
23
  }
@@ -26,6 +28,10 @@ export function cmdList(ctx: Context): void {
26
28
  if (flags.team) {
27
29
  console.log(`Team: ${flags.team}`);
28
30
  console.log();
31
+ } else if (config.registrySource === 'legacy') {
32
+ ui.warn(
33
+ 'Using legacy tmux-team.json registry. Run `tmt migrate` to store registrations in tmux.'
34
+ );
29
35
  }
30
36
  ui.table(
31
37
  ['NAME', 'PANE', 'REMARK'],
@@ -0,0 +1,84 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // migrate command - copy legacy JSON registry into tmux metadata
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context, PaneEntry } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+ import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
8
+ import { getRegistryScope, registrationFromEntry, scopeLabel } from '../registry.js';
9
+
10
+ interface MigrationItem {
11
+ agent: string;
12
+ fromPane: string;
13
+ pane: string;
14
+ remark?: string;
15
+ status: 'ready' | 'migrated';
16
+ }
17
+
18
+ export function cmdMigrate(ctx: Context, args: string[]): void {
19
+ const dryRun = args.includes('--dry-run');
20
+ const cleanup = args.includes('--cleanup');
21
+ const { ui, paths, flags, tmux, exit } = ctx;
22
+ const localConfig = loadLocalConfigFile(paths);
23
+ const scope = getRegistryScope(ctx);
24
+ const items: MigrationItem[] = [];
25
+
26
+ for (const [agentName, rawEntry] of Object.entries(localConfig)) {
27
+ if (agentName === '$config') continue;
28
+ const entry = rawEntry as PaneEntry | undefined;
29
+ if (!entry?.pane) continue;
30
+
31
+ const pane = tmux.resolvePaneTarget(entry.pane);
32
+ if (!pane) {
33
+ ui.error(`Pane '${entry.pane}' for agent '${agentName}' not found. Is tmux running?`);
34
+ exit(ExitCodes.PANE_NOT_FOUND);
35
+ }
36
+ const paneId = pane as string;
37
+
38
+ items.push({
39
+ agent: agentName,
40
+ fromPane: entry.pane,
41
+ pane: paneId,
42
+ ...(entry.remark !== undefined && { remark: entry.remark }),
43
+ status: dryRun ? 'ready' : 'migrated',
44
+ });
45
+
46
+ if (!dryRun) {
47
+ tmux.setAgentRegistration(paneId, scope, registrationFromEntry(agentName, entry));
48
+ }
49
+ }
50
+
51
+ if (!dryRun && cleanup && items.length > 0) {
52
+ for (const item of items) {
53
+ delete localConfig[item.agent];
54
+ }
55
+ saveLocalConfigFile(paths, localConfig);
56
+ }
57
+
58
+ if (flags.json) {
59
+ ui.json({
60
+ dryRun,
61
+ cleanup,
62
+ scope,
63
+ migrated: dryRun ? 0 : items.length,
64
+ items,
65
+ });
66
+ return;
67
+ }
68
+
69
+ if (items.length === 0) {
70
+ ui.info(`No legacy agents found in ${paths.localConfig}`);
71
+ return;
72
+ }
73
+
74
+ const action = dryRun ? 'Would migrate' : 'Migrated';
75
+ ui.success(`${action} ${items.length} agent(s) to ${scopeLabel(scope)}`);
76
+ ui.table(
77
+ ['AGENT', 'FROM', 'PANE', 'REMARK'],
78
+ items.map((item) => [item.agent, item.fromPane, item.pane, item.remark ?? '-'])
79
+ );
80
+
81
+ if (!dryRun && !cleanup) {
82
+ ui.info('Legacy JSON was left in place. Use --cleanup to remove migrated agent entries.');
83
+ }
84
+ }