@femtomc/mu-agent 26.2.86 → 26.2.88

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.
@@ -1,30 +1,62 @@
1
1
  import { registerMuSubcommand } from "./mu-command-dispatcher.js";
2
2
  const DEFAULT_PREFIX = "mu-sub-";
3
3
  const DEFAULT_ROLE_TAG = "role:worker";
4
+ const DEFAULT_SPAWN_MODE = "worker";
4
5
  const ISSUE_LIST_LIMIT = 40;
5
6
  const MU_CLI_TIMEOUT_MS = 12_000;
7
+ const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
8
+ const MIN_REFRESH_SECONDS = 2;
9
+ const MAX_REFRESH_SECONDS = 120;
10
+ const DEFAULT_STALE_AFTER_MS = 60_000;
11
+ const MIN_STALE_SECONDS = 10;
12
+ const MAX_STALE_SECONDS = 3_600;
6
13
  function shellQuote(value) {
7
14
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
8
15
  }
9
16
  function spawnRunId(now = new Date()) {
10
- return now.toISOString().replaceAll(/[-:TZ.]/g, "").slice(0, 14);
17
+ return now
18
+ .toISOString()
19
+ .replaceAll(/[-:TZ.]/g, "")
20
+ .slice(0, 14);
21
+ }
22
+ function sessionMatchesIssue(sessionName, issueId) {
23
+ return (sessionName === issueId ||
24
+ sessionName.endsWith(`-${issueId}`) ||
25
+ sessionName.includes(`-${issueId}-`) ||
26
+ sessionName.includes(`_${issueId}`));
11
27
  }
12
28
  function issueHasSession(sessions, issueId) {
13
- return sessions.some((session) => session === issueId ||
14
- session.endsWith(`-${issueId}`) ||
15
- session.includes(`-${issueId}-`) ||
16
- session.includes(`_${issueId}`));
29
+ return sessions.some((sessionName) => sessionMatchesIssue(sessionName, issueId));
17
30
  }
18
- function buildSubagentPrompt(issue) {
19
- return [
20
- `Work issue ${issue.id} (${truncateOneLine(issue.title, 80)}).`,
21
- `First run: mu issues claim ${issue.id}.`,
22
- `Keep forum updates in topic issue:${issue.id}.`,
23
- "When done, close with an explicit outcome and summary.",
24
- ].join(" ");
31
+ function buildSubagentPrompt(issue, mode) {
32
+ switch (mode) {
33
+ case "worker":
34
+ return [
35
+ `Work issue ${issue.id} (${truncateOneLine(issue.title, 80)}).`,
36
+ `First run: mu issues claim ${issue.id}.`,
37
+ `Keep forum updates in topic issue:${issue.id}.`,
38
+ "When done, close with an explicit outcome and summary.",
39
+ ].join(" ");
40
+ case "reviewer":
41
+ return [
42
+ `Review issue ${issue.id} (${truncateOneLine(issue.title, 80)}).`,
43
+ `First run: mu issues claim ${issue.id}.`,
44
+ "Validate acceptance criteria with concrete checks and evidence.",
45
+ "Post PASS/FAIL verdict, blockers, and required fixes in topic issue:<id>.",
46
+ "Close the issue with explicit outcome and review summary.",
47
+ ].join(" ");
48
+ case "researcher":
49
+ return [
50
+ `Research issue ${issue.id} (${truncateOneLine(issue.title, 80)}).`,
51
+ `First run: mu issues claim ${issue.id}.`,
52
+ "Collect concrete evidence and options; call out uncertainty explicitly.",
53
+ `Keep findings in topic issue:${issue.id}.`,
54
+ "Close the issue with a concise recommendation and rationale.",
55
+ ].join(" ");
56
+ }
25
57
  }
26
58
  async function spawnIssueTmuxSession(opts) {
27
- const shellCommand = `cd ${shellQuote(opts.cwd)} && mu exec ${shellQuote(buildSubagentPrompt(opts.issue))} ; rc=$?; echo __MU_DONE__:$rc`;
59
+ const shellCommand = `cd ${shellQuote(opts.cwd)} && mu exec ${shellQuote(buildSubagentPrompt(opts.issue, opts.mode))} ; rc=$?; echo __MU_DONE__:$rc`;
28
60
  let proc = null;
29
61
  try {
30
62
  proc = Bun.spawn({
@@ -63,6 +95,10 @@ function createDefaultState() {
63
95
  activeIssues: [],
64
96
  issueError: null,
65
97
  lastUpdatedMs: null,
98
+ refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
99
+ staleAfterMs: DEFAULT_STALE_AFTER_MS,
100
+ spawnPaused: false,
101
+ spawnMode: DEFAULT_SPAWN_MODE,
66
102
  };
67
103
  }
68
104
  function truncateOneLine(input, maxLen = 68) {
@@ -164,7 +200,11 @@ async function runMuCli(args) {
164
200
  timedOut = true;
165
201
  proc?.kill();
166
202
  }, MU_CLI_TIMEOUT_MS);
167
- const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readableText(proc.stdout), readableText(proc.stderr)]);
203
+ const [exitCode, stdout, stderr] = await Promise.all([
204
+ proc.exited,
205
+ readableText(proc.stdout),
206
+ readableText(proc.stderr),
207
+ ]);
168
208
  clearTimeout(timeout);
169
209
  return {
170
210
  exitCode,
@@ -188,7 +228,11 @@ async function listTmuxSessions(prefix) {
188
228
  const message = err instanceof Error ? err.message : String(err);
189
229
  return { sessions: [], error: `failed to launch tmux: ${message}` };
190
230
  }
191
- const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readableText(proc.stdout), readableText(proc.stderr)]);
231
+ const [exitCode, stdout, stderr] = await Promise.all([
232
+ proc.exited,
233
+ readableText(proc.stdout),
234
+ readableText(proc.stderr),
235
+ ]);
192
236
  const stderrTrimmed = stderr.trim();
193
237
  if (exitCode !== 0) {
194
238
  const lowered = stderrTrimmed.toLowerCase();
@@ -251,10 +295,138 @@ async function listIssueSlices(rootId, roleTag) {
251
295
  error: null,
252
296
  };
253
297
  }
254
- function formatIssueLine(ctx, issue) {
298
+ function formatIssueLine(ctx, issue, opts = {}) {
299
+ const marker = opts.marker ?? "•";
300
+ const tone = opts.tone ?? "accent";
255
301
  const id = ctx.ui.theme.fg("dim", issue.id);
256
302
  const priority = ctx.ui.theme.fg("muted", `p${issue.priority}`);
257
- return ` ${ctx.ui.theme.fg("success", "•")} ${id} ${priority} ${truncateOneLine(issue.title)}`;
303
+ const title = ctx.ui.theme.fg("text", truncateOneLine(issue.title));
304
+ return ` ${ctx.ui.theme.fg(tone, marker)} ${id} ${priority} ${title}`;
305
+ }
306
+ function queueMeter(value, total, width = 10) {
307
+ if (width <= 0 || total <= 0) {
308
+ return "";
309
+ }
310
+ const clamped = Math.max(0, Math.min(total, value));
311
+ const full = Math.floor((clamped / total) * width);
312
+ const empty = Math.max(0, width - full);
313
+ return "█".repeat(full) + "░".repeat(empty);
314
+ }
315
+ function formatRefreshAge(lastUpdatedMs) {
316
+ if (lastUpdatedMs == null) {
317
+ return "never";
318
+ }
319
+ const deltaSec = Math.max(0, Math.round((Date.now() - lastUpdatedMs) / 1000));
320
+ if (deltaSec < 60) {
321
+ return `${deltaSec}s ago`;
322
+ }
323
+ const mins = Math.floor(deltaSec / 60);
324
+ if (mins < 60) {
325
+ return `${mins}m ago`;
326
+ }
327
+ const hours = Math.floor(mins / 60);
328
+ return `${hours}h ago`;
329
+ }
330
+ function isRefreshStale(lastUpdatedMs, staleAfterMs) {
331
+ if (lastUpdatedMs == null) {
332
+ return false;
333
+ }
334
+ return Date.now() - lastUpdatedMs > staleAfterMs;
335
+ }
336
+ function computeQueueDrift(sessions, activeIssues) {
337
+ const activeWithoutSessionIds = activeIssues
338
+ .filter((issue) => !issueHasSession(sessions, issue.id))
339
+ .map((issue) => issue.id);
340
+ const orphanSessions = sessions.filter((sessionName) => !activeIssues.some((issue) => sessionMatchesIssue(sessionName, issue.id)));
341
+ return {
342
+ activeWithoutSessionIds,
343
+ orphanSessions,
344
+ };
345
+ }
346
+ function normalizeRoleTag(raw) {
347
+ const trimmed = raw.trim();
348
+ if (!trimmed || trimmed.toLowerCase() === "clear") {
349
+ return null;
350
+ }
351
+ if (trimmed === "worker" || trimmed === "orchestrator" || trimmed === "reviewer" || trimmed === "researcher") {
352
+ return `role:${trimmed}`;
353
+ }
354
+ return trimmed;
355
+ }
356
+ function parseSpawnMode(raw) {
357
+ const value = raw.trim().toLowerCase();
358
+ if (value === "worker" || value === "reviewer" || value === "researcher") {
359
+ return value;
360
+ }
361
+ return null;
362
+ }
363
+ function parseOnOff(raw) {
364
+ const value = (raw ?? "").trim().toLowerCase();
365
+ if (value === "on" || value === "yes" || value === "true" || value === "1") {
366
+ return true;
367
+ }
368
+ if (value === "off" || value === "no" || value === "false" || value === "0") {
369
+ return false;
370
+ }
371
+ return null;
372
+ }
373
+ function parseSnapshotFormat(raw) {
374
+ const value = (raw ?? "compact").trim().toLowerCase();
375
+ return value === "multiline" ? "multiline" : "compact";
376
+ }
377
+ function parseSecondsBounded(secondsRaw, minSeconds, maxSeconds, field) {
378
+ if (typeof secondsRaw !== "number" || !Number.isFinite(secondsRaw)) {
379
+ return { ok: false, error: `${field} must be a number.` };
380
+ }
381
+ const rounded = Math.round(secondsRaw);
382
+ if (rounded < minSeconds || rounded > maxSeconds) {
383
+ return { ok: false, error: `${field} must be ${minSeconds}-${maxSeconds} seconds.` };
384
+ }
385
+ return { ok: true, ms: rounded * 1_000 };
386
+ }
387
+ function subagentsSnapshot(state, format) {
388
+ const issueScope = state.issueRootId ?? "(all roots)";
389
+ const roleScope = state.issueRoleTag ?? "(all roles)";
390
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
391
+ const refreshStale = isRefreshStale(state.lastUpdatedMs, state.staleAfterMs);
392
+ const staleCount = drift.activeWithoutSessionIds.length + drift.orphanSessions.length;
393
+ const health = state.issueError || state.sessionError || refreshStale || staleCount > 0 ? "degraded" : "healthy";
394
+ const refreshAge = formatRefreshAge(state.lastUpdatedMs);
395
+ const paused = state.spawnPaused ? "yes" : "no";
396
+ const refreshSeconds = Math.round(state.refreshIntervalMs / 1_000);
397
+ const staleAfterSeconds = Math.round(state.staleAfterMs / 1_000);
398
+ if (format === "multiline") {
399
+ return [
400
+ "Subagents HUD snapshot",
401
+ `health: ${health}`,
402
+ `prefix: ${state.prefix || "(all sessions)"}`,
403
+ `issue_root: ${issueScope}`,
404
+ `issue_role: ${roleScope}`,
405
+ `spawn_mode: ${state.spawnMode}`,
406
+ `spawn_paused: ${paused}`,
407
+ `queues: ${state.readyIssues.length} ready / ${state.activeIssues.length} active`,
408
+ `sessions: ${state.sessions.length}`,
409
+ `drift_active_without_session: ${drift.activeWithoutSessionIds.length}`,
410
+ `drift_orphan_sessions: ${drift.orphanSessions.length}`,
411
+ `refresh_age: ${refreshAge}`,
412
+ `refresh_stale: ${refreshStale ? "yes" : "no"}`,
413
+ `refresh_seconds: ${refreshSeconds}`,
414
+ `stale_after_seconds: ${staleAfterSeconds}`,
415
+ ].join("\n");
416
+ }
417
+ return [
418
+ "HUD(subagents)",
419
+ `health=${health}`,
420
+ `root=${issueScope}`,
421
+ `role=${roleScope}`,
422
+ `mode=${state.spawnMode}`,
423
+ `paused=${paused}`,
424
+ `ready=${state.readyIssues.length}`,
425
+ `active=${state.activeIssues.length}`,
426
+ `sessions=${state.sessions.length}`,
427
+ `drift=${staleCount}`,
428
+ `refresh=${refreshAge}`,
429
+ ].join(" · ");
258
430
  }
259
431
  function renderSubagentsUi(ctx, state) {
260
432
  if (!ctx.hasUI) {
@@ -265,89 +437,152 @@ function renderSubagentsUi(ctx, state) {
265
437
  ctx.ui.setWidget("mu-subagents", undefined);
266
438
  return;
267
439
  }
268
- const issueScope = state.issueRootId ? `root ${state.issueRootId}` : "all roots";
440
+ const issueScope = state.issueRootId ? `root:${state.issueRootId}` : "all-roots";
269
441
  const roleScope = state.issueRoleTag ? state.issueRoleTag : "(all roles)";
270
- const issueStatus = `${state.readyIssues.length} ready / ${state.activeIssues.length} active`;
271
- if (state.sessionError || state.issueError) {
272
- ctx.ui.setStatus("mu-subagents", ctx.ui.theme.fg("warning", `subagents ${state.sessions.length} · issues ${issueStatus} · degraded`));
273
- }
274
- else {
275
- ctx.ui.setStatus("mu-subagents", ctx.ui.theme.fg("dim", `subagents ${state.sessions.length} · issues ${issueStatus} · ${issueScope}`));
276
- }
442
+ const refreshStale = isRefreshStale(state.lastUpdatedMs, state.staleAfterMs);
443
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
444
+ const staleCount = drift.activeWithoutSessionIds.length + drift.orphanSessions.length;
445
+ const hasError = Boolean(state.sessionError || state.issueError || refreshStale || staleCount > 0);
446
+ const healthColor = hasError ? "warning" : "success";
447
+ const healthLabel = hasError ? "degraded" : "healthy";
448
+ const queueTotal = state.readyIssues.length + state.activeIssues.length;
449
+ const queueBar = queueMeter(state.activeIssues.length, Math.max(1, queueTotal), 10);
450
+ const refreshAge = formatRefreshAge(state.lastUpdatedMs);
451
+ const pausedLabel = state.spawnPaused ? "yes" : "no";
452
+ const pausedColor = state.spawnPaused ? "warning" : "dim";
453
+ const refreshSeconds = Math.round(state.refreshIntervalMs / 1_000);
454
+ const staleAfterSeconds = Math.round(state.staleAfterMs / 1_000);
455
+ ctx.ui.setStatus("mu-subagents", [
456
+ ctx.ui.theme.fg("dim", "subagents"),
457
+ ctx.ui.theme.fg(healthColor, healthLabel),
458
+ ctx.ui.theme.fg("dim", `${state.spawnMode}`),
459
+ ctx.ui.theme.fg(pausedColor, `paused:${pausedLabel}`),
460
+ ctx.ui.theme.fg("dim", `${state.sessions.length} tmux`),
461
+ ctx.ui.theme.fg("dim", `${state.readyIssues.length} ready/${state.activeIssues.length} active`),
462
+ ctx.ui.theme.fg("muted", issueScope),
463
+ ].join(` ${ctx.ui.theme.fg("muted", "·")} `));
277
464
  const lines = [
278
- ctx.ui.theme.fg("accent", "Subagents monitor"),
279
- ctx.ui.theme.fg("dim", ` tmux prefix: ${state.prefix || "(all sessions)"}`),
280
- ctx.ui.theme.fg("dim", ` issue scope: ${issueScope} · tag ${roleScope}`),
465
+ ctx.ui.theme.fg("accent", ctx.ui.theme.bold("Subagents board")),
466
+ ` ${ctx.ui.theme.fg("muted", "health:")} ${ctx.ui.theme.fg(healthColor, healthLabel)}`,
467
+ ` ${ctx.ui.theme.fg("muted", "scope:")} ${ctx.ui.theme.fg("dim", `${issueScope} · ${roleScope}`)}`,
468
+ ` ${ctx.ui.theme.fg("muted", "tmux prefix:")} ${ctx.ui.theme.fg("dim", state.prefix || "(all sessions)")}`,
469
+ ` ${ctx.ui.theme.fg("muted", "spawn mode:")} ${ctx.ui.theme.fg("accent", state.spawnMode)}`,
470
+ ` ${ctx.ui.theme.fg("muted", "spawn paused:")} ${ctx.ui.theme.fg(pausedColor, pausedLabel)}`,
471
+ ` ${ctx.ui.theme.fg("muted", "refresh:")} ${ctx.ui.theme.fg("dim", `${refreshSeconds}s`)} ${ctx.ui.theme.fg("muted", "| stale after:")} ${ctx.ui.theme.fg("dim", `${staleAfterSeconds}s`)}`,
472
+ ` ${ctx.ui.theme.fg("muted", "queues:")} ${ctx.ui.theme.fg("accent", `${state.readyIssues.length} ready`)} ${ctx.ui.theme.fg("muted", "| ")} ${ctx.ui.theme.fg("warning", `${state.activeIssues.length} active`)} ${ctx.ui.theme.fg("dim", queueBar)}`,
473
+ ` ${ctx.ui.theme.fg("muted", "last refresh:")} ${ctx.ui.theme.fg(refreshStale ? "warning" : "dim", refreshAge)}`,
474
+ ` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`,
475
+ ctx.ui.theme.fg("accent", `tmux sessions (${state.sessions.length})`),
281
476
  ];
282
477
  if (state.sessionError) {
283
478
  lines.push(ctx.ui.theme.fg("warning", ` tmux error: ${state.sessionError}`));
284
479
  }
285
480
  else if (state.sessions.length === 0) {
286
- lines.push(ctx.ui.theme.fg("muted", " tmux: (no matching sessions)"));
481
+ lines.push(ctx.ui.theme.fg("muted", " (no matching sessions)"));
287
482
  }
288
483
  else {
289
484
  for (const name of state.sessions.slice(0, 8)) {
290
- lines.push(` ${ctx.ui.theme.fg("success", "●")} ${name}`);
485
+ lines.push(` ${ctx.ui.theme.fg("success", "●")} ${ctx.ui.theme.fg("text", name)}`);
291
486
  }
292
487
  if (state.sessions.length > 8) {
293
488
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.sessions.length - 8} more tmux sessions`));
294
489
  }
295
490
  }
491
+ lines.push(` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`);
296
492
  if (state.issueError) {
297
- lines.push(ctx.ui.theme.fg("warning", ` issue error: ${state.issueError}`));
493
+ lines.push(ctx.ui.theme.fg("warning", `issue error: ${state.issueError}`));
298
494
  }
299
495
  else {
300
- lines.push(ctx.ui.theme.fg("accent", "Ready issues"));
496
+ lines.push(ctx.ui.theme.fg("accent", `ready queue (${state.readyIssues.length})`));
301
497
  if (state.readyIssues.length === 0) {
302
498
  lines.push(ctx.ui.theme.fg("muted", " (no ready issues)"));
303
499
  }
304
500
  else {
305
501
  for (const issue of state.readyIssues.slice(0, 6)) {
306
- lines.push(formatIssueLine(ctx, issue));
502
+ lines.push(formatIssueLine(ctx, issue, { marker: "→", tone: "accent" }));
307
503
  }
308
504
  if (state.readyIssues.length > 6) {
309
505
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.readyIssues.length - 6} more ready issues`));
310
506
  }
311
507
  }
312
- lines.push(ctx.ui.theme.fg("accent", "Active issues"));
508
+ lines.push(ctx.ui.theme.fg("accent", `active queue (${state.activeIssues.length})`));
313
509
  if (state.activeIssues.length === 0) {
314
510
  lines.push(ctx.ui.theme.fg("muted", " (no in-progress issues)"));
315
511
  }
316
512
  else {
317
513
  for (const issue of state.activeIssues.slice(0, 6)) {
318
- lines.push(formatIssueLine(ctx, issue));
514
+ lines.push(formatIssueLine(ctx, issue, { marker: "●", tone: "warning" }));
319
515
  }
320
516
  if (state.activeIssues.length > 6) {
321
517
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.activeIssues.length - 6} more active issues`));
322
518
  }
323
519
  }
324
520
  }
521
+ if (refreshStale) {
522
+ lines.push(ctx.ui.theme.fg("warning", `refresh warning: last successful refresh is stale (>${staleAfterSeconds}s)`));
523
+ }
524
+ if (drift.activeWithoutSessionIds.length > 0) {
525
+ lines.push(ctx.ui.theme.fg("warning", `drift warning: active issues without tmux sessions (${drift.activeWithoutSessionIds.length})`));
526
+ lines.push(ctx.ui.theme.fg("warning", ` missing sessions for: ${drift.activeWithoutSessionIds.slice(0, 8).join(", ")}${drift.activeWithoutSessionIds.length > 8 ? " ..." : ""}`));
527
+ }
528
+ if (drift.orphanSessions.length > 0) {
529
+ lines.push(ctx.ui.theme.fg("warning", `drift warning: tmux sessions without active issues (${drift.orphanSessions.length})`));
530
+ lines.push(ctx.ui.theme.fg("warning", ` orphan sessions: ${drift.orphanSessions.slice(0, 8).join(", ")}${drift.orphanSessions.length > 8 ? " ..." : ""}`));
531
+ }
325
532
  ctx.ui.setWidget("mu-subagents", lines, { placement: "belowEditor" });
326
533
  }
327
534
  function subagentsUsageText() {
328
535
  return [
329
536
  "Usage:",
330
- " /mu subagents on|off|toggle|status|refresh",
537
+ " /mu subagents on|off|toggle|status|refresh|snapshot",
331
538
  " /mu subagents prefix <text|clear>",
332
539
  " /mu subagents root <issue-id|clear>",
333
540
  " /mu subagents role <tag|clear>",
541
+ " /mu subagents mode <worker|reviewer|researcher>",
542
+ " /mu subagents refresh-interval <seconds>",
543
+ " /mu subagents stale-after <seconds>",
544
+ " /mu subagents pause <on|off>",
334
545
  " /mu subagents spawn [N|all]",
335
546
  ].join("\n");
336
547
  }
337
- function normalizeRoleTag(raw) {
338
- const trimmed = raw.trim();
339
- if (!trimmed || trimmed.toLowerCase() === "clear") {
340
- return null;
341
- }
342
- if (trimmed === "worker" || trimmed === "orchestrator") {
343
- return `role:${trimmed}`;
344
- }
345
- return trimmed;
548
+ function subagentsDetails(state) {
549
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
550
+ return {
551
+ enabled: state.enabled,
552
+ prefix: state.prefix,
553
+ issue_root_id: state.issueRootId,
554
+ issue_role_tag: state.issueRoleTag,
555
+ spawn_mode: state.spawnMode,
556
+ spawn_paused: state.spawnPaused,
557
+ refresh_seconds: Math.round(state.refreshIntervalMs / 1_000),
558
+ stale_after_seconds: Math.round(state.staleAfterMs / 1_000),
559
+ sessions: [...state.sessions],
560
+ ready_issue_ids: state.readyIssues.map((issue) => issue.id),
561
+ active_issue_ids: state.activeIssues.map((issue) => issue.id),
562
+ active_without_session_ids: drift.activeWithoutSessionIds,
563
+ orphan_sessions: drift.orphanSessions,
564
+ refresh_stale: isRefreshStale(state.lastUpdatedMs, state.staleAfterMs),
565
+ issue_error: state.issueError,
566
+ session_error: state.sessionError,
567
+ last_updated_ms: state.lastUpdatedMs,
568
+ snapshot_compact: subagentsSnapshot(state, "compact"),
569
+ snapshot_multiline: subagentsSnapshot(state, "multiline"),
570
+ };
571
+ }
572
+ function subagentsToolError(message, state) {
573
+ return {
574
+ content: [{ type: "text", text: message }],
575
+ details: {
576
+ ok: false,
577
+ error: message,
578
+ ...subagentsDetails(state),
579
+ },
580
+ };
346
581
  }
347
582
  export function subagentsUiExtension(pi) {
348
583
  let activeCtx = null;
349
584
  let pollTimer = null;
350
- let state = createDefaultState();
585
+ const state = createDefaultState();
351
586
  const refresh = async (ctx) => {
352
587
  if (!state.enabled) {
353
588
  renderSubagentsUi(ctx, state);
@@ -381,11 +616,368 @@ export function subagentsUiExtension(pi) {
381
616
  return;
382
617
  }
383
618
  void refresh(activeCtx);
384
- }, 8_000);
619
+ }, state.refreshIntervalMs);
620
+ };
621
+ const restartPolling = () => {
622
+ if (!state.enabled) {
623
+ return;
624
+ }
625
+ stopPolling();
626
+ ensurePolling();
385
627
  };
386
628
  const notify = (ctx, message, level = "info") => {
387
629
  ctx.ui.notify(`${message}\n\n${subagentsUsageText()}`, level);
388
630
  };
631
+ const statusSummary = () => {
632
+ const when = state.lastUpdatedMs == null ? "never" : new Date(state.lastUpdatedMs).toLocaleTimeString();
633
+ const status = state.enabled ? "enabled" : "disabled";
634
+ const issueScope = state.issueRootId ?? "(all roots)";
635
+ const issueRole = state.issueRoleTag ?? "(all roles)";
636
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
637
+ const refreshStale = isRefreshStale(state.lastUpdatedMs, state.staleAfterMs);
638
+ const issueError = state.issueError ? `\nissue_error: ${state.issueError}` : "";
639
+ const tmuxError = state.sessionError ? `\ntmux_error: ${state.sessionError}` : "";
640
+ const driftInfo = drift.activeWithoutSessionIds.length > 0 || drift.orphanSessions.length > 0
641
+ ? `\ndrift_active_without_session: ${drift.activeWithoutSessionIds.length}\ndrift_orphan_sessions: ${drift.orphanSessions.length}`
642
+ : "";
643
+ const staleInfo = refreshStale ? "\nrefresh_stale: yes" : "\nrefresh_stale: no";
644
+ return {
645
+ level: state.issueError ||
646
+ state.sessionError ||
647
+ refreshStale ||
648
+ drift.activeWithoutSessionIds.length > 0 ||
649
+ drift.orphanSessions.length > 0
650
+ ? "warning"
651
+ : "info",
652
+ text: [
653
+ `Subagents monitor ${status}`,
654
+ `prefix: ${state.prefix || "(all sessions)"}`,
655
+ `issue_root: ${issueScope}`,
656
+ `issue_role: ${issueRole}`,
657
+ `spawn_mode: ${state.spawnMode}`,
658
+ `spawn_paused: ${state.spawnPaused ? "yes" : "no"}`,
659
+ `refresh_seconds: ${Math.round(state.refreshIntervalMs / 1_000)}`,
660
+ `stale_after_seconds: ${Math.round(state.staleAfterMs / 1_000)}`,
661
+ `sessions: ${state.sessions.length}`,
662
+ `ready_issues: ${state.readyIssues.length}`,
663
+ `active_issues: ${state.activeIssues.length}`,
664
+ `last refresh: ${when}`,
665
+ ].join("\n") +
666
+ issueError +
667
+ tmuxError +
668
+ driftInfo +
669
+ staleInfo,
670
+ };
671
+ };
672
+ const applySubagentsAction = async (params, ctx) => {
673
+ switch (params.action) {
674
+ case "status": {
675
+ const summary = statusSummary();
676
+ return { ok: true, message: summary.text, level: summary.level };
677
+ }
678
+ case "snapshot": {
679
+ const format = parseSnapshotFormat(params.snapshot_format);
680
+ return { ok: true, message: subagentsSnapshot(state, format), level: "info" };
681
+ }
682
+ case "on":
683
+ state.enabled = true;
684
+ ensurePolling();
685
+ await refresh(ctx);
686
+ return { ok: true, message: "Subagents monitor enabled.", level: "info" };
687
+ case "off":
688
+ state.enabled = false;
689
+ stopPolling();
690
+ renderSubagentsUi(ctx, state);
691
+ return { ok: true, message: "Subagents monitor disabled.", level: "info" };
692
+ case "toggle":
693
+ state.enabled = !state.enabled;
694
+ if (state.enabled) {
695
+ ensurePolling();
696
+ await refresh(ctx);
697
+ }
698
+ else {
699
+ stopPolling();
700
+ renderSubagentsUi(ctx, state);
701
+ }
702
+ return { ok: true, message: `Subagents monitor ${state.enabled ? "enabled" : "disabled"}.`, level: "info" };
703
+ case "refresh":
704
+ await refresh(ctx);
705
+ return { ok: true, message: "Subagents monitor refreshed.", level: "info" };
706
+ case "set_prefix": {
707
+ const value = params.prefix?.trim();
708
+ if (!value) {
709
+ return { ok: false, message: "Missing prefix value.", level: "error" };
710
+ }
711
+ state.prefix = value.toLowerCase() === "clear" ? "" : value;
712
+ state.enabled = true;
713
+ ensurePolling();
714
+ await refresh(ctx);
715
+ return { ok: true, message: `Subagents prefix set to ${state.prefix || "(all sessions)"}.`, level: "info" };
716
+ }
717
+ case "set_root": {
718
+ const value = params.root_issue_id?.trim();
719
+ if (!value) {
720
+ return { ok: false, message: "Missing root issue id.", level: "error" };
721
+ }
722
+ state.issueRootId = value.toLowerCase() === "clear" ? null : value;
723
+ state.enabled = true;
724
+ ensurePolling();
725
+ await refresh(ctx);
726
+ return { ok: true, message: `Subagents root set to ${state.issueRootId ?? "(all roots)"}.`, level: "info" };
727
+ }
728
+ case "set_role": {
729
+ const value = params.role_tag?.trim();
730
+ if (!value) {
731
+ return { ok: false, message: "Missing role/tag value.", level: "error" };
732
+ }
733
+ state.issueRoleTag = normalizeRoleTag(value);
734
+ state.enabled = true;
735
+ ensurePolling();
736
+ await refresh(ctx);
737
+ return {
738
+ ok: true,
739
+ message: `Subagents issue tag filter set to ${state.issueRoleTag ?? "(all roles)"}.`,
740
+ level: "info",
741
+ };
742
+ }
743
+ case "set_mode": {
744
+ const modeRaw = params.spawn_mode?.trim() ?? "";
745
+ const mode = parseSpawnMode(modeRaw);
746
+ if (!mode) {
747
+ return { ok: false, message: "Invalid spawn mode.", level: "error" };
748
+ }
749
+ state.spawnMode = mode;
750
+ state.enabled = true;
751
+ ensurePolling();
752
+ await refresh(ctx);
753
+ return { ok: true, message: `Subagents spawn mode set to ${mode}.`, level: "info" };
754
+ }
755
+ case "set_refresh_interval": {
756
+ const parsed = parseSecondsBounded(params.refresh_seconds, MIN_REFRESH_SECONDS, MAX_REFRESH_SECONDS, "refresh_seconds");
757
+ if (!parsed.ok) {
758
+ return { ok: false, message: parsed.error, level: "error" };
759
+ }
760
+ state.refreshIntervalMs = parsed.ms;
761
+ state.enabled = true;
762
+ restartPolling();
763
+ await refresh(ctx);
764
+ return {
765
+ ok: true,
766
+ message: `Subagents refresh interval set to ${Math.round(state.refreshIntervalMs / 1_000)}s.`,
767
+ level: "info",
768
+ };
769
+ }
770
+ case "set_stale_after": {
771
+ const parsed = parseSecondsBounded(params.stale_after_seconds, MIN_STALE_SECONDS, MAX_STALE_SECONDS, "stale_after_seconds");
772
+ if (!parsed.ok) {
773
+ return { ok: false, message: parsed.error, level: "error" };
774
+ }
775
+ state.staleAfterMs = parsed.ms;
776
+ state.enabled = true;
777
+ ensurePolling();
778
+ await refresh(ctx);
779
+ return {
780
+ ok: true,
781
+ message: `Subagents stale threshold set to ${Math.round(state.staleAfterMs / 1_000)}s.`,
782
+ level: "info",
783
+ };
784
+ }
785
+ case "set_spawn_paused": {
786
+ if (typeof params.spawn_paused !== "boolean") {
787
+ return { ok: false, message: "spawn_paused must be a boolean.", level: "error" };
788
+ }
789
+ state.spawnPaused = params.spawn_paused;
790
+ state.enabled = true;
791
+ ensurePolling();
792
+ await refresh(ctx);
793
+ return {
794
+ ok: true,
795
+ message: `Subagents spawn pause set to ${state.spawnPaused ? "on" : "off"}.`,
796
+ level: "info",
797
+ };
798
+ }
799
+ case "update": {
800
+ const changed = [];
801
+ let refreshIntervalChanged = false;
802
+ if (params.prefix !== undefined) {
803
+ if (typeof params.prefix !== "string") {
804
+ return { ok: false, message: "prefix must be a string.", level: "error" };
805
+ }
806
+ const trimmed = params.prefix.trim();
807
+ if (trimmed.length === 0) {
808
+ return { ok: false, message: "prefix must not be empty.", level: "error" };
809
+ }
810
+ state.prefix = trimmed.toLowerCase() === "clear" ? "" : trimmed;
811
+ changed.push("prefix");
812
+ }
813
+ if (params.root_issue_id !== undefined) {
814
+ if (typeof params.root_issue_id !== "string") {
815
+ return { ok: false, message: "root_issue_id must be a string.", level: "error" };
816
+ }
817
+ const trimmed = params.root_issue_id.trim();
818
+ if (trimmed.length === 0) {
819
+ return { ok: false, message: "root_issue_id must not be empty.", level: "error" };
820
+ }
821
+ state.issueRootId = trimmed.toLowerCase() === "clear" ? null : trimmed;
822
+ changed.push("root_issue_id");
823
+ }
824
+ if (params.role_tag !== undefined) {
825
+ if (typeof params.role_tag !== "string") {
826
+ return { ok: false, message: "role_tag must be a string.", level: "error" };
827
+ }
828
+ const trimmed = params.role_tag.trim();
829
+ if (trimmed.length === 0) {
830
+ return { ok: false, message: "role_tag must not be empty.", level: "error" };
831
+ }
832
+ state.issueRoleTag = normalizeRoleTag(trimmed);
833
+ changed.push("role_tag");
834
+ }
835
+ if (params.spawn_mode !== undefined) {
836
+ if (typeof params.spawn_mode !== "string") {
837
+ return { ok: false, message: "spawn_mode must be a string.", level: "error" };
838
+ }
839
+ const mode = parseSpawnMode(params.spawn_mode);
840
+ if (!mode) {
841
+ return { ok: false, message: "Invalid spawn mode.", level: "error" };
842
+ }
843
+ state.spawnMode = mode;
844
+ changed.push("spawn_mode");
845
+ }
846
+ if (params.refresh_seconds !== undefined) {
847
+ const parsed = parseSecondsBounded(params.refresh_seconds, MIN_REFRESH_SECONDS, MAX_REFRESH_SECONDS, "refresh_seconds");
848
+ if (!parsed.ok) {
849
+ return { ok: false, message: parsed.error, level: "error" };
850
+ }
851
+ state.refreshIntervalMs = parsed.ms;
852
+ refreshIntervalChanged = true;
853
+ changed.push("refresh_seconds");
854
+ }
855
+ if (params.stale_after_seconds !== undefined) {
856
+ const parsed = parseSecondsBounded(params.stale_after_seconds, MIN_STALE_SECONDS, MAX_STALE_SECONDS, "stale_after_seconds");
857
+ if (!parsed.ok) {
858
+ return { ok: false, message: parsed.error, level: "error" };
859
+ }
860
+ state.staleAfterMs = parsed.ms;
861
+ changed.push("stale_after_seconds");
862
+ }
863
+ if (params.spawn_paused !== undefined) {
864
+ if (typeof params.spawn_paused !== "boolean") {
865
+ return { ok: false, message: "spawn_paused must be a boolean.", level: "error" };
866
+ }
867
+ state.spawnPaused = params.spawn_paused;
868
+ changed.push("spawn_paused");
869
+ }
870
+ if (changed.length === 0) {
871
+ return { ok: false, message: "No update fields provided.", level: "error" };
872
+ }
873
+ state.enabled = true;
874
+ if (refreshIntervalChanged) {
875
+ restartPolling();
876
+ }
877
+ else {
878
+ ensurePolling();
879
+ }
880
+ await refresh(ctx);
881
+ return { ok: true, message: `Subagents monitor updated (${changed.join(", ")}).`, level: "info" };
882
+ }
883
+ case "spawn": {
884
+ if (state.spawnPaused) {
885
+ return {
886
+ ok: false,
887
+ message: "Spawn is paused. Use set_spawn_paused=false before spawning.",
888
+ level: "error",
889
+ };
890
+ }
891
+ if (!state.issueRootId) {
892
+ return {
893
+ ok: false,
894
+ message: "Set a root first (`/mu subagents root <root-id>`) before spawning.",
895
+ level: "error",
896
+ };
897
+ }
898
+ let spawnLimit = null;
899
+ if (params.count != null && params.count !== "all") {
900
+ const countNum = typeof params.count === "number" ? params.count : Number.parseInt(String(params.count), 10);
901
+ const parsed = Math.trunc(countNum);
902
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > ISSUE_LIST_LIMIT) {
903
+ return { ok: false, message: `Spawn count must be 1-${ISSUE_LIST_LIMIT} or 'all'.`, level: "error" };
904
+ }
905
+ spawnLimit = parsed;
906
+ }
907
+ const issueSlices = await listIssueSlices(state.issueRootId, state.issueRoleTag);
908
+ state.readyIssues = issueSlices.ready;
909
+ state.activeIssues = issueSlices.active;
910
+ state.issueError = issueSlices.error;
911
+ if (issueSlices.error) {
912
+ state.enabled = true;
913
+ ensurePolling();
914
+ renderSubagentsUi(ctx, state);
915
+ return { ok: false, message: `Cannot spawn: ${issueSlices.error}`, level: "error" };
916
+ }
917
+ const candidates = spawnLimit == null ? issueSlices.ready : issueSlices.ready.slice(0, spawnLimit);
918
+ if (candidates.length === 0) {
919
+ state.enabled = true;
920
+ ensurePolling();
921
+ await refresh(ctx);
922
+ return { ok: true, message: "No ready issues to spawn for current root/tag filter.", level: "info" };
923
+ }
924
+ const spawnPrefix = state.prefix.length > 0 ? state.prefix : DEFAULT_PREFIX;
925
+ const tmux = await listTmuxSessions(spawnPrefix);
926
+ if (tmux.error) {
927
+ state.sessionError = tmux.error;
928
+ state.enabled = true;
929
+ ensurePolling();
930
+ renderSubagentsUi(ctx, state);
931
+ return { ok: false, message: `Cannot spawn: ${tmux.error}`, level: "error" };
932
+ }
933
+ const existingSessions = [...tmux.sessions];
934
+ const runId = spawnRunId();
935
+ const launched = [];
936
+ const skipped = [];
937
+ const failed = [];
938
+ for (const issue of candidates) {
939
+ if (issueHasSession(existingSessions, issue.id)) {
940
+ skipped.push(`${issue.id} (session exists)`);
941
+ continue;
942
+ }
943
+ let sessionName = `${spawnPrefix}${runId}-${issue.id}`;
944
+ if (existingSessions.includes(sessionName)) {
945
+ let suffix = 1;
946
+ while (existingSessions.includes(`${sessionName}-${suffix}`)) {
947
+ suffix += 1;
948
+ }
949
+ sessionName = `${sessionName}-${suffix}`;
950
+ }
951
+ const spawned = await spawnIssueTmuxSession({
952
+ cwd: ctx.cwd,
953
+ sessionName,
954
+ issue,
955
+ mode: state.spawnMode,
956
+ });
957
+ if (spawned.ok) {
958
+ existingSessions.push(sessionName);
959
+ launched.push(`${issue.id} -> ${sessionName}`);
960
+ }
961
+ else {
962
+ failed.push(`${issue.id} (${spawned.error ?? "unknown error"})`);
963
+ }
964
+ }
965
+ state.enabled = true;
966
+ ensurePolling();
967
+ await refresh(ctx);
968
+ const summary = [
969
+ `Spawned ${launched.length}/${candidates.length} ready issue sessions (mode=${state.spawnMode}).`,
970
+ launched.length > 0 ? `launched: ${launched.join(", ")}` : "launched: (none)",
971
+ `skipped: ${skipped.length}`,
972
+ `failed: ${failed.length}`,
973
+ ];
974
+ if (failed.length > 0) {
975
+ summary.push(`failures: ${failed.join("; ")}`);
976
+ }
977
+ return { ok: true, message: summary.join("\n"), level: failed.length > 0 ? "warning" : "info" };
978
+ }
979
+ }
980
+ };
389
981
  pi.on("session_start", async (_event, ctx) => {
390
982
  activeCtx = ctx;
391
983
  if (state.enabled) {
@@ -407,195 +999,142 @@ export function subagentsUiExtension(pi) {
407
999
  registerMuSubcommand(pi, {
408
1000
  subcommand: "subagents",
409
1001
  summary: "Monitor tmux subagent sessions + issue queue, and spawn ready issue sessions",
410
- usage: "/mu subagents on|off|toggle|status|refresh|prefix|root|role|spawn",
1002
+ usage: "/mu subagents on|off|toggle|status|refresh|snapshot|prefix|root|role|mode|refresh-interval|stale-after|pause|spawn",
411
1003
  handler: async (args, ctx) => {
412
1004
  activeCtx = ctx;
413
1005
  const tokens = args
414
1006
  .trim()
415
1007
  .split(/\s+/)
416
1008
  .filter((token) => token.length > 0);
417
- if (tokens.length === 0 || tokens[0] === "status") {
418
- const when = state.lastUpdatedMs == null ? "never" : new Date(state.lastUpdatedMs).toLocaleTimeString();
419
- const status = state.enabled ? "enabled" : "disabled";
420
- const issueScope = state.issueRootId ?? "(all roots)";
421
- const issueRole = state.issueRoleTag ?? "(all roles)";
422
- const issueError = state.issueError ? `\nissue_error: ${state.issueError}` : "";
423
- const tmuxError = state.sessionError ? `\ntmux_error: ${state.sessionError}` : "";
424
- ctx.ui.notify([
425
- `Subagents monitor ${status}`,
426
- `prefix: ${state.prefix || "(all sessions)"}`,
427
- `issue_root: ${issueScope}`,
428
- `issue_role: ${issueRole}`,
429
- `sessions: ${state.sessions.length}`,
430
- `ready_issues: ${state.readyIssues.length}`,
431
- `active_issues: ${state.activeIssues.length}`,
432
- `last refresh: ${when}`,
433
- ].join("\n") + issueError + tmuxError, state.issueError || state.sessionError ? "warning" : "info");
434
- renderSubagentsUi(ctx, state);
435
- return;
436
- }
437
- switch (tokens[0]) {
1009
+ const command = tokens[0] ?? "status";
1010
+ let params;
1011
+ switch (command) {
1012
+ case "status":
1013
+ params = { action: "status" };
1014
+ break;
1015
+ case "snapshot":
1016
+ params = { action: "snapshot", snapshot_format: tokens[1] };
1017
+ break;
438
1018
  case "on":
439
- state.enabled = true;
440
- ensurePolling();
441
- await refresh(ctx);
442
- ctx.ui.notify("Subagents monitor enabled.", "info");
443
- return;
1019
+ params = { action: "on" };
1020
+ break;
444
1021
  case "off":
445
- state.enabled = false;
446
- stopPolling();
447
- renderSubagentsUi(ctx, state);
448
- ctx.ui.notify("Subagents monitor disabled.", "info");
449
- return;
1022
+ params = { action: "off" };
1023
+ break;
450
1024
  case "toggle":
451
- state.enabled = !state.enabled;
452
- if (state.enabled) {
453
- ensurePolling();
454
- await refresh(ctx);
455
- }
456
- else {
457
- stopPolling();
458
- renderSubagentsUi(ctx, state);
459
- }
460
- ctx.ui.notify(`Subagents monitor ${state.enabled ? "enabled" : "disabled"}.`, "info");
461
- return;
462
- case "refresh": {
463
- await refresh(ctx);
464
- ctx.ui.notify("Subagents monitor refreshed.", "info");
465
- return;
466
- }
467
- case "prefix": {
468
- const value = tokens.slice(1).join(" ").trim();
469
- if (!value) {
470
- notify(ctx, "Missing prefix value.", "error");
471
- return;
472
- }
473
- state.prefix = value.toLowerCase() === "clear" ? "" : value;
474
- state.enabled = true;
475
- ensurePolling();
476
- await refresh(ctx);
477
- ctx.ui.notify(`Subagents prefix set to ${state.prefix || "(all sessions)"}.`, "info");
478
- return;
479
- }
480
- case "root": {
481
- const value = tokens.slice(1).join(" ").trim();
482
- if (!value) {
483
- notify(ctx, "Missing root issue id.", "error");
484
- return;
485
- }
486
- state.issueRootId = value.toLowerCase() === "clear" ? null : value;
487
- state.enabled = true;
488
- ensurePolling();
489
- await refresh(ctx);
490
- ctx.ui.notify(`Subagents root set to ${state.issueRootId ?? "(all roots)"}.`, "info");
491
- return;
492
- }
493
- case "role": {
494
- const value = tokens.slice(1).join(" ").trim();
495
- if (!value) {
496
- notify(ctx, "Missing role/tag value.", "error");
497
- return;
498
- }
499
- state.issueRoleTag = normalizeRoleTag(value);
500
- state.enabled = true;
501
- ensurePolling();
502
- await refresh(ctx);
503
- ctx.ui.notify(`Subagents issue tag filter set to ${state.issueRoleTag ?? "(all roles)"}.`, "info");
504
- return;
1025
+ params = { action: "toggle" };
1026
+ break;
1027
+ case "refresh":
1028
+ params = { action: "refresh" };
1029
+ break;
1030
+ case "prefix":
1031
+ params = { action: "set_prefix", prefix: tokens.slice(1).join(" ") };
1032
+ break;
1033
+ case "root":
1034
+ params = { action: "set_root", root_issue_id: tokens.slice(1).join(" ") };
1035
+ break;
1036
+ case "role":
1037
+ params = { action: "set_role", role_tag: tokens.slice(1).join(" ") };
1038
+ break;
1039
+ case "mode":
1040
+ params = { action: "set_mode", spawn_mode: tokens[1] };
1041
+ break;
1042
+ case "refresh-interval":
1043
+ params = { action: "set_refresh_interval", refresh_seconds: Number.parseFloat(tokens[1] ?? "") };
1044
+ break;
1045
+ case "stale-after":
1046
+ params = { action: "set_stale_after", stale_after_seconds: Number.parseFloat(tokens[1] ?? "") };
1047
+ break;
1048
+ case "pause": {
1049
+ const parsed = parseOnOff(tokens[1]);
1050
+ params = { action: "set_spawn_paused", spawn_paused: parsed ?? undefined };
1051
+ break;
505
1052
  }
506
- case "spawn": {
507
- if (!state.issueRootId) {
508
- notify(ctx, "Set a root first (`/mu subagents root <root-id>`) before spawning.", "error");
509
- return;
510
- }
511
- let spawnLimit = null;
512
- const limitToken = tokens[1]?.trim();
513
- if (limitToken && limitToken.toLowerCase() !== "all") {
514
- const parsed = Number.parseInt(limitToken, 10);
515
- if (!Number.isFinite(parsed) || parsed < 1 || parsed > ISSUE_LIST_LIMIT) {
516
- notify(ctx, `Spawn count must be 1-${ISSUE_LIST_LIMIT} or 'all'.`, "error");
517
- return;
518
- }
519
- spawnLimit = parsed;
520
- }
521
- const issueSlices = await listIssueSlices(state.issueRootId, state.issueRoleTag);
522
- state.readyIssues = issueSlices.ready;
523
- state.activeIssues = issueSlices.active;
524
- state.issueError = issueSlices.error;
525
- if (issueSlices.error) {
526
- state.enabled = true;
527
- ensurePolling();
528
- renderSubagentsUi(ctx, state);
529
- notify(ctx, `Cannot spawn: ${issueSlices.error}`, "error");
530
- return;
531
- }
532
- const candidates = spawnLimit == null ? issueSlices.ready : issueSlices.ready.slice(0, spawnLimit);
533
- if (candidates.length === 0) {
534
- state.enabled = true;
535
- ensurePolling();
536
- await refresh(ctx);
537
- ctx.ui.notify("No ready issues to spawn for current root/tag filter.", "info");
538
- return;
539
- }
540
- const spawnPrefix = state.prefix.length > 0 ? state.prefix : DEFAULT_PREFIX;
541
- const tmux = await listTmuxSessions(spawnPrefix);
542
- if (tmux.error) {
543
- state.sessionError = tmux.error;
544
- state.enabled = true;
545
- ensurePolling();
546
- renderSubagentsUi(ctx, state);
547
- notify(ctx, `Cannot spawn: ${tmux.error}`, "error");
548
- return;
549
- }
550
- const existingSessions = [...tmux.sessions];
551
- const runId = spawnRunId();
552
- const launched = [];
553
- const skipped = [];
554
- const failed = [];
555
- for (const issue of candidates) {
556
- if (issueHasSession(existingSessions, issue.id)) {
557
- skipped.push(`${issue.id} (session exists)`);
558
- continue;
559
- }
560
- let sessionName = `${spawnPrefix}${runId}-${issue.id}`;
561
- if (existingSessions.includes(sessionName)) {
562
- let suffix = 1;
563
- while (existingSessions.includes(`${sessionName}-${suffix}`)) {
564
- suffix += 1;
1053
+ case "spawn":
1054
+ params = {
1055
+ action: "spawn",
1056
+ count: (() => {
1057
+ const token = tokens[1]?.trim();
1058
+ if (!token || token.toLowerCase() === "all") {
1059
+ return "all";
565
1060
  }
566
- sessionName = `${sessionName}-${suffix}`;
567
- }
568
- const spawned = await spawnIssueTmuxSession({
569
- cwd: ctx.cwd,
570
- sessionName,
571
- issue,
572
- });
573
- if (spawned.ok) {
574
- existingSessions.push(sessionName);
575
- launched.push(`${issue.id} -> ${sessionName}`);
576
- }
577
- else {
578
- failed.push(`${issue.id} (${spawned.error ?? "unknown error"})`);
579
- }
580
- }
581
- state.enabled = true;
582
- ensurePolling();
583
- await refresh(ctx);
584
- const summary = [
585
- `Spawned ${launched.length}/${candidates.length} ready issue sessions.`,
586
- launched.length > 0 ? `launched: ${launched.join(", ")}` : "launched: (none)",
587
- `skipped: ${skipped.length}`,
588
- `failed: ${failed.length}`,
589
- ];
590
- if (failed.length > 0) {
591
- summary.push(`failures: ${failed.join("; ")}`);
592
- }
593
- ctx.ui.notify(summary.join("\n"), failed.length > 0 ? "warning" : "info");
594
- return;
595
- }
1061
+ const parsed = Number.parseInt(token, 10);
1062
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
1063
+ })(),
1064
+ };
1065
+ break;
596
1066
  default:
597
- notify(ctx, `Unknown subagents command: ${tokens[0]}`, "error");
1067
+ notify(ctx, `Unknown subagents command: ${command}`, "error");
1068
+ return;
1069
+ }
1070
+ const result = await applySubagentsAction(params, ctx);
1071
+ if (!result.ok) {
1072
+ notify(ctx, result.message, result.level);
1073
+ return;
1074
+ }
1075
+ ctx.ui.notify(result.message, result.level);
1076
+ },
1077
+ });
1078
+ pi.registerTool({
1079
+ name: "mu_subagents_hud",
1080
+ label: "mu subagents HUD",
1081
+ description: "Control or inspect subagents HUD state, including tmux scope, queue filters, spawn profile, and health policies.",
1082
+ parameters: {
1083
+ type: "object",
1084
+ properties: {
1085
+ action: {
1086
+ type: "string",
1087
+ enum: [
1088
+ "status",
1089
+ "snapshot",
1090
+ "on",
1091
+ "off",
1092
+ "toggle",
1093
+ "refresh",
1094
+ "set_prefix",
1095
+ "set_root",
1096
+ "set_role",
1097
+ "set_mode",
1098
+ "set_refresh_interval",
1099
+ "set_stale_after",
1100
+ "set_spawn_paused",
1101
+ "update",
1102
+ "spawn",
1103
+ ],
1104
+ },
1105
+ prefix: { type: "string" },
1106
+ root_issue_id: { type: "string" },
1107
+ role_tag: { type: "string" },
1108
+ spawn_mode: { type: "string", enum: ["worker", "reviewer", "researcher"] },
1109
+ refresh_seconds: { type: "number", minimum: MIN_REFRESH_SECONDS, maximum: MAX_REFRESH_SECONDS },
1110
+ stale_after_seconds: { type: "number", minimum: MIN_STALE_SECONDS, maximum: MAX_STALE_SECONDS },
1111
+ spawn_paused: { type: "boolean" },
1112
+ snapshot_format: { type: "string", enum: ["compact", "multiline"] },
1113
+ count: {
1114
+ anyOf: [
1115
+ { type: "integer", minimum: 1, maximum: ISSUE_LIST_LIMIT },
1116
+ { type: "string", enum: ["all"] },
1117
+ ],
1118
+ },
1119
+ },
1120
+ required: ["action"],
1121
+ additionalProperties: false,
1122
+ },
1123
+ execute: async (_toolCallId, paramsRaw, _signal, _onUpdate, ctx) => {
1124
+ activeCtx = ctx;
1125
+ const params = paramsRaw;
1126
+ const result = await applySubagentsAction(params, ctx);
1127
+ if (!result.ok) {
1128
+ return subagentsToolError(result.message, state);
598
1129
  }
1130
+ return {
1131
+ content: [{ type: "text", text: result.message }],
1132
+ details: {
1133
+ ok: true,
1134
+ action: params.action,
1135
+ ...subagentsDetails(state),
1136
+ },
1137
+ };
599
1138
  },
600
1139
  });
601
1140
  }