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.
@@ -0,0 +1,361 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // team command - inspect pane team/workspace scope and manage explicit shared teams
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context, RegistryScope, TeamPaneInfo, TeamPaneRegistration } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+ import { registrationFromEntry } from '../registry.js';
8
+
9
+ export function cmdTeam(ctx: Context, args: string[]): void {
10
+ const subcommand = args[0];
11
+
12
+ switch (subcommand) {
13
+ case undefined:
14
+ listTeamNames(ctx, []);
15
+ break;
16
+ case 'ls':
17
+ case 'list':
18
+ listTeamMembers(ctx, args.slice(1));
19
+ break;
20
+ case 'add':
21
+ addTeamMember(ctx, args.slice(1));
22
+ break;
23
+ case 'rm':
24
+ case 'remove':
25
+ removeTeam(ctx, args.slice(1));
26
+ break;
27
+ case 'panes':
28
+ case 'inventory':
29
+ listPaneInventory(ctx, args.slice(1));
30
+ break;
31
+ default:
32
+ ctx.ui.error(`Unknown team subcommand: ${subcommand}`);
33
+ ctx.ui.error(
34
+ 'Usage: tmux-team team [ls <team>|add <team> <name> [pane]|rm <team> --force|panes]'
35
+ );
36
+ ctx.exit(ExitCodes.ERROR);
37
+ }
38
+ }
39
+
40
+ function listTeamNames(ctx: Context, args: string[]): void {
41
+ const teams = ctx.tmux.listTeams();
42
+ const summaryOnly = args.includes('--summary');
43
+
44
+ if (ctx.flags.json) {
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.');
52
+ return;
53
+ }
54
+
55
+ if (summaryOnly) {
56
+ ctx.ui.table(
57
+ ['TEAM', 'AGENTS'],
58
+ rows.map(([teamName, agents]) => [teamName, agents.join(', ') || '-'])
59
+ );
60
+ return;
61
+ }
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
+
149
+ const groups = panesToGroups(panes);
150
+ if (groups.length === 0) {
151
+ ctx.ui.info('No tmux panes found.');
152
+ return;
153
+ }
154
+
155
+ for (const [index, group] of groups.entries()) {
156
+ if (index > 0) console.log('');
157
+ console.log(group.title);
158
+ ctx.ui.table(['PANE', 'TARGET', 'CWD', 'CMD'], group.rows);
159
+ }
160
+ }
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
+
252
+ interface PaneGroup {
253
+ key: string;
254
+ title: string;
255
+ agents: Set<string>;
256
+ rows: string[][];
257
+ }
258
+
259
+ function panesToGroups(panes: TeamPaneInfo[]): PaneGroup[] {
260
+ const groups = new Map<string, PaneGroup>();
261
+
262
+ for (const pane of panes) {
263
+ if (pane.registrations.length === 0) {
264
+ addPaneToGroup(groups, '2:', 'Unregistered panes', pane);
265
+ continue;
266
+ }
267
+
268
+ for (const registration of pane.registrations) {
269
+ addPaneToGroup(
270
+ groups,
271
+ scopeSortKey(registration),
272
+ groupTitle(registration),
273
+ pane,
274
+ registration
275
+ );
276
+ }
277
+ }
278
+
279
+ return [...groups.values()]
280
+ .sort((a, b) => a.key.localeCompare(b.key))
281
+ .map((group) => ({
282
+ ...group,
283
+ title:
284
+ group.agents.size > 0
285
+ ? `${group.title} (${[...group.agents].sort().join(', ')})`
286
+ : group.title,
287
+ rows: group.rows.sort((a, b) => a[1].localeCompare(b[1])),
288
+ }));
289
+ }
290
+
291
+ function addPaneToGroup(
292
+ groups: Map<string, PaneGroup>,
293
+ key: string,
294
+ title: string,
295
+ pane: TeamPaneInfo,
296
+ registration?: TeamPaneRegistration
297
+ ): void {
298
+ const group = groups.get(key) ?? { key, title, agents: new Set<string>(), rows: [] };
299
+ if (registration) group.agents.add(formatAgent(registration));
300
+ group.rows.push([pane.pane, pane.target ?? '-', pane.cwd ?? '-', pane.command || '-']);
301
+ groups.set(key, group);
302
+ }
303
+
304
+ function scopeSortKey(registration: TeamPaneRegistration): string {
305
+ if (registration.scopeType === 'team') return `0:${registration.scope}`;
306
+ if (registration.scopeType === 'workspace') return `1:${registration.scope}`;
307
+ return '2:';
308
+ }
309
+
310
+ function groupTitle(registration: TeamPaneRegistration): string {
311
+ if (registration.scopeType === 'team') return `Team: ${registration.scope}`;
312
+ return `Workspace: ${registration.scope}`;
313
+ }
314
+
315
+ function formatAgent(registration: TeamPaneRegistration): string {
316
+ return registration.remark
317
+ ? `${registration.agent} (${registration.remark})`
318
+ : registration.agent;
319
+ }
320
+
321
+ function removeTeam(ctx: Context, args: string[]): void {
322
+ const teamName = args.find((arg) => !arg.startsWith('-'));
323
+ const dryRun = args.includes('--dry-run');
324
+ const force = ctx.flags.force || args.includes('--force') || args.includes('-f');
325
+
326
+ if (!teamName) {
327
+ ctx.ui.error('Usage: tmux-team team rm <team> --force');
328
+ ctx.exit(ExitCodes.ERROR);
329
+ }
330
+
331
+ const teams = ctx.tmux.listTeams();
332
+ const agents = teams[teamName] ?? [];
333
+ if (agents.length === 0) {
334
+ ctx.ui.error(`Team '${teamName}' not found.`);
335
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
336
+ }
337
+
338
+ if (ctx.flags.json && dryRun) {
339
+ ctx.ui.json({ team: teamName, dryRun: true, agents, removed: 0 });
340
+ return;
341
+ }
342
+
343
+ if (dryRun) {
344
+ ctx.ui.info(`Would remove team '${teamName}' from ${agents.length} agent(s).`);
345
+ ctx.ui.table(['TEAM', 'AGENTS'], [[teamName, agents.join(', ')]]);
346
+ return;
347
+ }
348
+
349
+ if (!force) {
350
+ ctx.ui.error(`Refusing to remove team '${teamName}' without --force.`);
351
+ ctx.exit(ExitCodes.ERROR);
352
+ }
353
+
354
+ const result = ctx.tmux.removeTeam(teamName);
355
+ if (ctx.flags.json) {
356
+ ctx.ui.json({ team: teamName, removed: result.removed, agents: result.agents });
357
+ return;
358
+ }
359
+
360
+ ctx.ui.success(`Removed team '${teamName}' from ${result.removed} pane(s).`);
361
+ }
@@ -5,13 +5,14 @@
5
5
  import type { Context, PaneEntry } from '../types.js';
6
6
  import { ExitCodes } from '../exits.js';
7
7
  import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
8
+ import { getRegistryScope, registrationFromEntry } from '../registry.js';
8
9
 
9
10
  export function cmdUpdate(
10
11
  ctx: Context,
11
12
  name: string,
12
13
  options: { pane?: string; remark?: string }
13
14
  ): void {
14
- const { ui, config, paths, flags, exit } = ctx;
15
+ const { ui, config, paths, flags, tmux, exit } = ctx;
15
16
 
16
17
  if (!config.paneRegistry[name]) {
17
18
  ui.error(`Agent '${name}' not found. Use 'tmux-team add' to create.`);
@@ -23,22 +24,18 @@ export function cmdUpdate(
23
24
  exit(ExitCodes.ERROR);
24
25
  }
25
26
 
26
- // Load existing config to preserve all fields (preamble, deny, etc.)
27
- const localConfig = loadLocalConfigFile(paths);
28
-
29
- // Handle edge case where local config was modified externally
30
- let entry = localConfig[name] as PaneEntry | undefined;
31
- if (!entry) {
32
- // Fall back to in-memory paneRegistry if entry is missing
33
- entry = { ...config.paneRegistry[name] };
34
- localConfig[name] = entry;
35
- }
27
+ let entry: PaneEntry = { ...config.paneRegistry[name] };
36
28
 
37
29
  const updates: string[] = [];
38
30
 
39
31
  if (options.pane) {
40
- entry.pane = options.pane;
41
- updates.push(`pane → ${options.pane}`);
32
+ const resolvedPane = tmux.resolvePaneTarget(options.pane);
33
+ if (!resolvedPane) {
34
+ ui.error(`Pane '${options.pane}' not found. Is tmux running?`);
35
+ exit(ExitCodes.PANE_NOT_FOUND);
36
+ }
37
+ entry.pane = resolvedPane as string;
38
+ updates.push(`pane → ${entry.pane}`);
42
39
  }
43
40
 
44
41
  if (options.remark) {
@@ -46,7 +43,21 @@ export function cmdUpdate(
46
43
  updates.push(`remark updated`);
47
44
  }
48
45
 
49
- saveLocalConfigFile(paths, localConfig);
46
+ const canonicalPane = tmux.resolvePaneTarget(entry.pane);
47
+ if (!canonicalPane) {
48
+ ui.error(`Pane '${entry.pane}' not found. Is tmux running?`);
49
+ exit(ExitCodes.PANE_NOT_FOUND);
50
+ }
51
+ entry.pane = canonicalPane as string;
52
+
53
+ const scope = getRegistryScope(ctx);
54
+ const updatedLegacy = updateLegacyEntryIfPresent(paths, name, entry);
55
+ const removed = options.pane ? tmux.clearAgentRegistration(name, scope) : false;
56
+ tmux.setAgentRegistration(entry.pane, scope, registrationFromEntry(name, entry));
57
+
58
+ if (updatedLegacy && removed) {
59
+ pruneLegacyEntry(paths, name);
60
+ }
50
61
 
51
62
  if (flags.json) {
52
63
  ui.json({ updated: name, ...options });
@@ -56,3 +67,23 @@ export function cmdUpdate(
56
67
  }
57
68
  }
58
69
  }
70
+
71
+ function updateLegacyEntryIfPresent(
72
+ paths: Context['paths'],
73
+ name: string,
74
+ entry: PaneEntry
75
+ ): boolean {
76
+ const localConfig = loadLocalConfigFile(paths);
77
+ if (!localConfig[name]) return false;
78
+
79
+ localConfig[name] = { ...entry };
80
+ saveLocalConfigFile(paths, localConfig);
81
+ return true;
82
+ }
83
+
84
+ function pruneLegacyEntry(paths: Context['paths'], name: string): void {
85
+ const localConfig = loadLocalConfigFile(paths);
86
+ if (!localConfig[name]) return;
87
+ delete localConfig[name];
88
+ saveLocalConfigFile(paths, localConfig);
89
+ }
@@ -246,6 +246,30 @@ describe('loadConfig', () => {
246
246
  expect(config.paneRegistry.codex?.pane).toBe('1.1');
247
247
  });
248
248
 
249
+ it('overlays tmux registry on top of legacy local entries', () => {
250
+ const localConfig = {
251
+ claude: { pane: '1.0', remark: 'legacy' },
252
+ codex: { pane: '1.1', remark: 'legacy codex' },
253
+ };
254
+
255
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
256
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
257
+
258
+ const config = loadConfig(mockPaths, {
259
+ paneRegistry: {
260
+ claude: { pane: '%9', remark: 'tmux' },
261
+ },
262
+ agents: {
263
+ claude: { preamble: 'from tmux' },
264
+ },
265
+ });
266
+
267
+ expect(config.registrySource).toBe('tmux');
268
+ expect(config.paneRegistry.claude).toEqual({ pane: '%9', remark: 'tmux' });
269
+ expect(config.paneRegistry.codex).toEqual({ pane: '1.1', remark: 'legacy codex' });
270
+ expect(config.agents.claude?.preamble).toBe('from tmux');
271
+ });
272
+
249
273
  it('merges both global and local config (agents from local only)', () => {
250
274
  const globalConfig = {
251
275
  mode: 'wait',
package/src/config.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  LocalSettings,
13
13
  ResolvedConfig,
14
14
  Paths,
15
+ TmuxRegistry,
15
16
  } from './types.js';
16
17
 
17
18
  const CONFIG_FILENAME = 'config.json';
@@ -103,6 +104,27 @@ function findUpward(filename: string, startDir: string): string | null {
103
104
  }
104
105
  }
105
106
 
107
+ /**
108
+ * Resolve the workspace scope used for default registrations.
109
+ *
110
+ * Prefer the nearest Git root so commands run from subdirectories share the
111
+ * same default team. Fall back to cwd for non-Git folders.
112
+ */
113
+ export function resolveWorkspaceRoot(cwd: string = process.cwd()): string {
114
+ let dir = path.resolve(cwd);
115
+ while (true) {
116
+ const gitPath = path.join(dir, '.git');
117
+ if (fs.existsSync(gitPath)) {
118
+ return dir;
119
+ }
120
+ const parent = path.dirname(dir);
121
+ if (parent === dir) {
122
+ return path.resolve(cwd);
123
+ }
124
+ dir = parent;
125
+ }
126
+ }
127
+
106
128
  /**
107
129
  * Get the path to a shared team config file.
108
130
  */
@@ -122,6 +144,7 @@ export function ensureTeamsDir(globalDir: string): void {
122
144
 
123
145
  export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Paths {
124
146
  const globalDir = resolveGlobalDir();
147
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
125
148
 
126
149
  // If team name is specified, use the shared team config
127
150
  if (teamName) {
@@ -131,6 +154,7 @@ export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Pa
131
154
  globalConfig: path.join(globalDir, CONFIG_FILENAME),
132
155
  localConfig: teamConfig,
133
156
  stateFile: path.join(globalDir, STATE_FILENAME),
157
+ workspaceRoot,
134
158
  };
135
159
  }
136
160
 
@@ -143,6 +167,7 @@ export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Pa
143
167
  globalConfig: path.join(globalDir, CONFIG_FILENAME),
144
168
  localConfig: localConfigPath,
145
169
  stateFile: path.join(globalDir, STATE_FILENAME),
170
+ workspaceRoot,
146
171
  };
147
172
  }
148
173
 
@@ -177,13 +202,14 @@ function loadJsonFile<T>(filePath: string): T | null {
177
202
  *
178
203
  * Note: CLI flags are applied by the caller after this function returns.
179
204
  */
180
- export function loadConfig(paths: Paths): ResolvedConfig {
205
+ export function loadConfig(paths: Paths, tmuxRegistry?: TmuxRegistry): ResolvedConfig {
181
206
  // Start with defaults
182
207
  const config: ResolvedConfig = {
183
208
  ...DEFAULT_CONFIG,
184
209
  defaults: { ...DEFAULT_CONFIG.defaults },
185
210
  agents: {},
186
211
  paneRegistry: {},
212
+ registrySource: 'none',
187
213
  };
188
214
 
189
215
  // Merge global config (mode, preambleMode, defaults only)
@@ -196,8 +222,9 @@ export function loadConfig(paths: Paths): ResolvedConfig {
196
222
  }
197
223
  }
198
224
 
199
- // Load local config (pane registry + optional settings + agent config)
200
- // Local config is the SSOT for agent configuration (preamble, deny)
225
+ // Load local config (legacy pane registry + optional settings + agent config).
226
+ // Runtime agent registration now prefers tmux pane metadata; local pane entries
227
+ // remain as a compatibility fallback when no tmux metadata exists for the scope.
201
228
  const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
202
229
  if (localConfigFile) {
203
230
  // Extract local settings if present
@@ -222,6 +249,7 @@ export function loadConfig(paths: Paths): ResolvedConfig {
222
249
  // Add to pane registry if has valid pane field
223
250
  if (paneEntry.pane) {
224
251
  config.paneRegistry[agentName] = paneEntry;
252
+ config.registrySource = 'legacy';
225
253
  }
226
254
 
227
255
  // Build agents config from preamble/deny fields
@@ -237,6 +265,12 @@ export function loadConfig(paths: Paths): ResolvedConfig {
237
265
  }
238
266
  }
239
267
 
268
+ if (tmuxRegistry && Object.keys(tmuxRegistry.paneRegistry).length > 0) {
269
+ config.paneRegistry = { ...config.paneRegistry, ...tmuxRegistry.paneRegistry };
270
+ config.agents = { ...config.agents, ...tmuxRegistry.agents };
271
+ config.registrySource = 'tmux';
272
+ }
273
+
240
274
  return config;
241
275
  }
242
276
 
@@ -18,7 +18,14 @@ describe('createContext', () => {
18
18
  const config: ResolvedConfig = {
19
19
  mode: 'polling',
20
20
  preambleMode: 'always',
21
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
21
+ defaults: {
22
+ timeout: 180,
23
+ pollInterval: 1,
24
+ captureLines: 100,
25
+ maxCaptureLines: 2000,
26
+ preambleEvery: 3,
27
+ pasteEnterDelayMs: 500,
28
+ },
22
29
  agents: {},
23
30
  paneRegistry: {},
24
31
  };
@@ -35,6 +42,13 @@ describe('createContext', () => {
35
42
  capture: vi.fn(),
36
43
  listPanes: vi.fn(() => []),
37
44
  getCurrentPaneId: vi.fn(() => null),
45
+ resolvePaneTarget: vi.fn((target: string) => target),
46
+ getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
47
+ setAgentRegistration: vi.fn(),
48
+ clearAgentRegistration: vi.fn(() => false),
49
+ listTeams: vi.fn(() => ({})),
50
+ listTeamPanes: vi.fn(() => []),
51
+ removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
38
52
  };
39
53
 
40
54
  vi.doMock('./config.js', () => ({
@@ -54,6 +68,67 @@ describe('createContext', () => {
54
68
  expect(ctx.tmux).toBe(tmux);
55
69
  });
56
70
 
71
+ it('uses team registry scope when --team is set', async () => {
72
+ vi.resetModules();
73
+
74
+ const paths: Paths = {
75
+ globalDir: '/g',
76
+ globalConfig: '/g/config.json',
77
+ localConfig: '/g/teams/egp.json',
78
+ stateFile: '/g/state.json',
79
+ workspaceRoot: '/repo',
80
+ };
81
+ const config: ResolvedConfig = {
82
+ mode: 'polling',
83
+ preambleMode: 'always',
84
+ defaults: {
85
+ timeout: 180,
86
+ pollInterval: 1,
87
+ captureLines: 100,
88
+ maxCaptureLines: 2000,
89
+ preambleEvery: 3,
90
+ pasteEnterDelayMs: 500,
91
+ },
92
+ agents: {},
93
+ paneRegistry: {},
94
+ };
95
+ const tmux: Tmux = {
96
+ send: vi.fn(),
97
+ capture: vi.fn(),
98
+ listPanes: vi.fn(() => []),
99
+ getCurrentPaneId: vi.fn(() => null),
100
+ resolvePaneTarget: vi.fn((target: string) => target),
101
+ getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
102
+ setAgentRegistration: vi.fn(),
103
+ clearAgentRegistration: vi.fn(() => false),
104
+ listTeams: vi.fn(() => ({})),
105
+ listTeamPanes: vi.fn(() => []),
106
+ removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
107
+ };
108
+
109
+ vi.doMock('./config.js', () => ({
110
+ resolvePaths: () => paths,
111
+ loadConfig: () => config,
112
+ }));
113
+ vi.doMock('./ui.js', () => ({
114
+ createUI: () => ({
115
+ info: vi.fn(),
116
+ success: vi.fn(),
117
+ warn: vi.fn(),
118
+ error: vi.fn(),
119
+ table: vi.fn(),
120
+ json: vi.fn(),
121
+ }),
122
+ }));
123
+ vi.doMock('./tmux.js', () => ({ createTmux: () => tmux }));
124
+
125
+ const { createContext } = await import('./context.js');
126
+ const ctx = createContext({ argv: [], flags: { json: false, verbose: false, team: 'egp' } });
127
+
128
+ expect(ctx.registryScope).toEqual({ type: 'team', teamName: 'egp' });
129
+ expect(tmux.getAgentRegistry).toHaveBeenCalledWith({ type: 'team', teamName: 'egp' });
130
+ });
131
+
57
132
  it('ctx.exit calls process.exit', async () => {
58
133
  vi.resetModules();
59
134
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {