u-foo 1.7.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +9 -1
  2. package/README.zh-CN.md +9 -1
  3. package/bin/ufoo.js +4 -2
  4. package/package.json +1 -1
  5. package/src/agent/cliRunner.js +3 -2
  6. package/src/agent/ucodeBootstrap.js +5 -3
  7. package/src/agent/ufooAgent.js +185 -6
  8. package/src/assistant/constants.js +1 -1
  9. package/src/assistant/engine.js +1 -6
  10. package/src/chat/commandExecutor.js +116 -19
  11. package/src/chat/commands.js +8 -1
  12. package/src/chat/completionController.js +40 -0
  13. package/src/chat/cronScheduler.js +37 -6
  14. package/src/chat/daemonMessageRouter.js +23 -3
  15. package/src/chat/dashboardKeyController.js +48 -59
  16. package/src/chat/dashboardView.js +31 -39
  17. package/src/chat/index.js +154 -77
  18. package/src/chat/inputListenerController.js +14 -0
  19. package/src/chat/inputSubmitHandler.js +9 -5
  20. package/src/chat/settingsController.js +0 -28
  21. package/src/chat/transientAgentState.js +64 -0
  22. package/src/cli/groupCoreCommands.js +21 -12
  23. package/src/cli.js +23 -1
  24. package/src/daemon/cronOps.js +48 -11
  25. package/src/daemon/groupOrchestrator.js +581 -97
  26. package/src/daemon/index.js +420 -5
  27. package/src/daemon/ops.js +25 -7
  28. package/src/daemon/promptLoop.js +16 -0
  29. package/src/daemon/promptRequest.js +126 -2
  30. package/src/daemon/reporting.js +18 -0
  31. package/src/daemon/soloBootstrap.js +435 -0
  32. package/src/daemon/status.js +7 -1
  33. package/src/globalMode.js +33 -0
  34. package/src/group/bootstrap.js +157 -0
  35. package/src/group/promptProfiles.js +646 -0
  36. package/src/group/templateValidation.js +99 -0
  37. package/src/group/validateTemplate.js +36 -5
  38. package/src/init/index.js +13 -7
  39. package/src/report/store.js +6 -0
  40. package/src/shared/eventContract.js +1 -0
  41. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  42. package/templates/groups/product-discovery.json +79 -0
  43. package/templates/groups/ui-polish.json +87 -0
  44. package/templates/groups/verify-ship.json +79 -0
  45. package/templates/groups/research-quick.json +0 -49
@@ -6,6 +6,8 @@ const { runGroupCoreCommand } = require("../cli/groupCoreCommands");
6
6
  const { loadConfig: loadProjectConfig, saveConfig: saveProjectConfig, loadGlobalUcodeConfig, saveGlobalUcodeConfig } = require("../config");
7
7
  const { resolveTransport } = require("../code/nativeRunner");
8
8
  const { parseIntervalMs, formatIntervalMs } = require("./cronScheduler");
9
+ const { isGlobalControllerProjectRoot, resolveGlobalControllerUfooDir } = require("../globalMode");
10
+ const { loadPromptProfileRegistry } = require("../group/promptProfiles");
9
11
 
10
12
  function defaultCreateDoctor(projectRoot) {
11
13
  const UfooDoctor = require("../doctor");
@@ -92,6 +94,7 @@ function createCommandExecutor(options = {}) {
92
94
  stopCronTask = () => false,
93
95
  runGroupCore = runGroupCoreCommand,
94
96
  requestCron = null,
97
+ globalMode = false,
95
98
  listProjects = () => [],
96
99
  getCurrentProject = () => ({ projectRoot }),
97
100
  switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
@@ -404,7 +407,7 @@ function createCommandExecutor(options = {}) {
404
407
  if (args.length === 0) {
405
408
  logMessage(
406
409
  "error",
407
- "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>] [scope=inplace|window]"
410
+ "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [profile=<id>] [count=<n>] [scope=inplace|window]"
408
411
  );
409
412
  return;
410
413
  }
@@ -452,6 +455,7 @@ function createCommandExecutor(options = {}) {
452
455
  }
453
456
 
454
457
  const nickname = parsedOptions.nickname || "";
458
+ const promptProfile = parsedOptions.profile || parsedOptions.prompt_profile || "";
455
459
  const count = parseInt(parsedOptions.count || "1", 10);
456
460
  const scopeRaw = parsedOptions.scope || parsedOptions.launch_scope || parsedOptions.window || "";
457
461
  let launchScope = normalizeLaunchScopeOption(scopeRaw, "inplace");
@@ -473,6 +477,10 @@ function createCommandExecutor(options = {}) {
473
477
  logMessage("error", "{white-fg}✗{/white-fg} nickname requires count=1");
474
478
  return;
475
479
  }
480
+ if (promptProfile && count > 1) {
481
+ logMessage("error", "{white-fg}✗{/white-fg} profile requires count=1");
482
+ return;
483
+ }
476
484
 
477
485
  try {
478
486
  const request = {
@@ -480,6 +488,7 @@ function createCommandExecutor(options = {}) {
480
488
  agent: normalizedAgent,
481
489
  count: Number.isFinite(count) ? count : 1,
482
490
  nickname,
491
+ prompt_profile: promptProfile,
483
492
  launch_scope: launchScope,
484
493
  ...collectHostLaunchRequestContext(),
485
494
  };
@@ -494,6 +503,52 @@ function createCommandExecutor(options = {}) {
494
503
  }
495
504
  }
496
505
 
506
+ async function handleRoleCommand(args = []) {
507
+ const action = String(args[0] || "").trim().toLowerCase();
508
+ if (action === "list" || action === "ls") {
509
+ try {
510
+ const registry = loadPromptProfileRegistry(projectRoot);
511
+ const profiles = registry.profiles || [];
512
+ if (profiles.length === 0) {
513
+ logMessage("system", "{white-fg}⚙{/white-fg} No prompt profiles found.");
514
+ return;
515
+ }
516
+ logMessage("system", `{white-fg}⚙{/white-fg} Available prompt profiles (${profiles.length}):`);
517
+ for (const p of profiles) {
518
+ const aliases = p.aliases && p.aliases.length > 0 ? ` {gray-fg}(${p.aliases.join(", ")}){/gray-fg}` : "";
519
+ const source = p.source ? ` {cyan-fg}[${p.source}]{/cyan-fg}` : "";
520
+ const summary = p.summary ? ` ${p.summary}` : "";
521
+ logMessage("system", ` {bold}${escapeBlessed(p.id)}{/bold}${aliases}${source}`);
522
+ if (summary) {
523
+ logMessage("system", ` ${escapeBlessed(summary)}`);
524
+ }
525
+ }
526
+ } catch (err) {
527
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to list profiles: ${escapeBlessed(err.message)}`);
528
+ }
529
+ return;
530
+ }
531
+
532
+ const target = String(args[0] || "").trim();
533
+ const profile = String(args[1] || "").trim();
534
+ if (!target || !profile) {
535
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /role <agent-id|nickname> <prompt-profile>");
536
+ logMessage("error", " /role list");
537
+ return;
538
+ }
539
+
540
+ try {
541
+ send({
542
+ type: IPC_REQUEST_TYPES.ASSIGN_ROLE,
543
+ target,
544
+ prompt_profile: profile,
545
+ });
546
+ schedule(requestStatus, 1000);
547
+ } catch (err) {
548
+ logMessage("error", `{white-fg}✗{/white-fg} Role assignment failed: ${escapeBlessed(err.message)}`);
549
+ }
550
+ }
551
+
497
552
  async function handleResumeCommand(args = []) {
498
553
  const action = String(args[0] || "").toLowerCase();
499
554
  if (action === "list" || action === "ls") {
@@ -517,7 +572,11 @@ function createCommandExecutor(options = {}) {
517
572
 
518
573
  if (subcommand === "list") {
519
574
  const rowsRaw = await Promise.resolve(listProjects());
520
- const rows = Array.isArray(rowsRaw) ? rowsRaw : [];
575
+ const rows = (Array.isArray(rowsRaw) ? rowsRaw : []).filter((row) => {
576
+ if (!globalMode) return true;
577
+ const root = row && row.project_root ? String(row.project_root) : "";
578
+ return !isGlobalControllerProjectRoot(root);
579
+ });
521
580
  const current = await Promise.resolve(getCurrentProject());
522
581
  const currentRoot = current && current.project_root ? String(current.project_root) : "";
523
582
  if (rows.length === 0) {
@@ -545,12 +604,19 @@ function createCommandExecutor(options = {}) {
545
604
  logMessage("error", "{white-fg}✗{/white-fg} Current project unavailable");
546
605
  return;
547
606
  }
607
+ if (globalMode && isGlobalControllerProjectRoot(current.project_root)) {
608
+ logMessage(
609
+ "system",
610
+ `{cyan-fg}Current:{/cyan-fg} global controller (${escapeBlessed(resolveGlobalControllerUfooDir())})`
611
+ );
612
+ return;
613
+ }
548
614
  logMessage("system", `{cyan-fg}Current:{/cyan-fg} ${escapeBlessed(current.project_root)}`);
549
615
  return;
550
616
  }
551
617
 
552
618
  if (subcommand === "switch") {
553
- const target = String(args[1] || "").trim();
619
+ const target = args.slice(1).join(" ").trim();
554
620
  if (!target) {
555
621
  logMessage("error", "{white-fg}✗{/white-fg} Usage: /project switch <index|path>");
556
622
  return;
@@ -570,6 +636,27 @@ function createCommandExecutor(options = {}) {
570
636
  logMessage("error", "{white-fg}✗{/white-fg} Unknown project command. Use: list, current, switch");
571
637
  }
572
638
 
639
+ async function handleOpenCommand(args = []) {
640
+ if (!globalMode) {
641
+ logMessage("error", "{white-fg}✗{/white-fg} /open is only available in global mode");
642
+ return;
643
+ }
644
+ const target = args.join(" ").trim();
645
+ if (!target) {
646
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /open <path>");
647
+ return;
648
+ }
649
+ logMessage("system", `{white-fg}⚙{/white-fg} Opening project: ${escapeBlessed(target)}`);
650
+ const result = await Promise.resolve(switchProject({ target }));
651
+ if (!result || result.ok !== true) {
652
+ const reason = result && result.error ? String(result.error) : "open failed";
653
+ logMessage("error", `{white-fg}✗{/white-fg} Open failed: ${escapeBlessed(reason)}`);
654
+ return;
655
+ }
656
+ const nextRoot = result.project_root || result.projectRoot || "";
657
+ logMessage("system", `{white-fg}✓{/white-fg} Opened project: ${escapeBlessed(nextRoot)}`);
658
+ }
659
+
573
660
  function parseKeyValueArgs(args = []) {
574
661
  const parsed = {};
575
662
  for (const raw of args) {
@@ -688,6 +775,9 @@ function createCommandExecutor(options = {}) {
688
775
  const targetsRaw = String(
689
776
  kv.target || kv.targets || kv.agent || kv.agents || ""
690
777
  ).trim();
778
+ const title = String(
779
+ kv.title || kv.name || kv.label || ""
780
+ ).trim();
691
781
  const prompt = String(
692
782
  kv.prompt || kv.message || kv.msg || nonKvParts.join(" ") || ""
693
783
  ).trim();
@@ -695,7 +785,7 @@ function createCommandExecutor(options = {}) {
695
785
  if ((!intervalRaw && !atRaw) || !targetsRaw || !prompt) {
696
786
  logMessage(
697
787
  "error",
698
- "{white-fg}✗{/white-fg} Usage: /cron start every=<10s|5m> or at=\"YYYY-MM-DD HH:mm\" target=<agent1,agent2> prompt=\"...\""
788
+ "{white-fg}✗{/white-fg} Usage: /cron start every=<10s|5m> or at=\"YYYY-MM-DD HH:mm\" target=<agent1,agent2> [title=\"...\"] prompt=\"...\""
699
789
  );
700
790
  return;
701
791
  }
@@ -728,21 +818,18 @@ function createCommandExecutor(options = {}) {
728
818
  }
729
819
 
730
820
  if (typeof requestCron === "function") {
821
+ const request = {
822
+ operation: "start",
823
+ targets,
824
+ prompt,
825
+ };
826
+ if (title) request.title = title;
731
827
  if (atMs > 0) {
732
- requestCron({
733
- operation: "start",
734
- once_at_ms: atMs,
735
- targets,
736
- prompt,
737
- });
828
+ request.once_at_ms = atMs;
738
829
  } else {
739
- requestCron({
740
- operation: "start",
741
- interval_ms: intervalMs,
742
- targets,
743
- prompt,
744
- });
830
+ request.interval_ms = intervalMs;
745
831
  }
832
+ requestCron(request);
746
833
  schedule(requestStatus, 200);
747
834
  return;
748
835
  }
@@ -752,11 +839,13 @@ function createCommandExecutor(options = {}) {
752
839
  return;
753
840
  }
754
841
 
755
- const task = createCronTask({
842
+ const taskPayload = {
756
843
  intervalMs,
757
844
  targets,
758
845
  prompt,
759
- });
846
+ };
847
+ if (title) taskPayload.title = title;
848
+ const task = createCronTask(taskPayload);
760
849
  if (!task) {
761
850
  logMessage("error", "{white-fg}✗{/white-fg} Failed to create cron task");
762
851
  return;
@@ -764,7 +853,7 @@ function createCommandExecutor(options = {}) {
764
853
 
765
854
  logMessage(
766
855
  "system",
767
- `{white-fg}✓{/white-fg} Cron started ${task.id}: ${atMs > 0 ? `at ${formatCronAt(atMs)}` : `every ${formatIntervalMs(intervalMs)}`} -> ${targets.join(", ")}`
856
+ `{white-fg}✓{/white-fg} Cron started ${task.id}: ${task.label || `${atMs > 0 ? `at ${formatCronAt(atMs)}` : `every ${formatIntervalMs(intervalMs)}`} -> ${targets.join(", ")}`}`
768
857
  );
769
858
  }
770
859
 
@@ -1118,12 +1207,18 @@ function createCommandExecutor(options = {}) {
1118
1207
  case "launch":
1119
1208
  await handleLaunchCommand(args);
1120
1209
  return true;
1210
+ case "open":
1211
+ await handleOpenCommand(args);
1212
+ return true;
1121
1213
  case "resume":
1122
1214
  await handleResumeCommand(args);
1123
1215
  return true;
1124
1216
  case "project":
1125
1217
  await handleProjectCommand(args);
1126
1218
  return true;
1219
+ case "role":
1220
+ await handleRoleCommand(args);
1221
+ return true;
1127
1222
  case "cron":
1128
1223
  await handleCronCommand(args);
1129
1224
  return true;
@@ -1152,8 +1247,10 @@ function createCommandExecutor(options = {}) {
1152
1247
  handleCtxCommand,
1153
1248
  handleSkillsCommand,
1154
1249
  handleLaunchCommand,
1250
+ handleOpenCommand,
1155
1251
  handleResumeCommand,
1156
1252
  handleProjectCommand,
1253
+ handleRoleCommand,
1157
1254
  handleCronCommand,
1158
1255
  handleGroupCommand,
1159
1256
  handleSettingsCommand,
@@ -30,7 +30,7 @@ const COMMAND_TREE = {
30
30
  "/cron": {
31
31
  desc: "Cron scheduler operations",
32
32
  children: {
33
- start: { desc: "Create cron task" },
33
+ start: { desc: "Create cron task (optional title)" },
34
34
  list: { desc: "List cron tasks" },
35
35
  stop: { desc: "Stop cron task by id or all" },
36
36
  },
@@ -47,6 +47,7 @@ const COMMAND_TREE = {
47
47
  },
48
48
  },
49
49
  "/init": { desc: "Initialize modules" },
50
+ "/open": { desc: "Open project path in global mode" },
50
51
  "/launch": {
51
52
  desc: "Launch new agent",
52
53
  children: {
@@ -63,6 +64,12 @@ const COMMAND_TREE = {
63
64
  switch: { desc: "Switch daemon connection to project index/path" },
64
65
  },
65
66
  },
67
+ "/role": {
68
+ desc: "Assign preset role to an existing agent",
69
+ children: {
70
+ list: { desc: "List available prompt profiles" },
71
+ },
72
+ },
66
73
  "/resume": {
67
74
  desc: "Resume agents (optional nickname) or list recoverable targets",
68
75
  children: {
@@ -11,6 +11,7 @@ function createCompletionController(options = {}) {
11
11
  completionPanel,
12
12
  promptBox,
13
13
  commandRegistry = [],
14
+ getGroupTemplateCandidates = () => [],
14
15
  getMentionCandidates = () => [],
15
16
  normalizeCommandPrefix = () => {},
16
17
  truncateText = (text) => String(text || ""),
@@ -141,8 +142,33 @@ function createCompletionController(options = {}) {
141
142
  const parts = trimmed.split(/\s+/);
142
143
  const mainCmd = parts[0];
143
144
  const isLaunch = mainCmd && mainCmd.toLowerCase() === "/launch";
145
+ const isGroup = mainCmd && mainCmd.toLowerCase() === "/group";
144
146
  const wantsSubcommands = (parts.length > 1 || (endsWithSpace && parts.length === 1));
145
147
 
148
+ if (isGroup) {
149
+ const groupSubcommand = String(parts[1] || "").trim().toLowerCase();
150
+ const wantsGroupRunArgs = groupSubcommand === "run" && (parts.length > 2 || endsWithSpace);
151
+ if (wantsGroupRunArgs) {
152
+ const aliasFilter = String(parts[2] || "").trim().toLowerCase();
153
+ return (Array.isArray(getGroupTemplateCandidates()) ? getGroupTemplateCandidates() : [])
154
+ .map((item) => {
155
+ const alias = String(item && item.alias ? item.alias : item && item.cmd ? item.cmd : "").trim();
156
+ if (!alias) return null;
157
+ const desc = String(item && item.desc ? item.desc : item && item.name ? item.name : "").trim();
158
+ const source = String(item && item.source ? item.source : "").trim();
159
+ const detail = [desc, source].filter(Boolean).join(" · ");
160
+ return {
161
+ cmd: alias,
162
+ desc: detail,
163
+ isArgumentSuggestion: true,
164
+ argumentPrefix: "/group run",
165
+ };
166
+ })
167
+ .filter((item) => item && (!aliasFilter || item.cmd.toLowerCase().startsWith(aliasFilter)))
168
+ .sort((a, b) => a.cmd.localeCompare(b.cmd, "en", { sensitivity: "base" }));
169
+ }
170
+ }
171
+
146
172
  if ((wantsSubcommands || isLaunch) && mainCmd && mainCmd.startsWith("/")) {
147
173
  const subFilter = parts[1] || "";
148
174
  const mainCmdObj = commandRegistry.find((item) =>
@@ -261,6 +287,13 @@ function createCompletionController(options = {}) {
261
287
  return { text: `${completedCore} `, isComplete };
262
288
  }
263
289
 
290
+ if (selected.isArgumentSuggestion) {
291
+ const prefix = String(selected.argumentPrefix || "").trim();
292
+ const completedCore = prefix ? `${prefix} ${selected.cmd}` : selected.cmd;
293
+ const isComplete = trimmed === completedCore || trimmed.startsWith(`${completedCore} `);
294
+ return { text: `${completedCore} `, isComplete };
295
+ }
296
+
264
297
  const completedCore = selected.cmd;
265
298
  const hasChildren = selected.subcommands && selected.subcommands.length > 0;
266
299
  const isComplete =
@@ -291,6 +324,9 @@ function createCompletionController(options = {}) {
291
324
  const parts = input.value.split(/\s+/);
292
325
  parts[parts.length - 1] = selected.cmd;
293
326
  input.value = `${parts.join(" ")} `;
327
+ } else if (selected.isArgumentSuggestion) {
328
+ const prefix = String(selected.argumentPrefix || "").trim();
329
+ input.value = prefix ? `${prefix} ${selected.cmd} ` : `${selected.cmd} `;
294
330
  } else {
295
331
  input.value = `${selected.cmd} `;
296
332
  }
@@ -304,6 +340,8 @@ function createCompletionController(options = {}) {
304
340
 
305
341
  if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
306
342
  show(input.value);
343
+ } else if (selected.isSubcommand && selected.parentCmd === "/group" && selected.cmd === "run") {
344
+ show(input.value);
307
345
  } else {
308
346
  hide();
309
347
  }
@@ -346,6 +384,8 @@ function createCompletionController(options = {}) {
346
384
  applyPreview(nextPreview);
347
385
  if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
348
386
  show(input.value);
387
+ } else if (selected.isSubcommand && selected.parentCmd === "/group" && selected.cmd === "run") {
388
+ show(input.value);
349
389
  } else {
350
390
  hide();
351
391
  }
@@ -29,13 +29,39 @@ function sanitizeSummaryText(value = "") {
29
29
  .trim();
30
30
  }
31
31
 
32
+ function truncateCronText(value = "", maxLength = 24) {
33
+ const text = String(value || "").trim();
34
+ if (!text) return "";
35
+ if (text.length <= maxLength) return text;
36
+ return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
37
+ }
38
+
39
+ function resolveTaskTitle(task = {}) {
40
+ const explicitTitle = truncateCronText(
41
+ sanitizeSummaryText(task.title || "").replace(/:/g, "-"),
42
+ 24
43
+ );
44
+ if (explicitTitle) return explicitTitle;
45
+ const fallbackTitle = truncateCronText(
46
+ sanitizeSummaryText(task.prompt || "").replace(/:/g, "-"),
47
+ 24
48
+ );
49
+ return fallbackTitle || "(empty)";
50
+ }
51
+
52
+ function buildTaskLabel(task = {}) {
53
+ const targets = Array.isArray(task.targets) && task.targets.length > 0
54
+ ? task.targets.join("+")
55
+ : "unknown";
56
+ const title = resolveTaskTitle(task);
57
+ const interval = formatIntervalMs(task.intervalMs || 0);
58
+ return `${targets}:${title}:${interval}`;
59
+ }
60
+
32
61
  function summarizeTask(task = {}) {
33
62
  const id = String(task.id || "");
34
- const interval = formatIntervalMs(task.intervalMs || 0);
35
- const targets = Array.isArray(task.targets) ? task.targets.join("+") : "";
36
- const promptRaw = sanitizeSummaryText(task.prompt || "");
37
- const prompt = promptRaw.length > 24 ? `${promptRaw.slice(0, 24)}...` : promptRaw;
38
- return `${id}@${interval}->${targets}: ${prompt || "(empty)"}`;
63
+ const label = buildTaskLabel(task);
64
+ return id ? `${id} ${label}` : label;
39
65
  }
40
66
 
41
67
  function createCronScheduler(options = {}) {
@@ -58,7 +84,7 @@ function createCronScheduler(options = {}) {
58
84
  }
59
85
  }
60
86
 
61
- function addTask({ intervalMs = 0, targets = [], prompt = "" } = {}) {
87
+ function addTask({ intervalMs = 0, targets = [], prompt = "", title = "" } = {}) {
62
88
  const safeInterval = Number.parseInt(intervalMs, 10);
63
89
  const safeTargets = Array.isArray(targets)
64
90
  ? targets.map((item) => String(item || "").trim()).filter(Boolean)
@@ -74,6 +100,7 @@ function createCronScheduler(options = {}) {
74
100
  intervalMs: safeInterval,
75
101
  targets: Array.from(new Set(safeTargets)),
76
102
  prompt: safePrompt,
103
+ title: resolveTaskTitle({ title, prompt: safePrompt }),
77
104
  createdAt: nowFn(),
78
105
  lastRunAt: 0,
79
106
  tickCount: 0,
@@ -100,6 +127,7 @@ function createCronScheduler(options = {}) {
100
127
  notifyChange();
101
128
  return {
102
129
  ...task,
130
+ label: buildTaskLabel(task),
103
131
  summary: summarizeTask(task),
104
132
  };
105
133
  }
@@ -110,9 +138,11 @@ function createCronScheduler(options = {}) {
110
138
  intervalMs: task.intervalMs,
111
139
  targets: task.targets.slice(),
112
140
  prompt: task.prompt,
141
+ title: task.title,
113
142
  createdAt: task.createdAt,
114
143
  lastRunAt: task.lastRunAt,
115
144
  tickCount: task.tickCount,
145
+ label: buildTaskLabel(task),
116
146
  summary: summarizeTask(task),
117
147
  }));
118
148
  }
@@ -155,6 +185,7 @@ function createCronScheduler(options = {}) {
155
185
  module.exports = {
156
186
  parseIntervalMs,
157
187
  formatIntervalMs,
188
+ buildTaskLabel,
158
189
  summarizeTask,
159
190
  createCronScheduler,
160
191
  };
@@ -254,12 +254,12 @@ function createDaemonMessageRouter(options = {}) {
254
254
  if (task.mode === "once") {
255
255
  logMessage(
256
256
  "system",
257
- `{white-fg}✓{/white-fg} Cron scheduled ${escapeBlessed(task.id)} at ${escapeBlessed(task.onceAt || String(task.onceAtMs || ""))}`
257
+ `{white-fg}✓{/white-fg} Cron scheduled ${escapeBlessed(task.id)}: ${escapeBlessed(task.label || task.onceAt || String(task.onceAtMs || ""))}`
258
258
  );
259
259
  } else {
260
260
  logMessage(
261
261
  "system",
262
- `{white-fg}✓{/white-fg} Cron started ${escapeBlessed(task.id)}: every ${escapeBlessed(task.interval || String(task.intervalMs || ""))}`
262
+ `{white-fg}✓{/white-fg} Cron started ${escapeBlessed(task.id)}: ${escapeBlessed(task.label || task.interval || String(task.intervalMs || ""))}`
263
263
  );
264
264
  }
265
265
  } else if (operation === "stop") {
@@ -286,7 +286,14 @@ function createDaemonMessageRouter(options = {}) {
286
286
  payload.disambiguate.candidates.length > 0
287
287
  ) {
288
288
  const pending = getPending();
289
- setPending({ disambiguate: payload.disambiguate, original: pending && pending.original });
289
+ const routedProjectRoot = payload.routed_project && payload.routed_project.project_root
290
+ ? payload.routed_project.project_root
291
+ : (pending && pending.project_root ? pending.project_root : "");
292
+ setPending({
293
+ disambiguate: payload.disambiguate,
294
+ original: pending && pending.original,
295
+ project_root: routedProjectRoot || undefined,
296
+ });
290
297
  const prompt = payload.disambiguate.prompt || "Choose target:";
291
298
  resolveStatusLine(`{gray-fg}?{/gray-fg} ${escapeBlessed(prompt)}`);
292
299
  logMessage("disambiguate", `{white-fg}?{/white-fg} ${escapeBlessed(prompt)}`);
@@ -323,6 +330,19 @@ function createDaemonMessageRouter(options = {}) {
323
330
  requestStatus();
324
331
  return true;
325
332
  }
333
+ if (data.event === "controller_report") {
334
+ const report = data.report && typeof data.report === "object" ? data.report : {};
335
+ const publisher = report.agent_id || data.publisher || "ufoo-agent";
336
+ const displayName = resolveAgentDisplayName(publisher);
337
+ const detail = report.summary || report.message || data.message || report.task_id || "report";
338
+ logMessage(
339
+ "system",
340
+ `{gray-fg}↥{/gray-fg} {cyan-fg}${escapeBlessed(displayName)}{/cyan-fg} {gray-fg}→ ufoo-agent{/gray-fg} ${escapeBlessed(detail)}`
341
+ );
342
+ requestStatus();
343
+ renderScreen();
344
+ return true;
345
+ }
326
346
  const prefix = data.event === "broadcast" ? "{gray-fg}⇢{/gray-fg}" : "{gray-fg}↔{/gray-fg}";
327
347
  const publisher = data.publisher && data.publisher !== "unknown"
328
348
  ? data.publisher