cru-teams 1.0.2 → 1.1.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 +1 -0
- package/package.json +2 -1
- package/src/cli.ts +3 -1
- package/src/commands/doctor.ts +17 -1
- package/src/commands/panes.ts +239 -12
- package/src/lib/cmux.ts +258 -0
- package/src/lib/env.ts +5 -0
- package/src/lib/mirror.ts +44 -2
- package/src/lib/panes.ts +14 -1
- package/src/lib/preflight.ts +13 -4
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Agent teams let multiple Claude Code instances work together in parallel — cru
|
|
|
25
25
|
- **Already using tmux?** You're good.
|
|
26
26
|
- **iTerm2?** Use [`tmux -CC`](https://iterm2.com/documentation-tmux-integration.html) for native pane integration.
|
|
27
27
|
- **Ghostty?** Workers still run in tmux (that's how Claude Code spawns agents), but cru mirrors them into native Ghostty splits via [AppleScript](https://ghostty.org/docs/features/applescript) — so you get Ghostty's UI instead of working inside tmux yourself. Requires Ghostty v1.3.0+.
|
|
28
|
+
- **[cmux](https://www.cmux.dev)?** Native support — cru mirrors workers into cmux splits via the cmux CLI socket. Tabs get labeled with ◫ (lead) and ⚡ (workers) for quick identification. Auto-detected when running inside cmux.
|
|
28
29
|
|
|
29
30
|
## Install
|
|
30
31
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cru-teams",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "◫ Manage terminal layouts for Claude Code agent teams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"license": "ISC",
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"ajv": "^8.18.0",
|
|
27
28
|
"incur": "^0.2.2"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -21,9 +21,11 @@ const PREFLIGHT: Record<string, string[]> = {
|
|
|
21
21
|
panes: ['pane-session'],
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const pkg = require('../package.json')
|
|
25
|
+
|
|
24
26
|
Cli.create('cru', {
|
|
25
27
|
description: '◫ Manage pane layouts for Claude Code agent teams',
|
|
26
|
-
version:
|
|
28
|
+
version: pkg.version,
|
|
27
29
|
})
|
|
28
30
|
.use(async (c, next) => {
|
|
29
31
|
const checks = PREFLIGHT[c.command]
|
package/src/commands/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'incur'
|
|
2
|
-
import { hasBinary, getVersion, inTmux, inGhostty, detectTerminal } from '../lib/env'
|
|
2
|
+
import { hasBinary, getVersion, inTmux, inGhostty, inCmux, detectTerminal } from '../lib/env'
|
|
3
3
|
|
|
4
4
|
export const doctor = {
|
|
5
5
|
description: 'Check environment requirements for cru',
|
|
@@ -68,6 +68,22 @@ export const doctor = {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// cmux support
|
|
72
|
+
if (inCmux()) {
|
|
73
|
+
try {
|
|
74
|
+
const { cmuxVersion, isCmuxAvailable } = require('../lib/cmux')
|
|
75
|
+
if (isCmuxAvailable()) {
|
|
76
|
+
const ver = cmuxVersion() || 'unknown'
|
|
77
|
+
checks.push({ name: 'cmux', status: 'ok', detail: `${ver} (socket connected)` })
|
|
78
|
+
checks.push({ name: 'pane-backend', status: 'ok', detail: 'cmux (native CLI)' })
|
|
79
|
+
} else {
|
|
80
|
+
checks.push({ name: 'cmux', status: 'fail', detail: 'socket not responding', fix: 'Ensure cmux is running' })
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
checks.push({ name: 'cmux', status: 'fail', detail: 'cannot connect to cmux' })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
const bunPath = hasBinary('bun')
|
|
72
88
|
if (!bunPath) {
|
|
73
89
|
checks.push({ name: 'bun', status: 'fail', detail: 'not installed', fix: 'curl -fsSL https://bun.sh/install | bash' })
|
package/src/commands/panes.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '../lib/tmux'
|
|
8
8
|
import { computeGrid } from '../lib/layout'
|
|
9
9
|
import { loadPanes, savePanes } from '../lib/panes'
|
|
10
|
-
import { inGhostty } from '../lib/env'
|
|
10
|
+
import { inGhostty, inCmux } from '../lib/env'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Ghostty+tmux mirror flow:
|
|
@@ -135,19 +135,196 @@ function runGridGhostty(c) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
const WORKER_COLORS = ['green', 'blue', 'yellow', 'magenta', 'cyan', 'red']
|
|
139
|
+
|
|
140
|
+
/** Resolve worker names from team config, falling back to "worker-N". */
|
|
141
|
+
function resolveWorkerNames(teamName: string | undefined, count: number): string[] {
|
|
142
|
+
if (teamName) {
|
|
143
|
+
try {
|
|
144
|
+
const config = readTeamConfig(teamName)
|
|
145
|
+
// Filter out the lead (role=lead or name=main/team-lead) — only want workers
|
|
146
|
+
const names = (config.members || [])
|
|
147
|
+
.filter((m: any) => m.role !== 'lead' && m.name !== 'main' && m.name !== 'team-lead')
|
|
148
|
+
.map((m: any) => m.name)
|
|
149
|
+
if (names.length >= count) return names.slice(0, count)
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
return Array.from({ length: count }, (_, i) => `worker-${i + 1}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Apply config overrides from CLI options. */
|
|
156
|
+
function applyConfigOverrides(conf: ReturnType<typeof loadConfig>, options: any): void {
|
|
157
|
+
if (options['lead-size'] != null) conf.layout.lead.size = options['lead-size']
|
|
158
|
+
if (options['lead-position']) conf.layout.lead.position = options['lead-position']
|
|
159
|
+
if (options.fill) conf.layout.grid.fill = options.fill
|
|
160
|
+
if (options['max-cols'] != null) conf.layout.grid.maxCols = options['max-cols']
|
|
161
|
+
if (options['max-rows'] != null) conf.layout.grid.maxRows = options['max-rows']
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* cmux grid: mirror headless tmux workers into cmux splits.
|
|
166
|
+
*
|
|
167
|
+
* Same approach as Ghostty: workers spawn in `tmux -L claude-swarm-*`
|
|
168
|
+
* sessions. We find the swarm, break each pane into its own tmux window,
|
|
169
|
+
* create cmux splits, and attach each to a view session.
|
|
170
|
+
*/
|
|
171
|
+
function runGridCmux(c) {
|
|
172
|
+
const {
|
|
173
|
+
findBestSwarm,
|
|
174
|
+
getWorkerPanes,
|
|
175
|
+
mirrorWorkerToCmux,
|
|
176
|
+
setRemainOnExit,
|
|
177
|
+
} = require('../lib/mirror')
|
|
178
|
+
const {
|
|
179
|
+
currentSurface,
|
|
180
|
+
focusSurface,
|
|
181
|
+
closeSurface,
|
|
182
|
+
renameSurface,
|
|
183
|
+
getSurfaceTitle,
|
|
184
|
+
} = require('../lib/cmux') as typeof import('../lib/cmux')
|
|
185
|
+
|
|
186
|
+
const teamName = c.args.team
|
|
187
|
+
const expectedWorkers = c.options.expect
|
|
188
|
+
const leadSurface = currentSurface()
|
|
189
|
+
const deadline = Date.now() + 60_000
|
|
190
|
+
|
|
191
|
+
// Close previously-tracked workers (never arbitrary surfaces)
|
|
192
|
+
if (teamName) {
|
|
193
|
+
const existing = loadPanes(teamName)
|
|
194
|
+
if (existing?.backend === 'cmux') {
|
|
195
|
+
for (const w of existing.workers) closeSurface(w.paneId)
|
|
196
|
+
Bun.sleepSync(300)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 1. Find the swarm socket
|
|
201
|
+
let swarm: { socket: string; session: string } | null = null
|
|
202
|
+
if (expectedWorkers) {
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
swarm = findBestSwarm(expectedWorkers)
|
|
205
|
+
if (swarm) break
|
|
206
|
+
Bun.sleepSync(200)
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
swarm = findBestSwarm()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!swarm) {
|
|
213
|
+
return c.error({
|
|
214
|
+
code: 'NO_SWARM',
|
|
215
|
+
message: 'No claude-swarm tmux session found. Is a Claude Code team running?',
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(` [grid] found swarm: ${swarm.socket}/${swarm.session}`)
|
|
220
|
+
setRemainOnExit(swarm.socket)
|
|
221
|
+
|
|
222
|
+
// 2. Incrementally mirror workers into a grid layout
|
|
223
|
+
// Row 0: split right from lead, then right from each previous
|
|
224
|
+
// Row 1+: split down from the column above
|
|
225
|
+
const conf = loadConfig()
|
|
226
|
+
applyConfigOverrides(conf, c.options)
|
|
227
|
+
const totalExpected = expectedWorkers || 1
|
|
228
|
+
const { cols } = computeGrid(totalExpected, conf.layout)
|
|
229
|
+
const mirrored = new Map<string, { cmuxSurface: string; viewSession: string }>()
|
|
230
|
+
const mirroredSurfaces: string[] = [] // ordered list for grid positioning
|
|
231
|
+
|
|
232
|
+
while (Date.now() < deadline) {
|
|
233
|
+
const allWorkers = getWorkerPanes(swarm.socket)
|
|
234
|
+
const newWorkers = allWorkers.filter((p: string) => !mirrored.has(p))
|
|
235
|
+
|
|
236
|
+
for (const paneId of newWorkers) {
|
|
237
|
+
const idx = mirrored.size
|
|
238
|
+
const row = Math.floor(idx / cols)
|
|
239
|
+
const col = idx % cols
|
|
240
|
+
|
|
241
|
+
let splitDir: 'right' | 'down'
|
|
242
|
+
let splitTarget: string
|
|
243
|
+
if (idx === 0) {
|
|
244
|
+
// First worker: split right from lead
|
|
245
|
+
splitDir = 'right'
|
|
246
|
+
splitTarget = leadSurface
|
|
247
|
+
} else if (row === 0) {
|
|
248
|
+
// Row 0: split right from previous worker
|
|
249
|
+
splitDir = 'right'
|
|
250
|
+
splitTarget = mirroredSurfaces[idx - 1]
|
|
251
|
+
} else {
|
|
252
|
+
// Row 1+: split down from the cell above in same column
|
|
253
|
+
splitDir = 'down'
|
|
254
|
+
splitTarget = mirroredSurfaces[(row - 1) * cols + col]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = mirrorWorkerToCmux(
|
|
258
|
+
swarm.socket, swarm.session,
|
|
259
|
+
paneId, idx, splitTarget, splitDir,
|
|
260
|
+
)
|
|
261
|
+
mirrored.set(paneId, result)
|
|
262
|
+
mirroredSurfaces.push(result.cmuxSurface)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (expectedWorkers && mirrored.size >= expectedWorkers) break
|
|
266
|
+
|
|
267
|
+
if (!expectedWorkers && mirrored.size > 0 && newWorkers.length === 0) {
|
|
268
|
+
Bun.sleepSync(3000)
|
|
269
|
+
const finalWorkers = getWorkerPanes(swarm.socket)
|
|
270
|
+
if (finalWorkers.filter((p: string) => !mirrored.has(p)).length === 0) break
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
Bun.sleepSync(200)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (mirrored.size === 0) {
|
|
278
|
+
return c.error({ code: 'NO_WORKERS', message: 'No worker panes found in swarm.' })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 3. Label surfaces
|
|
282
|
+
const label = teamName || 'cru'
|
|
283
|
+
const leadOriginalTitle = getSurfaceTitle(leadSurface)
|
|
284
|
+
try { renameSurface(leadSurface, `${leadOriginalTitle} (◫ lead @ ${label})`) } catch {}
|
|
285
|
+
|
|
286
|
+
const mirrors = [...mirrored.entries()]
|
|
287
|
+
const names = resolveWorkerNames(teamName, mirrors.length)
|
|
288
|
+
const workers = mirrors.map(([, m], i) => {
|
|
289
|
+
try { renameSurface(m.cmuxSurface, `⚡ ${names[i]} @ ${label}`) } catch {}
|
|
290
|
+
return { name: names[i], paneId: m.cmuxSurface, color: WORKER_COLORS[i % WORKER_COLORS.length] }
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// 4. Focus lead and save tracking
|
|
294
|
+
focusSurface(leadSurface)
|
|
295
|
+
|
|
296
|
+
if (teamName) {
|
|
297
|
+
savePanes(teamName, {
|
|
298
|
+
leadPane: leadSurface,
|
|
299
|
+
windowId: leadSurface,
|
|
300
|
+
backend: 'cmux',
|
|
301
|
+
createdAt: Date.now(),
|
|
302
|
+
workers,
|
|
303
|
+
leadOriginalTitle,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
applied: true,
|
|
309
|
+
backend: 'cmux',
|
|
310
|
+
swarm: `${swarm.socket}/${swarm.session}`,
|
|
311
|
+
workers: mirrored.size,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
138
315
|
function runGrid(c) {
|
|
316
|
+
// cmux: native pane management via CLI
|
|
317
|
+
if (inCmux()) {
|
|
318
|
+
return runGridCmux(c)
|
|
319
|
+
}
|
|
320
|
+
|
|
139
321
|
// Ghostty: mirror tmux panes into native splits
|
|
140
322
|
if (inGhostty()) {
|
|
141
323
|
return runGridGhostty(c)
|
|
142
324
|
}
|
|
143
325
|
|
|
144
326
|
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']
|
|
327
|
+
applyConfigOverrides(conf, c.options)
|
|
151
328
|
|
|
152
329
|
const teamName = c.args.team
|
|
153
330
|
let windowId: string | null = null
|
|
@@ -233,7 +410,7 @@ function runGrid(c) {
|
|
|
233
410
|
|
|
234
411
|
function runClose(c) {
|
|
235
412
|
let teamName = c.args.team
|
|
236
|
-
if (!teamName && !inGhostty()) {
|
|
413
|
+
if (!teamName && !inGhostty() && !inCmux()) {
|
|
237
414
|
try { teamName = findTeamForCurrentWindow() } catch {}
|
|
238
415
|
}
|
|
239
416
|
if (!teamName) return c.error({ code: 'NO_TEAM', message: 'No team specified and none found in current window' })
|
|
@@ -242,9 +419,14 @@ function runClose(c) {
|
|
|
242
419
|
const cruPanes = loadPanes(teamName)
|
|
243
420
|
if (cruPanes && cruPanes.workers.length > 0) {
|
|
244
421
|
// Use the right kill method based on how panes were tracked
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
422
|
+
let killFn: (id: string) => void
|
|
423
|
+
if (cruPanes.backend === 'cmux') {
|
|
424
|
+
killFn = (id: string) => { try { require('../lib/cmux').closeSurface(id) } catch (e) { console.warn(`[close] failed to close cmux surface ${id}: ${e}`) } }
|
|
425
|
+
} else if (cruPanes.backend === 'ghostty') {
|
|
426
|
+
killFn = (id: string) => { try { require('../lib/ghostty').closeTerminal(id) } catch (e) { console.warn(`[close] failed to close Ghostty terminal ${id}: ${e}`) } }
|
|
427
|
+
} else {
|
|
428
|
+
killFn = (id: string) => killPane(id)
|
|
429
|
+
}
|
|
248
430
|
|
|
249
431
|
for (const w of cruPanes.workers) {
|
|
250
432
|
killFn(w.paneId)
|
|
@@ -282,7 +464,21 @@ function runClose(c) {
|
|
|
282
464
|
} catch {}
|
|
283
465
|
}
|
|
284
466
|
|
|
285
|
-
if (closed.length === 0 &&
|
|
467
|
+
if (closed.length === 0 && inCmux()) {
|
|
468
|
+
// Fallback: close all non-lead cmux surfaces in the caller's workspace only
|
|
469
|
+
try {
|
|
470
|
+
const { currentSurface, currentWorkspace, listAllSurfaceIds, closeSurface } = require('../lib/cmux')
|
|
471
|
+
const lead = currentSurface()
|
|
472
|
+
const ws = currentWorkspace()
|
|
473
|
+
const allSurfaces = listAllSurfaceIds(ws)
|
|
474
|
+
for (const id of allSurfaces) {
|
|
475
|
+
if (id !== lead) {
|
|
476
|
+
closeSurface(id)
|
|
477
|
+
closed.push({ name: `pane-${closed.length + 1}`, pane: id })
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch {}
|
|
481
|
+
} else if (closed.length === 0 && inGhostty()) {
|
|
286
482
|
// Fallback: close all non-lead Ghostty terminals
|
|
287
483
|
// Uses process-tree-based currentTerminal() — works regardless of focus
|
|
288
484
|
try {
|
|
@@ -310,10 +506,41 @@ function runClose(c) {
|
|
|
310
506
|
} catch {}
|
|
311
507
|
}
|
|
312
508
|
|
|
509
|
+
// Restore lead pane's original title (remove appended team label)
|
|
510
|
+
if (cruPanes?.backend === 'cmux' && cruPanes.leadOriginalTitle != null) {
|
|
511
|
+
try {
|
|
512
|
+
const { renameSurface } = require('../lib/cmux')
|
|
513
|
+
renameSurface(cruPanes.leadPane, cruPanes.leadOriginalTitle)
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Clean up pane tracking file (team data stays for `cru logs` review)
|
|
518
|
+
if (teamName && closed.length > 0) {
|
|
519
|
+
try {
|
|
520
|
+
const { unlinkSync } = require('node:fs')
|
|
521
|
+
const { join } = require('node:path')
|
|
522
|
+
const { teamsDir } = require('../lib/paths')
|
|
523
|
+
unlinkSync(join(teamsDir(), teamName, 'cru-panes.json'))
|
|
524
|
+
} catch {}
|
|
525
|
+
}
|
|
526
|
+
|
|
313
527
|
return { team: teamName, closed: closed.length, panes: closed }
|
|
314
528
|
}
|
|
315
529
|
|
|
316
530
|
function runList(c) {
|
|
531
|
+
if (inCmux()) {
|
|
532
|
+
const { currentSurface, listSurfaces } = require('../lib/cmux')
|
|
533
|
+
let currentId: string | null = null
|
|
534
|
+
try { currentId = currentSurface() } catch {}
|
|
535
|
+
const surfaces = listSurfaces()
|
|
536
|
+
const panes = surfaces.map((s: { id: string; index: number }) => ({
|
|
537
|
+
id: s.id,
|
|
538
|
+
index: s.index,
|
|
539
|
+
current: s.id === currentId,
|
|
540
|
+
}))
|
|
541
|
+
return { backend: 'cmux', panes }
|
|
542
|
+
}
|
|
543
|
+
|
|
317
544
|
if (inGhostty()) {
|
|
318
545
|
const { ghostty, currentTerminal, listAllTerminals } = require('../lib/ghostty')
|
|
319
546
|
const allIds = listAllTerminals()
|
package/src/lib/cmux.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cmux backend — native pane management via the cmux CLI.
|
|
3
|
+
*
|
|
4
|
+
* cmux is a macOS terminal with a CLI for controlling panes/surfaces
|
|
5
|
+
* via a Unix socket (/tmp/cmux.sock).
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: All operations pass explicit --surface/--workspace flags
|
|
8
|
+
* so they work regardless of which tab/surface the user has focused.
|
|
9
|
+
* Never rely on the "focused" or "current" surface.
|
|
10
|
+
*
|
|
11
|
+
* Surface refs (surface:N) are monotonic — they don't shift when
|
|
12
|
+
* other surfaces are created or destroyed, so they're safe to store.
|
|
13
|
+
*
|
|
14
|
+
* Environment variables set by cmux in each terminal:
|
|
15
|
+
* CMUX_WORKSPACE_ID, CMUX_SURFACE_ID, CMUX_SOCKET_PATH
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import { computeGrid } from './layout'
|
|
20
|
+
import type { LayoutConf } from './tmux'
|
|
21
|
+
|
|
22
|
+
export interface CmuxSurface {
|
|
23
|
+
id: string // e.g. "surface:15" — monotonic ref, stable for the session
|
|
24
|
+
index: number
|
|
25
|
+
title: string
|
|
26
|
+
focused: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Core
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Run a cmux CLI command safely (no shell interpolation). */
|
|
34
|
+
export function cmux(...args: string[]): string {
|
|
35
|
+
return execFileSync('cmux', args, { encoding: 'utf-8', timeout: 10_000 }).trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Check if cmux socket is responding. */
|
|
39
|
+
export function isCmuxAvailable(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
cmux('ping')
|
|
42
|
+
return true
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get cmux version. */
|
|
49
|
+
export function cmuxVersion(): string | null {
|
|
50
|
+
try {
|
|
51
|
+
return cmux('version')
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Identity
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the caller's surface ref and workspace ref.
|
|
63
|
+
* Uses `cmux identify` which reliably returns the calling process's context,
|
|
64
|
+
* regardless of what the user has focused.
|
|
65
|
+
*/
|
|
66
|
+
export function identify(): { surface: string; workspace: string } {
|
|
67
|
+
const info = JSON.parse(cmux('identify'))
|
|
68
|
+
const surface = info.caller?.surface_ref
|
|
69
|
+
const workspace = info.caller?.workspace_ref
|
|
70
|
+
if (!surface) throw new Error('cmux identify did not return caller surface_ref')
|
|
71
|
+
return { surface, workspace: workspace || 'workspace:1' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get the caller's surface ref. */
|
|
75
|
+
export function currentSurface(): string {
|
|
76
|
+
return identify().surface
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get the caller's workspace ref. */
|
|
80
|
+
export function currentWorkspace(): string {
|
|
81
|
+
return identify().workspace
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Surfaces
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** List all surfaces in a workspace. Defaults to the caller's workspace. */
|
|
89
|
+
export function listSurfaces(workspace?: string): CmuxSurface[] {
|
|
90
|
+
const ws = workspace || currentWorkspace()
|
|
91
|
+
const output = cmux('list-panels', '--workspace', ws)
|
|
92
|
+
if (!output) return []
|
|
93
|
+
|
|
94
|
+
return output.split('\n')
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((line, i) => {
|
|
97
|
+
// Format: "* surface:15 terminal [focused] "Title""
|
|
98
|
+
// or: " surface:20 terminal "Title""
|
|
99
|
+
const match = line.match(/^([*\s]*)(surface:\d+)\s+\S+\s*(.*)$/)
|
|
100
|
+
if (!match) return null
|
|
101
|
+
const focused = match[3].includes('[focused]')
|
|
102
|
+
const titleMatch = match[3].match(/"([^"]*)"/)
|
|
103
|
+
return {
|
|
104
|
+
id: match[2],
|
|
105
|
+
index: i,
|
|
106
|
+
title: titleMatch?.[1] || '',
|
|
107
|
+
focused,
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.filter((s): s is CmuxSurface => s !== null)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** List all surface IDs in a workspace. Defaults to the caller's workspace. */
|
|
114
|
+
export function listAllSurfaceIds(workspace?: string): string[] {
|
|
115
|
+
return listSurfaces(workspace).map((s) => s.id)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Split relative to a specific surface and return the new surface ref.
|
|
120
|
+
* Always pass --surface so splits happen in the right place regardless of focus.
|
|
121
|
+
*/
|
|
122
|
+
export function splitSurface(
|
|
123
|
+
direction: 'right' | 'down' | 'left' | 'up' = 'right',
|
|
124
|
+
relativeTo?: string,
|
|
125
|
+
): string {
|
|
126
|
+
const args = ['new-split', direction]
|
|
127
|
+
if (relativeTo) args.push('--surface', relativeTo)
|
|
128
|
+
// Output format: "OK surface:20 workspace:1"
|
|
129
|
+
const output = cmux(...args)
|
|
130
|
+
const match = output.match(/surface:\d+/)
|
|
131
|
+
if (!match) throw new Error(`Unexpected new-split output: ${output}`)
|
|
132
|
+
return match[0]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Close a surface by ref. */
|
|
136
|
+
export function closeSurface(surfaceId: string): void {
|
|
137
|
+
try {
|
|
138
|
+
cmux('close-surface', '--surface', surfaceId)
|
|
139
|
+
} catch {
|
|
140
|
+
// surface may already be closed
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Rename a surface's tab for identification. */
|
|
145
|
+
export function renameSurface(surfaceId: string, title: string): void {
|
|
146
|
+
cmux('rename-tab', title, '--surface', surfaceId)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Get the current title of a surface. */
|
|
150
|
+
export function getSurfaceTitle(surfaceId: string): string {
|
|
151
|
+
const surfaces = listSurfaces()
|
|
152
|
+
return surfaces.find((s) => s.id === surfaceId)?.title || ''
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// I/O
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/** Send text to a surface (pastes the text). */
|
|
160
|
+
export function sendText(surfaceId: string, text: string): void {
|
|
161
|
+
cmux('send', '--surface', surfaceId, text)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Send a key press to a surface. */
|
|
165
|
+
export function sendKey(surfaceId: string, key: string): void {
|
|
166
|
+
cmux('send-key', '--surface', surfaceId, key)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Send text followed by Enter (like typing a command). */
|
|
170
|
+
export function sendCommand(surfaceId: string, text: string): void {
|
|
171
|
+
sendText(surfaceId, text)
|
|
172
|
+
sendKey(surfaceId, 'Return')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read the screen content of a surface. */
|
|
176
|
+
export function readScreen(surfaceId: string, lines?: number): string {
|
|
177
|
+
const args = ['read-screen', '--surface', surfaceId]
|
|
178
|
+
if (lines) args.push('--lines', String(lines))
|
|
179
|
+
return cmux(...args)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Focus
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** Focus a surface. */
|
|
187
|
+
export function focusSurface(surfaceId: string): void {
|
|
188
|
+
const panes = cmux('list-panes')
|
|
189
|
+
for (const line of panes.split('\n').filter(Boolean)) {
|
|
190
|
+
const paneMatch = line.match(/^[*\s]*(pane:\d+)/)
|
|
191
|
+
if (!paneMatch) continue
|
|
192
|
+
const paneSurfaces = cmux('list-pane-surfaces', '--pane', paneMatch[1])
|
|
193
|
+
if (paneSurfaces.includes(surfaceId)) {
|
|
194
|
+
cmux('focus-pane', '--pane', paneMatch[1])
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Grid builder
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build a grid layout using iterative splits.
|
|
206
|
+
*
|
|
207
|
+
* All splits use explicit --surface targeting so they work regardless
|
|
208
|
+
* of what the user has focused. No focus changes needed.
|
|
209
|
+
*
|
|
210
|
+
* Returns the surface refs of the created worker panes.
|
|
211
|
+
*/
|
|
212
|
+
export function buildGrid(
|
|
213
|
+
leadSurface: string,
|
|
214
|
+
workerCount: number,
|
|
215
|
+
conf: LayoutConf,
|
|
216
|
+
): string[] {
|
|
217
|
+
const { cols, rows } = computeGrid(workerCount, conf)
|
|
218
|
+
const isHorizontal = conf.lead.position === 'left' || conf.lead.position === 'right'
|
|
219
|
+
const workerSurfaces: string[] = []
|
|
220
|
+
|
|
221
|
+
const firstSplitDir = isHorizontal
|
|
222
|
+
? (conf.lead.position === 'left' ? 'right' : 'left')
|
|
223
|
+
: (conf.lead.position === 'top' ? 'down' : 'up')
|
|
224
|
+
|
|
225
|
+
// First worker: split relative to the lead surface
|
|
226
|
+
const firstWorker = splitSurface(firstSplitDir as 'right' | 'down' | 'left' | 'up', leadSurface)
|
|
227
|
+
workerSurfaces.push(firstWorker)
|
|
228
|
+
Bun.sleepSync(100)
|
|
229
|
+
|
|
230
|
+
if (workerCount <= 1) return workerSurfaces
|
|
231
|
+
|
|
232
|
+
const colDir = isHorizontal ? 'down' : 'right'
|
|
233
|
+
const rowDir = isHorizontal ? 'right' : 'down'
|
|
234
|
+
let created = 1
|
|
235
|
+
|
|
236
|
+
// Build columns in row 0 — each splits relative to the previous worker
|
|
237
|
+
for (let c = 1; c < cols && created < workerCount; c++) {
|
|
238
|
+
const s = splitSurface(rowDir as 'right' | 'down', workerSurfaces[workerSurfaces.length - 1])
|
|
239
|
+
workerSurfaces.push(s)
|
|
240
|
+
created++
|
|
241
|
+
Bun.sleepSync(100)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Add additional rows by splitting column panes downward
|
|
245
|
+
for (let r = 1; r < rows && created < workerCount; r++) {
|
|
246
|
+
for (let c = 0; c < cols && created < workerCount; c++) {
|
|
247
|
+
const splitTarget = workerSurfaces[c + (r - 1) * cols]
|
|
248
|
+
if (splitTarget) {
|
|
249
|
+
const s = splitSurface(colDir as 'right' | 'down', splitTarget)
|
|
250
|
+
workerSurfaces.push(s)
|
|
251
|
+
created++
|
|
252
|
+
Bun.sleepSync(100)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return workerSurfaces
|
|
258
|
+
}
|
package/src/lib/env.ts
CHANGED
|
@@ -43,3 +43,8 @@ export function detectTerminal(): string {
|
|
|
43
43
|
export function inGhostty(): boolean {
|
|
44
44
|
return detectTerminal() === 'ghostty' && !inTmux()
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Check if we're inside a cmux terminal. */
|
|
48
|
+
export function inCmux(): boolean {
|
|
49
|
+
return !!process.env.CMUX_WORKSPACE_ID
|
|
50
|
+
}
|
package/src/lib/mirror.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { execFileSync } from 'node:child_process'
|
|
19
19
|
import { readdirSync, statSync } from 'node:fs'
|
|
20
20
|
import { join } from 'node:path'
|
|
21
|
-
import { splitTerminal, sendCommand } from './ghostty'
|
|
21
|
+
import { splitTerminal, sendCommand as ghosttySendCommand } from './ghostty'
|
|
22
22
|
|
|
23
23
|
/** Run a tmux command on a specific socket (or default). */
|
|
24
24
|
function tmuxSocket(args: string[], socket?: string): string {
|
|
@@ -141,9 +141,51 @@ export function mirrorSingleWorker(
|
|
|
141
141
|
Bun.sleepSync(300)
|
|
142
142
|
|
|
143
143
|
// Attach to the view session via the custom socket
|
|
144
|
-
|
|
144
|
+
ghosttySendCommand(newTermId, `tmux -L ${socket} attach -t ${viewName}`)
|
|
145
145
|
Bun.sleepSync(200)
|
|
146
146
|
|
|
147
147
|
console.log(` [mirror] ${windowName} (${paneId}) → ghostty:${newTermId}`)
|
|
148
148
|
return { ghosttyTerminal: newTermId, viewSession: viewName }
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Mirror a single tmux worker pane into a cmux split.
|
|
153
|
+
* Same approach as Ghostty mirroring but uses cmux CLI for splits.
|
|
154
|
+
*/
|
|
155
|
+
export function mirrorWorkerToCmux(
|
|
156
|
+
socket: string,
|
|
157
|
+
session: string,
|
|
158
|
+
paneId: string,
|
|
159
|
+
index: number,
|
|
160
|
+
splitTarget: string,
|
|
161
|
+
splitDirection: 'right' | 'down',
|
|
162
|
+
): { cmuxSurface: string; viewSession: string } {
|
|
163
|
+
const { splitSurface, sendCommand: cmuxSendCommand } = require('./cmux')
|
|
164
|
+
|
|
165
|
+
const windowName = `worker-${index + 1}`
|
|
166
|
+
const viewName = `view-${index + 1}`
|
|
167
|
+
|
|
168
|
+
// Break pane into its own tmux window
|
|
169
|
+
try {
|
|
170
|
+
tmuxSocket(['break-pane', '-s', paneId, '-d', '-n', windowName], socket)
|
|
171
|
+
} catch (e: any) {
|
|
172
|
+
console.error(` [mirror] break-pane ${paneId}: ${e.message}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Create a session group member pointing at the worker's window
|
|
176
|
+
try { tmuxSocket(['kill-session', '-t', viewName], socket) } catch {}
|
|
177
|
+
tmuxSocket(['new-session', '-d', '-t', session, '-s', viewName], socket)
|
|
178
|
+
tmuxSocket(['set-option', '-t', viewName, 'status', 'off'], socket)
|
|
179
|
+
tmuxSocket(['select-window', '-t', `${viewName}:${windowName}`], socket)
|
|
180
|
+
|
|
181
|
+
// Create cmux split relative to the target surface
|
|
182
|
+
const newSurface = splitSurface(splitDirection, splitTarget)
|
|
183
|
+
Bun.sleepSync(300)
|
|
184
|
+
|
|
185
|
+
// Attach to the view session via the custom socket
|
|
186
|
+
cmuxSendCommand(newSurface, `tmux -L ${socket} attach -t ${viewName}`)
|
|
187
|
+
Bun.sleepSync(200)
|
|
188
|
+
|
|
189
|
+
console.log(` [mirror] ${windowName} (${paneId}) → cmux:${newSurface}`)
|
|
190
|
+
return { cmuxSurface: newSurface, viewSession: viewName }
|
|
191
|
+
}
|
package/src/lib/panes.ts
CHANGED
|
@@ -8,7 +8,8 @@ export interface PaneRecord {
|
|
|
8
8
|
windowId: string
|
|
9
9
|
workers: Array<{ name: string; paneId: string; color: string }>
|
|
10
10
|
createdAt: number
|
|
11
|
-
backend?: 'tmux' | 'ghostty'
|
|
11
|
+
backend?: 'tmux' | 'ghostty' | 'cmux'
|
|
12
|
+
leadOriginalTitle?: string
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function panePath(teamName: string): string {
|
|
@@ -34,6 +35,18 @@ export function isTeamAlive(teamName: string): boolean {
|
|
|
34
35
|
try {
|
|
35
36
|
const cruPanes = loadPanes(teamName)
|
|
36
37
|
|
|
38
|
+
// For cmux-tracked teams, check via cmux CLI
|
|
39
|
+
if (cruPanes?.backend === 'cmux') {
|
|
40
|
+
try {
|
|
41
|
+
const { listAllSurfaceIds } = require('./cmux')
|
|
42
|
+
const allSurfaces = new Set(listAllSurfaceIds())
|
|
43
|
+
return cruPanes.workers.some((w) => allSurfaces.has(w.paneId))
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn(`[isTeamAlive] failed to query cmux for team "${teamName}": ${e}`)
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
// For ghostty-tracked teams, check via AppleScript
|
|
38
51
|
if (cruPanes?.backend === 'ghostty') {
|
|
39
52
|
try {
|
package/src/lib/preflight.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasBinary, inTmux, inGhostty, detectTerminal } from './env'
|
|
1
|
+
import { hasBinary, inTmux, inGhostty, inCmux, detectTerminal } from './env'
|
|
2
2
|
|
|
3
3
|
const INSTALL_HINTS: Record<string, Record<string, string>> = {
|
|
4
4
|
tmux: {
|
|
@@ -59,7 +59,16 @@ export function preflight(...checks: Check[]): { ok: boolean; errors: PreflightE
|
|
|
59
59
|
break
|
|
60
60
|
|
|
61
61
|
case 'pane-session':
|
|
62
|
-
if (
|
|
62
|
+
if (inCmux()) {
|
|
63
|
+
try {
|
|
64
|
+
const { isCmuxAvailable } = require('./cmux')
|
|
65
|
+
if (!isCmuxAvailable()) {
|
|
66
|
+
errors.push({ check: 'pane-session', message: 'cmux socket is not responding. Is cmux running?' })
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
errors.push({ check: 'pane-session', message: 'Cannot connect to cmux socket' })
|
|
70
|
+
}
|
|
71
|
+
} else if (inGhostty()) {
|
|
63
72
|
if (process.platform !== 'darwin') {
|
|
64
73
|
errors.push({ check: 'pane-session', message: 'Ghostty AppleScript is only available on macOS' })
|
|
65
74
|
} else {
|
|
@@ -73,9 +82,9 @@ export function preflight(...checks: Check[]): { ok: boolean; errors: PreflightE
|
|
|
73
82
|
}
|
|
74
83
|
}
|
|
75
84
|
} 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') })
|
|
85
|
+
errors.push({ check: 'pane-session', message: 'tmux is not installed (or use Ghostty/cmux for native pane support)', hint: platformHint('tmux') })
|
|
77
86
|
} else if (!inTmux()) {
|
|
78
|
-
errors.push({ check: 'pane-session', message: 'Not inside a tmux session (or use Ghostty for native pane support)', hint: tmuxCmd })
|
|
87
|
+
errors.push({ check: 'pane-session', message: 'Not inside a tmux session (or use Ghostty/cmux for native pane support)', hint: tmuxCmd })
|
|
79
88
|
}
|
|
80
89
|
break
|
|
81
90
|
|