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,302 @@
|
|
|
1
|
+
import { z } from 'incur'
|
|
2
|
+
import { readTeamConfig, listTeams } from '@/lib/teams'
|
|
3
|
+
import { loadPanes } from '@/lib/panes'
|
|
4
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
5
|
+
import { join, basename } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
|
|
8
|
+
const COLORS: Record<string, string> = {
|
|
9
|
+
blue: '\x1b[34m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
magenta: '\x1b[35m',
|
|
14
|
+
purple: '\x1b[35m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
}
|
|
17
|
+
const AUTO_COLORS = ['cyan', 'blue', 'green', 'yellow', 'magenta', 'red']
|
|
18
|
+
const DIM = '\x1b[2m'
|
|
19
|
+
const BOLD = '\x1b[1m'
|
|
20
|
+
const RESET = '\x1b[0m'
|
|
21
|
+
|
|
22
|
+
type MsgKind = 'chat' | 'task' | 'idle' | 'shutdown'
|
|
23
|
+
|
|
24
|
+
interface Event {
|
|
25
|
+
ts: number
|
|
26
|
+
type: 'created' | 'joined' | 'message'
|
|
27
|
+
kind?: MsgKind
|
|
28
|
+
team?: string
|
|
29
|
+
agent?: string
|
|
30
|
+
color?: string
|
|
31
|
+
from?: string
|
|
32
|
+
to?: string
|
|
33
|
+
text?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readInboxes(teamName: string): Event[] {
|
|
37
|
+
const inboxDir = join(homedir(), '.claude', 'teams', teamName, 'inboxes')
|
|
38
|
+
if (!existsSync(inboxDir)) return []
|
|
39
|
+
|
|
40
|
+
const events: Event[] = []
|
|
41
|
+
for (const file of readdirSync(inboxDir)) {
|
|
42
|
+
if (!file.endsWith('.json')) continue
|
|
43
|
+
const to = basename(file, '.json')
|
|
44
|
+
try {
|
|
45
|
+
const messages = JSON.parse(readFileSync(join(inboxDir, file), 'utf-8'))
|
|
46
|
+
for (const msg of messages) {
|
|
47
|
+
let text = msg.summary || msg.text
|
|
48
|
+
let kind: MsgKind = 'chat'
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(msg.text)
|
|
51
|
+
switch (parsed.type) {
|
|
52
|
+
case 'task_assignment':
|
|
53
|
+
text = parsed.subject || parsed.description || 'assigned'
|
|
54
|
+
kind = 'task'
|
|
55
|
+
break
|
|
56
|
+
case 'shutdown_request':
|
|
57
|
+
text = parsed.reason || 'requested'
|
|
58
|
+
kind = 'shutdown'
|
|
59
|
+
break
|
|
60
|
+
case 'shutdown_approved':
|
|
61
|
+
text = 'approved'
|
|
62
|
+
kind = 'shutdown'
|
|
63
|
+
break
|
|
64
|
+
case 'idle_notification':
|
|
65
|
+
kind = 'idle'
|
|
66
|
+
text = parsed.idleReason || 'available'
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
|
|
71
|
+
events.push({
|
|
72
|
+
ts: new Date(msg.timestamp).getTime(),
|
|
73
|
+
type: 'message',
|
|
74
|
+
kind,
|
|
75
|
+
from: msg.from,
|
|
76
|
+
to,
|
|
77
|
+
text,
|
|
78
|
+
color: msg.color,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
return events
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function teamEvents(teamName: string): { events: Event[]; colorMap: Record<string, string> } {
|
|
87
|
+
const config = readTeamConfig(teamName)
|
|
88
|
+
const colorMap: Record<string, string> = {}
|
|
89
|
+
|
|
90
|
+
// 1. Seed from cru-panes.json (most reliable — has colors from spawn time)
|
|
91
|
+
const cruPanes = loadPanes(teamName)
|
|
92
|
+
if (cruPanes) {
|
|
93
|
+
for (const w of cruPanes.workers) {
|
|
94
|
+
if (w.color && COLORS[w.color]) colorMap[w.name] = w.color
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Layer on Claude config members (may have colors for registered workers)
|
|
99
|
+
for (const m of config.members) {
|
|
100
|
+
if (!colorMap[m.name] && m.color && COLORS[m.color]) colorMap[m.name] = m.color
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const events: Event[] = []
|
|
104
|
+
|
|
105
|
+
events.push({ ts: config.createdAt, type: 'created', team: teamName })
|
|
106
|
+
|
|
107
|
+
for (const m of config.members) {
|
|
108
|
+
if (m.agentType === 'team-lead') continue
|
|
109
|
+
events.push({ ts: m.joinedAt, type: 'joined', team: teamName, agent: m.name, color: m.color })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const msgEvents = readInboxes(teamName)
|
|
113
|
+
// Build colorMap from inbox message colors (workers may not be in config yet)
|
|
114
|
+
for (const e of msgEvents) {
|
|
115
|
+
e.team = teamName
|
|
116
|
+
if (e.color && e.from && !colorMap[e.from]) {
|
|
117
|
+
colorMap[e.from] = e.color
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
events.push(...msgEvents)
|
|
121
|
+
|
|
122
|
+
return { events, colorMap }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function truncate(s: string, n: number): string {
|
|
126
|
+
if (!s) return ''
|
|
127
|
+
const oneline = s.replace(/\n/g, ' ').trim()
|
|
128
|
+
return oneline.length > n ? `${oneline.slice(0, n)}…` : oneline
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function time(ts: number): string {
|
|
132
|
+
const d = new Date(ts)
|
|
133
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hashColor(name: string): string {
|
|
137
|
+
let h = 0
|
|
138
|
+
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
|
|
139
|
+
return AUTO_COLORS[Math.abs(h) % AUTO_COLORS.length]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function colorize(name: string, color: string | undefined, colorMap?: Record<string, string>): string {
|
|
143
|
+
if (!color && colorMap) {
|
|
144
|
+
if (!colorMap[name]) colorMap[name] = hashColor(name)
|
|
145
|
+
color = colorMap[name]
|
|
146
|
+
}
|
|
147
|
+
if (name === 'team-lead') return `${BOLD}${name}${RESET}`
|
|
148
|
+
const c = color && COLORS[color]
|
|
149
|
+
return c ? `${c}${name}${RESET}` : name
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatEvents(
|
|
153
|
+
events: Event[],
|
|
154
|
+
colorMap: Record<string, string>,
|
|
155
|
+
opts: { full?: boolean; showTeam?: boolean },
|
|
156
|
+
): string {
|
|
157
|
+
const lines: string[] = []
|
|
158
|
+
|
|
159
|
+
for (const e of events) {
|
|
160
|
+
const t = `${DIM}${time(e.ts)}${RESET}`
|
|
161
|
+
const prefix = opts.showTeam ? `${DIM}${e.team}${RESET} ` : ''
|
|
162
|
+
switch (e.type) {
|
|
163
|
+
case 'created':
|
|
164
|
+
lines.push(`${t} ${prefix}${DIM}◉ team created${RESET}`)
|
|
165
|
+
break
|
|
166
|
+
case 'joined':
|
|
167
|
+
lines.push(`${t} ${prefix}${colorize('⊕', e.color, colorMap)} ${colorize(e.agent!, e.color, colorMap)} joined`)
|
|
168
|
+
break
|
|
169
|
+
case 'message': {
|
|
170
|
+
const from = colorize(e.from!, colorMap[e.from!], colorMap)
|
|
171
|
+
const to = colorize(e.to!, colorMap[e.to!], colorMap)
|
|
172
|
+
const text = opts.full ? e.text : truncate(e.text || '', 80)
|
|
173
|
+
switch (e.kind) {
|
|
174
|
+
case 'task':
|
|
175
|
+
lines.push(`${t} ${prefix}${BOLD}◉${RESET} ${from} → ${to}: ${text}`)
|
|
176
|
+
break
|
|
177
|
+
case 'idle':
|
|
178
|
+
lines.push(`${t} ${prefix}${DIM}○${RESET} ${from} ${DIM}idle${RESET}`)
|
|
179
|
+
break
|
|
180
|
+
case 'shutdown':
|
|
181
|
+
lines.push(`${t} ${prefix}${DIM}⊘${RESET} ${from} → ${to}${DIM}: ${text}${RESET}`)
|
|
182
|
+
break
|
|
183
|
+
default:
|
|
184
|
+
lines.push(`${t} ${prefix}● ${from} → ${to}: ${text}`)
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lines.join('\n')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const logs = {
|
|
196
|
+
description: 'Show team activity log',
|
|
197
|
+
args: z.object({
|
|
198
|
+
team: z.string().optional().describe('Team name (omit to show all teams)'),
|
|
199
|
+
}),
|
|
200
|
+
options: z.object({
|
|
201
|
+
full: z.boolean().default(false).describe('Show full message text'),
|
|
202
|
+
last: z.coerce.number().optional().describe('Show only the last N events'),
|
|
203
|
+
follow: z.boolean().default(false).describe('Follow live events'),
|
|
204
|
+
}),
|
|
205
|
+
alias: { follow: 'f' },
|
|
206
|
+
async run(c) {
|
|
207
|
+
const fixedTeam = c.args.team
|
|
208
|
+
const getTeams = () => fixedTeam ? [fixedTeam] : listTeams()
|
|
209
|
+
|
|
210
|
+
const teamNames = getTeams()
|
|
211
|
+
if (teamNames.length === 0) return { events: [], message: 'No teams found' }
|
|
212
|
+
|
|
213
|
+
const mergedColorMap: Record<string, string> = {}
|
|
214
|
+
|
|
215
|
+
const collectEvents = () => {
|
|
216
|
+
const names = getTeams()
|
|
217
|
+
const events: Event[] = []
|
|
218
|
+
for (const name of names) {
|
|
219
|
+
try {
|
|
220
|
+
const result = teamEvents(name)
|
|
221
|
+
events.push(...result.events)
|
|
222
|
+
Object.assign(mergedColorMap, result.colorMap)
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
events.sort((a, b) => a.ts - b.ts)
|
|
226
|
+
return { events, teams: names }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { events: allEvents } = collectEvents()
|
|
230
|
+
|
|
231
|
+
const limit = c.options.last ?? (fixedTeam ? undefined : 50)
|
|
232
|
+
const display = limit ? allEvents.slice(-limit) : allEvents
|
|
233
|
+
|
|
234
|
+
if (c.agent) {
|
|
235
|
+
return {
|
|
236
|
+
teams: teamNames,
|
|
237
|
+
events: display.map((e) => ({
|
|
238
|
+
time: new Date(e.ts).toISOString(),
|
|
239
|
+
type: e.type,
|
|
240
|
+
...(e.team ? { team: e.team } : {}),
|
|
241
|
+
...(e.agent ? { agent: e.agent } : {}),
|
|
242
|
+
...(e.from ? { from: e.from, to: e.to } : {}),
|
|
243
|
+
...(e.text ? { text: e.text } : {}),
|
|
244
|
+
})),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const showTeam = !fixedTeam || teamNames.length > 1
|
|
249
|
+
if (!showTeam) {
|
|
250
|
+
const created = new Date(allEvents[0]?.ts || Date.now())
|
|
251
|
+
const date = created.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
252
|
+
console.log(`\n${BOLD}${teamNames[0]}${RESET} ${DIM}— ${date}${RESET}\n`)
|
|
253
|
+
} else {
|
|
254
|
+
console.log()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fmtOpts = { full: c.options.full, showTeam }
|
|
258
|
+
console.log(formatEvents(display, mergedColorMap, fmtOpts))
|
|
259
|
+
|
|
260
|
+
if (!c.options.follow) {
|
|
261
|
+
console.log(`\n${DIM}Run with -f to follow live${RESET}\n`)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Follow mode: poll for new events every 2s
|
|
266
|
+
console.log(`${DIM}following — c: clear, q: quit${RESET}\n`)
|
|
267
|
+
|
|
268
|
+
let lastTs = allEvents.length > 0 ? allEvents[allEvents.length - 1].ts : 0
|
|
269
|
+
const poll = () => {
|
|
270
|
+
const { events: fresh } = collectEvents()
|
|
271
|
+
|
|
272
|
+
const newEvents = fresh.filter((e) => e.ts > lastTs)
|
|
273
|
+
if (newEvents.length > 0) {
|
|
274
|
+
process.stdout.write(formatEvents(newEvents, mergedColorMap, fmtOpts) + '\n')
|
|
275
|
+
lastTs = newEvents[newEvents.length - 1].ts
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const interval = setInterval(poll, 2000)
|
|
280
|
+
const quit = () => {
|
|
281
|
+
clearInterval(interval)
|
|
282
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
283
|
+
console.log(`\n${DIM}stopped${RESET}`)
|
|
284
|
+
process.exit(0)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Handle keyboard input
|
|
288
|
+
if (process.stdin.isTTY) {
|
|
289
|
+
process.stdin.setRawMode(true)
|
|
290
|
+
process.stdin.resume()
|
|
291
|
+
process.stdin.on('data', (key) => {
|
|
292
|
+
if (key[0] === 3 || key[0] === 0x71) quit() // Ctrl+C or q
|
|
293
|
+
if (key[0] === 0x63 || key[0] === 12) { // c or Ctrl+L
|
|
294
|
+
process.stdout.write('\x1b[2J\x1b[H') // clear screen
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
process.on('SIGINT', quit)
|
|
300
|
+
await new Promise(() => {})
|
|
301
|
+
},
|
|
302
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { z } from 'incur'
|
|
2
|
+
import { loadConfig } from '@/lib/config'
|
|
3
|
+
import { readTeamConfig, findTeamWindow, findTeamForCurrentWindow } from '@/lib/teams'
|
|
4
|
+
import {
|
|
5
|
+
currentPane, paneWindow, getWindowDimensions, listWindowPanes,
|
|
6
|
+
killPane, applyGrid, listPaneDetails,
|
|
7
|
+
} from '@/lib/tmux'
|
|
8
|
+
import { computeGrid } from '@/lib/layout'
|
|
9
|
+
import { loadPanes, savePanes } from '@/lib/panes'
|
|
10
|
+
import { inGhostty } from '@/lib/env'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ghostty+tmux mirror flow:
|
|
14
|
+
* Workers live in a headless tmux with a CUSTOM SOCKET (tmux -L claude-swarm-*).
|
|
15
|
+
* We find the socket, discover worker panes, break each into its own tmux window,
|
|
16
|
+
* create Ghostty splits, and attach each to a session-group view.
|
|
17
|
+
*
|
|
18
|
+
* Uses INCREMENTAL mirroring: opens each pane as soon as the worker spawns,
|
|
19
|
+
* without waiting for all workers to be ready.
|
|
20
|
+
*/
|
|
21
|
+
function runGridGhostty(c) {
|
|
22
|
+
const {
|
|
23
|
+
findBestSwarm,
|
|
24
|
+
getWorkerPanes,
|
|
25
|
+
mirrorSingleWorker,
|
|
26
|
+
setRemainOnExit,
|
|
27
|
+
} = require('@/lib/mirror')
|
|
28
|
+
const {
|
|
29
|
+
currentTerminal,
|
|
30
|
+
focusTerminal,
|
|
31
|
+
} = require('@/lib/ghostty')
|
|
32
|
+
|
|
33
|
+
const teamName = c.args.team
|
|
34
|
+
const expectedWorkers = c.options.expect
|
|
35
|
+
const deadline = Date.now() + 60_000
|
|
36
|
+
|
|
37
|
+
// 1. Find the swarm socket
|
|
38
|
+
// Only poll if --expect was passed (the skill sets this when workers are spawning).
|
|
39
|
+
// Otherwise check once and fail fast — the user called this directly.
|
|
40
|
+
let swarm: { socket: string; session: string } | null = null
|
|
41
|
+
if (expectedWorkers) {
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
swarm = findBestSwarm(expectedWorkers)
|
|
44
|
+
if (swarm) break
|
|
45
|
+
Bun.sleepSync(200)
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
swarm = findBestSwarm()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!swarm) {
|
|
52
|
+
return c.error({
|
|
53
|
+
code: 'NO_SWARM',
|
|
54
|
+
message: 'No claude-swarm tmux session found. Is a Claude Code team running?',
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(` [grid] found swarm: ${swarm.socket}/${swarm.session}`)
|
|
59
|
+
|
|
60
|
+
// Keep dead panes visible after worker exits
|
|
61
|
+
setRemainOnExit(swarm.socket)
|
|
62
|
+
|
|
63
|
+
// 2. Incrementally mirror workers as they appear
|
|
64
|
+
const leadTerminalId = currentTerminal()
|
|
65
|
+
const mirrored = new Map<string, { ghosttyTerminal: string; viewSession: string }>()
|
|
66
|
+
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
const allWorkers = getWorkerPanes(swarm.socket)
|
|
69
|
+
const newWorkers = allWorkers.filter(p => !mirrored.has(p))
|
|
70
|
+
|
|
71
|
+
for (const paneId of newWorkers) {
|
|
72
|
+
const idx = mirrored.size
|
|
73
|
+
const splitDir: 'right' | 'down' = idx === 0 ? 'right' : 'down'
|
|
74
|
+
const splitTarget = idx === 0
|
|
75
|
+
? leadTerminalId
|
|
76
|
+
: [...mirrored.values()].pop()!.ghosttyTerminal
|
|
77
|
+
|
|
78
|
+
const result = mirrorSingleWorker(
|
|
79
|
+
swarm.socket, swarm.session,
|
|
80
|
+
paneId, idx, splitTarget, splitDir,
|
|
81
|
+
)
|
|
82
|
+
mirrored.set(paneId, result)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// All expected workers mirrored
|
|
86
|
+
if (expectedWorkers && mirrored.size >= expectedWorkers) break
|
|
87
|
+
|
|
88
|
+
// No expected count — wait for workers to stop appearing
|
|
89
|
+
if (!expectedWorkers && mirrored.size > 0 && newWorkers.length === 0) {
|
|
90
|
+
Bun.sleepSync(3000) // give stragglers 3s
|
|
91
|
+
const finalWorkers = getWorkerPanes(swarm.socket)
|
|
92
|
+
if (finalWorkers.filter(p => !mirrored.has(p)).length === 0) break
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Bun.sleepSync(200)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (mirrored.size === 0) {
|
|
100
|
+
return c.error({
|
|
101
|
+
code: 'NO_WORKERS',
|
|
102
|
+
message: 'No worker panes found in swarm.',
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Focus lead
|
|
107
|
+
focusTerminal(leadTerminalId)
|
|
108
|
+
|
|
109
|
+
// 4. Save pane tracking
|
|
110
|
+
if (teamName) {
|
|
111
|
+
const mirrors = [...mirrored.entries()]
|
|
112
|
+
savePanes(teamName, {
|
|
113
|
+
leadPane: leadTerminalId,
|
|
114
|
+
windowId: leadTerminalId, // Ghostty doesn't have tmux-style window IDs; use lead terminal as key
|
|
115
|
+
backend: 'ghostty',
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
workers: mirrors.map(([, m], i) => ({
|
|
118
|
+
name: `worker-${i + 1}`,
|
|
119
|
+
paneId: m.ghosttyTerminal,
|
|
120
|
+
color: ['green', 'blue', 'yellow', 'magenta', 'cyan', 'red'][i % 6],
|
|
121
|
+
})),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
applied: true,
|
|
127
|
+
backend: 'ghostty+tmux',
|
|
128
|
+
swarm: `${swarm.socket}/${swarm.session}`,
|
|
129
|
+
workers: mirrored.size,
|
|
130
|
+
mirrors: [...mirrored.entries()].map(([paneId, m]) => ({
|
|
131
|
+
tmux: paneId,
|
|
132
|
+
ghostty: m.ghosttyTerminal,
|
|
133
|
+
view: m.viewSession,
|
|
134
|
+
})),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runGrid(c) {
|
|
139
|
+
// Ghostty: mirror tmux panes into native splits
|
|
140
|
+
if (inGhostty()) {
|
|
141
|
+
return runGridGhostty(c)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const conf = loadConfig()
|
|
145
|
+
|
|
146
|
+
if (c.options['lead-size'] != null) conf.layout.lead.size = c.options['lead-size']
|
|
147
|
+
if (c.options['lead-position']) conf.layout.lead.position = c.options['lead-position']
|
|
148
|
+
if (c.options.fill) conf.layout.grid.fill = c.options.fill
|
|
149
|
+
if (c.options['max-cols'] != null) conf.layout.grid.maxCols = c.options['max-cols']
|
|
150
|
+
if (c.options['max-rows'] != null) conf.layout.grid.maxRows = c.options['max-rows']
|
|
151
|
+
|
|
152
|
+
const teamName = c.args.team
|
|
153
|
+
let windowId: string | null = null
|
|
154
|
+
let leadPaneId: string | null = null
|
|
155
|
+
let workerPaneIds: Set<string> | null = null
|
|
156
|
+
let cruPanesData: ReturnType<typeof loadPanes> = null
|
|
157
|
+
|
|
158
|
+
if (teamName) {
|
|
159
|
+
cruPanesData = loadPanes(teamName)
|
|
160
|
+
if (cruPanesData) {
|
|
161
|
+
windowId = cruPanesData.windowId
|
|
162
|
+
workerPaneIds = new Set(cruPanesData.workers.map((w) => w.paneId))
|
|
163
|
+
} else {
|
|
164
|
+
windowId = findTeamWindow(teamName)
|
|
165
|
+
const teamConf = readTeamConfig(teamName)
|
|
166
|
+
workerPaneIds = new Set(
|
|
167
|
+
teamConf.members.filter((m) => m.tmuxPaneId).map((m) => m.tmuxPaneId),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
leadPaneId = currentPane()
|
|
172
|
+
windowId = paneWindow(leadPaneId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!windowId) return c.error({ code: 'NO_WINDOW', message: 'Could not find terminal window' })
|
|
176
|
+
|
|
177
|
+
if (c.options.expect) {
|
|
178
|
+
const expectedTotal = c.options.expect + 1
|
|
179
|
+
const deadline = Date.now() + 30_000
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
if (listWindowPanes(windowId).length >= expectedTotal) break
|
|
182
|
+
Bun.sleepSync(500)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { w: W, h: H } = getWindowDimensions(windowId)
|
|
187
|
+
const panes = listWindowPanes(windowId)
|
|
188
|
+
|
|
189
|
+
if (panes.length < 2) {
|
|
190
|
+
return c.error({ code: 'NO_WORKERS', message: 'Only one pane in window — nothing to arrange' })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let leadPane, workerPanes
|
|
194
|
+
if (workerPaneIds) {
|
|
195
|
+
leadPane = panes.find((p) => !workerPaneIds.has(p.id))
|
|
196
|
+
workerPanes = panes.filter((p) => workerPaneIds.has(p.id))
|
|
197
|
+
} else {
|
|
198
|
+
leadPane = panes.find((p) => p.id === leadPaneId)
|
|
199
|
+
workerPanes = panes.filter((p) => p.id !== leadPaneId)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!leadPane) return c.error({ code: 'NO_LEAD', message: 'Could not identify lead pane' })
|
|
203
|
+
if (workerPanes.length === 0) return c.error({ code: 'NO_WORKERS', message: 'No worker panes found' })
|
|
204
|
+
|
|
205
|
+
applyGrid(windowId, leadPane.id, workerPanes.map((p) => p.id), conf.layout)
|
|
206
|
+
|
|
207
|
+
const N = workerPanes.length
|
|
208
|
+
const { cols, rows } = computeGrid(N, conf.layout)
|
|
209
|
+
const grid = []
|
|
210
|
+
let idx = 0
|
|
211
|
+
for (let r = 0; r < rows; r++) {
|
|
212
|
+
const row = []
|
|
213
|
+
for (let col = 0; col < cols; col++) {
|
|
214
|
+
if (idx < N) {
|
|
215
|
+
const cruWorker = cruPanesData?.workers[idx]
|
|
216
|
+
row.push(cruWorker?.name || `pane-${idx + 1}`)
|
|
217
|
+
idx++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
grid.push(row)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
applied: true,
|
|
225
|
+
window: windowId,
|
|
226
|
+
dimensions: `${W}x${H}`,
|
|
227
|
+
lead: { position: conf.layout.lead.position, size: `${conf.layout.lead.size}%` },
|
|
228
|
+
workers: N,
|
|
229
|
+
grid_size: `${rows}x${cols}`,
|
|
230
|
+
grid,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runClose(c) {
|
|
235
|
+
let teamName = c.args.team
|
|
236
|
+
if (!teamName && !inGhostty()) {
|
|
237
|
+
try { teamName = findTeamForCurrentWindow() } catch {}
|
|
238
|
+
}
|
|
239
|
+
if (!teamName) return c.error({ code: 'NO_TEAM', message: 'No team specified and none found in current window' })
|
|
240
|
+
const closed: Array<{ name: string; pane: string }> = []
|
|
241
|
+
|
|
242
|
+
const cruPanes = loadPanes(teamName)
|
|
243
|
+
if (cruPanes && cruPanes.workers.length > 0) {
|
|
244
|
+
// Use the right kill method based on how panes were tracked
|
|
245
|
+
const killFn = cruPanes.backend === 'ghostty'
|
|
246
|
+
? (id: string) => { try { require('@/lib/ghostty').closeTerminal(id) } catch (e) { console.warn(`[close] failed to close Ghostty terminal ${id}: ${e}`) } }
|
|
247
|
+
: (id: string) => killPane(id)
|
|
248
|
+
|
|
249
|
+
for (const w of cruPanes.workers) {
|
|
250
|
+
killFn(w.paneId)
|
|
251
|
+
closed.push({ name: w.name, pane: w.paneId })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// If mirrored, also clean up tmux view sessions on the custom socket
|
|
255
|
+
if (cruPanes.backend === 'ghostty') {
|
|
256
|
+
try {
|
|
257
|
+
const { findSwarmSockets } = require('@/lib/mirror')
|
|
258
|
+
const { execFileSync } = require('node:child_process')
|
|
259
|
+
for (const socket of findSwarmSockets()) {
|
|
260
|
+
try {
|
|
261
|
+
const sessions = execFileSync(
|
|
262
|
+
'tmux', ['-L', socket, 'list-sessions', '-F', '#{session_name}'],
|
|
263
|
+
{ encoding: 'utf-8', timeout: 3000 },
|
|
264
|
+
).trim().split('\n')
|
|
265
|
+
for (const name of sessions) {
|
|
266
|
+
if (name.startsWith('view-')) {
|
|
267
|
+
try { execFileSync('tmux', ['-L', socket, 'kill-session', '-t', name], { timeout: 3000 }) } catch {}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
try {
|
|
276
|
+
const config = readTeamConfig(teamName)
|
|
277
|
+
const workers = config.members.filter((m) => m.tmuxPaneId)
|
|
278
|
+
for (const member of workers) {
|
|
279
|
+
killPane(member.tmuxPaneId)
|
|
280
|
+
closed.push({ name: member.name, pane: member.tmuxPaneId })
|
|
281
|
+
}
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (closed.length === 0 && inGhostty()) {
|
|
286
|
+
// Fallback: close all non-lead Ghostty terminals
|
|
287
|
+
// Uses process-tree-based currentTerminal() — works regardless of focus
|
|
288
|
+
try {
|
|
289
|
+
const { currentTerminal, listAllTerminals, closeTerminal } = require('@/lib/ghostty')
|
|
290
|
+
const lead = currentTerminal()
|
|
291
|
+
const allTerminals = listAllTerminals()
|
|
292
|
+
for (const id of allTerminals) {
|
|
293
|
+
if (id !== lead) {
|
|
294
|
+
closeTerminal(id)
|
|
295
|
+
closed.push({ name: `pane-${closed.length + 1}`, pane: id })
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch {}
|
|
299
|
+
} else if (closed.length === 0) {
|
|
300
|
+
try {
|
|
301
|
+
const lead = currentPane()
|
|
302
|
+
const windowId = paneWindow(lead)
|
|
303
|
+
const panes = listWindowPanes(windowId)
|
|
304
|
+
for (const p of panes) {
|
|
305
|
+
if (p.id !== lead) {
|
|
306
|
+
killPane(p.id)
|
|
307
|
+
closed.push({ name: `pane-${closed.length + 1}`, pane: p.id })
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { team: teamName, closed: closed.length, panes: closed }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function runList(c) {
|
|
317
|
+
if (inGhostty()) {
|
|
318
|
+
const { ghostty, currentTerminal, listAllTerminals } = require('@/lib/ghostty')
|
|
319
|
+
const allIds = listAllTerminals()
|
|
320
|
+
// Get working directories for all terminals
|
|
321
|
+
let dirs: string[] = []
|
|
322
|
+
try {
|
|
323
|
+
const raw = ghostty('get working directory of every terminal')
|
|
324
|
+
dirs = raw.split(', ')
|
|
325
|
+
} catch {}
|
|
326
|
+
let currentId: string | null = null
|
|
327
|
+
try { currentId = currentTerminal() } catch {}
|
|
328
|
+
const panes = allIds.map((id: string, i: number) => ({
|
|
329
|
+
id,
|
|
330
|
+
index: i,
|
|
331
|
+
cwd: dirs[i] || '',
|
|
332
|
+
current: id === currentId,
|
|
333
|
+
}))
|
|
334
|
+
return { backend: 'ghostty', panes }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const teamName = c.args.team
|
|
338
|
+
let windowId: string | null = null
|
|
339
|
+
|
|
340
|
+
if (teamName) {
|
|
341
|
+
windowId = findTeamWindow(teamName)
|
|
342
|
+
} else {
|
|
343
|
+
try {
|
|
344
|
+
const lead = currentPane()
|
|
345
|
+
windowId = paneWindow(lead)
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!windowId) return c.error({ code: 'NO_WINDOW', message: 'Could not find terminal window' })
|
|
350
|
+
|
|
351
|
+
const info = listPaneDetails(windowId)
|
|
352
|
+
return { window: windowId, panes: info }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const panes = {
|
|
356
|
+
description: 'Manage terminal panes (list, grid layout, close)',
|
|
357
|
+
args: z.object({
|
|
358
|
+
action: z.enum(['list', 'grid', 'close']).default('list').describe('Action: list, grid, or close'),
|
|
359
|
+
team: z.string().optional().describe('Team name (omit to auto-detect)'),
|
|
360
|
+
}),
|
|
361
|
+
options: z.object({
|
|
362
|
+
'lead-size': z.coerce.number().optional().describe('Grid: override lead size (%)'),
|
|
363
|
+
'lead-position': z.enum(['left', 'right', 'top', 'bottom']).optional().describe('Grid: override lead position'),
|
|
364
|
+
fill: z.enum(['row', 'column']).optional().describe('Grid: override fill direction'),
|
|
365
|
+
'max-cols': z.coerce.number().optional().describe('Grid: max columns'),
|
|
366
|
+
'max-rows': z.coerce.number().optional().describe('Grid: max rows'),
|
|
367
|
+
expect: z.coerce.number().optional().describe('Grid: wait for N worker panes before applying'),
|
|
368
|
+
}),
|
|
369
|
+
run(c) {
|
|
370
|
+
switch (c.args.action) {
|
|
371
|
+
case 'grid': return runGrid(c)
|
|
372
|
+
case 'close': return runClose(c)
|
|
373
|
+
case 'list': return runList(c)
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
}
|