@femtomc/mu-agent 26.2.86 → 26.2.87

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":"planning-ui.d.ts","sourceRoot":"","sources":["../../src/extensions/planning-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AA4FpF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,YAAY,QAoHnD;AAED,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"planning-ui.d.ts","sourceRoot":"","sources":["../../src/extensions/planning-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AA4LpF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,YAAY,QA8KnD;AAED,eAAe,mBAAmB,CAAC"}
@@ -5,6 +5,33 @@ const DEFAULT_STEPS = [
5
5
  "Present plan with IDs, ordering, risks",
6
6
  "Refine with user feedback until approved",
7
7
  ];
8
+ const BAR_CHARS = ["░", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
9
+ function phaseTone(phase) {
10
+ switch (phase) {
11
+ case "investigating":
12
+ return "dim";
13
+ case "drafting":
14
+ return "accent";
15
+ case "reviewing":
16
+ return "warning";
17
+ case "approved":
18
+ return "success";
19
+ }
20
+ }
21
+ function progressBar(done, total, width = 10) {
22
+ if (width <= 0 || total <= 0) {
23
+ return "";
24
+ }
25
+ const clampedDone = Math.max(0, Math.min(total, done));
26
+ const filled = (clampedDone / total) * width;
27
+ const full = Math.floor(filled);
28
+ const frac = filled - full;
29
+ const fracIdx = Math.round(frac * (BAR_CHARS.length - 1));
30
+ const empty = width - full - (fracIdx > 0 ? 1 : 0);
31
+ return (BAR_CHARS[BAR_CHARS.length - 1].repeat(full) +
32
+ (fracIdx > 0 ? BAR_CHARS[fracIdx] : "") +
33
+ BAR_CHARS[0].repeat(Math.max(0, empty)));
34
+ }
8
35
  function createDefaultState() {
9
36
  return {
10
37
  enabled: false,
@@ -37,17 +64,28 @@ function renderPlanningUi(ctx, state) {
37
64
  const done = state.steps.filter((step) => step.done).length;
38
65
  const total = state.steps.length;
39
66
  const phase = summarizePhase(state.phase);
40
- const rootSuffix = state.rootIssueId ? ` root:${state.rootIssueId}` : "";
41
- ctx.ui.setStatus("mu-planning", ctx.ui.theme.fg("dim", `planning ${done}/${total} · ${phase}${rootSuffix}`));
67
+ const phaseColor = phaseTone(state.phase);
68
+ const rootLabel = state.rootIssueId ?? "(unset)";
69
+ const meter = progressBar(done, total, 10);
70
+ ctx.ui.setStatus("mu-planning", [
71
+ ctx.ui.theme.fg("dim", "plan"),
72
+ ctx.ui.theme.fg(phaseColor, phase),
73
+ ctx.ui.theme.fg("dim", `${done}/${total}`),
74
+ ctx.ui.theme.fg(phaseColor, meter),
75
+ ctx.ui.theme.fg("muted", `root:${rootLabel}`),
76
+ ].join(` ${ctx.ui.theme.fg("muted", "·")} `));
42
77
  const lines = [
43
- ctx.ui.theme.fg("accent", `Planning (${phase})`),
44
- state.rootIssueId
45
- ? ctx.ui.theme.fg("dim", ` root issue: ${state.rootIssueId}`)
46
- : ctx.ui.theme.fg("dim", " root issue: (unset)"),
78
+ ctx.ui.theme.fg("accent", ctx.ui.theme.bold("Planning board")),
79
+ ` ${ctx.ui.theme.fg("muted", "phase:")} ${ctx.ui.theme.fg(phaseColor, phase)}`,
80
+ ` ${ctx.ui.theme.fg("muted", "progress:")} ${ctx.ui.theme.fg("dim", `${done}/${total}`)} ${ctx.ui.theme.fg(phaseColor, meter)}`,
81
+ ` ${ctx.ui.theme.fg("muted", "root:")} ${ctx.ui.theme.fg("dim", rootLabel)}`,
82
+ ` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`,
47
83
  ...state.steps.map((step, index) => {
48
84
  const mark = step.done ? ctx.ui.theme.fg("success", "☑") : ctx.ui.theme.fg("muted", "☐");
49
- return `${mark} ${index + 1}. ${step.label}`;
85
+ const label = step.done ? ctx.ui.theme.fg("dim", step.label) : ctx.ui.theme.fg("text", step.label);
86
+ return `${mark} ${ctx.ui.theme.fg("muted", `${index + 1}.`)} ${label}`;
50
87
  }),
88
+ ctx.ui.theme.fg("muted", " /mu plan status · /mu plan phase <...>"),
51
89
  ];
52
90
  ctx.ui.setWidget("mu-planning", lines, { placement: "belowEditor" });
53
91
  }
@@ -67,6 +105,32 @@ function parsePlanningPhase(raw) {
67
105
  }
68
106
  return null;
69
107
  }
108
+ function planningDetails(state) {
109
+ return {
110
+ enabled: state.enabled,
111
+ phase: state.phase,
112
+ root_issue_id: state.rootIssueId,
113
+ steps: state.steps.map((step, index) => ({
114
+ index: index + 1,
115
+ label: step.label,
116
+ done: step.done,
117
+ })),
118
+ };
119
+ }
120
+ function planningStatusSummary(state) {
121
+ const done = state.steps.filter((step) => step.done).length;
122
+ const root = state.rootIssueId ?? "(unset)";
123
+ return `Planning HUD: ${state.enabled ? "enabled" : "disabled"}\nphase: ${state.phase}\nroot: ${root}\nsteps: ${done}/${state.steps.length}`;
124
+ }
125
+ function planningToolError(message) {
126
+ return {
127
+ content: [{ type: "text", text: message }],
128
+ details: {
129
+ ok: false,
130
+ error: message,
131
+ },
132
+ };
133
+ }
70
134
  export function planningUiExtension(pi) {
71
135
  let state = createDefaultState();
72
136
  const notify = (ctx, message, level = "info") => {
@@ -75,6 +139,66 @@ export function planningUiExtension(pi) {
75
139
  const refresh = (ctx) => {
76
140
  renderPlanningUi(ctx, state);
77
141
  };
142
+ const applyPlanningAction = (params) => {
143
+ switch (params.action) {
144
+ case "status":
145
+ return { ok: true, message: planningStatusSummary(state), level: "info" };
146
+ case "on":
147
+ state.enabled = true;
148
+ return { ok: true, message: "Planning HUD enabled.", level: "info" };
149
+ case "off":
150
+ state.enabled = false;
151
+ return { ok: true, message: "Planning HUD disabled.", level: "info" };
152
+ case "toggle":
153
+ state.enabled = !state.enabled;
154
+ return { ok: true, message: `Planning HUD ${state.enabled ? "enabled" : "disabled"}.`, level: "info" };
155
+ case "reset":
156
+ state = createDefaultState();
157
+ return { ok: true, message: "Planning HUD state reset.", level: "info" };
158
+ case "phase": {
159
+ const phase = parsePlanningPhase(params.phase ?? "");
160
+ if (!phase) {
161
+ return { ok: false, message: "Invalid phase.", level: "error" };
162
+ }
163
+ state.phase = phase;
164
+ state.enabled = true;
165
+ return { ok: true, message: `Planning phase set to ${phase}.`, level: "info" };
166
+ }
167
+ case "root": {
168
+ const root = params.root_issue_id?.trim();
169
+ if (!root) {
170
+ return { ok: false, message: "Missing root issue id.", level: "error" };
171
+ }
172
+ state.rootIssueId = root.toLowerCase() === "clear" ? null : root;
173
+ state.enabled = true;
174
+ return { ok: true, message: `Planning root set to ${state.rootIssueId ?? "(unset)"}.`, level: "info" };
175
+ }
176
+ case "check":
177
+ case "uncheck":
178
+ case "toggle_step": {
179
+ const step = params.step;
180
+ if (typeof step !== "number" || !Number.isFinite(step)) {
181
+ return { ok: false, message: "Step index must be a number.", level: "error" };
182
+ }
183
+ const parsed = Math.trunc(step);
184
+ if (parsed < 1 || parsed > state.steps.length) {
185
+ return { ok: false, message: `Step index out of range (1-${state.steps.length}).`, level: "error" };
186
+ }
187
+ const index = parsed - 1;
188
+ if (params.action === "check") {
189
+ state.steps[index].done = true;
190
+ }
191
+ else if (params.action === "uncheck") {
192
+ state.steps[index].done = false;
193
+ }
194
+ else {
195
+ state.steps[index].done = !state.steps[index].done;
196
+ }
197
+ state.enabled = true;
198
+ return { ok: true, message: `Planning step ${index + 1} updated.`, level: "info" };
199
+ }
200
+ }
201
+ };
78
202
  pi.on("session_start", async (_event, ctx) => {
79
203
  refresh(ctx);
80
204
  });
@@ -90,89 +214,88 @@ export function planningUiExtension(pi) {
90
214
  .trim()
91
215
  .split(/\s+/)
92
216
  .filter((token) => token.length > 0);
93
- if (tokens.length === 0 || tokens[0] === "status") {
94
- const done = state.steps.filter((step) => step.done).length;
95
- const root = state.rootIssueId ?? "(unset)";
96
- ctx.ui.notify(`Planning HUD: ${state.enabled ? "enabled" : "disabled"}\nphase: ${state.phase}\nroot: ${root}\nsteps: ${done}/${state.steps.length}`, "info");
97
- refresh(ctx);
98
- return;
99
- }
100
- switch (tokens[0]) {
217
+ const command = tokens[0] ?? "status";
218
+ let params;
219
+ switch (command) {
101
220
  case "on":
102
- state.enabled = true;
103
- refresh(ctx);
104
- ctx.ui.notify("Planning HUD enabled.", "info");
105
- return;
221
+ params = { action: "on" };
222
+ break;
106
223
  case "off":
107
- state.enabled = false;
108
- refresh(ctx);
109
- ctx.ui.notify("Planning HUD disabled.", "info");
110
- return;
224
+ params = { action: "off" };
225
+ break;
111
226
  case "toggle":
112
- state.enabled = !state.enabled;
113
- refresh(ctx);
114
- ctx.ui.notify(`Planning HUD ${state.enabled ? "enabled" : "disabled"}.`, "info");
115
- return;
227
+ params = { action: "toggle" };
228
+ break;
229
+ case "status":
230
+ params = { action: "status" };
231
+ break;
116
232
  case "reset":
117
- state = createDefaultState();
118
- refresh(ctx);
119
- ctx.ui.notify("Planning HUD state reset.", "info");
120
- return;
121
- case "phase": {
122
- const phase = parsePlanningPhase(tokens[1] ?? "");
123
- if (!phase) {
124
- notify(ctx, "Invalid phase.", "error");
125
- return;
126
- }
127
- state.phase = phase;
128
- state.enabled = true;
129
- refresh(ctx);
130
- ctx.ui.notify(`Planning phase set to ${phase}.`, "info");
131
- return;
132
- }
133
- case "root": {
134
- const value = (tokens[1] ?? "").trim();
135
- if (!value) {
136
- notify(ctx, "Missing root issue id.", "error");
137
- return;
138
- }
139
- state.rootIssueId = value.toLowerCase() === "clear" ? null : value;
140
- state.enabled = true;
141
- refresh(ctx);
142
- ctx.ui.notify(`Planning root set to ${state.rootIssueId ?? "(unset)"}.`, "info");
143
- return;
144
- }
233
+ params = { action: "reset" };
234
+ break;
235
+ case "phase":
236
+ params = { action: "phase", phase: tokens[1] };
237
+ break;
238
+ case "root":
239
+ params = { action: "root", root_issue_id: tokens[1] };
240
+ break;
145
241
  case "check":
242
+ params = { action: "check", step: Number.parseInt(tokens[1] ?? "", 10) };
243
+ break;
146
244
  case "uncheck":
147
- case "toggle-step": {
148
- const indexRaw = tokens[1] ?? "";
149
- const parsed = Number.parseInt(indexRaw, 10);
150
- if (!Number.isFinite(parsed)) {
151
- notify(ctx, "Step index must be a number.", "error");
152
- return;
153
- }
154
- if (parsed < 1 || parsed > state.steps.length) {
155
- notify(ctx, `Step index out of range (1-${state.steps.length}).`, "error");
156
- return;
157
- }
158
- const index = parsed - 1;
159
- if (tokens[0] === "check") {
160
- state.steps[index].done = true;
161
- }
162
- else if (tokens[0] === "uncheck") {
163
- state.steps[index].done = false;
164
- }
165
- else {
166
- state.steps[index].done = !state.steps[index].done;
167
- }
168
- state.enabled = true;
169
- refresh(ctx);
170
- ctx.ui.notify(`Planning step ${index + 1} updated.`, "info");
171
- return;
172
- }
245
+ params = { action: "uncheck", step: Number.parseInt(tokens[1] ?? "", 10) };
246
+ break;
247
+ case "toggle-step":
248
+ params = { action: "toggle_step", step: Number.parseInt(tokens[1] ?? "", 10) };
249
+ break;
173
250
  default:
174
- notify(ctx, `Unknown plan command: ${tokens[0]}`, "error");
251
+ notify(ctx, `Unknown plan command: ${command}`, "error");
252
+ return;
253
+ }
254
+ const result = applyPlanningAction(params);
255
+ refresh(ctx);
256
+ if (!result.ok) {
257
+ notify(ctx, result.message, result.level ?? "error");
258
+ return;
259
+ }
260
+ ctx.ui.notify(result.message, result.level ?? "info");
261
+ },
262
+ });
263
+ pi.registerTool({
264
+ name: "mu_planning_hud",
265
+ label: "mu planning HUD",
266
+ description: "Control or inspect planning HUD state (phase, root issue, checklist progress).",
267
+ parameters: {
268
+ type: "object",
269
+ properties: {
270
+ action: {
271
+ type: "string",
272
+ enum: ["status", "on", "off", "toggle", "reset", "phase", "root", "check", "uncheck", "toggle_step"],
273
+ },
274
+ phase: {
275
+ type: "string",
276
+ enum: ["investigating", "drafting", "reviewing", "approved"],
277
+ },
278
+ root_issue_id: { type: "string" },
279
+ step: { type: "integer", minimum: 1 },
280
+ },
281
+ required: ["action"],
282
+ additionalProperties: false,
283
+ },
284
+ execute: async (_toolCallId, paramsRaw, _signal, _onUpdate, ctx) => {
285
+ const params = paramsRaw;
286
+ const result = applyPlanningAction(params);
287
+ refresh(ctx);
288
+ if (!result.ok) {
289
+ return planningToolError(result.message);
175
290
  }
291
+ return {
292
+ content: [{ type: "text", text: `${result.message}\n\n${planningStatusSummary(state)}` }],
293
+ details: {
294
+ ok: true,
295
+ action: params.action,
296
+ ...planningDetails(state),
297
+ },
298
+ };
176
299
  },
177
300
  });
178
301
  }
@@ -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;AAuapF,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,YAAY,QAmRpD;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;AA+fpF,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,YAAY,QAwWpD;AAED,eAAe,oBAAoB,CAAC"}
@@ -251,10 +251,37 @@ async function listIssueSlices(rootId, roleTag) {
251
251
  error: null,
252
252
  };
253
253
  }
254
- function formatIssueLine(ctx, issue) {
254
+ function formatIssueLine(ctx, issue, opts = {}) {
255
+ const marker = opts.marker ?? "•";
256
+ const tone = opts.tone ?? "accent";
255
257
  const id = ctx.ui.theme.fg("dim", issue.id);
256
258
  const priority = ctx.ui.theme.fg("muted", `p${issue.priority}`);
257
- return ` ${ctx.ui.theme.fg("success", "•")} ${id} ${priority} ${truncateOneLine(issue.title)}`;
259
+ const title = ctx.ui.theme.fg("text", truncateOneLine(issue.title));
260
+ return ` ${ctx.ui.theme.fg(tone, marker)} ${id} ${priority} ${title}`;
261
+ }
262
+ function queueMeter(value, total, width = 10) {
263
+ if (width <= 0 || total <= 0) {
264
+ return "";
265
+ }
266
+ const clamped = Math.max(0, Math.min(total, value));
267
+ const full = Math.floor((clamped / total) * width);
268
+ const empty = Math.max(0, width - full);
269
+ return "█".repeat(full) + "░".repeat(empty);
270
+ }
271
+ function formatRefreshAge(lastUpdatedMs) {
272
+ if (lastUpdatedMs == null) {
273
+ return "never";
274
+ }
275
+ const deltaSec = Math.max(0, Math.round((Date.now() - lastUpdatedMs) / 1000));
276
+ if (deltaSec < 60) {
277
+ return `${deltaSec}s ago`;
278
+ }
279
+ const mins = Math.floor(deltaSec / 60);
280
+ if (mins < 60) {
281
+ return `${mins}m ago`;
282
+ }
283
+ const hours = Math.floor(mins / 60);
284
+ return `${hours}h ago`;
258
285
  }
259
286
  function renderSubagentsUi(ctx, state) {
260
287
  if (!ctx.hasUI) {
@@ -265,57 +292,69 @@ function renderSubagentsUi(ctx, state) {
265
292
  ctx.ui.setWidget("mu-subagents", undefined);
266
293
  return;
267
294
  }
268
- const issueScope = state.issueRootId ? `root ${state.issueRootId}` : "all roots";
295
+ const issueScope = state.issueRootId ? `root:${state.issueRootId}` : "all-roots";
269
296
  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
- }
297
+ const hasError = Boolean(state.sessionError || state.issueError);
298
+ const healthColor = hasError ? "warning" : "success";
299
+ const healthLabel = hasError ? "degraded" : "healthy";
300
+ const queueTotal = state.readyIssues.length + state.activeIssues.length;
301
+ const queueBar = queueMeter(state.activeIssues.length, Math.max(1, queueTotal), 10);
302
+ const refreshAge = formatRefreshAge(state.lastUpdatedMs);
303
+ ctx.ui.setStatus("mu-subagents", [
304
+ ctx.ui.theme.fg("dim", "subagents"),
305
+ ctx.ui.theme.fg(healthColor, healthLabel),
306
+ ctx.ui.theme.fg("dim", `${state.sessions.length} tmux`),
307
+ ctx.ui.theme.fg("dim", `${state.readyIssues.length} ready/${state.activeIssues.length} active`),
308
+ ctx.ui.theme.fg("muted", issueScope),
309
+ ].join(` ${ctx.ui.theme.fg("muted", "·")} `));
277
310
  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}`),
311
+ ctx.ui.theme.fg("accent", ctx.ui.theme.bold("Subagents board")),
312
+ ` ${ctx.ui.theme.fg("muted", "health:")} ${ctx.ui.theme.fg(healthColor, healthLabel)}`,
313
+ ` ${ctx.ui.theme.fg("muted", "scope:")} ${ctx.ui.theme.fg("dim", `${issueScope} · ${roleScope}`)}`,
314
+ ` ${ctx.ui.theme.fg("muted", "tmux prefix:")} ${ctx.ui.theme.fg("dim", state.prefix || "(all sessions)")}`,
315
+ ` ${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)}`,
317
+ ` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`,
318
+ ctx.ui.theme.fg("accent", `tmux sessions (${state.sessions.length})`),
281
319
  ];
282
320
  if (state.sessionError) {
283
321
  lines.push(ctx.ui.theme.fg("warning", ` tmux error: ${state.sessionError}`));
284
322
  }
285
323
  else if (state.sessions.length === 0) {
286
- lines.push(ctx.ui.theme.fg("muted", " tmux: (no matching sessions)"));
324
+ lines.push(ctx.ui.theme.fg("muted", " (no matching sessions)"));
287
325
  }
288
326
  else {
289
327
  for (const name of state.sessions.slice(0, 8)) {
290
- lines.push(` ${ctx.ui.theme.fg("success", "●")} ${name}`);
328
+ lines.push(` ${ctx.ui.theme.fg("success", "●")} ${ctx.ui.theme.fg("text", name)}`);
291
329
  }
292
330
  if (state.sessions.length > 8) {
293
331
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.sessions.length - 8} more tmux sessions`));
294
332
  }
295
333
  }
334
+ lines.push(` ${ctx.ui.theme.fg("dim", "────────────────────────────")}`);
296
335
  if (state.issueError) {
297
- lines.push(ctx.ui.theme.fg("warning", ` issue error: ${state.issueError}`));
336
+ lines.push(ctx.ui.theme.fg("warning", `issue error: ${state.issueError}`));
298
337
  }
299
338
  else {
300
- lines.push(ctx.ui.theme.fg("accent", "Ready issues"));
339
+ lines.push(ctx.ui.theme.fg("accent", `ready queue (${state.readyIssues.length})`));
301
340
  if (state.readyIssues.length === 0) {
302
341
  lines.push(ctx.ui.theme.fg("muted", " (no ready issues)"));
303
342
  }
304
343
  else {
305
344
  for (const issue of state.readyIssues.slice(0, 6)) {
306
- lines.push(formatIssueLine(ctx, issue));
345
+ lines.push(formatIssueLine(ctx, issue, { marker: "→", tone: "accent" }));
307
346
  }
308
347
  if (state.readyIssues.length > 6) {
309
348
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.readyIssues.length - 6} more ready issues`));
310
349
  }
311
350
  }
312
- lines.push(ctx.ui.theme.fg("accent", "Active issues"));
351
+ lines.push(ctx.ui.theme.fg("accent", `active queue (${state.activeIssues.length})`));
313
352
  if (state.activeIssues.length === 0) {
314
353
  lines.push(ctx.ui.theme.fg("muted", " (no in-progress issues)"));
315
354
  }
316
355
  else {
317
356
  for (const issue of state.activeIssues.slice(0, 6)) {
318
- lines.push(formatIssueLine(ctx, issue));
357
+ lines.push(formatIssueLine(ctx, issue, { marker: "●", tone: "warning" }));
319
358
  }
320
359
  if (state.activeIssues.length > 6) {
321
360
  lines.push(ctx.ui.theme.fg("muted", ` ... +${state.activeIssues.length - 6} more active issues`));
@@ -344,6 +383,30 @@ function normalizeRoleTag(raw) {
344
383
  }
345
384
  return trimmed;
346
385
  }
386
+ function subagentsDetails(state) {
387
+ return {
388
+ enabled: state.enabled,
389
+ prefix: state.prefix,
390
+ issue_root_id: state.issueRootId,
391
+ issue_role_tag: state.issueRoleTag,
392
+ sessions: [...state.sessions],
393
+ ready_issue_ids: state.readyIssues.map((issue) => issue.id),
394
+ active_issue_ids: state.activeIssues.map((issue) => issue.id),
395
+ issue_error: state.issueError,
396
+ session_error: state.sessionError,
397
+ last_updated_ms: state.lastUpdatedMs,
398
+ };
399
+ }
400
+ function subagentsToolError(message, state) {
401
+ return {
402
+ content: [{ type: "text", text: message }],
403
+ details: {
404
+ ok: false,
405
+ error: message,
406
+ ...subagentsDetails(state),
407
+ },
408
+ };
409
+ }
347
410
  export function subagentsUiExtension(pi) {
348
411
  let activeCtx = null;
349
412
  let pollTimer = null;
@@ -386,6 +449,180 @@ export function subagentsUiExtension(pi) {
386
449
  const notify = (ctx, message, level = "info") => {
387
450
  ctx.ui.notify(`${message}\n\n${subagentsUsageText()}`, level);
388
451
  };
452
+ const statusSummary = () => {
453
+ const when = state.lastUpdatedMs == null ? "never" : new Date(state.lastUpdatedMs).toLocaleTimeString();
454
+ const status = state.enabled ? "enabled" : "disabled";
455
+ const issueScope = state.issueRootId ?? "(all roots)";
456
+ const issueRole = state.issueRoleTag ?? "(all roles)";
457
+ const issueError = state.issueError ? `\nissue_error: ${state.issueError}` : "";
458
+ const tmuxError = state.sessionError ? `\ntmux_error: ${state.sessionError}` : "";
459
+ return {
460
+ level: state.issueError || state.sessionError ? "warning" : "info",
461
+ text: [
462
+ `Subagents monitor ${status}`,
463
+ `prefix: ${state.prefix || "(all sessions)"}`,
464
+ `issue_root: ${issueScope}`,
465
+ `issue_role: ${issueRole}`,
466
+ `sessions: ${state.sessions.length}`,
467
+ `ready_issues: ${state.readyIssues.length}`,
468
+ `active_issues: ${state.activeIssues.length}`,
469
+ `last refresh: ${when}`,
470
+ ].join("\n") + issueError + tmuxError,
471
+ };
472
+ };
473
+ const applySubagentsAction = async (params, ctx) => {
474
+ switch (params.action) {
475
+ case "status": {
476
+ const summary = statusSummary();
477
+ return { ok: true, message: summary.text, level: summary.level };
478
+ }
479
+ case "on":
480
+ state.enabled = true;
481
+ ensurePolling();
482
+ await refresh(ctx);
483
+ return { ok: true, message: "Subagents monitor enabled.", level: "info" };
484
+ case "off":
485
+ state.enabled = false;
486
+ stopPolling();
487
+ renderSubagentsUi(ctx, state);
488
+ return { ok: true, message: "Subagents monitor disabled.", level: "info" };
489
+ case "toggle":
490
+ state.enabled = !state.enabled;
491
+ if (state.enabled) {
492
+ ensurePolling();
493
+ await refresh(ctx);
494
+ }
495
+ else {
496
+ stopPolling();
497
+ renderSubagentsUi(ctx, state);
498
+ }
499
+ return { ok: true, message: `Subagents monitor ${state.enabled ? "enabled" : "disabled"}.`, level: "info" };
500
+ case "refresh":
501
+ await refresh(ctx);
502
+ return { ok: true, message: "Subagents monitor refreshed.", level: "info" };
503
+ case "set_prefix": {
504
+ const value = params.prefix?.trim();
505
+ if (!value) {
506
+ return { ok: false, message: "Missing prefix value.", level: "error" };
507
+ }
508
+ state.prefix = value.toLowerCase() === "clear" ? "" : value;
509
+ state.enabled = true;
510
+ ensurePolling();
511
+ await refresh(ctx);
512
+ return { ok: true, message: `Subagents prefix set to ${state.prefix || "(all sessions)"}.`, level: "info" };
513
+ }
514
+ case "set_root": {
515
+ const value = params.root_issue_id?.trim();
516
+ if (!value) {
517
+ return { ok: false, message: "Missing root issue id.", level: "error" };
518
+ }
519
+ state.issueRootId = value.toLowerCase() === "clear" ? null : value;
520
+ state.enabled = true;
521
+ ensurePolling();
522
+ await refresh(ctx);
523
+ return { ok: true, message: `Subagents root set to ${state.issueRootId ?? "(all roots)"}.`, level: "info" };
524
+ }
525
+ case "set_role": {
526
+ const value = params.role_tag?.trim();
527
+ if (!value) {
528
+ return { ok: false, message: "Missing role/tag value.", level: "error" };
529
+ }
530
+ state.issueRoleTag = normalizeRoleTag(value);
531
+ state.enabled = true;
532
+ ensurePolling();
533
+ await refresh(ctx);
534
+ return { ok: true, message: `Subagents issue tag filter set to ${state.issueRoleTag ?? "(all roles)"}.`, level: "info" };
535
+ }
536
+ case "spawn": {
537
+ if (!state.issueRootId) {
538
+ return {
539
+ ok: false,
540
+ message: "Set a root first (`/mu subagents root <root-id>`) before spawning.",
541
+ level: "error",
542
+ };
543
+ }
544
+ let spawnLimit = null;
545
+ if (params.count != null && params.count !== "all") {
546
+ const countNum = typeof params.count === "number" ? params.count : Number.parseInt(String(params.count), 10);
547
+ const parsed = Math.trunc(countNum);
548
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > ISSUE_LIST_LIMIT) {
549
+ return { ok: false, message: `Spawn count must be 1-${ISSUE_LIST_LIMIT} or 'all'.`, level: "error" };
550
+ }
551
+ spawnLimit = parsed;
552
+ }
553
+ const issueSlices = await listIssueSlices(state.issueRootId, state.issueRoleTag);
554
+ state.readyIssues = issueSlices.ready;
555
+ state.activeIssues = issueSlices.active;
556
+ state.issueError = issueSlices.error;
557
+ if (issueSlices.error) {
558
+ state.enabled = true;
559
+ ensurePolling();
560
+ renderSubagentsUi(ctx, state);
561
+ return { ok: false, message: `Cannot spawn: ${issueSlices.error}`, level: "error" };
562
+ }
563
+ const candidates = spawnLimit == null ? issueSlices.ready : issueSlices.ready.slice(0, spawnLimit);
564
+ if (candidates.length === 0) {
565
+ state.enabled = true;
566
+ ensurePolling();
567
+ await refresh(ctx);
568
+ return { ok: true, message: "No ready issues to spawn for current root/tag filter.", level: "info" };
569
+ }
570
+ const spawnPrefix = state.prefix.length > 0 ? state.prefix : DEFAULT_PREFIX;
571
+ const tmux = await listTmuxSessions(spawnPrefix);
572
+ if (tmux.error) {
573
+ state.sessionError = tmux.error;
574
+ state.enabled = true;
575
+ ensurePolling();
576
+ renderSubagentsUi(ctx, state);
577
+ return { ok: false, message: `Cannot spawn: ${tmux.error}`, level: "error" };
578
+ }
579
+ const existingSessions = [...tmux.sessions];
580
+ const runId = spawnRunId();
581
+ const launched = [];
582
+ const skipped = [];
583
+ const failed = [];
584
+ for (const issue of candidates) {
585
+ if (issueHasSession(existingSessions, issue.id)) {
586
+ skipped.push(`${issue.id} (session exists)`);
587
+ continue;
588
+ }
589
+ let sessionName = `${spawnPrefix}${runId}-${issue.id}`;
590
+ if (existingSessions.includes(sessionName)) {
591
+ let suffix = 1;
592
+ while (existingSessions.includes(`${sessionName}-${suffix}`)) {
593
+ suffix += 1;
594
+ }
595
+ sessionName = `${sessionName}-${suffix}`;
596
+ }
597
+ const spawned = await spawnIssueTmuxSession({
598
+ cwd: ctx.cwd,
599
+ sessionName,
600
+ issue,
601
+ });
602
+ if (spawned.ok) {
603
+ existingSessions.push(sessionName);
604
+ launched.push(`${issue.id} -> ${sessionName}`);
605
+ }
606
+ else {
607
+ failed.push(`${issue.id} (${spawned.error ?? "unknown error"})`);
608
+ }
609
+ }
610
+ state.enabled = true;
611
+ ensurePolling();
612
+ await refresh(ctx);
613
+ const summary = [
614
+ `Spawned ${launched.length}/${candidates.length} ready issue sessions.`,
615
+ launched.length > 0 ? `launched: ${launched.join(", ")}` : "launched: (none)",
616
+ `skipped: ${skipped.length}`,
617
+ `failed: ${failed.length}`,
618
+ ];
619
+ if (failed.length > 0) {
620
+ summary.push(`failures: ${failed.join("; ")}`);
621
+ }
622
+ return { ok: true, message: summary.join("\n"), level: failed.length > 0 ? "warning" : "info" };
623
+ }
624
+ }
625
+ };
389
626
  pi.on("session_start", async (_event, ctx) => {
390
627
  activeCtx = ctx;
391
628
  if (state.enabled) {
@@ -414,188 +651,94 @@ export function subagentsUiExtension(pi) {
414
651
  .trim()
415
652
  .split(/\s+/)
416
653
  .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]) {
654
+ const command = tokens[0] ?? "status";
655
+ let params;
656
+ switch (command) {
657
+ case "status":
658
+ params = { action: "status" };
659
+ break;
438
660
  case "on":
439
- state.enabled = true;
440
- ensurePolling();
441
- await refresh(ctx);
442
- ctx.ui.notify("Subagents monitor enabled.", "info");
443
- return;
661
+ params = { action: "on" };
662
+ break;
444
663
  case "off":
445
- state.enabled = false;
446
- stopPolling();
447
- renderSubagentsUi(ctx, state);
448
- ctx.ui.notify("Subagents monitor disabled.", "info");
449
- return;
664
+ params = { action: "off" };
665
+ break;
450
666
  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;
505
- }
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;
667
+ params = { action: "toggle" };
668
+ break;
669
+ case "refresh":
670
+ params = { action: "refresh" };
671
+ break;
672
+ case "prefix":
673
+ params = { action: "set_prefix", prefix: tokens.slice(1).join(" ") };
674
+ break;
675
+ case "root":
676
+ params = { action: "set_root", root_issue_id: tokens.slice(1).join(" ") };
677
+ break;
678
+ case "role":
679
+ params = { action: "set_role", role_tag: tokens.slice(1).join(" ") };
680
+ break;
681
+ case "spawn":
682
+ params = {
683
+ action: "spawn",
684
+ count: (() => {
685
+ const token = tokens[1]?.trim();
686
+ if (!token || token.toLowerCase() === "all") {
687
+ return "all";
565
688
  }
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
- }
689
+ const parsed = Number.parseInt(token, 10);
690
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
691
+ })(),
692
+ };
693
+ break;
596
694
  default:
597
- notify(ctx, `Unknown subagents command: ${tokens[0]}`, "error");
695
+ notify(ctx, `Unknown subagents command: ${command}`, "error");
696
+ return;
697
+ }
698
+ const result = await applySubagentsAction(params, ctx);
699
+ if (!result.ok) {
700
+ notify(ctx, result.message, result.level);
701
+ return;
702
+ }
703
+ ctx.ui.notify(result.message, result.level);
704
+ },
705
+ });
706
+ pi.registerTool({
707
+ name: "mu_subagents_hud",
708
+ label: "mu subagents HUD",
709
+ description: "Control or inspect subagents HUD state, including tmux scope, issue queue filters, and ready-queue spawning.",
710
+ parameters: {
711
+ type: "object",
712
+ properties: {
713
+ action: {
714
+ type: "string",
715
+ enum: ["status", "on", "off", "toggle", "refresh", "set_prefix", "set_root", "set_role", "spawn"],
716
+ },
717
+ prefix: { type: "string" },
718
+ root_issue_id: { type: "string" },
719
+ role_tag: { type: "string" },
720
+ count: {
721
+ anyOf: [{ type: "integer", minimum: 1, maximum: ISSUE_LIST_LIMIT }, { type: "string", enum: ["all"] }],
722
+ },
723
+ },
724
+ required: ["action"],
725
+ additionalProperties: false,
726
+ },
727
+ execute: async (_toolCallId, paramsRaw, _signal, _onUpdate, ctx) => {
728
+ activeCtx = ctx;
729
+ const params = paramsRaw;
730
+ const result = await applySubagentsAction(params, ctx);
731
+ if (!result.ok) {
732
+ return subagentsToolError(result.message, state);
598
733
  }
734
+ return {
735
+ content: [{ type: "text", text: result.message }],
736
+ details: {
737
+ ok: true,
738
+ action: params.action,
739
+ ...subagentsDetails(state),
740
+ },
741
+ };
599
742
  },
600
743
  });
601
744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-agent",
3
- "version": "26.2.86",
3
+ "version": "26.2.87",
4
4
  "description": "Shared agent runtime for mu assistant sessions, orchestration roles, and serve extensions.",
5
5
  "keywords": [
6
6
  "mu",
@@ -24,7 +24,7 @@
24
24
  "themes/**"
25
25
  ],
26
26
  "dependencies": {
27
- "@femtomc/mu-core": "26.2.86",
27
+ "@femtomc/mu-core": "26.2.87",
28
28
  "@mariozechner/pi-agent-core": "^0.53.0",
29
29
  "@mariozechner/pi-ai": "^0.53.0",
30
30
  "@mariozechner/pi-coding-agent": "^0.53.0",
@@ -45,6 +45,20 @@ Optional planning HUD (interactive operator session):
45
45
  /mu plan phase investigating
46
46
  ```
47
47
 
48
+ Tool contract (preferred when tools are available):
49
+
50
+ - Tool: `mu_planning_hud`
51
+ - Actions: `status`, `on`, `off`, `toggle`, `reset`, `phase`, `root`, `check`, `uncheck`, `toggle_step`
52
+ - Parameters:
53
+ - `phase`: `investigating|drafting|reviewing|approved`
54
+ - `root_issue_id`: issue ID or `clear`
55
+ - `step`: 1-based checklist index
56
+
57
+ Example tool calls:
58
+ - Set phase: `{"action":"phase","phase":"drafting"}`
59
+ - Bind root: `{"action":"root","root_issue_id":"<root-id>"}`
60
+ - Update checklist: `{"action":"check","step":2}`
61
+
48
62
  Also inspect repo files directly (read/bash) for implementation constraints.
49
63
 
50
64
  ### B) Draft DAG in mu-issue
@@ -85,6 +85,23 @@ The widget picks up tracker decomposition by reading `mu issues ready` and
85
85
  Use `spawn` to launch tmux sessions directly from the ready queue for the
86
86
  current root/tag filter.
87
87
 
88
+ Tool contract (preferred when tools are available):
89
+
90
+ - Tool: `mu_subagents_hud`
91
+ - Actions: `status`, `on`, `off`, `toggle`, `refresh`, `set_prefix`, `set_root`, `set_role`, `spawn`
92
+ - Parameters:
93
+ - `prefix`: tmux prefix or `clear`
94
+ - `root_issue_id`: issue root ID or `clear`
95
+ - `role_tag`: issue tag filter (for example `role:worker`) or `clear`
96
+ - `count`: integer 1..40 or `"all"` for spawn
97
+
98
+ Example tool calls:
99
+ - Configure root + role:
100
+ - `{"action":"set_root","root_issue_id":"<root-id>"}`
101
+ - `{"action":"set_role","role_tag":"role:worker"}`
102
+ - Refresh status: `{"action":"refresh"}`
103
+ - Spawn from ready queue: `{"action":"spawn","count":"all"}`
104
+
88
105
  ## Handoffs and follow-up turns
89
106
 
90
107
  With `mu exec`, follow up by issuing another `mu exec` command in the same tmux pane