u-foo 1.7.5 → 1.8.1

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 (44) 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 +184 -5
  8. package/src/assistant/constants.js +1 -1
  9. package/src/chat/commandExecutor.js +98 -3
  10. package/src/chat/commands.js +7 -0
  11. package/src/chat/completionController.js +40 -0
  12. package/src/chat/daemonMessageRouter.js +21 -1
  13. package/src/chat/dashboardKeyController.js +55 -3
  14. package/src/chat/dashboardView.js +26 -5
  15. package/src/chat/index.js +148 -41
  16. package/src/chat/inputListenerController.js +14 -0
  17. package/src/chat/inputMath.js +1 -1
  18. package/src/chat/inputSubmitHandler.js +9 -5
  19. package/src/chat/transientAgentState.js +64 -0
  20. package/src/cli/groupCoreCommands.js +21 -12
  21. package/src/cli.js +23 -1
  22. package/src/code/tui.js +1 -1
  23. package/src/daemon/cronOps.js +11 -4
  24. package/src/daemon/groupOrchestrator.js +581 -97
  25. package/src/daemon/index.js +418 -3
  26. package/src/daemon/ops.js +25 -7
  27. package/src/daemon/promptLoop.js +16 -0
  28. package/src/daemon/promptRequest.js +126 -2
  29. package/src/daemon/reporting.js +18 -0
  30. package/src/daemon/soloBootstrap.js +435 -0
  31. package/src/daemon/status.js +5 -1
  32. package/src/globalMode.js +33 -0
  33. package/src/group/bootstrap.js +157 -0
  34. package/src/group/promptProfiles.js +646 -0
  35. package/src/group/templateValidation.js +99 -0
  36. package/src/group/validateTemplate.js +36 -5
  37. package/src/init/index.js +13 -7
  38. package/src/report/store.js +6 -0
  39. package/src/shared/eventContract.js +1 -0
  40. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  41. package/templates/groups/product-discovery.json +79 -0
  42. package/templates/groups/ui-polish.json +87 -0
  43. package/templates/groups/verify-ship.json +79 -0
  44. package/templates/groups/research-quick.json +0 -49
package/README.md CHANGED
@@ -113,13 +113,16 @@ ucode-core list --json
113
113
 
114
114
  ## Global Chat (`ufoo -g`)
115
115
 
116
- Use `ufoo -g` (or `ufoo --global`) to launch a cross-project chat dashboard. Instead of being scoped to a single project, global mode connects to all running ufoo daemons and lets you switch between projects on the fly.
116
+ Use `ufoo -g` (or `ufoo --global`) to launch a cross-project chat dashboard. Instead of being scoped to the current working directory, global mode runs from a home-scoped controller context and stores its runtime under `~/.ufoo`, then connects to project daemons on demand.
117
+
118
+ When global chat stays on the home-scoped controller, plain prompts first go through the controller's `ufoo-agent`, which picks the best registered project and forwards the prompt to that project's `ufoo-agent` for agent-level routing.
117
119
 
118
120
  ```bash
119
121
  $ ufoo -g
120
122
 
121
123
  > /project list # List all running project daemons
122
124
  > /project switch 2 # Switch to project #2
125
+ > /open ~/Code/my-app # Initialize/start/switch to a project by path
123
126
  > /launch claude scope=inplace # Launch agent in current context
124
127
  > @claude-1 Start reviewing the auth module
125
128
  ```
@@ -128,9 +131,14 @@ $ ufoo -g
128
131
  |---------|-------------|
129
132
  | `/project list` | List running projects from global runtime registry |
130
133
  | `/project switch <index\|path>` | Switch active project daemon connection |
134
+ | `/open <path>` | Global-mode shortcut to initialize/start/open a project daemon by path |
131
135
  | `/launch <agent> scope=inplace` | Launch agent in current workspace |
132
136
  | `/launch <agent> scope=window` | Launch agent in separate terminal window |
133
137
 
138
+ Notes:
139
+ - If you just type a normal message in the controller view, global `ufoo-agent` will try to route it to the most relevant registered project first.
140
+ - The selected project's `ufoo-agent` then continues the second-hop routing to a concrete coding agent.
141
+
134
142
  ## Agent Configuration
135
143
 
136
144
  Configure AI providers in `.ufoo/config.json`:
package/README.zh-CN.md CHANGED
@@ -113,13 +113,16 @@ ucode-core list --json
113
113
 
114
114
  ## 全局聊天(`ufoo -g`)
115
115
 
116
- 使用 `ufoo -g`(或 `ufoo --global`)启动跨项目聊天仪表盘。全局模式会连接所有正在运行的 ufoo 守护进程,支持在不同项目之间快速切换。
116
+ 使用 `ufoo -g`(或 `ufoo --global`)启动跨项目聊天仪表盘。全局模式不再绑定当前工作目录,而是使用一个基于家目录的控制器上下文,并将自身运行时写入 `~/.ufoo`,然后按需连接各项目的 ufoo 守护进程。
117
+
118
+ 当全局聊天停留在这个家目录控制器视图时,普通消息会先经过控制器侧的 `ufoo-agent`,由它在当前已注册的 Projects 里选择最合适的项目,再把消息转交给目标项目内的 `ufoo-agent` 继续做 agent 级路由。
117
119
 
118
120
  ```bash
119
121
  $ ufoo -g
120
122
 
121
123
  > /project list # 列出所有运行中的项目守护进程
122
124
  > /project switch 2 # 切换到第 2 个项目
125
+ > /open ~/Code/my-app # 按路径初始化/启动/打开一个项目
123
126
  > /launch claude scope=inplace # 在当前上下文启动 Agent
124
127
  > @claude-1 开始审查 auth 模块
125
128
  ```
@@ -128,9 +131,14 @@ $ ufoo -g
128
131
  |------|------|
129
132
  | `/project list` | 列出全局运行时注册的项目 |
130
133
  | `/project switch <序号\|路径>` | 切换活动项目的 daemon 连接 |
134
+ | `/open <path>` | 仅在全局模式下可用;按路径初始化、启动并打开项目 daemon |
131
135
  | `/launch <agent> scope=inplace` | 在当前工作区启动 Agent |
132
136
  | `/launch <agent> scope=window` | 在独立终端窗口启动 Agent |
133
137
 
138
+ 说明:
139
+ - 如果你在控制器视图里直接输入普通消息,全局 `ufoo-agent` 会先尝试把它路由到最相关的已注册项目。
140
+ - 选中的项目里,项目侧 `ufoo-agent` 会继续完成第二跳路由,选择具体 coding agent。
141
+
134
142
  ## Agent 配置
135
143
 
136
144
  在 `.ufoo/config.json` 中配置 AI 提供商:
package/bin/ufoo.js CHANGED
@@ -5,6 +5,7 @@ const { runDaemonCli } = require("../src/daemon/run");
5
5
  const { runChat } = require("../src/chat");
6
6
  const { runInternalRunner } = require("../src/agent/internalRunner");
7
7
  const { runPtyRunner } = require("../src/agent/ptyRunner");
8
+ const { resolveGlobalControllerProjectRoot } = require("../src/globalMode");
8
9
 
9
10
  const rawArgv = process.argv.slice(2);
10
11
 
@@ -16,9 +17,10 @@ async function main() {
16
17
  const globalMode = hasGlobalModeFlag(rawArgv);
17
18
  const argv = rawArgv.filter((arg) => arg !== "-g" && arg !== "--global");
18
19
  const cmd = argv[0];
20
+ const chatProjectRoot = globalMode ? resolveGlobalControllerProjectRoot() : process.cwd();
19
21
 
20
22
  if (!cmd) {
21
- await runChat(process.cwd(), { globalMode });
23
+ await runChat(chatProjectRoot, { globalMode });
22
24
  return;
23
25
  }
24
26
  if (cmd === "daemon") {
@@ -47,7 +49,7 @@ async function main() {
47
49
  return;
48
50
  }
49
51
  if (cmd === "chat") {
50
- await runChat(process.cwd(), { globalMode });
52
+ await runChat(chatProjectRoot, { globalMode });
51
53
  return;
52
54
  }
53
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.7.5",
3
+ "version": "1.8.1",
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",
@@ -1,5 +1,6 @@
1
1
  const { spawn } = require("child_process");
2
2
  const { randomUUID } = require("crypto");
3
+ const { DEFAULT_ASSISTANT_TIMEOUT_MS } = require("../assistant/constants");
3
4
 
4
5
  const ROUTER_JSON_SCHEMA = JSON.stringify({
5
6
  type: "object",
@@ -616,7 +617,7 @@ async function runCliAgent(params) {
616
617
  cwd: params.cwd,
617
618
  env,
618
619
  input: stdin,
619
- timeoutMs: params.timeoutMs || 300000, // 5 minutes for complex tasks
620
+ timeoutMs: params.timeoutMs || DEFAULT_ASSISTANT_TIMEOUT_MS,
620
621
  onStdout: codexParser ? (chunk) => codexParser.onChunk(chunk) : null,
621
622
  signal: params.signal,
622
623
  });
@@ -678,7 +679,7 @@ async function runCliAgent(params) {
678
679
  cwd: params.cwd,
679
680
  env,
680
681
  input: retryStdin,
681
- timeoutMs: params.timeoutMs || 300000,
682
+ timeoutMs: params.timeoutMs || DEFAULT_ASSISTANT_TIMEOUT_MS,
682
683
  onStdout: retryParser ? (chunk) => retryParser.onChunk(chunk) : null,
683
684
  signal: params.signal,
684
685
  });
@@ -77,18 +77,20 @@ function buildBootstrapContent({
77
77
  function prepareUcodeBootstrap({
78
78
  projectRoot = process.cwd(),
79
79
  promptFile = "",
80
+ promptText = "",
80
81
  targetFile = "",
81
82
  } = {}) {
82
83
  const resolvedProjectRoot = path.resolve(projectRoot);
83
84
  const resolvedPrompt = String(promptFile || "").trim();
84
85
  const resolvedTarget = String(targetFile || "").trim() || defaultBootstrapPath(resolvedProjectRoot);
85
86
 
86
- const promptText = readFileSafe(resolvedPrompt);
87
+ const inlinePromptText = String(promptText || "").trim();
88
+ const resolvedPromptText = inlinePromptText || readFileSafe(resolvedPrompt);
87
89
  const rules = resolveProjectRules(resolvedProjectRoot);
88
90
  const content = buildBootstrapContent({
89
91
  projectRoot: resolvedProjectRoot,
90
92
  promptFile: resolvedPrompt,
91
- promptText,
93
+ promptText: resolvedPromptText,
92
94
  rules,
93
95
  });
94
96
 
@@ -99,7 +101,7 @@ function prepareUcodeBootstrap({
99
101
  ok: true,
100
102
  file: resolvedTarget,
101
103
  promptFile: resolvedPrompt,
102
- hasPrompt: Boolean(promptText.trim()),
104
+ hasPrompt: Boolean(resolvedPromptText.trim()),
103
105
  rulesCount: rules.length,
104
106
  };
105
107
  }
@@ -11,6 +11,8 @@ const {
11
11
  } = require("../code/nativeRunner");
12
12
  const { DEFAULT_ASSISTANT_TIMEOUT_MS } = require("../assistant/constants");
13
13
  const { normalizeAgentTypeAlias } = require("../bus/utils");
14
+ const { listProjectRuntimes } = require("../projects/registry");
15
+ const { isGlobalControllerProjectRoot } = require("../globalMode");
14
16
 
15
17
  function loadSessionState(projectRoot) {
16
18
  const dir = getUfooPaths(projectRoot).agentDir;
@@ -260,7 +262,180 @@ function loadBusSummary(projectRoot, maxLines = 20) {
260
262
  return { agents, nicknames, reports, agent_prompt_history: promptHistory, summary, recent };
261
263
  }
262
264
 
263
- function buildSystemPrompt(context) {
265
+ function slicePromptHistoryForProject(value = {}) {
266
+ const input = value && typeof value === "object" ? value : {};
267
+ const perAgent = Array.isArray(input.per_agent) ? input.per_agent.slice(0, 3) : [];
268
+ return {
269
+ scanned_files: Number(input.scanned_files || 0) || 0,
270
+ matched_events: Number(input.matched_events || 0) || 0,
271
+ per_agent: perAgent.map((row) => ({
272
+ agent_id: String(row && row.agent_id ? row.agent_id : ""),
273
+ nickname: String(row && row.nickname ? row.nickname : ""),
274
+ total_count: Number(row && row.total_count ? row.total_count : 0) || 0,
275
+ sample_count: Number(row && row.sample_count ? row.sample_count : 0) || 0,
276
+ last_ts: String(row && row.last_ts ? row.last_ts : ""),
277
+ samples: Array.isArray(row && row.samples)
278
+ ? row.samples.slice(0, 2).map((sample) => ({
279
+ ts: String(sample && sample.ts ? sample.ts : ""),
280
+ publisher: String(sample && sample.publisher ? sample.publisher : ""),
281
+ prompt: String(sample && sample.prompt ? sample.prompt : ""),
282
+ }))
283
+ : [],
284
+ })),
285
+ };
286
+ }
287
+
288
+ function buildGlobalProjectRouterContext(projectRoot, options = {}) {
289
+ const maxProjects = Number.isFinite(options.maxProjects) && options.maxProjects > 0
290
+ ? Math.floor(options.maxProjects)
291
+ : 12;
292
+
293
+ let rows = [];
294
+ try {
295
+ rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
296
+ } catch {
297
+ rows = [];
298
+ }
299
+
300
+ rows = rows
301
+ .filter((row) => {
302
+ const status = String((row && row.status) || "").trim().toLowerCase();
303
+ if (status === "stopped") return false;
304
+ return !isGlobalControllerProjectRoot(row && row.project_root ? row.project_root : "");
305
+ })
306
+ .slice(0, maxProjects);
307
+
308
+ let activeAgentTotal = 0;
309
+ let busyAgentTotal = 0;
310
+ let unreadTotal = 0;
311
+ let decisionsOpenTotal = 0;
312
+
313
+ const projects = rows.map((row) => {
314
+ const targetRoot = String(row && row.project_root ? row.project_root : "");
315
+ const fallbackName = String(row && row.project_name ? row.project_name : targetRoot);
316
+ let topDirs = [];
317
+ try {
318
+ const entries = fs.readdirSync(targetRoot, { withFileTypes: true });
319
+ topDirs = entries
320
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
321
+ .map((e) => e.name)
322
+ .slice(0, 20);
323
+ } catch {
324
+ // ignore unreadable directories
325
+ }
326
+ try {
327
+ const status = buildStatus(targetRoot);
328
+ const activeMeta = Array.isArray(status && status.active_meta) ? status.active_meta : [];
329
+ const agents = activeMeta.map((item) => ({
330
+ id: String(item && item.id ? item.id : ""),
331
+ nickname: String(item && item.nickname ? item.nickname : ""),
332
+ display: String(item && item.display ? item.display : ""),
333
+ launch_mode: String(item && item.launch_mode ? item.launch_mode : ""),
334
+ activity_state: String(item && item.activity_state ? item.activity_state : ""),
335
+ activity_since: String(item && item.activity_since ? item.activity_since : ""),
336
+ }));
337
+ const nicknames = {};
338
+ agents.forEach((item) => {
339
+ if (item.nickname) nicknames[item.nickname] = item.id;
340
+ });
341
+ const promptHistory = buildAgentPromptHistory(targetRoot, agents, nicknames, {
342
+ perAgentLimit: 2,
343
+ maxFiles: 2,
344
+ });
345
+ const busyCount = agents.filter((item) => isBusyActivityState(item.activity_state)).length;
346
+ activeAgentTotal += agents.length;
347
+ busyAgentTotal += busyCount;
348
+ const unread = Number(status && status.unread && status.unread.total ? status.unread.total : 0) || 0;
349
+ const decisionsOpen = Number(status && status.decisions && status.decisions.open ? status.decisions.open : 0) || 0;
350
+ unreadTotal += unread;
351
+ decisionsOpenTotal += decisionsOpen;
352
+ return {
353
+ project_root: targetRoot,
354
+ project_name: fallbackName,
355
+ top_dirs: topDirs,
356
+ status: String(row && row.status ? row.status : "unknown"),
357
+ last_seen: String(row && row.last_seen ? row.last_seen : ""),
358
+ active_count: agents.length,
359
+ busy_count: busyCount,
360
+ ready_count: Math.max(agents.length - busyCount, 0),
361
+ unread_total: unread,
362
+ decisions_open: decisionsOpen,
363
+ reports_pending_total: Number(status && status.reports && status.reports.pending_total ? status.reports.pending_total : 0) || 0,
364
+ groups_active: Number(status && status.groups && status.groups.active ? status.groups.active : 0) || 0,
365
+ agents: agents.slice(0, 6),
366
+ agent_prompt_history: slicePromptHistoryForProject(promptHistory),
367
+ };
368
+ } catch {
369
+ return {
370
+ project_root: targetRoot,
371
+ project_name: fallbackName,
372
+ top_dirs: topDirs,
373
+ status: String(row && row.status ? row.status : "unknown"),
374
+ last_seen: String(row && row.last_seen ? row.last_seen : ""),
375
+ active_count: 0,
376
+ busy_count: 0,
377
+ ready_count: 0,
378
+ unread_total: 0,
379
+ decisions_open: 0,
380
+ reports_pending_total: 0,
381
+ groups_active: 0,
382
+ agents: [],
383
+ agent_prompt_history: { scanned_files: 0, matched_events: 0, per_agent: [] },
384
+ };
385
+ }
386
+ });
387
+
388
+ const runningCount = projects.filter((item) => item.status === "running").length;
389
+ const staleCount = projects.filter((item) => item.status === "stale").length;
390
+
391
+ return {
392
+ mode: "global-router",
393
+ controller_project_root: projectRoot,
394
+ summary: {
395
+ project_count: projects.length,
396
+ running_count: runningCount,
397
+ stale_count: staleCount,
398
+ active_agent_total: activeAgentTotal,
399
+ busy_agent_total: busyAgentTotal,
400
+ unread_total: unreadTotal,
401
+ decisions_open_total: decisionsOpenTotal,
402
+ },
403
+ projects,
404
+ };
405
+ }
406
+
407
+ function buildSystemPrompt(context, options = {}) {
408
+ const mode = String(options.routingMode || (context && context.mode) || "").trim().toLowerCase();
409
+ if (mode === "global-router") {
410
+ return [
411
+ "You are ufoo-agent, the global project router for `ufoo chat -g`.",
412
+ "You run inside the home-scoped controller runtime and must choose the right project before any project-local routing happens.",
413
+ "Return ONLY valid JSON. No extra text.",
414
+ "Schema:",
415
+ "{",
416
+ ' "reply": "string",',
417
+ ` "assistant_call": {"kind":"explore|bash|mixed","task":"string","context":"optional","expect":"optional","provider":"codex|claude|ufoo (optional)","model":"optional","timeout_ms":${DEFAULT_ASSISTANT_TIMEOUT_MS}},`,
418
+ ' "project_route": {"project_root":"absolute-path","project_name":"string","prompt":"string","reason":"string"},',
419
+ ' "dispatch": [],',
420
+ ' "ops": []',
421
+ "}",
422
+ "Rules:",
423
+ "- Use project_route when the request should be handed to one specific registered project.",
424
+ "- project_route.prompt should usually preserve the user request, optionally rewritten only to clarify project context for the next router.",
425
+ "- Each project entry has top_dirs: the immediate subdirectories of project_root. Use these to match sub-project or component names mentioned by the user (e.g. if user says 'voyager' and a project has 'voyager' in top_dirs, route there).",
426
+ "- Keep dispatch empty in global-router mode. Do NOT send directly to coding agents from the global controller.",
427
+ "- Keep ops empty in global-router mode. Do NOT launch/rename/close/cron project-local agents from the global controller.",
428
+ "- The target project's ufoo-agent will do the second-hop routing to a concrete agent.",
429
+ "- If the user asks for a global comparison, registry overview, or other controller-level answer, reply directly and omit project_route.",
430
+ "- If no registered project is a clear match, reply with a concise clarification request or tell the user to use /open <path> first.",
431
+ "- assistant_call is allowed for lightweight controller-side inspection when the registry/context is insufficient.",
432
+ "- Prefer continuity: if a project's recent prompt history clearly matches the current request, route there.",
433
+ "",
434
+ "Context: registered projects and project activity summaries:",
435
+ JSON.stringify(context),
436
+ ].join("\n");
437
+ }
438
+
264
439
  const hasAgents = context.agents && context.agents.length > 0;
265
440
  const agentGuidance = hasAgents
266
441
  ? ""
@@ -275,7 +450,7 @@ function buildSystemPrompt(context) {
275
450
  ' "reply": "string",',
276
451
  ` "assistant_call": {"kind":"explore|bash|mixed","task":"string","context":"optional","expect":"optional","provider":"codex|claude|ufoo (optional)","model":"optional","timeout_ms":${DEFAULT_ASSISTANT_TIMEOUT_MS}},`,
277
452
  ' "dispatch": [{"target":"broadcast|<agent-id>|<nickname>","message":"string","injection_mode":"immediate|queued (optional)","source":"optional"}],',
278
- ' "ops": [{"action":"launch|close|rename|cron","agent":"codex|claude|ucode","count":1,"agent_id":"id","nickname":"optional","operation":"start|list|stop","every":"30m","interval_ms":1800000,"at":"YYYY-MM-DD HH:mm","once_at_ms":1700000000000,"target":"agent-id|nickname|csv","targets":["agent-id"],"title":"optional short title","prompt":"message","id":"task-id|all"}],',
453
+ ' "ops": [{"action":"launch|close|rename|role|cron","agent":"codex|claude|ucode","count":1,"agent_id":"id","nickname":"optional","prompt_profile":"profile-id (for role)","operation":"start|list|stop","every":"30m","interval_ms":1800000,"at":"YYYY-MM-DD HH:mm","once_at_ms":1700000000000,"target":"agent-id|nickname|csv","targets":["agent-id"],"title":"optional short title","prompt":"message","id":"task-id|all"}],',
279
454
  ' "disambiguate": {"prompt":"string","candidates":[{"agent_id":"id","reason":"string"}]}',
280
455
  "}",
281
456
  "Rules:",
@@ -286,6 +461,7 @@ function buildSystemPrompt(context) {
286
461
  "- For scheduled follow-up (cron), use ops.cron with operation=start and include target(s)+prompt, plus optional title; use every/interval_ms for recurring or at/once_at_ms for one-time.",
287
462
  "- To check scheduled tasks, use ops.cron with operation=list.",
288
463
  "- To stop scheduled tasks, use ops.cron with operation=stop and id (or id=all).",
464
+ "- To assign a preset role to an existing agent, use ops.role with target (agent-id or nickname) and prompt_profile (profile id or alias). Available profiles: discovery-facilitator, scope-challenger, system-architect, implementation-lead, frontend-refiner, design-critic, review-critic, qa-driver, debug-investigator, release-coordinator, task-breakdown, research-scan, rapid-prototype.",
289
465
  "- Use top-level assistant_call for project exploration, temporary shell tasks, and quick execution support.",
290
466
  "- assistant_call fields: kind (explore|bash|mixed), task (required), context/expect (optional), provider (codex|claude|ufoo, optional), model/timeout_ms (optional).",
291
467
  "- Prefer assistant_call over launching coding agents when the task is short-lived.",
@@ -453,10 +629,13 @@ async function runNativeRouterCall({ projectRoot, prompt, systemPrompt, model: r
453
629
  }
454
630
  }
455
631
 
456
- async function runUfooAgent({ projectRoot, prompt, provider, model }) {
632
+ async function runUfooAgent({ projectRoot, prompt, provider, model, routingMode = "", routingContext = null }) {
457
633
  const state = loadSessionState(projectRoot);
458
- const bus = loadBusSummary(projectRoot);
459
- const systemPrompt = buildSystemPrompt(bus);
634
+ const mode = String(routingMode || (routingContext && routingContext.mode) || "").trim().toLowerCase();
635
+ const bus = routingContext || (mode === "global-router"
636
+ ? buildGlobalProjectRouterContext(projectRoot)
637
+ : loadBusSummary(projectRoot));
638
+ const systemPrompt = buildSystemPrompt(bus, { routingMode: mode });
460
639
  const history = loadHistory(projectRoot);
461
640
  const historyPrompt = buildHistoryPrompt(history);
462
641
  const fullPrompt = historyPrompt ? `${historyPrompt}User: ${prompt}` : prompt;
@@ -1,4 +1,4 @@
1
- const DEFAULT_ASSISTANT_TIMEOUT_MS = 300000; // 5 minutes
1
+ const DEFAULT_ASSISTANT_TIMEOUT_MS = 600000; // 10 minutes
2
2
  const DEFAULT_ASSISTANT_TIMEOUT_GRACE_MS = 5000;
3
3
 
4
4
  function normalizeAssistantTimeoutMs(value, fallback = DEFAULT_ASSISTANT_TIMEOUT_MS) {
@@ -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) {
@@ -1120,12 +1207,18 @@ function createCommandExecutor(options = {}) {
1120
1207
  case "launch":
1121
1208
  await handleLaunchCommand(args);
1122
1209
  return true;
1210
+ case "open":
1211
+ await handleOpenCommand(args);
1212
+ return true;
1123
1213
  case "resume":
1124
1214
  await handleResumeCommand(args);
1125
1215
  return true;
1126
1216
  case "project":
1127
1217
  await handleProjectCommand(args);
1128
1218
  return true;
1219
+ case "role":
1220
+ await handleRoleCommand(args);
1221
+ return true;
1129
1222
  case "cron":
1130
1223
  await handleCronCommand(args);
1131
1224
  return true;
@@ -1154,8 +1247,10 @@ function createCommandExecutor(options = {}) {
1154
1247
  handleCtxCommand,
1155
1248
  handleSkillsCommand,
1156
1249
  handleLaunchCommand,
1250
+ handleOpenCommand,
1157
1251
  handleResumeCommand,
1158
1252
  handleProjectCommand,
1253
+ handleRoleCommand,
1159
1254
  handleCronCommand,
1160
1255
  handleGroupCommand,
1161
1256
  handleSettingsCommand,
@@ -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: {