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.
Files changed (149) hide show
  1. package/README.md +44 -4
  2. package/SKILLS/ufoo/SKILL.md +17 -2
  3. package/SKILLS/uinit/SKILL.md +8 -3
  4. package/bin/ucode-core.js +15 -0
  5. package/bin/ucode.js +125 -0
  6. package/bin/ufoo-assistant-agent.js +5 -0
  7. package/bin/ufoo-engine.js +25 -0
  8. package/bin/ufoo.js +4 -0
  9. package/modules/AGENTS.template.md +14 -4
  10. package/modules/bus/README.md +8 -5
  11. package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
  12. package/modules/context/SKILLS/uctx/SKILL.md +3 -1
  13. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  14. package/package.json +12 -3
  15. package/scripts/import-pi-mono.js +124 -0
  16. package/scripts/postinstall.js +20 -49
  17. package/scripts/sync-claude-skills.sh +21 -0
  18. package/src/agent/cliRunner.js +524 -31
  19. package/src/agent/internalRunner.js +76 -9
  20. package/src/agent/launcher.js +97 -45
  21. package/src/agent/normalizeOutput.js +1 -1
  22. package/src/agent/notifier.js +144 -4
  23. package/src/agent/ptyRunner.js +480 -10
  24. package/src/agent/ptyWrapper.js +28 -3
  25. package/src/agent/readyDetector.js +16 -0
  26. package/src/agent/ucode.js +443 -0
  27. package/src/agent/ucodeBootstrap.js +113 -0
  28. package/src/agent/ucodeBuild.js +67 -0
  29. package/src/agent/ucodeDoctor.js +184 -0
  30. package/src/agent/ucodeRuntimeConfig.js +129 -0
  31. package/src/agent/ufooAgent.js +11 -2
  32. package/src/assistant/agent.js +260 -0
  33. package/src/assistant/bridge.js +172 -0
  34. package/src/assistant/engine.js +252 -0
  35. package/src/assistant/stdio.js +58 -0
  36. package/src/assistant/ufooEngineCli.js +306 -0
  37. package/src/bus/activate.js +27 -11
  38. package/src/bus/daemon.js +133 -5
  39. package/src/bus/index.js +137 -80
  40. package/src/bus/inject.js +47 -17
  41. package/src/bus/message.js +145 -17
  42. package/src/bus/nickname.js +3 -1
  43. package/src/bus/queue.js +6 -1
  44. package/src/bus/store.js +189 -0
  45. package/src/bus/subscriber.js +20 -4
  46. package/src/bus/utils.js +9 -3
  47. package/src/chat/agentBar.js +117 -0
  48. package/src/chat/agentDirectory.js +88 -0
  49. package/src/chat/agentSockets.js +225 -0
  50. package/src/chat/agentViewController.js +298 -0
  51. package/src/chat/chatLogController.js +115 -0
  52. package/src/chat/commandExecutor.js +700 -0
  53. package/src/chat/commands.js +132 -0
  54. package/src/chat/completionController.js +414 -0
  55. package/src/chat/cronScheduler.js +160 -0
  56. package/src/chat/daemonConnection.js +166 -0
  57. package/src/chat/daemonCoordinator.js +64 -0
  58. package/src/chat/daemonMessageRouter.js +257 -0
  59. package/src/chat/daemonReconnect.js +41 -0
  60. package/src/chat/daemonTransport.js +36 -0
  61. package/src/chat/daemonTransportDefaults.js +10 -0
  62. package/src/chat/dashboardKeyController.js +480 -0
  63. package/src/chat/dashboardView.js +154 -0
  64. package/src/chat/index.js +935 -2909
  65. package/src/chat/inputHistoryController.js +105 -0
  66. package/src/chat/inputListenerController.js +304 -0
  67. package/src/chat/inputMath.js +104 -0
  68. package/src/chat/inputSubmitHandler.js +171 -0
  69. package/src/chat/layout.js +165 -0
  70. package/src/chat/pasteController.js +81 -0
  71. package/src/chat/rawKeyMap.js +42 -0
  72. package/src/chat/settingsController.js +132 -0
  73. package/src/chat/statusLineController.js +177 -0
  74. package/src/chat/streamTracker.js +138 -0
  75. package/src/chat/text.js +70 -0
  76. package/src/chat/transport.js +61 -0
  77. package/src/cli/busCoreCommands.js +59 -0
  78. package/src/cli/ctxCoreCommands.js +199 -0
  79. package/src/cli/onlineCoreCommands.js +379 -0
  80. package/src/cli.js +741 -238
  81. package/src/code/README.md +29 -0
  82. package/src/code/UCODE_PROMPT.md +32 -0
  83. package/src/code/agent.js +1651 -0
  84. package/src/code/cli.js +158 -0
  85. package/src/code/config +0 -0
  86. package/src/code/dispatch.js +42 -0
  87. package/src/code/index.js +70 -0
  88. package/src/code/nativeRunner.js +1213 -0
  89. package/src/code/runtime.js +154 -0
  90. package/src/code/sessionStore.js +162 -0
  91. package/src/code/taskDecomposer.js +269 -0
  92. package/src/code/tools/bash.js +53 -0
  93. package/src/code/tools/common.js +42 -0
  94. package/src/code/tools/edit.js +70 -0
  95. package/src/code/tools/read.js +44 -0
  96. package/src/code/tools/write.js +35 -0
  97. package/src/code/tui.js +1580 -0
  98. package/src/config.js +47 -1
  99. package/src/context/decisions.js +12 -2
  100. package/src/context/index.js +18 -1
  101. package/src/context/sync.js +127 -0
  102. package/src/daemon/agentProcessManager.js +74 -0
  103. package/src/daemon/cronOps.js +241 -0
  104. package/src/daemon/index.js +661 -488
  105. package/src/daemon/ipcServer.js +99 -0
  106. package/src/daemon/ops.js +417 -179
  107. package/src/daemon/promptLoop.js +319 -0
  108. package/src/daemon/promptRequest.js +101 -0
  109. package/src/daemon/providerSessions.js +32 -17
  110. package/src/daemon/reporting.js +90 -0
  111. package/src/daemon/run.js +2 -5
  112. package/src/daemon/status.js +24 -1
  113. package/src/init/index.js +68 -14
  114. package/src/online/bridge.js +663 -0
  115. package/src/online/client.js +245 -0
  116. package/src/online/runner.js +253 -0
  117. package/src/online/server.js +992 -0
  118. package/src/online/tokens.js +103 -0
  119. package/src/report/store.js +331 -0
  120. package/src/shared/eventContract.js +35 -0
  121. package/src/shared/ptySocketContract.js +21 -0
  122. package/src/status/index.js +50 -17
  123. package/src/terminal/adapterContract.js +87 -0
  124. package/src/terminal/adapterRouter.js +84 -0
  125. package/src/terminal/adapters/externalAdapter.js +14 -0
  126. package/src/terminal/adapters/internalAdapter.js +13 -0
  127. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  128. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  129. package/src/terminal/adapters/terminalAdapter.js +31 -0
  130. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  131. package/src/ufoo/agentsStore.js +69 -3
  132. package/src/utils/banner.js +5 -2
  133. package/scripts/.archived/bash-to-js-migration/README.md +0 -46
  134. package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
  135. package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
  136. package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
  137. package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
  138. package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
  139. package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
  140. package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
  141. package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
  142. package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
  143. package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
  144. package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
  145. package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
  146. package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
  147. package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
  148. package/scripts/banner.sh +0 -2
  149. 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;