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 +1 -0
- package/package.json +1 -1
- package/src/cli.ts +3 -1
- package/src/commands/panes.ts +192 -75
- package/src/lib/cmux.ts +319 -146
- package/src/lib/mirror.ts +44 -2
- package/src/lib/panes.ts +1 -0
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
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/panes.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
169
|
-
let
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
281
|
+
if (expectedWorkers && mirrored.size >= expectedWorkers) break
|
|
208
282
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
227
|
-
|
|
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
|
-
|
|
329
|
+
// Start background task progress watcher
|
|
330
|
+
spawnProgressWatcher(teamName)
|
|
331
|
+
}
|
|
235
332
|
|
|
236
333
|
return {
|
|
237
334
|
applied: true,
|
|
238
335
|
backend: 'cmux',
|
|
239
|
-
|
|
240
|
-
workers:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
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
|
|
38
|
-
export function
|
|
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
|
-
|
|
67
|
-
return
|
|
42
|
+
cmux('ping')
|
|
43
|
+
return true
|
|
68
44
|
} catch {
|
|
69
|
-
return
|
|
45
|
+
return false
|
|
70
46
|
}
|
|
71
47
|
}
|
|
72
48
|
|
|
73
|
-
/** Get
|
|
74
|
-
export function
|
|
49
|
+
/** Get cmux version. */
|
|
50
|
+
export function cmuxVersion(): string | null {
|
|
75
51
|
try {
|
|
76
|
-
|
|
77
|
-
return info.caller?.workspace_ref || 'workspace:1'
|
|
52
|
+
return cmux('version')
|
|
78
53
|
} catch {
|
|
79
|
-
return
|
|
54
|
+
return null
|
|
80
55
|
}
|
|
81
56
|
}
|
|
82
57
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
/**
|
|
177
|
-
export function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
/**
|
|
186
|
-
export function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
393
|
+
* Build a grid layout using iterative splits.
|
|
197
394
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
|
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
|
-
//
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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