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
@@ -5,6 +5,29 @@ const {
5
5
  listControllerInboxEntries,
6
6
  consumeControllerInboxEntries,
7
7
  } = require("../report/store");
8
+ const { isGlobalControllerProjectRoot } = require("../globalMode");
9
+
10
+ function normalizeProjectRoute(route) {
11
+ if (!route || typeof route !== "object") return null;
12
+ const projectRoot = String(route.project_root || route.projectRoot || "").trim();
13
+ if (!projectRoot) return null;
14
+ return {
15
+ project_root: projectRoot,
16
+ project_name: String(route.project_name || route.projectName || "").trim(),
17
+ prompt: String(route.prompt || route.message || "").trim(),
18
+ reason: String(route.reason || "").trim(),
19
+ };
20
+ }
21
+
22
+ function buildRoutedProjectPayload(basePayload = {}, route = {}) {
23
+ const payload = basePayload && typeof basePayload === "object" ? { ...basePayload } : {};
24
+ payload.routed_project = {
25
+ project_root: String(route.project_root || ""),
26
+ project_name: String(route.project_name || ""),
27
+ reason: String(route.reason || ""),
28
+ };
29
+ return payload;
30
+ }
8
31
 
9
32
  function buildPromptWithPrivateReports(prompt = "", reports = [], requestMeta = {}) {
10
33
  const meta = requestMeta && typeof requestMeta === "object" ? requestMeta : {};
@@ -34,6 +57,9 @@ function buildPromptWithPrivateReports(prompt = "", reports = [], requestMeta =
34
57
  lines.push(JSON.stringify(reports, null, 2));
35
58
  lines.push("");
36
59
  lines.push("Use these runtime reports when deciding reply/dispatch/ops.");
60
+ lines.push("Treat them as control-plane observability, not automatic downstream delivery instructions.");
61
+ lines.push("If report.meta.handoff.status is \"delivered\" and report.meta.needs_dispatch is not true, do not dispatch that handoff again.");
62
+ lines.push("Only treat a report as a controller dispatch request when report.meta.needs_dispatch is true.");
37
63
  return lines.join("\n");
38
64
  }
39
65
 
@@ -52,12 +78,61 @@ async function handlePromptRequest(options = {}) {
52
78
  handleOps,
53
79
  markPending = () => {},
54
80
  reportTaskStatus = () => {},
81
+ forwardProjectPrompt = null,
55
82
  log = () => {},
56
83
  } = options;
57
84
 
58
85
  log(`prompt ${String(req.text || "").slice(0, 200)}`);
86
+ const requestMeta = req.request_meta && typeof req.request_meta === "object" ? req.request_meta : {};
87
+ const isGlobalController = isGlobalControllerProjectRoot(projectRoot);
88
+ const forcedProjectRoot = String(requestMeta.force_project_root || "").trim();
89
+
90
+ if (isGlobalController && forcedProjectRoot) {
91
+ try {
92
+ const routed = await Promise.resolve(forwardProjectPrompt({
93
+ targetProjectRoot: forcedProjectRoot,
94
+ targetProjectName: String(requestMeta.force_project_name || "").trim(),
95
+ prompt: req.text || "",
96
+ routeReason: "forced_project_root",
97
+ requestMeta,
98
+ }));
99
+ if (!routed || routed.ok !== true) {
100
+ const error = routed && routed.error ? routed.error : "project forward failed";
101
+ socket.write(
102
+ `${JSON.stringify({
103
+ type: IPC_RESPONSE_TYPES.ERROR,
104
+ error,
105
+ })}\n`,
106
+ );
107
+ return false;
108
+ }
109
+ const payload = buildRoutedProjectPayload(routed.payload || {}, {
110
+ project_root: routed.project_root || forcedProjectRoot,
111
+ project_name: routed.project_name || String(requestMeta.force_project_name || "").trim(),
112
+ reason: "forced_project_root",
113
+ });
114
+ socket.write(
115
+ `${JSON.stringify({
116
+ type: IPC_RESPONSE_TYPES.RESPONSE,
117
+ data: payload,
118
+ opsResults: routed.opsResults || [],
119
+ })}\n`,
120
+ );
121
+ return true;
122
+ } catch (err) {
123
+ socket.write(
124
+ `${JSON.stringify({
125
+ type: IPC_RESPONSE_TYPES.ERROR,
126
+ error: err.message || String(err),
127
+ })}\n`,
128
+ );
129
+ return false;
130
+ }
131
+ }
132
+
59
133
  const privateReports = listControllerInboxEntries(projectRoot, "ufoo-agent", { num: 100 });
60
- const promptText = buildPromptWithPrivateReports(req.text || "", privateReports, req.request_meta);
134
+ const promptText = buildPromptWithPrivateReports(req.text || "", privateReports, requestMeta);
135
+ const useGlobalProjectRouter = isGlobalController;
61
136
 
62
137
  try {
63
138
  const handled = await runPromptWithAssistant({
@@ -74,6 +149,8 @@ async function handlePromptRequest(options = {}) {
74
149
  reportTaskStatus,
75
150
  maxAssistantLoops: 2,
76
151
  log,
152
+ ufooAgentOptions: useGlobalProjectRouter ? { routingMode: "global-router" } : {},
153
+ finalizeLocally: !useGlobalProjectRouter,
77
154
  });
78
155
 
79
156
  if (!handled.ok) {
@@ -87,9 +164,56 @@ async function handlePromptRequest(options = {}) {
87
164
  return false;
88
165
  }
89
166
 
167
+ if (useGlobalProjectRouter) {
168
+ const route = normalizeProjectRoute(handled.payload && handled.payload.project_route);
169
+ if (route) {
170
+ const routed = await Promise.resolve(forwardProjectPrompt({
171
+ targetProjectRoot: route.project_root,
172
+ targetProjectName: route.project_name,
173
+ prompt: route.prompt || req.text || "",
174
+ routeReason: route.reason,
175
+ requestMeta,
176
+ }));
177
+ if (!routed || routed.ok !== true) {
178
+ log(`project-forward-fail ${route.project_root} ${(routed && routed.error) || "project forward failed"}`);
179
+ socket.write(
180
+ `${JSON.stringify({
181
+ type: IPC_RESPONSE_TYPES.ERROR,
182
+ error: (routed && routed.error) || "project forward failed",
183
+ })}\n`,
184
+ );
185
+ return false;
186
+ }
187
+
188
+ consumeControllerInboxEntries(projectRoot, "ufoo-agent", privateReports);
189
+ const routedPayload = buildRoutedProjectPayload(routed.payload || {}, {
190
+ project_root: routed.project_root || route.project_root,
191
+ project_name: routed.project_name || route.project_name,
192
+ reason: route.reason,
193
+ });
194
+ log(`project-forward-ok ${route.project_root}`);
195
+ socket.write(
196
+ `${JSON.stringify({
197
+ type: IPC_RESPONSE_TYPES.RESPONSE,
198
+ data: routedPayload,
199
+ opsResults: routed.opsResults || [],
200
+ })}\n`,
201
+ );
202
+ return true;
203
+ }
204
+ }
205
+
90
206
  consumeControllerInboxEntries(projectRoot, "ufoo-agent", privateReports);
91
207
 
92
- const payload = handled.payload || {};
208
+ const payload = handled.payload && typeof handled.payload === "object"
209
+ ? { ...handled.payload }
210
+ : {};
211
+ if (useGlobalProjectRouter) {
212
+ delete payload.project_route;
213
+ delete payload.disambiguate;
214
+ payload.dispatch = [];
215
+ payload.ops = [];
216
+ }
93
217
  const opsResults = handled.opsResults || [];
94
218
  log(`ok reply=${Boolean(payload.reply)} dispatch=${(payload.dispatch || []).length} ops=${(payload.ops || []).length}`);
95
219
  socket.write(
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const EventBus = require("../bus");
2
3
  const { BUS_STATUS_PHASES } = require("../shared/eventContract");
3
4
  const {
4
5
  REPORT_PHASES,
@@ -62,6 +63,21 @@ function publishToPrivateController(projectRoot, entry) {
62
63
  appendControllerInboxEntry(projectRoot, entry.controller_id, entry);
63
64
  }
64
65
 
66
+ async function wakePrivateController(projectRoot, entry, log = () => {}) {
67
+ if (!entry || entry.scope !== "private" || !entry.controller_id) return false;
68
+ try {
69
+ const eventBus = new EventBus(projectRoot);
70
+ await eventBus.wake(entry.controller_id, {
71
+ reason: `agent-report:${entry.phase}`,
72
+ shake: false,
73
+ });
74
+ return true;
75
+ } catch (err) {
76
+ log(`report wake skipped controller=${entry.controller_id} error=${err.message || String(err)}`);
77
+ return false;
78
+ }
79
+ }
80
+
65
81
  async function recordAgentReport({
66
82
  projectRoot,
67
83
  report,
@@ -72,6 +88,7 @@ async function recordAgentReport({
72
88
  appendReport(projectRoot, entry);
73
89
  const state = updateReportState(projectRoot, entry);
74
90
  publishToPrivateController(projectRoot, entry);
91
+ await wakePrivateController(projectRoot, entry, log);
75
92
  const displayName = resolveAgentDisplayName(projectRoot, entry.agent_id);
76
93
  if (entry.scope !== "private") {
77
94
  onStatus(buildReportStatus(entry, displayName));
@@ -87,4 +104,5 @@ module.exports = {
87
104
  formatStatusText,
88
105
  buildReportStatus,
89
106
  publishToPrivateController,
107
+ wakePrivateController,
90
108
  };
@@ -0,0 +1,435 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const EventBus = require("../bus");
5
+ const { prepareUcodeBootstrap } = require("../agent/ucodeBootstrap");
6
+ const { isMetaActive } = require("../bus/utils");
7
+ const { getUfooPaths } = require("../ufoo/paths");
8
+ const { loadAgentsData, saveAgentsData } = require("../ufoo/agentsStore");
9
+ const {
10
+ loadPromptProfileRegistry,
11
+ resolvePromptProfileReference,
12
+ } = require("../group/promptProfiles");
13
+ const {
14
+ buildSoloPromptMetadata,
15
+ composeSoloBootstrapPrompt,
16
+ } = require("../group/bootstrap");
17
+
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ function asTrimmedString(value) {
23
+ return typeof value === "string" ? value.trim() : "";
24
+ }
25
+
26
+ function resolveSoloPromptProfile(projectRoot, reference = "", options = {}) {
27
+ const requested = String(reference || "").trim();
28
+ if (!requested) {
29
+ return { ok: true, requested_profile: "", profile: null, registry: null };
30
+ }
31
+ const registry = loadPromptProfileRegistry(projectRoot, options);
32
+ if (Array.isArray(registry.errors) && registry.errors.length > 0) {
33
+ const first = registry.errors[0];
34
+ return {
35
+ ok: false,
36
+ error: first && first.message ? first.message : "prompt profile registry failed to load",
37
+ registry,
38
+ };
39
+ }
40
+ const profile = resolvePromptProfileReference(registry, requested);
41
+ if (!profile) {
42
+ return {
43
+ ok: false,
44
+ error: `unknown prompt profile: ${requested}`,
45
+ registry,
46
+ };
47
+ }
48
+ return {
49
+ ok: true,
50
+ requested_profile: requested,
51
+ profile,
52
+ registry,
53
+ };
54
+ }
55
+
56
+ function buildSoloBootstrap({
57
+ nickname = "",
58
+ agentType = "",
59
+ requestedProfile = "",
60
+ profile = null,
61
+ } = {}) {
62
+ if (!profile) {
63
+ return { ok: true, required: false, promptText: "", metadata: {}, profile: null };
64
+ }
65
+ const metadata = buildSoloPromptMetadata({
66
+ nickname,
67
+ agentType,
68
+ requestedProfile,
69
+ resolvedProfile: profile.id,
70
+ displayName: profile.display_name,
71
+ shortName: profile.short_name,
72
+ summary: profile.summary,
73
+ source: profile.source,
74
+ });
75
+ const promptText = composeSoloBootstrapPrompt({
76
+ profilePrompt: profile.prompt,
77
+ metadata,
78
+ });
79
+ return {
80
+ ok: true,
81
+ required: true,
82
+ promptText,
83
+ metadata,
84
+ profile,
85
+ };
86
+ }
87
+
88
+ function parseTimestampMs(value) {
89
+ const ms = Date.parse(String(value || ""));
90
+ return Number.isFinite(ms) ? ms : Number.NaN;
91
+ }
92
+
93
+ function getAgentRuntimeMeta(projectRoot, subscriberId) {
94
+ try {
95
+ const busPath = getUfooPaths(projectRoot).agentsFile;
96
+ const bus = loadAgentsData(busPath);
97
+ const meta = bus && bus.agents ? bus.agents[subscriberId] : null;
98
+ return meta && typeof meta === "object" ? meta : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ async function waitForSoloBootstrapReady(projectRoot, subscriberId, options = {}) {
105
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
106
+ const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 250;
107
+ const protectionMs = Number.isFinite(options.protectionMs) ? options.protectionMs : 3000;
108
+ const workingGraceMs = Number.isFinite(options.workingGraceMs) ? options.workingGraceMs : 10000;
109
+ const startedAt = Date.now();
110
+ let lastState = "";
111
+
112
+ while (Date.now() - startedAt < timeoutMs) {
113
+ const meta = getAgentRuntimeMeta(projectRoot, subscriberId);
114
+ const status = asTrimmedString(meta && meta.status).toLowerCase();
115
+ const state = asTrimmedString(meta && meta.activity_state).toLowerCase();
116
+ lastState = state || lastState;
117
+ if (status && status !== "active") {
118
+ return { ok: false, error: `agent became ${status} before bootstrap` };
119
+ }
120
+ const elapsed = Date.now() - startedAt;
121
+ if (state === "ready" || state === "idle" || state === "waiting_input" || state === "blocked") {
122
+ return { ok: true, activity_state: state };
123
+ }
124
+ if (state === "working") {
125
+ if (elapsed < protectionMs) {
126
+ await sleep(retryDelayMs);
127
+ continue;
128
+ }
129
+ const activitySinceMs = parseTimestampMs(meta && meta.activity_since);
130
+ const workingMs = Number.isFinite(activitySinceMs)
131
+ ? Math.max(0, Date.now() - activitySinceMs)
132
+ : elapsed;
133
+ if (workingMs >= workingGraceMs) {
134
+ return { ok: true, activity_state: state, degraded: true };
135
+ }
136
+ }
137
+ if (!state || state === "starting" || state === "running" || state === "working") {
138
+ await sleep(retryDelayMs);
139
+ continue;
140
+ }
141
+ await sleep(retryDelayMs);
142
+ }
143
+
144
+ return {
145
+ ok: false,
146
+ error: lastState
147
+ ? `agent not ready for bootstrap (last activity_state=${lastState})`
148
+ : "agent not ready for bootstrap",
149
+ };
150
+ }
151
+
152
+ async function injectSoloBootstrapPrompt(projectRoot, subscriberId, promptText, options = {}) {
153
+ const ready = await waitForSoloBootstrapReady(projectRoot, subscriberId, options);
154
+ if (!ready.ok) return ready;
155
+
156
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
157
+ const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 250;
158
+ const deadline = Date.now() + timeoutMs;
159
+ const bus = new EventBus(projectRoot);
160
+ let lastError = null;
161
+ while (Date.now() < deadline) {
162
+ try {
163
+ await bus.inject(subscriberId, promptText);
164
+ return { ok: true };
165
+ } catch (err) {
166
+ lastError = err;
167
+ await sleep(retryDelayMs);
168
+ }
169
+ }
170
+ return {
171
+ ok: false,
172
+ error: lastError && lastError.message ? lastError.message : `bootstrap inject failed for ${subscriberId}`,
173
+ };
174
+ }
175
+
176
+ function prepareSoloUcodeBootstrap(projectRoot, nickname, promptText) {
177
+ const safeNickname = String(nickname || "agent").replace(/[^a-zA-Z0-9._-]/g, "-");
178
+ const targetFile = path.join(getUfooPaths(projectRoot).agentDir, "ucode", `${safeNickname}-bootstrap.md`);
179
+ return prepareUcodeBootstrap({
180
+ projectRoot,
181
+ targetFile,
182
+ promptText,
183
+ });
184
+ }
185
+
186
+ function loadBusMeta(projectRoot) {
187
+ return loadAgentsData(getUfooPaths(projectRoot).agentsFile);
188
+ }
189
+
190
+ function isLiveAgentMeta(meta) {
191
+ return Boolean(meta) && isMetaActive(meta);
192
+ }
193
+
194
+ function resolveExistingAgent(projectRoot, target = "") {
195
+ const key = String(target || "").trim();
196
+ if (!key) return null;
197
+ const bus = loadBusMeta(projectRoot);
198
+ const agents = bus && bus.agents ? bus.agents : {};
199
+ if (isLiveAgentMeta(agents[key])) {
200
+ return { subscriberId: key, meta: agents[key] };
201
+ }
202
+ for (const [subscriberId, meta] of Object.entries(agents)) {
203
+ if (meta && meta.nickname === key && isLiveAgentMeta(meta)) {
204
+ return { subscriberId, meta };
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function findOwningGroup(projectRoot, subscriberId = "") {
211
+ const targetSubscriber = asTrimmedString(subscriberId);
212
+ if (!targetSubscriber) return null;
213
+ const liveMeta = getAgentRuntimeMeta(projectRoot, targetSubscriber);
214
+ if (!isLiveAgentMeta(liveMeta)) return null;
215
+ if (asTrimmedString(liveMeta.role_owner).toLowerCase() === "solo") return null;
216
+ const liveNickname = asTrimmedString(liveMeta.nickname);
217
+ const groupsDir = getUfooPaths(projectRoot).groupsDir;
218
+ if (!groupsDir) return null;
219
+ let files = [];
220
+ try {
221
+ files = require("fs").readdirSync(groupsDir).filter((name) => name.endsWith(".json"));
222
+ } catch {
223
+ return null;
224
+ }
225
+ for (const fileName of files) {
226
+ try {
227
+ const filePath = path.join(groupsDir, fileName);
228
+ const runtime = JSON.parse(require("fs").readFileSync(filePath, "utf8"));
229
+ if (!runtime || runtime.status !== "active" || !Array.isArray(runtime.members)) continue;
230
+ const found = runtime.members.find((member) =>
231
+ member
232
+ && asTrimmedString(member.subscriber_id) === targetSubscriber
233
+ && (member.status === "active" || member.status === "reused")
234
+ && asTrimmedString(member.nickname)
235
+ && (!liveNickname || asTrimmedString(member.nickname) === liveNickname)
236
+ );
237
+ if (found) {
238
+ return {
239
+ group_id: String(runtime.group_id || "").trim(),
240
+ template_alias: String(runtime.template_alias || "").trim(),
241
+ nickname: String(found.nickname || "").trim(),
242
+ };
243
+ }
244
+ } catch {
245
+ // ignore malformed runtime
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+
251
+ function pickLaunchSubscriber(launchResult = {}, fallbackTarget = "") {
252
+ if (launchResult && Array.isArray(launchResult.subscriber_ids) && launchResult.subscriber_ids.length > 0) {
253
+ return asTrimmedString(launchResult.subscriber_ids[0]);
254
+ }
255
+ if (launchResult && launchResult.agent_id) {
256
+ return asTrimmedString(launchResult.agent_id);
257
+ }
258
+ return asTrimmedString(fallbackTarget);
259
+ }
260
+
261
+ function shouldRollbackRoleAssignmentLaunch(launchResult = {}) {
262
+ return Boolean(launchResult && launchResult.ok !== false && launchResult.skipped !== true);
263
+ }
264
+
265
+ async function rollbackLaunchAfterRoleAssignmentFailure(
266
+ projectRoot,
267
+ launchResult,
268
+ fallbackTarget,
269
+ handleOps,
270
+ processManager = null
271
+ ) {
272
+ if (!shouldRollbackRoleAssignmentLaunch(launchResult)) {
273
+ return { ok: true, skipped: true, rolled_back: false, target: "" };
274
+ }
275
+ if (typeof handleOps !== "function") {
276
+ return { ok: false, rolled_back: false, target: "", error: "handleOps is required for rollback" };
277
+ }
278
+ const target = pickLaunchSubscriber(launchResult, fallbackTarget);
279
+ if (!target) {
280
+ return { ok: false, rolled_back: false, target: "", error: "missing subscriber_id for rollback" };
281
+ }
282
+ const opsResults = await handleOps(projectRoot, [{ action: "close", agent_id: target }], processManager);
283
+ const closeResult = Array.isArray(opsResults)
284
+ ? opsResults.find((entry) => entry && entry.action === "close")
285
+ : null;
286
+ if (closeResult && closeResult.ok !== false) {
287
+ return { ok: true, skipped: false, rolled_back: true, target };
288
+ }
289
+ return {
290
+ ok: false,
291
+ skipped: false,
292
+ rolled_back: false,
293
+ target,
294
+ error: closeResult && closeResult.error ? closeResult.error : "close failed",
295
+ };
296
+ }
297
+
298
+ function persistSoloRoleMetadata(projectRoot, subscriberId, payload = {}) {
299
+ const filePath = getUfooPaths(projectRoot).agentsFile;
300
+ const bus = loadAgentsData(filePath);
301
+ const meta = bus.agents && bus.agents[subscriberId];
302
+ if (!meta) return false;
303
+ bus.agents[subscriberId] = {
304
+ ...meta,
305
+ bootstrap_kind: "solo",
306
+ role_owner: "solo",
307
+ requested_profile: String(payload.requested_profile || "").trim(),
308
+ resolved_profile: String(payload.resolved_profile || "").trim(),
309
+ bootstrap_fingerprint: String(payload.bootstrap_fingerprint || "").trim(),
310
+ bootstrapped_subscriber_id: String(payload.bootstrapped_subscriber_id || subscriberId).trim(),
311
+ role_assigned_at: new Date().toISOString(),
312
+ };
313
+ saveAgentsData(filePath, bus);
314
+ return true;
315
+ }
316
+
317
+ function buildSoloBootstrapFingerprint({ subscriberId = "", requestedProfile = "", resolvedProfile = "", promptText = "" } = {}) {
318
+ return require("crypto")
319
+ .createHash("sha256")
320
+ .update(JSON.stringify({
321
+ subscriber_id: String(subscriberId || ""),
322
+ requested_profile: String(requestedProfile || ""),
323
+ resolved_profile: String(resolvedProfile || ""),
324
+ prompt: String(promptText || ""),
325
+ }))
326
+ .digest("hex");
327
+ }
328
+
329
+ function isSameSoloAssignment(meta = {}, subscriberId = "", requestedProfile = "", resolvedProfile = "", promptText = "") {
330
+ const currentOwner = String(meta.role_owner || "").trim();
331
+ const currentKind = String(meta.bootstrap_kind || "").trim();
332
+ const currentFingerprint = String(meta.bootstrap_fingerprint || "").trim();
333
+ const nextFingerprint = buildSoloBootstrapFingerprint({
334
+ subscriberId,
335
+ requestedProfile,
336
+ resolvedProfile,
337
+ promptText,
338
+ });
339
+ return currentOwner === "solo"
340
+ && currentKind === "solo"
341
+ && currentFingerprint
342
+ && currentFingerprint === nextFingerprint;
343
+ }
344
+
345
+ async function assignSoloRoleToExistingAgent(projectRoot, target, profileReference, options = {}) {
346
+ const resolvedTarget = resolveExistingAgent(projectRoot, target);
347
+ if (!resolvedTarget) {
348
+ return { ok: false, error: `agent not found: ${target}` };
349
+ }
350
+ const ownership = findOwningGroup(projectRoot, resolvedTarget.subscriberId);
351
+ if (ownership) {
352
+ return {
353
+ ok: false,
354
+ error: `agent is group-owned by ${ownership.group_id || ownership.template_alias || "active-group"}`,
355
+ group: ownership,
356
+ subscriber_id: resolvedTarget.subscriberId,
357
+ };
358
+ }
359
+
360
+ const resolvedProfile = resolveSoloPromptProfile(projectRoot, profileReference, options.promptProfilesOptions || {});
361
+ if (!resolvedProfile.ok) return resolvedProfile;
362
+ const built = buildSoloBootstrap({
363
+ nickname: resolvedTarget.meta.nickname || resolvedTarget.subscriberId,
364
+ agentType: resolvedTarget.meta.agent_type || "",
365
+ requestedProfile: resolvedProfile.requested_profile,
366
+ profile: resolvedProfile.profile,
367
+ });
368
+ if (!built.required) {
369
+ return { ok: false, error: "prompt profile is required" };
370
+ }
371
+
372
+ if (isSameSoloAssignment(
373
+ resolvedTarget.meta,
374
+ resolvedTarget.subscriberId,
375
+ resolvedProfile.requested_profile,
376
+ resolvedProfile.profile.id,
377
+ built.promptText,
378
+ )) {
379
+ return {
380
+ ok: true,
381
+ skipped: true,
382
+ subscriber_id: resolvedTarget.subscriberId,
383
+ requested_profile: resolvedProfile.requested_profile,
384
+ resolved_profile: resolvedProfile.profile.id,
385
+ };
386
+ }
387
+
388
+ const injected = await injectSoloBootstrapPrompt(
389
+ projectRoot,
390
+ resolvedTarget.subscriberId,
391
+ built.promptText,
392
+ options.bootstrapOptions || {},
393
+ );
394
+ if (!injected.ok) {
395
+ return {
396
+ ok: false,
397
+ error: injected.error || "solo bootstrap inject failed",
398
+ subscriber_id: resolvedTarget.subscriberId,
399
+ };
400
+ }
401
+
402
+ const fingerprint = buildSoloBootstrapFingerprint({
403
+ subscriberId: resolvedTarget.subscriberId,
404
+ requestedProfile: resolvedProfile.requested_profile,
405
+ resolvedProfile: resolvedProfile.profile.id,
406
+ promptText: built.promptText,
407
+ });
408
+ persistSoloRoleMetadata(projectRoot, resolvedTarget.subscriberId, {
409
+ requested_profile: resolvedProfile.requested_profile,
410
+ resolved_profile: resolvedProfile.profile.id,
411
+ bootstrap_fingerprint: fingerprint,
412
+ bootstrapped_subscriber_id: resolvedTarget.subscriberId,
413
+ });
414
+
415
+ return {
416
+ ok: true,
417
+ subscriber_id: resolvedTarget.subscriberId,
418
+ requested_profile: resolvedProfile.requested_profile,
419
+ resolved_profile: resolvedProfile.profile.id,
420
+ };
421
+ }
422
+
423
+ module.exports = {
424
+ resolveSoloPromptProfile,
425
+ buildSoloBootstrap,
426
+ waitForSoloBootstrapReady,
427
+ injectSoloBootstrapPrompt,
428
+ prepareSoloUcodeBootstrap,
429
+ resolveExistingAgent,
430
+ findOwningGroup,
431
+ persistSoloRoleMetadata,
432
+ buildSoloBootstrapFingerprint,
433
+ rollbackLaunchAfterRoleAssignmentFailure,
434
+ assignSoloRoleToExistingAgent,
435
+ };
@@ -2,7 +2,7 @@ const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { getUfooPaths } = require("../ufoo/paths");
4
4
  const { isMetaActive } = require("../bus/utils");
5
- const { readReportSummary } = require("../report/store");
5
+ const { readReportSummary, countControllerInboxEntries } = require("../report/store");
6
6
 
7
7
  function readBus(projectRoot) {
8
8
  const busPath = getUfooPaths(projectRoot).agentsFile;
@@ -72,6 +72,8 @@ function normalizeCronTasks(raw = []) {
72
72
  onceAt: String(task && task.onceAt ? task.onceAt : ""),
73
73
  targets: Array.isArray(task && task.targets) ? task.targets.slice() : [],
74
74
  prompt: String(task && task.prompt ? task.prompt : ""),
75
+ title: String(task && task.title ? task.title : ""),
76
+ label: String(task && task.label ? task.label : ""),
75
77
  summary: String(task && task.summary ? task.summary : ""),
76
78
  createdAt: Number(task && task.createdAt ? task.createdAt : 0) || 0,
77
79
  lastRunAt: Number(task && task.lastRunAt ? task.lastRunAt : 0) || 0,
@@ -130,6 +132,7 @@ function buildStatus(projectRoot, options = {}) {
130
132
  const decisions = readDecisions(projectRoot);
131
133
  const unread = readUnread(projectRoot);
132
134
  const reports = readReportSummary(projectRoot);
135
+ const controllerPendingTotal = countControllerInboxEntries(projectRoot, "ufoo-agent");
133
136
  const groups = readGroups(projectRoot);
134
137
  const subscribers = bus ? Object.keys(bus.agents || {}) : [];
135
138
  const cronTasks = normalizeCronTasks(options.cronTasks || []);
@@ -174,6 +177,9 @@ function buildStatus(projectRoot, options = {}) {
174
177
  unread,
175
178
  decisions,
176
179
  reports,
180
+ controller: {
181
+ pending_total: controllerPendingTotal,
182
+ },
177
183
  cron: {
178
184
  count: cronTasks.length,
179
185
  tasks: cronTasks,