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/tmux.ts CHANGED
@@ -2,9 +2,19 @@
2
2
  // Pure tmux wrapper - buffer paste, capture-pane, pane detection
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
- import { execSync } from 'child_process';
5
+ import { execFileSync, execSync } from 'child_process';
6
6
  import crypto from 'crypto';
7
- import type { Tmux, PaneInfo } from './types.js';
7
+ import type {
8
+ AgentRegistration,
9
+ PaneAgentMetadata,
10
+ Tmux,
11
+ PaneInfo,
12
+ RegistryScope,
13
+ TeamPaneInfo,
14
+ TmuxRegistry,
15
+ } from './types.js';
16
+
17
+ const AGENT_METADATA_OPTION = '@tmux-team.agent';
8
18
 
9
19
  // Known agent patterns for auto-detection
10
20
  const KNOWN_AGENTS: Record<string, string[]> = {
@@ -27,6 +37,151 @@ function detectAgentName(command: string): string | null {
27
37
  return null;
28
38
  }
29
39
 
40
+ function safeParseMetadata(text: string): PaneAgentMetadata | undefined {
41
+ if (!text.trim()) return undefined;
42
+ try {
43
+ const parsed = JSON.parse(text) as PaneAgentMetadata;
44
+ if (!parsed || parsed.version !== 1 || typeof parsed !== 'object') {
45
+ return undefined;
46
+ }
47
+ return parsed;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function emptyMetadata(): PaneAgentMetadata {
54
+ return { version: 1 };
55
+ }
56
+
57
+ function registrationForScope(
58
+ metadata: PaneAgentMetadata | undefined,
59
+ scope: RegistryScope
60
+ ): AgentRegistration | undefined {
61
+ if (!metadata) return undefined;
62
+ if (scope.type === 'team') {
63
+ return metadata.teams?.[scope.teamName];
64
+ }
65
+ return metadata.workspaces?.[scope.workspaceRoot];
66
+ }
67
+
68
+ function setRegistrationForScope(
69
+ metadata: PaneAgentMetadata,
70
+ scope: RegistryScope,
71
+ registration: AgentRegistration
72
+ ): PaneAgentMetadata {
73
+ if (scope.type === 'team') {
74
+ metadata.teams = { ...metadata.teams, [scope.teamName]: registration };
75
+ } else {
76
+ metadata.workspaces = {
77
+ ...metadata.workspaces,
78
+ [scope.workspaceRoot]: registration,
79
+ };
80
+ }
81
+ return metadata;
82
+ }
83
+
84
+ function deleteRegistrationForScope(
85
+ metadata: PaneAgentMetadata,
86
+ scope: RegistryScope
87
+ ): AgentRegistration | undefined {
88
+ let removed: AgentRegistration | undefined;
89
+ if (scope.type === 'team') {
90
+ removed = metadata.teams?.[scope.teamName];
91
+ if (metadata.teams) {
92
+ delete metadata.teams[scope.teamName];
93
+ if (Object.keys(metadata.teams).length === 0) delete metadata.teams;
94
+ }
95
+ } else {
96
+ removed = metadata.workspaces?.[scope.workspaceRoot];
97
+ if (metadata.workspaces) {
98
+ delete metadata.workspaces[scope.workspaceRoot];
99
+ if (Object.keys(metadata.workspaces).length === 0) delete metadata.workspaces;
100
+ }
101
+ }
102
+ return removed;
103
+ }
104
+
105
+ function hasRegistrations(metadata: PaneAgentMetadata): boolean {
106
+ return Boolean(
107
+ (metadata.workspaces && Object.keys(metadata.workspaces).length > 0) ||
108
+ (metadata.teams && Object.keys(metadata.teams).length > 0)
109
+ );
110
+ }
111
+
112
+ function registryFromPanes(panes: PaneInfo[], scope: RegistryScope): TmuxRegistry {
113
+ const paneRegistry: TmuxRegistry['paneRegistry'] = {};
114
+ const agents: TmuxRegistry['agents'] = {};
115
+
116
+ for (const pane of panes) {
117
+ const registration = registrationForScope(pane.metadata, scope);
118
+ if (!registration || paneRegistry[registration.name]) {
119
+ continue;
120
+ }
121
+
122
+ paneRegistry[registration.name] = {
123
+ pane: pane.id,
124
+ ...(registration.remark !== undefined && { remark: registration.remark }),
125
+ ...(registration.preamble !== undefined && { preamble: registration.preamble }),
126
+ ...(registration.deny !== undefined && { deny: registration.deny }),
127
+ };
128
+
129
+ if (
130
+ Object.prototype.hasOwnProperty.call(registration, 'preamble') ||
131
+ Object.prototype.hasOwnProperty.call(registration, 'deny')
132
+ ) {
133
+ agents[registration.name] = {
134
+ ...(Object.prototype.hasOwnProperty.call(registration, 'preamble') && {
135
+ preamble: registration.preamble,
136
+ }),
137
+ ...(Object.prototype.hasOwnProperty.call(registration, 'deny') && {
138
+ deny: registration.deny,
139
+ }),
140
+ };
141
+ }
142
+ }
143
+
144
+ return { paneRegistry, agents };
145
+ }
146
+
147
+ function teamPaneInfoFromPane(pane: PaneInfo): TeamPaneInfo {
148
+ const registrations: TeamPaneInfo['registrations'] = [];
149
+
150
+ for (const [workspaceRoot, registration] of Object.entries(pane.metadata?.workspaces ?? {})) {
151
+ registrations.push({
152
+ scopeType: 'workspace',
153
+ scope: workspaceRoot,
154
+ agent: registration.name,
155
+ ...(registration.remark !== undefined && { remark: registration.remark }),
156
+ });
157
+ }
158
+
159
+ for (const [teamName, registration] of Object.entries(pane.metadata?.teams ?? {})) {
160
+ registrations.push({
161
+ scopeType: 'team',
162
+ scope: teamName,
163
+ agent: registration.name,
164
+ ...(registration.remark !== undefined && { remark: registration.remark }),
165
+ });
166
+ }
167
+
168
+ registrations.sort(
169
+ (a, b) =>
170
+ a.scopeType.localeCompare(b.scopeType) ||
171
+ a.scope.localeCompare(b.scope) ||
172
+ a.agent.localeCompare(b.agent)
173
+ );
174
+
175
+ return {
176
+ pane: pane.id,
177
+ ...(pane.target && { target: pane.target }),
178
+ ...(pane.cwd && { cwd: pane.cwd }),
179
+ command: pane.command,
180
+ suggestedName: pane.suggestedName,
181
+ registrations,
182
+ };
183
+ }
184
+
30
185
  export function createTmux(): Tmux {
31
186
  function sleepMs(ms: number): void {
32
187
  if (ms <= 0) return;
@@ -88,29 +243,57 @@ export function createTmux(): Tmux {
88
243
 
89
244
  listPanes(): PaneInfo[] {
90
245
  try {
91
- // Get all panes with their IDs and current commands
92
- const output = execSync('tmux list-panes -a -F "#{pane_id}\t#{pane_current_command}"', {
93
- encoding: 'utf-8',
94
- stdio: ['pipe', 'pipe', 'pipe'],
95
- });
246
+ // Get all panes with stable IDs, human tmux targets, cwd, commands, and tmux-team metadata.
247
+ const output = execSync(
248
+ `tmux list-panes -a -F "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_path}\t#{pane_current_command}\t#{${AGENT_METADATA_OPTION}}"`,
249
+ {
250
+ encoding: 'utf-8',
251
+ stdio: ['pipe', 'pipe', 'pipe'],
252
+ }
253
+ );
96
254
 
255
+ const seen = new Set<string>();
97
256
  return output
98
- .trim()
99
257
  .split('\n')
100
258
  .filter((line) => line.trim())
101
259
  .map((line) => {
102
- const [id, command] = line.split('\t');
260
+ const fields = line.split('\t');
261
+ const [id, target, cwd, command, metadataText = ''] =
262
+ fields.length >= 5
263
+ ? fields
264
+ : [fields[0], undefined, undefined, fields[1] ?? '', fields[2] ?? ''];
265
+ const metadata = safeParseMetadata(metadataText);
103
266
  return {
104
267
  id: id || '',
268
+ ...(target && { target }),
269
+ ...(cwd && { cwd }),
105
270
  command: command || '',
106
271
  suggestedName: detectAgentName(command || ''),
272
+ ...(metadata && { metadata }),
107
273
  };
274
+ })
275
+ .filter((pane) => {
276
+ if (!pane.id || seen.has(pane.id)) return false;
277
+ seen.add(pane.id);
278
+ return true;
108
279
  });
109
280
  } catch {
110
281
  return [];
111
282
  }
112
283
  },
113
284
 
285
+ resolvePaneTarget(target: string): string | null {
286
+ try {
287
+ const output = execFileSync('tmux', ['display-message', '-p', '-t', target, '#{pane_id}'], {
288
+ encoding: 'utf-8',
289
+ stdio: ['pipe', 'pipe', 'pipe'],
290
+ });
291
+ return output.trim() || null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ },
296
+
114
297
  getCurrentPaneId(): string | null {
115
298
  // First check environment variable
116
299
  if (process.env.TMUX_PANE) {
@@ -128,5 +311,102 @@ export function createTmux(): Tmux {
128
311
  return null;
129
312
  }
130
313
  },
314
+
315
+ getAgentRegistry(scope: RegistryScope): TmuxRegistry {
316
+ return registryFromPanes(this.listPanes(), scope);
317
+ },
318
+
319
+ setAgentRegistration(
320
+ paneId: string,
321
+ scope: RegistryScope,
322
+ registration: AgentRegistration
323
+ ): void {
324
+ const metadata = readPaneMetadata(paneId);
325
+ const next = setRegistrationForScope(metadata, scope, registration);
326
+ writePaneMetadata(paneId, next);
327
+ },
328
+
329
+ clearAgentRegistration(name: string, scope: RegistryScope): boolean {
330
+ let removed = false;
331
+ for (const pane of this.listPanes()) {
332
+ const registration = registrationForScope(pane.metadata, scope);
333
+ if (registration?.name !== name) continue;
334
+
335
+ const metadata = pane.metadata ?? emptyMetadata();
336
+ deleteRegistrationForScope(metadata, scope);
337
+ writePaneMetadata(pane.id, metadata);
338
+ removed = true;
339
+ }
340
+ return removed;
341
+ },
342
+
343
+ listTeams(): Record<string, string[]> {
344
+ const teams: Record<string, Set<string>> = {};
345
+ for (const pane of this.listPanes()) {
346
+ for (const [teamName, registration] of Object.entries(pane.metadata?.teams ?? {})) {
347
+ if (!teams[teamName]) teams[teamName] = new Set<string>();
348
+ teams[teamName].add(registration.name);
349
+ }
350
+ }
351
+ return Object.fromEntries(
352
+ Object.entries(teams).map(([teamName, agents]) => [teamName, [...agents].sort()])
353
+ );
354
+ },
355
+
356
+ listTeamPanes(): TeamPaneInfo[] {
357
+ return this.listPanes().map(teamPaneInfoFromPane);
358
+ },
359
+
360
+ removeTeam(teamName: string): { removed: number; agents: string[] } {
361
+ const agents = new Set<string>();
362
+ let removed = 0;
363
+ for (const pane of this.listPanes()) {
364
+ if (!pane.metadata?.teams?.[teamName]) continue;
365
+
366
+ agents.add(pane.metadata.teams[teamName].name);
367
+ const metadata = pane.metadata;
368
+ const teamRegistrations = metadata.teams;
369
+ if (teamRegistrations) {
370
+ delete teamRegistrations[teamName];
371
+ if (Object.keys(teamRegistrations).length === 0) delete metadata.teams;
372
+ }
373
+ writePaneMetadata(pane.id, metadata);
374
+ removed += 1;
375
+ }
376
+ return { removed, agents: [...agents].sort() };
377
+ },
131
378
  };
132
379
  }
380
+
381
+ function readPaneMetadata(paneId: string): PaneAgentMetadata {
382
+ try {
383
+ const output = execFileSync(
384
+ 'tmux',
385
+ ['show-options', '-p', '-t', paneId, '-v', AGENT_METADATA_OPTION],
386
+ {
387
+ encoding: 'utf-8',
388
+ stdio: ['pipe', 'pipe', 'pipe'],
389
+ }
390
+ );
391
+ return safeParseMetadata(output) ?? emptyMetadata();
392
+ } catch {
393
+ return emptyMetadata();
394
+ }
395
+ }
396
+
397
+ function writePaneMetadata(paneId: string, metadata: PaneAgentMetadata): void {
398
+ if (!hasRegistrations(metadata)) {
399
+ execFileSync('tmux', ['set-option', '-p', '-u', '-t', paneId, AGENT_METADATA_OPTION], {
400
+ stdio: 'pipe',
401
+ });
402
+ return;
403
+ }
404
+
405
+ execFileSync(
406
+ 'tmux',
407
+ ['set-option', '-p', '-t', paneId, AGENT_METADATA_OPTION, JSON.stringify(metadata)],
408
+ {
409
+ stdio: 'pipe',
410
+ }
411
+ );
412
+ }
package/src/types.ts CHANGED
@@ -14,6 +14,24 @@ export interface PaneEntry {
14
14
  deny?: string[]; // Permission deny patterns
15
15
  }
16
16
 
17
+ export interface AgentRegistration {
18
+ name: string;
19
+ remark?: string;
20
+ preamble?: string;
21
+ deny?: string[];
22
+ }
23
+
24
+ export interface PaneAgentMetadata {
25
+ version: 1;
26
+ workspaces?: Record<string, AgentRegistration>;
27
+ teams?: Record<string, AgentRegistration>;
28
+ }
29
+
30
+ export interface TmuxRegistry {
31
+ paneRegistry: Record<string, PaneEntry>;
32
+ agents: Record<string, AgentConfig>;
33
+ }
34
+
17
35
  export interface ConfigDefaults {
18
36
  timeout: number; // seconds
19
37
  pollInterval: number; // seconds
@@ -51,6 +69,7 @@ export interface ResolvedConfig {
51
69
  defaults: ConfigDefaults;
52
70
  agents: Record<string, AgentConfig>;
53
71
  paneRegistry: Record<string, PaneEntry>;
72
+ registrySource?: 'tmux' | 'legacy' | 'none';
54
73
  }
55
74
 
56
75
  export interface Flags {
@@ -72,6 +91,7 @@ export interface Paths {
72
91
  globalConfig: string;
73
92
  localConfig: string;
74
93
  stateFile: string;
94
+ workspaceRoot?: string;
75
95
  }
76
96
 
77
97
  export interface UI {
@@ -85,8 +105,27 @@ export interface UI {
85
105
 
86
106
  export interface PaneInfo {
87
107
  id: string; // e.g., "%1"
108
+ target?: string; // e.g., "main:1.0"
109
+ cwd?: string; // pane_current_path
88
110
  command: string; // e.g., "node", "python", "zsh"
89
111
  suggestedName: string | null; // e.g., "codex" if detected from command
112
+ metadata?: PaneAgentMetadata;
113
+ }
114
+
115
+ export interface TeamPaneRegistration {
116
+ scopeType: 'workspace' | 'team';
117
+ scope: string;
118
+ agent: string;
119
+ remark?: string;
120
+ }
121
+
122
+ export interface TeamPaneInfo {
123
+ pane: string;
124
+ target?: string;
125
+ cwd?: string;
126
+ command: string;
127
+ suggestedName: string | null;
128
+ registrations: TeamPaneRegistration[];
90
129
  }
91
130
 
92
131
  export interface Tmux {
@@ -94,8 +133,23 @@ export interface Tmux {
94
133
  capture: (paneId: string, lines: number) => string;
95
134
  listPanes: () => PaneInfo[];
96
135
  getCurrentPaneId: () => string | null;
136
+ resolvePaneTarget: (target: string) => string | null;
137
+ getAgentRegistry: (scope: RegistryScope) => TmuxRegistry;
138
+ setAgentRegistration: (
139
+ paneId: string,
140
+ scope: RegistryScope,
141
+ registration: AgentRegistration
142
+ ) => void;
143
+ clearAgentRegistration: (name: string, scope: RegistryScope) => boolean;
144
+ listTeams: () => Record<string, string[]>;
145
+ listTeamPanes: () => TeamPaneInfo[];
146
+ removeTeam: (teamName: string) => { removed: number; agents: string[] };
97
147
  }
98
148
 
149
+ export type RegistryScope =
150
+ | { type: 'workspace'; workspaceRoot: string }
151
+ | { type: 'team'; teamName: string };
152
+
99
153
  export interface WaitResult {
100
154
  requestId: string;
101
155
  nonce: string;
@@ -110,5 +164,6 @@ export interface Context {
110
164
  config: ResolvedConfig;
111
165
  tmux: Tmux;
112
166
  paths: Paths;
167
+ registryScope?: RegistryScope;
113
168
  exit: (code: number) => never;
114
169
  }
package/src/ui.test.ts CHANGED
@@ -101,7 +101,13 @@ describe('ui', () => {
101
101
  const { createUI } = await import('./ui.js');
102
102
  const ui = createUI(false);
103
103
 
104
- ui.table(['Name', 'Value'], [['test', ''], ['empty', null as unknown as string]]);
104
+ ui.table(
105
+ ['Name', 'Value'],
106
+ [
107
+ ['test', ''],
108
+ ['empty', null as unknown as string],
109
+ ]
110
+ );
105
111
  const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
106
112
  // Empty cells should be rendered as '-'
107
113
  expect(output).toContain('-');