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.
- package/README.md +84 -0
- package/package.json +33 -0
- package/skills/cru/SKILL.md +54 -0
- package/src/cli.ts +49 -0
- package/src/commands/clean.ts +78 -0
- package/src/commands/config.ts +11 -0
- package/src/commands/doctor.ts +82 -0
- package/src/commands/init.ts +35 -0
- package/src/commands/logs.ts +302 -0
- package/src/commands/panes.ts +376 -0
- package/src/commands/tasks.ts +110 -0
- package/src/commands/teams.ts +102 -0
- package/src/lib/config.ts +61 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ghostty.ts +153 -0
- package/src/lib/layout.ts +162 -0
- package/src/lib/mirror.ts +149 -0
- package/src/lib/panes.ts +62 -0
- package/src/lib/paths.ts +6 -0
- package/src/lib/preflight.ts +94 -0
- package/src/lib/teams.ts +57 -0
- package/src/lib/tmux.ts +167 -0
|
@@ -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
|
+
}
|
package/src/lib/panes.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/teams.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/tmux.ts
ADDED
|
@@ -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
|
+
}
|