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,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
+ }