cru-teams 1.0.3 → 1.2.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.3",
3
+ "version": "1.2.0",
4
4
  "description": "◫ Manage terminal layouts for Claude Code agent teams",
5
5
  "type": "module",
6
6
  "bin": {
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]
@@ -135,87 +135,186 @@ 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
+ */
138
171
  function runGridCmux(c) {
172
+ const {
173
+ findBestSwarm,
174
+ getWorkerPanes,
175
+ mirrorWorkerToCmux,
176
+ setRemainOnExit,
177
+ } = require('../lib/mirror')
139
178
  const {
140
179
  currentSurface,
141
- listSurfaces,
142
- buildGrid,
143
180
  focusSurface,
144
181
  closeSurface,
145
- } = require('../lib/cmux')
146
-
147
- const conf = loadConfig()
148
- if (c.options['lead-size'] != null) conf.layout.lead.size = c.options['lead-size']
149
- if (c.options['lead-position']) conf.layout.lead.position = c.options['lead-position']
150
- if (c.options.fill) conf.layout.grid.fill = c.options.fill
151
- if (c.options['max-cols'] != null) conf.layout.grid.maxCols = c.options['max-cols']
152
- if (c.options['max-rows'] != null) conf.layout.grid.maxRows = c.options['max-rows']
182
+ renameSurface,
183
+ getSurfaceTitle,
184
+ notify,
185
+ setStatus,
186
+ setProgress,
187
+ sidebarLog,
188
+ clearStatus,
189
+ clearProgress,
190
+ clearLog,
191
+ setPhase,
192
+ clearPhase,
193
+ spawnProgressWatcher,
194
+ } = require('../lib/cmux') as typeof import('../lib/cmux')
153
195
 
154
196
  const teamName = c.args.team
197
+ const expectedWorkers = c.options.expect
155
198
  const leadSurface = currentSurface()
199
+ const deadline = Date.now() + 60_000
156
200
 
157
- // If team already has tracked panes, close old workers first
201
+ // Close previously-tracked workers (never arbitrary surfaces)
158
202
  if (teamName) {
159
203
  const existing = loadPanes(teamName)
160
204
  if (existing?.backend === 'cmux') {
161
- for (const w of existing.workers) {
162
- closeSurface(w.paneId)
163
- }
205
+ for (const w of existing.workers) closeSurface(w.paneId)
164
206
  Bun.sleepSync(300)
165
207
  }
166
208
  }
167
209
 
168
- // Count existing non-lead surfaces as workers, or use --expect
169
- let workerCount = c.options.expect
170
- if (!workerCount) {
171
- const surfaces = listSurfaces()
172
- workerCount = surfaces.length - 1 // subtract lead
173
- if (workerCount < 1) {
174
- return c.error({ code: 'NO_WORKERS', message: 'Only one surface — nothing to arrange. Use --expect N to create workers.' })
210
+ // 1. Find the swarm socket
211
+ let swarm: { socket: string; session: string } | null = null
212
+ if (expectedWorkers) {
213
+ while (Date.now() < deadline) {
214
+ swarm = findBestSwarm(expectedWorkers)
215
+ if (swarm) break
216
+ Bun.sleepSync(200)
175
217
  }
218
+ } else {
219
+ swarm = findBestSwarm()
176
220
  }
177
221
 
178
- // If --expect is set and we need to create surfaces
179
- const surfaces = listSurfaces()
180
- const existingWorkerCount = surfaces.length - 1
181
- if (c.options.expect && existingWorkerCount < c.options.expect) {
182
- // Close existing non-lead surfaces to rebuild from scratch
183
- for (const s of surfaces) {
184
- if (s.id !== leadSurface) {
185
- closeSurface(s.id)
222
+ if (!swarm) {
223
+ return c.error({
224
+ code: 'NO_SWARM',
225
+ message: 'No claude-swarm tmux session found. Is a Claude Code team running?',
226
+ })
227
+ }
228
+
229
+ console.log(` [grid] found swarm: ${swarm.socket}/${swarm.session}`)
230
+ setRemainOnExit(swarm.socket)
231
+
232
+ const label = teamName || 'cru'
233
+ setStatus('team', label, { icon: 'person.2.fill', color: '#6366f1' })
234
+ setPhase('spawning', label)
235
+ sidebarLog('Swarm found, mirroring workers...', 'progress')
236
+
237
+ // 2. Incrementally mirror workers into a grid layout
238
+ const conf = loadConfig()
239
+ applyConfigOverrides(conf, c.options)
240
+ const totalExpected = expectedWorkers || 1
241
+ const { cols } = computeGrid(totalExpected, conf.layout)
242
+ const mirrored = new Map<string, { cmuxSurface: string; viewSession: string }>()
243
+ const mirroredSurfaces: string[] = []
244
+
245
+ setProgress(0, `0/${totalExpected} workers`)
246
+
247
+ while (Date.now() < deadline) {
248
+ const allWorkers = getWorkerPanes(swarm.socket)
249
+ const newWorkers = allWorkers.filter((p: string) => !mirrored.has(p))
250
+
251
+ for (const paneId of newWorkers) {
252
+ const idx = mirrored.size
253
+ const row = Math.floor(idx / cols)
254
+ const col = idx % cols
255
+
256
+ let splitDir: 'right' | 'down'
257
+ let splitTarget: string
258
+ if (idx === 0) {
259
+ splitDir = 'right'
260
+ splitTarget = leadSurface
261
+ } else if (row === 0) {
262
+ splitDir = 'right'
263
+ splitTarget = mirroredSurfaces[idx - 1]
264
+ } else {
265
+ splitDir = 'down'
266
+ splitTarget = mirroredSurfaces[(row - 1) * cols + col]
186
267
  }
187
- }
188
- Bun.sleepSync(300)
189
-
190
- // Build fresh grid
191
- const workerSurfaces = buildGrid(leadSurface, workerCount, conf.layout)
192
-
193
- if (teamName) {
194
- savePanes(teamName, {
195
- leadPane: leadSurface,
196
- windowId: leadSurface,
197
- backend: 'cmux',
198
- createdAt: Date.now(),
199
- workers: workerSurfaces.map((id: string, i: number) => ({
200
- name: `worker-${i + 1}`,
201
- paneId: id,
202
- color: ['green', 'blue', 'yellow', 'magenta', 'cyan', 'red'][i % 6],
203
- })),
204
- })
268
+
269
+ const result = mirrorWorkerToCmux(
270
+ swarm.socket, swarm.session,
271
+ paneId, idx, splitTarget, splitDir,
272
+ )
273
+ mirrored.set(paneId, result)
274
+ mirroredSurfaces.push(result.cmuxSurface)
275
+
276
+ // Update sidebar progress
277
+ sidebarLog(`worker-${idx + 1} mirrored`, 'success')
278
+ setProgress(mirrored.size / totalExpected, `${mirrored.size}/${totalExpected} workers`)
205
279
  }
206
280
 
207
- focusSurface(leadSurface)
281
+ if (expectedWorkers && mirrored.size >= expectedWorkers) break
208
282
 
209
- return {
210
- applied: true,
211
- backend: 'cmux',
212
- lead: { position: conf.layout.lead.position, size: `${conf.layout.lead.size}%` },
213
- workers: workerSurfaces.length,
283
+ if (!expectedWorkers && mirrored.size > 0 && newWorkers.length === 0) {
284
+ Bun.sleepSync(3000)
285
+ const finalWorkers = getWorkerPanes(swarm.socket)
286
+ if (finalWorkers.filter((p: string) => !mirrored.has(p)).length === 0) break
287
+ continue
214
288
  }
289
+
290
+ Bun.sleepSync(200)
215
291
  }
216
292
 
217
- // Rearrange existing surfaces — just track them
218
- const workerSurfaces = surfaces.filter((s: any) => s.id !== leadSurface)
293
+ if (mirrored.size === 0) {
294
+ clearProgress()
295
+ clearStatus('team')
296
+ clearPhase()
297
+ return c.error({ code: 'NO_WORKERS', message: 'No worker panes found in swarm.' })
298
+ }
299
+
300
+ // 3. Label surfaces
301
+ const leadOriginalTitle = getSurfaceTitle(leadSurface)
302
+ try { renameSurface(leadSurface, `${leadOriginalTitle} (◫ lead @ ${label})`) } catch {}
303
+
304
+ const mirrors = [...mirrored.entries()]
305
+ const names = resolveWorkerNames(teamName, mirrors.length)
306
+ const workers = mirrors.map(([, m], i) => {
307
+ try { renameSurface(m.cmuxSurface, `⚡ ${names[i]} @ ${label}`) } catch {}
308
+ return { name: names[i], paneId: m.cmuxSurface, color: WORKER_COLORS[i % WORKER_COLORS.length] }
309
+ })
310
+
311
+ // 4. Finalize sidebar — clear spawn artifacts, switch to working
312
+ clearProgress()
313
+ clearLog()
314
+ setStatus('team', `${label} (${workers.length} workers)`, { icon: 'person.2.fill', color: '#22c55e' })
315
+ setPhase('working', label)
316
+ notify(`◫ ${label}`, `Team ready — ${workers.length} workers in grid`)
317
+ focusSurface(leadSurface)
219
318
 
220
319
  if (teamName) {
221
320
  savePanes(teamName, {
@@ -223,21 +322,19 @@ function runGridCmux(c) {
223
322
  windowId: leadSurface,
224
323
  backend: 'cmux',
225
324
  createdAt: Date.now(),
226
- workers: workerSurfaces.map((s: any, i: number) => ({
227
- name: `worker-${i + 1}`,
228
- paneId: s.id,
229
- color: ['green', 'blue', 'yellow', 'magenta', 'cyan', 'red'][i % 6],
230
- })),
325
+ workers,
326
+ leadOriginalTitle,
231
327
  })
232
- }
233
328
 
234
- focusSurface(leadSurface)
329
+ // Start background task progress watcher
330
+ spawnProgressWatcher(teamName)
331
+ }
235
332
 
236
333
  return {
237
334
  applied: true,
238
335
  backend: 'cmux',
239
- lead: { position: conf.layout.lead.position, size: `${conf.layout.lead.size}%` },
240
- workers: workerSurfaces.length,
336
+ swarm: `${swarm.socket}/${swarm.session}`,
337
+ workers: mirrored.size,
241
338
  }
242
339
  }
243
340
 
@@ -253,12 +350,7 @@ function runGrid(c) {
253
350
  }
254
351
 
255
352
  const conf = loadConfig()
256
-
257
- if (c.options['lead-size'] != null) conf.layout.lead.size = c.options['lead-size']
258
- if (c.options['lead-position']) conf.layout.lead.position = c.options['lead-position']
259
- if (c.options.fill) conf.layout.grid.fill = c.options.fill
260
- if (c.options['max-cols'] != null) conf.layout.grid.maxCols = c.options['max-cols']
261
- if (c.options['max-rows'] != null) conf.layout.grid.maxRows = c.options['max-rows']
353
+ applyConfigOverrides(conf, c.options)
262
354
 
263
355
  const teamName = c.args.team
264
356
  let windowId: string | null = null
@@ -399,11 +491,12 @@ function runClose(c) {
399
491
  }
400
492
 
401
493
  if (closed.length === 0 && inCmux()) {
402
- // Fallback: close all non-lead cmux surfaces
494
+ // Fallback: close all non-lead cmux surfaces in the caller's workspace only
403
495
  try {
404
- const { currentSurface, listAllSurfaceIds, closeSurface } = require('../lib/cmux')
496
+ const { currentSurface, currentWorkspace, listAllSurfaceIds, closeSurface } = require('../lib/cmux')
405
497
  const lead = currentSurface()
406
- const allSurfaces = listAllSurfaceIds()
498
+ const ws = currentWorkspace()
499
+ const allSurfaces = listAllSurfaceIds(ws)
407
500
  for (const id of allSurfaces) {
408
501
  if (id !== lead) {
409
502
  closeSurface(id)
@@ -439,16 +532,40 @@ function runClose(c) {
439
532
  } catch {}
440
533
  }
441
534
 
535
+ // Restore lead pane's original title and clear sidebar
536
+ if (cruPanes?.backend === 'cmux') {
537
+ const { renameSurface, notify, clearStatus, clearProgress, clearPhase, clearLog } = require('../lib/cmux')
538
+ if (cruPanes.leadOriginalTitle != null) {
539
+ try { renameSurface(cruPanes.leadPane, cruPanes.leadOriginalTitle) } catch {}
540
+ }
541
+ try { notify(`◫ ${teamName}`, `Team shut down — ${closed.length} workers closed`) } catch {}
542
+ // Clean slate — remove all cru sidebar state
543
+ try { clearStatus('team') } catch {}
544
+ try { clearPhase() } catch {}
545
+ try { clearProgress() } catch {}
546
+ try { clearLog() } catch {}
547
+ }
548
+
549
+ // Clean up pane tracking file (team data stays for `cru logs` review)
550
+ if (teamName && closed.length > 0) {
551
+ try {
552
+ const { unlinkSync } = require('node:fs')
553
+ const { join } = require('node:path')
554
+ const { teamsDir } = require('../lib/paths')
555
+ unlinkSync(join(teamsDir(), teamName, 'cru-panes.json'))
556
+ } catch {}
557
+ }
558
+
442
559
  return { team: teamName, closed: closed.length, panes: closed }
443
560
  }
444
561
 
445
562
  function runList(c) {
446
563
  if (inCmux()) {
447
- const { currentSurface, listSurfaces, readScreen } = require('../lib/cmux')
564
+ const { currentSurface, listSurfaces } = require('../lib/cmux')
448
565
  let currentId: string | null = null
449
566
  try { currentId = currentSurface() } catch {}
450
567
  const surfaces = listSurfaces()
451
- const panes = surfaces.map((s: any) => ({
568
+ const panes = surfaces.map((s: { id: string; index: number }) => ({
452
569
  id: s.id,
453
570
  index: s.index,
454
571
  current: s.id === currentId,
package/src/lib/cmux.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * cmux backend — native pane management via the cmux CLI.
3
3
  *
4
- * cmux is a Ghostty-based macOS terminal with vertical tabs,
5
- * notifications, and a CLI for controlling panes/surfaces via
6
- * a Unix socket (/tmp/cmux.sock).
4
+ * cmux is a macOS terminal with a CLI for controlling panes/surfaces
5
+ * via a Unix socket (/tmp/cmux.sock).
7
6
  *
8
- * Surfaces = individual terminal instances (like tmux panes).
9
- * Panes = split containers holding one or more surfaces.
7
+ * IMPORTANT: All operations pass explicit --surface/--workspace flags
8
+ * so they work regardless of which tab/surface the user has focused.
9
+ * identify() prefers the caller's own surface, falling back to focused
10
+ * only when env vars are stale (e.g. detached scripts).
11
+ *
12
+ * Surface refs (surface:N) are monotonic — they don't shift when
13
+ * other surfaces are created or destroyed, so they're safe to store.
10
14
  *
11
15
  * Environment variables set by cmux in each terminal:
12
16
  * CMUX_WORKSPACE_ID, CMUX_SURFACE_ID, CMUX_SOCKET_PATH
@@ -17,103 +21,120 @@ import { computeGrid } from './layout'
17
21
  import type { LayoutConf } from './tmux'
18
22
 
19
23
  export interface CmuxSurface {
20
- id: string // e.g. "surface:1"
21
- index: number
22
- }
23
-
24
- export interface CmuxSurfaceDetails {
25
- id: string
24
+ id: string // e.g. "surface:15" — monotonic ref, stable for the session
26
25
  index: number
27
- type: string
28
- focused: boolean
29
26
  title: string
27
+ focused: boolean
30
28
  }
31
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Core
32
+ // ---------------------------------------------------------------------------
33
+
32
34
  /** Run a cmux CLI command safely (no shell interpolation). */
33
35
  export function cmux(...args: string[]): string {
34
36
  return execFileSync('cmux', args, { encoding: 'utf-8', timeout: 10_000 }).trim()
35
37
  }
36
38
 
37
- /** Check if we're inside a cmux terminal. */
38
- export function inCmux(): boolean {
39
- return !!process.env.CMUX_WORKSPACE_ID
40
- }
41
-
42
- /** Get the current surface ID (the surface running this process). */
43
- export function currentSurface(): string {
44
- const envSurface = process.env.CMUX_SURFACE_ID
45
- if (envSurface) {
46
- // cmux identify returns JSON with refs — verify surface is still valid
47
- try {
48
- const info = JSON.parse(cmux('identify', '--no-caller'))
49
- if (info.focused?.surface_ref) {
50
- // env var is a UUID; we need the ref format for commands
51
- const panels = cmux('list-panels')
52
- // Find the line that matches our UUID or is focused
53
- const lines = panels.split('\n').filter(Boolean)
54
- for (const line of lines) {
55
- if (line.includes('[focused]')) {
56
- const match = line.match(/^[*\s]*(surface:\d+)/)
57
- if (match) return match[1]
58
- }
59
- }
60
- }
61
- } catch {}
62
- }
63
-
64
- // Fallback: use identify to get caller's surface ref
39
+ /** Check if cmux socket is responding. */
40
+ export function isCmuxAvailable(): boolean {
65
41
  try {
66
- const info = JSON.parse(cmux('identify'))
67
- return info.caller?.surface_ref || 'surface:1'
42
+ cmux('ping')
43
+ return true
68
44
  } catch {
69
- return 'surface:1'
45
+ return false
70
46
  }
71
47
  }
72
48
 
73
- /** Get the current workspace ref. */
74
- export function currentWorkspace(): string {
49
+ /** Get cmux version. */
50
+ export function cmuxVersion(): string | null {
75
51
  try {
76
- const info = JSON.parse(cmux('identify'))
77
- return info.caller?.workspace_ref || 'workspace:1'
52
+ return cmux('version')
78
53
  } catch {
79
- return 'workspace:1'
54
+ return null
80
55
  }
81
56
  }
82
57
 
83
- /** List all surfaces in the current workspace. */
84
- export function listSurfaces(): CmuxSurface[] {
85
- const output = cmux('list-panels')
58
+ // ---------------------------------------------------------------------------
59
+ // Identity
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Get the caller's surface ref and workspace ref.
64
+ * Uses `cmux identify` which reliably returns the calling process's context,
65
+ * regardless of what the user has focused.
66
+ */
67
+ export function identify(): { surface: string; workspace: string } {
68
+ const info = JSON.parse(cmux('identify'))
69
+ // Prefer caller (the process's own surface), fall back to focused surface
70
+ const surface = info.caller?.surface_ref || info.focused?.surface_ref
71
+ const workspace = info.caller?.workspace_ref || info.focused?.workspace_ref
72
+ if (!surface) throw new Error('cmux identify did not return a surface_ref')
73
+ return { surface, workspace: workspace || 'workspace:1' }
74
+ }
75
+
76
+ /** Get the caller's surface ref. */
77
+ export function currentSurface(): string {
78
+ return identify().surface
79
+ }
80
+
81
+ /** Get the caller's workspace ref. */
82
+ export function currentWorkspace(): string {
83
+ return identify().workspace
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Surfaces
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** List all surfaces in a workspace. Defaults to the caller's workspace. */
91
+ export function listSurfaces(workspace?: string): CmuxSurface[] {
92
+ const ws = workspace || currentWorkspace()
93
+ const output = cmux('list-panels', '--workspace', ws)
86
94
  if (!output) return []
87
95
 
88
96
  return output.split('\n')
89
97
  .filter(Boolean)
90
98
  .map((line, i) => {
91
- const match = line.match(/^[*\s]*(surface:\d+)/)
92
- return match ? { id: match[1], index: i } : null
99
+ // Format: "* surface:15 terminal [focused] "Title""
100
+ // or: " surface:20 terminal "Title""
101
+ const match = line.match(/^([*\s]*)(surface:\d+)\s+\S+\s*(.*)$/)
102
+ if (!match) return null
103
+ const focused = match[3].includes('[focused]')
104
+ const titleMatch = match[3].match(/"([^"]*)"/)
105
+ return {
106
+ id: match[2],
107
+ index: i,
108
+ title: titleMatch?.[1] || '',
109
+ focused,
110
+ }
93
111
  })
94
112
  .filter((s): s is CmuxSurface => s !== null)
95
113
  }
96
114
 
97
- /** List all surface IDs in the current workspace. */
98
- export function listAllSurfaceIds(): string[] {
99
- return listSurfaces().map((s) => s.id)
115
+ /** List all surface IDs in a workspace. Defaults to the caller's workspace. */
116
+ export function listAllSurfaceIds(workspace?: string): string[] {
117
+ return listSurfaces(workspace).map((s) => s.id)
100
118
  }
101
119
 
102
- /** Split a surface and return the new surface's ref. */
120
+ /**
121
+ * Split relative to a specific surface and return the new surface ref.
122
+ * Always pass --surface so splits happen in the right place regardless of focus.
123
+ */
103
124
  export function splitSurface(
104
125
  direction: 'right' | 'down' | 'left' | 'up' = 'right',
126
+ relativeTo?: string,
105
127
  ): string {
106
- // Snapshot surface IDs before split
107
- const before = new Set(listAllSurfaceIds())
108
- cmux('new-split', direction)
109
- // Find the new surface by diffing
110
- const after = listAllSurfaceIds()
111
- const newId = after.find((id) => !before.has(id))
112
- if (!newId) throw new Error('Split did not create a new surface')
113
- return newId
128
+ const args = ['new-split', direction]
129
+ if (relativeTo) args.push('--surface', relativeTo)
130
+ // Output format: "OK surface:20 workspace:1"
131
+ const output = cmux(...args)
132
+ const match = output.match(/surface:\d+/)
133
+ if (!match) throw new Error(`Unexpected new-split output: ${output}`)
134
+ return match[0]
114
135
  }
115
136
 
116
- /** Close a surface. */
137
+ /** Close a surface by ref. */
117
138
  export function closeSurface(surfaceId: string): void {
118
139
  try {
119
140
  cmux('close-surface', '--surface', surfaceId)
@@ -122,6 +143,21 @@ export function closeSurface(surfaceId: string): void {
122
143
  }
123
144
  }
124
145
 
146
+ /** Rename a surface's tab for identification. */
147
+ export function renameSurface(surfaceId: string, title: string): void {
148
+ cmux('rename-tab', title, '--surface', surfaceId)
149
+ }
150
+
151
+ /** Get the current title of a surface. */
152
+ export function getSurfaceTitle(surfaceId: string): string {
153
+ const surfaces = listSurfaces()
154
+ return surfaces.find((s) => s.id === surfaceId)?.title || ''
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // I/O
159
+ // ---------------------------------------------------------------------------
160
+
125
161
  /** Send text to a surface (pastes the text). */
126
162
  export function sendText(surfaceId: string, text: string): void {
127
163
  cmux('send', '--surface', surfaceId, text)
@@ -138,29 +174,6 @@ export function sendCommand(surfaceId: string, text: string): void {
138
174
  sendKey(surfaceId, 'Return')
139
175
  }
140
176
 
141
- /** Focus a surface. */
142
- export function focusSurface(surfaceId: string): void {
143
- // Extract pane ref from surface — need to focus the pane
144
- const panels = cmux('list-panels')
145
- const lines = panels.split('\n').filter(Boolean)
146
-
147
- // Just focus via the pane that contains this surface
148
- // For now, use focus-pane on the pane associated with the surface
149
- const panes = cmux('list-panes')
150
- const paneLines = panes.split('\n').filter(Boolean)
151
- // Try each pane to find the one containing our surface
152
- for (const line of paneLines) {
153
- const paneMatch = line.match(/^[*\s]*(pane:\d+)/)
154
- if (paneMatch) {
155
- const paneSurfaces = cmux('list-pane-surfaces', '--pane', paneMatch[1])
156
- if (paneSurfaces.includes(surfaceId)) {
157
- cmux('focus-pane', '--pane', paneMatch[1])
158
- return
159
- }
160
- }
161
- }
162
- }
163
-
164
177
  /** Read the screen content of a surface. */
165
178
  export function readScreen(surfaceId: string, lines?: number): string {
166
179
  const args = ['read-screen', '--surface', surfaceId]
@@ -168,38 +181,221 @@ export function readScreen(surfaceId: string, lines?: number): string {
168
181
  return cmux(...args)
169
182
  }
170
183
 
171
- /** Resize a pane in the given direction. */
172
- export function resizePane(paneRef: string, direction: 'L' | 'R' | 'U' | 'D', amount = 10): void {
173
- cmux('resize-pane', '--pane', paneRef, `-${direction}`, '--amount', String(amount))
184
+ // ---------------------------------------------------------------------------
185
+ // Notifications & Sidebar
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** Send a desktop notification. */
189
+ export function notify(title: string, body?: string, workspace?: string): void {
190
+ const args = ['notify', '--title', title]
191
+ if (body) args.push('--body', body)
192
+ if (workspace) args.push('--workspace', workspace)
193
+ try { cmux(...args) } catch {}
174
194
  }
175
195
 
176
- /** Get cmux version. */
177
- export function cmuxVersion(): string | null {
178
- try {
179
- return cmux('version')
180
- } catch {
181
- return null
196
+ /** Set a status key in the sidebar. */
197
+ export function setStatus(key: string, value: string, opts?: { icon?: string; color?: string; workspace?: string }): void {
198
+ const args = ['set-status', key, value]
199
+ if (opts?.icon) args.push('--icon', opts.icon)
200
+ if (opts?.color) args.push('--color', opts.color)
201
+ if (opts?.workspace) args.push('--workspace', opts.workspace)
202
+ try { cmux(...args) } catch {}
203
+ }
204
+
205
+ /** Clear a status key from the sidebar. */
206
+ export function clearStatus(key: string, workspace?: string): void {
207
+ const args = ['clear-status', key]
208
+ if (workspace) args.push('--workspace', workspace)
209
+ try { cmux(...args) } catch {}
210
+ }
211
+
212
+ /** Set a progress bar in the sidebar (0.0 – 1.0). */
213
+ export function setProgress(value: number, label?: string, workspace?: string): void {
214
+ const args = ['set-progress', String(Math.min(1, Math.max(0, value)))]
215
+ if (label) args.push('--label', label)
216
+ if (workspace) args.push('--workspace', workspace)
217
+ try { cmux(...args) } catch {}
218
+ }
219
+
220
+ /** Clear the sidebar progress bar. */
221
+ export function clearProgress(workspace?: string): void {
222
+ const args = ['clear-progress']
223
+ if (workspace) args.push('--workspace', workspace)
224
+ try { cmux(...args) } catch {}
225
+ }
226
+
227
+ /** Append a log entry to the sidebar. */
228
+ export function sidebarLog(message: string, level: 'info' | 'progress' | 'success' | 'warning' | 'error' = 'info', workspace?: string): void {
229
+ const args = ['log', '--level', level, '--source', 'cru']
230
+ if (workspace) args.push('--workspace', workspace)
231
+ args.push('--', message)
232
+ try { cmux(...args) } catch {}
233
+ }
234
+
235
+ /** Clear all sidebar log entries. */
236
+ export function clearLog(workspace?: string): void {
237
+ const args = ['clear-log']
238
+ if (workspace) args.push('--workspace', workspace)
239
+ try { cmux(...args) } catch {}
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Lifecycle status pills
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /** Set the team lifecycle phase in the sidebar. Uses SF Symbol icons. */
247
+ export function setPhase(phase: 'spawning' | 'working' | 'done', teamName: string, detail?: string, workspace?: string): void {
248
+ const phases = {
249
+ spawning: { icon: 'circle.dotted', color: '#a78bfa', label: 'spawning' },
250
+ working: { icon: 'bolt.fill', color: '#eab308', label: 'working' },
251
+ done: { icon: 'checkmark.circle.fill', color: '#22c55e', label: 'done' },
182
252
  }
253
+ const p = phases[phase]
254
+ const value = detail ? `${p.label} — ${detail}` : p.label
255
+ setStatus('phase', value, { icon: p.icon, color: p.color, workspace })
183
256
  }
184
257
 
185
- /** Check if cmux socket is responding. */
186
- export function isCmuxAvailable(): boolean {
187
- try {
188
- cmux('ping')
189
- return true
190
- } catch {
191
- return false
258
+ /** Clear the phase pill. */
259
+ export function clearPhase(workspace?: string): void {
260
+ clearStatus('phase', workspace)
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Progress watcher
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Spawn a background process that polls Claude Code's task system
269
+ * and updates the cmux sidebar with worker progress.
270
+ *
271
+ * Reads ~/.claude/tasks/<team>/*.json to track completed vs total tasks.
272
+ * Exits when all tasks are done or the team is closed.
273
+ */
274
+ export function spawnProgressWatcher(teamName: string): void {
275
+ // Capture the current workspace now so the detached script doesn't rely on env vars
276
+ const ws = currentWorkspace()
277
+
278
+ const script = `
279
+ const { readFileSync, readdirSync, existsSync } = require('node:fs');
280
+ const { join } = require('node:path');
281
+ const { execFileSync } = require('node:child_process');
282
+ const { homedir } = require('node:os');
283
+
284
+ const ws = process.env.CRU_WORKSPACE;
285
+ const team = process.env.CRU_TEAM;
286
+ const cmux = (...args) => {
287
+ try { return execFileSync('cmux', [...args, '--workspace', ws], { encoding: 'utf-8', timeout: 5000 }).trim(); }
288
+ catch { return ''; }
289
+ };
290
+
291
+ const setPhase = (icon, color, label) => {
292
+ cmux('set-status', 'phase', label, '--icon', icon, '--color', color);
293
+ };
294
+
295
+ const tasksDir = join(homedir(), '.claude', 'tasks', team);
296
+ const panesFile = join(homedir(), '.claude', 'teams', team, 'cru-panes.json');
297
+ let lastCompleted = 0;
298
+ let staleCount = 0;
299
+
300
+ setPhase('bolt.fill', '#eab308', 'working');
301
+ let tasksStarted = false;
302
+
303
+ while (true) {
304
+ // Stop if team was closed (panes file removed)
305
+ if (!existsSync(panesFile)) break;
306
+
307
+ // Read task files
308
+ let tasks = [];
309
+ try {
310
+ if (existsSync(tasksDir)) {
311
+ for (const f of readdirSync(tasksDir)) {
312
+ if (!f.endsWith('.json')) continue;
313
+ try { tasks.push(JSON.parse(readFileSync(join(tasksDir, f), 'utf-8'))); } catch {}
314
+ }
315
+ }
316
+ } catch {}
317
+
318
+ if (tasks.length > 0) {
319
+ // First time tasks appear — clear the "N workers ready" progress bar
320
+ if (!tasksStarted) {
321
+ tasksStarted = true;
322
+ staleCount = 0;
323
+ cmux('clear-progress');
324
+ }
325
+
326
+ const completed = tasks.filter(t => t.status === 'completed').length;
327
+ const totalTasks = tasks.length;
328
+
329
+ if (completed !== lastCompleted) {
330
+ lastCompleted = completed;
331
+ staleCount = 0;
332
+
333
+ const ratio = totalTasks > 0 ? completed / totalTasks : 0;
334
+ cmux('set-progress', String(ratio), '--label', completed + '/' + totalTasks + ' tasks done');
335
+
336
+ if (completed > 0 && completed < totalTasks) {
337
+ cmux('log', '--level', 'progress', '--source', 'cru', '--', completed + '/' + totalTasks + ' tasks completed');
338
+ setPhase('bolt.fill', '#eab308', 'working — ' + completed + '/' + totalTasks + ' done');
339
+ }
340
+
341
+ if (completed >= totalTasks) {
342
+ cmux('set-progress', '1', '--label', 'All tasks done');
343
+ cmux('log', '--level', 'success', '--source', 'cru', '--', 'All ' + totalTasks + ' tasks completed');
344
+ cmux('notify', '--title', '◫ ' + team, '--body', 'All tasks completed');
345
+ setPhase('checkmark.circle.fill', '#22c55e', 'done');
346
+ cmux('set-status', 'team', team + ' ✓', '--icon', 'person.2.fill', '--color', '#22c55e');
347
+ break;
348
+ }
349
+ } else {
350
+ // Only count staleness once tasks have appeared
351
+ staleCount++;
352
+ }
353
+ }
354
+
355
+ // Give up after 10 min of no progress (only counted after tasks appear)
356
+ if (tasksStarted && staleCount > 300) break;
357
+
358
+ Bun.sleepSync(2000);
359
+ }
360
+ `;
361
+
362
+ // Spawn detached so cru can exit — pass values via env to avoid injection
363
+ Bun.spawn(['bun', '-e', script], {
364
+ detached: true,
365
+ stdio: ['ignore', 'ignore', 'ignore'],
366
+ env: { ...process.env, CRU_WORKSPACE: ws, CRU_TEAM: teamName },
367
+ }).unref()
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Focus
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /** Focus a surface. */
375
+ export function focusSurface(surfaceId: string): void {
376
+ const panes = cmux('list-panes')
377
+ for (const line of panes.split('\n').filter(Boolean)) {
378
+ const paneMatch = line.match(/^[*\s]*(pane:\d+)/)
379
+ if (!paneMatch) continue
380
+ const paneSurfaces = cmux('list-pane-surfaces', '--pane', paneMatch[1])
381
+ if (paneSurfaces.includes(surfaceId)) {
382
+ cmux('focus-pane', '--pane', paneMatch[1])
383
+ return
384
+ }
192
385
  }
193
386
  }
194
387
 
388
+ // ---------------------------------------------------------------------------
389
+ // Grid builder
390
+ // ---------------------------------------------------------------------------
391
+
195
392
  /**
196
- * Build a grid layout for cmux using iterative splits.
393
+ * Build a grid layout using iterative splits.
197
394
  *
198
- * cmux doesn't have tmux-style layout strings, so we build the grid
199
- * imperatively: split the workspace into lead + grid area, then split
200
- * the grid area into rows and columns.
395
+ * All splits use explicit --surface targeting so they work regardless
396
+ * of what the user has focused. No focus changes needed.
201
397
  *
202
- * Returns the surface IDs of the created worker panes.
398
+ * Returns the surface refs of the created worker panes.
203
399
  */
204
400
  export function buildGrid(
205
401
  leadSurface: string,
@@ -210,64 +406,41 @@ export function buildGrid(
210
406
  const isHorizontal = conf.lead.position === 'left' || conf.lead.position === 'right'
211
407
  const workerSurfaces: string[] = []
212
408
 
213
- // Create the first worker by splitting from the lead
214
409
  const firstSplitDir = isHorizontal
215
410
  ? (conf.lead.position === 'left' ? 'right' : 'left')
216
411
  : (conf.lead.position === 'top' ? 'down' : 'up')
217
412
 
218
- // Focus lead before splitting
219
- focusSurface(leadSurface)
220
- Bun.sleepSync(100)
221
-
222
- const firstWorker = splitSurface(firstSplitDir as 'right' | 'down' | 'left' | 'up')
413
+ // First worker: split relative to the lead surface
414
+ const firstWorker = splitSurface(firstSplitDir as 'right' | 'down' | 'left' | 'up', leadSurface)
223
415
  workerSurfaces.push(firstWorker)
224
416
  Bun.sleepSync(100)
225
417
 
226
- // Create remaining workers by splitting existing ones
227
- // Strategy: fill row by row
228
- // Row 0 already has 1 pane. For row 0, add more columns.
229
- // Then for each additional row, split an existing row pane downward.
418
+ if (workerCount <= 1) return workerSurfaces
230
419
 
231
- // First, complete row 0 by splitting horizontally
232
420
  const colDir = isHorizontal ? 'down' : 'right'
233
421
  const rowDir = isHorizontal ? 'right' : 'down'
234
-
235
422
  let created = 1
236
- if (workerCount <= 1) return workerSurfaces
237
423
 
238
- // Build column by column in the first row
424
+ // Build columns in row 0 — each splits relative to the previous worker
239
425
  for (let c = 1; c < cols && created < workerCount; c++) {
240
- focusSurface(workerSurfaces[workerSurfaces.length - 1])
241
- Bun.sleepSync(100)
242
- const s = splitSurface(rowDir as 'right' | 'down')
426
+ const s = splitSurface(rowDir as 'right' | 'down', workerSurfaces[workerSurfaces.length - 1])
243
427
  workerSurfaces.push(s)
244
428
  created++
245
429
  Bun.sleepSync(100)
246
430
  }
247
431
 
248
- // Now add additional rows by splitting each column pane
432
+ // Add additional rows by splitting column panes downward
249
433
  for (let r = 1; r < rows && created < workerCount; r++) {
250
434
  for (let c = 0; c < cols && created < workerCount; c++) {
251
- // Split the pane in row 0, column c (index = c)
252
- const targetIdx = c
253
- if (targetIdx < workerSurfaces.length) {
254
- // Find the bottommost pane in this column
255
- // For simplicity, split the pane at position c + (r-1)*cols
256
- const splitTarget = workerSurfaces[targetIdx + (r - 1) * cols]
257
- if (splitTarget) {
258
- focusSurface(splitTarget)
259
- Bun.sleepSync(100)
260
- const s = splitSurface(colDir as 'right' | 'down')
261
- workerSurfaces.push(s)
262
- created++
263
- Bun.sleepSync(100)
264
- }
435
+ const splitTarget = workerSurfaces[c + (r - 1) * cols]
436
+ if (splitTarget) {
437
+ const s = splitSurface(colDir as 'right' | 'down', splitTarget)
438
+ workerSurfaces.push(s)
439
+ created++
440
+ Bun.sleepSync(100)
265
441
  }
266
442
  }
267
443
  }
268
444
 
269
- // Focus lead pane at the end
270
- focusSurface(leadSurface)
271
-
272
445
  return workerSurfaces
273
446
  }
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
@@ -9,6 +9,7 @@ export interface PaneRecord {
9
9
  workers: Array<{ name: string; paneId: string; color: string }>
10
10
  createdAt: number
11
11
  backend?: 'tmux' | 'ghostty' | 'cmux'
12
+ leadOriginalTitle?: string
12
13
  }
13
14
 
14
15
  function panePath(teamName: string): string {