u-foo 1.8.9 → 1.9.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.
package/bin/uclaude.js CHANGED
@@ -6,8 +6,18 @@
6
6
  */
7
7
 
8
8
  const AgentLauncher = require("../src/agent/launcher");
9
+ const { resolveDefaultManualBootstrap } = require("../src/agent/defaultBootstrap");
9
10
 
10
11
  const launcher = new AgentLauncher("claude-code", "claude");
11
- const args = process.argv.slice(2);
12
+ const resolved = resolveDefaultManualBootstrap({
13
+ projectRoot: process.cwd(),
14
+ agentType: "claude-code",
15
+ args: process.argv.slice(2),
16
+ env: process.env,
17
+ });
12
18
 
13
- launcher.launch(args);
19
+ for (const [key, value] of Object.entries(resolved.env || {})) {
20
+ process.env[key] = String(value);
21
+ }
22
+
23
+ launcher.launch(resolved.args);
package/bin/ucodex.js CHANGED
@@ -6,8 +6,18 @@
6
6
  */
7
7
 
8
8
  const AgentLauncher = require("../src/agent/launcher");
9
+ const { resolveDefaultManualBootstrap } = require("../src/agent/defaultBootstrap");
9
10
 
10
11
  const launcher = new AgentLauncher("codex", "codex");
11
- const args = process.argv.slice(2);
12
+ const resolved = resolveDefaultManualBootstrap({
13
+ projectRoot: process.cwd(),
14
+ agentType: "codex",
15
+ args: process.argv.slice(2),
16
+ env: process.env,
17
+ });
12
18
 
13
- launcher.launch(args);
19
+ for (const [key, value] of Object.entries(resolved.env || {})) {
20
+ process.env[key] = String(value);
21
+ }
22
+
23
+ launcher.launch(resolved.args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.8.9",
3
+ "version": "1.9.0",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { SHARED_UFOO_PROTOCOL } = require("../group/bootstrap");
6
+ const { getUfooPaths } = require("../ufoo/paths");
7
+
8
+ function asTrimmedString(value) {
9
+ return typeof value === "string" ? value.trim() : "";
10
+ }
11
+
12
+ function hasArg(args = [], names = []) {
13
+ if (!Array.isArray(args) || args.length === 0) return false;
14
+ const known = new Set((Array.isArray(names) ? names : []).map((item) => asTrimmedString(item)).filter(Boolean));
15
+ return args.some((item) => {
16
+ const text = asTrimmedString(item);
17
+ if (!text) return false;
18
+ if (known.has(text)) return true;
19
+ const eqIndex = text.indexOf("=");
20
+ if (eqIndex <= 0) return false;
21
+ return known.has(text.slice(0, eqIndex).trim());
22
+ });
23
+ }
24
+
25
+ function hasMetaCommandArgs(args = []) {
26
+ return hasArg(args, ["-h", "--help", "-v", "--version"]);
27
+ }
28
+
29
+ function buildDefaultStartupBootstrapPrompt({ agentType = "" } = {}) {
30
+ const normalizedAgent = asTrimmedString(agentType).toLowerCase();
31
+ const displayAgent = normalizedAgent === "claude-code"
32
+ ? "Claude"
33
+ : (normalizedAgent === "codex" ? "Codex" : "agent");
34
+ return [
35
+ `Session bootstrap for ${displayAgent}.`,
36
+ "Adopt the following ufoo coordination protocol silently.",
37
+ "Do not reply to this bootstrap message unless the user explicitly asks about it. After applying it, continue the active task or wait for user input.",
38
+ SHARED_UFOO_PROTOCOL,
39
+ ].join("\n\n");
40
+ }
41
+
42
+ function defaultBootstrapFile(projectRoot, agentType = "") {
43
+ const safeAgentType = asTrimmedString(agentType).replace(/[^a-zA-Z0-9._-]/g, "-") || "agent";
44
+ return path.join(getUfooPaths(projectRoot).agentDir, safeAgentType, "default-bootstrap.md");
45
+ }
46
+
47
+ function prepareDefaultBootstrapFile({
48
+ projectRoot,
49
+ agentType = "",
50
+ promptText = "",
51
+ targetFile = "",
52
+ } = {}) {
53
+ const root = asTrimmedString(projectRoot) || process.cwd();
54
+ const file = asTrimmedString(targetFile) || defaultBootstrapFile(root, agentType);
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, String(promptText || ""), "utf8");
57
+ return { ok: true, file };
58
+ }
59
+
60
+ function resolveDefaultManualBootstrap({
61
+ projectRoot,
62
+ agentType = "",
63
+ args = [],
64
+ env = process.env,
65
+ } = {}) {
66
+ const normalizedAgent = asTrimmedString(agentType).toLowerCase();
67
+ const currentEnv = env && typeof env === "object" ? env : {};
68
+ const currentArgs = Array.isArray(args) ? args.slice() : [];
69
+ if (
70
+ currentEnv.UFOO_SKIP_DEFAULT_BOOTSTRAP === "1"
71
+ || currentEnv.UFOO_STARTUP_BOOTSTRAP_TEXT
72
+ || hasMetaCommandArgs(currentArgs)
73
+ ) {
74
+ return { args: currentArgs, env: {}, mode: "skip" };
75
+ }
76
+
77
+ if (normalizedAgent === "claude-code") {
78
+ if (hasArg(currentArgs, ["--append-system-prompt", "--system-prompt"])) {
79
+ return { args: currentArgs, env: {}, mode: "skip" };
80
+ }
81
+ const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent });
82
+ const prepared = prepareDefaultBootstrapFile({
83
+ projectRoot,
84
+ agentType: normalizedAgent,
85
+ promptText,
86
+ });
87
+ return {
88
+ args: [...currentArgs, "--append-system-prompt", prepared.file],
89
+ env: {},
90
+ mode: "system-prompt-file",
91
+ file: prepared.file,
92
+ promptText,
93
+ };
94
+ }
95
+
96
+ if (normalizedAgent === "codex") {
97
+ if (currentArgs.length > 0) {
98
+ return { args: currentArgs, env: {}, mode: "skip" };
99
+ }
100
+ const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent });
101
+ return {
102
+ args: currentArgs,
103
+ env: {
104
+ UFOO_STARTUP_BOOTSTRAP_TEXT: promptText,
105
+ },
106
+ mode: "post-launch-inject",
107
+ promptText,
108
+ };
109
+ }
110
+
111
+ return { args: currentArgs, env: {}, mode: "skip" };
112
+ }
113
+
114
+ module.exports = {
115
+ hasArg,
116
+ hasMetaCommandArgs,
117
+ buildDefaultStartupBootstrapPrompt,
118
+ defaultBootstrapFile,
119
+ prepareDefaultBootstrapFile,
120
+ resolveDefaultManualBootstrap,
121
+ };
@@ -224,6 +224,42 @@ function computeInjectedSubmitDelayMs(agentType, text) {
224
224
  return delayMs;
225
225
  }
226
226
 
227
+ function sleep(ms) {
228
+ return new Promise((resolve) => setTimeout(resolve, ms));
229
+ }
230
+
231
+ function logInjectedCommand(wrapper, source, commandText) {
232
+ if (!wrapper || !wrapper.logger) return;
233
+ const text = String(commandText || "");
234
+ if (!text) return;
235
+ const logEntry = {
236
+ ts: Date.now(),
237
+ dir: "in",
238
+ data: { text, encoding: "utf8", size: text.length },
239
+ source,
240
+ };
241
+ wrapper.logger.write(JSON.stringify(logEntry) + "\n");
242
+ }
243
+
244
+ async function injectPtyCommand(wrapper, agentType, commandText, source = "inject") {
245
+ const text = String(commandText || "");
246
+ if (!wrapper || !text) return false;
247
+ const normalizedAgentType = String(agentType || "").trim().toLowerCase();
248
+ const submitDelayMs = computeInjectedSubmitDelayMs(agentType, text);
249
+ wrapper.write(text);
250
+ if (normalizedAgentType === "claude-code") {
251
+ await sleep(submitDelayMs);
252
+ wrapper.write("\r");
253
+ } else {
254
+ await sleep(submitDelayMs);
255
+ wrapper.write("\x1b");
256
+ await sleep(100);
257
+ wrapper.write("\r");
258
+ }
259
+ logInjectedCommand(wrapper, source, text);
260
+ return true;
261
+ }
262
+
227
263
  async function resolveHostRegistrationData(launchMode) {
228
264
  if (launchMode !== "host") {
229
265
  return {
@@ -451,6 +487,7 @@ class AgentLauncher {
451
487
  return new Promise((resolve, reject) => {
452
488
  let buffer = "";
453
489
  let settled = false;
490
+ const registerTimeoutMs = parseInt(process.env.UFOO_REGISTER_TIMEOUT_MS, 10) || 8000;
454
491
  const timeout = setTimeout(() => {
455
492
  if (settled) return;
456
493
  settled = true;
@@ -460,7 +497,7 @@ class AgentLauncher {
460
497
  // ignore
461
498
  }
462
499
  reject(new Error("register_agent timeout"));
463
- }, 8000);
500
+ }, registerTimeoutMs);
464
501
 
465
502
  const cleanup = () => {
466
503
  clearTimeout(timeout);
@@ -607,6 +644,7 @@ class AgentLauncher {
607
644
  }
608
645
 
609
646
  // 7. 启动命令(PTY wrapper或直接spawn)
647
+ const startupBootstrapText = String(process.env.UFOO_STARTUP_BOOTSTRAP_TEXT || "").trim();
610
648
 
611
649
  // 7.1 PTY启用条件(显式开关 + 自动检测)
612
650
  let shouldUsePty = false;
@@ -704,6 +742,10 @@ class AgentLauncher {
704
742
  }
705
743
  }
706
744
 
745
+ if (startupBootstrapText && wrapper.pty) {
746
+ await injectPtyCommand(wrapper, this.agentType, startupBootstrapText, "startup-bootstrap");
747
+ }
748
+
707
749
  await notifyDaemonAgentReady(daemonSockPath, subscriberId, wrapper.pty ? wrapper.pty.pid : 0);
708
750
  });
709
751
 
@@ -812,37 +854,8 @@ class AgentLauncher {
812
854
  continue;
813
855
  }
814
856
  // 注入命令到PTY(带延迟确保输入完成)
815
- // Claude Code (Ink TUI) interprets ESC+CR within ~100ms as
816
- // Alt+Enter (newline) instead of two separate keys. Use a
817
- // longer gap so the escape sequence parser times out.
818
- const commandText = String(req.command);
819
- const submitDelayMs = computeInjectedSubmitDelayMs(this.agentType, commandText);
820
- wrapper.write(commandText);
821
- if (normalizedAgentType === "claude-code") {
822
- // Claude Code: send CR directly without ESC.
823
- // ESC before CR is interpreted as Alt+Enter (newline).
824
- setTimeout(() => {
825
- wrapper.write("\r");
826
- }, submitDelayMs);
827
- } else {
828
- // Codex/others: ESC dismisses autocomplete, then CR submits.
829
- setTimeout(() => {
830
- wrapper.write("\x1b");
831
- setTimeout(() => {
832
- wrapper.write("\r");
833
- }, 100);
834
- }, submitDelayMs);
835
- }
857
+ void injectPtyCommand(wrapper, this.agentType, req.command, "inject");
836
858
  client.write(JSON.stringify({ ok: true }) + "\n");
837
- if (wrapper.logger) {
838
- const logEntry = {
839
- ts: Date.now(),
840
- dir: "in",
841
- data: { text: req.command, encoding: "utf8", size: req.command.length },
842
- source: "inject",
843
- };
844
- wrapper.logger.write(JSON.stringify(logEntry) + "\n");
845
- }
846
859
  } else if (req.type === PTY_SOCKET_MESSAGE_TYPES.RAW && req.data) {
847
860
  // Raw PTY write (no Enter appended) - for TTY view passthrough
848
861
  wrapper.write(req.data);
@@ -952,5 +965,6 @@ class AgentLauncher {
952
965
  AgentLauncher._sanitizeNickname = (nick) => nick.replace(/[^a-zA-Z0-9_-]/g, "");
953
966
  AgentLauncher._findPreviousSession = findPreviousSession;
954
967
  AgentLauncher._notifyDaemonAgentReady = notifyDaemonAgentReady;
968
+ AgentLauncher._injectPtyCommand = injectPtyCommand;
955
969
 
956
970
  module.exports = AgentLauncher;
@@ -8,6 +8,7 @@ const { resolveTransport } = require("../code/nativeRunner");
8
8
  const { parseIntervalMs, formatIntervalMs } = require("./cronScheduler");
9
9
  const { isGlobalControllerProjectRoot, resolveGlobalControllerUfooDir } = require("../globalMode");
10
10
  const { loadPromptProfileRegistry } = require("../group/promptProfiles");
11
+ const { resolveSoloAgentType } = require("../solo/commands");
11
12
 
12
13
  function defaultCreateDoctor(projectRoot) {
13
14
  const UfooDoctor = require("../doctor");
@@ -528,10 +529,15 @@ function createCommandExecutor(options = {}) {
528
529
  return;
529
530
  }
530
531
 
531
- const target = String(args[0] || "").trim();
532
- const profile = String(args[1] || "").trim();
532
+ const target = action === "assign"
533
+ ? String(args[1] || "").trim()
534
+ : String(args[0] || "").trim();
535
+ const profile = action === "assign"
536
+ ? String(args[2] || "").trim()
537
+ : String(args[1] || "").trim();
533
538
  if (!target || !profile) {
534
- logMessage("error", "{white-fg}✗{/white-fg} Usage: /role <agent-id|nickname> <prompt-profile>");
539
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /role assign <agent-id|nickname> <prompt-profile>");
540
+ logMessage("error", " /role <agent-id|nickname> <prompt-profile>");
535
541
  logMessage("error", " /role list");
536
542
  return;
537
543
  }
@@ -548,6 +554,95 @@ function createCommandExecutor(options = {}) {
548
554
  }
549
555
  }
550
556
 
557
+ async function handleSoloCommand(args = []) {
558
+ const subcommand = String(args[0] || "").trim().toLowerCase();
559
+ if (!subcommand) {
560
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /solo <run|list> ...");
561
+ return;
562
+ }
563
+
564
+ if (subcommand === "list" || subcommand === "ls") {
565
+ try {
566
+ const registry = loadPromptProfileRegistry(projectRoot);
567
+ const profiles = registry.profiles || [];
568
+ if (profiles.length === 0) {
569
+ logMessage("system", "{white-fg}⚙{/white-fg} No solo roles found.");
570
+ return;
571
+ }
572
+ logMessage("system", `{white-fg}⚙{/white-fg} Available solo roles (${profiles.length}):`);
573
+ for (const p of profiles) {
574
+ const aliases = p.aliases && p.aliases.length > 0 ? ` {gray-fg}(${p.aliases.join(", ")}){/gray-fg}` : "";
575
+ const source = p.source ? ` {cyan-fg}[${p.source}]{/cyan-fg}` : "";
576
+ const summary = p.summary ? ` ${p.summary}` : "";
577
+ logMessage("system", ` {bold}${escapeBlessed(p.id)}{/bold}${aliases}${source}`);
578
+ if (summary) {
579
+ logMessage("system", ` ${escapeBlessed(summary)}`);
580
+ }
581
+ }
582
+ } catch (err) {
583
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to list solo roles: ${escapeBlessed(err.message)}`);
584
+ }
585
+ return;
586
+ }
587
+
588
+ if (subcommand !== "run") {
589
+ logMessage("error", `{white-fg}✗{/white-fg} Unknown solo action: ${escapeBlessed(subcommand)}`);
590
+ return;
591
+ }
592
+
593
+ const profile = String(args[1] || "").trim();
594
+ if (!profile) {
595
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /solo run <prompt-profile> [agent=codex|claude|ucode] [nickname=<name>] [scope=inplace|window]");
596
+ return;
597
+ }
598
+
599
+ const parsedOptions = {};
600
+ for (let i = 2; i < args.length; i += 1) {
601
+ const arg = args[i];
602
+ if (arg.includes("=")) {
603
+ const [key, value] = arg.split("=", 2);
604
+ parsedOptions[key] = value;
605
+ }
606
+ }
607
+
608
+ function normalizeLaunchScopeOption(value, fallback = "inplace") {
609
+ const raw = String(value || "").trim().toLowerCase();
610
+ if (!raw) return fallback;
611
+ if (raw === "inplace" || raw === "same" || raw === "current" || raw === "tab" || raw === "pane") {
612
+ return "inplace";
613
+ }
614
+ if (raw === "window" || raw === "separate" || raw === "new" || raw === "new-window" || raw === "external") {
615
+ return "window";
616
+ }
617
+ return "";
618
+ }
619
+
620
+ const config = loadConfig(projectRoot);
621
+ const agent = resolveSoloAgentType(config, parsedOptions.agent || parsedOptions.type || "");
622
+ const nickname = String(parsedOptions.nickname || "").trim();
623
+ const scopeRaw = parsedOptions.scope || parsedOptions.launch_scope || "";
624
+ const launchScope = normalizeLaunchScopeOption(scopeRaw, "inplace");
625
+ if (scopeRaw && !launchScope) {
626
+ logMessage("error", "{white-fg}✗{/white-fg} scope must be inplace|window");
627
+ return;
628
+ }
629
+
630
+ try {
631
+ send({
632
+ type: IPC_REQUEST_TYPES.LAUNCH_AGENT,
633
+ agent: agent === "ucode" ? "ufoo" : agent,
634
+ count: 1,
635
+ nickname,
636
+ prompt_profile: profile,
637
+ launch_scope: launchScope,
638
+ ...collectHostLaunchRequestContext(),
639
+ });
640
+ schedule(requestStatus, 1000);
641
+ } catch (err) {
642
+ logMessage("error", `{white-fg}✗{/white-fg} Solo launch failed: ${escapeBlessed(err.message)}`);
643
+ }
644
+ }
645
+
551
646
  async function handleResumeCommand(args = []) {
552
647
  const action = String(args[0] || "").toLowerCase();
553
648
  if (action === "list" || action === "ls") {
@@ -1218,6 +1313,9 @@ function createCommandExecutor(options = {}) {
1218
1313
  case "role":
1219
1314
  await handleRoleCommand(args);
1220
1315
  return true;
1316
+ case "solo":
1317
+ await handleSoloCommand(args);
1318
+ return true;
1221
1319
  case "cron":
1222
1320
  await handleCronCommand(args);
1223
1321
  return true;
@@ -1250,6 +1348,7 @@ function createCommandExecutor(options = {}) {
1250
1348
  handleResumeCommand,
1251
1349
  handleProjectCommand,
1252
1350
  handleRoleCommand,
1351
+ handleSoloCommand,
1253
1352
  handleCronCommand,
1254
1353
  handleGroupCommand,
1255
1354
  handleSettingsCommand,
@@ -67,7 +67,15 @@ const COMMAND_TREE = {
67
67
  "/role": {
68
68
  desc: "Assign preset role to an existing agent",
69
69
  children: {
70
- list: { desc: "List available prompt profiles" },
70
+ assign: { desc: "Assign a role to an existing agent", order: 1 },
71
+ list: { desc: "List available prompt profiles", order: 2 },
72
+ },
73
+ },
74
+ "/solo": {
75
+ desc: "Solo role agent operations",
76
+ children: {
77
+ run: { desc: "Launch a solo role agent", order: 1 },
78
+ list: { desc: "List available solo roles", order: 2 },
71
79
  },
72
80
  },
73
81
  "/resume": {
@@ -111,10 +119,20 @@ function buildCommandRegistry(tree) {
111
119
  const entry = { cmd, desc: node.desc || "" };
112
120
  if (node.children) {
113
121
  entry.subcommands = Object.keys(node.children)
114
- .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
122
+ .sort((a, b) => {
123
+ const aNode = node.children[a] || {};
124
+ const bNode = node.children[b] || {};
125
+ const aOrder = Number.isFinite(aNode.order) ? aNode.order : Number.POSITIVE_INFINITY;
126
+ const bOrder = Number.isFinite(bNode.order) ? bNode.order : Number.POSITIVE_INFINITY;
127
+ if (aOrder !== bOrder) return aOrder - bOrder;
128
+ return a.localeCompare(b, "en", { sensitivity: "base" });
129
+ })
115
130
  .map((sub) => ({
116
131
  cmd: sub,
117
132
  desc: (node.children[sub] && node.children[sub].desc) || "",
133
+ order: Number.isFinite(node.children[sub] && node.children[sub].order)
134
+ ? node.children[sub].order
135
+ : undefined,
118
136
  }));
119
137
  }
120
138
  return entry;
@@ -4,6 +4,13 @@ const FALLBACK_LAUNCH_SUBCOMMANDS = [
4
4
  { cmd: "ucode", desc: "Launch ucode core agent" },
5
5
  ];
6
6
 
7
+ function sortSubcommandEntries(a, b) {
8
+ const aOrder = Number.isFinite(a && a.order) ? a.order : Number.POSITIVE_INFINITY;
9
+ const bOrder = Number.isFinite(b && b.order) ? b.order : Number.POSITIVE_INFINITY;
10
+ if (aOrder !== bOrder) return aOrder - bOrder;
11
+ return String(a && a.cmd ? a.cmd : "").localeCompare(String(b && b.cmd ? b.cmd : ""), "en", { sensitivity: "base" });
12
+ }
13
+
7
14
  function createCompletionController(options = {}) {
8
15
  const {
9
16
  input,
@@ -12,6 +19,7 @@ function createCompletionController(options = {}) {
12
19
  promptBox,
13
20
  commandRegistry = [],
14
21
  getGroupTemplateCandidates = () => [],
22
+ getSoloProfileCandidates = () => [],
15
23
  getMentionCandidates = () => [],
16
24
  normalizeCommandPrefix = () => {},
17
25
  truncateText = (text) => String(text || ""),
@@ -143,6 +151,7 @@ function createCompletionController(options = {}) {
143
151
  const mainCmd = parts[0];
144
152
  const isLaunch = mainCmd && mainCmd.toLowerCase() === "/launch";
145
153
  const isGroup = mainCmd && mainCmd.toLowerCase() === "/group";
154
+ const isSolo = mainCmd && mainCmd.toLowerCase() === "/solo";
146
155
  const wantsSubcommands = (parts.length > 1 || (endsWithSpace && parts.length === 1));
147
156
 
148
157
  if (isGroup) {
@@ -169,6 +178,28 @@ function createCompletionController(options = {}) {
169
178
  }
170
179
  }
171
180
 
181
+ if (isSolo) {
182
+ const soloSubcommand = String(parts[1] || "").trim().toLowerCase();
183
+ const wantsSoloRunArgs = soloSubcommand === "run" && (parts.length > 2 || endsWithSpace);
184
+ if (wantsSoloRunArgs) {
185
+ const profileFilter = String(parts[2] || "").trim().toLowerCase();
186
+ return (Array.isArray(getSoloProfileCandidates()) ? getSoloProfileCandidates() : [])
187
+ .map((item) => {
188
+ const profileId = String(item && item.cmd ? item.cmd : item && item.id ? item.id : "").trim();
189
+ if (!profileId) return null;
190
+ const desc = String(item && item.desc ? item.desc : item && item.summary ? item.summary : "").trim();
191
+ return {
192
+ cmd: profileId,
193
+ desc,
194
+ isArgumentSuggestion: true,
195
+ argumentPrefix: "/solo run",
196
+ };
197
+ })
198
+ .filter((item) => item && (!profileFilter || item.cmd.toLowerCase().startsWith(profileFilter)))
199
+ .sort((a, b) => a.cmd.localeCompare(b.cmd, "en", { sensitivity: "base" }));
200
+ }
201
+ }
202
+
172
203
  if ((wantsSubcommands || isLaunch) && mainCmd && mainCmd.startsWith("/")) {
173
204
  const subFilter = parts[1] || "";
174
205
  const mainCmdObj = commandRegistry.find((item) =>
@@ -188,12 +219,12 @@ function createCompletionController(options = {}) {
188
219
  if (isLaunch) {
189
220
  return subs
190
221
  .map((sub) => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
191
- .sort((a, b) => a.cmd.localeCompare(b.cmd));
222
+ .sort(sortSubcommandEntries);
192
223
  }
193
224
  return subs
194
225
  .filter((sub) => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
195
226
  .map((sub) => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
196
- .sort((a, b) => a.cmd.localeCompare(b.cmd));
227
+ .sort(sortSubcommandEntries);
197
228
  }
198
229
  return [];
199
230
  }
@@ -340,7 +371,11 @@ function createCompletionController(options = {}) {
340
371
 
341
372
  if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
342
373
  show(input.value);
343
- } else if (selected.isSubcommand && selected.parentCmd === "/group" && selected.cmd === "run") {
374
+ } else if (
375
+ selected.isSubcommand
376
+ && ((selected.parentCmd === "/group" && selected.cmd === "run")
377
+ || (selected.parentCmd === "/solo" && selected.cmd === "run"))
378
+ ) {
344
379
  show(input.value);
345
380
  } else {
346
381
  hide();
@@ -384,7 +419,11 @@ function createCompletionController(options = {}) {
384
419
  applyPreview(nextPreview);
385
420
  if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
386
421
  show(input.value);
387
- } else if (selected.isSubcommand && selected.parentCmd === "/group" && selected.cmd === "run") {
422
+ } else if (
423
+ selected.isSubcommand
424
+ && ((selected.parentCmd === "/group" && selected.cmd === "run")
425
+ || (selected.parentCmd === "/solo" && selected.cmd === "run"))
426
+ ) {
388
427
  show(input.value);
389
428
  } else {
390
429
  hide();
@@ -1,3 +1,5 @@
1
+ const { restartLocks } = require("./daemonTransport");
2
+
1
3
  function resolveDaemonConnection(daemonConnection) {
2
4
  return typeof daemonConnection === "function" ? daemonConnection() : daemonConnection;
3
5
  }
@@ -14,11 +16,10 @@ function restartDaemonFlow(options = {}) {
14
16
 
15
17
  const statusMsg = resolveStatusLine || ((text) => logMessage("status", text));
16
18
 
17
- let restartInProgress = false;
18
-
19
19
  return async function restartDaemon() {
20
- if (restartInProgress) return;
21
- restartInProgress = true;
20
+ // Use global restart lock to prevent concurrent restart flows
21
+ if (restartLocks.get(projectRoot)) return;
22
+ restartLocks.set(projectRoot, true);
22
23
  statusMsg("{gray-fg}⚙{/gray-fg} Restarting daemon...");
23
24
  try {
24
25
  const connection = resolveDaemonConnection(daemonConnection);
@@ -34,7 +35,7 @@ function restartDaemonFlow(options = {}) {
34
35
  statusMsg("{gray-fg}✗{/gray-fg} Failed to reconnect to daemon");
35
36
  }
36
37
  } finally {
37
- restartInProgress = false;
38
+ restartLocks.delete(projectRoot);
38
39
  }
39
40
  };
40
41
  }
@@ -1,5 +1,8 @@
1
1
  const { DAEMON_TRANSPORT_DEFAULTS } = require("./daemonTransportDefaults");
2
2
 
3
+ // Global restart lock per project to prevent concurrent restart flows
4
+ const restartLocks = new Map();
5
+
3
6
  function createDaemonTransport(options = {}) {
4
7
  const {
5
8
  projectRoot,
@@ -34,7 +37,9 @@ function createDaemonTransport(options = {}) {
34
37
  );
35
38
  if (!client) {
36
39
  // Retry once with a fresh daemon start and longer wait.
37
- if (!isRunning(target.projectRoot)) {
40
+ // Check if a restart is already in progress via the explicit restart flow.
41
+ const isExplicitRestartInProgress = restartLocks.get(target.projectRoot);
42
+ if (!isExplicitRestartInProgress && !isRunning(target.projectRoot)) {
38
43
  startDaemon(target.projectRoot);
39
44
  await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
40
45
  }
@@ -75,4 +80,5 @@ function createDaemonTransport(options = {}) {
75
80
 
76
81
  module.exports = {
77
82
  createDaemonTransport,
83
+ restartLocks,
78
84
  };
package/src/chat/index.js CHANGED
@@ -18,6 +18,7 @@ const { getUfooPaths } = require("../ufoo/paths");
18
18
  const { startDaemon, stopDaemon, connectWithRetry } = require("./transport");
19
19
  const { escapeBlessed, stripBlessedTags, truncateText } = require("./text");
20
20
  const { COMMAND_REGISTRY, parseCommand, parseAtTarget } = require("./commands");
21
+ const { buildPromptProfileCandidates } = require("../solo/commands");
21
22
  const inputMath = require("./inputMath");
22
23
  const { createStreamTracker } = require("./streamTracker");
23
24
  const agentDirectory = require("./agentDirectory");
@@ -559,6 +560,10 @@ async function runChat(projectRoot, options = {}) {
559
560
  source: item.source || "",
560
561
  }));
561
562
  },
563
+ getSoloProfileCandidates: () => {
564
+ const registry = loadPromptProfileRegistry(activeProjectRoot);
565
+ return buildPromptProfileCandidates(registry);
566
+ },
562
567
  getMentionCandidates: () => activeAgents.map((id) => ({
563
568
  id,
564
569
  label: getAgentLabel(id),
package/src/cli.js CHANGED
@@ -7,6 +7,9 @@ const { runBusCoreCommand } = require("./cli/busCoreCommands");
7
7
  const { runCtxCommand } = require("./cli/ctxCoreCommands");
8
8
  const { runOnlineCommand } = require("./cli/onlineCoreCommands");
9
9
  const { runGroupCoreCommand } = require("./cli/groupCoreCommands");
10
+ const { loadConfig } = require("./config");
11
+ const { loadPromptProfileRegistry } = require("./group/promptProfiles");
12
+ const { resolveSoloAgentType } = require("./solo/commands");
10
13
  const { listProjectRuntimes, getCurrentProjectRuntime } = require("./projects/registry");
11
14
  const { canonicalProjectRoot, buildProjectId } = require("./projects/projectId");
12
15
  const { getUfooPaths } = require("./ufoo/paths");
@@ -448,6 +451,68 @@ async function runCli(argv) {
448
451
  process.exitCode = 1;
449
452
  }
450
453
  });
454
+ program
455
+ .command("solo")
456
+ .description("Solo role agent operations")
457
+ .argument("<action>", "run|list")
458
+ .argument("[profile]", "Prompt profile id or alias")
459
+ .option("--agent <type>", "Agent type: codex|claude|ucode")
460
+ .option("--nickname <name>", "Optional nickname")
461
+ .option("--scope <scope>", "Launch scope: inplace|window", "inplace")
462
+ .option("--json", "Output role list as JSON")
463
+ .action(async (action, profile, opts) => {
464
+ try {
465
+ const projectRoot = process.cwd();
466
+ const subcommand = String(action || "").trim().toLowerCase();
467
+ if (subcommand === "list" || subcommand === "ls") {
468
+ const registry = loadPromptProfileRegistry(projectRoot);
469
+ if (opts.json) {
470
+ console.log(JSON.stringify({ profiles: registry.profiles || [] }, null, 2));
471
+ return;
472
+ }
473
+ const profiles = registry.profiles || [];
474
+ if (profiles.length === 0) {
475
+ console.log("No solo roles found.");
476
+ return;
477
+ }
478
+ console.log(`Available solo roles (${profiles.length}):`);
479
+ for (const p of profiles) {
480
+ const aliases = p.aliases && p.aliases.length > 0 ? ` (${p.aliases.join(", ")})` : "";
481
+ const source = p.source ? ` [${p.source}]` : "";
482
+ console.log(` ${p.id}${aliases}${source}`);
483
+ if (p.summary) console.log(` ${p.summary}`);
484
+ }
485
+ return;
486
+ }
487
+ if (subcommand !== "run") {
488
+ throw new Error(`Unknown solo action: ${subcommand}`);
489
+ }
490
+ const promptProfile = String(profile || "").trim();
491
+ if (!promptProfile) {
492
+ throw new Error("solo run requires <profile>");
493
+ }
494
+ await ensureDaemonRunning(projectRoot);
495
+ const config = loadConfig(projectRoot);
496
+ const agent = resolveSoloAgentType(config, opts.agent || "");
497
+ const scope = String(opts.scope || "inplace").trim().toLowerCase();
498
+ if (scope !== "inplace" && scope !== "window") {
499
+ throw new Error("scope must be inplace|window");
500
+ }
501
+ const resp = await sendDaemonRequest(projectRoot, {
502
+ type: "launch_agent",
503
+ agent: agent === "ucode" ? "ufoo" : agent,
504
+ nickname: String(opts.nickname || "").trim(),
505
+ prompt_profile: promptProfile,
506
+ count: 1,
507
+ launch_scope: scope,
508
+ ...collectHostLaunchRequestContext(),
509
+ });
510
+ console.log(resp?.data?.reply || `Launched ${agent} role ${promptProfile}`);
511
+ } catch (err) {
512
+ console.error(err.message || String(err));
513
+ process.exitCode = 1;
514
+ }
515
+ });
451
516
  program
452
517
  .command("role")
453
518
  .description("Assign a preset role to an existing agent")
@@ -1312,6 +1377,8 @@ async function runCli(argv) {
1312
1377
  console.log(" ufoo group status [groupId] [--json]");
1313
1378
  console.log(" ufoo group diagram <alias|groupId> [--ascii|--mermaid] [--json]");
1314
1379
  console.log(" ufoo group stop <groupId> [--json]");
1380
+ console.log(" ufoo solo list [--json]");
1381
+ console.log(" ufoo solo run <profile> [--agent <codex|claude|ucode>] [--nickname <name>] [--scope <inplace|window>]");
1315
1382
  console.log(" ufoo online server [--port 8787] [--host 127.0.0.1] [--token-file <path>]");
1316
1383
  console.log(" ufoo online token <subscriber> [--nickname <name>] [--server <url>] [--file <path>]");
1317
1384
  console.log(" ufoo online room create [--name <room>] --type public|private [--password <pwd>] [--created-by <name>] [--server <url>]");
@@ -1795,6 +1862,67 @@ async function runCli(argv) {
1795
1862
  })();
1796
1863
  return;
1797
1864
  }
1865
+ if (cmd === "solo") {
1866
+ const sub = String(rest[0] || "").trim().toLowerCase();
1867
+ (async () => {
1868
+ try {
1869
+ const projectRoot = process.cwd();
1870
+ if (sub === "list" || sub === "ls") {
1871
+ const outputJson = rest.includes("--json");
1872
+ const registry = loadPromptProfileRegistry(projectRoot);
1873
+ if (outputJson) {
1874
+ console.log(JSON.stringify({ profiles: registry.profiles || [] }, null, 2));
1875
+ return;
1876
+ }
1877
+ const profiles = registry.profiles || [];
1878
+ if (profiles.length === 0) {
1879
+ console.log("No solo roles found.");
1880
+ return;
1881
+ }
1882
+ console.log(`Available solo roles (${profiles.length}):`);
1883
+ for (const p of profiles) {
1884
+ const aliases = p.aliases && p.aliases.length > 0 ? ` (${p.aliases.join(", ")})` : "";
1885
+ const source = p.source ? ` [${p.source}]` : "";
1886
+ console.log(` ${p.id}${aliases}${source}`);
1887
+ if (p.summary) console.log(` ${p.summary}`);
1888
+ }
1889
+ return;
1890
+ }
1891
+ if (sub === "run") {
1892
+ const profile = String(rest[1] || "").trim();
1893
+ if (!profile) throw new Error("solo run requires <profile>");
1894
+ const agentIdx = rest.indexOf("--agent");
1895
+ const agentInput = agentIdx !== -1 ? String(rest[agentIdx + 1] || "").trim() : "";
1896
+ const nickIdx = rest.indexOf("--nickname");
1897
+ const nickname = nickIdx !== -1 ? String(rest[nickIdx + 1] || "").trim() : "";
1898
+ const scopeIdx = rest.indexOf("--scope");
1899
+ const scope = scopeIdx !== -1 ? String(rest[scopeIdx + 1] || "").trim().toLowerCase() : "inplace";
1900
+ if (scope !== "inplace" && scope !== "window") {
1901
+ throw new Error("scope must be inplace|window");
1902
+ }
1903
+ await ensureDaemonRunning(projectRoot);
1904
+ const config = loadConfig(projectRoot);
1905
+ const agent = resolveSoloAgentType(config, agentInput);
1906
+ const resp = await sendDaemonRequest(projectRoot, {
1907
+ type: "launch_agent",
1908
+ agent: agent === "ucode" ? "ufoo" : agent,
1909
+ nickname,
1910
+ prompt_profile: profile,
1911
+ count: 1,
1912
+ launch_scope: scope,
1913
+ ...collectHostLaunchRequestContext(),
1914
+ });
1915
+ console.log(resp?.data?.reply || `Launched ${agent} role ${profile}`);
1916
+ return;
1917
+ }
1918
+ throw new Error(`Unknown solo action: ${sub || "(empty)"}`);
1919
+ } catch (err) {
1920
+ console.error(err.message || String(err));
1921
+ process.exitCode = 1;
1922
+ }
1923
+ })();
1924
+ return;
1925
+ }
1798
1926
  if (cmd === "online") {
1799
1927
  const sub = rest[0] || "";
1800
1928
  if (!sub) {
@@ -753,6 +753,7 @@ function createGroupOrchestrator(options = {}) {
753
753
  }
754
754
 
755
755
  const plan = buildLaunchPlan(validated.entry.data);
756
+ const templateDefaults = (validated.entry.data && validated.entry.data.defaults) || {};
756
757
  const groupId = generateGroupId(validated.entry.alias || alias, instance);
757
758
  const projectNicknamePrefix = buildProjectNicknamePrefix(projectRoot);
758
759
 
@@ -835,6 +836,12 @@ function createGroupOrchestrator(options = {}) {
835
836
  const item = compiled.executionPlan[i];
836
837
  const member = runtime.members[i];
837
838
  const extraEnv = {};
839
+ if (item.bootstrap_required) {
840
+ extraEnv.UFOO_SKIP_DEFAULT_BOOTSTRAP = "1";
841
+ }
842
+ if (Number.isInteger(templateDefaults.start_timeout_ms) && templateDefaults.start_timeout_ms > 0) {
843
+ extraEnv.UFOO_REGISTER_TIMEOUT_MS = String(templateDefaults.start_timeout_ms);
844
+ }
838
845
  let extraArgs = [];
839
846
  let bootstrapInjected = false;
840
847
 
@@ -201,16 +201,16 @@ function looksLikeRunningDaemon(projectRoot, pid) {
201
201
  function isRunning(projectRoot) {
202
202
  const pid = readPid(projectRoot);
203
203
  if (!pid) return false;
204
- if (looksLikeRunningDaemon(projectRoot, pid)) {
205
- return true;
206
- }
204
+ return looksLikeRunningDaemon(projectRoot, pid);
205
+ }
206
+
207
+ function cleanupStaleState(projectRoot) {
207
208
  try {
208
209
  fs.unlinkSync(pidPath(projectRoot));
209
210
  } catch {
210
211
  // ignore
211
212
  }
212
213
  removeSocket(projectRoot);
213
- return false;
214
214
  }
215
215
 
216
216
  function removeSocket(projectRoot) {
@@ -1051,6 +1051,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1051
1051
  await init.init({ modules: "context,bus", project: root });
1052
1052
  }
1053
1053
  if (!isRunning(root)) {
1054
+ cleanupStaleState(root);
1054
1055
  const daemonBin = path.join(__dirname, "..", "..", "bin", "ufoo.js");
1055
1056
  const child = spawn(process.execPath, [daemonBin, "daemon", "--start"], {
1056
1057
  detached: true,
@@ -1384,6 +1385,12 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1384
1385
  }
1385
1386
  }
1386
1387
  }
1388
+ if (requestedProfile) {
1389
+ op.extra_env = {
1390
+ ...(op.extra_env && typeof op.extra_env === "object" ? op.extra_env : {}),
1391
+ UFOO_SKIP_DEFAULT_BOOTSTRAP: "1",
1392
+ };
1393
+ }
1387
1394
  try {
1388
1395
  const opsResults = await handleOps(projectRoot, [op], processManager);
1389
1396
  const launchResult = opsResults.find((r) => r.action === "launch");
@@ -2323,4 +2330,4 @@ function stopDaemon(projectRoot) {
2323
2330
  return killed;
2324
2331
  }
2325
2332
 
2326
- module.exports = { startDaemon, stopDaemon, isRunning, socketPath };
2333
+ module.exports = { startDaemon, stopDaemon, isRunning, cleanupStaleState, socketPath };
@@ -2,6 +2,15 @@
2
2
 
3
3
  const crypto = require("crypto");
4
4
 
5
+ const SHARED_UFOO_PROTOCOL = [
6
+ "ufoo protocol:",
7
+ "- At session start, sync shared context with `ufoo ctx decisions -l` and `ufoo ctx decisions -n 1`.",
8
+ "- If your work produces knowledge with information value, record it before acting via `ufoo ctx decisions new \"Title\"`.",
9
+ "- Use `ufoo bus send <target-nickname> \"<message>\"` for agent-to-agent handoffs.",
10
+ "- If you receive pending bus work, execute it immediately, reply to the sender, then `ufoo bus ack \"$UFOO_SUBSCRIBER_ID\"`.",
11
+ "- Use `ufoo report` for controller/runtime status updates, not as a substitute for direct handoffs.",
12
+ ].join("\n");
13
+
5
14
  const SHARED_GROUP_PREFIX = [
6
15
  "You are part of a ufoo multi-agent group.",
7
16
  "",
@@ -13,6 +22,8 @@ const SHARED_GROUP_PREFIX = [
13
22
  "- When reporting, separate facts, inferences, and recommendations.",
14
23
  "- Preserve continuity with the group's current task rather than restarting analysis from scratch.",
15
24
  "",
25
+ SHARED_UFOO_PROTOCOL,
26
+ "",
16
27
  "Coordination protocol:",
17
28
  "- Use direct handoff for worker-to-worker delivery.",
18
29
  "- Use private `ufoo report` updates for ufoo-agent control-plane reporting.",
@@ -28,6 +39,8 @@ const SOLO_AGENT_PREFIX = [
28
39
  "- Surface uncertainty explicitly.",
29
40
  "- Preserve continuity with the current task instead of restarting from scratch.",
30
41
  "- Use ufoo-agent for control-plane coordination, not as a substitute for doing your role.",
42
+ "",
43
+ SHARED_UFOO_PROTOCOL,
31
44
  ].join("\n");
32
45
 
33
46
  function asTrimmedString(value) {
@@ -145,6 +158,7 @@ function computeBootstrapFingerprint({
145
158
  }
146
159
 
147
160
  module.exports = {
161
+ SHARED_UFOO_PROTOCOL,
148
162
  SHARED_GROUP_PREFIX,
149
163
  SOLO_AGENT_PREFIX,
150
164
  buildGroupPromptMetadata,
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ function asTrimmedString(value) {
4
+ return typeof value === "string" ? value.trim() : "";
5
+ }
6
+
7
+ function resolveSoloAgentType(config = {}, requestedAgent = "") {
8
+ const requested = asTrimmedString(requestedAgent).toLowerCase();
9
+ if (requested === "claude" || requested === "uclaude" || requested === "claude-code") return "claude";
10
+ if (requested === "codex" || requested === "ucodex" || requested === "openai") return "codex";
11
+ if (requested === "ucode" || requested === "ufoo" || requested === "ufoo-code") return "ucode";
12
+
13
+ const provider = asTrimmedString(config && config.agentProvider).toLowerCase();
14
+ if (provider === "claude-cli") return "claude";
15
+ if (provider === "ucode") return "ucode";
16
+ return "codex";
17
+ }
18
+
19
+ function buildPromptProfileCandidates(registry = null) {
20
+ const profiles = Array.isArray(registry && registry.profiles) ? registry.profiles : [];
21
+ return profiles.map((item) => ({
22
+ cmd: item.id,
23
+ desc: [item.summary || "", item.source || ""].filter(Boolean).join(" · "),
24
+ source: item.source || "",
25
+ }));
26
+ }
27
+
28
+ module.exports = {
29
+ resolveSoloAgentType,
30
+ buildPromptProfileCandidates,
31
+ };
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "defaults": {
9
9
  "launch_mode": "auto",
10
- "start_timeout_ms": 15000
10
+ "start_timeout_ms": 30000
11
11
  },
12
12
  "agents": [
13
13
  {
@@ -16,7 +16,13 @@
16
16
  "type": "auto",
17
17
  "role": "coordinate builders, track progress, enforce delivery cadence",
18
18
  "prompt_profile": "pmo-coordinator",
19
- "accept_from": [],
19
+ "accept_from": [
20
+ "builder-1",
21
+ "builder-2",
22
+ "builder-3",
23
+ "builder-4",
24
+ "reviewer"
25
+ ],
20
26
  "report_to": [
21
27
  "builder-1",
22
28
  "builder-2",