u-foo 1.0.6 → 1.1.9
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 +44 -4
- package/SKILLS/ufoo/SKILL.md +17 -2
- package/SKILLS/uinit/SKILL.md +8 -3
- package/bin/ucode-core.js +15 -0
- package/bin/ucode.js +125 -0
- package/bin/ufoo-assistant-agent.js +5 -0
- package/bin/ufoo-engine.js +25 -0
- package/bin/ufoo.js +4 -0
- package/modules/AGENTS.template.md +14 -4
- package/modules/bus/README.md +8 -5
- package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
- package/modules/context/SKILLS/uctx/SKILL.md +3 -1
- package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
- package/package.json +12 -3
- package/scripts/import-pi-mono.js +124 -0
- package/scripts/postinstall.js +20 -49
- package/scripts/sync-claude-skills.sh +21 -0
- package/src/agent/cliRunner.js +524 -31
- package/src/agent/internalRunner.js +76 -9
- package/src/agent/launcher.js +97 -45
- package/src/agent/normalizeOutput.js +1 -1
- package/src/agent/notifier.js +144 -4
- package/src/agent/ptyRunner.js +480 -10
- package/src/agent/ptyWrapper.js +28 -3
- package/src/agent/readyDetector.js +16 -0
- package/src/agent/ucode.js +443 -0
- package/src/agent/ucodeBootstrap.js +113 -0
- package/src/agent/ucodeBuild.js +67 -0
- package/src/agent/ucodeDoctor.js +184 -0
- package/src/agent/ucodeRuntimeConfig.js +129 -0
- package/src/agent/ufooAgent.js +11 -2
- package/src/assistant/agent.js +260 -0
- package/src/assistant/bridge.js +172 -0
- package/src/assistant/engine.js +252 -0
- package/src/assistant/stdio.js +58 -0
- package/src/assistant/ufooEngineCli.js +306 -0
- package/src/bus/activate.js +27 -11
- package/src/bus/daemon.js +133 -5
- package/src/bus/index.js +137 -80
- package/src/bus/inject.js +47 -17
- package/src/bus/message.js +145 -17
- package/src/bus/nickname.js +3 -1
- package/src/bus/queue.js +6 -1
- package/src/bus/store.js +189 -0
- package/src/bus/subscriber.js +20 -4
- package/src/bus/utils.js +9 -3
- package/src/chat/agentBar.js +117 -0
- package/src/chat/agentDirectory.js +88 -0
- package/src/chat/agentSockets.js +225 -0
- package/src/chat/agentViewController.js +298 -0
- package/src/chat/chatLogController.js +115 -0
- package/src/chat/commandExecutor.js +700 -0
- package/src/chat/commands.js +132 -0
- package/src/chat/completionController.js +414 -0
- package/src/chat/cronScheduler.js +160 -0
- package/src/chat/daemonConnection.js +166 -0
- package/src/chat/daemonCoordinator.js +64 -0
- package/src/chat/daemonMessageRouter.js +257 -0
- package/src/chat/daemonReconnect.js +41 -0
- package/src/chat/daemonTransport.js +36 -0
- package/src/chat/daemonTransportDefaults.js +10 -0
- package/src/chat/dashboardKeyController.js +480 -0
- package/src/chat/dashboardView.js +154 -0
- package/src/chat/index.js +935 -2909
- package/src/chat/inputHistoryController.js +105 -0
- package/src/chat/inputListenerController.js +304 -0
- package/src/chat/inputMath.js +104 -0
- package/src/chat/inputSubmitHandler.js +171 -0
- package/src/chat/layout.js +165 -0
- package/src/chat/pasteController.js +81 -0
- package/src/chat/rawKeyMap.js +42 -0
- package/src/chat/settingsController.js +132 -0
- package/src/chat/statusLineController.js +177 -0
- package/src/chat/streamTracker.js +138 -0
- package/src/chat/text.js +70 -0
- package/src/chat/transport.js +61 -0
- package/src/cli/busCoreCommands.js +59 -0
- package/src/cli/ctxCoreCommands.js +199 -0
- package/src/cli/onlineCoreCommands.js +379 -0
- package/src/cli.js +741 -238
- package/src/code/README.md +29 -0
- package/src/code/UCODE_PROMPT.md +32 -0
- package/src/code/agent.js +1651 -0
- package/src/code/cli.js +158 -0
- package/src/code/config +0 -0
- package/src/code/dispatch.js +42 -0
- package/src/code/index.js +70 -0
- package/src/code/nativeRunner.js +1213 -0
- package/src/code/runtime.js +154 -0
- package/src/code/sessionStore.js +162 -0
- package/src/code/taskDecomposer.js +269 -0
- package/src/code/tools/bash.js +53 -0
- package/src/code/tools/common.js +42 -0
- package/src/code/tools/edit.js +70 -0
- package/src/code/tools/read.js +44 -0
- package/src/code/tools/write.js +35 -0
- package/src/code/tui.js +1580 -0
- package/src/config.js +47 -1
- package/src/context/decisions.js +12 -2
- package/src/context/index.js +18 -1
- package/src/context/sync.js +127 -0
- package/src/daemon/agentProcessManager.js +74 -0
- package/src/daemon/cronOps.js +241 -0
- package/src/daemon/index.js +661 -488
- package/src/daemon/ipcServer.js +99 -0
- package/src/daemon/ops.js +417 -179
- package/src/daemon/promptLoop.js +319 -0
- package/src/daemon/promptRequest.js +101 -0
- package/src/daemon/providerSessions.js +32 -17
- package/src/daemon/reporting.js +90 -0
- package/src/daemon/run.js +2 -5
- package/src/daemon/status.js +24 -1
- package/src/init/index.js +68 -14
- package/src/online/bridge.js +663 -0
- package/src/online/client.js +245 -0
- package/src/online/runner.js +253 -0
- package/src/online/server.js +992 -0
- package/src/online/tokens.js +103 -0
- package/src/report/store.js +331 -0
- package/src/shared/eventContract.js +35 -0
- package/src/shared/ptySocketContract.js +21 -0
- package/src/status/index.js +50 -17
- package/src/terminal/adapterContract.js +87 -0
- package/src/terminal/adapterRouter.js +84 -0
- package/src/terminal/adapters/externalAdapter.js +14 -0
- package/src/terminal/adapters/internalAdapter.js +13 -0
- package/src/terminal/adapters/internalPtyAdapter.js +42 -0
- package/src/terminal/adapters/internalQueueAdapter.js +37 -0
- package/src/terminal/adapters/terminalAdapter.js +31 -0
- package/src/terminal/adapters/tmuxAdapter.js +30 -0
- package/src/ufoo/agentsStore.js +69 -3
- package/src/utils/banner.js +5 -2
- package/scripts/.archived/bash-to-js-migration/README.md +0 -46
- package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
- package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
- package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
- package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
- package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
- package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
- package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
- package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
- package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
- package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
- package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
- package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
- package/scripts/banner.sh +0 -2
- package/src/bus/API_DESIGN.md +0 -204
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const OnlineClient = require("./client");
|
|
5
|
+
const {
|
|
6
|
+
generateToken,
|
|
7
|
+
getToken,
|
|
8
|
+
getTokenByNickname,
|
|
9
|
+
setToken,
|
|
10
|
+
defaultTokensPath,
|
|
11
|
+
} = require("./tokens");
|
|
12
|
+
|
|
13
|
+
// --- State persistence (for bus/decisions sync) ---
|
|
14
|
+
|
|
15
|
+
function defaultState() {
|
|
16
|
+
return {
|
|
17
|
+
last_seq: 0,
|
|
18
|
+
synced_decisions: {},
|
|
19
|
+
synced_order: [],
|
|
20
|
+
last_decision_by_nick: {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeState(state) {
|
|
25
|
+
const merged = { ...defaultState(), ...(state || {}) };
|
|
26
|
+
if (!merged.synced_decisions) merged.synced_decisions = {};
|
|
27
|
+
if (!Array.isArray(merged.synced_order)) merged.synced_order = [];
|
|
28
|
+
if (!merged.last_decision_by_nick) merged.last_decision_by_nick = {};
|
|
29
|
+
return merged;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readState(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
return normalizeState(JSON.parse(raw));
|
|
36
|
+
} catch {
|
|
37
|
+
return normalizeState(null);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeState(filePath, state) {
|
|
42
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
|
+
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function markSyncedDecision(state, id) {
|
|
47
|
+
if (!id) return;
|
|
48
|
+
if (state.synced_decisions[id]) return;
|
|
49
|
+
state.synced_decisions[id] = Date.now();
|
|
50
|
+
state.synced_order.push(id);
|
|
51
|
+
if (state.synced_order.length > 500) {
|
|
52
|
+
const remove = state.synced_order.splice(0, state.synced_order.length - 500);
|
|
53
|
+
remove.forEach((rid) => {
|
|
54
|
+
delete state.synced_decisions[rid];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseDecisionIdFromFile(fileName) {
|
|
60
|
+
if (!fileName) return { id: "" };
|
|
61
|
+
const base = fileName.endsWith(".md") ? fileName.slice(0, -3) : fileName;
|
|
62
|
+
const parts = base.split("-");
|
|
63
|
+
if (parts.length < 3) {
|
|
64
|
+
return { id: base, filename: fileName };
|
|
65
|
+
}
|
|
66
|
+
const num = parseInt(parts[0], 10);
|
|
67
|
+
const nickname = parts[1];
|
|
68
|
+
return {
|
|
69
|
+
id: base,
|
|
70
|
+
filename: fileName,
|
|
71
|
+
num: Number.isFinite(num) ? num : null,
|
|
72
|
+
nickname,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Token auto-resolve ---
|
|
77
|
+
|
|
78
|
+
function resolveToken(opts) {
|
|
79
|
+
if (opts.token) return { token: opts.token, tokenHash: "" };
|
|
80
|
+
if (opts.tokenHash) return { token: "", tokenHash: opts.tokenHash };
|
|
81
|
+
|
|
82
|
+
const file = opts.tokenFile || defaultTokensPath();
|
|
83
|
+
if (opts.subscriberId) {
|
|
84
|
+
const existing = getToken(file, opts.subscriberId);
|
|
85
|
+
if (existing) {
|
|
86
|
+
if (existing.token_hash) return { token: "", tokenHash: existing.token_hash };
|
|
87
|
+
if (existing.token) return { token: existing.token, tokenHash: "" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (opts.nickname) {
|
|
91
|
+
const byNick = getTokenByNickname(file, opts.nickname);
|
|
92
|
+
if (byNick) {
|
|
93
|
+
if (byNick.token_hash) return { token: "", tokenHash: byNick.token_hash };
|
|
94
|
+
if (byNick.token) return { token: byNick.token, tokenHash: "" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const newToken = generateToken();
|
|
99
|
+
return { token: newToken, tokenHash: "", generated: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function autoSubscriberId(nickname) {
|
|
103
|
+
const hex = crypto.randomBytes(4).toString("hex");
|
|
104
|
+
return `${nickname || "agent"}:${hex}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Inbox / Outbox paths ---
|
|
108
|
+
|
|
109
|
+
function onlineDir() {
|
|
110
|
+
return path.join(
|
|
111
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
112
|
+
".ufoo",
|
|
113
|
+
"online"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function inboxDir() {
|
|
118
|
+
return path.join(onlineDir(), "inbox");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function outboxFilePath(nickname) {
|
|
122
|
+
const dir = path.join(onlineDir(), "outbox");
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
return path.join(dir, `${nickname}.jsonl`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function messageSource(msg) {
|
|
128
|
+
if (msg.room) return "room";
|
|
129
|
+
return "channel";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function appendToInbox(nickname, msg) {
|
|
133
|
+
const dir = inboxDir();
|
|
134
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
135
|
+
const entry = {
|
|
136
|
+
...msg,
|
|
137
|
+
_source: messageSource(msg),
|
|
138
|
+
_receivedAt: new Date().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
fs.appendFileSync(path.join(dir, `${nickname}.jsonl`), JSON.stringify(entry) + "\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Helpers ---
|
|
144
|
+
|
|
145
|
+
function sleep(ms) {
|
|
146
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class OnlineConnect {
|
|
150
|
+
constructor(options = {}) {
|
|
151
|
+
this.projectRoot = options.projectRoot || process.cwd();
|
|
152
|
+
this.nickname = options.nickname || "";
|
|
153
|
+
this.subscriberId = options.subscriberId || autoSubscriberId(this.nickname);
|
|
154
|
+
this.url = options.url || "ws://127.0.0.1:8787/ufoo/online";
|
|
155
|
+
this.world = options.world || "default";
|
|
156
|
+
this.agentType = options.agentType || "ufoo";
|
|
157
|
+
this.tokenFile = options.tokenFile || "";
|
|
158
|
+
this.pollIntervalMs = options.pollIntervalMs || 1500;
|
|
159
|
+
this.pingMs = options.pingMs || 0;
|
|
160
|
+
this.allowInsecureWs = options.allowInsecureWs || false;
|
|
161
|
+
|
|
162
|
+
// Remote trust gating for private rooms
|
|
163
|
+
this.trustRemote = options.trustRemote || false;
|
|
164
|
+
if (Array.isArray(options.allowFrom)) {
|
|
165
|
+
this.allowFrom = new Set(options.allowFrom);
|
|
166
|
+
} else if (typeof options.allowFrom === "string" && options.allowFrom) {
|
|
167
|
+
this.allowFrom = new Set([options.allowFrom]);
|
|
168
|
+
} else {
|
|
169
|
+
this.allowFrom = new Set();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Join targets
|
|
173
|
+
this.channel = options.channel || "";
|
|
174
|
+
this.room = options.room || "";
|
|
175
|
+
this.roomPassword = options.roomPassword || "";
|
|
176
|
+
|
|
177
|
+
// Token auto-resolve
|
|
178
|
+
const resolved = resolveToken({
|
|
179
|
+
token: options.token || "",
|
|
180
|
+
tokenHash: options.tokenHash || "",
|
|
181
|
+
tokenFile: this.tokenFile,
|
|
182
|
+
subscriberId: this.subscriberId,
|
|
183
|
+
nickname: this.nickname,
|
|
184
|
+
});
|
|
185
|
+
this.token = resolved.token;
|
|
186
|
+
this.tokenHash = resolved.tokenHash;
|
|
187
|
+
|
|
188
|
+
// Private room mode enables bus/decisions sync
|
|
189
|
+
this.privateMode = !!this.room;
|
|
190
|
+
this.syncEnabled = this.privateMode && (this.trustRemote || this.allowFrom.size > 0);
|
|
191
|
+
|
|
192
|
+
// State for bus/decisions sync (only used in private mode)
|
|
193
|
+
this.stateFile = path.join(this.projectRoot, ".ufoo", "online", "bridge-state.json");
|
|
194
|
+
this.state = this.privateMode ? readState(this.stateFile) : defaultState();
|
|
195
|
+
|
|
196
|
+
this.eventBus = null;
|
|
197
|
+
this.pollRunId = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
makeClient() {
|
|
201
|
+
return new OnlineClient({
|
|
202
|
+
url: this.url,
|
|
203
|
+
subscriberId: this.subscriberId,
|
|
204
|
+
nickname: this.nickname,
|
|
205
|
+
world: this.world,
|
|
206
|
+
agentType: this.agentType,
|
|
207
|
+
token: this.token,
|
|
208
|
+
tokenHash: this.tokenHash,
|
|
209
|
+
tokenFile: this.tokenFile,
|
|
210
|
+
allowInsecureWs: this.allowInsecureWs,
|
|
211
|
+
capabilities: this.syncEnabled ? ["bus", "context"] : [],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async start() {
|
|
216
|
+
if (!this.nickname) throw new Error("--nickname is required");
|
|
217
|
+
|
|
218
|
+
// Init local bus only when sync is enabled
|
|
219
|
+
if (this.syncEnabled) {
|
|
220
|
+
const EventBus = require("../bus");
|
|
221
|
+
this.eventBus = new EventBus(this.projectRoot);
|
|
222
|
+
await this.eventBus.ensureJoined();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Reconnect loop
|
|
226
|
+
for (let attempt = 0; ; attempt++) {
|
|
227
|
+
try {
|
|
228
|
+
await this.runOnce(attempt);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(JSON.stringify({
|
|
231
|
+
type: "connect_error",
|
|
232
|
+
message: err?.message || String(err),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
const delay = Math.min(8000, 500 * Math.pow(2, attempt));
|
|
236
|
+
console.error(JSON.stringify({ type: "reconnect_wait", ms: delay }));
|
|
237
|
+
await sleep(delay);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async runOnce() {
|
|
242
|
+
const client = this.makeClient();
|
|
243
|
+
let closed = false;
|
|
244
|
+
|
|
245
|
+
client.on("message", (msg) => {
|
|
246
|
+
// stdout JSON output
|
|
247
|
+
console.log(JSON.stringify(msg));
|
|
248
|
+
// inbox persistence
|
|
249
|
+
if (msg && msg.type === "event") {
|
|
250
|
+
appendToInbox(this.nickname, msg);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
client.on("error", (err) => {
|
|
255
|
+
console.error(JSON.stringify({
|
|
256
|
+
type: "client_error",
|
|
257
|
+
message: err?.message || String(err),
|
|
258
|
+
}));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
client.on("close", () => {
|
|
262
|
+
console.error(JSON.stringify({ type: "client_close" }));
|
|
263
|
+
});
|
|
264
|
+
const closePromise = new Promise((resolve) => {
|
|
265
|
+
client.once("close", () => {
|
|
266
|
+
closed = true;
|
|
267
|
+
resolve();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await client.connect();
|
|
272
|
+
console.log("CONNECTED");
|
|
273
|
+
|
|
274
|
+
// Persist token on successful connect
|
|
275
|
+
const file = this.tokenFile || defaultTokensPath();
|
|
276
|
+
if (this.token) {
|
|
277
|
+
setToken(file, this.subscriberId, this.token, this.url, { nickname: this.nickname });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Join channel or room
|
|
281
|
+
if (this.channel) client.join(this.channel);
|
|
282
|
+
if (this.room) client.joinRoom(this.room, this.roomPassword);
|
|
283
|
+
|
|
284
|
+
// Keepalive ping
|
|
285
|
+
let pingTimer = null;
|
|
286
|
+
if (this.pingMs > 0) {
|
|
287
|
+
pingTimer = setInterval(() => client.send({ type: "ping" }), this.pingMs);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Message handler for sync-enabled mode (bus/decisions/wake sync)
|
|
291
|
+
if (this.syncEnabled) {
|
|
292
|
+
client.on("message", (msg) => this.handleOnlineMessage(client, msg));
|
|
293
|
+
// Wake handler
|
|
294
|
+
client.on("wake", (msg) => this.handleWake(msg));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Keep process alive
|
|
298
|
+
const keepAliveTimer = setInterval(() => {}, 10000);
|
|
299
|
+
|
|
300
|
+
// Start poll loop (outbox always, bus/decisions in private mode)
|
|
301
|
+
const runId = ++this.pollRunId;
|
|
302
|
+
const shouldRun = () => !closed && this.pollRunId === runId;
|
|
303
|
+
const pollPromise = this.pollLoop(client, shouldRun);
|
|
304
|
+
|
|
305
|
+
// Wait for disconnect
|
|
306
|
+
await closePromise;
|
|
307
|
+
await pollPromise;
|
|
308
|
+
|
|
309
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
310
|
+
clearInterval(keepAliveTimer);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- Message handling ---
|
|
314
|
+
|
|
315
|
+
handleOnlineMessage(client, msg) {
|
|
316
|
+
if (!msg || msg.type !== "event") return;
|
|
317
|
+
if (!msg.payload || typeof msg.payload.kind !== "string") return;
|
|
318
|
+
if (msg.payload.origin && msg.payload.origin === this.subscriberId) return;
|
|
319
|
+
|
|
320
|
+
if (msg.payload.kind === "message") {
|
|
321
|
+
if (!this.isRemoteTrusted(msg)) return;
|
|
322
|
+
if (!this.eventBus) return;
|
|
323
|
+
const from = msg.from || "remote";
|
|
324
|
+
const text = msg.payload.message || "";
|
|
325
|
+
const decorated = `[${from}] ${text}`.trim();
|
|
326
|
+
try {
|
|
327
|
+
this.eventBus.send("*", decorated, "remote:online");
|
|
328
|
+
} catch {
|
|
329
|
+
// ignore
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (msg.payload.kind === "decisions.sync") {
|
|
335
|
+
if (!this.isRemoteTrusted(msg)) return;
|
|
336
|
+
this.applyDecisionFromRemote(msg);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
handleWake(msg) {
|
|
341
|
+
if (!this.eventBus) return;
|
|
342
|
+
if (!this.isRemoteTrusted({ from: msg.from || "" })) return;
|
|
343
|
+
const from = msg.from || "";
|
|
344
|
+
try {
|
|
345
|
+
// Trigger local bus wake for this agent's subscriber
|
|
346
|
+
this.eventBus.wake(this.nickname, { reason: `online:${from}` }).catch(() => {});
|
|
347
|
+
} catch {
|
|
348
|
+
// ignore
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
isRemoteTrusted(msg) {
|
|
353
|
+
if (this.trustRemote) return true;
|
|
354
|
+
if (!this.allowFrom || this.allowFrom.size === 0) return false;
|
|
355
|
+
const from = msg?.from || "";
|
|
356
|
+
return this.allowFrom.has(from);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
sendEventSafe(client, payload) {
|
|
360
|
+
try {
|
|
361
|
+
return client.sendEvent(payload) === true;
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- Poll loop: outbox + (private mode: bus/decisions) → online ---
|
|
368
|
+
|
|
369
|
+
async pollLoop(client, shouldRun = () => true) {
|
|
370
|
+
while (shouldRun()) {
|
|
371
|
+
try {
|
|
372
|
+
const outboxOk = this.drainOutbox(client);
|
|
373
|
+
if (!outboxOk) {
|
|
374
|
+
try { client.close(); } catch { /* ignore */ }
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (this.syncEnabled) {
|
|
378
|
+
const syncBusOk = this.syncLocalToOnline(client);
|
|
379
|
+
if (!syncBusOk) {
|
|
380
|
+
try { client.close(); } catch { /* ignore */ }
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const syncDecisionsOk = this.syncDecisionsToOnline(client);
|
|
384
|
+
if (!syncDecisionsOk) {
|
|
385
|
+
try { client.close(); } catch { /* ignore */ }
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// ignore
|
|
391
|
+
}
|
|
392
|
+
if (!shouldRun()) return;
|
|
393
|
+
await sleep(this.pollIntervalMs);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
drainOutbox(client) {
|
|
398
|
+
const file = outboxFilePath(this.nickname);
|
|
399
|
+
const dir = path.dirname(file);
|
|
400
|
+
const base = path.basename(file);
|
|
401
|
+
const drainSuffix = ".drain";
|
|
402
|
+
const drainFiles = [];
|
|
403
|
+
|
|
404
|
+
// Drain any leftover temp files (e.g., from a previous crash)
|
|
405
|
+
try {
|
|
406
|
+
const existing = fs.readdirSync(dir)
|
|
407
|
+
.filter((name) => name.startsWith(`${base}.`) && name.endsWith(drainSuffix));
|
|
408
|
+
existing.forEach((name) => drainFiles.push(path.join(dir, name)));
|
|
409
|
+
} catch {
|
|
410
|
+
// ignore
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (fs.existsSync(file)) {
|
|
414
|
+
const tmp = path.join(dir, `${base}.${process.pid}.${Date.now()}${drainSuffix}`);
|
|
415
|
+
try {
|
|
416
|
+
fs.renameSync(file, tmp);
|
|
417
|
+
drainFiles.push(tmp);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err && (err.code === "ENOENT" || err.code === "EBUSY" || err.code === "EPERM" || err.code === "EACCES")) {
|
|
420
|
+
// Try again on next poll; avoid truncation-based races.
|
|
421
|
+
} else {
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let sendOk = true;
|
|
428
|
+
for (const drainFile of drainFiles) {
|
|
429
|
+
let raw = "";
|
|
430
|
+
try {
|
|
431
|
+
raw = fs.readFileSync(drainFile, "utf8");
|
|
432
|
+
} catch {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
raw = raw.trim();
|
|
436
|
+
if (!raw) {
|
|
437
|
+
try { fs.unlinkSync(drainFile); } catch { /* ignore */ }
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!sendOk) {
|
|
442
|
+
try {
|
|
443
|
+
fs.appendFileSync(file, `${raw}\n`, "utf8");
|
|
444
|
+
} catch {
|
|
445
|
+
// ignore requeue failures
|
|
446
|
+
}
|
|
447
|
+
try { fs.unlinkSync(drainFile); } catch { /* ignore */ }
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
452
|
+
const retryLines = [];
|
|
453
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
454
|
+
const line = lines[i];
|
|
455
|
+
let msg;
|
|
456
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
457
|
+
const text = msg.text || msg.message || "";
|
|
458
|
+
if (!text) continue;
|
|
459
|
+
|
|
460
|
+
// Determine routing: explicit target in msg, or fall back to connect defaults
|
|
461
|
+
const route = {};
|
|
462
|
+
if (msg.channel) route.channel = msg.channel;
|
|
463
|
+
else if (msg.room) route.room = msg.room;
|
|
464
|
+
else if (this.room) route.room = this.room;
|
|
465
|
+
else if (this.channel) route.channel = this.channel;
|
|
466
|
+
else continue; // nowhere to send
|
|
467
|
+
|
|
468
|
+
const sent = this.sendEventSafe(client, {
|
|
469
|
+
...route,
|
|
470
|
+
payload: { kind: "message", message: text, origin: this.subscriberId },
|
|
471
|
+
});
|
|
472
|
+
if (!sent) {
|
|
473
|
+
sendOk = false;
|
|
474
|
+
retryLines.push(line);
|
|
475
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
476
|
+
retryLines.push(lines[j]);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (retryLines.length > 0) {
|
|
483
|
+
try {
|
|
484
|
+
fs.appendFileSync(file, `${retryLines.join("\n")}\n`, "utf8");
|
|
485
|
+
} catch {
|
|
486
|
+
// ignore requeue failures
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
try { fs.unlinkSync(drainFile); } catch { /* ignore */ }
|
|
490
|
+
}
|
|
491
|
+
return sendOk;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
eventRoute() {
|
|
495
|
+
if (this.room) return { room: this.room };
|
|
496
|
+
if (this.channel) return { channel: this.channel };
|
|
497
|
+
return {};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
syncLocalToOnline(client) {
|
|
501
|
+
const eventsDir = path.join(this.projectRoot, ".ufoo", "bus", "events");
|
|
502
|
+
if (!fs.existsSync(eventsDir)) return true;
|
|
503
|
+
const files = fs.readdirSync(eventsDir)
|
|
504
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
505
|
+
.sort();
|
|
506
|
+
|
|
507
|
+
let lastSeq = this.state.last_seq || 0;
|
|
508
|
+
let sendFailed = false;
|
|
509
|
+
|
|
510
|
+
for (const file of files) {
|
|
511
|
+
const filePath = path.join(eventsDir, file);
|
|
512
|
+
const lines = fs.readFileSync(filePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
|
|
513
|
+
for (const line of lines) {
|
|
514
|
+
let event = null;
|
|
515
|
+
try {
|
|
516
|
+
event = JSON.parse(line);
|
|
517
|
+
} catch {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (!event || !event.seq || event.seq <= lastSeq) continue;
|
|
521
|
+
if (event.event !== "message") continue;
|
|
522
|
+
if (event.publisher === "remote:online") {
|
|
523
|
+
lastSeq = Math.max(lastSeq, event.seq);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const sent = this.sendEventSafe(client, {
|
|
528
|
+
...this.eventRoute(),
|
|
529
|
+
payload: {
|
|
530
|
+
kind: "message",
|
|
531
|
+
message: event.data?.message || "",
|
|
532
|
+
origin: this.subscriberId,
|
|
533
|
+
target: event.target || "*",
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
if (!sent) {
|
|
537
|
+
sendFailed = true;
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
lastSeq = Math.max(lastSeq, event.seq);
|
|
542
|
+
}
|
|
543
|
+
if (sendFailed) break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (lastSeq !== this.state.last_seq) {
|
|
547
|
+
this.state.last_seq = lastSeq;
|
|
548
|
+
writeState(this.stateFile, this.state);
|
|
549
|
+
}
|
|
550
|
+
return !sendFailed;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
syncDecisionsToOnline(client) {
|
|
554
|
+
const decisionsDir = path.join(this.projectRoot, ".ufoo", "context", "decisions");
|
|
555
|
+
if (!fs.existsSync(decisionsDir)) return true;
|
|
556
|
+
|
|
557
|
+
const files = fs.readdirSync(decisionsDir)
|
|
558
|
+
.filter((f) => f.endsWith(".md"))
|
|
559
|
+
.sort();
|
|
560
|
+
|
|
561
|
+
let changed = false;
|
|
562
|
+
let sendFailed = false;
|
|
563
|
+
|
|
564
|
+
for (const file of files) {
|
|
565
|
+
const parsed = parseDecisionIdFromFile(file);
|
|
566
|
+
if (!parsed.id) continue;
|
|
567
|
+
|
|
568
|
+
const nickname = parsed.nickname || "";
|
|
569
|
+
const num = parsed.num || 0;
|
|
570
|
+
const lastNum = this.state.last_decision_by_nick[nickname] || 0;
|
|
571
|
+
|
|
572
|
+
if (this.state.synced_decisions[parsed.id]) continue;
|
|
573
|
+
if (nickname && num && num <= lastNum) continue;
|
|
574
|
+
|
|
575
|
+
const filePath = path.join(decisionsDir, file);
|
|
576
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
577
|
+
|
|
578
|
+
const sent = this.sendEventSafe(client, {
|
|
579
|
+
...this.eventRoute(),
|
|
580
|
+
payload: {
|
|
581
|
+
kind: "decisions.sync",
|
|
582
|
+
origin: this.subscriberId,
|
|
583
|
+
decision: {
|
|
584
|
+
id: parsed.id,
|
|
585
|
+
filename: file,
|
|
586
|
+
nickname,
|
|
587
|
+
num,
|
|
588
|
+
content,
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
if (!sent) {
|
|
593
|
+
sendFailed = true;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
markSyncedDecision(this.state, parsed.id);
|
|
598
|
+
if (nickname && num) {
|
|
599
|
+
this.state.last_decision_by_nick[nickname] = Math.max(lastNum, num);
|
|
600
|
+
}
|
|
601
|
+
changed = true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (changed) {
|
|
605
|
+
writeState(this.stateFile, this.state);
|
|
606
|
+
}
|
|
607
|
+
return !sendFailed;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
applyDecisionFromRemote(msg) {
|
|
611
|
+
if (!this.isRemoteTrusted(msg)) return;
|
|
612
|
+
const decision = msg.payload?.decision || {};
|
|
613
|
+
const origin = msg.payload?.origin || "";
|
|
614
|
+
if (origin && origin === this.subscriberId) return;
|
|
615
|
+
|
|
616
|
+
const id = decision.id || decision.decision_id || "";
|
|
617
|
+
if (!id) return;
|
|
618
|
+
|
|
619
|
+
const rawFilename = decision.filename || decision.file || `${id}.md`;
|
|
620
|
+
const content = decision.content || "";
|
|
621
|
+
if (!content) return;
|
|
622
|
+
|
|
623
|
+
const parsed = parseDecisionIdFromFile(rawFilename);
|
|
624
|
+
const nickname = decision.nickname || parsed.nickname || "";
|
|
625
|
+
const num = decision.num || parsed.num || 0;
|
|
626
|
+
|
|
627
|
+
const decisionsDir = path.join(this.projectRoot, ".ufoo", "context", "decisions");
|
|
628
|
+
fs.mkdirSync(decisionsDir, { recursive: true });
|
|
629
|
+
|
|
630
|
+
// Step 8: Path traversal defense (3 layers)
|
|
631
|
+
// Layer 1: strip directory components
|
|
632
|
+
let safeFilename = path.basename(rawFilename);
|
|
633
|
+
// Layer 2: whitelist allowed characters
|
|
634
|
+
if (!/^[\w\-.]+$/.test(safeFilename)) return;
|
|
635
|
+
// Ensure .md extension
|
|
636
|
+
if (!safeFilename.endsWith(".md")) safeFilename = `${safeFilename}.md`;
|
|
637
|
+
// Layer 3: verify resolved path stays within decisionsDir
|
|
638
|
+
const targetPath = path.resolve(decisionsDir, safeFilename);
|
|
639
|
+
if (!targetPath.startsWith(decisionsDir + path.sep) && targetPath !== decisionsDir) return;
|
|
640
|
+
|
|
641
|
+
if (!fs.existsSync(targetPath)) {
|
|
642
|
+
fs.writeFileSync(targetPath, content, "utf8");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
markSyncedDecision(this.state, id);
|
|
646
|
+
if (nickname && num) {
|
|
647
|
+
const lastNum = this.state.last_decision_by_nick[nickname] || 0;
|
|
648
|
+
this.state.last_decision_by_nick[nickname] = Math.max(lastNum, num);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
const DecisionsManager = require("../context/decisions");
|
|
653
|
+
const manager = new DecisionsManager(this.projectRoot);
|
|
654
|
+
manager.writeIndex();
|
|
655
|
+
} catch {
|
|
656
|
+
// ignore
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
writeState(this.stateFile, this.state);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
module.exports = OnlineConnect;
|