cru-teams 1.1.0 → 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/package.json +1 -1
- package/src/commands/panes.ts +46 -14
- package/src/lib/cmux.ts +192 -4
package/package.json
CHANGED
package/src/commands/panes.ts
CHANGED
|
@@ -181,6 +181,16 @@ function runGridCmux(c) {
|
|
|
181
181
|
closeSurface,
|
|
182
182
|
renameSurface,
|
|
183
183
|
getSurfaceTitle,
|
|
184
|
+
notify,
|
|
185
|
+
setStatus,
|
|
186
|
+
setProgress,
|
|
187
|
+
sidebarLog,
|
|
188
|
+
clearStatus,
|
|
189
|
+
clearProgress,
|
|
190
|
+
clearLog,
|
|
191
|
+
setPhase,
|
|
192
|
+
clearPhase,
|
|
193
|
+
spawnProgressWatcher,
|
|
184
194
|
} = require('../lib/cmux') as typeof import('../lib/cmux')
|
|
185
195
|
|
|
186
196
|
const teamName = c.args.team
|
|
@@ -219,15 +229,20 @@ function runGridCmux(c) {
|
|
|
219
229
|
console.log(` [grid] found swarm: ${swarm.socket}/${swarm.session}`)
|
|
220
230
|
setRemainOnExit(swarm.socket)
|
|
221
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
|
+
|
|
222
237
|
// 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
238
|
const conf = loadConfig()
|
|
226
239
|
applyConfigOverrides(conf, c.options)
|
|
227
240
|
const totalExpected = expectedWorkers || 1
|
|
228
241
|
const { cols } = computeGrid(totalExpected, conf.layout)
|
|
229
242
|
const mirrored = new Map<string, { cmuxSurface: string; viewSession: string }>()
|
|
230
|
-
const mirroredSurfaces: string[] = []
|
|
243
|
+
const mirroredSurfaces: string[] = []
|
|
244
|
+
|
|
245
|
+
setProgress(0, `0/${totalExpected} workers`)
|
|
231
246
|
|
|
232
247
|
while (Date.now() < deadline) {
|
|
233
248
|
const allWorkers = getWorkerPanes(swarm.socket)
|
|
@@ -241,15 +256,12 @@ function runGridCmux(c) {
|
|
|
241
256
|
let splitDir: 'right' | 'down'
|
|
242
257
|
let splitTarget: string
|
|
243
258
|
if (idx === 0) {
|
|
244
|
-
// First worker: split right from lead
|
|
245
259
|
splitDir = 'right'
|
|
246
260
|
splitTarget = leadSurface
|
|
247
261
|
} else if (row === 0) {
|
|
248
|
-
// Row 0: split right from previous worker
|
|
249
262
|
splitDir = 'right'
|
|
250
263
|
splitTarget = mirroredSurfaces[idx - 1]
|
|
251
264
|
} else {
|
|
252
|
-
// Row 1+: split down from the cell above in same column
|
|
253
265
|
splitDir = 'down'
|
|
254
266
|
splitTarget = mirroredSurfaces[(row - 1) * cols + col]
|
|
255
267
|
}
|
|
@@ -260,6 +272,10 @@ function runGridCmux(c) {
|
|
|
260
272
|
)
|
|
261
273
|
mirrored.set(paneId, result)
|
|
262
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`)
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
if (expectedWorkers && mirrored.size >= expectedWorkers) break
|
|
@@ -275,11 +291,13 @@ function runGridCmux(c) {
|
|
|
275
291
|
}
|
|
276
292
|
|
|
277
293
|
if (mirrored.size === 0) {
|
|
294
|
+
clearProgress()
|
|
295
|
+
clearStatus('team')
|
|
296
|
+
clearPhase()
|
|
278
297
|
return c.error({ code: 'NO_WORKERS', message: 'No worker panes found in swarm.' })
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
// 3. Label surfaces
|
|
282
|
-
const label = teamName || 'cru'
|
|
283
301
|
const leadOriginalTitle = getSurfaceTitle(leadSurface)
|
|
284
302
|
try { renameSurface(leadSurface, `${leadOriginalTitle} (◫ lead @ ${label})`) } catch {}
|
|
285
303
|
|
|
@@ -290,7 +308,12 @@ function runGridCmux(c) {
|
|
|
290
308
|
return { name: names[i], paneId: m.cmuxSurface, color: WORKER_COLORS[i % WORKER_COLORS.length] }
|
|
291
309
|
})
|
|
292
310
|
|
|
293
|
-
// 4.
|
|
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`)
|
|
294
317
|
focusSurface(leadSurface)
|
|
295
318
|
|
|
296
319
|
if (teamName) {
|
|
@@ -302,6 +325,9 @@ function runGridCmux(c) {
|
|
|
302
325
|
workers,
|
|
303
326
|
leadOriginalTitle,
|
|
304
327
|
})
|
|
328
|
+
|
|
329
|
+
// Start background task progress watcher
|
|
330
|
+
spawnProgressWatcher(teamName)
|
|
305
331
|
}
|
|
306
332
|
|
|
307
333
|
return {
|
|
@@ -506,12 +532,18 @@ function runClose(c) {
|
|
|
506
532
|
} catch {}
|
|
507
533
|
}
|
|
508
534
|
|
|
509
|
-
// Restore lead pane's original title
|
|
510
|
-
if (cruPanes?.backend === 'cmux'
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
renameSurface(cruPanes.leadPane, cruPanes.leadOriginalTitle)
|
|
514
|
-
}
|
|
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 {}
|
|
515
547
|
}
|
|
516
548
|
|
|
517
549
|
// Clean up pane tracking file (team data stays for `cru logs` review)
|
package/src/lib/cmux.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* IMPORTANT: All operations pass explicit --surface/--workspace flags
|
|
8
8
|
* so they work regardless of which tab/surface the user has focused.
|
|
9
|
-
*
|
|
9
|
+
* identify() prefers the caller's own surface, falling back to focused
|
|
10
|
+
* only when env vars are stale (e.g. detached scripts).
|
|
10
11
|
*
|
|
11
12
|
* Surface refs (surface:N) are monotonic — they don't shift when
|
|
12
13
|
* other surfaces are created or destroyed, so they're safe to store.
|
|
@@ -65,9 +66,10 @@ export function cmuxVersion(): string | null {
|
|
|
65
66
|
*/
|
|
66
67
|
export function identify(): { surface: string; workspace: string } {
|
|
67
68
|
const info = JSON.parse(cmux('identify'))
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
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')
|
|
71
73
|
return { surface, workspace: workspace || 'workspace:1' }
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -179,6 +181,192 @@ export function readScreen(surfaceId: string, lines?: number): string {
|
|
|
179
181
|
return cmux(...args)
|
|
180
182
|
}
|
|
181
183
|
|
|
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 {}
|
|
194
|
+
}
|
|
195
|
+
|
|
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' },
|
|
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 })
|
|
256
|
+
}
|
|
257
|
+
|
|
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
|
+
|
|
182
370
|
// ---------------------------------------------------------------------------
|
|
183
371
|
// Focus
|
|
184
372
|
// ---------------------------------------------------------------------------
|