@automagik/genie 4.260331.1 → 4.260331.3
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/.claude-plugin/marketplace.json +1 -1
- package/.genie/agents/metrics-updater/AGENT.md +16 -186
- package/.genie/agents/metrics-updater/runs.jsonl +1 -0
- package/.genie/agents/metrics-updater/state.json +4 -6
- package/README.md +4 -4
- package/dist/genie.js +13 -12
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/lib/protocol-router-spawn.ts +5 -2
- package/src/lib/team-auto-spawn.ts +14 -2
- package/src/lib/tmux-resolve.test.ts +144 -0
- package/src/lib/tmux.ts +57 -0
- package/src/term-commands/agents.ts +12 -6
- package/src/term-commands/team.ts +107 -19
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260331.
|
|
3
|
+
"version": "4.260331.3",
|
|
4
4
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Namastex Labs"
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { getProvider } from './providers/registry.js';
|
|
28
28
|
import * as teamManager from './team-manager.js';
|
|
29
29
|
import { genieTmuxCmd } from './tmux-wrapper.js';
|
|
30
|
-
import { applyPaneColor, ensureTeamWindow, getCurrentSessionName, listWindows } from './tmux.js';
|
|
30
|
+
import { applyPaneColor, ensureTeamWindow, getCurrentSessionName, listWindows, resolveRepoSession } from './tmux.js';
|
|
31
31
|
import * as wishState from './wish-state.js';
|
|
32
32
|
|
|
33
33
|
const execAsync = promisify(exec);
|
|
@@ -191,7 +191,10 @@ export async function spawnWorkerFromTemplate(
|
|
|
191
191
|
const fullCommand = buildFullCommand(launch);
|
|
192
192
|
const workerId = await generateWorkerId(team, template.role);
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
// Session resolution: team config → repo path mapping → current session → team name
|
|
195
|
+
const teamConfig = await teamManager.getTeam(team);
|
|
196
|
+
const session =
|
|
197
|
+
teamConfig?.tmuxSessionName ?? (await resolveRepoSession(repoPath)) ?? (await getCurrentSessionName()) ?? team;
|
|
195
198
|
const { paneId, teamWindow } = await spawnPaneInSession(session, team, repoPath, fullCommand);
|
|
196
199
|
|
|
197
200
|
const now = new Date().toISOString();
|
|
@@ -38,14 +38,26 @@ function getSystemPromptFile(workingDir: string): string | null {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Ensure a tmux session exists for the given team.
|
|
41
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* Resolution order:
|
|
43
|
+
* 1. Current tmux session (caller is inside tmux)
|
|
44
|
+
* 2. Team config `tmuxSessionName` (stored during team create)
|
|
45
|
+
* 3. Create/find session named after team (last resort)
|
|
42
46
|
*/
|
|
43
47
|
async function ensureSession(teamName: string): Promise<string> {
|
|
44
48
|
// If inside tmux, reuse the current session
|
|
45
49
|
const current = await tmux.getCurrentSessionName();
|
|
46
50
|
if (current) return current;
|
|
47
51
|
|
|
48
|
-
//
|
|
52
|
+
// Check team config for stored session name
|
|
53
|
+
const { getTeam } = await import('./team-manager.js');
|
|
54
|
+
const teamConfig = await getTeam(teamName);
|
|
55
|
+
if (teamConfig?.tmuxSessionName) {
|
|
56
|
+
const existing = await tmux.findSessionByName(teamConfig.tmuxSessionName);
|
|
57
|
+
if (existing) return teamConfig.tmuxSessionName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback: create/find session named after the team
|
|
49
61
|
const sessionName = sanitizeTeamName(teamName);
|
|
50
62
|
const existing = await tmux.findSessionByName(sessionName);
|
|
51
63
|
if (existing) return sessionName;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resolveRepoSession — ensures repo path is mapped to the correct tmux session.
|
|
3
|
+
*
|
|
4
|
+
* These tests mock the tmux wrapper to avoid requiring a running tmux server.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
// Mock the tmux wrapper before importing tmux module
|
|
10
|
+
const mockExecuteTmux = mock(async (_cmd: string) => '');
|
|
11
|
+
|
|
12
|
+
// We need to mock the module before importing
|
|
13
|
+
mock.module('./tmux-wrapper.js', () => ({
|
|
14
|
+
executeTmux: mockExecuteTmux,
|
|
15
|
+
genieTmuxPrefix: () => ['-L', 'genie'],
|
|
16
|
+
genieTmuxCmd: (sub: string) => `tmux -L genie ${sub}`,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { resolveRepoSession } = await import('./tmux.js');
|
|
20
|
+
|
|
21
|
+
describe('resolveRepoSession', () => {
|
|
22
|
+
const originalTMUX = process.env.TMUX;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockExecuteTmux.mockReset();
|
|
26
|
+
process.env.TMUX = undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (originalTMUX !== undefined) {
|
|
31
|
+
process.env.TMUX = originalTMUX;
|
|
32
|
+
} else {
|
|
33
|
+
process.env.TMUX = undefined;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('returns exact session match for basename', async () => {
|
|
38
|
+
// list-sessions returns a session named "genie"
|
|
39
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
40
|
+
if (cmd.includes('list-sessions')) {
|
|
41
|
+
return '$1:genie:1:3\n$2:sofia:0:2';
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await resolveRepoSession('/workspace/repos/genie');
|
|
47
|
+
expect(result).toBe('genie');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('returns current TMUX session when no exact match', async () => {
|
|
51
|
+
process.env.TMUX = '/tmp/tmux-1000/genie,12345,0';
|
|
52
|
+
|
|
53
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
54
|
+
if (cmd.includes('list-sessions')) {
|
|
55
|
+
return '$1:sofia:1:2';
|
|
56
|
+
}
|
|
57
|
+
if (cmd.includes('display-message')) {
|
|
58
|
+
return 'sofia';
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await resolveRepoSession('/workspace/repos/my-project');
|
|
64
|
+
expect(result).toBe('sofia');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('returns partial match when no exact match and not inside tmux', async () => {
|
|
68
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
69
|
+
if (cmd.includes('list-sessions')) {
|
|
70
|
+
return '$1:genie-dev:1:3\n$2:sofia:0:2';
|
|
71
|
+
}
|
|
72
|
+
return '';
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await resolveRepoSession('/workspace/repos/genie');
|
|
76
|
+
expect(result).toBe('genie-dev');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('returns derived basename when no sessions match', async () => {
|
|
80
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
81
|
+
if (cmd.includes('list-sessions')) {
|
|
82
|
+
return '$1:sofia:1:2\n$2:totvs:0:1';
|
|
83
|
+
}
|
|
84
|
+
return '';
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await resolveRepoSession('/workspace/repos/genie');
|
|
88
|
+
expect(result).toBe('genie');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns derived basename when tmux is not available', async () => {
|
|
92
|
+
mockExecuteTmux.mockImplementation(async () => {
|
|
93
|
+
throw new Error('no server running');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await resolveRepoSession('/workspace/repos/genie');
|
|
97
|
+
expect(result).toBe('genie');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('handles repo path with trailing slash', async () => {
|
|
101
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
102
|
+
if (cmd.includes('list-sessions')) {
|
|
103
|
+
return '$1:genie:1:3';
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await resolveRepoSession('/workspace/repos/genie/');
|
|
109
|
+
// basename('/workspace/repos/genie/') returns '' in node, so no exact match
|
|
110
|
+
// The derived name would be empty, falling back to empty string
|
|
111
|
+
// This documents the edge case — callers should provide clean paths
|
|
112
|
+
expect(typeof result).toBe('string');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('exact match takes priority over TMUX env session', async () => {
|
|
116
|
+
process.env.TMUX = '/tmp/tmux-1000/genie,12345,0';
|
|
117
|
+
|
|
118
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
119
|
+
if (cmd.includes('list-sessions')) {
|
|
120
|
+
return '$1:genie:1:3\n$2:sofia:0:2';
|
|
121
|
+
}
|
|
122
|
+
if (cmd.includes('display-message')) {
|
|
123
|
+
return 'sofia';
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Exact match "genie" should win over current session "sofia"
|
|
129
|
+
const result = await resolveRepoSession('/workspace/repos/genie');
|
|
130
|
+
expect(result).toBe('genie');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('handles genie-os repo correctly', async () => {
|
|
134
|
+
mockExecuteTmux.mockImplementation(async (cmd: string) => {
|
|
135
|
+
if (cmd.includes('list-sessions')) {
|
|
136
|
+
return '$1:genie:1:3\n$2:genie-os:0:2\n$3:sofia:0:1';
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = await resolveRepoSession('/workspace/repos/genie-os');
|
|
142
|
+
expect(result).toBe('genie-os');
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/lib/tmux.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
import { shellQuote } from './team-lead-command.js';
|
|
2
3
|
import { executeTmux as wrapperExecuteTmux } from './tmux-wrapper.js';
|
|
3
4
|
|
|
@@ -428,6 +429,49 @@ async function rehydratePaneColorHook(windowId: string): Promise<void> {
|
|
|
428
429
|
}
|
|
429
430
|
}
|
|
430
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Resolve the tmux session that should host windows for a given repo.
|
|
434
|
+
*
|
|
435
|
+
* Resolution order:
|
|
436
|
+
* 1. `basename(repoPath)` exact match against existing tmux sessions
|
|
437
|
+
* 2. `process.env.TMUX` current session (caller is inside tmux)
|
|
438
|
+
* 3. `tmux list-sessions` partial match (session name contains basename)
|
|
439
|
+
* 4. Return derived basename (ensureTeamWindow will create it on demand)
|
|
440
|
+
*
|
|
441
|
+
* This prevents session explosion: teams for `/workspace/repos/genie` land
|
|
442
|
+
* in the existing `genie` session instead of creating a new session per team.
|
|
443
|
+
*/
|
|
444
|
+
export async function resolveRepoSession(repoPath: string): Promise<string> {
|
|
445
|
+
const derived = basename(repoPath);
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const sessions = await listSessions();
|
|
449
|
+
|
|
450
|
+
// 1. Exact match — basename maps directly to a session
|
|
451
|
+
const exact = sessions.find((s) => s.name === derived);
|
|
452
|
+
if (exact) return exact.name;
|
|
453
|
+
|
|
454
|
+
// 2. Inside tmux — use current session
|
|
455
|
+
if (process.env.TMUX) {
|
|
456
|
+
try {
|
|
457
|
+
const name = (await executeTmux("display-message -p '#{session_name}'")).trim();
|
|
458
|
+
if (name) return name;
|
|
459
|
+
} catch {
|
|
460
|
+
/* fall through */
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 3. Partial match — session name contains the repo basename
|
|
465
|
+
const partial = sessions.find((s) => s.name.includes(derived));
|
|
466
|
+
if (partial) return partial.name;
|
|
467
|
+
} catch {
|
|
468
|
+
/* tmux not available — fall through to derived name */
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 4. Last resort — derived basename (will be created on demand by ensureTeamWindow)
|
|
472
|
+
return derived;
|
|
473
|
+
}
|
|
474
|
+
|
|
431
475
|
/**
|
|
432
476
|
* Check if a tmux pane is still alive by attempting a minimal capture.
|
|
433
477
|
* Returns false for invalid pane IDs ('inline', empty, non-%N format).
|
|
@@ -442,3 +486,16 @@ export async function isPaneAlive(paneId: string): Promise<boolean> {
|
|
|
442
486
|
return false;
|
|
443
487
|
}
|
|
444
488
|
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Kill a tmux window by session:window target.
|
|
492
|
+
* Returns true if the window was killed, false if it didn't exist or the kill failed.
|
|
493
|
+
*/
|
|
494
|
+
export async function killWindow(sessionName: string, windowName: string): Promise<boolean> {
|
|
495
|
+
try {
|
|
496
|
+
await executeTmux(`kill-window -t ${shellQuote(`${sessionName}:${windowName}`)}`);
|
|
497
|
+
return true;
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -665,10 +665,10 @@ export function buildInitialSplitWindowCommand(windowId: string, cwd: string | u
|
|
|
665
665
|
* Resolve team window for spawn. Returns null if team is unset or resolution fails.
|
|
666
666
|
*
|
|
667
667
|
* Session resolution order:
|
|
668
|
-
* 1. Explicit `sessionOverride` (from --session flag)
|
|
669
|
-
* 2. `
|
|
670
|
-
* 3.
|
|
671
|
-
* 4. Team name as session name (last resort)
|
|
668
|
+
* 1. Explicit `sessionOverride` (from --tmux-session flag)
|
|
669
|
+
* 2. Team config `tmuxSessionName` (stored during team create — source of truth)
|
|
670
|
+
* 3. `resolveRepoSession(cwd)` (derive from repo path)
|
|
671
|
+
* 4. Team name as session name (absolute last resort)
|
|
672
672
|
*/
|
|
673
673
|
async function resolveSpawnTeamWindow(
|
|
674
674
|
team: string | undefined,
|
|
@@ -677,10 +677,16 @@ async function resolveSpawnTeamWindow(
|
|
|
677
677
|
): Promise<TeamWindowInfo | null> {
|
|
678
678
|
if (!team) return null;
|
|
679
679
|
try {
|
|
680
|
-
let sessionName = sessionOverride
|
|
680
|
+
let sessionName = sessionOverride;
|
|
681
681
|
if (!sessionName) {
|
|
682
682
|
const teamConfig = await teamManager.getTeam(team);
|
|
683
|
-
sessionName = teamConfig?.tmuxSessionName
|
|
683
|
+
sessionName = teamConfig?.tmuxSessionName;
|
|
684
|
+
}
|
|
685
|
+
if (!sessionName) {
|
|
686
|
+
sessionName = await tmux.resolveRepoSession(cwd);
|
|
687
|
+
}
|
|
688
|
+
if (!sessionName) {
|
|
689
|
+
sessionName = team;
|
|
684
690
|
}
|
|
685
691
|
return await tmux.ensureTeamWindow(sessionName, team, cwd);
|
|
686
692
|
} catch (err) {
|
|
@@ -26,15 +26,25 @@ export function registerTeamNamespace(program: Command): void {
|
|
|
26
26
|
.requiredOption('--repo <path>', 'Path to the git repository')
|
|
27
27
|
.option('--branch <branch>', 'Base branch to create from', 'dev')
|
|
28
28
|
.option('--wish <slug>', 'Wish slug — auto-spawns a task leader with wish context')
|
|
29
|
-
.option('--session <name>', 'Tmux session
|
|
29
|
+
.option('--tmux-session <name>', 'Tmux session to place team window in (default: derived from repo path)')
|
|
30
|
+
.option('--session <name>', 'Alias for --tmux-session (deprecated)')
|
|
30
31
|
.option('--no-spawn', 'Create team and copy wish without spawning the leader (useful for testing)')
|
|
31
32
|
.action(
|
|
32
33
|
async (
|
|
33
34
|
name: string,
|
|
34
|
-
options: {
|
|
35
|
+
options: {
|
|
36
|
+
repo: string;
|
|
37
|
+
branch: string;
|
|
38
|
+
wish?: string;
|
|
39
|
+
tmuxSession?: string;
|
|
40
|
+
session?: string;
|
|
41
|
+
spawn?: boolean;
|
|
42
|
+
},
|
|
35
43
|
) => {
|
|
36
44
|
try {
|
|
37
|
-
|
|
45
|
+
// --session is a deprecated alias for --tmux-session
|
|
46
|
+
const merged = { ...options, tmuxSession: options.tmuxSession ?? options.session };
|
|
47
|
+
await handleTeamCreate(name, merged);
|
|
38
48
|
} catch (error) {
|
|
39
49
|
const message = error instanceof Error ? error.message : String(error);
|
|
40
50
|
console.error(`Error: ${message}`);
|
|
@@ -214,6 +224,21 @@ export function registerTeamNamespace(program: Command): void {
|
|
|
214
224
|
process.exit(1);
|
|
215
225
|
}
|
|
216
226
|
});
|
|
227
|
+
|
|
228
|
+
// team cleanup
|
|
229
|
+
team
|
|
230
|
+
.command('cleanup')
|
|
231
|
+
.description('Kill tmux windows for done/archived teams')
|
|
232
|
+
.option('--dry-run', 'Show what would be cleaned without doing it')
|
|
233
|
+
.action(async (options: { dryRun?: boolean }) => {
|
|
234
|
+
try {
|
|
235
|
+
await handleTeamCleanup(options);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
238
|
+
console.error(`Error: ${message}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
217
242
|
}
|
|
218
243
|
|
|
219
244
|
// ============================================================================
|
|
@@ -222,11 +247,12 @@ export function registerTeamNamespace(program: Command): void {
|
|
|
222
247
|
|
|
223
248
|
async function handleTeamCreate(
|
|
224
249
|
name: string,
|
|
225
|
-
options: { repo: string; branch: string; wish?: string;
|
|
250
|
+
options: { repo: string; branch: string; wish?: string; tmuxSession?: string; spawn?: boolean },
|
|
226
251
|
): Promise<void> {
|
|
252
|
+
const resolvedRepo = resolve(options.repo);
|
|
253
|
+
|
|
227
254
|
// Validate wish exists before creating team — auto-copy from cwd if needed
|
|
228
255
|
if (options.wish) {
|
|
229
|
-
const resolvedRepo = resolve(options.repo);
|
|
230
256
|
const wishPath = join(resolvedRepo, '.genie', 'wishes', options.wish, 'WISH.md');
|
|
231
257
|
if (!existsSync(wishPath)) {
|
|
232
258
|
// Auto-copy: search cwd for the wish
|
|
@@ -246,19 +272,16 @@ async function handleTeamCreate(
|
|
|
246
272
|
|
|
247
273
|
const config = await teamManager.createTeam(name, options.repo, options.branch);
|
|
248
274
|
|
|
249
|
-
//
|
|
250
|
-
|
|
275
|
+
// Always resolve tmuxSessionName — prevents session explosion on parallel creates
|
|
276
|
+
// Resolution: explicit --tmux-session → PG agent session → repo path mapping
|
|
277
|
+
const { findSessionByRepo } = await import('../lib/agent-directory.js');
|
|
278
|
+
const { resolveRepoSession } = await import('../lib/tmux.js');
|
|
279
|
+
config.tmuxSessionName =
|
|
280
|
+
options.tmuxSession ?? (await findSessionByRepo(resolvedRepo)) ?? (await resolveRepoSession(resolvedRepo));
|
|
251
281
|
if (options.wish) {
|
|
252
282
|
config.wishSlug = options.wish;
|
|
253
|
-
needsUpdate = true;
|
|
254
|
-
}
|
|
255
|
-
if (options.session) {
|
|
256
|
-
config.tmuxSessionName = options.session;
|
|
257
|
-
needsUpdate = true;
|
|
258
|
-
}
|
|
259
|
-
if (needsUpdate) {
|
|
260
|
-
await teamManager.updateTeamConfig(name, config);
|
|
261
283
|
}
|
|
284
|
+
await teamManager.updateTeamConfig(name, config);
|
|
262
285
|
|
|
263
286
|
console.log(`Team "${config.name}" created.`);
|
|
264
287
|
console.log(` Worktree: ${config.worktreePath}`);
|
|
@@ -271,7 +294,7 @@ async function handleTeamCreate(
|
|
|
271
294
|
}
|
|
272
295
|
|
|
273
296
|
if (options.wish && options.spawn !== false) {
|
|
274
|
-
await spawnLeaderWithWish(config, options.wish, options.repo, options.
|
|
297
|
+
await spawnLeaderWithWish(config, options.wish, options.repo, options.tmuxSession);
|
|
275
298
|
}
|
|
276
299
|
}
|
|
277
300
|
|
|
@@ -293,11 +316,15 @@ async function spawnLeaderWithWish(
|
|
|
293
316
|
): Promise<void> {
|
|
294
317
|
const { handleWorkerSpawn } = await import('./agents.js');
|
|
295
318
|
const { findSessionByRepo } = await import('../lib/agent-directory.js');
|
|
319
|
+
const { resolveRepoSession } = await import('../lib/tmux.js');
|
|
296
320
|
const resolvedRepo = resolve(repoPath);
|
|
297
321
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
322
|
+
// Use already-resolved session from handleTeamCreate, with fallback chain for safety
|
|
323
|
+
const tmuxSession =
|
|
324
|
+
sessionOverride ??
|
|
325
|
+
config.tmuxSessionName ??
|
|
326
|
+
(await findSessionByRepo(resolvedRepo)) ??
|
|
327
|
+
(await resolveRepoSession(resolvedRepo));
|
|
301
328
|
config.tmuxSessionName = tmuxSession;
|
|
302
329
|
await teamManager.updateTeamConfig(config.name, config);
|
|
303
330
|
|
|
@@ -418,3 +445,64 @@ function printTeamSummary(t: TeamConfig): void {
|
|
|
418
445
|
console.log(` Worktree: ${t.worktreePath}`);
|
|
419
446
|
console.log(` Members: ${t.members.length}`);
|
|
420
447
|
}
|
|
448
|
+
|
|
449
|
+
// ============================================================================
|
|
450
|
+
// Team Cleanup Handler
|
|
451
|
+
// ============================================================================
|
|
452
|
+
|
|
453
|
+
/** Find the tmux window matching a team name (handles dot-sanitized names). */
|
|
454
|
+
async function findTeamWindow(sessionName: string, teamName: string): Promise<{ name: string } | null> {
|
|
455
|
+
const tmuxLib = await import('../lib/tmux.js');
|
|
456
|
+
const session = await tmuxLib.findSessionByName(sessionName);
|
|
457
|
+
if (!session) return null;
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const windows = await tmuxLib.listWindows(sessionName);
|
|
461
|
+
return windows.find((w) => w.name === teamName || w.name === teamName.replace(/\./g, '_')) ?? null;
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Try to kill a team's tmux window. Returns a log message or null. */
|
|
468
|
+
async function cleanupTeamWindow(t: TeamConfig, dryRun: boolean): Promise<string | null> {
|
|
469
|
+
if (!t.tmuxSessionName) return null;
|
|
470
|
+
const match = await findTeamWindow(t.tmuxSessionName, t.name);
|
|
471
|
+
if (!match) return null;
|
|
472
|
+
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
return ` [dry-run] Would kill window "${match.name}" in session "${t.tmuxSessionName}" (team "${t.name}" [${t.status}])`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const tmuxLib = await import('../lib/tmux.js');
|
|
478
|
+
const killed = await tmuxLib.killWindow(t.tmuxSessionName, match.name);
|
|
479
|
+
if (!killed) return null;
|
|
480
|
+
return ` Killed window "${match.name}" in session "${t.tmuxSessionName}" (team "${t.name}")`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Kill tmux windows for done/archived teams. */
|
|
484
|
+
async function handleTeamCleanup(options: { dryRun?: boolean }): Promise<void> {
|
|
485
|
+
const allTeams = await teamManager.listTeams(true);
|
|
486
|
+
const cleanable = allTeams.filter((t) => t.status === 'done' || t.status === 'archived');
|
|
487
|
+
|
|
488
|
+
if (cleanable.length === 0) {
|
|
489
|
+
console.log('No done/archived teams to clean up.');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let cleaned = 0;
|
|
494
|
+
for (const t of cleanable) {
|
|
495
|
+
const msg = await cleanupTeamWindow(t, options.dryRun === true);
|
|
496
|
+
if (msg) {
|
|
497
|
+
console.log(msg);
|
|
498
|
+
cleaned++;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const verb = options.dryRun ? 'Would clean' : 'Cleaned';
|
|
503
|
+
if (cleaned === 0) {
|
|
504
|
+
console.log('No tmux windows found for done/archived teams.');
|
|
505
|
+
} else {
|
|
506
|
+
console.log(`\n${verb} ${cleaned} window${cleaned === 1 ? '' : 's'}.`);
|
|
507
|
+
}
|
|
508
|
+
}
|