@femtomc/mu-agent 26.2.87 → 26.2.89

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 +1 @@
1
- {"version":3,"file":"subagents-ui.d.ts","sourceRoot":"","sources":["../../src/extensions/subagents-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AA+fpF,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,YAAY,QAwWpD;AAED,eAAe,oBAAoB,CAAC"}
1
+ {"version":3,"file":"subagents-ui.d.ts","sourceRoot":"","sources":["../../src/extensions/subagents-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AA2uBpF,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,YAAY,QAwmBpD;AAED,eAAe,oBAAoB,CAAC"}
@@ -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();
@@ -283,6 +327,107 @@ function formatRefreshAge(lastUpdatedMs) {
283
327
  const hours = Math.floor(mins / 60);
284
328
  return `${hours}h ago`;
285
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(" · ");
430
+ }
286
431
  function renderSubagentsUi(ctx, state) {
287
432
  if (!ctx.hasUI) {
288
433
  return;
@@ -294,15 +439,24 @@ function renderSubagentsUi(ctx, state) {
294
439
  }
295
440
  const issueScope = state.issueRootId ? `root:${state.issueRootId}` : "all-roots";
296
441
  const roleScope = state.issueRoleTag ? state.issueRoleTag : "(all roles)";
297
- const hasError = Boolean(state.sessionError || state.issueError);
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);
298
446
  const healthColor = hasError ? "warning" : "success";
299
447
  const healthLabel = hasError ? "degraded" : "healthy";
300
448
  const queueTotal = state.readyIssues.length + state.activeIssues.length;
301
449
  const queueBar = queueMeter(state.activeIssues.length, Math.max(1, queueTotal), 10);
302
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);
303
455
  ctx.ui.setStatus("mu-subagents", [
304
456
  ctx.ui.theme.fg("dim", "subagents"),
305
457
  ctx.ui.theme.fg(healthColor, healthLabel),
458
+ ctx.ui.theme.fg("dim", `${state.spawnMode}`),
459
+ ctx.ui.theme.fg(pausedColor, `paused:${pausedLabel}`),
306
460
  ctx.ui.theme.fg("dim", `${state.sessions.length} tmux`),
307
461
  ctx.ui.theme.fg("dim", `${state.readyIssues.length} ready/${state.activeIssues.length} active`),
308
462
  ctx.ui.theme.fg("muted", issueScope),
@@ -312,8 +466,11 @@ function renderSubagentsUi(ctx, state) {
312
466
  ` ${ctx.ui.theme.fg("muted", "health:")} ${ctx.ui.theme.fg(healthColor, healthLabel)}`,
313
467
  ` ${ctx.ui.theme.fg("muted", "scope:")} ${ctx.ui.theme.fg("dim", `${issueScope} · ${roleScope}`)}`,
314
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`)}`,
315
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)}`,
316
- ` ${ctx.ui.theme.fg("muted", "last refresh:")} ${ctx.ui.theme.fg("dim", refreshAge)}`,
473
+ ` ${ctx.ui.theme.fg("muted", "last refresh:")} ${ctx.ui.theme.fg(refreshStale ? "warning" : "dim", refreshAge)}`,
317
474
  ` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`,
318
475
  ctx.ui.theme.fg("accent", `tmux sessions (${state.sessions.length})`),
319
476
  ];
@@ -361,40 +518,55 @@ function renderSubagentsUi(ctx, state) {
361
518
  }
362
519
  }
363
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
+ }
364
532
  ctx.ui.setWidget("mu-subagents", lines, { placement: "belowEditor" });
365
533
  }
366
534
  function subagentsUsageText() {
367
535
  return [
368
536
  "Usage:",
369
- " /mu subagents on|off|toggle|status|refresh",
537
+ " /mu subagents on|off|toggle|status|refresh|snapshot",
370
538
  " /mu subagents prefix <text|clear>",
371
539
  " /mu subagents root <issue-id|clear>",
372
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>",
373
545
  " /mu subagents spawn [N|all]",
374
546
  ].join("\n");
375
547
  }
376
- function normalizeRoleTag(raw) {
377
- const trimmed = raw.trim();
378
- if (!trimmed || trimmed.toLowerCase() === "clear") {
379
- return null;
380
- }
381
- if (trimmed === "worker" || trimmed === "orchestrator") {
382
- return `role:${trimmed}`;
383
- }
384
- return trimmed;
385
- }
386
548
  function subagentsDetails(state) {
549
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
387
550
  return {
388
551
  enabled: state.enabled,
389
552
  prefix: state.prefix,
390
553
  issue_root_id: state.issueRootId,
391
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),
392
559
  sessions: [...state.sessions],
393
560
  ready_issue_ids: state.readyIssues.map((issue) => issue.id),
394
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),
395
565
  issue_error: state.issueError,
396
566
  session_error: state.sessionError,
397
567
  last_updated_ms: state.lastUpdatedMs,
568
+ snapshot_compact: subagentsSnapshot(state, "compact"),
569
+ snapshot_multiline: subagentsSnapshot(state, "multiline"),
398
570
  };
399
571
  }
400
572
  function subagentsToolError(message, state) {
@@ -410,7 +582,7 @@ function subagentsToolError(message, state) {
410
582
  export function subagentsUiExtension(pi) {
411
583
  let activeCtx = null;
412
584
  let pollTimer = null;
413
- let state = createDefaultState();
585
+ const state = createDefaultState();
414
586
  const refresh = async (ctx) => {
415
587
  if (!state.enabled) {
416
588
  renderSubagentsUi(ctx, state);
@@ -444,7 +616,14 @@ export function subagentsUiExtension(pi) {
444
616
  return;
445
617
  }
446
618
  void refresh(activeCtx);
447
- }, 8_000);
619
+ }, state.refreshIntervalMs);
620
+ };
621
+ const restartPolling = () => {
622
+ if (!state.enabled) {
623
+ return;
624
+ }
625
+ stopPolling();
626
+ ensurePolling();
448
627
  };
449
628
  const notify = (ctx, message, level = "info") => {
450
629
  ctx.ui.notify(`${message}\n\n${subagentsUsageText()}`, level);
@@ -454,20 +633,40 @@ export function subagentsUiExtension(pi) {
454
633
  const status = state.enabled ? "enabled" : "disabled";
455
634
  const issueScope = state.issueRootId ?? "(all roots)";
456
635
  const issueRole = state.issueRoleTag ?? "(all roles)";
636
+ const drift = computeQueueDrift(state.sessions, state.activeIssues);
637
+ const refreshStale = isRefreshStale(state.lastUpdatedMs, state.staleAfterMs);
457
638
  const issueError = state.issueError ? `\nissue_error: ${state.issueError}` : "";
458
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";
459
644
  return {
460
- level: state.issueError || state.sessionError ? "warning" : "info",
645
+ level: state.issueError ||
646
+ state.sessionError ||
647
+ refreshStale ||
648
+ drift.activeWithoutSessionIds.length > 0 ||
649
+ drift.orphanSessions.length > 0
650
+ ? "warning"
651
+ : "info",
461
652
  text: [
462
653
  `Subagents monitor ${status}`,
463
654
  `prefix: ${state.prefix || "(all sessions)"}`,
464
655
  `issue_root: ${issueScope}`,
465
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)}`,
466
661
  `sessions: ${state.sessions.length}`,
467
662
  `ready_issues: ${state.readyIssues.length}`,
468
663
  `active_issues: ${state.activeIssues.length}`,
469
664
  `last refresh: ${when}`,
470
- ].join("\n") + issueError + tmuxError,
665
+ ].join("\n") +
666
+ issueError +
667
+ tmuxError +
668
+ driftInfo +
669
+ staleInfo,
471
670
  };
472
671
  };
473
672
  const applySubagentsAction = async (params, ctx) => {
@@ -476,6 +675,10 @@ export function subagentsUiExtension(pi) {
476
675
  const summary = statusSummary();
477
676
  return { ok: true, message: summary.text, level: summary.level };
478
677
  }
678
+ case "snapshot": {
679
+ const format = parseSnapshotFormat(params.snapshot_format);
680
+ return { ok: true, message: subagentsSnapshot(state, format), level: "info" };
681
+ }
479
682
  case "on":
480
683
  state.enabled = true;
481
684
  ensurePolling();
@@ -531,9 +734,160 @@ export function subagentsUiExtension(pi) {
531
734
  state.enabled = true;
532
735
  ensurePolling();
533
736
  await refresh(ctx);
534
- return { ok: true, message: `Subagents issue tag filter set to ${state.issueRoleTag ?? "(all roles)"}.`, level: "info" };
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" };
535
882
  }
536
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
+ }
537
891
  if (!state.issueRootId) {
538
892
  return {
539
893
  ok: false,
@@ -598,6 +952,7 @@ export function subagentsUiExtension(pi) {
598
952
  cwd: ctx.cwd,
599
953
  sessionName,
600
954
  issue,
955
+ mode: state.spawnMode,
601
956
  });
602
957
  if (spawned.ok) {
603
958
  existingSessions.push(sessionName);
@@ -611,7 +966,7 @@ export function subagentsUiExtension(pi) {
611
966
  ensurePolling();
612
967
  await refresh(ctx);
613
968
  const summary = [
614
- `Spawned ${launched.length}/${candidates.length} ready issue sessions.`,
969
+ `Spawned ${launched.length}/${candidates.length} ready issue sessions (mode=${state.spawnMode}).`,
615
970
  launched.length > 0 ? `launched: ${launched.join(", ")}` : "launched: (none)",
616
971
  `skipped: ${skipped.length}`,
617
972
  `failed: ${failed.length}`,
@@ -644,7 +999,7 @@ export function subagentsUiExtension(pi) {
644
999
  registerMuSubcommand(pi, {
645
1000
  subcommand: "subagents",
646
1001
  summary: "Monitor tmux subagent sessions + issue queue, and spawn ready issue sessions",
647
- 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",
648
1003
  handler: async (args, ctx) => {
649
1004
  activeCtx = ctx;
650
1005
  const tokens = args
@@ -657,6 +1012,9 @@ export function subagentsUiExtension(pi) {
657
1012
  case "status":
658
1013
  params = { action: "status" };
659
1014
  break;
1015
+ case "snapshot":
1016
+ params = { action: "snapshot", snapshot_format: tokens[1] };
1017
+ break;
660
1018
  case "on":
661
1019
  params = { action: "on" };
662
1020
  break;
@@ -678,6 +1036,20 @@ export function subagentsUiExtension(pi) {
678
1036
  case "role":
679
1037
  params = { action: "set_role", role_tag: tokens.slice(1).join(" ") };
680
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;
1052
+ }
681
1053
  case "spawn":
682
1054
  params = {
683
1055
  action: "spawn",
@@ -706,19 +1078,43 @@ export function subagentsUiExtension(pi) {
706
1078
  pi.registerTool({
707
1079
  name: "mu_subagents_hud",
708
1080
  label: "mu subagents HUD",
709
- description: "Control or inspect subagents HUD state, including tmux scope, issue queue filters, and ready-queue spawning.",
1081
+ description: "Control or inspect subagents HUD state, including tmux scope, queue filters, spawn profile, and health policies.",
710
1082
  parameters: {
711
1083
  type: "object",
712
1084
  properties: {
713
1085
  action: {
714
1086
  type: "string",
715
- enum: ["status", "on", "off", "toggle", "refresh", "set_prefix", "set_root", "set_role", "spawn"],
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
+ ],
716
1104
  },
717
1105
  prefix: { type: "string" },
718
1106
  root_issue_id: { type: "string" },
719
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"] },
720
1113
  count: {
721
- anyOf: [{ type: "integer", minimum: 1, maximum: ISSUE_LIST_LIMIT }, { type: "string", enum: ["all"] }],
1114
+ anyOf: [
1115
+ { type: "integer", minimum: 1, maximum: ISSUE_LIST_LIMIT },
1116
+ { type: "string", enum: ["all"] },
1117
+ ],
722
1118
  },
723
1119
  },
724
1120
  required: ["action"],