u-foo 2.3.19 → 2.3.20

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.20",
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,15 @@ 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 iconLine = (icon = "", text = "") => {
310
+ const pad = " ".repeat(Math.max(0, iconWidth - displayWidth(icon)));
311
+ return `${CLAUDE_ORANGE}${icon}${ANSI_RESET}${pad}${text}`;
312
+ };
303
313
  const lines = [
304
- ` ${iconTop} ${product}v${packageVersion}`,
305
- `${iconMiddle}${detail}`,
306
- ` ${iconBottom} ${projectPath}`,
314
+ iconLine("▐▛███▜▌", `${product}v${packageVersion}`),
315
+ iconLine("▝▜█████▛▘", detail),
316
+ iconLine(" ▘▘▝▝", projectPath),
307
317
  "",
308
318
  ];
309
319
  if (width < 44) return lines;
@@ -464,6 +474,7 @@ function createAgentViewController(options = {}) {
464
474
  function enterAgentView(agentId, options = {}) {
465
475
  if (currentView === "agent" && viewingAgent === agentId) return;
466
476
  if (currentView === "agent") {
477
+ if (agentViewUsesBus && viewingAgent) sendBusWatch(viewingAgent, false);
467
478
  disconnectAgentOutput();
468
479
  disconnectAgentInput();
469
480
  }
@@ -488,6 +499,7 @@ function createAgentViewController(options = {}) {
488
499
  agentInputSuppressUntil = now() + 300;
489
500
  agentViewUsesBus = Boolean(options.useBus);
490
501
  if (agentViewUsesBus) {
502
+ sendBusWatch(agentId, true);
491
503
  resetBusView(agentId);
492
504
  renderBusView();
493
505
  } else {
@@ -511,6 +523,7 @@ function createAgentViewController(options = {}) {
511
523
 
512
524
  disconnectAgentOutput();
513
525
  disconnectAgentInput();
526
+ if (agentViewUsesBus && viewingAgent) sendBusWatch(viewingAgent, false);
514
527
  agentViewUsesBus = false;
515
528
  agentOutputSuppressed = false;
516
529
  agentBarVisible = false;
@@ -536,11 +549,11 @@ function createAgentViewController(options = {}) {
536
549
  setDashboardView("agents");
537
550
  setSelectedAgentIndex(-1);
538
551
  setScreenGrabKeys(false);
539
- forceScreenRepaint();
540
552
  clearTargetAgent();
541
553
  renderDashboard();
542
554
  focusInput();
543
555
  resizeInput();
556
+ forceScreenRepaint();
544
557
  try {
545
558
  if (screen.program && typeof screen.program.showCursor === "function") {
546
559
  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,
@@ -352,6 +354,19 @@ function createDaemonMessageRouter(options = {}) {
352
354
  function handleBusMessage(msg) {
353
355
  const data = msg.data || {};
354
356
  if (data.event === "activity_state_changed") {
357
+ const publisher = data.publisher && data.publisher !== "unknown"
358
+ ? data.publisher
359
+ : (data.subscriber || "bus");
360
+ const viewingAgent = getViewingAgent();
361
+ if (
362
+ getCurrentView() === "agent" &&
363
+ isAgentViewUsesBus() &&
364
+ viewingAgent &&
365
+ isAgentEventForViewingAgent(data, viewingAgent, publisher)
366
+ ) {
367
+ const state = data.state || (data.data && data.data.state) || "";
368
+ if (state) writeToAgentTerm(`[state] ${state}\r\n`);
369
+ }
355
370
  requestStatus();
356
371
  return true;
357
372
  }
@@ -375,11 +390,12 @@ function createDaemonMessageRouter(options = {}) {
375
390
  if (data.silent && !streamPayload) return true;
376
391
  if (!displayMessage && !streamPayload) return true;
377
392
 
393
+ const viewingAgent = getViewingAgent();
378
394
  const isAgentViewTarget =
379
395
  getCurrentView() === "agent" &&
380
396
  isAgentViewUsesBus() &&
381
- getViewingAgent() &&
382
- publisher === getViewingAgent();
397
+ viewingAgent &&
398
+ isAgentEventForViewingAgent(data, viewingAgent, publisher);
383
399
 
384
400
  const displayName = resolveAgentDisplayName(publisher);
385
401
 
package/src/chat/index.js CHANGED
@@ -1581,6 +1581,14 @@ async function runChat(projectRoot, options = {}) {
1581
1581
  source: "chat-internal-agent-view",
1582
1582
  });
1583
1583
  },
1584
+ sendBusWatch: (agentId, enabled) => {
1585
+ if (!agentId) return;
1586
+ send({
1587
+ type: IPC_REQUEST_TYPES.BUS_WATCH,
1588
+ agent_id: agentId,
1589
+ enabled: enabled !== false,
1590
+ });
1591
+ },
1584
1592
  sendResize: (cols, rows) => {
1585
1593
  sendResizeWithCapabilities(cols, rows);
1586
1594
  },
@@ -1612,6 +1620,21 @@ async function runChat(projectRoot, options = {}) {
1612
1620
  getCurrentView: () => getCurrentView(),
1613
1621
  isAgentViewUsesBus: () => isAgentViewUsesBus(),
1614
1622
  getViewingAgent: () => getViewingAgent(),
1623
+ isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
1624
+ if (!viewingAgent) return false;
1625
+ const label = getAgentLabel(viewingAgent);
1626
+ const candidates = [
1627
+ publisher,
1628
+ data && data.publisher,
1629
+ data && data.target,
1630
+ data && data.subscriber,
1631
+ ].filter(Boolean);
1632
+ return candidates.some((value) => (
1633
+ value === viewingAgent ||
1634
+ value === label ||
1635
+ resolveAgentId(value) === viewingAgent
1636
+ ));
1637
+ },
1615
1638
  writeToAgentTerm,
1616
1639
  consumePendingDelivery,
1617
1640
  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,169 @@ 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 emitRecentWatchedEvents(agentId, limit = 80) {
893
+ if (!agentId) return;
894
+ const previous = new Set(state.watchedAgents);
895
+ state.watchedAgents.add(agentId);
896
+ const aliases = buildWatchedAliases();
897
+ state.watchedAgents = previous;
898
+ const matches = [];
899
+ const files = getEventFiles().slice(-3);
900
+ for (const file of files) {
901
+ for (const evt of readEventFile(file)) {
902
+ if (isWatchedEvent(evt, aliases)) matches.push(evt);
903
+ }
904
+ }
905
+ for (const evt of matches.slice(-limit)) emitBusEvent(evt);
906
+ }
907
+
908
+ function pollWatchedEvents() {
909
+ if (state.watchedAgents.size === 0) {
910
+ state.lastEventSeq = readCurrentSeq();
911
+ return;
912
+ }
913
+ const aliases = buildWatchedAliases();
914
+ let maxSeq = state.lastEventSeq;
915
+ for (const file of getEventFiles().slice(-2)) {
916
+ for (const evt of readEventFile(file)) {
917
+ const seq = Number(evt.seq);
918
+ if (hasPositiveSeq(seq)) {
919
+ if (seq <= state.lastEventSeq) continue;
920
+ if (seq > maxSeq) maxSeq = seq;
921
+ }
922
+ if (isWatchedEvent(evt, aliases)) emitBusEvent(evt);
923
+ }
924
+ }
925
+ state.lastEventSeq = Math.max(state.lastEventSeq, maxSeq);
926
+ }
927
+
761
928
  function ensureSubscriber() {
762
929
  if (state.subscriber || joinInProgress) return;
763
930
  const debugFile = path.join(getUfooPaths(projectRoot).runDir, "bus-join-debug.txt");
@@ -785,9 +952,7 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
785
952
  })();
786
953
  }
787
954
 
788
- function poll() {
789
- ensureSubscriber();
790
- if (typeof shouldDrain === "function" && !shouldDrain()) return;
955
+ function pollQueue() {
791
956
  if (!state.queueFile) return;
792
957
  if (!fs.existsSync(state.queueFile)) return;
793
958
  let content = "";
@@ -828,15 +993,7 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
828
993
  continue;
829
994
  }
830
995
  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
- }
996
+ emitBusEvent(evt);
840
997
  if (evt.publisher && state.pending.has(evt.publisher)) {
841
998
  state.pending.delete(evt.publisher);
842
999
  if (onStatus) {
@@ -847,6 +1004,13 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
847
1004
  }
848
1005
  }
849
1006
 
1007
+ function poll() {
1008
+ ensureSubscriber();
1009
+ if (typeof shouldDrain === "function" && !shouldDrain()) return;
1010
+ pollQueue();
1011
+ pollWatchedEvents();
1012
+ }
1013
+
850
1014
  const interval = setInterval(poll, 1000);
851
1015
  return {
852
1016
  markPending(target) {
@@ -865,6 +1029,19 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
865
1029
  } catch {}
866
1030
  return state.subscriber;
867
1031
  },
1032
+ watchAgent(agentId, enabled = true) {
1033
+ if (!agentId) return;
1034
+ if (enabled) {
1035
+ emitRecentWatchedEvents(agentId);
1036
+ state.watchedAgents.add(agentId);
1037
+ state.lastEventSeq = Math.max(state.lastEventSeq, readCurrentSeq());
1038
+ } else {
1039
+ state.watchedAgents.delete(agentId);
1040
+ if (state.watchedAgents.size === 0) {
1041
+ state.lastEventSeq = readCurrentSeq();
1042
+ }
1043
+ }
1044
+ },
868
1045
  stop() {
869
1046
  clearInterval(interval);
870
1047
  },
@@ -1187,6 +1364,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1187
1364
  }
1188
1365
  return;
1189
1366
  }
1367
+ if (req.type === IPC_REQUEST_TYPES.BUS_WATCH) {
1368
+ const agentId = String(req.agent_id || "").trim();
1369
+ if (agentId) {
1370
+ busBridge.watchAgent(agentId, req.enabled !== false);
1371
+ }
1372
+ return;
1373
+ }
1190
1374
  if (req.type === IPC_REQUEST_TYPES.CRON) {
1191
1375
  if (!daemonCronController) {
1192
1376
  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",