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 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.2",
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: '1.0.0',
28
+ version: pkg.version,
27
29
  })
28
30
  .use(async (c, next) => {
29
31
  const checks = PREFLIGHT[c.command]
@@ -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' })
@@ -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
- 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)
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 && inGhostty()) {
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()
@@ -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
- sendCommand(newTermId, `tmux -L ${socket} attach -t ${viewName}`)
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 {
@@ -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 (inGhostty()) {
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