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.
- package/LICENSE +35 -0
- package/README.md +163 -0
- package/README.zh-CN.md +163 -0
- package/bin/uclaude +65 -0
- package/bin/ucodex +65 -0
- package/bin/ufoo +93 -0
- package/bin/ufoo.js +35 -0
- package/modules/AGENTS.template.md +87 -0
- package/modules/bus/README.md +132 -0
- package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
- package/modules/bus/scripts/bus-alert.sh +185 -0
- package/modules/bus/scripts/bus-listen.sh +117 -0
- package/modules/context/ASSUMPTIONS.md +7 -0
- package/modules/context/CONSTRAINTS.md +7 -0
- package/modules/context/CONTEXT-STRUCTURE.md +49 -0
- package/modules/context/DECISION-PROTOCOL.md +62 -0
- package/modules/context/HANDOFF.md +33 -0
- package/modules/context/README.md +82 -0
- package/modules/context/RULES.md +15 -0
- package/modules/context/SKILLS/README.md +14 -0
- package/modules/context/SKILLS/uctx/SKILL.md +91 -0
- package/modules/context/SYSTEM.md +18 -0
- package/modules/context/TEMPLATES/assumptions.md +4 -0
- package/modules/context/TEMPLATES/constraints.md +4 -0
- package/modules/context/TEMPLATES/decision.md +16 -0
- package/modules/context/TEMPLATES/project-context-readme.md +6 -0
- package/modules/context/TEMPLATES/system.md +3 -0
- package/modules/context/TEMPLATES/terminology.md +4 -0
- package/modules/context/TERMINOLOGY.md +10 -0
- package/modules/resources/ICONS/README.md +12 -0
- package/modules/resources/ICONS/libraries/README.md +17 -0
- package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
- package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
- package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
- package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
- package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
- package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
- package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
- package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
- package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
- package/modules/resources/ICONS/rules.md +7 -0
- package/modules/resources/README.md +9 -0
- package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
- package/modules/resources/UI/TONE.md +6 -0
- package/package.json +40 -0
- package/scripts/banner.sh +89 -0
- package/scripts/bus-alert.sh +6 -0
- package/scripts/bus-autotrigger.sh +6 -0
- package/scripts/bus-daemon.sh +231 -0
- package/scripts/bus-inject.sh +144 -0
- package/scripts/bus-listen.sh +6 -0
- package/scripts/bus.sh +984 -0
- package/scripts/context-decisions.sh +167 -0
- package/scripts/context-doctor.sh +72 -0
- package/scripts/context-lint.sh +110 -0
- package/scripts/doctor.sh +22 -0
- package/scripts/init.sh +247 -0
- package/scripts/skills.sh +113 -0
- package/scripts/status.sh +125 -0
- package/src/agent/cliRunner.js +190 -0
- package/src/agent/internalRunner.js +212 -0
- package/src/agent/normalizeOutput.js +41 -0
- package/src/agent/ufooAgent.js +222 -0
- package/src/chat/index.js +1603 -0
- package/src/cli.js +349 -0
- package/src/config.js +37 -0
- package/src/daemon/index.js +501 -0
- package/src/daemon/ops.js +120 -0
- package/src/daemon/run.js +41 -0
- 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 };
|