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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cru-teams",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "◫ Manage terminal layouts for Claude Code agent teams",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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[] = [] // ordered list for grid positioning
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. Focus lead and save tracking
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 (remove appended team label)
510
- if (cruPanes?.backend === 'cmux' && cruPanes.leadOriginalTitle != null) {
511
- try {
512
- const { renameSurface } = require('../lib/cmux')
513
- renameSurface(cruPanes.leadPane, cruPanes.leadOriginalTitle)
514
- } catch {}
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
- * Never rely on the "focused" or "current" surface.
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
- const surface = info.caller?.surface_ref
69
- const workspace = info.caller?.workspace_ref
70
- if (!surface) throw new Error('cmux identify did not return caller surface_ref')
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
  // ---------------------------------------------------------------------------