cru-teams 1.0.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,149 @@
1
+ /**
2
+ * Mirror tmux panes into native Ghostty splits.
3
+ *
4
+ * When Claude Code spawns a team via Agent tool, workers end up in a
5
+ * headless tmux session using a CUSTOM SOCKET: `tmux -L claude-swarm-<PID>`.
6
+ * These don't show up in the default `tmux list-sessions`.
7
+ *
8
+ * This module:
9
+ * 1. Finds claude-swarm socket files in /tmp/tmux-<uid>/
10
+ * 2. Queries each socket for worker panes
11
+ * 3. Breaks panes into separate tmux windows
12
+ * 4. Creates Ghostty splits, each running `tmux -L <socket> attach -t <view>`
13
+ *
14
+ * Result: each Ghostty split shows a real tmux pane with full interactivity.
15
+ * Workers retain all team features (SendMessage, tasks, team bar).
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process'
19
+ import { readdirSync, statSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { splitTerminal, sendCommand } from './ghostty'
22
+
23
+ /** Run a tmux command on a specific socket (or default). */
24
+ function tmuxSocket(args: string[], socket?: string): string {
25
+ const fullArgs = socket ? ['-L', socket, ...args] : args
26
+ return execFileSync('tmux', fullArgs, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim()
27
+ }
28
+
29
+ /** Find claude-swarm tmux socket names, newest first. */
30
+ export function findSwarmSockets(): string[] {
31
+ const uid = process.getuid?.() ?? 501
32
+ const sockDir = join('/tmp', `tmux-${uid}`)
33
+ try {
34
+ return readdirSync(sockDir)
35
+ .filter((f) => f.startsWith('claude-swarm-'))
36
+ .map((f) => ({ name: f, mtime: statSync(join(sockDir, f)).mtimeMs }))
37
+ .sort((a, b) => b.mtime - a.mtime)
38
+ .map((f) => f.name)
39
+ } catch {
40
+ return []
41
+ }
42
+ }
43
+
44
+ /** Check if a PID is still running. */
45
+ function isPidAlive(pid: number): boolean {
46
+ try {
47
+ process.kill(pid, 0)
48
+ return true
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ /** Find live swarm sockets that have active sessions. */
55
+ export function findLiveSwarms(): Array<{ socket: string; session: string; paneCount: number }> {
56
+ const results: Array<{ socket: string; session: string; paneCount: number }> = []
57
+
58
+ for (const socket of findSwarmSockets()) {
59
+ const pid = Number(socket.replace('claude-swarm-', ''))
60
+ if (pid && !isPidAlive(pid)) continue
61
+
62
+ try {
63
+ const sessions = tmuxSocket(['list-sessions', '-F', '#{session_name}'], socket)
64
+ .split('\n').filter(Boolean)
65
+ .filter(s => !s.startsWith('view-'))
66
+ if (sessions.length === 0) continue
67
+ const session = sessions[0]
68
+
69
+ const allPanes = tmuxSocket(['list-panes', '-a', '-F', '#{pane_id}'], socket)
70
+ .split('\n').filter(Boolean)
71
+ const uniquePanes = [...new Set(allPanes)]
72
+
73
+ results.push({ socket, session, paneCount: uniquePanes.length })
74
+ } catch {
75
+ // Server not running — skip
76
+ }
77
+ }
78
+ return results
79
+ }
80
+
81
+ /** Find the swarm with the most panes (most likely the current team). */
82
+ export function findBestSwarm(expectedWorkers?: number): { socket: string; session: string } | null {
83
+ const swarms = findLiveSwarms()
84
+ if (swarms.length === 0) return null
85
+
86
+ if (expectedWorkers) {
87
+ const match = swarms.find((s) => s.paneCount >= expectedWorkers)
88
+ if (match) return match
89
+ }
90
+
91
+ swarms.sort((a, b) => b.paneCount - a.paneCount)
92
+ return swarms[0]
93
+ }
94
+
95
+ /** Get worker pane IDs from a swarm socket. All panes are workers (lead runs in user's terminal). */
96
+ export function getWorkerPanes(socket: string, _session?: string): string[] {
97
+ try {
98
+ const allPanes = tmuxSocket(['list-panes', '-a', '-F', '#{pane_id}'], socket)
99
+ .split('\n').filter(Boolean)
100
+ return [...new Set(allPanes)].sort()
101
+ } catch {
102
+ return []
103
+ }
104
+ }
105
+
106
+ /** Enable remain-on-exit globally so panes stay visible after worker exits. */
107
+ export function setRemainOnExit(socket: string): void {
108
+ try { tmuxSocket(['set-option', '-g', 'remain-on-exit', 'on'], socket) } catch {}
109
+ }
110
+
111
+ /**
112
+ * Mirror a single tmux worker pane into a Ghostty split.
113
+ * Used for incremental mirroring — opens each pane as soon as the worker spawns.
114
+ */
115
+ export function mirrorSingleWorker(
116
+ socket: string,
117
+ session: string,
118
+ paneId: string,
119
+ index: number,
120
+ splitTarget: string,
121
+ splitDirection: 'right' | 'down',
122
+ ): { ghosttyTerminal: string; viewSession: string } {
123
+ const windowName = `worker-${index + 1}`
124
+ const viewName = `view-${index + 1}`
125
+
126
+ // Break pane into its own tmux window
127
+ try {
128
+ tmuxSocket(['break-pane', '-s', paneId, '-d', '-n', windowName], socket)
129
+ } catch (e: any) {
130
+ console.error(` [mirror] break-pane ${paneId}: ${e.message}`)
131
+ }
132
+
133
+ // Create a session group member pointing at the worker's window
134
+ try { tmuxSocket(['kill-session', '-t', viewName], socket) } catch {}
135
+ tmuxSocket(['new-session', '-d', '-t', session, '-s', viewName], socket)
136
+ tmuxSocket(['set-option', '-t', viewName, 'status', 'off'], socket)
137
+ tmuxSocket(['select-window', '-t', `${viewName}:${windowName}`], socket)
138
+
139
+ // Create Ghostty split
140
+ const newTermId = splitTerminal(splitTarget, splitDirection)
141
+ Bun.sleepSync(300)
142
+
143
+ // Attach to the view session via the custom socket
144
+ sendCommand(newTermId, `tmux -L ${socket} attach -t ${viewName}`)
145
+ Bun.sleepSync(200)
146
+
147
+ console.log(` [mirror] ${windowName} (${paneId}) → ghostty:${newTermId}`)
148
+ return { ghosttyTerminal: newTermId, viewSession: viewName }
149
+ }
@@ -0,0 +1,62 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { teamsDir } from './paths'
4
+ import { listAllPaneIds } from './tmux'
5
+
6
+ export interface PaneRecord {
7
+ leadPane: string
8
+ windowId: string
9
+ workers: Array<{ name: string; paneId: string; color: string }>
10
+ createdAt: number
11
+ backend?: 'tmux' | 'ghostty'
12
+ }
13
+
14
+ function panePath(teamName: string): string {
15
+ return join(teamsDir(), teamName, 'cru-panes.json')
16
+ }
17
+
18
+ export function savePanes(teamName: string, record: PaneRecord): void {
19
+ const dir = join(teamsDir(), teamName)
20
+ mkdirSync(dir, { recursive: true })
21
+ writeFileSync(panePath(teamName), JSON.stringify(record, null, 2))
22
+ }
23
+
24
+ export function loadPanes(teamName: string): PaneRecord | null {
25
+ try {
26
+ return JSON.parse(readFileSync(panePath(teamName), 'utf-8'))
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /** Check if a team has live worker panes. */
33
+ export function isTeamAlive(teamName: string): boolean {
34
+ try {
35
+ const cruPanes = loadPanes(teamName)
36
+
37
+ // For ghostty-tracked teams, check via AppleScript
38
+ if (cruPanes?.backend === 'ghostty') {
39
+ try {
40
+ const { listAllTerminals } = require('./ghostty')
41
+ const allTerminals = new Set(listAllTerminals())
42
+ return cruPanes.workers.some((w) => allTerminals.has(w.paneId))
43
+ } catch (e) {
44
+ console.warn(`[isTeamAlive] failed to query Ghostty for team "${teamName}": ${e}`)
45
+ return false
46
+ }
47
+ }
48
+
49
+ // For tmux teams, check pane IDs
50
+ const allPanes = new Set(listAllPaneIds())
51
+
52
+ if (cruPanes && cruPanes.workers.some((w) => allPanes.has(w.paneId))) return true
53
+
54
+ // Fallback: check Claude's team config for tmuxPaneId entries
55
+ // Late require to avoid circular dep (teams.ts imports panes.ts)
56
+ const { readTeamConfig } = require('./teams')
57
+ const config = readTeamConfig(teamName)
58
+ return config.members.some((m: any) => m.tmuxPaneId && allPanes.has(m.tmuxPaneId))
59
+ } catch {
60
+ return false
61
+ }
62
+ }
@@ -0,0 +1,6 @@
1
+ import { join } from 'node:path'
2
+ import { homedir } from 'node:os'
3
+
4
+ export function teamsDir(): string {
5
+ return join(homedir(), '.claude', 'teams')
6
+ }
@@ -0,0 +1,94 @@
1
+ import { hasBinary, inTmux, inGhostty, detectTerminal } from '@/lib/env'
2
+
3
+ const INSTALL_HINTS: Record<string, Record<string, string>> = {
4
+ tmux: {
5
+ darwin: 'brew install tmux',
6
+ linux: 'sudo apt install tmux # or your package manager',
7
+ fallback: 'https://github.com/tmux/tmux/wiki/Installing',
8
+ },
9
+ claude: {
10
+ all: 'npm install -g @anthropic-ai/claude-code',
11
+ docs: 'https://docs.anthropic.com/en/docs/claude-code',
12
+ },
13
+ }
14
+
15
+ function platformHint(tool: string): string {
16
+ const hints = INSTALL_HINTS[tool]
17
+ if (!hints) return ''
18
+ if (hints.all) return hints.all
19
+ const platform = process.platform
20
+ return hints[platform] || hints.fallback || ''
21
+ }
22
+
23
+ interface PreflightError {
24
+ check: string
25
+ message: string
26
+ hint?: string
27
+ }
28
+
29
+ type Check = 'tmux' | 'tmux-session' | 'claude' | 'pane-session' | 'terminal'
30
+
31
+ /**
32
+ * Run preflight checks. Returns { ok, errors, terminal }.
33
+ */
34
+ export function preflight(...checks: Check[]): { ok: boolean; errors: PreflightError[]; terminal: string } {
35
+ const terminal = detectTerminal()
36
+ const tmuxCmd = terminal === 'iterm2' ? 'tmux -CC' : 'tmux'
37
+ const errors: PreflightError[] = []
38
+
39
+ for (const check of checks) {
40
+ switch (check) {
41
+ case 'tmux':
42
+ if (!hasBinary('tmux')) {
43
+ errors.push({ check: 'tmux', message: 'tmux is not installed', hint: platformHint('tmux') })
44
+ }
45
+ break
46
+
47
+ case 'tmux-session':
48
+ if (!hasBinary('tmux')) {
49
+ errors.push({ check: 'tmux', message: 'tmux is not installed', hint: platformHint('tmux') })
50
+ } else if (!inTmux()) {
51
+ errors.push({ check: 'tmux-session', message: 'Not inside a tmux session', hint: tmuxCmd })
52
+ }
53
+ break
54
+
55
+ case 'claude':
56
+ if (!hasBinary('claude')) {
57
+ errors.push({ check: 'claude', message: 'Claude Code CLI is not installed', hint: platformHint('claude') })
58
+ }
59
+ break
60
+
61
+ case 'pane-session':
62
+ if (inGhostty()) {
63
+ if (process.platform !== 'darwin') {
64
+ errors.push({ check: 'pane-session', message: 'Ghostty AppleScript is only available on macOS' })
65
+ } else {
66
+ try {
67
+ const { isGhosttyScriptable } = require('./ghostty')
68
+ if (!isGhosttyScriptable()) {
69
+ errors.push({ check: 'pane-session', message: 'Ghostty is not responding to AppleScript. Check that macos-applescript is enabled.' })
70
+ }
71
+ } catch {
72
+ errors.push({ check: 'pane-session', message: 'Cannot connect to Ghostty via AppleScript' })
73
+ }
74
+ }
75
+ } else if (!hasBinary('tmux')) {
76
+ errors.push({ check: 'pane-session', message: 'tmux is not installed (or use Ghostty for native pane support)', hint: platformHint('tmux') })
77
+ } else if (!inTmux()) {
78
+ errors.push({ check: 'pane-session', message: 'Not inside a tmux session (or use Ghostty for native pane support)', hint: tmuxCmd })
79
+ }
80
+ break
81
+
82
+ case 'terminal':
83
+ if (terminal === 'vscode') {
84
+ errors.push({
85
+ check: 'terminal',
86
+ message: 'VS Code terminal cannot display tmux panes. Use iTerm2 or a standalone terminal with tmux.',
87
+ })
88
+ }
89
+ break
90
+ }
91
+ }
92
+
93
+ return { ok: errors.length === 0, errors, terminal }
94
+ }
@@ -0,0 +1,57 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { teamsDir } from './paths'
4
+ import { loadPanes } from './panes'
5
+ import { currentPane, paneWindow, tmux } from './tmux'
6
+
7
+ export function readTeamConfig(teamName: string): any {
8
+ const p = join(teamsDir(), teamName, 'config.json')
9
+ return JSON.parse(readFileSync(p, 'utf-8'))
10
+ }
11
+
12
+ export function listTeams(): string[] {
13
+ const dir = teamsDir()
14
+ try {
15
+ return readdirSync(dir).filter((d) => {
16
+ try {
17
+ readFileSync(join(dir, d, 'config.json'), 'utf-8')
18
+ return true
19
+ } catch {
20
+ return false
21
+ }
22
+ })
23
+ } catch {
24
+ return []
25
+ }
26
+ }
27
+
28
+ /** Find which team owns the current terminal window. */
29
+ export function findTeamForCurrentWindow(): string | null {
30
+ const windowId = paneWindow(currentPane())
31
+ for (const name of listTeams()) {
32
+ const panes = loadPanes(name)
33
+ if (panes?.windowId === windowId) return name
34
+ }
35
+ return null
36
+ }
37
+
38
+ export function findTeamWindow(teamName: string): string | null {
39
+ // Primary: cru's own pane tracking
40
+ const cruPanes = loadPanes(teamName)
41
+ if (cruPanes) return cruPanes.windowId
42
+
43
+ // Fallback: search via Claude's team config
44
+ const config = readTeamConfig(teamName)
45
+ const workerPaneIds = config.members
46
+ .filter((m: any) => m.tmuxPaneId)
47
+ .map((m: any) => m.tmuxPaneId)
48
+ if (workerPaneIds.length === 0) return null
49
+
50
+ // Search all panes to find which window contains a worker pane
51
+ const lines = tmux('list-panes', '-a', '-F', '#{window_id} #{pane_id}').split('\n')
52
+ for (const line of lines) {
53
+ const [winId, paneId] = line.split(' ')
54
+ if (paneId === workerPaneIds[0]) return winId
55
+ }
56
+ return null
57
+ }
@@ -0,0 +1,167 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { buildLayout } from './layout'
3
+
4
+ export interface PaneInfo {
5
+ id: string
6
+ index: number
7
+ }
8
+
9
+ export interface PaneDetails {
10
+ id: string
11
+ index: number
12
+ width: number
13
+ height: number
14
+ left: number
15
+ top: number
16
+ pid: number
17
+ }
18
+
19
+ /** Run a tmux command safely (no shell interpolation). */
20
+ export function tmux(...args: string[]): string {
21
+ return execFileSync('tmux', args, { encoding: 'utf-8' }).trim()
22
+ }
23
+
24
+ export function tmuxChecksum(layout: string): string {
25
+ let csum = 0
26
+ for (const c of layout) {
27
+ csum = ((csum >> 1) + ((csum & 1) << 15)) & 0xffff
28
+ csum = (csum + c.charCodeAt(0)) & 0xffff
29
+ }
30
+ return csum.toString(16).padStart(4, '0')
31
+ }
32
+
33
+ export function getWindowDimensions(windowId: string): { w: number; h: number } {
34
+ // display-message can return empty in control mode — use list-windows as fallback
35
+ const dims = tmux('display-message', '-t', windowId, '-p', '#{window_width}x#{window_height}')
36
+ if (dims && dims.includes('x')) {
37
+ const [w, h] = dims.split('x').map(Number)
38
+ if (w && h) return { w, h }
39
+ }
40
+ const fallback = tmux('list-windows', '-a', '-F', '#{window_id} #{window_width} #{window_height}')
41
+ .split('\n')
42
+ .find((l) => l.startsWith(windowId + ' '))
43
+ if (fallback) {
44
+ const [, w, h] = fallback.split(' ')
45
+ return { w: Number(w), h: Number(h) }
46
+ }
47
+ throw new Error(`Cannot get dimensions for window ${windowId}`)
48
+ }
49
+
50
+ export function listWindowPanes(windowId: string): PaneInfo[] {
51
+ return tmux('list-panes', '-t', windowId, '-F', '#{pane_id} #{pane_index}')
52
+ .split('\n')
53
+ .map((l) => {
54
+ const [id, idx] = l.split(' ')
55
+ return { id, index: Number(idx) }
56
+ })
57
+ }
58
+
59
+ export function listAllPaneIds(): string[] {
60
+ return tmux('list-panes', '-a', '-F', '#{pane_id}').split('\n')
61
+ }
62
+
63
+ export function applyLayout(windowId: string, layoutStr: string): void {
64
+ const full = `${tmuxChecksum(layoutStr)},${layoutStr}`
65
+ tmux('select-layout', '-t', windowId, full)
66
+ }
67
+
68
+ /** Get the current pane ID (the pane running this process). */
69
+ export function currentPane(): string {
70
+ // TMUX_PANE is set per-pane by tmux — most reliable source
71
+ const envPane = process.env.TMUX_PANE
72
+ if (envPane) {
73
+ // Verify it still exists (can be stale in iTerm2 tmux -CC)
74
+ try {
75
+ const allPanes = tmux('list-panes', '-a', '-F', '#{pane_id}').split('\n')
76
+ if (allPanes.includes(envPane)) return envPane
77
+ } catch {}
78
+ }
79
+ // Fallback — may not work in control mode but worth trying
80
+ return tmux('display-message', '-p', '#{pane_id}')
81
+ }
82
+
83
+ /** Get the window ID for a given pane. */
84
+ export function paneWindow(paneId: string): string {
85
+ // display-message can return empty in tmux control mode (tmux -CC / VS Code)
86
+ const result = tmux('display-message', '-t', paneId, '-p', '#{window_id}')
87
+ if (result) return result
88
+
89
+ // Fallback: search all panes
90
+ const lines = tmux('list-panes', '-a', '-F', '#{pane_id} #{window_id}').split('\n')
91
+ for (const line of lines) {
92
+ const [pid, wid] = line.split(' ')
93
+ if (pid === paneId) return wid
94
+ }
95
+ throw new Error(`Cannot find window for pane ${paneId}`)
96
+ }
97
+
98
+ /** Split a pane and return the new pane ID. */
99
+ export function splitPane(targetPane: string, { horizontal = false } = {}): string {
100
+ const flag = horizontal ? '-v' : '-h'
101
+ return tmux('split-window', flag, '-t', targetPane, '-P', '-F', '#{pane_id}')
102
+ }
103
+
104
+ /** Send keys to a pane (runs a command). */
105
+ export function sendKeys(paneId: string, text: string): void {
106
+ tmux('send-keys', '-t', paneId, text, 'Enter')
107
+ }
108
+
109
+ /** Kill a specific pane. */
110
+ export function killPane(paneId: string): void {
111
+ try {
112
+ tmux('kill-pane', '-t', paneId)
113
+ } catch {
114
+ // pane may already be dead
115
+ }
116
+ }
117
+
118
+ /** Select (focus) a pane. */
119
+ export function selectPane(paneId: string): void {
120
+ tmux('select-pane', '-t', paneId)
121
+ }
122
+
123
+ export function listPaneDetails(windowId: string): PaneDetails[] {
124
+ return tmux('list-panes', '-t', windowId, '-F', '#{pane_id} #{pane_index} #{pane_width} #{pane_height} #{pane_left} #{pane_top} #{pane_pid}')
125
+ .split('\n')
126
+ .map((l) => {
127
+ const [id, index, width, height, left, top, pid] = l.split(' ')
128
+ return { id, index: Number(index), width: Number(width), height: Number(height), left: Number(left), top: Number(top), pid: Number(pid) }
129
+ })
130
+ }
131
+
132
+ export function swapPanes(a: string, b: string): void {
133
+ tmux('swap-pane', '-d', '-s', a, '-t', b)
134
+ }
135
+
136
+ /** Apply a grid layout: lead pane on one side, workers in a grid on the other. */
137
+ export function applyGrid(windowId: string, leadPaneId: string, workerPaneIds: string[], conf: LayoutConf): void {
138
+ const { w: W, h: H } = getWindowDimensions(windowId)
139
+ const leadId = leadPaneId.replace('%', '')
140
+ const workerIds = workerPaneIds.map((p) => p.replace('%', ''))
141
+ const layoutStr = buildLayout(W, H, leadId, workerIds, conf)
142
+ applyLayout(windowId, layoutStr)
143
+
144
+ // Fix pane assignment for right/bottom — tmux assigns by window order, not layout IDs
145
+ const pos = conf.lead.position
146
+ if (pos === 'right' || pos === 'bottom') {
147
+ const posKey = pos === 'right' ? 'pane_left' : 'pane_top'
148
+ const afterInfo = tmux('list-panes', '-t', windowId, '-F', `#{pane_id} #{${posKey}}`)
149
+ .split('\n')
150
+ .map((l) => { const [id, v] = l.split(' '); return { id, pos: Number(v) } })
151
+
152
+ const leadAfter = afterInfo.find((p) => p.id === leadPaneId)
153
+ const maxPos = Math.max(...afterInfo.map((p) => p.pos))
154
+ if (leadAfter && leadAfter.pos !== maxPos) {
155
+ const paneAtLeadPos = afterInfo.find((p) => p.pos === maxPos)
156
+ if (paneAtLeadPos) {
157
+ swapPanes(leadPaneId, paneAtLeadPos.id)
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ // Re-export layout config type for callers
164
+ export interface LayoutConf {
165
+ lead: { position: 'left' | 'right' | 'top' | 'bottom'; size: number }
166
+ grid: { fill: 'row' | 'column'; maxCols: number | null; maxRows: number | null }
167
+ }