u-foo 2.3.19 → 2.3.21

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/README.md CHANGED
@@ -143,7 +143,9 @@ ufoo resume <ucode|uclaude|ucodex|nickname>
143
143
  ufoo recover list
144
144
  ```
145
145
 
146
- Common chat commands include `/status`, `/bus list`, `/bus status`, `/settings`, `/project list`, `/project switch <index|path>`, `/open <path>`, `/resume list`, `/group status`, and `@nickname <message>`.
146
+ Common chat commands include `/status`, `/bus list`, `/bus status`, `/settings`, `/project list`, `/project switch <index|path>`, `/open <path>`, `/resume list`, `/group status`, `/skills`, and `@nickname <message>`.
147
+
148
+ In `ufoo chat`, `/skills` lists ufoo's built-in available skills and preset workflow capabilities so users can discover and choose them. It does not execute a task by itself, and it is not a private capability list for any one agent.
147
149
 
148
150
  ### Event Bus
149
151
 
@@ -197,7 +199,7 @@ Use decisions sparingly for plan-level constraints. Durable project facts belong
197
199
  | Online | `ufoo online server|token|room|channel|connect|send|inbox` |
198
200
  | History | `ufoo history build|show|prompt` |
199
201
  | Skills | `ufoo skills list|install` |
200
- | Chat settings | `/settings`, `/settings agent`, `/settings router`, `/settings ucode` |
202
+ | Chat commands | `/skills`, `/settings`, `/settings agent`, `/settings router`, `/settings ucode` |
201
203
 
202
204
  ### Groups
203
205
 
@@ -246,8 +248,12 @@ Use the low-level queue runtime:
246
248
  ucode-core submit --tool read --args-json '{"path":"README.md"}' --json
247
249
  ucode-core run-once --json
248
250
  ucode-core list --json
251
+ ucode-core skills list --json
252
+ ucode-core skills show <name>
249
253
  ```
250
254
 
255
+ `ucode-core skills list` discovers ufoo/ucode built-in and local `SKILL.md` preset workflow capabilities for selection. It lists metadata only; full skill bodies are loaded by ucode only when the user explicitly references a skill such as `$demo` or a direct `SKILL.md` link.
256
+
251
257
  ## Configuration
252
258
 
253
259
  Project configuration is stored in `.ufoo/config.json`. `ucode` provider credentials are stored globally in `~/.ufoo/config.json` and merged into project config at load time.
package/README.zh-CN.md CHANGED
@@ -133,7 +133,9 @@ ufoo resume <ucode|uclaude|ucodex|nickname>
133
133
  ufoo recover list
134
134
  ```
135
135
 
136
- 常见 chat 命令包括 `/status`、`/bus list`、`/bus status`、`/settings`、`/project list`、`/project switch <index|path>`、`/open <path>`、`/resume list`、`/group status` 和 `@nickname <message>`。
136
+ 常见 chat 命令包括 `/status`、`/bus list`、`/bus status`、`/settings`、`/project list`、`/project switch <index|path>`、`/open <path>`、`/resume list`、`/group status`、`/skills` 和 `@nickname <message>`。
137
+
138
+ 在 `ufoo chat` 中,`/skills` 用于列出 ufoo 内置的可用 skills 和预设工作流能力,方便用户发现和选择。它本身不等于执行任务,也不表示某个 agent 的私有能力列表。
137
139
 
138
140
  ### 事件总线
139
141
 
@@ -211,8 +213,12 @@ ufoo ucode build
211
213
  ucode-core submit --tool read --args-json '{"path":"README.md"}' --json
212
214
  ucode-core run-once --json
213
215
  ucode-core list --json
216
+ ucode-core skills list --json
217
+ ucode-core skills show <name>
214
218
  ```
215
219
 
220
+ `ucode-core skills list` 用于发现 ufoo/ucode 内置和本地 `SKILL.md` 预设工作流能力,输出的是供选择的元数据;完整 skill 内容只会在用户显式引用 `$demo` 或直接链接某个 `SKILL.md` 时由 ucode 加载。
221
+
216
222
  ## 命令参考
217
223
 
218
224
  | 范围 | 命令 |
@@ -228,7 +234,7 @@ ucode-core list --json
228
234
  | Online | `ufoo online server|token|room|channel|connect|send|inbox` |
229
235
  | History | `ufoo history build|show|prompt` |
230
236
  | Skills | `ufoo skills list|install` |
231
- | Chat 设置 | `/settings`, `/settings agent`, `/settings router`, `/settings ucode` |
237
+ | Chat 命令 | `/skills`, `/settings`, `/settings agent`, `/settings router`, `/settings ucode` |
232
238
 
233
239
  ## 配置
234
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.3.19",
3
+ "version": "2.3.21",
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",
@@ -42,6 +42,7 @@ function createAgentViewController(options = {}) {
42
42
  sendBusMessage = () => {},
43
43
  sendResize = () => {},
44
44
  requestScreenSnapshot = () => {},
45
+ sendBusWatch = () => {},
45
46
  } = options;
46
47
 
47
48
  if (!screen || typeof screen.render !== "function") {
@@ -69,10 +70,14 @@ function createAgentViewController(options = {}) {
69
70
  };
70
71
 
71
72
  function getRows() {
73
+ if (Number.isFinite(screen.height) && screen.height > 0) return screen.height;
74
+ if (Number.isFinite(screen.rows) && screen.rows > 0) return screen.rows;
72
75
  return processStdout.rows || 24;
73
76
  }
74
77
 
75
78
  function getCols() {
79
+ if (Number.isFinite(screen.width) && screen.width > 0) return screen.width;
80
+ if (Number.isFinite(screen.cols) && screen.cols > 0) return screen.cols;
76
81
  return processStdout.columns || 80;
77
82
  }
78
83
 
@@ -257,11 +262,14 @@ function createAgentViewController(options = {}) {
257
262
  function forceScreenRepaint() {
258
263
  if (typeof screen.realloc === "function") {
259
264
  screen.realloc();
260
- return;
261
- }
262
- if (typeof screen.alloc === "function") {
265
+ } else if (typeof screen.alloc === "function") {
263
266
  screen.alloc(true);
264
267
  }
268
+ try {
269
+ originalRender();
270
+ } catch {
271
+ // Ignore repaint failures while restoring from raw agent view.
272
+ }
265
273
  }
266
274
 
267
275
  function compactProjectPath(projectRoot = "") {
@@ -297,13 +305,16 @@ function createAgentViewController(options = {}) {
297
305
  const projectPath = compactProjectPath(getProjectRoot());
298
306
  const product = "ClaudeCode";
299
307
  const detail = label ? `${label} · managed headless` : "managed headless";
300
- const iconTop = `${CLAUDE_ORANGE}▐▛███▜▌${ANSI_RESET}`;
301
- const iconMiddle = `${CLAUDE_ORANGE}▝▜█████▛▘${ANSI_RESET}`;
302
- const iconBottom = `${CLAUDE_ORANGE}▘▘▝▝${ANSI_RESET}`;
308
+ const iconWidth = 9;
309
+ const iconGap = " ";
310
+ const iconLine = (icon = "", text = "") => {
311
+ const pad = " ".repeat(Math.max(0, iconWidth - displayWidth(icon)));
312
+ return `${CLAUDE_ORANGE}${icon}${ANSI_RESET}${pad}${iconGap}${text}`;
313
+ };
303
314
  const lines = [
304
- ` ${iconTop} ${product}v${packageVersion}`,
305
- `${iconMiddle}${detail}`,
306
- ` ${iconBottom} ${projectPath}`,
315
+ iconLine(" ▐▛███▜▌", `${product}v${packageVersion}`),
316
+ iconLine("▝▜█████▛▘", detail),
317
+ iconLine(" ▘▘ ▝▝ ", projectPath),
307
318
  "",
308
319
  ];
309
320
  if (width < 44) return lines;
@@ -464,6 +475,7 @@ function createAgentViewController(options = {}) {
464
475
  function enterAgentView(agentId, options = {}) {
465
476
  if (currentView === "agent" && viewingAgent === agentId) return;
466
477
  if (currentView === "agent") {
478
+ if (agentViewUsesBus && viewingAgent) sendBusWatch(viewingAgent, false);
467
479
  disconnectAgentOutput();
468
480
  disconnectAgentInput();
469
481
  }
@@ -488,6 +500,7 @@ function createAgentViewController(options = {}) {
488
500
  agentInputSuppressUntil = now() + 300;
489
501
  agentViewUsesBus = Boolean(options.useBus);
490
502
  if (agentViewUsesBus) {
503
+ sendBusWatch(agentId, true);
491
504
  resetBusView(agentId);
492
505
  renderBusView();
493
506
  } else {
@@ -511,6 +524,7 @@ function createAgentViewController(options = {}) {
511
524
 
512
525
  disconnectAgentOutput();
513
526
  disconnectAgentInput();
527
+ if (agentViewUsesBus && viewingAgent) sendBusWatch(viewingAgent, false);
514
528
  agentViewUsesBus = false;
515
529
  agentOutputSuppressed = false;
516
530
  agentBarVisible = false;
@@ -536,11 +550,11 @@ function createAgentViewController(options = {}) {
536
550
  setDashboardView("agents");
537
551
  setSelectedAgentIndex(-1);
538
552
  setScreenGrabKeys(false);
539
- forceScreenRepaint();
540
553
  clearTargetAgent();
541
554
  renderDashboard();
542
555
  focusInput();
543
556
  resizeInput();
557
+ forceScreenRepaint();
544
558
  try {
545
559
  if (screen.program && typeof screen.program.showCursor === "function") {
546
560
  screen.program.showCursor();
@@ -448,12 +448,12 @@ function createCommandExecutor(options = {}) {
448
448
  try {
449
449
  const skills = createSkills(projectRoot);
450
450
 
451
- if (subcommand === "list") {
451
+ if (!subcommand || subcommand === "list") {
452
452
  const skillList = skills.list();
453
453
  if (skillList.length === 0) {
454
- logMessage("system", "{white-fg}No skills found{/white-fg}");
454
+ logMessage("system", "{white-fg}No built-in ufoo skills found{/white-fg}");
455
455
  } else {
456
- logMessage("system", `{cyan-fg}Available skills:{/cyan-fg} ${skillList.length}`);
456
+ logMessage("system", `{cyan-fg}Available built-in ufoo skills:{/cyan-fg} ${skillList.length}`);
457
457
  for (const skill of skillList) {
458
458
  logMessage("system", ` • ${skill}`);
459
459
  }
@@ -129,10 +129,10 @@ const COMMAND_TREE = {
129
129
  },
130
130
  },
131
131
  "/skills": {
132
- desc: "Skills management",
132
+ desc: "List ufoo built-in skills and preset workflows",
133
133
  children: {
134
- install: { desc: "Install skills (use: all or name)" },
135
- list: { desc: "List available skills" },
134
+ install: { desc: "Install built-in skills (use: all or name)" },
135
+ list: { desc: "List built-in skills for discovery" },
136
136
  },
137
137
  },
138
138
  "/status": { desc: "Status display" },
@@ -19,6 +19,8 @@ function createDaemonMessageRouter(options = {}) {
19
19
  getCurrentView = () => "main",
20
20
  isAgentViewUsesBus = () => false,
21
21
  getViewingAgent = () => "",
22
+ isAgentEventForViewingAgent = (data, viewingAgent, publisher) =>
23
+ Boolean(viewingAgent && (publisher === viewingAgent || data?.target === viewingAgent)),
22
24
  writeToAgentTerm = () => {},
23
25
  consumePendingDelivery = () => false,
24
26
  getPendingState = () => null,
@@ -375,11 +377,24 @@ function createDaemonMessageRouter(options = {}) {
375
377
  if (data.silent && !streamPayload) return true;
376
378
  if (!displayMessage && !streamPayload) return true;
377
379
 
380
+ const viewingAgent = getViewingAgent();
381
+ const isOwnInternalPrompt =
382
+ data.source === "chat-internal-agent-view" &&
383
+ viewingAgent &&
384
+ data.target === viewingAgent &&
385
+ publisher !== viewingAgent;
386
+ if (
387
+ getCurrentView() === "agent" &&
388
+ isAgentViewUsesBus() &&
389
+ isOwnInternalPrompt
390
+ ) {
391
+ return true;
392
+ }
378
393
  const isAgentViewTarget =
379
394
  getCurrentView() === "agent" &&
380
395
  isAgentViewUsesBus() &&
381
- getViewingAgent() &&
382
- publisher === getViewingAgent();
396
+ viewingAgent &&
397
+ isAgentEventForViewingAgent(data, viewingAgent, publisher);
383
398
 
384
399
  const displayName = resolveAgentDisplayName(publisher);
385
400
 
package/src/chat/index.js CHANGED
@@ -1062,11 +1062,12 @@ async function runChat(projectRoot, options = {}) {
1062
1062
  : `${prefix}>`;
1063
1063
 
1064
1064
  promptBox.setContent(content);
1065
+ if (!input.parent || !promptBox.parent) return;
1066
+
1065
1067
  promptBox.width = content.length + 1; // content + spacer
1066
1068
  input.left = promptBox.width;
1067
1069
  input.width = `100%-${promptBox.width}`;
1068
1070
 
1069
- if (!input.parent || !promptBox.parent) return;
1070
1071
  resizeInput();
1071
1072
  if (typeof input._updateCursor === "function") {
1072
1073
  input._updateCursor();
@@ -1581,6 +1582,14 @@ async function runChat(projectRoot, options = {}) {
1581
1582
  source: "chat-internal-agent-view",
1582
1583
  });
1583
1584
  },
1585
+ sendBusWatch: (agentId, enabled) => {
1586
+ if (!agentId) return;
1587
+ send({
1588
+ type: IPC_REQUEST_TYPES.BUS_WATCH,
1589
+ agent_id: agentId,
1590
+ enabled: enabled !== false,
1591
+ });
1592
+ },
1584
1593
  sendResize: (cols, rows) => {
1585
1594
  sendResizeWithCapabilities(cols, rows);
1586
1595
  },
@@ -1612,6 +1621,21 @@ async function runChat(projectRoot, options = {}) {
1612
1621
  getCurrentView: () => getCurrentView(),
1613
1622
  isAgentViewUsesBus: () => isAgentViewUsesBus(),
1614
1623
  getViewingAgent: () => getViewingAgent(),
1624
+ isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
1625
+ if (!viewingAgent) return false;
1626
+ const label = getAgentLabel(viewingAgent);
1627
+ const candidates = [
1628
+ publisher,
1629
+ data && data.publisher,
1630
+ data && data.target,
1631
+ data && data.subscriber,
1632
+ ].filter(Boolean);
1633
+ return candidates.some((value) => (
1634
+ value === viewingAgent ||
1635
+ value === label ||
1636
+ resolveAgentId(value) === viewingAgent
1637
+ ));
1638
+ },
1615
1639
  writeToAgentTerm,
1616
1640
  consumePendingDelivery,
1617
1641
  getPendingState,
package/src/code/agent.js CHANGED
@@ -25,6 +25,12 @@ const {
25
25
  loadSessionSnapshot,
26
26
  } = require("./sessionStore");
27
27
  const { buildPromptContext } = require("./prompts");
28
+ const {
29
+ buildSkillInjections,
30
+ formatSkillsList,
31
+ listUcodeSkills,
32
+ showSkill,
33
+ } = require("./skills");
28
34
 
29
35
  function printPrompt() {
30
36
  process.stdout.write("> ");
@@ -243,6 +249,61 @@ function createToolLogCollector(logs = [], onToolLog = null) {
243
249
  };
244
250
  }
245
251
 
252
+ function pushSkillWarning(logs = [], onToolLog = null, warning = "") {
253
+ const text = String(warning || "").trim();
254
+ if (!text) return null;
255
+ const entry = {
256
+ type: "skills",
257
+ phase: "warning",
258
+ message: text,
259
+ error: text,
260
+ };
261
+ if (Array.isArray(logs)) logs.push(entry);
262
+ if (typeof onToolLog === "function") {
263
+ try {
264
+ onToolLog(entry);
265
+ } catch {
266
+ // ignore callback failures
267
+ }
268
+ }
269
+ return entry;
270
+ }
271
+
272
+ function stripSkillBlocksFromText(value = "") {
273
+ return String(value || "")
274
+ .replace(/<skill>\s*[\s\S]*?<\/skill>\s*/g, "")
275
+ .trim();
276
+ }
277
+
278
+ function stripSkillBlocksFromMessages(messages = []) {
279
+ if (!Array.isArray(messages)) return [];
280
+ return messages.map((message) => {
281
+ if (!message || typeof message !== "object" || Array.isArray(message)) return message;
282
+ if (typeof message.content === "string") {
283
+ return {
284
+ ...message,
285
+ content: stripSkillBlocksFromText(message.content),
286
+ };
287
+ }
288
+ if (Array.isArray(message.content)) {
289
+ return {
290
+ ...message,
291
+ content: message.content.map((item) => {
292
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
293
+ if (typeof item.text === "string") {
294
+ return {
295
+ ...item,
296
+ text: stripSkillBlocksFromText(item.text),
297
+ };
298
+ }
299
+ return item;
300
+ }),
301
+ };
302
+ }
303
+ return message;
304
+ });
305
+ }
306
+
246
307
  function isProjectAnalysisTask(task = "") {
247
308
  const text = String(task || "").trim().toLowerCase();
248
309
  if (!text) return false;
@@ -398,6 +459,16 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
398
459
  const taskPrompt = analysisTask
399
460
  ? `${taskText}\n\nAnalysis requirements:\n- Inspect repository evidence before concluding.\n- Cite concrete file observations.\n- Keep findings concise and actionable.`
400
461
  : taskText;
462
+ const skillInjections = buildSkillInjections({
463
+ prompt: taskPrompt,
464
+ workspaceRoot,
465
+ });
466
+ for (const warning of skillInjections.warnings || []) {
467
+ pushSkillWarning(logs, onToolLog, warning);
468
+ }
469
+ const effectiveTaskPrompt = Array.isArray(skillInjections.blocks) && skillInjections.blocks.length > 0
470
+ ? `${skillInjections.blocks.join("\n\n")}\n\n${taskPrompt}`
471
+ : taskPrompt;
401
472
  const systemContext = [String(state.context || "").trim(), preflightContext]
402
473
  .filter(Boolean)
403
474
  .join("\n\n");
@@ -422,7 +493,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
422
493
  workspaceRoot,
423
494
  provider,
424
495
  model,
425
- prompt: taskPrompt,
496
+ prompt: effectiveTaskPrompt,
426
497
  systemPrompt: systemContext,
427
498
  messages: Array.isArray(state.nlMessages) ? state.nlMessages : [],
428
499
  sessionId: String(sessionIdValue || ""),
@@ -440,7 +511,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
440
511
  // Use decomposed runner for bug fix tasks
441
512
  if (useDecomposition) {
442
513
  const decomposedResult = await runDecomposedTask({
443
- task: taskText,
514
+ task: effectiveTaskPrompt,
444
515
  state,
445
516
  onProgress: options.onProgress,
446
517
  onToolEvent: pushToolLog,
@@ -497,7 +568,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
497
568
  state.sessionId = cliRes.sessionId.trim();
498
569
  }
499
570
  if (cliRes && Array.isArray(cliRes.messages)) {
500
- state.nlMessages = cliRes.messages;
571
+ state.nlMessages = stripSkillBlocksFromMessages(cliRes.messages);
501
572
  }
502
573
  const normalized = String(cliRes.output || "").trim();
503
574
  const summary = extractJsonSummary(normalized);
@@ -1235,6 +1306,8 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
1235
1306
  " help",
1236
1307
  " exit|quit",
1237
1308
  " ubus|/ubus",
1309
+ " skills [list]",
1310
+ " skills show <name>",
1238
1311
  " bg|/bg <task>",
1239
1312
  " resume <session-id>",
1240
1313
  " tool <read|write|edit|bash> <args-json>",
@@ -1254,6 +1327,45 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
1254
1327
  kind: "ubus",
1255
1328
  };
1256
1329
  }
1330
+ const skillsMatch = text.match(/^(?:\/skills|skills)(?:\s+(.*))?$/i);
1331
+ if (skillsMatch) {
1332
+ const args = String(skillsMatch[1] || "").trim().split(/\s+/).filter(Boolean);
1333
+ const action = String(args[0] || "list").toLowerCase();
1334
+ if (action === "list" || action === "ls") {
1335
+ const outcome = listUcodeSkills({ workspaceRoot });
1336
+ return {
1337
+ kind: "skills",
1338
+ output: formatSkillsList(outcome),
1339
+ skills: outcome.skills,
1340
+ errors: outcome.errors,
1341
+ };
1342
+ }
1343
+ if (action === "show") {
1344
+ const name = String(args[1] || "").trim();
1345
+ if (!name) {
1346
+ return {
1347
+ kind: "error",
1348
+ output: "usage: skills show <name>",
1349
+ };
1350
+ }
1351
+ const result = showSkill({ name, workspaceRoot });
1352
+ if (!result.ok) {
1353
+ return {
1354
+ kind: "error",
1355
+ output: result.error,
1356
+ };
1357
+ }
1358
+ return {
1359
+ kind: "skills",
1360
+ output: result.output,
1361
+ skill: result.skill,
1362
+ };
1363
+ }
1364
+ return {
1365
+ kind: "error",
1366
+ output: "usage: skills [list] | skills show <name>",
1367
+ };
1368
+ }
1257
1369
  if (text === "bg" || text === "/bg") {
1258
1370
  return {
1259
1371
  kind: "error",
@@ -1498,7 +1610,7 @@ async function runUcodeCoreAgent({
1498
1610
  if (result.kind === "probe") {
1499
1611
  return;
1500
1612
  }
1501
- if (result.kind === "help" || result.kind === "tool" || result.kind === "error") {
1613
+ if (result.kind === "help" || result.kind === "tool" || result.kind === "skills" || result.kind === "error") {
1502
1614
  stdout.write(`${result.output}\n`);
1503
1615
  }
1504
1616
  if (result.kind === "ubus") {
@@ -1701,6 +1813,8 @@ module.exports = {
1701
1813
  normalizeToolLogEvent,
1702
1814
  isProjectAnalysisTask,
1703
1815
  buildNlFallbackSummary,
1816
+ buildNlContext,
1817
+ stripSkillBlocksFromMessages,
1704
1818
  resolvePlannerProvider,
1705
1819
  extractJsonSummary,
1706
1820
  enrichNativeError,
package/src/code/cli.js CHANGED
@@ -4,6 +4,11 @@ const {
4
4
  listResults,
5
5
  } = require("./runtime");
6
6
  const { runUcodeCoreAgent } = require("./agent");
7
+ const {
8
+ formatSkillsList,
9
+ listUcodeSkills,
10
+ showSkill,
11
+ } = require("./skills");
7
12
 
8
13
  function parseArgs(argv = []) {
9
14
  const args = Array.isArray(argv) ? argv.slice() : [];
@@ -16,11 +21,21 @@ function parseArgs(argv = []) {
16
21
  max: 1,
17
22
  num: 20,
18
23
  taskId: "",
24
+ skillsAction: "",
25
+ skillsName: "",
19
26
  };
20
27
 
21
28
  for (let i = 1; i < args.length; i += 1) {
22
29
  const item = String(args[i] || "").trim();
23
30
  if (!item) continue;
31
+ if (out.command === "skills" && !item.startsWith("-")) {
32
+ if (!out.skillsAction) {
33
+ out.skillsAction = item.toLowerCase();
34
+ } else if (!out.skillsName) {
35
+ out.skillsName = item;
36
+ }
37
+ continue;
38
+ }
24
39
  if (item === "--json") {
25
40
  out.json = true;
26
41
  continue;
@@ -69,6 +84,8 @@ function usage() {
69
84
  " submit --tool <read|write|edit|bash> --args-json <json> [--workspace <path>] [--task-id <id>]",
70
85
  " run-once [--max <n>] [--workspace <path>]",
71
86
  " list [--num <n>]",
87
+ " skills list [--workspace <path>]",
88
+ " skills show <name> [--workspace <path>]",
72
89
  "",
73
90
  "Flags:",
74
91
  " --json Output JSON",
@@ -147,6 +164,36 @@ async function runUcodeCoreCli({
147
164
  return { exitCode: 0, output: `${lines.join("\n")}${lines.length ? "\n" : ""}` };
148
165
  }
149
166
 
167
+ if (cmd === "skills") {
168
+ const action = options.skillsAction || "list";
169
+ const workspaceRoot = options.workspace || projectRoot;
170
+ if (action === "list" || action === "ls") {
171
+ const outcome = listUcodeSkills({ workspaceRoot });
172
+ if (options.json) {
173
+ return { exitCode: 0, output: `${JSON.stringify({ ok: true, ...outcome })}\n` };
174
+ }
175
+ return { exitCode: 0, output: `${formatSkillsList(outcome)}\n` };
176
+ }
177
+ if (action === "show") {
178
+ if (!options.skillsName) {
179
+ return { exitCode: 1, output: "skills show requires <name>\n" };
180
+ }
181
+ const result = showSkill({
182
+ name: options.skillsName,
183
+ workspaceRoot,
184
+ asJson: options.json,
185
+ });
186
+ if (!result.ok) {
187
+ return { exitCode: 1, output: `${options.json ? JSON.stringify({ ok: false, error: result.error }) : result.error}\n` };
188
+ }
189
+ if (options.json) {
190
+ return { exitCode: 0, output: `${JSON.stringify(result)}\n` };
191
+ }
192
+ return { exitCode: 0, output: `${result.output}\n` };
193
+ }
194
+ return { exitCode: 1, output: "unknown skills command: use list or show\n" };
195
+ }
196
+
150
197
  return { exitCode: 1, output: `unknown command: ${cmd}\n` };
151
198
  }
152
199
 
@@ -8,8 +8,13 @@ const { getSafetySection } = require("./safety");
8
8
  const { getOutputEfficiencySection } = require("./efficiency");
9
9
  const { getUfooIntegrationSection } = require("./ufoo");
10
10
  const { getEnvironmentSection } = require("./environment");
11
+ const {
12
+ listUcodeSkills,
13
+ renderSkillsSection,
14
+ } = require("../skills");
11
15
  const {
12
16
  systemPromptSection,
17
+ uncachedSection,
13
18
  resolveSections,
14
19
  clearSectionCache,
15
20
  } = require("./sections");
@@ -68,6 +73,10 @@ function getSystemPrompt({
68
73
  systemPromptSection("environment", () =>
69
74
  getEnvironmentSection({ workspaceRoot, model, provider }),
70
75
  ),
76
+ uncachedSection("skills", () => {
77
+ const outcome = listUcodeSkills({ workspaceRoot: workspaceRoot || process.cwd() });
78
+ return renderSkillsSection(outcome.skills);
79
+ }),
71
80
  ];
72
81
  const dynamicSections = resolveSections(dynamicSectionDefs);
73
82
 
@@ -0,0 +1,74 @@
1
+ const {
2
+ listUcodeSkills,
3
+ findSkillsByName,
4
+ } = require("./loader");
5
+ const {
6
+ renderSkillsSection,
7
+ formatSkillsList,
8
+ } = require("./render");
9
+ const {
10
+ buildSkillInjections,
11
+ } = require("./injection");
12
+
13
+ function showSkill({ name = "", workspaceRoot = process.cwd(), asJson = false } = {}) {
14
+ const outcome = listUcodeSkills({ workspaceRoot });
15
+ const matches = findSkillsByName(outcome.skills, name);
16
+ if (matches.length === 0) {
17
+ return {
18
+ ok: false,
19
+ error: `skill not found: ${name}`,
20
+ outcome,
21
+ };
22
+ }
23
+ if (matches.length > 1) {
24
+ return {
25
+ ok: false,
26
+ error: `skill is ambiguous: ${name}`,
27
+ outcome,
28
+ matches,
29
+ };
30
+ }
31
+ const skill = matches[0];
32
+ let content = "";
33
+ try {
34
+ content = require("fs").readFileSync(skill.path, "utf8");
35
+ } catch (err) {
36
+ return {
37
+ ok: false,
38
+ error: err && err.message ? err.message : "failed to read skill",
39
+ outcome,
40
+ skill,
41
+ };
42
+ }
43
+ if (asJson) {
44
+ return {
45
+ ok: true,
46
+ skill,
47
+ content,
48
+ errors: outcome.errors,
49
+ };
50
+ }
51
+ return {
52
+ ok: true,
53
+ output: [
54
+ `# ${skill.name}`,
55
+ "",
56
+ `Description: ${skill.description}`,
57
+ `Path: ${skill.path}`,
58
+ "",
59
+ content.trim(),
60
+ ].join("\n"),
61
+ skill,
62
+ content,
63
+ errors: outcome.errors,
64
+ };
65
+ }
66
+
67
+ module.exports = {
68
+ listUcodeSkills,
69
+ findSkillsByName,
70
+ renderSkillsSection,
71
+ formatSkillsList,
72
+ buildSkillInjections,
73
+ showSkill,
74
+ };
@@ -0,0 +1,120 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ canonicalPath,
5
+ findSkillsByName,
6
+ listUcodeSkills,
7
+ } = require("./loader");
8
+
9
+ function markdownSkillLinks(prompt = "") {
10
+ const text = String(prompt || "");
11
+ const links = [];
12
+ const re = /\[([^\]]+)]\(([^)]+)\)/g;
13
+ let match;
14
+ while ((match = re.exec(text))) {
15
+ const label = String(match[1] || "").trim();
16
+ const target = String(match[2] || "").trim();
17
+ if (!target) continue;
18
+ if (target.startsWith("skill://") || /(?:^|[/\\])SKILL\.md(?:[#?].*)?$/i.test(target)) {
19
+ links.push({ label, target });
20
+ }
21
+ }
22
+ return links;
23
+ }
24
+
25
+ function mentionedSkillNames(prompt = "") {
26
+ const text = String(prompt || "");
27
+ const names = new Set();
28
+ const re = /(^|[^\w-])\$([A-Za-z0-9][A-Za-z0-9_-]{0,63})\b/g;
29
+ let match;
30
+ while ((match = re.exec(text))) {
31
+ names.add(String(match[2] || "").trim());
32
+ }
33
+ return Array.from(names).filter(Boolean);
34
+ }
35
+
36
+ function resolveSkillLinkTarget(target = "", workspaceRoot = process.cwd()) {
37
+ const raw = String(target || "").trim().replace(/[#?].*$/, "");
38
+ if (!raw) return "";
39
+ let value = raw;
40
+ if (value.startsWith("skill://")) {
41
+ value = value.slice("skill://".length);
42
+ try {
43
+ value = decodeURIComponent(value);
44
+ } catch {
45
+ // keep raw value
46
+ }
47
+ }
48
+ if (!value) return "";
49
+ if (path.isAbsolute(value)) return canonicalPath(value);
50
+ return canonicalPath(path.resolve(workspaceRoot || process.cwd(), value));
51
+ }
52
+
53
+ function findSkillByPath(skills = [], targetPath = "") {
54
+ const target = canonicalPath(targetPath);
55
+ return (Array.isArray(skills) ? skills : []).find((skill) => canonicalPath(skill.path) === target) || null;
56
+ }
57
+
58
+ function readSkillBlock(skill) {
59
+ const content = fs.readFileSync(skill.path, "utf8");
60
+ return `<skill>\n<name>${skill.name}</name>\n<path>${String(skill.path).replace(/\\/g, "/")}</path>\n${content}\n</skill>`;
61
+ }
62
+
63
+ function buildSkillInjections({
64
+ prompt = "",
65
+ workspaceRoot = process.cwd(),
66
+ skillsOutcome = null,
67
+ loadSkills = listUcodeSkills,
68
+ } = {}) {
69
+ const outcome = skillsOutcome || loadSkills({ workspaceRoot });
70
+ const skills = Array.isArray(outcome.skills) ? outcome.skills : [];
71
+ const warnings = Array.isArray(outcome.errors)
72
+ ? outcome.errors.map((err) => `failed to load skill ${err.path}: ${err.message}`)
73
+ : [];
74
+ const selected = new Map();
75
+
76
+ for (const name of mentionedSkillNames(prompt)) {
77
+ const matches = findSkillsByName(skills, name);
78
+ if (matches.length === 1) {
79
+ selected.set(canonicalPath(matches[0].path), matches[0]);
80
+ } else if (matches.length > 1) {
81
+ warnings.push(`skill $${name} is ambiguous; link to a specific SKILL.md path`);
82
+ } else {
83
+ warnings.push(`skill $${name} was not found`);
84
+ }
85
+ }
86
+
87
+ for (const link of markdownSkillLinks(prompt)) {
88
+ const targetPath = resolveSkillLinkTarget(link.target, workspaceRoot);
89
+ const skill = findSkillByPath(skills, targetPath);
90
+ if (skill) {
91
+ selected.set(canonicalPath(skill.path), skill);
92
+ } else {
93
+ warnings.push(`skill link ${link.target} did not resolve to an enabled skill`);
94
+ }
95
+ }
96
+
97
+ const blocks = [];
98
+ for (const skill of selected.values()) {
99
+ try {
100
+ blocks.push(readSkillBlock(skill));
101
+ } catch (err) {
102
+ warnings.push(`failed to read skill ${skill.path}: ${err && err.message ? err.message : "read failed"}`);
103
+ }
104
+ }
105
+
106
+ return {
107
+ blocks,
108
+ warnings,
109
+ skills,
110
+ errors: Array.isArray(outcome.errors) ? outcome.errors : [],
111
+ };
112
+ }
113
+
114
+ module.exports = {
115
+ mentionedSkillNames,
116
+ markdownSkillLinks,
117
+ resolveSkillLinkTarget,
118
+ findSkillByPath,
119
+ buildSkillInjections,
120
+ };
@@ -0,0 +1,218 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const matter = require("gray-matter");
5
+
6
+ const SKILL_FILE = "SKILL.md";
7
+ const DEFAULT_MAX_DEPTH = 6;
8
+
9
+ function repoRootFromHere() {
10
+ return path.resolve(__dirname, "..", "..", "..");
11
+ }
12
+
13
+ function canonicalPath(filePath = "") {
14
+ const resolved = path.resolve(String(filePath || ""));
15
+ try {
16
+ return fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved);
17
+ } catch {
18
+ return resolved;
19
+ }
20
+ }
21
+
22
+ function isDirectory(filePath = "") {
23
+ try {
24
+ return fs.statSync(filePath).isDirectory();
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function isFile(filePath = "") {
31
+ try {
32
+ return fs.statSync(filePath).isFile();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function homeDir(env = process.env) {
39
+ return String((env && env.HOME) || os.homedir() || "").trim();
40
+ }
41
+
42
+ function defaultSkillRoots({
43
+ workspaceRoot = process.cwd(),
44
+ env = process.env,
45
+ repoRoot = repoRootFromHere(),
46
+ } = {}) {
47
+ const workspace = path.resolve(String(workspaceRoot || process.cwd()));
48
+ const home = homeDir(env);
49
+ const codexHome = String((env && env.CODEX_HOME) || "").trim()
50
+ || (home ? path.join(home, ".codex") : "");
51
+ const roots = [
52
+ { path: path.join(workspace, ".agents", "skills"), scope: "repo", source: "workspace-agents" },
53
+ { path: path.join(workspace, ".codex", "skills"), scope: "repo", source: "workspace-codex" },
54
+ ];
55
+
56
+ if (home) {
57
+ roots.push({ path: path.join(home, ".agents", "skills"), scope: "user", source: "user-agents" });
58
+ }
59
+ if (codexHome) {
60
+ roots.push({ path: path.join(codexHome, "skills"), scope: "user", source: "user-codex" });
61
+ roots.push({ path: path.join(codexHome, "skills", ".system"), scope: "system", source: "codex-system" });
62
+ }
63
+
64
+ const root = path.resolve(String(repoRoot || repoRootFromHere()));
65
+ roots.push({ path: path.join(root, "SKILLS"), scope: "builtin", source: "ufoo" });
66
+ const modulesDir = path.join(root, "modules");
67
+ try {
68
+ for (const entry of fs.readdirSync(modulesDir, { withFileTypes: true })) {
69
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
70
+ roots.push({
71
+ path: path.join(modulesDir, entry.name, "SKILLS"),
72
+ scope: "builtin",
73
+ source: `ufoo-module:${entry.name}`,
74
+ });
75
+ }
76
+ } catch {
77
+ // Modules are optional in tests and local installs.
78
+ }
79
+
80
+ const seen = new Set();
81
+ return roots
82
+ .map((rootInfo) => ({ ...rootInfo, path: path.resolve(rootInfo.path) }))
83
+ .filter((rootInfo) => {
84
+ if (seen.has(rootInfo.path)) return false;
85
+ seen.add(rootInfo.path);
86
+ return true;
87
+ });
88
+ }
89
+
90
+ function parseSkillFile(filePath, rootInfo = {}) {
91
+ const skillPath = canonicalPath(filePath);
92
+ const dir = path.dirname(skillPath);
93
+ let parsed;
94
+ try {
95
+ parsed = matter(fs.readFileSync(skillPath, "utf8"));
96
+ } catch (err) {
97
+ throw new Error(err && err.message ? err.message : "failed to read skill");
98
+ }
99
+
100
+ const data = parsed && parsed.data && typeof parsed.data === "object" ? parsed.data : {};
101
+ const name = String(data.name || "").trim();
102
+ const description = String(data.description || "").trim();
103
+ if (!name) throw new Error("missing required frontmatter field: name");
104
+ if (!description) throw new Error("missing required frontmatter field: description");
105
+
106
+ const metadata = data.metadata && typeof data.metadata === "object" ? data.metadata : {};
107
+ const shortDescription = String(
108
+ metadata["short-description"]
109
+ || metadata.shortDescription
110
+ || data["short-description"]
111
+ || data.shortDescription
112
+ || ""
113
+ ).trim();
114
+
115
+ return {
116
+ name,
117
+ description,
118
+ shortDescription,
119
+ path: skillPath,
120
+ dir,
121
+ scope: rootInfo.scope || "repo",
122
+ source: rootInfo.source || "",
123
+ enabled: true,
124
+ };
125
+ }
126
+
127
+ function discoverSkillsUnderRoot(rootInfo = {}, options = {}) {
128
+ const root = path.resolve(String(rootInfo.path || ""));
129
+ const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : DEFAULT_MAX_DEPTH;
130
+ const skills = [];
131
+ const errors = [];
132
+ if (!root || !isDirectory(root)) return { skills, errors };
133
+
134
+ const walk = (dir, depth) => {
135
+ if (depth > maxDepth) return;
136
+ const skillFile = path.join(dir, SKILL_FILE);
137
+ if (isFile(skillFile)) {
138
+ try {
139
+ skills.push(parseSkillFile(skillFile, rootInfo));
140
+ } catch (err) {
141
+ errors.push({
142
+ path: canonicalPath(skillFile),
143
+ message: err && err.message ? err.message : "invalid skill",
144
+ });
145
+ }
146
+ return;
147
+ }
148
+
149
+ let entries = [];
150
+ try {
151
+ entries = fs.readdirSync(dir, { withFileTypes: true });
152
+ } catch (err) {
153
+ errors.push({
154
+ path: canonicalPath(dir),
155
+ message: err && err.message ? err.message : "failed to read directory",
156
+ });
157
+ return;
158
+ }
159
+
160
+ for (const entry of entries) {
161
+ if (!entry.isDirectory()) continue;
162
+ if (entry.name.startsWith(".")) continue;
163
+ walk(path.join(dir, entry.name), depth + 1);
164
+ }
165
+ };
166
+
167
+ walk(root, 0);
168
+ return { skills, errors };
169
+ }
170
+
171
+ function listUcodeSkills(options = {}) {
172
+ const roots = Array.isArray(options.roots)
173
+ ? options.roots
174
+ : defaultSkillRoots(options);
175
+ const seenPaths = new Set();
176
+ const skills = [];
177
+ const errors = [];
178
+
179
+ for (const rootInfo of roots) {
180
+ const discovered = discoverSkillsUnderRoot(rootInfo, options);
181
+ for (const err of discovered.errors) errors.push(err);
182
+ for (const skill of discovered.skills) {
183
+ const key = canonicalPath(skill.path);
184
+ if (seenPaths.has(key)) continue;
185
+ seenPaths.add(key);
186
+ skills.push(skill);
187
+ }
188
+ }
189
+
190
+ const scopeRank = { repo: 0, user: 1, builtin: 2, system: 3, admin: 4 };
191
+ skills.sort((a, b) => {
192
+ const rankA = scopeRank[a.scope] == null ? 9 : scopeRank[a.scope];
193
+ const rankB = scopeRank[b.scope] == null ? 9 : scopeRank[b.scope];
194
+ if (rankA !== rankB) return rankA - rankB;
195
+ if (a.name !== b.name) return a.name.localeCompare(b.name);
196
+ return a.path.localeCompare(b.path);
197
+ });
198
+
199
+ return { skills, errors };
200
+ }
201
+
202
+ function findSkillsByName(skills = [], name = "") {
203
+ const target = String(name || "").trim().toLowerCase();
204
+ if (!target) return [];
205
+ return (Array.isArray(skills) ? skills : [])
206
+ .filter((skill) => skill && skill.enabled !== false && String(skill.name || "").trim().toLowerCase() === target);
207
+ }
208
+
209
+ module.exports = {
210
+ SKILL_FILE,
211
+ DEFAULT_MAX_DEPTH,
212
+ canonicalPath,
213
+ defaultSkillRoots,
214
+ parseSkillFile,
215
+ discoverSkillsUnderRoot,
216
+ listUcodeSkills,
217
+ findSkillsByName,
218
+ };
@@ -0,0 +1,46 @@
1
+ function renderSkillsSection(skills = []) {
2
+ const list = (Array.isArray(skills) ? skills : []).filter((skill) => skill && skill.enabled !== false);
3
+ if (list.length === 0) return "";
4
+
5
+ const lines = [];
6
+ lines.push("## Skills");
7
+ lines.push("ufoo/ucode skills are built-in or local preset workflow capabilities discovered from SKILL.md files. The list below is for discovery and selection; it is not a private capability list for one agent, and the full skill body is loaded only when a user explicitly requests a skill.");
8
+ lines.push("### Available skills");
9
+
10
+ for (const skill of list) {
11
+ const pathText = String(skill.path || "").replace(/\\/g, "/");
12
+ const desc = String(skill.description || skill.shortDescription || "").trim();
13
+ lines.push(`- ${skill.name}: ${desc} (file: ${pathText})`);
14
+ }
15
+
16
+ lines.push("### How to use skills");
17
+ lines.push("- If the user names a skill with `$SkillName` or links directly to a `SKILL.md`, use that skill for this turn.");
18
+ lines.push("- Do not assume a skill applies just because it exists; match the user request to the listed skill descriptions.");
19
+ lines.push("- When a skill is selected, read only the specific skill body and nearby referenced files needed for the task.");
20
+ lines.push("- If a skill is ambiguous, missing, or unreadable, say so briefly and continue with the best fallback.");
21
+
22
+ return lines.join("\n");
23
+ }
24
+
25
+ function formatSkillsList({ skills = [], errors = [] } = {}) {
26
+ const lines = [];
27
+ const list = (Array.isArray(skills) ? skills : []).filter((skill) => skill && skill.enabled !== false);
28
+ lines.push(`Available ufoo/ucode skills and preset workflows: ${list.length}`);
29
+ for (const skill of list) {
30
+ lines.push(`- ${skill.name}: ${skill.description} (${skill.scope}, ${skill.path})`);
31
+ }
32
+ const errs = Array.isArray(errors) ? errors : [];
33
+ if (errs.length > 0) {
34
+ lines.push("");
35
+ lines.push(`Skill load warnings: ${errs.length}`);
36
+ for (const err of errs) {
37
+ lines.push(`- ${err.path}: ${err.message}`);
38
+ }
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ module.exports = {
44
+ renderSkillsSection,
45
+ formatSkillsList,
46
+ };
@@ -739,6 +739,10 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
739
739
  subscriber: null,
740
740
  queueFile: null,
741
741
  pending: new Set(),
742
+ watchedAgents: new Set(),
743
+ lastEventSeq: 0,
744
+ emittedEventKeys: [],
745
+ emittedEventKeySet: new Set(),
742
746
  };
743
747
  const eventBus = new EventBus(projectRoot);
744
748
  let joinInProgress = false;
@@ -758,6 +762,153 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
758
762
  return agentId;
759
763
  }
760
764
 
765
+ function getEventDedupeKey(evt) {
766
+ if (!evt || typeof evt !== "object") return "";
767
+ const seq = Number(evt.seq);
768
+ if (Number.isFinite(seq) && seq > 0) return `seq:${seq}`;
769
+ return [
770
+ "event",
771
+ evt.timestamp || evt.ts || "",
772
+ evt.event || "",
773
+ evt.publisher || "",
774
+ evt.target || "",
775
+ JSON.stringify(evt.data || {}),
776
+ ].join(":");
777
+ }
778
+
779
+ function rememberEmittedEvent(evt) {
780
+ const key = getEventDedupeKey(evt);
781
+ if (!key) return false;
782
+ if (state.emittedEventKeySet.has(key)) return true;
783
+ state.emittedEventKeySet.add(key);
784
+ state.emittedEventKeys.push(key);
785
+ if (state.emittedEventKeys.length > 500) {
786
+ const removed = state.emittedEventKeys.splice(0, state.emittedEventKeys.length - 500);
787
+ for (const item of removed) state.emittedEventKeySet.delete(item);
788
+ }
789
+ return false;
790
+ }
791
+
792
+ function hasPositiveSeq(seq) {
793
+ const value = Number(seq);
794
+ return Number.isFinite(value) && value > 0;
795
+ }
796
+
797
+ function toBridgeEvent(evt) {
798
+ const data = evt.data && typeof evt.data === "object" ? evt.data : {};
799
+ return {
800
+ seq: evt.seq,
801
+ event: evt.event,
802
+ publisher: evt.publisher,
803
+ target: evt.target,
804
+ data,
805
+ message: data.message || "",
806
+ state: data.state || "",
807
+ previous: data.previous || "",
808
+ subscriber: data.subscriber || "",
809
+ source: data.source || "",
810
+ injection_mode: data.injection_mode || "",
811
+ ts: evt.timestamp || evt.ts,
812
+ };
813
+ }
814
+
815
+ function emitBusEvent(evt) {
816
+ if (!evt || !onEvent) return;
817
+ if (rememberEmittedEvent(evt)) return;
818
+ onEvent(toBridgeEvent(evt));
819
+ }
820
+
821
+ function readAgentsData() {
822
+ try {
823
+ const busPath = getUfooPaths(projectRoot).agentsFile;
824
+ return JSON.parse(fs.readFileSync(busPath, "utf8"));
825
+ } catch {
826
+ return {};
827
+ }
828
+ }
829
+
830
+ function buildWatchedAliases() {
831
+ const aliases = new Set();
832
+ const bus = readAgentsData();
833
+ for (const agentId of state.watchedAgents) {
834
+ aliases.add(agentId);
835
+ const meta = bus.agents && bus.agents[agentId];
836
+ if (!meta) continue;
837
+ if (meta.nickname) aliases.add(meta.nickname);
838
+ if (meta.scoped_nickname) aliases.add(meta.scoped_nickname);
839
+ if (meta.display_nickname) aliases.add(meta.display_nickname);
840
+ }
841
+ return aliases;
842
+ }
843
+
844
+ function isWatchedEvent(evt, aliases = buildWatchedAliases()) {
845
+ if (!evt || (evt.event !== "message" && evt.event !== "activity_state_changed")) return false;
846
+ const publisher = String(evt.publisher || "");
847
+ const target = String(evt.target || "");
848
+ const subscriber = evt.data && evt.data.subscriber ? String(evt.data.subscriber) : "";
849
+ return aliases.has(publisher) || aliases.has(target) || aliases.has(subscriber);
850
+ }
851
+
852
+ function getEventFiles() {
853
+ try {
854
+ const dir = getUfooPaths(projectRoot).busEventsDir;
855
+ return fs.readdirSync(dir)
856
+ .filter((name) => name.endsWith(".jsonl"))
857
+ .sort()
858
+ .map((name) => path.join(dir, name));
859
+ } catch {
860
+ return [];
861
+ }
862
+ }
863
+
864
+ function readCurrentSeq() {
865
+ try {
866
+ const raw = fs.readFileSync(path.join(getUfooPaths(projectRoot).busDir, "seq.counter"), "utf8").trim();
867
+ const seq = Number(raw);
868
+ return Number.isFinite(seq) ? seq : 0;
869
+ } catch {
870
+ return 0;
871
+ }
872
+ }
873
+
874
+ function readEventFile(file) {
875
+ try {
876
+ return fs.readFileSync(file, "utf8")
877
+ .split(/\r?\n/)
878
+ .filter(Boolean)
879
+ .map((line) => {
880
+ try {
881
+ return JSON.parse(line);
882
+ } catch {
883
+ return null;
884
+ }
885
+ })
886
+ .filter(Boolean);
887
+ } catch {
888
+ return [];
889
+ }
890
+ }
891
+
892
+ function pollWatchedEvents() {
893
+ if (state.watchedAgents.size === 0) {
894
+ state.lastEventSeq = readCurrentSeq();
895
+ return;
896
+ }
897
+ const aliases = buildWatchedAliases();
898
+ let maxSeq = state.lastEventSeq;
899
+ for (const file of getEventFiles().slice(-2)) {
900
+ for (const evt of readEventFile(file)) {
901
+ const seq = Number(evt.seq);
902
+ if (hasPositiveSeq(seq)) {
903
+ if (seq <= state.lastEventSeq) continue;
904
+ if (seq > maxSeq) maxSeq = seq;
905
+ }
906
+ if (isWatchedEvent(evt, aliases)) emitBusEvent(evt);
907
+ }
908
+ }
909
+ state.lastEventSeq = Math.max(state.lastEventSeq, maxSeq);
910
+ }
911
+
761
912
  function ensureSubscriber() {
762
913
  if (state.subscriber || joinInProgress) return;
763
914
  const debugFile = path.join(getUfooPaths(projectRoot).runDir, "bus-join-debug.txt");
@@ -785,9 +936,7 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
785
936
  })();
786
937
  }
787
938
 
788
- function poll() {
789
- ensureSubscriber();
790
- if (typeof shouldDrain === "function" && !shouldDrain()) return;
939
+ function pollQueue() {
791
940
  if (!state.queueFile) return;
792
941
  if (!fs.existsSync(state.queueFile)) return;
793
942
  let content = "";
@@ -828,15 +977,7 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
828
977
  continue;
829
978
  }
830
979
  if (!evt) continue;
831
- if (onEvent) {
832
- onEvent({
833
- event: evt.event,
834
- publisher: evt.publisher,
835
- target: evt.target,
836
- message: evt.data?.message || "",
837
- ts: evt.timestamp || evt.ts,
838
- });
839
- }
980
+ emitBusEvent(evt);
840
981
  if (evt.publisher && state.pending.has(evt.publisher)) {
841
982
  state.pending.delete(evt.publisher);
842
983
  if (onStatus) {
@@ -847,6 +988,13 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
847
988
  }
848
989
  }
849
990
 
991
+ function poll() {
992
+ ensureSubscriber();
993
+ if (typeof shouldDrain === "function" && !shouldDrain()) return;
994
+ pollQueue();
995
+ pollWatchedEvents();
996
+ }
997
+
850
998
  const interval = setInterval(poll, 1000);
851
999
  return {
852
1000
  markPending(target) {
@@ -865,6 +1013,18 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
865
1013
  } catch {}
866
1014
  return state.subscriber;
867
1015
  },
1016
+ watchAgent(agentId, enabled = true) {
1017
+ if (!agentId) return;
1018
+ if (enabled) {
1019
+ state.watchedAgents.add(agentId);
1020
+ state.lastEventSeq = Math.max(state.lastEventSeq, readCurrentSeq());
1021
+ } else {
1022
+ state.watchedAgents.delete(agentId);
1023
+ if (state.watchedAgents.size === 0) {
1024
+ state.lastEventSeq = readCurrentSeq();
1025
+ }
1026
+ }
1027
+ },
868
1028
  stop() {
869
1029
  clearInterval(interval);
870
1030
  },
@@ -1187,6 +1347,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1187
1347
  }
1188
1348
  return;
1189
1349
  }
1350
+ if (req.type === IPC_REQUEST_TYPES.BUS_WATCH) {
1351
+ const agentId = String(req.agent_id || "").trim();
1352
+ if (agentId) {
1353
+ busBridge.watchAgent(agentId, req.enabled !== false);
1354
+ }
1355
+ return;
1356
+ }
1190
1357
  if (req.type === IPC_REQUEST_TYPES.CRON) {
1191
1358
  if (!daemonCronController) {
1192
1359
  socket.write(
@@ -5,6 +5,7 @@ const IPC_REQUEST_TYPES = {
5
5
  PROMPT: "prompt",
6
6
  CRON: "cron",
7
7
  BUS_SEND: "bus_send",
8
+ BUS_WATCH: "bus_watch",
8
9
  CLOSE_AGENT: "close_agent",
9
10
  LAUNCH_AGENT: "launch_agent",
10
11
  LAUNCH_GROUP: "launch_group",