u-foo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +163 -0
  3. package/README.zh-CN.md +163 -0
  4. package/bin/uclaude +65 -0
  5. package/bin/ucodex +65 -0
  6. package/bin/ufoo +93 -0
  7. package/bin/ufoo.js +35 -0
  8. package/modules/AGENTS.template.md +87 -0
  9. package/modules/bus/README.md +132 -0
  10. package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
  11. package/modules/bus/scripts/bus-alert.sh +185 -0
  12. package/modules/bus/scripts/bus-listen.sh +117 -0
  13. package/modules/context/ASSUMPTIONS.md +7 -0
  14. package/modules/context/CONSTRAINTS.md +7 -0
  15. package/modules/context/CONTEXT-STRUCTURE.md +49 -0
  16. package/modules/context/DECISION-PROTOCOL.md +62 -0
  17. package/modules/context/HANDOFF.md +33 -0
  18. package/modules/context/README.md +82 -0
  19. package/modules/context/RULES.md +15 -0
  20. package/modules/context/SKILLS/README.md +14 -0
  21. package/modules/context/SKILLS/uctx/SKILL.md +91 -0
  22. package/modules/context/SYSTEM.md +18 -0
  23. package/modules/context/TEMPLATES/assumptions.md +4 -0
  24. package/modules/context/TEMPLATES/constraints.md +4 -0
  25. package/modules/context/TEMPLATES/decision.md +16 -0
  26. package/modules/context/TEMPLATES/project-context-readme.md +6 -0
  27. package/modules/context/TEMPLATES/system.md +3 -0
  28. package/modules/context/TEMPLATES/terminology.md +4 -0
  29. package/modules/context/TERMINOLOGY.md +10 -0
  30. package/modules/resources/ICONS/README.md +12 -0
  31. package/modules/resources/ICONS/libraries/README.md +17 -0
  32. package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
  33. package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
  34. package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
  35. package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
  36. package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
  37. package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
  38. package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
  39. package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
  40. package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
  41. package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
  42. package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
  43. package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
  44. package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
  45. package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
  46. package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
  47. package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
  48. package/modules/resources/ICONS/rules.md +7 -0
  49. package/modules/resources/README.md +9 -0
  50. package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
  51. package/modules/resources/UI/TONE.md +6 -0
  52. package/package.json +40 -0
  53. package/scripts/banner.sh +89 -0
  54. package/scripts/bus-alert.sh +6 -0
  55. package/scripts/bus-autotrigger.sh +6 -0
  56. package/scripts/bus-daemon.sh +231 -0
  57. package/scripts/bus-inject.sh +144 -0
  58. package/scripts/bus-listen.sh +6 -0
  59. package/scripts/bus.sh +984 -0
  60. package/scripts/context-decisions.sh +167 -0
  61. package/scripts/context-doctor.sh +72 -0
  62. package/scripts/context-lint.sh +110 -0
  63. package/scripts/doctor.sh +22 -0
  64. package/scripts/init.sh +247 -0
  65. package/scripts/skills.sh +113 -0
  66. package/scripts/status.sh +125 -0
  67. package/src/agent/cliRunner.js +190 -0
  68. package/src/agent/internalRunner.js +212 -0
  69. package/src/agent/normalizeOutput.js +41 -0
  70. package/src/agent/ufooAgent.js +222 -0
  71. package/src/chat/index.js +1603 -0
  72. package/src/cli.js +349 -0
  73. package/src/config.js +37 -0
  74. package/src/daemon/index.js +501 -0
  75. package/src/daemon/ops.js +120 -0
  76. package/src/daemon/run.js +41 -0
  77. package/src/daemon/status.js +78 -0
@@ -0,0 +1,501 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const net = require("net");
4
+ const { runUfooAgent } = require("../agent/ufooAgent");
5
+ const { spawnAgent, closeAgent } = require("./ops");
6
+ const { buildStatus } = require("./status");
7
+ const { spawnSync } = require("child_process");
8
+
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ async function renameSpawnedAgent(projectRoot, agentType, nickname, startIso) {
14
+ if (!nickname) return null;
15
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
16
+ const script = path.join(projectRoot, "scripts", "bus.sh");
17
+ const targetType = agentType === "codex" ? "codex" : "claude-code";
18
+ const deadline = Date.now() + 10000;
19
+ while (Date.now() < deadline) {
20
+ try {
21
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
22
+ let entries = Object.entries(bus.subscribers || {})
23
+ .filter(([, meta]) => meta && meta.agent_type === targetType && meta.status === "active");
24
+ if (startIso) {
25
+ entries = entries.filter(([, meta]) => (meta.joined_at || "") >= startIso);
26
+ }
27
+ if (entries.length === 0) {
28
+ await sleep(200);
29
+ continue;
30
+ }
31
+ let candidates = entries.filter(([, meta]) => !meta.nickname);
32
+ if (candidates.length === 0) candidates = entries;
33
+ candidates.sort((a, b) => (a[1].joined_at || "").localeCompare(b[1].joined_at || ""));
34
+ const [agentId] = candidates[candidates.length - 1];
35
+ const res = spawnSync("bash", [script, "rename", agentId, nickname], { cwd: projectRoot });
36
+ if (res.status === 0) return { ok: true, agent_id: agentId, nickname };
37
+ const err = (res.stderr || res.stdout || "").toString("utf8").trim();
38
+ return { ok: false, agent_id: agentId, nickname, error: err || "rename failed" };
39
+ } catch {
40
+ // ignore and retry
41
+ }
42
+ await sleep(200);
43
+ }
44
+ return { ok: false, nickname, error: "rename timeout" };
45
+ }
46
+
47
+ function ensureDir(dir) {
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ }
50
+
51
+ function socketPath(projectRoot) {
52
+ return path.join(projectRoot, ".ufoo", "run", "ufoo.sock");
53
+ }
54
+
55
+ function pidPath(projectRoot) {
56
+ return path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.pid");
57
+ }
58
+
59
+ function logPath(projectRoot) {
60
+ return path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.log");
61
+ }
62
+
63
+ function writePid(projectRoot) {
64
+ fs.writeFileSync(pidPath(projectRoot), String(process.pid));
65
+ }
66
+
67
+ function readPid(projectRoot) {
68
+ try {
69
+ return parseInt(fs.readFileSync(pidPath(projectRoot), "utf8"), 10);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function isRunning(projectRoot) {
76
+ const pid = readPid(projectRoot);
77
+ if (!pid) return false;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch {
82
+ try {
83
+ fs.unlinkSync(pidPath(projectRoot));
84
+ } catch {
85
+ // ignore
86
+ }
87
+ removeSocket(projectRoot);
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function removeSocket(projectRoot) {
93
+ const sock = socketPath(projectRoot);
94
+ if (fs.existsSync(sock)) fs.unlinkSync(sock);
95
+ }
96
+
97
+ function parseJsonLines(buffer) {
98
+ const lines = buffer.split(/\r?\n/).filter(Boolean);
99
+ const items = [];
100
+ for (const line of lines) {
101
+ try {
102
+ items.push(JSON.parse(line));
103
+ } catch {
104
+ // ignore
105
+ }
106
+ }
107
+ return items;
108
+ }
109
+
110
+ function readBus(projectRoot) {
111
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
112
+ try {
113
+ return JSON.parse(fs.readFileSync(busPath, "utf8"));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function listSubscribers(projectRoot, agentType) {
120
+ const bus = readBus(projectRoot);
121
+ if (!bus) return [];
122
+ return Object.entries(bus.subscribers || {})
123
+ .filter(([, meta]) => meta && meta.agent_type === agentType)
124
+ .map(([id]) => id);
125
+ }
126
+
127
+ async function waitForNewSubscriber(projectRoot, agentType, existing, timeoutMs = 5000) {
128
+ const start = Date.now();
129
+ while (Date.now() - start < timeoutMs) {
130
+ const current = listSubscribers(projectRoot, agentType);
131
+ const diff = current.find((id) => !existing.includes(id));
132
+ if (diff) return diff;
133
+ // eslint-disable-next-line no-await-in-loop
134
+ await new Promise((r) => setTimeout(r, 200));
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function renameSubscriber(projectRoot, subscriberId, nickname) {
140
+ const script = path.join(projectRoot, "scripts", "bus.sh");
141
+ const res = spawnSync("bash", [script, "rename", subscriberId, nickname], { cwd: projectRoot });
142
+ return res.status === 0;
143
+ }
144
+
145
+ function checkAndCleanupNickname(projectRoot, nickname) {
146
+ if (!nickname) return { existing: null, cleaned: false };
147
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
148
+ try {
149
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
150
+ const entries = Object.entries(bus.subscribers || {})
151
+ .filter(([, meta]) => meta && meta.nickname === nickname);
152
+
153
+ if (entries.length === 0) {
154
+ return { existing: null, cleaned: false };
155
+ }
156
+
157
+ // Check for active agent with same nickname
158
+ const activeAgent = entries.find(([, meta]) => meta.status === "active");
159
+ if (activeAgent) {
160
+ return { existing: activeAgent[0], cleaned: false };
161
+ }
162
+
163
+ // Clean up offline agents with same nickname
164
+ for (const [agentId] of entries) {
165
+ delete bus.subscribers[agentId];
166
+ }
167
+ fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
168
+ return { existing: null, cleaned: true };
169
+ } catch {
170
+ return { existing: null, cleaned: false };
171
+ }
172
+ }
173
+
174
+ async function handleOps(projectRoot, ops = []) {
175
+ const results = [];
176
+ for (const op of ops) {
177
+ if (op.action === "spawn") {
178
+ const count = op.count || 1;
179
+ const agent = op.agent === "codex" ? "codex" : "claude";
180
+ const nickname = op.nickname || "";
181
+ const startTime = new Date(Date.now() - 1000);
182
+ const startIso = startTime.toISOString();
183
+ if (nickname && count > 1) {
184
+ results.push({
185
+ action: "spawn",
186
+ ok: false,
187
+ agent,
188
+ count,
189
+ error: "nickname requires count=1",
190
+ });
191
+ continue;
192
+ }
193
+ try {
194
+ // Check for existing agent with same nickname
195
+ const { existing, cleaned } = checkAndCleanupNickname(projectRoot, nickname);
196
+ if (existing) {
197
+ // Agent with this nickname already exists and is active
198
+ results.push({
199
+ action: "spawn",
200
+ ok: true,
201
+ agent,
202
+ count,
203
+ nickname: nickname || undefined,
204
+ agent_id: existing,
205
+ skipped: true,
206
+ message: `Agent '${nickname}' already exists`,
207
+ });
208
+ continue;
209
+ }
210
+ // eslint-disable-next-line no-await-in-loop
211
+ await spawnAgent(projectRoot, agent, count, nickname);
212
+ results.push({ action: "spawn", ok: true, agent, count, nickname: nickname || undefined });
213
+ if (nickname) {
214
+ // eslint-disable-next-line no-await-in-loop
215
+ const renameResult = await renameSpawnedAgent(projectRoot, agent, nickname, startIso);
216
+ if (renameResult) {
217
+ results.push({ action: "rename", ...renameResult });
218
+ }
219
+ }
220
+ } catch (err) {
221
+ results.push({ action: "spawn", ok: false, agent, count, error: err.message });
222
+ }
223
+ } else if (op.action === "close") {
224
+ const ok = await closeAgent(projectRoot, op.agent_id);
225
+ results.push({ action: "close", ok, agent_id: op.agent_id });
226
+ }
227
+ }
228
+ return results;
229
+ }
230
+
231
+ function dispatchMessages(projectRoot, dispatch = [], daemonSubscriber = null) {
232
+ const script = path.join(projectRoot, "scripts", "bus.sh");
233
+ const defaultPublisher = daemonSubscriber || "ufoo-agent";
234
+ const env = { ...process.env, AI_BUS_PUBLISHER: defaultPublisher };
235
+ for (const item of dispatch) {
236
+ if (!item || !item.target || !item.message) continue;
237
+ const pub = item.publisher || defaultPublisher;
238
+ env.AI_BUS_PUBLISHER = pub;
239
+ if (item.target === "broadcast") {
240
+ spawnSync("bash", [script, "broadcast", item.message], { env, cwd: projectRoot });
241
+ } else {
242
+ spawnSync("bash", [script, "send", item.target, item.message], { env, cwd: projectRoot });
243
+ }
244
+ }
245
+ }
246
+
247
+ function startBusBridge(projectRoot, onEvent, onStatus) {
248
+ const script = path.join(projectRoot, "scripts", "bus.sh");
249
+ const state = {
250
+ subscriber: null,
251
+ queueFile: null,
252
+ pending: new Set(),
253
+ };
254
+
255
+ function ensureSubscriber() {
256
+ if (state.subscriber) return;
257
+ const debugFile = path.join(projectRoot, ".ufoo", "run", "bus-join-debug.txt");
258
+ try {
259
+ fs.writeFileSync(debugFile, `Attempting join at ${new Date().toISOString()}\n`, { flag: "a" });
260
+ // Clear session env vars so join creates a new session
261
+ const env = { ...process.env, CLAUDE_SESSION_ID: "", CODEX_SESSION_ID: "" };
262
+ const res = spawnSync("bash", [script, "join"], { cwd: projectRoot, env });
263
+ if (res.status !== 0) {
264
+ const errMsg = (res.stderr || res.stdout || "").toString("utf8");
265
+ fs.writeFileSync(debugFile, `Join failed: ${errMsg}\n`, { flag: "a" });
266
+ return;
267
+ }
268
+ const out = (res.stdout || "").toString("utf8").trim();
269
+ const sub = out.split(/\r?\n/).pop();
270
+ if (!sub) {
271
+ fs.writeFileSync(debugFile, `Join returned empty subscriber\n`, { flag: "a" });
272
+ return;
273
+ }
274
+ state.subscriber = sub;
275
+ const safe = sub.replace(/:/g, "_");
276
+ state.queueFile = path.join(projectRoot, ".ufoo", "bus", "queues", safe, "pending.jsonl");
277
+ fs.writeFileSync(debugFile, `Successfully joined as ${sub}\n`, { flag: "a" });
278
+ } catch (err) {
279
+ fs.writeFileSync(debugFile, `Exception: ${err.message || err}\n`, { flag: "a" });
280
+ }
281
+ }
282
+
283
+ function poll() {
284
+ ensureSubscriber();
285
+ if (!state.queueFile) return;
286
+ if (!fs.existsSync(state.queueFile)) return;
287
+ let content;
288
+ try {
289
+ content = fs.readFileSync(state.queueFile, "utf8");
290
+ } catch {
291
+ return;
292
+ }
293
+ const lines = content.split(/\r?\n/).filter(Boolean);
294
+ if (!lines.length) return;
295
+ for (const line of lines) {
296
+ let evt;
297
+ try {
298
+ evt = JSON.parse(line);
299
+ } catch {
300
+ continue;
301
+ }
302
+ if (!evt) continue;
303
+ if (onEvent) {
304
+ onEvent({
305
+ event: evt.event,
306
+ publisher: evt.publisher,
307
+ target: evt.target,
308
+ message: evt.data?.message || "",
309
+ ts: evt.ts,
310
+ });
311
+ }
312
+ if (evt.publisher && state.pending.has(evt.publisher)) {
313
+ state.pending.delete(evt.publisher);
314
+ if (onStatus) {
315
+ onStatus({ phase: "done", text: `${evt.publisher} done`, key: evt.publisher });
316
+ }
317
+ }
318
+ }
319
+ try {
320
+ fs.truncateSync(state.queueFile, 0);
321
+ } catch {
322
+ // ignore
323
+ }
324
+ }
325
+
326
+ const interval = setInterval(poll, 1000);
327
+ return {
328
+ markPending(target) {
329
+ if (!target) return;
330
+ state.pending.add(target);
331
+ if (onStatus) {
332
+ onStatus({ phase: "start", text: `${target} processing`, key: target });
333
+ }
334
+ },
335
+ getSubscriber() {
336
+ ensureSubscriber();
337
+ try {
338
+ fs.writeFileSync(path.join(projectRoot, ".ufoo", "run", "bridge-debug.txt"),
339
+ `subscriber: ${state.subscriber || "NULL"}\nqueue: ${state.queueFile || "NULL"}\n`);
340
+ } catch {}
341
+ return state.subscriber;
342
+ },
343
+ stop() {
344
+ clearInterval(interval);
345
+ },
346
+ };
347
+ }
348
+
349
+ function startDaemon({ projectRoot, provider, model }) {
350
+ if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
351
+ throw new Error("Missing .ufoo. Run: ufoo init");
352
+ }
353
+
354
+ const runDir = path.join(projectRoot, ".ufoo", "run");
355
+ ensureDir(runDir);
356
+ removeSocket(projectRoot);
357
+ writePid(projectRoot);
358
+
359
+ const logFile = fs.createWriteStream(logPath(projectRoot), { flags: "a" });
360
+ const log = (msg) => {
361
+ logFile.write(`[daemon] ${new Date().toISOString()} ${msg}\n`);
362
+ };
363
+
364
+ const sockets = new Set();
365
+ const sendToSockets = (payload) => {
366
+ const line = `${JSON.stringify(payload)}\n`;
367
+ for (const sock of sockets) {
368
+ if (!sock || sock.destroyed) continue;
369
+ try {
370
+ sock.write(line);
371
+ } catch {
372
+ // ignore write errors
373
+ }
374
+ }
375
+ };
376
+
377
+ const busBridge = startBusBridge(projectRoot, (evt) => {
378
+ sendToSockets({ type: "bus", data: evt });
379
+ }, (status) => {
380
+ sendToSockets({ type: "status", data: status });
381
+ });
382
+
383
+ const server = net.createServer((socket) => {
384
+ sockets.add(socket);
385
+ socket.on("close", () => sockets.delete(socket));
386
+ let buffer = "";
387
+ socket.on("data", async (data) => {
388
+ buffer += data.toString("utf8");
389
+ const lines = buffer.split(/\r?\n/);
390
+ buffer = lines.pop() || "";
391
+ const complete = lines.filter((l) => l.trim());
392
+ for (const line of complete) {
393
+ const items = parseJsonLines(line);
394
+ for (const req of items) {
395
+ if (!req || typeof req !== "object") continue;
396
+ if (req.type === "status") {
397
+ const status = buildStatus(projectRoot);
398
+ socket.write(`${JSON.stringify({ type: "status", data: status })}\n`);
399
+ continue;
400
+ }
401
+ if (req.type === "prompt") {
402
+ log(`prompt ${String(req.text || "").slice(0, 200)}`);
403
+ let result;
404
+ try {
405
+ result = await runUfooAgent({
406
+ projectRoot,
407
+ prompt: req.text || "",
408
+ provider,
409
+ model,
410
+ });
411
+ } catch (err) {
412
+ log(`error ${err.message || String(err)}`);
413
+ socket.write(
414
+ `${JSON.stringify({
415
+ type: "error",
416
+ error: err.message || String(err),
417
+ })}\n`,
418
+ );
419
+ continue;
420
+ }
421
+ if (!result.ok) {
422
+ log(`agent-fail ${result.error || "agent failed"}`);
423
+ socket.write(
424
+ `${JSON.stringify({ type: "error", error: result.error || "agent failed" })}\n`,
425
+ );
426
+ continue;
427
+ }
428
+ for (const item of result.payload.dispatch || []) {
429
+ if (item && item.target && item.target !== "broadcast") {
430
+ busBridge.markPending(item.target);
431
+ }
432
+ }
433
+ dispatchMessages(projectRoot, result.payload.dispatch || [], busBridge.getSubscriber());
434
+ const opsResults = await handleOps(projectRoot, result.payload.ops || []);
435
+ log(`ok reply=${Boolean(result.payload.reply)} dispatch=${(result.payload.dispatch || []).length} ops=${(result.payload.ops || []).length}`);
436
+ socket.write(
437
+ `${JSON.stringify({
438
+ type: "response",
439
+ data: result.payload,
440
+ opsResults,
441
+ })}\n`,
442
+ );
443
+ }
444
+ }
445
+ }
446
+ });
447
+ });
448
+
449
+ server.listen(socketPath(projectRoot));
450
+ log(`Started pid=${process.pid}`);
451
+
452
+ process.on("exit", () => {
453
+ busBridge.stop();
454
+ removeSocket(projectRoot);
455
+ });
456
+ process.on("SIGTERM", () => {
457
+ busBridge.stop();
458
+ removeSocket(projectRoot);
459
+ process.exit(0);
460
+ });
461
+ }
462
+
463
+ function stopDaemon(projectRoot) {
464
+ const pid = readPid(projectRoot);
465
+ if (!pid) {
466
+ removeSocket(projectRoot);
467
+ return false;
468
+ }
469
+ let killed = false;
470
+ try {
471
+ process.kill(pid, "SIGTERM");
472
+ const started = Date.now();
473
+ while (Date.now() - started < 1500) {
474
+ try {
475
+ process.kill(pid, 0);
476
+ } catch {
477
+ killed = true;
478
+ break;
479
+ }
480
+ }
481
+ // Force kill if still alive.
482
+ try {
483
+ process.kill(pid, 0);
484
+ process.kill(pid, "SIGKILL");
485
+ killed = true;
486
+ } catch {
487
+ // ignore if already dead
488
+ }
489
+ } catch {
490
+ // ignore kill errors (e.g., already dead)
491
+ }
492
+ try {
493
+ fs.unlinkSync(pidPath(projectRoot));
494
+ } catch {
495
+ // ignore
496
+ }
497
+ removeSocket(projectRoot);
498
+ return killed;
499
+ }
500
+
501
+ module.exports = { startDaemon, stopDaemon, isRunning, socketPath };
@@ -0,0 +1,120 @@
1
+ const { spawn } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { loadConfig } = require("../config");
5
+
6
+ function resolveAgentId(projectRoot, agentId) {
7
+ if (!agentId) return agentId;
8
+ if (agentId.includes(":")) return agentId;
9
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
10
+ try {
11
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
12
+ const entries = Object.entries(bus.subscribers || {});
13
+ const match = entries.find(([, meta]) => meta?.nickname === agentId);
14
+ if (match) return match[0];
15
+ const targetType = agentId === "claude" ? "claude-code" : agentId;
16
+ const candidates = entries
17
+ .filter(([, meta]) => meta?.agent_type === targetType && meta?.status === "active")
18
+ .map(([id]) => id);
19
+ if (candidates.length === 1) return candidates[0];
20
+ } catch {
21
+ // ignore
22
+ }
23
+ return agentId;
24
+ }
25
+
26
+ function runAppleScript(lines) {
27
+ return new Promise((resolve, reject) => {
28
+ const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
29
+ let stderr = "";
30
+ proc.stderr.on("data", (d) => {
31
+ stderr += d.toString("utf8");
32
+ });
33
+ proc.on("close", (code) => {
34
+ if (code === 0) resolve();
35
+ else reject(new Error(stderr || "osascript failed"));
36
+ });
37
+ });
38
+ }
39
+
40
+ function escapeCommand(cmd) {
41
+ return cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
42
+ }
43
+
44
+ function shellEscape(value) {
45
+ const str = String(value);
46
+ return `'${str.replace(/'/g, `'\\''`)}'`;
47
+ }
48
+
49
+ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "") {
50
+ const runner = path.join(projectRoot, "bin", "ufoo.js");
51
+ const logDir = path.join(projectRoot, ".ufoo", "run");
52
+ const logFile = path.join(logDir, `agent-${agent}-${Date.now()}.log`);
53
+ const errLog = fs.openSync(logFile, "a");
54
+ for (let i = 0; i < count; i += 1) {
55
+ const child = spawn(process.execPath, [runner, "agent-runner", agent], {
56
+ detached: true,
57
+ stdio: ["ignore", errLog, errLog],
58
+ cwd: projectRoot,
59
+ env: { ...process.env, UFOO_INTERNAL_AGENT: "1", UFOO_NICKNAME: nickname || "" },
60
+ });
61
+ child.unref();
62
+ }
63
+ setTimeout(() => {
64
+ try {
65
+ fs.closeSync(errLog);
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }, 1000);
70
+ }
71
+
72
+ async function spawnAgent(projectRoot, agent, count = 1, nickname = "") {
73
+ const config = loadConfig(projectRoot);
74
+ if (config.launchMode === "internal") {
75
+ await spawnInternalAgent(projectRoot, agent, count, nickname);
76
+ return;
77
+ }
78
+ if (process.platform !== "darwin") {
79
+ throw new Error("spawnAgent is only supported on macOS Terminal.app");
80
+ }
81
+ const binary = agent === "codex" ? "ucodex" : "uclaude";
82
+ const cwdCmd = `cd "${projectRoot}"`;
83
+ const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
84
+ const runCmd = `${cwdCmd} && ${nickEnv}${binary}`;
85
+ const script = [
86
+ 'tell application "Terminal"',
87
+ `do script "${escapeCommand(runCmd)}"`,
88
+ "activate",
89
+ "end tell",
90
+ ];
91
+ for (let i = 0; i < count; i += 1) {
92
+ // eslint-disable-next-line no-await-in-loop
93
+ await runAppleScript(script);
94
+ }
95
+ }
96
+
97
+ async function closeAgent(projectRoot, agentId) {
98
+ if (process.platform !== "darwin") {
99
+ return false;
100
+ }
101
+ const resolvedId = resolveAgentId(projectRoot, agentId);
102
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
103
+ let pid = null;
104
+ try {
105
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
106
+ const entry = bus.subscribers?.[resolvedId];
107
+ if (entry && entry.pid) pid = entry.pid;
108
+ } catch {
109
+ pid = null;
110
+ }
111
+ if (!pid) return false;
112
+ try {
113
+ process.kill(pid, "SIGTERM");
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ module.exports = { spawnAgent, closeAgent };
@@ -0,0 +1,41 @@
1
+ const path = require("path");
2
+ const { startDaemon, stopDaemon, isRunning } = require("./index");
3
+
4
+ function runDaemonCli(argv) {
5
+ const cmd = argv[1] || "start";
6
+ const projectRoot = process.cwd();
7
+ const provider = process.env.UFOO_AGENT_PROVIDER || "codex-cli";
8
+ const model =
9
+ process.env.UFOO_AGENT_MODEL || (provider === "claude-cli" ? "opus" : "");
10
+
11
+ if (cmd === "start" || cmd === "--start") {
12
+ if (isRunning(projectRoot)) return;
13
+ if (!process.env.UFOO_DAEMON_CHILD) {
14
+ const { spawn } = require("child_process");
15
+ const child = spawn(process.execPath, [path.join(__dirname, "..", "..", "bin", "ufoo.js"), "daemon", "start"], {
16
+ detached: true,
17
+ stdio: "ignore",
18
+ env: { ...process.env, UFOO_DAEMON_CHILD: "1" },
19
+ cwd: projectRoot,
20
+ });
21
+ child.unref();
22
+ return;
23
+ }
24
+ startDaemon({ projectRoot, provider, model });
25
+ return;
26
+ }
27
+ if (cmd === "stop" || cmd === "--stop") {
28
+ stopDaemon(projectRoot);
29
+ return;
30
+ }
31
+ if (cmd === "status" || cmd === "--status") {
32
+ const running = isRunning(projectRoot);
33
+ // eslint-disable-next-line no-console
34
+ console.log(running ? "running" : "stopped");
35
+ return;
36
+ }
37
+
38
+ throw new Error(`Unknown daemon command: ${cmd}`);
39
+ }
40
+
41
+ module.exports = { runDaemonCli };