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.
- package/README.md +102 -24
- package/package.json +1 -1
- package/src/cli.test.ts +15 -1
- package/src/cli.ts +10 -0
- package/src/commands/add.ts +17 -32
- package/src/commands/basic-commands.test.ts +293 -17
- package/src/commands/completion.ts +6 -8
- package/src/commands/config-command.test.ts +9 -8
- package/src/commands/config.ts +1 -5
- package/src/commands/help.ts +5 -2
- package/src/commands/install.test.ts +15 -1
- package/src/commands/list.ts +7 -1
- package/src/commands/migrate.ts +84 -0
- package/src/commands/preamble.test.ts +15 -2
- package/src/commands/preamble.ts +61 -16
- package/src/commands/remove.ts +10 -6
- package/src/commands/talk.test.ts +111 -22
- package/src/commands/talk.ts +8 -3
- package/src/commands/team.ts +172 -0
- package/src/commands/update.ts +45 -14
- package/src/config.test.ts +24 -0
- package/src/config.ts +37 -3
- package/src/context.test.ts +76 -1
- package/src/context.ts +8 -1
- package/src/identity.test.ts +3 -9
- package/src/identity.ts +7 -9
- package/src/registry.test.ts +61 -0
- package/src/registry.ts +29 -0
- package/src/tmux.test.ts +190 -1
- package/src/tmux.ts +289 -9
- package/src/types.ts +55 -0
- package/src/ui.test.ts +7 -1
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('');
|
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 {
|
|
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
|
|
92
|
-
const output = execSync(
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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(
|
|
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('-');
|