@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.
@@ -2,7 +2,7 @@
2
2
  "id": "genie",
3
3
  "name": "Genie",
4
4
  "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit",
5
- "version": "4.260331.1",
5
+ "version": "4.260331.3",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260331.1",
3
+ "version": "4.260331.3",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260331.1",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260331.1",
3
+ "version": "4.260331.3",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -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
- const session = (await getCurrentSessionName()) ?? team;
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
- * Uses the current tmux session if inside one, otherwise creates a session named after the team.
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
- // Otherwise create/find a session named after the team (folder-based naming)
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. `getCurrentSessionName()` (TMUX env or list-sessions fallback)
670
- * 3. Team config `tmuxSessionName` (stored during team create)
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 ?? (await tmux.getCurrentSessionName(team));
680
+ let sessionName = sessionOverride;
681
681
  if (!sessionName) {
682
682
  const teamConfig = await teamManager.getTeam(team);
683
- sessionName = teamConfig?.tmuxSessionName ?? team;
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 name (avoids session explosion on parallel creates)')
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: { repo: string; branch: string; wish?: string; session?: string; spawn?: boolean },
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
- await handleTeamCreate(name, options);
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; session?: string; spawn?: boolean },
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
- // Store wish slug and tmux session in team config
250
- let needsUpdate = false;
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.session);
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
- // Resolve tmux session BEFORE spawning prevents parallel creates from each creating a new session
299
- // Resolution: 1) explicit --session, 2) repo owner agent's session, 3) team name as new session
300
- const tmuxSession = sessionOverride ?? (await findSessionByRepo(resolvedRepo)) ?? config.name;
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
+ }