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.
package/src/context.ts CHANGED
@@ -18,9 +18,15 @@ export function createContext(options: CreateContextOptions): Context {
18
18
  const { argv, flags, cwd = process.cwd() } = options;
19
19
 
20
20
  const paths = resolvePaths(cwd, flags.team);
21
- const config = loadConfig(paths);
22
21
  const ui = createUI(flags.json);
23
22
  const tmux = createTmux();
23
+ const registryScope = flags.team
24
+ ? { type: 'team' as const, teamName: flags.team }
25
+ : {
26
+ type: 'workspace' as const,
27
+ workspaceRoot: paths.workspaceRoot ?? cwd,
28
+ };
29
+ const config = loadConfig(paths, tmux.getAgentRegistry(registryScope));
24
30
 
25
31
  return {
26
32
  argv,
@@ -29,6 +35,7 @@ export function createContext(options: CreateContextOptions): Context {
29
35
  config,
30
36
  tmux,
31
37
  paths,
38
+ registryScope,
32
39
  exit(code: number): never {
33
40
  process.exit(code);
34
41
  },
@@ -1,18 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import type { PaneEntry } from './types.js';
3
3
  import { resolveActor } from './identity.js';
4
- import { execSync } from 'child_process';
5
4
 
6
5
  vi.mock('child_process', () => ({
7
6
  execSync: vi.fn(),
8
7
  }));
9
8
 
10
- const mockedExec = vi.mocked(execSync);
11
-
12
9
  describe('resolveActor', () => {
13
10
  const paneRegistry: Record<string, PaneEntry> = {
14
- claude: { pane: '10.0' },
15
- codex: { pane: '10.1' },
11
+ claude: { pane: '%98' },
12
+ codex: { pane: '%99' },
16
13
  };
17
14
 
18
15
  beforeEach(() => {
@@ -41,7 +38,6 @@ describe('resolveActor', () => {
41
38
  it('uses pane identity when in tmux and pane matches registry', () => {
42
39
  process.env.TMUX = '1';
43
40
  process.env.TMUX_PANE = '%99';
44
- mockedExec.mockReturnValue('10.1\n');
45
41
  const res = resolveActor(paneRegistry);
46
42
  expect(res.actor).toBe('codex');
47
43
  expect(res.source).toBe('pane');
@@ -51,7 +47,6 @@ describe('resolveActor', () => {
51
47
  process.env.TMUX = '1';
52
48
  process.env.TMUX_PANE = '%99';
53
49
  process.env.TMT_AGENT_NAME = 'claude';
54
- mockedExec.mockReturnValue('10.1\n');
55
50
  const res = resolveActor(paneRegistry);
56
51
  expect(res.actor).toBe('codex');
57
52
  expect(res.warning).toContain('Identity mismatch');
@@ -59,9 +54,8 @@ describe('resolveActor', () => {
59
54
 
60
55
  it('uses env actor with warning when pane is unregistered', () => {
61
56
  process.env.TMUX = '1';
62
- process.env.TMUX_PANE = '%99';
57
+ process.env.TMUX_PANE = '%100';
63
58
  process.env.TMT_AGENT_NAME = 'someone';
64
- mockedExec.mockReturnValue('99.9\n');
65
59
  const res = resolveActor(paneRegistry);
66
60
  expect(res.actor).toBe('someone');
67
61
  expect(res.source).toBe('env');
package/src/identity.ts CHANGED
@@ -12,7 +12,7 @@ export interface ActorResolution {
12
12
  }
13
13
 
14
14
  /**
15
- * Get current tmux pane ID (e.g., "1.0").
15
+ * Get current tmux pane ID (e.g., "%12").
16
16
  */
17
17
  function getCurrentPane(): string | null {
18
18
  if (!process.env.TMUX) {
@@ -20,16 +20,14 @@ function getCurrentPane(): string | null {
20
20
  }
21
21
 
22
22
  const tmuxPane = process.env.TMUX_PANE;
23
- if (!tmuxPane) {
24
- return null;
25
- }
23
+ if (tmuxPane) return tmuxPane;
26
24
 
27
25
  try {
28
- const result = execSync(
29
- `tmux display-message -p -t "${tmuxPane}" '#{window_index}.#{pane_index}'`,
30
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
31
- );
32
- return result.trim();
26
+ const result = execSync(`tmux display-message -p '#{pane_id}'`, {
27
+ encoding: 'utf-8',
28
+ stdio: ['pipe', 'pipe', 'pipe'],
29
+ });
30
+ return result.trim() || null;
33
31
  } catch {
34
32
  return null;
35
33
  }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Context } from './types.js';
3
+ import { getRegistryScope, registrationFromEntry, scopeLabel } from './registry.js';
4
+
5
+ function ctx(overrides: Partial<Context>): Context {
6
+ return {
7
+ argv: [],
8
+ flags: { json: false, verbose: false },
9
+ ui: {} as Context['ui'],
10
+ config: {} as Context['config'],
11
+ tmux: {} as Context['tmux'],
12
+ paths: {
13
+ globalDir: '/g',
14
+ globalConfig: '/g/c',
15
+ localConfig: '/r/tmux-team.json',
16
+ stateFile: '/g/s',
17
+ workspaceRoot: '/repo',
18
+ },
19
+ exit: (() => {
20
+ throw new Error('exit');
21
+ }) as Context['exit'],
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe('registry helpers', () => {
27
+ it('prefers explicit context registry scope', () => {
28
+ const scope = { type: 'team' as const, teamName: 'egp' };
29
+ expect(getRegistryScope(ctx({ registryScope: scope }))).toBe(scope);
30
+ });
31
+
32
+ it('uses --team when no explicit scope exists', () => {
33
+ expect(getRegistryScope(ctx({ flags: { json: false, verbose: false, team: 'egp' } }))).toEqual({
34
+ type: 'team',
35
+ teamName: 'egp',
36
+ });
37
+ });
38
+
39
+ it('falls back to workspace scope', () => {
40
+ expect(getRegistryScope(ctx({}))).toEqual({ type: 'workspace', workspaceRoot: '/repo' });
41
+ expect(scopeLabel({ type: 'workspace', workspaceRoot: '/repo' })).toBe('workspace /repo');
42
+ expect(scopeLabel({ type: 'team', teamName: 'egp' })).toBe('team "egp"');
43
+ });
44
+
45
+ it('converts pane entries into registrations', () => {
46
+ expect(
47
+ registrationFromEntry('codex', {
48
+ pane: '%1',
49
+ remark: 'review',
50
+ preamble: 'Be strict',
51
+ deny: ['x'],
52
+ })
53
+ ).toEqual({
54
+ name: 'codex',
55
+ remark: 'review',
56
+ preamble: 'Be strict',
57
+ deny: ['x'],
58
+ });
59
+ expect(registrationFromEntry('codex')).toEqual({ name: 'codex' });
60
+ });
61
+ });
@@ -0,0 +1,29 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Registry helpers for workspace/team scoped agent metadata
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { AgentRegistration, Context, PaneEntry, RegistryScope } from './types.js';
6
+
7
+ export function getRegistryScope(ctx: Context): RegistryScope {
8
+ if (ctx.registryScope) return ctx.registryScope;
9
+ if (ctx.flags.team) {
10
+ return { type: 'team', teamName: ctx.flags.team };
11
+ }
12
+ return {
13
+ type: 'workspace',
14
+ workspaceRoot: ctx.paths.workspaceRoot ?? process.cwd(),
15
+ };
16
+ }
17
+
18
+ export function scopeLabel(scope: RegistryScope): string {
19
+ return scope.type === 'team' ? `team "${scope.teamName}"` : `workspace ${scope.workspaceRoot}`;
20
+ }
21
+
22
+ export function registrationFromEntry(name: string, entry?: PaneEntry): AgentRegistration {
23
+ return {
24
+ name,
25
+ ...(entry?.remark !== undefined && { remark: entry.remark }),
26
+ ...(entry?.preamble !== undefined && { preamble: entry.preamble }),
27
+ ...(entry?.deny !== undefined && { deny: entry.deny }),
28
+ };
29
+ }
package/src/tmux.test.ts CHANGED
@@ -3,15 +3,17 @@
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
- import { execSync } from 'child_process';
6
+ import { execFileSync, execSync } from 'child_process';
7
7
  import { createTmux } from './tmux.js';
8
8
 
9
9
  // Mock child_process
10
10
  vi.mock('child_process', () => ({
11
11
  execSync: vi.fn(),
12
+ execFileSync: vi.fn(),
12
13
  }));
13
14
 
14
15
  const mockedExecSync = vi.mocked(execSync);
16
+ const mockedExecFileSync = vi.mocked(execFileSync);
15
17
 
16
18
  describe('createTmux', () => {
17
19
  beforeEach(() => {
@@ -192,6 +194,56 @@ describe('createTmux', () => {
192
194
  { id: '%2', command: 'codex', suggestedName: 'codex' },
193
195
  ]);
194
196
  });
197
+
198
+ it('ignores invalid metadata and duplicate pane IDs', () => {
199
+ mockedExecSync.mockReturnValue('%1\tcodex\tbad-json\n%1\tcodex\tbad-json\n%2\tzsh\t{}\n');
200
+ const tmux = createTmux();
201
+ expect(tmux.listPanes()).toEqual([
202
+ { id: '%1', command: 'codex', suggestedName: 'codex' },
203
+ { id: '%2', command: 'zsh', suggestedName: null },
204
+ ]);
205
+ expect(tmux.getAgentRegistry({ type: 'workspace', workspaceRoot: '/repo' })).toEqual({
206
+ paneRegistry: {},
207
+ agents: {},
208
+ });
209
+ });
210
+
211
+ it('parses tmux-team pane metadata', () => {
212
+ mockedExecSync.mockReturnValue(
213
+ '%1\tcodex\t{"version":1,"workspaces":{"/repo":{"name":"codex","remark":"review"}}}\n'
214
+ );
215
+ const tmux = createTmux();
216
+ expect(tmux.getAgentRegistry({ type: 'workspace', workspaceRoot: '/repo' })).toEqual({
217
+ paneRegistry: { codex: { pane: '%1', remark: 'review' } },
218
+ agents: {},
219
+ });
220
+ });
221
+
222
+ it('parses pane target and cwd from modern list-panes output', () => {
223
+ mockedExecSync.mockReturnValue('%1\tmain:2.0\t/repo\tcodex\t{"version":1}\n');
224
+ const tmux = createTmux();
225
+ expect(tmux.listPanes()).toEqual([
226
+ {
227
+ id: '%1',
228
+ target: 'main:2.0',
229
+ cwd: '/repo',
230
+ command: 'codex',
231
+ suggestedName: 'codex',
232
+ metadata: { version: 1 },
233
+ },
234
+ ]);
235
+ });
236
+
237
+ it('builds team registries and agent config from metadata', () => {
238
+ mockedExecSync.mockReturnValue(
239
+ '%1\tcodex\t{"version":1,"teams":{"egp":{"name":"codex","preamble":"Be strict","deny":["x"]}}}\n'
240
+ );
241
+ const tmux = createTmux();
242
+ expect(tmux.getAgentRegistry({ type: 'team', teamName: 'egp' })).toEqual({
243
+ paneRegistry: { codex: { pane: '%1', preamble: 'Be strict', deny: ['x'] } },
244
+ agents: { codex: { preamble: 'Be strict', deny: ['x'] } },
245
+ });
246
+ });
195
247
  });
196
248
 
197
249
  describe('getCurrentPaneId', () => {
@@ -233,6 +285,143 @@ describe('createTmux', () => {
233
285
  });
234
286
  });
235
287
 
288
+ describe('metadata registry writes', () => {
289
+ it('resolves pane targets to canonical pane IDs', () => {
290
+ mockedExecFileSync.mockReturnValue('%9\n');
291
+ const tmux = createTmux();
292
+ expect(tmux.resolvePaneTarget('1.2')).toBe('%9');
293
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
294
+ 'tmux',
295
+ ['display-message', '-p', '-t', '1.2', '#{pane_id}'],
296
+ expect.any(Object)
297
+ );
298
+ });
299
+
300
+ it('sets workspace registration on pane metadata', () => {
301
+ mockedExecFileSync.mockReturnValueOnce('');
302
+ const tmux = createTmux();
303
+ tmux.setAgentRegistration(
304
+ '%9',
305
+ { type: 'workspace', workspaceRoot: '/repo' },
306
+ { name: 'codex', preamble: 'Be strict' }
307
+ );
308
+ expect(mockedExecFileSync).toHaveBeenLastCalledWith(
309
+ 'tmux',
310
+ [
311
+ 'set-option',
312
+ '-p',
313
+ '-t',
314
+ '%9',
315
+ '@tmux-team.agent',
316
+ '{"version":1,"workspaces":{"/repo":{"name":"codex","preamble":"Be strict"}}}',
317
+ ],
318
+ expect.any(Object)
319
+ );
320
+ });
321
+
322
+ it('sets team registration while preserving existing metadata', () => {
323
+ mockedExecFileSync.mockReturnValueOnce(
324
+ '{"version":1,"workspaces":{"/repo":{"name":"codex"}}}\n'
325
+ );
326
+ const tmux = createTmux();
327
+ tmux.setAgentRegistration('%9', { type: 'team', teamName: 'egp' }, { name: 'reviewer' });
328
+ expect(mockedExecFileSync).toHaveBeenLastCalledWith(
329
+ 'tmux',
330
+ [
331
+ 'set-option',
332
+ '-p',
333
+ '-t',
334
+ '%9',
335
+ '@tmux-team.agent',
336
+ '{"version":1,"workspaces":{"/repo":{"name":"codex"}},"teams":{"egp":{"name":"reviewer"}}}',
337
+ ],
338
+ expect.any(Object)
339
+ );
340
+ });
341
+
342
+ it('clears scoped registration and unsets empty metadata', () => {
343
+ mockedExecSync.mockReturnValue(
344
+ '%1\tcodex\t{"version":1,"workspaces":{"/repo":{"name":"codex"}}}\n'
345
+ );
346
+ const tmux = createTmux();
347
+ expect(
348
+ tmux.clearAgentRegistration('codex', { type: 'workspace', workspaceRoot: '/repo' })
349
+ ).toBe(true);
350
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
351
+ 'tmux',
352
+ ['set-option', '-p', '-u', '-t', '%1', '@tmux-team.agent'],
353
+ expect.any(Object)
354
+ );
355
+ });
356
+
357
+ it('returns false when clearing a missing registration', () => {
358
+ mockedExecSync.mockReturnValue(
359
+ '%1\tcodex\t{"version":1,"workspaces":{"/repo":{"name":"codex"}}}\n'
360
+ );
361
+ const tmux = createTmux();
362
+ expect(
363
+ tmux.clearAgentRegistration('claude', { type: 'workspace', workspaceRoot: '/repo' })
364
+ ).toBe(false);
365
+ expect(mockedExecFileSync).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it('lists teams from pane metadata', () => {
369
+ mockedExecSync.mockReturnValue(
370
+ '%1\tcodex\t{"version":1,"teams":{"egp":{"name":"codex"},"checkout":{"name":"claude"}}}\n'
371
+ );
372
+ const tmux = createTmux();
373
+ expect(tmux.listTeams()).toEqual({ checkout: ['claude'], egp: ['codex'] });
374
+ });
375
+
376
+ it('lists pane team and workspace details', () => {
377
+ mockedExecSync.mockReturnValue(
378
+ '%1\tmain:1.0\t/repo\tclaude\t{"version":1,"workspaces":{"/repo":{"name":"claude","remark":"lead"}},"teams":{"egp":{"name":"reviewer"}}}\n%2\tmain:1.1\t/tmp\tzsh\t\n'
379
+ );
380
+ const tmux = createTmux();
381
+ expect(tmux.listTeamPanes()).toEqual([
382
+ {
383
+ pane: '%1',
384
+ target: 'main:1.0',
385
+ cwd: '/repo',
386
+ command: 'claude',
387
+ suggestedName: 'claude',
388
+ registrations: [
389
+ { scopeType: 'team', scope: 'egp', agent: 'reviewer' },
390
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
391
+ ],
392
+ },
393
+ {
394
+ pane: '%2',
395
+ target: 'main:1.1',
396
+ cwd: '/tmp',
397
+ command: 'zsh',
398
+ suggestedName: null,
399
+ registrations: [],
400
+ },
401
+ ]);
402
+ });
403
+
404
+ it('removes one team while preserving other registrations', () => {
405
+ mockedExecSync.mockReturnValue(
406
+ '%1\tcodex\t{"version":1,"teams":{"egp":{"name":"codex"},"checkout":{"name":"claude"}}}\n'
407
+ );
408
+ const tmux = createTmux();
409
+ expect(tmux.removeTeam('egp')).toEqual({ removed: 1, agents: ['codex'] });
410
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
411
+ 'tmux',
412
+ [
413
+ 'set-option',
414
+ '-p',
415
+ '-t',
416
+ '%1',
417
+ '@tmux-team.agent',
418
+ '{"version":1,"teams":{"checkout":{"name":"claude"}}}',
419
+ ],
420
+ expect.any(Object)
421
+ );
422
+ });
423
+ });
424
+
236
425
  describe('pane ID handling', () => {
237
426
  it('accepts window.pane format', () => {
238
427
  mockedExecSync.mockReturnValue('');