quadwork 1.19.2 → 2.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/README.md +19 -35
- package/bin/quadwork.js +48 -1118
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +14 -14
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
- package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
- package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
- package/out/_next/static/chunks/{153f.fj8jlvle.js → 0_lyyn..t63bc.js} +1 -1
- package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
- package/out/_next/static/chunks/0py7102i226n5.js +1 -0
- package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
- package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
- package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
- package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
- package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
- package/out/_not-found/__next._full.txt +13 -13
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +13 -13
- package/out/app-shell/__next._full.txt +13 -13
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +8 -8
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +13 -13
- package/out/index.html +1 -1
- package/out/index.txt +14 -14
- package/out/project/_/__next._full.txt +14 -14
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +8 -8
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/queue/__next._full.txt +14 -14
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +8 -8
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +14 -14
- package/out/project/_.html +1 -1
- package/out/project/_.txt +14 -14
- package/out/settings/__next._full.txt +14 -14
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +8 -8
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +14 -14
- package/out/setup/__next._full.txt +14 -14
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +8 -8
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +14 -14
- package/package.json +4 -2
- package/server/ac-restore.js +128 -0
- package/server/bridges/discord.js +183 -0
- package/server/bridges/telegram.js +210 -0
- package/server/config.js +4 -60
- package/server/file-chat.js +318 -0
- package/server/index.js +173 -1286
- package/server/install-agentchattr.js +3 -284
- package/server/mcp-chat-shim.js +171 -0
- package/server/migrate-ac.js +158 -0
- package/server/pty-dispatcher.js +188 -0
- package/server/routes.js +149 -1397
- package/templates/CLAUDE.md +2 -2
- package/templates/OVERNIGHT-QUEUE.md +1 -1
- package/templates/seeds/butler.CLAUDE.md +30 -62
- package/templates/seeds/dev.AGENTS.md +10 -1
- package/templates/seeds/head.AGENTS.md +3 -3
- package/templates/seeds/re1.AGENTS.md +3 -3
- package/templates/seeds/re2.AGENTS.md +3 -3
- package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
- package/bridges/discord/discord_bridge.py +0 -666
- package/bridges/discord/requirements.txt +0 -2
- package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
- package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
- package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
- package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
- package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
- package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
- package/server/__tests__/rate-limit-handling.test.js +0 -168
- package/server/__tests__/scrub-secrets.test.js +0 -235
- package/server/__tests__/v1110-security-qa.test.js +0 -312
- package/server/agentchattr-registry.js +0 -188
- package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
- package/server/queue-watcher.js +0 -171
- package/server/queue-watcher.test.js +0 -64
- package/server/routes.batchProgress.test.js +0 -94
- package/server/routes.chatWsSend.test.js +0 -161
- package/server/routes.discordBridge.test.js +0 -80
- package/server/routes.parseActiveBatch.test.js +0 -88
- package/server/routes.telegramBridge.test.js +0 -241
- package/templates/config.toml +0 -72
- package/templates/wrapper.py +0 -70
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
package/server/routes.js
CHANGED
|
@@ -10,15 +10,26 @@ const path = require("path");
|
|
|
10
10
|
const os = require("os");
|
|
11
11
|
|
|
12
12
|
const multer = require("multer");
|
|
13
|
+
const fileChat = require("./file-chat");
|
|
14
|
+
const telegramBridge = require("./bridges/telegram");
|
|
15
|
+
const discordBridge = require("./bridges/discord");
|
|
13
16
|
|
|
14
17
|
const router = express.Router();
|
|
15
18
|
|
|
19
|
+
// #730: PTY dispatch callback — set by index.js at startup
|
|
20
|
+
let _ptyDispatchCallback = null;
|
|
21
|
+
function setPtyDispatchCallback(fn) { _ptyDispatchCallback = fn; }
|
|
22
|
+
|
|
16
23
|
const CONFIG_DIR = path.join(os.homedir(), ".quadwork");
|
|
17
24
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
18
25
|
const ENV_PATH = path.join(CONFIG_DIR, ".env");
|
|
19
26
|
const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
|
|
20
27
|
const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
21
28
|
|
|
29
|
+
function isLocalhost(ip) {
|
|
30
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
// ─── GitHub API rate limit tracking (#554) ────────────────────────────────
|
|
23
34
|
// Shared rate-limit state: periodically refreshed via `gh api rate_limit`.
|
|
24
35
|
// Server-side gh calls check this before executing and back off when low.
|
|
@@ -140,8 +151,6 @@ function cachedGhEndpoint(cacheKey, ghArgs, res, { transform } = {}) {
|
|
|
140
151
|
|
|
141
152
|
const DEFAULT_CONFIG = {
|
|
142
153
|
port: 8400,
|
|
143
|
-
agentchattr_url: "http://127.0.0.1:8300",
|
|
144
|
-
agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"),
|
|
145
154
|
projects: [],
|
|
146
155
|
};
|
|
147
156
|
|
|
@@ -205,10 +214,10 @@ router.put("/api/config", (req, res) => {
|
|
|
205
214
|
}
|
|
206
215
|
});
|
|
207
216
|
|
|
208
|
-
// ─── Chat (
|
|
217
|
+
// ─── Chat (file-based) ────────────────────────────────────────────────────
|
|
209
218
|
|
|
210
|
-
const {
|
|
211
|
-
const {
|
|
219
|
+
const { sanitizeOperatorName, ensureSecureDir, writeSecureFile, writeConfig } = require("./config");
|
|
220
|
+
const { findAgentChattr } = require("./install-agentchattr");
|
|
212
221
|
|
|
213
222
|
/**
|
|
214
223
|
* Seed ~/.quadwork/{projectId}/OVERNIGHT-QUEUE.md from the template.
|
|
@@ -231,108 +240,41 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
|
|
|
231
240
|
} catch { /* non-fatal */ }
|
|
232
241
|
}
|
|
233
242
|
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
243
|
+
function getProjectMaxHops(projectId) {
|
|
244
|
+
if (!projectId) return 30;
|
|
245
|
+
const cfg = readConfigFile();
|
|
246
|
+
const project = (cfg.projects || []).find((p) => p.id === projectId);
|
|
247
|
+
if (project?.max_agent_hops != null) return project.max_agent_hops;
|
|
248
|
+
return 30;
|
|
237
249
|
}
|
|
238
250
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
251
|
+
function getProjectChatMode(projectId) {
|
|
252
|
+
const cfg = readConfigFile();
|
|
253
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
254
|
+
return project?.chat_mode === "ac" ? "ac" : "file";
|
|
242
255
|
}
|
|
243
256
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
function emitSystemMessage(projectId, text) {
|
|
258
|
+
try {
|
|
259
|
+
fileChat.appendMessage(projectId, { sender: "system", type: "system", text });
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
248
262
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (const [k, v] of Object.entries(req.query)) {
|
|
252
|
-
if (k !== "path") fwd.set(k, String(v));
|
|
253
|
-
}
|
|
254
|
-
if (tok) fwd.set("token", tok);
|
|
255
|
-
return `${base}${apiPath}?${fwd.toString()}`;
|
|
256
|
-
};
|
|
263
|
+
router.get("/api/chat", (req, res) => {
|
|
264
|
+
const projectId = req.query.project;
|
|
257
265
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const retry = await fetch(buildUrl(refreshed), { headers: chatAuthHeaders(refreshed) });
|
|
269
|
-
if (!retry.ok) return res.status(retry.status).json({ error: `AgentChattr returned ${retry.status}` });
|
|
270
|
-
return res.json(await retry.json());
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (!r.ok) return res.status(r.status).json({ error: `AgentChattr returned ${r.status}` });
|
|
274
|
-
res.json(await r.json());
|
|
275
|
-
} catch (err) {
|
|
276
|
-
// #487: fetch threw (ECONNREFUSED, DNS failure, etc.) — AC is
|
|
277
|
-
// unreachable. Resync the token and retry once; AC may have
|
|
278
|
-
// restarted with a new session token by the time the retry fires.
|
|
279
|
-
if (projectId) {
|
|
280
|
-
try { await syncChattrToken(projectId); } catch {}
|
|
281
|
-
const { token: refreshed } = getChattrConfig(projectId);
|
|
282
|
-
if (refreshed && refreshed !== token) {
|
|
283
|
-
try {
|
|
284
|
-
const retry = await fetch(buildUrl(refreshed), { headers: chatAuthHeaders(refreshed) });
|
|
285
|
-
if (!retry.ok) return res.status(retry.status).json({ error: `AgentChattr returned ${retry.status}` });
|
|
286
|
-
return res.json(await retry.json());
|
|
287
|
-
} catch {}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
res.status(502).json({ error: "AgentChattr unreachable", detail: err.message });
|
|
291
|
-
}
|
|
266
|
+
const sinceId = Number(req.query.since_id) || Number(req.query.cursor) || 0;
|
|
267
|
+
const messages = fileChat.readMessages(projectId, {
|
|
268
|
+
since_id: sinceId,
|
|
269
|
+
limit: Number(req.query.limit) || 50,
|
|
270
|
+
});
|
|
271
|
+
const normalized = messages.map((m) => ({
|
|
272
|
+
...m,
|
|
273
|
+
time: m.time || (m.ts ? new Date(m.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) : ""),
|
|
274
|
+
}));
|
|
275
|
+
return res.json(normalized);
|
|
292
276
|
});
|
|
293
277
|
|
|
294
|
-
// #225 sub-E: send chat messages from the dashboard via the
|
|
295
|
-
// AgentChattr WebSocket, not via /api/send.
|
|
296
|
-
//
|
|
297
|
-
// /api/send requires `Authorization: Bearer <registration_token>` and
|
|
298
|
-
// the token must resolve to a registered instance via
|
|
299
|
-
// `registry.resolve_token()`. The session_token we store on the
|
|
300
|
-
// project entry only authorizes browser/middleware traffic — it is
|
|
301
|
-
// NOT a registration token, so /api/send always 401s with
|
|
302
|
-
// "missing Authorization: Bearer <token>". The dashboard browser
|
|
303
|
-
// already sends through the WebSocket on `/ws?token=<session_token>`
|
|
304
|
-
// and the server accepts that path, so we mirror that exact flow
|
|
305
|
-
// from the express server: open a one-shot ws, push the message,
|
|
306
|
-
// wait briefly for ack, close.
|
|
307
|
-
const { WebSocket: NodeWebSocket } = require("ws");
|
|
308
|
-
const { syncChattrToken } = require("./config");
|
|
309
|
-
|
|
310
|
-
// #236: wait for AgentChattr to echo our message back over the same ws
|
|
311
|
-
// connection before resolving, instead of fire-and-forgetting. AC's
|
|
312
|
-
// /ws handler does this on every connect:
|
|
313
|
-
// 1. Replays history as N `{type:"message", data: msg}` frames.
|
|
314
|
-
// 2. Sends one `{type:"status", data: …}` frame (broadcast_status).
|
|
315
|
-
// 3. Enters the receive loop and accepts our outgoing frame.
|
|
316
|
-
// After our `type:"message"` is processed, AC calls `store.add()`
|
|
317
|
-
// which broadcasts the stored record back to all clients (including
|
|
318
|
-
// us) as another `{type:"message", data: msg}`.
|
|
319
|
-
//
|
|
320
|
-
// To get a race-free ack we therefore:
|
|
321
|
-
// A. Wait for the first `type:"status"` frame to confirm the
|
|
322
|
-
// history replay is done — any `type:"message"` frame seen
|
|
323
|
-
// BEFORE that is historical and must be ignored.
|
|
324
|
-
// B. Only then send our message and record the highest message
|
|
325
|
-
// id observed so far as a correlation baseline.
|
|
326
|
-
// C. Accept the first post-send `type:"message"` whose payload
|
|
327
|
-
// matches (sender, text, channel, reply_to) AND whose id is
|
|
328
|
-
// strictly greater than the baseline (AC ids are monotonically
|
|
329
|
-
// increasing from store.add). This eliminates the risk a
|
|
330
|
-
// reviewer flagged on #382 round 1: a historical identical
|
|
331
|
-
// message from <1.5s ago could have satisfied the old
|
|
332
|
-
// heuristic matcher.
|
|
333
|
-
// On timeout / early close / 4003, we surface a proper error so the
|
|
334
|
-
// /api/chat handler can return a 5xx (or 401) instead of a silent
|
|
335
|
-
// {ok:true}.
|
|
336
278
|
// #693: Auto-normalize bare agent names to @mentions in outbound messages.
|
|
337
279
|
// Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
|
|
338
280
|
// Already-prefixed mentions are not double-prefixed; suffixed names like
|
|
@@ -340,341 +282,67 @@ const { syncChattrToken } = require("./config");
|
|
|
340
282
|
const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
|
|
341
283
|
function normalizeMentions(text) {
|
|
342
284
|
if (typeof text !== "string" || !text) return text || "";
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
function sendViaWebSocket(baseUrl, sessionToken, message) {
|
|
350
|
-
// #693: normalize bare agent names to @mentions before sending
|
|
351
|
-
message = { ...message, text: normalizeMentions(message.text || "") };
|
|
352
|
-
return new Promise((resolve, reject) => {
|
|
353
|
-
const wsUrl = `${baseUrl.replace(/^http/, "ws")}/ws?token=${encodeURIComponent(sessionToken || "")}`;
|
|
354
|
-
const ws = new NodeWebSocket(wsUrl);
|
|
355
|
-
let settled = false;
|
|
356
|
-
let historyFlushed = false;
|
|
357
|
-
let sent = false;
|
|
358
|
-
let maxIdAtSend = -Infinity;
|
|
359
|
-
let maxHistoryId = -Infinity;
|
|
360
|
-
const finish = (err, value) => {
|
|
361
|
-
if (settled) return;
|
|
362
|
-
settled = true;
|
|
363
|
-
try { ws.close(); } catch {}
|
|
364
|
-
if (err) reject(err); else resolve(value);
|
|
365
|
-
};
|
|
366
|
-
const giveUp = setTimeout(() => finish(new Error("websocket send timeout")), 4000);
|
|
367
|
-
const doSend = () => {
|
|
368
|
-
if (sent || settled) return;
|
|
369
|
-
try {
|
|
370
|
-
maxIdAtSend = maxHistoryId;
|
|
371
|
-
ws.send(JSON.stringify({ type: "message", ...message }));
|
|
372
|
-
sent = true;
|
|
373
|
-
} catch (err) { clearTimeout(giveUp); finish(err); }
|
|
374
|
-
};
|
|
375
|
-
ws.on("open", () => {
|
|
376
|
-
// Do NOT send yet. Wait for the status frame that marks the
|
|
377
|
-
// end of history replay so we have a clean correlation
|
|
378
|
-
// baseline. A safety timer covers the (unlikely) case of an
|
|
379
|
-
// AC build that doesn't emit status on connect — after 750ms
|
|
380
|
-
// we fall back to sending anyway, using whatever max id we
|
|
381
|
-
// collected from history so far as the baseline.
|
|
382
|
-
setTimeout(() => {
|
|
383
|
-
if (!historyFlushed) {
|
|
384
|
-
historyFlushed = true;
|
|
385
|
-
doSend();
|
|
386
|
-
}
|
|
387
|
-
}, 750);
|
|
388
|
-
});
|
|
389
|
-
ws.on("message", (raw) => {
|
|
390
|
-
if (settled) return;
|
|
391
|
-
let frame;
|
|
392
|
-
try { frame = JSON.parse(raw.toString()); } catch { return; }
|
|
393
|
-
if (!frame || !frame.type) return;
|
|
394
|
-
if (frame.type === "status" && !historyFlushed) {
|
|
395
|
-
historyFlushed = true;
|
|
396
|
-
doSend();
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (frame.type !== "message" || !frame.data) return;
|
|
400
|
-
const d = frame.data;
|
|
401
|
-
// Track the highest message id we have observed, whether from
|
|
402
|
-
// history replay or from other live broadcasts. Used as the
|
|
403
|
-
// baseline for the post-send correlation check.
|
|
404
|
-
if (typeof d.id === "number" && d.id > maxHistoryId) {
|
|
405
|
-
maxHistoryId = d.id;
|
|
406
|
-
}
|
|
407
|
-
if (!sent) return; // anything before our send is history
|
|
408
|
-
if (typeof d.id !== "number" || d.id <= maxIdAtSend) return;
|
|
409
|
-
if (d.sender !== message.sender) return;
|
|
410
|
-
if (d.text !== message.text) return;
|
|
411
|
-
if ((d.channel || "general") !== (message.channel || "general")) return;
|
|
412
|
-
const wantReply = message.reply_to ?? null;
|
|
413
|
-
const gotReply = d.reply_to ?? null;
|
|
414
|
-
if (wantReply !== gotReply) return;
|
|
415
|
-
clearTimeout(giveUp);
|
|
416
|
-
finish(null, { ok: true, message: d });
|
|
417
|
-
});
|
|
418
|
-
ws.on("error", (err) => { clearTimeout(giveUp); finish(err); });
|
|
419
|
-
ws.on("close", (code, reason) => {
|
|
420
|
-
// Code 4003 = bad token (see app.py /ws handler). Surface as
|
|
421
|
-
// 401 so the dashboard's chat error banner shows the right thing.
|
|
422
|
-
if (!settled && code === 4003) {
|
|
423
|
-
clearTimeout(giveUp);
|
|
424
|
-
const msg = (reason && reason.toString()) || "forbidden: invalid session token";
|
|
425
|
-
const e = new Error(msg);
|
|
426
|
-
e.code = "EAGENTCHATTR_401";
|
|
427
|
-
finish(e);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
// Any other premature close after we sent but before we saw
|
|
431
|
-
// the echo is an error — the old code path would have claimed
|
|
432
|
-
// success, silently swallowing a server-side reject.
|
|
433
|
-
if (!settled) {
|
|
434
|
-
clearTimeout(giveUp);
|
|
435
|
-
const r = (reason && reason.toString()) || "";
|
|
436
|
-
finish(new Error(`websocket closed before ack (code=${code}${r ? ", reason=" + r : ""})`));
|
|
437
|
-
}
|
|
438
|
-
});
|
|
285
|
+
const preserved = [];
|
|
286
|
+
const ph = "\x00CODE\x00";
|
|
287
|
+
let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
|
|
288
|
+
preserved.push(m);
|
|
289
|
+
return ph;
|
|
439
290
|
});
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
let settled = false;
|
|
454
|
-
const finish = (err, value) => {
|
|
455
|
-
if (settled) return;
|
|
456
|
-
settled = true;
|
|
457
|
-
try { ws.close(); } catch {}
|
|
458
|
-
if (err) reject(err); else resolve(value);
|
|
459
|
-
};
|
|
460
|
-
const giveUp = setTimeout(() => finish(new Error("websocket send timeout")), 4000);
|
|
461
|
-
ws.on("open", () => {
|
|
462
|
-
try {
|
|
463
|
-
ws.send(JSON.stringify(event));
|
|
464
|
-
setTimeout(() => { clearTimeout(giveUp); finish(null, { ok: true }); }, 250);
|
|
465
|
-
} catch (err) { clearTimeout(giveUp); finish(err); }
|
|
466
|
-
});
|
|
467
|
-
ws.on("error", (err) => { clearTimeout(giveUp); finish(err); });
|
|
468
|
-
ws.on("close", (code, reason) => {
|
|
469
|
-
if (!settled && code === 4003) {
|
|
470
|
-
clearTimeout(giveUp);
|
|
471
|
-
const msg = (reason && reason.toString()) || "forbidden: invalid session token";
|
|
472
|
-
const e = new Error(msg);
|
|
473
|
-
e.code = "EAGENTCHATTR_401";
|
|
474
|
-
finish(e);
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// #403 / quadwork#274: read/write the loop guard for a given project.
|
|
481
|
-
// Source of truth at rest is the project's config.toml [routing]
|
|
482
|
-
// max_agent_hops. The PUT also pushes the value to the running AC via
|
|
483
|
-
// `update_settings` so the change is live without a daemon restart.
|
|
484
|
-
// Resolve the per-project config.toml path through resolveProjectChattr
|
|
485
|
-
// so we honor `project.agentchattr_dir` (web wizard sets this; legacy
|
|
486
|
-
// imports can have arbitrary paths) and don't drift from the rest of
|
|
487
|
-
// the codebase that already goes through that helper.
|
|
488
|
-
function resolveProjectConfigToml(projectId) {
|
|
489
|
-
const resolved = resolveProjectChattr(projectId);
|
|
490
|
-
if (!resolved || !resolved.dir) return null;
|
|
491
|
-
return path.join(resolved.dir, "config.toml");
|
|
291
|
+
safe = MENTION_AGENT_NAMES.reduce(
|
|
292
|
+
(t, name) =>
|
|
293
|
+
t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
|
|
294
|
+
const before = str.slice(Math.max(0, offset - 20), offset);
|
|
295
|
+
if (/[=\/]$/.test(before) || /\b(run|exec|npx|start|checkout|switch|rebase|cd|cat|ls|rm|mv|cp|mkdir)\s+$/i.test(before)) return match;
|
|
296
|
+
const after = str.slice(offset + name.length, offset + name.length + 1);
|
|
297
|
+
if (after === "/") return match;
|
|
298
|
+
return `@${name}`;
|
|
299
|
+
}),
|
|
300
|
+
safe,
|
|
301
|
+
);
|
|
302
|
+
let i = 0;
|
|
303
|
+
return safe.replace(new RegExp(ph, "g"), () => preserved[i++] || "");
|
|
492
304
|
}
|
|
493
305
|
|
|
494
306
|
router.get("/api/loop-guard", (req, res) => {
|
|
495
307
|
const projectId = req.query.project;
|
|
496
308
|
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
const content = fs.readFileSync(tomlPath, "utf-8");
|
|
501
|
-
const m = content.match(/^\s*max_agent_hops\s*=\s*(\d+)/m);
|
|
502
|
-
const value = m ? parseInt(m[1], 10) : 30;
|
|
503
|
-
res.json({ value, source: m ? "toml" : "default" });
|
|
504
|
-
} catch (err) {
|
|
505
|
-
res.status(500).json({ error: "Failed to read config.toml", detail: err.message });
|
|
506
|
-
}
|
|
309
|
+
const value = getProjectMaxHops(projectId);
|
|
310
|
+
return res.json({ value, source: value === 30 ? "default" : "config" });
|
|
507
311
|
});
|
|
508
312
|
|
|
509
|
-
router.put("/api/loop-guard",
|
|
313
|
+
router.put("/api/loop-guard", (req, res) => {
|
|
510
314
|
const projectId = req.query.project || req.body?.project;
|
|
511
315
|
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
512
316
|
const raw = req.body?.value;
|
|
513
317
|
const value = typeof raw === "number" ? raw : parseInt(raw, 10);
|
|
514
|
-
// AC's update_settings handler clamps to [1, 50]; mirror that
|
|
515
|
-
// here so we don't write a value AC will silently rewrite.
|
|
516
318
|
if (!Number.isInteger(value) || value < 4 || value > 50) {
|
|
517
319
|
return res.status(400).json({ error: "value must be an integer between 4 and 50" });
|
|
518
320
|
}
|
|
519
321
|
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
if (!
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// whether the /continue auto-resume should fire (only when the
|
|
527
|
-
// operator is RAISING the limit — lowering it means they want
|
|
528
|
-
// the runaway loop to stay paused).
|
|
529
|
-
let previousValue = null;
|
|
530
|
-
try {
|
|
531
|
-
const previousContent = fs.readFileSync(tomlPath, "utf-8");
|
|
532
|
-
const prevMatch = previousContent.match(/^\s*max_agent_hops\s*=\s*(\d+)/m);
|
|
533
|
-
if (prevMatch) previousValue = parseInt(prevMatch[1], 10);
|
|
534
|
-
} catch {
|
|
535
|
-
// fall through — previousValue stays null, auto-resume will skip
|
|
536
|
-
}
|
|
537
|
-
try {
|
|
538
|
-
let content = fs.readFileSync(tomlPath, "utf-8");
|
|
539
|
-
if (/^\s*max_agent_hops\s*=/m.test(content)) {
|
|
540
|
-
content = content.replace(/^\s*max_agent_hops\s*=.*$/m, `max_agent_hops = ${value}`);
|
|
541
|
-
} else if (/^\s*\[routing\]/m.test(content)) {
|
|
542
|
-
// Section exists but the key doesn't — append the key on the
|
|
543
|
-
// line right after the [routing] header to keep it scoped.
|
|
544
|
-
content = content.replace(/^(\s*\[routing\]\s*\n)/m, `$1max_agent_hops = ${value}\n`);
|
|
545
|
-
} else {
|
|
546
|
-
const trailing = content.endsWith("\n") ? "" : "\n";
|
|
547
|
-
content += `${trailing}\n[routing]\ndefault = "none"\nmax_agent_hops = ${value}\n`;
|
|
548
|
-
}
|
|
549
|
-
writeSecureFile(tomlPath, content);
|
|
550
|
-
} catch (err) {
|
|
551
|
-
return res.status(500).json({ error: "Failed to write config.toml", detail: err.message });
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// 2. Best-effort push to the running AC so the change is live.
|
|
555
|
-
// On stale-token (4003 → EAGENTCHATTR_401) recover the same way
|
|
556
|
-
// /api/chat does (#230): re-sync the session token from AC and
|
|
557
|
-
// retry once. Other failures stay non-fatal — the persisted value
|
|
558
|
-
// still takes effect on next AC restart.
|
|
559
|
-
//
|
|
560
|
-
// #417 / quadwork#309: the update_settings ws event correctly
|
|
561
|
-
// updates router.max_hops in the running AC (verified in AC's
|
|
562
|
-
// app.py:1249), AND writes settings.json via _save_settings. But
|
|
563
|
-
// AC's router stays paused once it has tripped the guard — raising
|
|
564
|
-
// max_hops at runtime does NOT resurrect an already-paused channel
|
|
565
|
-
// (router.py:76-77 → `paused = True`). The operator typically
|
|
566
|
-
// raises the limit precisely BECAUSE the channel is stuck paused,
|
|
567
|
-
// so we immediately follow the update_settings event with a
|
|
568
|
-
// `/continue` chat message (the same path AC's own slash command
|
|
569
|
-
// handler uses at app.py:1106-1110) to resume routing. This is the
|
|
570
|
-
// whole fix: the previous version updated max_hops live but left
|
|
571
|
-
// the channel frozen, which made the widget look like a no-op.
|
|
572
|
-
let live = false;
|
|
573
|
-
let autoResumed = false;
|
|
574
|
-
// Only auto-resume when ALL of:
|
|
575
|
-
// (a) operator is RAISING the limit (lowering = "make it
|
|
576
|
-
// stricter", must leave a paused runaway alone)
|
|
577
|
-
// (b) the router is currently paused (AC's continue_routing
|
|
578
|
-
// resets hop_count + paused + guard_emitted unconditionally,
|
|
579
|
-
// so firing it on an actively-running chain would silently
|
|
580
|
-
// extend the chain beyond the new limit — t2a finding)
|
|
581
|
-
// (c) previousValue is known (null means we can't prove it's a
|
|
582
|
-
// raise, so err on the side of not touching router state)
|
|
583
|
-
const isRaising = previousValue !== null && value > previousValue;
|
|
584
|
-
const ensureLive = async (sessionToken) => {
|
|
585
|
-
await sendWsEvent(base, sessionToken, { type: "update_settings", data: { max_agent_hops: value } });
|
|
586
|
-
if (isRaising) {
|
|
587
|
-
// Check AC's /api/status before firing /continue so we don't
|
|
588
|
-
// reset hop_count on a running (unpaused) chain. The endpoint
|
|
589
|
-
// exposes `paused: true` iff ANY channel currently paused.
|
|
590
|
-
let isPaused = false;
|
|
591
|
-
try {
|
|
592
|
-
// AC's security middleware (app.py:212-224) only accepts
|
|
593
|
-
// bearer auth for /api/messages, /api/send, and /api/rules/*.
|
|
594
|
-
// /api/status requires x-session-token header (or ?token=),
|
|
595
|
-
// so pass that instead — a bearer header silently 403s and
|
|
596
|
-
// leaves isPaused stuck at false, defeating the gate.
|
|
597
|
-
const statusUrl = `${base}/api/status`;
|
|
598
|
-
const statusRes = await fetch(statusUrl, {
|
|
599
|
-
headers: sessionToken ? { "x-session-token": sessionToken } : {},
|
|
600
|
-
signal: AbortSignal.timeout(5000),
|
|
601
|
-
});
|
|
602
|
-
if (statusRes.ok) {
|
|
603
|
-
const statusJson = await statusRes.json();
|
|
604
|
-
isPaused = !!(statusJson && statusJson.paused);
|
|
605
|
-
}
|
|
606
|
-
} catch {
|
|
607
|
-
// Status fetch failed — err toward "don't auto-resume". The
|
|
608
|
-
// operator can always type /continue manually.
|
|
609
|
-
}
|
|
610
|
-
if (isPaused) {
|
|
611
|
-
// Resume paused channels. /continue is routed by AC's ws
|
|
612
|
-
// message handler when the buffer starts with /continue;
|
|
613
|
-
// the handler calls router.continue_routing() which
|
|
614
|
-
// unpauses AND resets hop_count — which is why we gate on
|
|
615
|
-
// isPaused to avoid wiping the counter on a live chain.
|
|
616
|
-
await sendWsEvent(base, sessionToken, { type: "message", text: "/continue", channel: "general", sender: "user" });
|
|
617
|
-
autoResumed = true;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
live = true;
|
|
621
|
-
};
|
|
622
|
-
let base = null;
|
|
623
|
-
try {
|
|
624
|
-
const chattr = getChattrConfig(projectId);
|
|
625
|
-
base = chattr.url;
|
|
626
|
-
const sessionToken = chattr.token;
|
|
627
|
-
if (base) {
|
|
628
|
-
try {
|
|
629
|
-
await ensureLive(sessionToken);
|
|
630
|
-
} catch (err) {
|
|
631
|
-
if (err && err.code === "EAGENTCHATTR_401") {
|
|
632
|
-
console.warn(`[loop-guard] ws auth failed for ${projectId}, re-syncing session token and retrying...`);
|
|
633
|
-
try { await syncChattrToken(projectId); }
|
|
634
|
-
catch (syncErr) { console.warn(`[loop-guard] syncChattrToken failed: ${syncErr.message}`); }
|
|
635
|
-
const { token: refreshed } = getChattrConfig(projectId);
|
|
636
|
-
if (refreshed && refreshed !== sessionToken) {
|
|
637
|
-
try {
|
|
638
|
-
await ensureLive(refreshed);
|
|
639
|
-
} catch (retryErr) {
|
|
640
|
-
console.warn(`[loop-guard] retry after token resync failed: ${retryErr.message || retryErr}`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
} else {
|
|
644
|
-
throw err;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
} catch (err) {
|
|
649
|
-
console.warn(`[loop-guard] live update failed for ${projectId}: ${err.message || err}`);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
res.json({ ok: true, value, live, previousValue, resumed: autoResumed });
|
|
322
|
+
const cfg = readConfigFile();
|
|
323
|
+
const project = (cfg.projects || []).find((p) => p.id === projectId);
|
|
324
|
+
if (!project) return res.status(404).json({ error: "Project not found" });
|
|
325
|
+
project.max_agent_hops = value;
|
|
326
|
+
writeConfigFile(cfg);
|
|
327
|
+
return res.json({ ok: true, value });
|
|
653
328
|
});
|
|
654
329
|
|
|
655
330
|
// #412 / quadwork#279: project history export + import.
|
|
656
331
|
//
|
|
657
|
-
// Export
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
// be detected client-side.
|
|
332
|
+
// Export reads messages from file-chat and wraps the array in a
|
|
333
|
+
// small metadata envelope so future imports can warn on project-id
|
|
334
|
+
// mismatch and so a future schema bump can be detected client-side.
|
|
661
335
|
//
|
|
662
336
|
// Import accepts the same envelope, validates the shape + size,
|
|
663
|
-
// and replays each message
|
|
664
|
-
//
|
|
665
|
-
// field for cross-tool consistency. Originals' message IDs are NOT
|
|
666
|
-
// preserved (AC re-assigns on insert), which is a known v1 limit
|
|
667
|
-
// and matches the issue's "AgentChattr will tell us" note.
|
|
337
|
+
// and replays each message into the file-chat store — preserving
|
|
338
|
+
// the original sender field for cross-tool consistency.
|
|
668
339
|
|
|
669
340
|
const PROJECT_HISTORY_VERSION = 1;
|
|
670
341
|
const PROJECT_HISTORY_MAX_BYTES = 10 * 1024 * 1024; // 10 MB cap per issue
|
|
671
|
-
const PROJECT_HISTORY_REPLAY_DELAY_MS = 25; // pace AC ws inserts
|
|
672
342
|
|
|
673
343
|
// #414 / quadwork#297: reject imports whose messages claim a
|
|
674
|
-
// reserved agent / system sender by default.
|
|
675
|
-
//
|
|
676
|
-
// (every other route hardcodes / sanitizes to operator). Mirrors
|
|
677
|
-
// the RESERVED_OPERATOR_NAMES denylist from sanitizeOperatorName so
|
|
344
|
+
// reserved agent / system sender by default. Mirrors the
|
|
345
|
+
// RESERVED_OPERATOR_NAMES denylist from sanitizeOperatorName so
|
|
678
346
|
// the same identities are blocked across the codebase.
|
|
679
347
|
const RESERVED_HISTORY_SENDERS = new Set([
|
|
680
348
|
"head",
|
|
@@ -692,29 +360,11 @@ const RESERVED_HISTORY_SENDERS = new Set([
|
|
|
692
360
|
"system",
|
|
693
361
|
]);
|
|
694
362
|
|
|
695
|
-
router.get("/api/project-history",
|
|
363
|
+
router.get("/api/project-history", (req, res) => {
|
|
696
364
|
const projectId = req.query.project;
|
|
697
365
|
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
698
|
-
const { url: base, token: sessionToken } = getChattrConfig(projectId);
|
|
699
|
-
if (!base) return res.status(400).json({ error: "No AgentChattr configured for project" });
|
|
700
366
|
try {
|
|
701
|
-
|
|
702
|
-
// header; the session token is what the chat panel already uses.
|
|
703
|
-
const target = `${base}/api/messages?channel=general&limit=100000`;
|
|
704
|
-
const r = await fetch(target, {
|
|
705
|
-
headers: sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {},
|
|
706
|
-
// Cap the AC fetch at 30s so a hung daemon doesn't park the
|
|
707
|
-
// export request indefinitely.
|
|
708
|
-
signal: AbortSignal.timeout(30000),
|
|
709
|
-
});
|
|
710
|
-
if (!r.ok) {
|
|
711
|
-
const detail = await r.text().catch(() => "");
|
|
712
|
-
return res.status(502).json({ error: `AgentChattr /api/messages returned ${r.status}`, detail: detail.slice(0, 200) });
|
|
713
|
-
}
|
|
714
|
-
const raw = await r.json();
|
|
715
|
-
// AC returns either a bare array or { messages: [...] } depending
|
|
716
|
-
// on version — handle both.
|
|
717
|
-
const messages = Array.isArray(raw) ? raw : Array.isArray(raw && raw.messages) ? raw.messages : [];
|
|
367
|
+
const messages = fileChat.readMessages(projectId, { limit: 100000 });
|
|
718
368
|
res.json({
|
|
719
369
|
version: PROJECT_HISTORY_VERSION,
|
|
720
370
|
project_id: projectId,
|
|
@@ -723,7 +373,7 @@ router.get("/api/project-history", async (req, res) => {
|
|
|
723
373
|
messages,
|
|
724
374
|
});
|
|
725
375
|
} catch (err) {
|
|
726
|
-
res.status(
|
|
376
|
+
res.status(500).json({ error: "Project history export failed", detail: err.message || String(err) });
|
|
727
377
|
}
|
|
728
378
|
});
|
|
729
379
|
|
|
@@ -807,24 +457,9 @@ router.post("/api/project-history", async (req, res) => {
|
|
|
807
457
|
}
|
|
808
458
|
}
|
|
809
459
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
// Replay each message via the existing ws send helper. Preserve
|
|
814
|
-
// the original sender so the imported transcript still attributes
|
|
815
|
-
// each line correctly. Pace the writes so AC's ws handler isn't
|
|
816
|
-
// overloaded on a multi-thousand-message import.
|
|
817
|
-
//
|
|
818
|
-
// SECURITY NOTE: This deliberately bypasses /api/chat's #230/#288
|
|
819
|
-
// sanitize-as-user lockdown — the imported sender field is sent
|
|
820
|
-
// straight to AC's ws, so a crafted import file CAN post as
|
|
821
|
-
// `head` / `dev` / etc. That's intentional: imports must round-
|
|
822
|
-
// trip the original attribution to be useful (otherwise every
|
|
823
|
-
// restored message would say `user` and the transcript would be
|
|
824
|
-
// worthless). The trade-off is acceptable because the only entry
|
|
825
|
-
// point is an authenticated dashboard operator picking a file by
|
|
826
|
-
// hand and clicking through the project-mismatch confirm. Don't
|
|
827
|
-
// expose this route from a less-trusted surface without revisiting.
|
|
460
|
+
// Replay each message into the file-chat store. Preserve the
|
|
461
|
+
// original sender so the imported transcript still attributes
|
|
462
|
+
// each line correctly.
|
|
828
463
|
let imported = 0;
|
|
829
464
|
let skipped = 0;
|
|
830
465
|
const errors = [];
|
|
@@ -833,24 +468,18 @@ router.post("/api/project-history", async (req, res) => {
|
|
|
833
468
|
skipped++;
|
|
834
469
|
continue;
|
|
835
470
|
}
|
|
836
|
-
const msg = {
|
|
837
|
-
text: m.text,
|
|
838
|
-
channel: typeof m.channel === "string" && m.channel ? m.channel : "general",
|
|
839
|
-
sender: typeof m.sender === "string" && m.sender ? m.sender : "user",
|
|
840
|
-
};
|
|
841
471
|
try {
|
|
842
|
-
|
|
472
|
+
fileChat.appendMessage(projectId, {
|
|
473
|
+
sender: typeof m.sender === "string" && m.sender ? m.sender : "user",
|
|
474
|
+
text: m.text,
|
|
475
|
+
channel: typeof m.channel === "string" && m.channel ? m.channel : "general",
|
|
476
|
+
type: m.type || "message",
|
|
477
|
+
});
|
|
843
478
|
imported++;
|
|
844
479
|
} catch (err) {
|
|
845
480
|
errors.push(`#${m.id ?? "?"}: ${err.message || String(err)}`);
|
|
846
|
-
// Stop on the first error to avoid spamming AC if its ws is down.
|
|
847
481
|
if (errors.length > 5) break;
|
|
848
482
|
}
|
|
849
|
-
// Tiny delay between sends — AC's ws handler can keep up but
|
|
850
|
-
// 10k messages back-to-back hit the recv buffer hard.
|
|
851
|
-
if (PROJECT_HISTORY_REPLAY_DELAY_MS > 0) {
|
|
852
|
-
await new Promise((r) => setTimeout(r, PROJECT_HISTORY_REPLAY_DELAY_MS));
|
|
853
|
-
}
|
|
854
483
|
}
|
|
855
484
|
// #414 / quadwork#297 — Issue 2: stamp the import marker on the
|
|
856
485
|
// project so a re-import of the same file is caught next time.
|
|
@@ -1073,99 +702,36 @@ router.get("/api/activity/stats", (_req, res) => {
|
|
|
1073
702
|
}
|
|
1074
703
|
});
|
|
1075
704
|
|
|
1076
|
-
router.post("/api/chat",
|
|
705
|
+
router.post("/api/chat", (req, res) => {
|
|
1077
706
|
const projectId = req.query.project || req.body.project;
|
|
1078
|
-
const { url: base, token: sessionToken } = getChattrConfig(projectId);
|
|
1079
|
-
if (!base) return res.status(400).json({ error: "Missing project" });
|
|
1080
|
-
|
|
1081
|
-
// #230: ignore any client-supplied sender. /api/chat is the
|
|
1082
|
-
// dashboard's send path, so the message must always be attributed
|
|
1083
|
-
// to a server-controlled value. Forwarding `req.body.sender` would
|
|
1084
|
-
// let any caller hitting QuadWork's /api/chat impersonate an agent
|
|
1085
|
-
// identity (t1, t3, …) over the AgentChattr ws path, which the
|
|
1086
|
-
// old /api/send flow could not do.
|
|
1087
|
-
//
|
|
1088
|
-
// #405 / quadwork#278: read the operator's display name from the
|
|
1089
|
-
// server-side config file rather than hardcoding "user". The
|
|
1090
|
-
// sanitizer matches AC's registry name validator (1–32 alnum +
|
|
1091
|
-
// dash + underscore) so even a hand-edited config can't post a
|
|
1092
|
-
// value AC will reject (or impersonate an agent), and an empty /
|
|
1093
|
-
// missing value falls back to "user".
|
|
1094
|
-
let operatorSender = "user";
|
|
1095
|
-
try {
|
|
1096
|
-
const cfg = readConfigFile();
|
|
1097
|
-
operatorSender = sanitizeOperatorName(cfg.operator_name);
|
|
1098
|
-
} catch {
|
|
1099
|
-
// non-fatal — fall through to "user"
|
|
1100
|
-
}
|
|
1101
|
-
// #397 / quadwork#262: pass reply_to through to AgentChattr so the
|
|
1102
|
-
// dashboard's reply button mirrors AC's native threaded-reply
|
|
1103
|
-
// behavior. Only forward when it's a real positive integer — guards
|
|
1104
|
-
// against arbitrary client payloads.
|
|
1105
|
-
const replyToRaw = req.body?.reply_to;
|
|
1106
|
-
const replyTo = (typeof replyToRaw === "number" && Number.isInteger(replyToRaw) && replyToRaw > 0)
|
|
1107
|
-
? replyToRaw
|
|
1108
|
-
: null;
|
|
1109
|
-
const message = {
|
|
1110
|
-
text: typeof req.body?.text === "string" ? req.body.text : "",
|
|
1111
|
-
channel: req.body?.channel || "general",
|
|
1112
|
-
sender: operatorSender,
|
|
1113
|
-
attachments: Array.isArray(req.body?.attachments) ? req.body.attachments : [],
|
|
1114
|
-
...(replyTo !== null ? { reply_to: replyTo } : {}),
|
|
1115
|
-
};
|
|
1116
|
-
if (!message.text && message.attachments.length === 0) {
|
|
1117
|
-
return res.status(400).json({ error: "text or attachments required" });
|
|
1118
|
-
}
|
|
1119
707
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// (with server-assigned id/timestamp). Pass it through so
|
|
1130
|
-
// callers regain parity with the old /api/send response body.
|
|
1131
|
-
const result = await attemptSend();
|
|
1132
|
-
return res.json({ ok: true, message: result.message });
|
|
1133
|
-
} catch (err) {
|
|
1134
|
-
// If the cached session_token is stale (AgentChattr regenerates
|
|
1135
|
-
// one on every restart) the ws closes with code 4003 — re-sync
|
|
1136
|
-
// the token from AgentChattr's HTML and retry once before giving
|
|
1137
|
-
// up. This is the actual fix for the "401 after restart" report
|
|
1138
|
-
// in #230 (the cache was stuck on an old token).
|
|
1139
|
-
//
|
|
1140
|
-
// #487: also attempt resync on generic ws errors (connection
|
|
1141
|
-
// refused, ECONNRESET, timeout, etc.) — a 502/5xx from a
|
|
1142
|
-
// temporary AC outage may resolve after a token refresh once AC
|
|
1143
|
-
// is back.
|
|
1144
|
-
const isAuthError = err && err.code === "EAGENTCHATTR_401";
|
|
1145
|
-
const isConnError = !isAuthError && err;
|
|
1146
|
-
if ((isAuthError || isConnError) && projectId) {
|
|
1147
|
-
const tag = isAuthError ? "ws auth failed" : "ws connection error";
|
|
1148
|
-
console.warn(`[chat] ${tag} for project ${projectId}, re-syncing session token and retrying...`);
|
|
1149
|
-
try { await syncChattrToken(projectId); }
|
|
1150
|
-
catch (syncErr) { console.warn(`[chat] syncChattrToken failed: ${syncErr.message}`); }
|
|
1151
|
-
const { token: refreshed } = getChattrConfig(projectId);
|
|
1152
|
-
if (refreshed && refreshed !== sessionToken) {
|
|
1153
|
-
try {
|
|
1154
|
-
const retry = await sendViaWebSocket(base, refreshed, message);
|
|
1155
|
-
return res.json({ ok: true, resynced: true, message: retry.message });
|
|
1156
|
-
} catch (retryErr) {
|
|
1157
|
-
console.warn(`[chat] retry after token resync failed: ${retryErr.message}`);
|
|
1158
|
-
const status = isAuthError ? 401 : 502;
|
|
1159
|
-
return res.status(status).json({ error: "AgentChattr send failed (token resync did not help)", detail: retryErr.message });
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
if (isAuthError) {
|
|
1163
|
-
return res.status(401).json({ error: "AgentChattr auth failed", detail: err.message });
|
|
1164
|
-
}
|
|
708
|
+
const text = typeof req.body?.text === "string" ? req.body.text : "";
|
|
709
|
+
if (!text) return res.status(400).json({ error: "text required" });
|
|
710
|
+
const shimSender = req.headers["x-chat-sender"];
|
|
711
|
+
const shimToken = req.headers["x-chat-token"];
|
|
712
|
+
const bridgeSender = req.headers["x-bridge-sender"];
|
|
713
|
+
let sender = "user";
|
|
714
|
+
if (shimSender && shimToken) {
|
|
715
|
+
if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
|
|
716
|
+
return res.status(403).json({ error: "Invalid shim token" });
|
|
1165
717
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
718
|
+
sender = shimSender;
|
|
719
|
+
} else if (bridgeSender && isLocalhost(req.ip)) {
|
|
720
|
+
sender = bridgeSender;
|
|
1168
721
|
}
|
|
722
|
+
const msg = fileChat.appendMessage(projectId, {
|
|
723
|
+
sender,
|
|
724
|
+
text: normalizeMentions(text),
|
|
725
|
+
channel: req.body?.channel || "general",
|
|
726
|
+
type: "message",
|
|
727
|
+
});
|
|
728
|
+
// #717: loop guard — count agent hops, pause if threshold reached
|
|
729
|
+
const maxHops = getProjectMaxHops(projectId);
|
|
730
|
+
fileChat.checkLoopGuard(projectId, msg, maxHops);
|
|
731
|
+
if (!fileChat.isLoopGuardPaused(projectId)) {
|
|
732
|
+
if (_ptyDispatchCallback) _ptyDispatchCallback(projectId, msg);
|
|
733
|
+
}
|
|
734
|
+
return res.json({ ok: true, message: msg });
|
|
1169
735
|
});
|
|
1170
736
|
|
|
1171
737
|
// ─── Image upload (#466) ──────────────────────────────────────────────────
|
|
@@ -1253,19 +819,11 @@ router.get("/api/projects", async (req, res) => {
|
|
|
1253
819
|
|
|
1254
820
|
// Fetch chat messages from all projects (per-project AgentChattr instances)
|
|
1255
821
|
const chatMsgsByProject = {};
|
|
1256
|
-
const
|
|
1257
|
-
const { url: chattrUrl, token: chattrToken } = getChattrConfig(p.id);
|
|
822
|
+
for (const p of cfg.projects || []) {
|
|
1258
823
|
try {
|
|
1259
|
-
|
|
1260
|
-
const tokenParam = chattrToken ? `&token=${encodeURIComponent(chattrToken)}` : "";
|
|
1261
|
-
const r = await fetch(`${chattrUrl}/api/messages?channel=general&limit=30${tokenParam}`, { headers });
|
|
1262
|
-
if (r.ok) {
|
|
1263
|
-
const data = await r.json();
|
|
1264
|
-
chatMsgsByProject[p.id] = Array.isArray(data) ? data : data.messages || [];
|
|
1265
|
-
}
|
|
824
|
+
chatMsgsByProject[p.id] = fileChat.readMessages(p.id, { limit: 30 });
|
|
1266
825
|
} catch {}
|
|
1267
|
-
}
|
|
1268
|
-
await Promise.allSettled(chatFetches);
|
|
826
|
+
}
|
|
1269
827
|
// Aggregate all project chat messages for the activity feed
|
|
1270
828
|
let chatMsgs = Object.values(chatMsgsByProject).flat();
|
|
1271
829
|
|
|
@@ -2598,98 +2156,6 @@ router.post("/api/setup", (req, res) => {
|
|
|
2598
2156
|
}
|
|
2599
2157
|
return res.json({ ok: true, seeded });
|
|
2600
2158
|
}
|
|
2601
|
-
case "agentchattr-config": {
|
|
2602
|
-
const workingDir = body.workingDir;
|
|
2603
|
-
if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
|
|
2604
|
-
const dirName = path.basename(workingDir);
|
|
2605
|
-
const displayName = body.projectName || dirName;
|
|
2606
|
-
const parentDir = path.dirname(workingDir);
|
|
2607
|
-
const backends = body.backends;
|
|
2608
|
-
|
|
2609
|
-
// Phase 2D / #181: config.toml lives at the per-project AgentChattr
|
|
2610
|
-
// clone ROOT (~/.quadwork/{id}/agentchattr/), not inside the user's
|
|
2611
|
-
// project working_dir. AgentChattr's run.py loads ROOT/config.toml
|
|
2612
|
-
// and ignores --config, so the toml has to be at the same path the
|
|
2613
|
-
// clone lives at. Same path matches what writeQuadWorkConfig()
|
|
2614
|
-
// persists in agentchattr_dir (#182) and what the CLI wizard
|
|
2615
|
-
// writes (#184).
|
|
2616
|
-
//
|
|
2617
|
-
// We install the clone *here*, before writing config.toml. The
|
|
2618
|
-
// install must run first because installAgentChattr() refuses to
|
|
2619
|
-
// overwrite a non-empty directory it doesn't recognize — if we
|
|
2620
|
-
// mkdir + write config.toml first, the subsequent install in
|
|
2621
|
-
// add-config would see "unrelated content" and reject the dir,
|
|
2622
|
-
// breaking first-run web project creation (t2a's review of #195).
|
|
2623
|
-
const projectConfigDir = path.join(CONFIG_DIR, dirName, "agentchattr");
|
|
2624
|
-
if (!findAgentChattr(projectConfigDir)) {
|
|
2625
|
-
const installResult = installAgentChattr(projectConfigDir);
|
|
2626
|
-
if (!installResult) {
|
|
2627
|
-
const reason = installAgentChattr.lastError || "unknown error";
|
|
2628
|
-
return res.json({ ok: false, error: `AgentChattr install failed at ${projectConfigDir}: ${reason}` });
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
const dataDir = path.join(projectConfigDir, "data");
|
|
2632
|
-
ensureSecureDir(dataDir);
|
|
2633
|
-
const tomlPath = path.join(projectConfigDir, "config.toml");
|
|
2634
|
-
|
|
2635
|
-
// Resolve per-project ports: prefer explicit body params (from setup wizard),
|
|
2636
|
-
// then fall back to saved config, then defaults
|
|
2637
|
-
let chattrPort, mcp_http, mcp_sse;
|
|
2638
|
-
if (body.agentchattr_port) {
|
|
2639
|
-
chattrPort = String(body.agentchattr_port);
|
|
2640
|
-
mcp_http = body.mcp_http_port || 8200;
|
|
2641
|
-
mcp_sse = body.mcp_sse_port || 8201;
|
|
2642
|
-
} else {
|
|
2643
|
-
const projectChattr = resolveProjectChattr(dirName);
|
|
2644
|
-
chattrPort = new URL(projectChattr.url).port || "8300";
|
|
2645
|
-
mcp_http = projectChattr.mcp_http_port || 8200;
|
|
2646
|
-
mcp_sse = projectChattr.mcp_sse_port || 8201;
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
const agents = ["head", "re1", "re2", "dev"];
|
|
2650
|
-
const colors = ["#10a37f", "#22c55e", "#f59e0b", "#da7756"];
|
|
2651
|
-
const labels = ["Lead", "Reviewer 1", "Reviewer 2", "Builder"];
|
|
2652
|
-
|
|
2653
|
-
// Read or generate token for this project
|
|
2654
|
-
const crypto = require("crypto");
|
|
2655
|
-
const savedCfg = readConfigFile();
|
|
2656
|
-
const savedProject = savedCfg.projects?.find((p) => p.id === dirName);
|
|
2657
|
-
const sessionToken = body.agentchattr_token || savedProject?.agentchattr_token || crypto.randomBytes(16).toString("hex");
|
|
2658
|
-
|
|
2659
|
-
let content = `[meta]\nname = "${displayName}"\n\n`;
|
|
2660
|
-
content += `[server]\nport = ${chattrPort}\nhost = "127.0.0.1"\ndata_dir = "${dataDir}"\n`;
|
|
2661
|
-
if (sessionToken) content += `session_token = "${sessionToken}"\n`;
|
|
2662
|
-
content += `\n`;
|
|
2663
|
-
agents.forEach((agent, i) => {
|
|
2664
|
-
const wtDir = path.join(parentDir, `${dirName}-${agent}`);
|
|
2665
|
-
content += `[agents.${agent}]\ncommand = "${(backends && backends[agent]) || "claude"}"\ncwd = "${wtDir}"\ncolor = "${colors[i]}"\nlabel = "${labels[i]}"\nmcp_inject = "flag"\n\n`;
|
|
2666
|
-
});
|
|
2667
|
-
// #592: CLI-based agent sections for AC HEAD compatibility.
|
|
2668
|
-
// HEAD AC validates `base` against [agents.*] keys. Add CLI-name
|
|
2669
|
-
// sections (deduped) so registration works on both pinned and HEAD AC.
|
|
2670
|
-
const seenClis = new Set();
|
|
2671
|
-
agents.forEach((agent) => {
|
|
2672
|
-
const cmd = (backends && backends[agent]) || "claude";
|
|
2673
|
-
const cli = cmd.split("/").pop().split(" ")[0];
|
|
2674
|
-
seenClis.add(cli);
|
|
2675
|
-
});
|
|
2676
|
-
for (const cli of seenClis) {
|
|
2677
|
-
const injectMode = cli === "codex" ? "proxy_flag" : cli === "gemini" ? "env" : "flag";
|
|
2678
|
-
content += `[agents.${cli}]\ncommand = "${cli}"\nlabel = "${cli}"\nmcp_inject = "${injectMode}"\n\n`;
|
|
2679
|
-
}
|
|
2680
|
-
// #403 / quadwork#274: raise the loop guard from AC's default
|
|
2681
|
-
// of 4 to 30 so autonomous PR review cycles (head→dev→re1+re2→
|
|
2682
|
-
// dev→head, ~5 hops) don't fire mid-batch and force the
|
|
2683
|
-
// operator to type /continue. AC clamps to [1, 50] internally.
|
|
2684
|
-
content += `[routing]\ndefault = "none"\nmax_agent_hops = 30\n\n`;
|
|
2685
|
-
content += `[mcp]\nhttp_port = ${mcp_http}\nsse_port = ${mcp_sse}\n`;
|
|
2686
|
-
// #607: Bridge agent declarations so AC accepts dc/tg base registration
|
|
2687
|
-
content += `\n[agents.tg]\nlabel = "Telegram Bridge"\n\n`;
|
|
2688
|
-
content += `[agents.dc]\nlabel = "Discord Bridge"\n`;
|
|
2689
|
-
writeSecureFile(tomlPath, content);
|
|
2690
|
-
|
|
2691
|
-
return res.json({ ok: true, path: tomlPath, agentchattr_token: sessionToken, agentchattr_port: chattrPort, mcp_http_port: mcp_http, mcp_sse_port: mcp_sse });
|
|
2692
|
-
}
|
|
2693
2159
|
case "add-config": {
|
|
2694
2160
|
const { id, name, repo, workingDir, backends } = body;
|
|
2695
2161
|
const autoApprove = body.auto_approve !== false; // default true
|
|
@@ -2698,7 +2164,7 @@ router.post("/api/setup", (req, res) => {
|
|
|
2698
2164
|
const parentDir = path.dirname(workingDir);
|
|
2699
2165
|
let cfg;
|
|
2700
2166
|
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
|
|
2701
|
-
catch { cfg = { port: 8400,
|
|
2167
|
+
catch { cfg = { port: 8400, projects: [] }; }
|
|
2702
2168
|
if (cfg.projects.some((p) => p.id === id)) {
|
|
2703
2169
|
// Project already saved, but still (idempotently) seed the
|
|
2704
2170
|
// OVERNIGHT-QUEUE.md in case a previous run failed to write
|
|
@@ -2728,47 +2194,9 @@ router.post("/api/setup", (req, res) => {
|
|
|
2728
2194
|
...(cliBase === "codex" ? { reasoning_effort: "medium" } : {}),
|
|
2729
2195
|
};
|
|
2730
2196
|
}
|
|
2731
|
-
// Use pre-assigned ports/token from agentchattr-config step if provided,
|
|
2732
|
-
// otherwise auto-assign (direct add-config without prior agentchattr-config)
|
|
2733
|
-
const crypto = require("crypto");
|
|
2734
|
-
let chattrPort = body.agentchattr_port;
|
|
2735
|
-
let mcp_http_port = body.mcp_http_port;
|
|
2736
|
-
let mcp_sse_port = body.mcp_sse_port;
|
|
2737
|
-
let agentchattr_token = body.agentchattr_token;
|
|
2738
|
-
if (!chattrPort) {
|
|
2739
|
-
const usedChattrPorts = new Set(cfg.projects.map((p) => {
|
|
2740
|
-
try { return parseInt(new URL(p.agentchattr_url).port, 10); } catch { return 0; }
|
|
2741
|
-
}).filter(Boolean));
|
|
2742
|
-
const usedMcpPorts = new Set(cfg.projects.flatMap((p) => [p.mcp_http_port, p.mcp_sse_port]).filter(Boolean));
|
|
2743
|
-
chattrPort = 8300;
|
|
2744
|
-
while (usedChattrPorts.has(chattrPort)) chattrPort++;
|
|
2745
|
-
mcp_http_port = 8200;
|
|
2746
|
-
while (usedMcpPorts.has(mcp_http_port)) mcp_http_port++;
|
|
2747
|
-
mcp_sse_port = mcp_http_port + 1;
|
|
2748
|
-
while (usedMcpPorts.has(mcp_sse_port)) mcp_sse_port++;
|
|
2749
|
-
}
|
|
2750
|
-
if (!agentchattr_token) agentchattr_token = crypto.randomBytes(16).toString("hex");
|
|
2751
|
-
|
|
2752
|
-
// Phase 2D / #181: clone AgentChattr per-project before saving config.
|
|
2753
|
-
// The path here must match the one written into agentchattr_dir below
|
|
2754
|
-
// and the one agentchattr-config writes config.toml into.
|
|
2755
|
-
const perProjectDir = path.join(CONFIG_DIR, id, "agentchattr");
|
|
2756
|
-
if (!findAgentChattr(perProjectDir)) {
|
|
2757
|
-
const installResult = installAgentChattr(perProjectDir);
|
|
2758
|
-
if (!installResult) {
|
|
2759
|
-
const reason = installAgentChattr.lastError || "unknown error";
|
|
2760
|
-
return res.json({ ok: false, error: `AgentChattr install failed at ${perProjectDir}: ${reason}` });
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
|
|
2764
2197
|
cfg.projects.push({
|
|
2765
2198
|
id, name, repo, working_dir: workingDir, agents,
|
|
2766
|
-
|
|
2767
|
-
agentchattr_token,
|
|
2768
|
-
mcp_http_port,
|
|
2769
|
-
mcp_sse_port,
|
|
2770
|
-
// Per-project AgentChattr clone path (Option B / #181).
|
|
2771
|
-
agentchattr_dir: perProjectDir,
|
|
2199
|
+
chat_mode: "file",
|
|
2772
2200
|
});
|
|
2773
2201
|
const dir = path.dirname(CONFIG_PATH);
|
|
2774
2202
|
ensureSecureDir(dir);
|
|
@@ -2778,44 +2206,6 @@ router.post("/api/setup", (req, res) => {
|
|
|
2778
2206
|
// ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
|
|
2779
2207
|
writeOvernightQueueFileSafe(id, name || id, repo);
|
|
2780
2208
|
|
|
2781
|
-
// Batch 28 / #392 / quadwork#252: auto-spawn the per-project
|
|
2782
|
-
// AgentChattr process. The CLI wizard's writeAgentChattrConfig
|
|
2783
|
-
// does this; the web wizard previously left the install dormant
|
|
2784
|
-
// until the user clicked Restart, so MCP fell through to a stale
|
|
2785
|
-
// instance on port 8300. Mirror the loopback-restart pattern
|
|
2786
|
-
// already used by the agentchattr-config branch above. Failures
|
|
2787
|
-
// are non-fatal — the dashboard's Restart button is still
|
|
2788
|
-
// available, and per the issue add-config must still return ok.
|
|
2789
|
-
try {
|
|
2790
|
-
const qwPort = cfg.port || 8400;
|
|
2791
|
-
fetch(
|
|
2792
|
-
`http://127.0.0.1:${qwPort}/api/agentchattr/${encodeURIComponent(id)}/restart`,
|
|
2793
|
-
{ method: "POST" },
|
|
2794
|
-
)
|
|
2795
|
-
.then(async (r) => {
|
|
2796
|
-
// /restart reports spawn failures (e.g. port collision —
|
|
2797
|
-
// server/index.js:650-668) as HTTP 500, so a resolved
|
|
2798
|
-
// fetch is not the same thing as a successful spawn. Log
|
|
2799
|
-
// non-2xx responses with status and body so the operator
|
|
2800
|
-
// can see why the auto-spawn silently didn't take.
|
|
2801
|
-
if (!r.ok) {
|
|
2802
|
-
let detail = "";
|
|
2803
|
-
try { detail = (await r.text()).slice(0, 500); } catch {}
|
|
2804
|
-
console.warn(
|
|
2805
|
-
`[setup] auto-spawn AgentChattr for ${id} returned HTTP ${r.status}: ${detail}`,
|
|
2806
|
-
);
|
|
2807
|
-
}
|
|
2808
|
-
})
|
|
2809
|
-
.catch((err) => {
|
|
2810
|
-
console.warn(
|
|
2811
|
-
`[setup] auto-spawn AgentChattr for ${id} failed:`,
|
|
2812
|
-
err.message || err,
|
|
2813
|
-
);
|
|
2814
|
-
});
|
|
2815
|
-
} catch (err) {
|
|
2816
|
-
console.warn(`[setup] auto-spawn AgentChattr for ${id} skipped:`, err.message || err);
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
2209
|
return res.json({ ok: true });
|
|
2820
2210
|
}
|
|
2821
2211
|
default:
|
|
@@ -2925,172 +2315,6 @@ router.post("/api/rename", (req, res) => {
|
|
|
2925
2315
|
|
|
2926
2316
|
// ─── Telegram ──────────────────────────────────────────────────────────────
|
|
2927
2317
|
|
|
2928
|
-
const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
|
|
2929
|
-
// #444: pin agentchattr-telegram to a known commit (same pattern as
|
|
2930
|
-
// AGENTCHATTR_PIN in bin/quadwork.js for bcurts/agentchattr).
|
|
2931
|
-
const AGENTCHATTR_TELEGRAM_PIN = "045ee18f6d5dbcd0bd45d5ab29f06e2a27382aaf";
|
|
2932
|
-
|
|
2933
|
-
function telegramPidFile(projectId) {
|
|
2934
|
-
return path.join(CONFIG_DIR, `tg-bridge-${projectId}.pid`);
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
function telegramConfigToml(projectId) {
|
|
2938
|
-
return path.join(CONFIG_DIR, `telegram-${projectId}.toml`);
|
|
2939
|
-
}
|
|
2940
|
-
|
|
2941
|
-
// #383: path to a project's AgentChattr config.toml. The install
|
|
2942
|
-
// handler patches this file to declare the `tg` agent
|
|
2943
|
-
// so AC's registry accepts the bridge's register call.
|
|
2944
|
-
function projectAgentchattrConfigPath(projectId) {
|
|
2945
|
-
return path.join(CONFIG_DIR, projectId, "agentchattr", "config.toml");
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
// #383 Bug 1: prefer the per-project agentchattr_url. Every project
|
|
2949
|
-
// after the first uses a distinct port (8301, 8302, ...), so reading
|
|
2950
|
-
// the global default silently routed bridge traffic to the wrong AC
|
|
2951
|
-
// instance.
|
|
2952
|
-
function resolveProjectAgentchattrUrl(cfg, project) {
|
|
2953
|
-
return (
|
|
2954
|
-
(project && project.agentchattr_url) ||
|
|
2955
|
-
(cfg && cfg.agentchattr_url) ||
|
|
2956
|
-
"http://127.0.0.1:8300"
|
|
2957
|
-
);
|
|
2958
|
-
}
|
|
2959
|
-
|
|
2960
|
-
// #383 Bug 2: the upstream bridge only reads `agentchattr_url` from
|
|
2961
|
-
// inside `[telegram]`. A separate `[agentchattr]` section is silently
|
|
2962
|
-
// ignored and the bridge falls back to its hardcoded :8300 default.
|
|
2963
|
-
// #404: accept projectId so we can write a per-project cursor_file
|
|
2964
|
-
// path. Without this, multiple project bridges share the same default
|
|
2965
|
-
// cursor and clobber each other's position — the project with higher
|
|
2966
|
-
// AC message IDs advances the cursor past the other project's range,
|
|
2967
|
-
// silently killing AC→TG forwarding for that project.
|
|
2968
|
-
function buildTelegramBridgeToml(tg, projectId) {
|
|
2969
|
-
const cursorFile = path.join(CONFIG_DIR, `tg-bridge-cursor-${projectId}.json`);
|
|
2970
|
-
// #439: migrate old cursor file so the bridge doesn't replay history
|
|
2971
|
-
const oldCursor = path.join(CONFIG_DIR, `telegram-bridge-cursor-${projectId}.json`);
|
|
2972
|
-
if (!fs.existsSync(cursorFile) && fs.existsSync(oldCursor)) {
|
|
2973
|
-
fs.renameSync(oldCursor, cursorFile);
|
|
2974
|
-
}
|
|
2975
|
-
return (
|
|
2976
|
-
`[telegram]\n` +
|
|
2977
|
-
`bot_token = "${tg.bot_token}"\n` +
|
|
2978
|
-
`chat_id = "${tg.chat_id}"\n` +
|
|
2979
|
-
`agentchattr_url = "${tg.agentchattr_url}"\n` +
|
|
2980
|
-
`cursor_file = "${cursorFile}"\n` +
|
|
2981
|
-
`project_id = "${projectId}"\n`
|
|
2982
|
-
);
|
|
2983
|
-
}
|
|
2984
|
-
|
|
2985
|
-
// #383 Bug 3: AC's registry rejects any base name not pre-declared
|
|
2986
|
-
// in config.toml with `400 unknown base`. The bridge registers as
|
|
2987
|
-
// `tg` (#439: renamed from `telegram-bridge`), so every per-project
|
|
2988
|
-
// AC config must declare it. Idempotent: only appends if the section
|
|
2989
|
-
// is not already present. Also migrates old `[agents.telegram-bridge]`.
|
|
2990
|
-
function patchAgentchattrConfigForTelegramBridge(tomlText) {
|
|
2991
|
-
// #439: migrate old slug if present
|
|
2992
|
-
const original = tomlText;
|
|
2993
|
-
tomlText = tomlText.replace(/^\[agents\.telegram-bridge\]\s*$/m, "[agents.tg]");
|
|
2994
|
-
if (/^\[agents\.tg\]\s*$/m.test(tomlText)) {
|
|
2995
|
-
return { text: tomlText, changed: tomlText !== original };
|
|
2996
|
-
}
|
|
2997
|
-
const sep = tomlText.length === 0 || tomlText.endsWith("\n") ? "" : "\n";
|
|
2998
|
-
const block = `\n[agents.tg]\nlabel = "Telegram Bridge"\n`;
|
|
2999
|
-
return { text: tomlText + sep + block, changed: true };
|
|
3000
|
-
}
|
|
3001
|
-
|
|
3002
|
-
// #383 Bug 4: the upstream bridge treats env vars as higher
|
|
3003
|
-
// precedence than TOML values. If the parent shell exported
|
|
3004
|
-
// TELEGRAM_BOT_TOKEN for a different bot, the bridge silently ran
|
|
3005
|
-
// as the wrong identity. Scrub those keys from the child's env so
|
|
3006
|
-
// the TOML is the single source of truth.
|
|
3007
|
-
function buildTelegramBridgeSpawnEnv(parentEnv) {
|
|
3008
|
-
const env = { ...parentEnv };
|
|
3009
|
-
delete env.TELEGRAM_BOT_TOKEN;
|
|
3010
|
-
delete env.TELEGRAM_CHAT_ID;
|
|
3011
|
-
delete env.AGENTCHATTR_URL;
|
|
3012
|
-
return env;
|
|
3013
|
-
}
|
|
3014
|
-
|
|
3015
|
-
// #353: per-project log file for the bridge subprocess. The start
|
|
3016
|
-
// handler redirects stdout + stderr here so crashes (ImportError,
|
|
3017
|
-
// config parse, auth failure) are recoverable instead of
|
|
3018
|
-
// /dev/null'd by `stdio: "ignore"`.
|
|
3019
|
-
function telegramBridgeLog(projectId) {
|
|
3020
|
-
return path.join(CONFIG_DIR, `tg-bridge-${projectId}.log`);
|
|
3021
|
-
}
|
|
3022
|
-
|
|
3023
|
-
// Tail the last N lines of a file without reading the whole thing
|
|
3024
|
-
// into memory if it is huge. For the bridge log we care about the
|
|
3025
|
-
// final crash frame, not historical output.
|
|
3026
|
-
function readLastLines(filePath, n) {
|
|
3027
|
-
try {
|
|
3028
|
-
if (!fs.existsSync(filePath)) return "";
|
|
3029
|
-
const stat = fs.statSync(filePath);
|
|
3030
|
-
const readBytes = Math.min(stat.size, 64 * 1024);
|
|
3031
|
-
if (readBytes === 0) return "";
|
|
3032
|
-
const buf = Buffer.alloc(readBytes);
|
|
3033
|
-
const fd = fs.openSync(filePath, "r");
|
|
3034
|
-
try {
|
|
3035
|
-
fs.readSync(fd, buf, 0, readBytes, Math.max(0, stat.size - readBytes));
|
|
3036
|
-
} finally {
|
|
3037
|
-
fs.closeSync(fd);
|
|
3038
|
-
}
|
|
3039
|
-
const text = buf.toString("utf-8");
|
|
3040
|
-
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
3041
|
-
return lines.slice(-n).join("\n");
|
|
3042
|
-
} catch {
|
|
3043
|
-
return "";
|
|
3044
|
-
}
|
|
3045
|
-
}
|
|
3046
|
-
|
|
3047
|
-
// Verify that the bridge's Python runtime has its required modules
|
|
3048
|
-
// available. Cheap pre-flight so a missing `requests` install
|
|
3049
|
-
// produces a readable error instead of a silent Start → Stopped
|
|
3050
|
-
// flicker. Returns { ok: true } on success, { ok: false, error }
|
|
3051
|
-
// otherwise. Keep the import list small and close to what the
|
|
3052
|
-
// bridge actually needs; add modules here if the bridge gains new
|
|
3053
|
-
// hard deps.
|
|
3054
|
-
// #380: `pythonPath` defaults to bare `python3` for backward-compat,
|
|
3055
|
-
// but the production call sites (install, start) MUST pass the
|
|
3056
|
-
// dedicated bridge venv's interpreter (`<BRIDGE_DIR>/.venv/bin/python3`)
|
|
3057
|
-
// so the import check runs against the same interpreter the spawn will
|
|
3058
|
-
// use. See #379 research ticket for root cause.
|
|
3059
|
-
function checkTelegramBridgePythonDeps(pythonPath = "python3") {
|
|
3060
|
-
try {
|
|
3061
|
-
// Only check the third-party module the bridge actually needs
|
|
3062
|
-
// at import time — `requests`. Toml parsing differs between
|
|
3063
|
-
// Python versions (tomllib on 3.11+, tomli on 3.10-), and any
|
|
3064
|
-
// genuine toml import failure will now be captured in the
|
|
3065
|
-
// bridge log file on spawn, so this pre-flight stays narrow
|
|
3066
|
-
// and avoids false negatives on older Python installs.
|
|
3067
|
-
execFileSync(pythonPath, ["-c", "import requests"], {
|
|
3068
|
-
encoding: "utf-8",
|
|
3069
|
-
timeout: 10000,
|
|
3070
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3071
|
-
});
|
|
3072
|
-
return { ok: true };
|
|
3073
|
-
} catch (err) {
|
|
3074
|
-
const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
|
|
3075
|
-
const msg = stderr.trim() || (err && err.message) || "python3 import check failed";
|
|
3076
|
-
return { ok: false, error: msg };
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
|
|
3080
|
-
function isTelegramRunning(projectId) {
|
|
3081
|
-
const pf = telegramPidFile(projectId);
|
|
3082
|
-
if (!fs.existsSync(pf)) return false;
|
|
3083
|
-
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
3084
|
-
if (!pid) return false;
|
|
3085
|
-
try {
|
|
3086
|
-
process.kill(pid, 0);
|
|
3087
|
-
return true;
|
|
3088
|
-
} catch {
|
|
3089
|
-
fs.unlinkSync(pf);
|
|
3090
|
-
return false;
|
|
3091
|
-
}
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
2318
|
function readEnvToken(key) {
|
|
3095
2319
|
try {
|
|
3096
2320
|
const content = fs.readFileSync(ENV_PATH, "utf-8");
|
|
@@ -3128,8 +2352,6 @@ function getProjectTelegram(projectId) {
|
|
|
3128
2352
|
return {
|
|
3129
2353
|
bot_token: resolveToken(project.telegram.bot_token || ""),
|
|
3130
2354
|
chat_id: project.telegram.chat_id || "",
|
|
3131
|
-
// #383 Bug 1: prefer per-project URL over the global default.
|
|
3132
|
-
agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
|
|
3133
2355
|
};
|
|
3134
2356
|
} catch {
|
|
3135
2357
|
return null;
|
|
@@ -3156,11 +2378,8 @@ router.get("/api/telegram", async (req, res) => {
|
|
|
3156
2378
|
chatId = project.telegram.chat_id;
|
|
3157
2379
|
botUsername = project.telegram.bot_username || "";
|
|
3158
2380
|
}
|
|
3159
|
-
bridgeInstalled =
|
|
2381
|
+
bridgeInstalled = true;
|
|
3160
2382
|
} catch {}
|
|
3161
|
-
// Lazy-resolve bot username via Telegram getMe the first time
|
|
3162
|
-
// after a token is saved. Cache it on the project entry so later
|
|
3163
|
-
// requests don't hit the network.
|
|
3164
2383
|
if (configured && !botUsername && project?.telegram?.bot_token && cfg) {
|
|
3165
2384
|
try {
|
|
3166
2385
|
const resolved = resolveToken(project.telegram.bot_token);
|
|
@@ -3173,24 +2392,10 @@ router.get("/api/telegram", async (req, res) => {
|
|
|
3173
2392
|
try { writeConfig(cfg); } catch {}
|
|
3174
2393
|
}
|
|
3175
2394
|
}
|
|
3176
|
-
} catch { /* non-fatal — widget will just show no username */ }
|
|
3177
|
-
}
|
|
3178
|
-
// #353: if the bridge is not running but a log file exists with
|
|
3179
|
-
// content, tail it and expose it as `last_error` so the widget
|
|
3180
|
-
// can surface runtime crashes (bad token mid-session, network
|
|
3181
|
-
// failure, config parse error) that happen after the initial
|
|
3182
|
-
// 500 ms post-spawn liveness check and would otherwise just
|
|
3183
|
-
// revert the pill to Stopped with no explanation.
|
|
3184
|
-
const running = isTelegramRunning(projectId);
|
|
3185
|
-
let lastError = "";
|
|
3186
|
-
if (!running) {
|
|
3187
|
-
const logPath = telegramBridgeLog(projectId);
|
|
3188
|
-
try {
|
|
3189
|
-
if (fs.existsSync(logPath) && fs.statSync(logPath).size > 0) {
|
|
3190
|
-
lastError = readLastLines(logPath, 20);
|
|
3191
|
-
}
|
|
3192
2395
|
} catch {}
|
|
3193
2396
|
}
|
|
2397
|
+
const running = telegramBridge.isRunning(projectId);
|
|
2398
|
+
const lastError = running ? "" : (telegramBridge.getLastError(projectId) || "");
|
|
3194
2399
|
res.json({
|
|
3195
2400
|
running,
|
|
3196
2401
|
configured,
|
|
@@ -3220,238 +2425,37 @@ router.post("/api/telegram", async (req, res) => {
|
|
|
3220
2425
|
}
|
|
3221
2426
|
}
|
|
3222
2427
|
case "install": {
|
|
3223
|
-
|
|
3224
|
-
// `<BRIDGE_DIR>/.venv` and install requirements into it using
|
|
3225
|
-
// that venv's pip. All bridge subprocesses then spawn with
|
|
3226
|
-
// `<BRIDGE_DIR>/.venv/bin/python3` by absolute path. See #379
|
|
3227
|
-
// research ticket for the root cause — bare `python3` / `pip3`
|
|
3228
|
-
// resolve to Homebrew Python on modern macOS where `requests`
|
|
3229
|
-
// is not available, producing a ModuleNotFoundError on Start.
|
|
3230
|
-
// Idempotent: existing installs missing a `.venv` get the venv
|
|
3231
|
-
// created on top of the existing clone without re-cloning.
|
|
3232
|
-
const venvDir = path.join(BRIDGE_DIR, ".venv");
|
|
3233
|
-
const venvPython = path.join(venvDir, "bin", "python3");
|
|
3234
|
-
const venvPip = path.join(venvDir, "bin", "pip");
|
|
3235
|
-
let pipOutput = "";
|
|
3236
|
-
try {
|
|
3237
|
-
if (!fs.existsSync(BRIDGE_DIR)) {
|
|
3238
|
-
execFileSync("gh", ["repo", "clone", "realproject7/agentchattr-telegram", BRIDGE_DIR], { encoding: "utf-8", timeout: 30000 });
|
|
3239
|
-
}
|
|
3240
|
-
// #444 / #470: pin to a known commit — on fresh clone AND on
|
|
3241
|
-
// upgrade (existing clone may be on an older pin with stale
|
|
3242
|
-
// bridge_sender defaults).
|
|
3243
|
-
try {
|
|
3244
|
-
execFileSync("git", ["-C", BRIDGE_DIR, "fetch", "origin"], { encoding: "utf-8", timeout: 30000 });
|
|
3245
|
-
execFileSync("git", ["-C", BRIDGE_DIR, "checkout", "-B", "pinned", AGENTCHATTR_TELEGRAM_PIN], { encoding: "utf-8", timeout: 30000 });
|
|
3246
|
-
} catch {
|
|
3247
|
-
console.warn(`[telegram] WARNING: could not check out agentchattr-telegram pin ${AGENTCHATTR_TELEGRAM_PIN}; falling back to default branch.`);
|
|
3248
|
-
}
|
|
3249
|
-
// #380: create the dedicated venv if missing. `python3 -m venv`
|
|
3250
|
-
// builds a fresh isolated environment that bypasses PEP 668
|
|
3251
|
-
// externally-managed markers, so this works even on Homebrew
|
|
3252
|
-
// Python where bare `pip3 install` would be blocked.
|
|
3253
|
-
if (!fs.existsSync(venvPython)) {
|
|
3254
|
-
execFileSync("python3", ["-m", "venv", venvDir], { encoding: "utf-8", timeout: 60000 });
|
|
3255
|
-
}
|
|
3256
|
-
pipOutput = execFileSync(
|
|
3257
|
-
venvPip,
|
|
3258
|
-
["install", "-r", path.join(BRIDGE_DIR, "requirements.txt")],
|
|
3259
|
-
{ encoding: "utf-8", timeout: 120000 },
|
|
3260
|
-
);
|
|
3261
|
-
} catch (err) {
|
|
3262
|
-
const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
|
|
3263
|
-
return res.json({ ok: false, error: (stderr.trim() || err.message || "Install failed") });
|
|
3264
|
-
}
|
|
3265
|
-
const depCheck = checkTelegramBridgePythonDeps(venvPython);
|
|
3266
|
-
if (!depCheck.ok) {
|
|
3267
|
-
return res.json({
|
|
3268
|
-
ok: false,
|
|
3269
|
-
error:
|
|
3270
|
-
"pip reported success but the bridge venv's Python deps still fail to import. " +
|
|
3271
|
-
"This is unexpected for a freshly-created venv — check disk space and permissions " +
|
|
3272
|
-
`on ${venvDir}.\n\n` +
|
|
3273
|
-
`Import error: ${depCheck.error}\n\n` +
|
|
3274
|
-
`pip output tail:\n${pipOutput.split("\n").slice(-10).join("\n")}`,
|
|
3275
|
-
});
|
|
3276
|
-
}
|
|
3277
|
-
// #383 Bug 3 / #457: ensure every known project's AC config
|
|
3278
|
-
// declares the `tg` agent and migrates old `telegram-bridge`
|
|
3279
|
-
// slug. Restarts AC for projects whose config changed so the
|
|
3280
|
-
// new slug loads immediately.
|
|
3281
|
-
const patched = [];
|
|
3282
|
-
try {
|
|
3283
|
-
const cfgAll = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
3284
|
-
const serverPort = cfgAll.port || 8400;
|
|
3285
|
-
for (const proj of cfgAll.projects || []) {
|
|
3286
|
-
if (!proj || !proj.id) continue;
|
|
3287
|
-
const acPath = projectAgentchattrConfigPath(proj.id);
|
|
3288
|
-
if (!fs.existsSync(acPath)) continue;
|
|
3289
|
-
try {
|
|
3290
|
-
const before = fs.readFileSync(acPath, "utf-8");
|
|
3291
|
-
const { text, changed } = patchAgentchattrConfigForTelegramBridge(before);
|
|
3292
|
-
if (changed) {
|
|
3293
|
-
fs.writeFileSync(acPath, text);
|
|
3294
|
-
patched.push(proj.id);
|
|
3295
|
-
// #457: restart AC so it loads the new agent slug
|
|
3296
|
-
setTimeout(async () => {
|
|
3297
|
-
try {
|
|
3298
|
-
await fetch(`http://127.0.0.1:${serverPort}/api/agentchattr/${encodeURIComponent(proj.id)}/restart`, {
|
|
3299
|
-
method: "POST",
|
|
3300
|
-
});
|
|
3301
|
-
} catch {}
|
|
3302
|
-
}, 1000);
|
|
3303
|
-
}
|
|
3304
|
-
} catch {}
|
|
3305
|
-
}
|
|
3306
|
-
} catch {}
|
|
3307
|
-
return res.json({ ok: true, patched_projects: patched });
|
|
2428
|
+
return res.json({ ok: true, patched_projects: [] });
|
|
3308
2429
|
}
|
|
3309
2430
|
case "start": {
|
|
3310
2431
|
const projectId = body.project_id;
|
|
3311
2432
|
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
3312
|
-
if (
|
|
3313
|
-
const bridgeScript = path.join(BRIDGE_DIR, "telegram_bridge.py");
|
|
3314
|
-
if (!fs.existsSync(bridgeScript)) return res.json({ ok: false, error: "Bridge not installed. Click Install Bridge first." });
|
|
3315
|
-
// #380: resolve the dedicated venv's python3 by absolute path.
|
|
3316
|
-
// Do NOT activate the venv or set VIRTUAL_ENV in the parent —
|
|
3317
|
-
// calling the venv's python3 directly is sufficient because
|
|
3318
|
-
// Python's sys.executable bootstrap resolves the venv
|
|
3319
|
-
// automatically. See #379 research ticket.
|
|
3320
|
-
const venvPython = path.join(BRIDGE_DIR, ".venv", "bin", "python3");
|
|
3321
|
-
if (!fs.existsSync(venvPython)) {
|
|
3322
|
-
return res.json({
|
|
3323
|
-
ok: false,
|
|
3324
|
-
error: "Bridge venv missing. Click \"Install Bridge\" to create it.",
|
|
3325
|
-
});
|
|
3326
|
-
}
|
|
2433
|
+
if (telegramBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
|
|
3327
2434
|
const tg = getProjectTelegram(projectId);
|
|
3328
2435
|
if (!tg || !tg.bot_token || !tg.chat_id) return res.json({ ok: false, error: "Save bot_token and chat_id in project settings first." });
|
|
3329
|
-
const tomlPath = telegramConfigToml(projectId);
|
|
3330
|
-
// #383 Bug 2: write agentchattr_url inside [telegram]; the
|
|
3331
|
-
// bridge's load_config only reads from that section.
|
|
3332
|
-
const tomlContent = buildTelegramBridgeToml(tg, projectId);
|
|
3333
|
-
writeSecureFile(tomlPath, tomlContent);
|
|
3334
|
-
// #353: pre-flight import check so a fresh install with no
|
|
3335
|
-
// `requests` module produces a readable error instead of the
|
|
3336
|
-
// Start → Running → Stopped flicker that the v1 code path
|
|
3337
|
-
// produced with `stdio: "ignore"`.
|
|
3338
|
-
const depCheck = checkTelegramBridgePythonDeps(venvPython);
|
|
3339
|
-
if (!depCheck.ok) {
|
|
3340
|
-
// #372: persist the pre-flight failure to the bridge log
|
|
3341
|
-
// file so the GET /api/telegram `last_error` tail picks it
|
|
3342
|
-
// up on the next status poll. Without this the widget only
|
|
3343
|
-
// sees the error for ~5s before the polling cycle clobbers
|
|
3344
|
-
// local error state, producing the "silent fail" symptom
|
|
3345
|
-
// (pill flips back to Stopped with no trace of why).
|
|
3346
|
-
const msg =
|
|
3347
|
-
"Bridge Python dependencies not installed in the dedicated venv. " +
|
|
3348
|
-
"Click \"Install Bridge\" to (re)create the venv and install them.\n\n" +
|
|
3349
|
-
`Import error: ${depCheck.error}`;
|
|
3350
|
-
try {
|
|
3351
|
-
fs.writeFileSync(
|
|
3352
|
-
telegramBridgeLog(projectId),
|
|
3353
|
-
`[${new Date().toISOString()}] pre-flight dep check failed\n${msg}\n`,
|
|
3354
|
-
);
|
|
3355
|
-
} catch {}
|
|
3356
|
-
return res.json({ ok: false, error: msg });
|
|
3357
|
-
}
|
|
3358
|
-
// #353: capture stdout + stderr to a per-project log file so
|
|
3359
|
-
// bridge crashes (bad token, network failure, config parse
|
|
3360
|
-
// error, etc.) are recoverable. The handle must be opened
|
|
3361
|
-
// BEFORE spawn and passed through stdio so the detached
|
|
3362
|
-
// child keeps writing after the parent unrefs it.
|
|
3363
|
-
const logPath = telegramBridgeLog(projectId);
|
|
3364
|
-
// #353 follow-up: truncate the log at the start of every
|
|
3365
|
-
// spawn so the status endpoint's last_error tail only ever
|
|
3366
|
-
// reflects the *current* session. Otherwise a previous
|
|
3367
|
-
// crash's trace would linger forever and the widget would
|
|
3368
|
-
// keep surfacing a stale error even after the operator
|
|
3369
|
-
// fixed the underlying problem and restarted cleanly.
|
|
3370
|
-
try { fs.writeFileSync(logPath, ""); } catch {}
|
|
3371
|
-
let outFd, errFd;
|
|
3372
|
-
try {
|
|
3373
|
-
outFd = fs.openSync(logPath, "a");
|
|
3374
|
-
errFd = fs.openSync(logPath, "a");
|
|
3375
|
-
} catch (err) {
|
|
3376
|
-
return res.json({ ok: false, error: `Could not open bridge log file: ${err.message}` });
|
|
3377
|
-
}
|
|
3378
|
-
let child;
|
|
3379
2436
|
try {
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
child = spawn(venvPython, [bridgeScript, "--config", tomlPath], {
|
|
3386
|
-
detached: true,
|
|
3387
|
-
stdio: ["ignore", outFd, errFd],
|
|
3388
|
-
env: buildTelegramBridgeSpawnEnv(process.env),
|
|
3389
|
-
});
|
|
3390
|
-
child.unref();
|
|
3391
|
-
if (child.pid) fs.writeFileSync(telegramPidFile(projectId), String(child.pid));
|
|
2437
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
2438
|
+
const qwPort = cfg.port || 8400;
|
|
2439
|
+
telegramBridge.start(projectId, tg.bot_token, tg.chat_id, qwPort);
|
|
2440
|
+
emitSystemMessage(projectId, "Telegram bridge connected");
|
|
2441
|
+
return res.json({ ok: true, running: true });
|
|
3392
2442
|
} catch (err) {
|
|
3393
|
-
try { fs.closeSync(outFd); } catch {}
|
|
3394
|
-
try { fs.closeSync(errFd); } catch {}
|
|
3395
2443
|
return res.json({ ok: false, error: err.message || "Start failed" });
|
|
3396
2444
|
}
|
|
3397
|
-
// Close our copies of the fds in the parent now that the
|
|
3398
|
-
// child has inherited them — otherwise the parent holds the
|
|
3399
|
-
// log file open forever.
|
|
3400
|
-
try { fs.closeSync(outFd); } catch {}
|
|
3401
|
-
try { fs.closeSync(errFd); } catch {}
|
|
3402
|
-
// #353: liveness check — wait 500ms, then verify the child
|
|
3403
|
-
// is still running. If it already died, tail the log file
|
|
3404
|
-
// and return those lines as the error.
|
|
3405
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
3406
|
-
let alive = true;
|
|
3407
|
-
try { process.kill(child.pid, 0); } catch { alive = false; }
|
|
3408
|
-
if (!alive) {
|
|
3409
|
-
const tail = readLastLines(logPath, 20);
|
|
3410
|
-
try { fs.unlinkSync(telegramPidFile(projectId)); } catch {}
|
|
3411
|
-
return res.json({
|
|
3412
|
-
ok: false,
|
|
3413
|
-
error:
|
|
3414
|
-
"Bridge crashed on start (exited within 500ms).\n\n" +
|
|
3415
|
-
`Last log lines (${logPath}):\n${tail || "(log empty)"}`,
|
|
3416
|
-
});
|
|
3417
|
-
}
|
|
3418
|
-
return res.json({ ok: true, running: true, pid: child.pid });
|
|
3419
2445
|
}
|
|
3420
2446
|
case "stop": {
|
|
3421
2447
|
const projectId = body.project_id;
|
|
3422
2448
|
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
3423
|
-
// #388: deregister the bridge from AC before killing so the slot
|
|
3424
|
-
// clears immediately instead of lingering for 60s as a stale -2/-3.
|
|
3425
|
-
// Awaited so a fast stop→start cycle doesn't race the deregister.
|
|
3426
2449
|
try {
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
const acUrl = resolveProjectAgentchattrUrl(cfg, project);
|
|
3430
|
-
if (acUrl) {
|
|
3431
|
-
const acPort = new URL(acUrl).port || "8300";
|
|
3432
|
-
await fetch(`http://127.0.0.1:${acPort}/api/deregister/tg`, {
|
|
3433
|
-
method: "POST",
|
|
3434
|
-
signal: AbortSignal.timeout(3000),
|
|
3435
|
-
}).catch(() => {});
|
|
3436
|
-
}
|
|
3437
|
-
} catch {}
|
|
3438
|
-
try {
|
|
3439
|
-
const pf = telegramPidFile(projectId);
|
|
3440
|
-
if (fs.existsSync(pf)) {
|
|
3441
|
-
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
3442
|
-
if (pid) process.kill(pid, "SIGTERM");
|
|
3443
|
-
fs.unlinkSync(pf);
|
|
3444
|
-
}
|
|
3445
|
-
// #522: clear bridge log so last_error doesn't show stale
|
|
3446
|
-
// connection-refused messages after an intentional stop.
|
|
3447
|
-
try { fs.writeFileSync(telegramBridgeLog(projectId), ""); } catch {}
|
|
2450
|
+
telegramBridge.stop(projectId);
|
|
2451
|
+
emitSystemMessage(projectId, "Telegram bridge disconnected");
|
|
3448
2452
|
return res.json({ ok: true, running: false });
|
|
3449
2453
|
} catch (err) {
|
|
3450
2454
|
return res.json({ ok: false, error: err.message || "Stop failed" });
|
|
3451
2455
|
}
|
|
3452
2456
|
}
|
|
3453
2457
|
case "status":
|
|
3454
|
-
return res.json({ running:
|
|
2458
|
+
return res.json({ running: telegramBridge.isRunning(body.project_id || "") });
|
|
3455
2459
|
case "save-token": {
|
|
3456
2460
|
const projectId = body.project_id;
|
|
3457
2461
|
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
@@ -3508,89 +2512,6 @@ router.post("/api/telegram", async (req, res) => {
|
|
|
3508
2512
|
});
|
|
3509
2513
|
|
|
3510
2514
|
// --- Discord Bridge ---
|
|
3511
|
-
// #396/#399: Discord ↔ AgentChattr bridge, bundled in quadwork
|
|
3512
|
-
// package at bridges/discord/. Mirrors Telegram bridge patterns.
|
|
3513
|
-
|
|
3514
|
-
const DISCORD_BRIDGE_SRC = path.join(__dirname, "..", "bridges", "discord");
|
|
3515
|
-
const DISCORD_BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-discord");
|
|
3516
|
-
|
|
3517
|
-
function discordPidFile(projectId) {
|
|
3518
|
-
return path.join(CONFIG_DIR, `dc-bridge-${projectId}.pid`);
|
|
3519
|
-
}
|
|
3520
|
-
|
|
3521
|
-
function discordConfigToml(projectId) {
|
|
3522
|
-
return path.join(CONFIG_DIR, `discord-${projectId}.toml`);
|
|
3523
|
-
}
|
|
3524
|
-
|
|
3525
|
-
function discordBridgeLog(projectId) {
|
|
3526
|
-
return path.join(CONFIG_DIR, `dc-bridge-${projectId}.log`);
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
function buildDiscordBridgeToml(dc, projectId) {
|
|
3530
|
-
const cursorFile = path.join(CONFIG_DIR, `dc-bridge-cursor-${projectId}.json`);
|
|
3531
|
-
// #439: migrate old cursor file so the bridge doesn't replay history
|
|
3532
|
-
const oldCursor = path.join(CONFIG_DIR, `discord-bridge-cursor-${projectId}.json`);
|
|
3533
|
-
if (!fs.existsSync(cursorFile) && fs.existsSync(oldCursor)) {
|
|
3534
|
-
fs.renameSync(oldCursor, cursorFile);
|
|
3535
|
-
}
|
|
3536
|
-
return (
|
|
3537
|
-
`[discord]\n` +
|
|
3538
|
-
`bot_token = "${dc.bot_token}"\n` +
|
|
3539
|
-
`channel_id = "${dc.channel_id}"\n` +
|
|
3540
|
-
`agentchattr_url = "${dc.agentchattr_url}"\n` +
|
|
3541
|
-
`cursor_file = "${cursorFile}"\n` +
|
|
3542
|
-
`project_id = "${projectId}"\n`
|
|
3543
|
-
);
|
|
3544
|
-
}
|
|
3545
|
-
|
|
3546
|
-
function patchAgentchattrConfigForDiscordBridge(tomlText) {
|
|
3547
|
-
// #439: migrate old slug if present
|
|
3548
|
-
const original = tomlText;
|
|
3549
|
-
tomlText = tomlText.replace(/^\[agents\.discord-bridge\]\s*$/m, "[agents.dc]");
|
|
3550
|
-
if (/^\[agents\.dc\]\s*$/m.test(tomlText)) {
|
|
3551
|
-
return { text: tomlText, changed: tomlText !== original };
|
|
3552
|
-
}
|
|
3553
|
-
const sep = tomlText.length === 0 || tomlText.endsWith("\n") ? "" : "\n";
|
|
3554
|
-
const block = `\n[agents.dc]\nlabel = "Discord Bridge"\n`;
|
|
3555
|
-
return { text: tomlText + sep + block, changed: true };
|
|
3556
|
-
}
|
|
3557
|
-
|
|
3558
|
-
function buildDiscordBridgeSpawnEnv(parentEnv) {
|
|
3559
|
-
const env = { ...parentEnv };
|
|
3560
|
-
delete env.DISCORD_BOT_TOKEN;
|
|
3561
|
-
delete env.DISCORD_CHANNEL_ID;
|
|
3562
|
-
delete env.AGENTCHATTR_URL;
|
|
3563
|
-
return env;
|
|
3564
|
-
}
|
|
3565
|
-
|
|
3566
|
-
function checkDiscordBridgePythonDeps(pythonPath = "python3") {
|
|
3567
|
-
try {
|
|
3568
|
-
execFileSync(pythonPath, ["-c", "import discord, requests"], {
|
|
3569
|
-
encoding: "utf-8",
|
|
3570
|
-
timeout: 10000,
|
|
3571
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3572
|
-
});
|
|
3573
|
-
return { ok: true };
|
|
3574
|
-
} catch (err) {
|
|
3575
|
-
const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
|
|
3576
|
-
const msg = stderr.trim() || (err && err.message) || "python3 import check failed";
|
|
3577
|
-
return { ok: false, error: msg };
|
|
3578
|
-
}
|
|
3579
|
-
}
|
|
3580
|
-
|
|
3581
|
-
function isDiscordRunning(projectId) {
|
|
3582
|
-
const pf = discordPidFile(projectId);
|
|
3583
|
-
if (!fs.existsSync(pf)) return false;
|
|
3584
|
-
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
3585
|
-
if (!pid) return false;
|
|
3586
|
-
try {
|
|
3587
|
-
process.kill(pid, 0);
|
|
3588
|
-
return true;
|
|
3589
|
-
} catch {
|
|
3590
|
-
fs.unlinkSync(pf);
|
|
3591
|
-
return false;
|
|
3592
|
-
}
|
|
3593
|
-
}
|
|
3594
2515
|
|
|
3595
2516
|
function discordEnvKeyForProject(projectId) {
|
|
3596
2517
|
return `DISCORD_BOT_TOKEN_${projectId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
@@ -3604,7 +2525,6 @@ function getProjectDiscord(projectId) {
|
|
|
3604
2525
|
return {
|
|
3605
2526
|
bot_token: resolveToken(project.discord.bot_token || ""),
|
|
3606
2527
|
channel_id: project.discord.channel_id || "",
|
|
3607
|
-
agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
|
|
3608
2528
|
};
|
|
3609
2529
|
} catch {
|
|
3610
2530
|
return null;
|
|
@@ -3626,10 +2546,7 @@ router.get("/api/discord", async (req, res) => {
|
|
|
3626
2546
|
channelId = project.discord.channel_id;
|
|
3627
2547
|
botUsername = project.discord.bot_username || "";
|
|
3628
2548
|
}
|
|
3629
|
-
bridgeInstalled =
|
|
3630
|
-
// Lazy-resolve bot username via Discord's /users/@me the first time
|
|
3631
|
-
// after a token is saved. Cache it on the project entry so later
|
|
3632
|
-
// requests don't hit the network.
|
|
2549
|
+
bridgeInstalled = true;
|
|
3633
2550
|
if (configured && !botUsername && project?.discord?.bot_token && cfg) {
|
|
3634
2551
|
try {
|
|
3635
2552
|
const resolved = resolveToken(project.discord.bot_token);
|
|
@@ -3644,19 +2561,11 @@ router.get("/api/discord", async (req, res) => {
|
|
|
3644
2561
|
try { writeConfig(cfg); } catch {}
|
|
3645
2562
|
}
|
|
3646
2563
|
}
|
|
3647
|
-
} catch {
|
|
2564
|
+
} catch {}
|
|
3648
2565
|
}
|
|
3649
2566
|
} catch {}
|
|
3650
|
-
const running =
|
|
3651
|
-
|
|
3652
|
-
if (!running) {
|
|
3653
|
-
const logPath = discordBridgeLog(projectId);
|
|
3654
|
-
try {
|
|
3655
|
-
if (fs.existsSync(logPath) && fs.statSync(logPath).size > 0) {
|
|
3656
|
-
lastError = readLastLines(logPath, 20);
|
|
3657
|
-
}
|
|
3658
|
-
} catch {}
|
|
3659
|
-
}
|
|
2567
|
+
const running = discordBridge.isRunning(projectId);
|
|
2568
|
+
const lastError = running ? "" : (discordBridge.getLastError(projectId) || "");
|
|
3660
2569
|
res.json({
|
|
3661
2570
|
running,
|
|
3662
2571
|
configured,
|
|
@@ -3691,178 +2600,37 @@ router.post("/api/discord", async (req, res) => {
|
|
|
3691
2600
|
}
|
|
3692
2601
|
}
|
|
3693
2602
|
case "install": {
|
|
3694
|
-
|
|
3695
|
-
const venvPython = path.join(venvDir, "bin", "python3");
|
|
3696
|
-
const venvPip = path.join(venvDir, "bin", "pip");
|
|
3697
|
-
let pipOutput = "";
|
|
3698
|
-
try {
|
|
3699
|
-
// #506: always copy bundled bridge files (not just on first install)
|
|
3700
|
-
// so re-installing after a QuadWork upgrade refreshes the script.
|
|
3701
|
-
if (!fs.existsSync(DISCORD_BRIDGE_DIR)) {
|
|
3702
|
-
ensureSecureDir(DISCORD_BRIDGE_DIR);
|
|
3703
|
-
}
|
|
3704
|
-
fs.cpSync(
|
|
3705
|
-
path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
|
|
3706
|
-
path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"),
|
|
3707
|
-
);
|
|
3708
|
-
fs.cpSync(
|
|
3709
|
-
path.join(DISCORD_BRIDGE_SRC, "requirements.txt"),
|
|
3710
|
-
path.join(DISCORD_BRIDGE_DIR, "requirements.txt"),
|
|
3711
|
-
);
|
|
3712
|
-
if (!fs.existsSync(venvPython)) {
|
|
3713
|
-
execFileSync("python3", ["-m", "venv", venvDir], { encoding: "utf-8", timeout: 60000 });
|
|
3714
|
-
}
|
|
3715
|
-
pipOutput = execFileSync(
|
|
3716
|
-
venvPip,
|
|
3717
|
-
["install", "-r", path.join(DISCORD_BRIDGE_DIR, "requirements.txt")],
|
|
3718
|
-
{ encoding: "utf-8", timeout: 120000 },
|
|
3719
|
-
);
|
|
3720
|
-
} catch (err) {
|
|
3721
|
-
const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
|
|
3722
|
-
return res.json({ ok: false, error: (stderr.trim() || err.message || "Install failed") });
|
|
3723
|
-
}
|
|
3724
|
-
const depCheck = checkDiscordBridgePythonDeps(venvPython);
|
|
3725
|
-
if (!depCheck.ok) {
|
|
3726
|
-
return res.json({
|
|
3727
|
-
ok: false,
|
|
3728
|
-
error:
|
|
3729
|
-
"pip reported success but the bridge venv's Python deps still fail to import. " +
|
|
3730
|
-
`Check disk space and permissions on ${venvDir}.\n\n` +
|
|
3731
|
-
`Import error: ${depCheck.error}\n\n` +
|
|
3732
|
-
`pip output tail:\n${pipOutput.split("\n").slice(-10).join("\n")}`,
|
|
3733
|
-
});
|
|
3734
|
-
}
|
|
3735
|
-
// #457: Patch all project AC configs with [agents.dc] and
|
|
3736
|
-
// migrate old `discord-bridge` slug. Restart AC for changed projects.
|
|
3737
|
-
const patched = [];
|
|
3738
|
-
try {
|
|
3739
|
-
const cfgAll = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
3740
|
-
const serverPort = cfgAll.port || 8400;
|
|
3741
|
-
for (const proj of cfgAll.projects || []) {
|
|
3742
|
-
if (!proj || !proj.id) continue;
|
|
3743
|
-
const acPath = projectAgentchattrConfigPath(proj.id);
|
|
3744
|
-
if (!fs.existsSync(acPath)) continue;
|
|
3745
|
-
try {
|
|
3746
|
-
const before = fs.readFileSync(acPath, "utf-8");
|
|
3747
|
-
const { text, changed } = patchAgentchattrConfigForDiscordBridge(before);
|
|
3748
|
-
if (changed) {
|
|
3749
|
-
fs.writeFileSync(acPath, text);
|
|
3750
|
-
patched.push(proj.id);
|
|
3751
|
-
// #457: restart AC so it loads the new agent slug
|
|
3752
|
-
setTimeout(async () => {
|
|
3753
|
-
try {
|
|
3754
|
-
await fetch(`http://127.0.0.1:${serverPort}/api/agentchattr/${encodeURIComponent(proj.id)}/restart`, {
|
|
3755
|
-
method: "POST",
|
|
3756
|
-
});
|
|
3757
|
-
} catch {}
|
|
3758
|
-
}, 1000);
|
|
3759
|
-
}
|
|
3760
|
-
} catch {}
|
|
3761
|
-
}
|
|
3762
|
-
} catch {}
|
|
3763
|
-
return res.json({ ok: true, patched_projects: patched });
|
|
2603
|
+
return res.json({ ok: true, patched_projects: [] });
|
|
3764
2604
|
}
|
|
3765
2605
|
case "start": {
|
|
3766
2606
|
const projectId = body.project_id;
|
|
3767
2607
|
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
3768
|
-
if (
|
|
3769
|
-
const bridgeScript = path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py");
|
|
3770
|
-
if (!fs.existsSync(bridgeScript)) return res.json({ ok: false, error: "Bridge not installed. Click Install Bridge first." });
|
|
3771
|
-
const venvPython = path.join(DISCORD_BRIDGE_DIR, ".venv", "bin", "python3");
|
|
3772
|
-
if (!fs.existsSync(venvPython)) {
|
|
3773
|
-
return res.json({ ok: false, error: "Bridge venv missing. Click \"Install Bridge\" to create it." });
|
|
3774
|
-
}
|
|
2608
|
+
if (discordBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
|
|
3775
2609
|
const dc = getProjectDiscord(projectId);
|
|
3776
2610
|
if (!dc || !dc.bot_token || !dc.channel_id) return res.json({ ok: false, error: "Save bot_token and channel_id in project settings first." });
|
|
3777
|
-
const tomlPath = discordConfigToml(projectId);
|
|
3778
|
-
const tomlContent = buildDiscordBridgeToml(dc, projectId);
|
|
3779
|
-
writeSecureFile(tomlPath, tomlContent);
|
|
3780
|
-
const depCheck = checkDiscordBridgePythonDeps(venvPython);
|
|
3781
|
-
if (!depCheck.ok) {
|
|
3782
|
-
const msg =
|
|
3783
|
-
"Bridge Python dependencies not installed in the dedicated venv. " +
|
|
3784
|
-
"Click \"Install Bridge\" to (re)create the venv and install them.\n\n" +
|
|
3785
|
-
`Import error: ${depCheck.error}`;
|
|
3786
|
-
try {
|
|
3787
|
-
fs.writeFileSync(
|
|
3788
|
-
discordBridgeLog(projectId),
|
|
3789
|
-
`[${new Date().toISOString()}] pre-flight dep check failed\n${msg}\n`,
|
|
3790
|
-
);
|
|
3791
|
-
} catch {}
|
|
3792
|
-
return res.json({ ok: false, error: msg });
|
|
3793
|
-
}
|
|
3794
|
-
const logPath = discordBridgeLog(projectId);
|
|
3795
|
-
try { fs.writeFileSync(logPath, ""); } catch {}
|
|
3796
|
-
let outFd, errFd;
|
|
3797
|
-
try {
|
|
3798
|
-
outFd = fs.openSync(logPath, "a");
|
|
3799
|
-
errFd = fs.openSync(logPath, "a");
|
|
3800
|
-
} catch (err) {
|
|
3801
|
-
return res.json({ ok: false, error: `Could not open bridge log file: ${err.message}` });
|
|
3802
|
-
}
|
|
3803
|
-
let child;
|
|
3804
2611
|
try {
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
});
|
|
3810
|
-
child.unref();
|
|
3811
|
-
if (child.pid) fs.writeFileSync(discordPidFile(projectId), String(child.pid));
|
|
2612
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
2613
|
+
const qwPort = cfg.port || 8400;
|
|
2614
|
+
await discordBridge.start(projectId, dc.bot_token, dc.channel_id, qwPort);
|
|
2615
|
+
emitSystemMessage(projectId, "Discord bridge connected");
|
|
2616
|
+
return res.json({ ok: true, running: true });
|
|
3812
2617
|
} catch (err) {
|
|
3813
|
-
try { fs.closeSync(outFd); } catch {}
|
|
3814
|
-
try { fs.closeSync(errFd); } catch {}
|
|
3815
2618
|
return res.json({ ok: false, error: err.message || "Start failed" });
|
|
3816
2619
|
}
|
|
3817
|
-
try { fs.closeSync(outFd); } catch {}
|
|
3818
|
-
try { fs.closeSync(errFd); } catch {}
|
|
3819
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
3820
|
-
let alive = true;
|
|
3821
|
-
try { process.kill(child.pid, 0); } catch { alive = false; }
|
|
3822
|
-
if (!alive) {
|
|
3823
|
-
const tail = readLastLines(logPath, 20);
|
|
3824
|
-
try { fs.unlinkSync(discordPidFile(projectId)); } catch {}
|
|
3825
|
-
return res.json({
|
|
3826
|
-
ok: false,
|
|
3827
|
-
error:
|
|
3828
|
-
"Bridge crashed on start (exited within 500ms).\n\n" +
|
|
3829
|
-
`Last log lines (${logPath}):\n${tail || "(log empty)"}`,
|
|
3830
|
-
});
|
|
3831
|
-
}
|
|
3832
|
-
return res.json({ ok: true, running: true, pid: child.pid });
|
|
3833
2620
|
}
|
|
3834
2621
|
case "stop": {
|
|
3835
2622
|
const projectId = body.project_id;
|
|
3836
2623
|
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
3837
2624
|
try {
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
const acUrl = resolveProjectAgentchattrUrl(cfg, project);
|
|
3841
|
-
if (acUrl) {
|
|
3842
|
-
const acPort = new URL(acUrl).port || "8300";
|
|
3843
|
-
await fetch(`http://127.0.0.1:${acPort}/api/deregister/dc`, {
|
|
3844
|
-
method: "POST",
|
|
3845
|
-
signal: AbortSignal.timeout(3000),
|
|
3846
|
-
}).catch(() => {});
|
|
3847
|
-
}
|
|
3848
|
-
} catch {}
|
|
3849
|
-
try {
|
|
3850
|
-
const pf = discordPidFile(projectId);
|
|
3851
|
-
if (fs.existsSync(pf)) {
|
|
3852
|
-
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
3853
|
-
if (pid) process.kill(pid, "SIGTERM");
|
|
3854
|
-
fs.unlinkSync(pf);
|
|
3855
|
-
}
|
|
3856
|
-
// #522: clear bridge log so last_error doesn't show stale
|
|
3857
|
-
// connection-refused messages after an intentional stop.
|
|
3858
|
-
try { fs.writeFileSync(discordBridgeLog(projectId), ""); } catch {}
|
|
2625
|
+
discordBridge.stop(projectId);
|
|
2626
|
+
emitSystemMessage(projectId, "Discord bridge disconnected");
|
|
3859
2627
|
return res.json({ ok: true, running: false });
|
|
3860
2628
|
} catch (err) {
|
|
3861
2629
|
return res.json({ ok: false, error: err.message || "Stop failed" });
|
|
3862
2630
|
}
|
|
3863
2631
|
}
|
|
3864
2632
|
case "status":
|
|
3865
|
-
return res.json({ running:
|
|
2633
|
+
return res.json({ running: discordBridge.isRunning(body.project_id || "") });
|
|
3866
2634
|
case "save-config": {
|
|
3867
2635
|
const projectId = body.project_id;
|
|
3868
2636
|
const bot_token = typeof body.bot_token === "string" ? body.bot_token.trim() : "";
|
|
@@ -3976,25 +2744,9 @@ module.exports.parseActiveBatch = parseActiveBatch;
|
|
|
3976
2744
|
// summarizeItems for the batch-progress fixture test.
|
|
3977
2745
|
module.exports.buildNoPrRow = buildNoPrRow;
|
|
3978
2746
|
module.exports.summarizeItems = summarizeItems;
|
|
3979
|
-
// #353: expose readLastLines for the tg-bridge test.
|
|
3980
|
-
module.exports.readLastLines = readLastLines;
|
|
3981
|
-
// #380: expose checkTelegramBridgePythonDeps so the bridge test can
|
|
3982
|
-
// exercise the venv-path interpreter argument round trip.
|
|
3983
|
-
module.exports.checkTelegramBridgePythonDeps = checkTelegramBridgePythonDeps;
|
|
3984
|
-
// #383: pure helpers exposed for unit tests in
|
|
3985
|
-
// routes.telegramBridge.test.js. No production callers outside
|
|
3986
|
-
// this file.
|
|
3987
|
-
module.exports.resolveProjectAgentchattrUrl = resolveProjectAgentchattrUrl;
|
|
3988
|
-
module.exports.buildTelegramBridgeToml = buildTelegramBridgeToml;
|
|
3989
|
-
module.exports.patchAgentchattrConfigForTelegramBridge = patchAgentchattrConfigForTelegramBridge;
|
|
3990
|
-
module.exports.buildTelegramBridgeSpawnEnv = buildTelegramBridgeSpawnEnv;
|
|
3991
|
-
module.exports.checkDiscordBridgePythonDeps = checkDiscordBridgePythonDeps;
|
|
3992
|
-
module.exports.buildDiscordBridgeToml = buildDiscordBridgeToml;
|
|
3993
|
-
module.exports.patchAgentchattrConfigForDiscordBridge = patchAgentchattrConfigForDiscordBridge;
|
|
3994
|
-
module.exports.buildDiscordBridgeSpawnEnv = buildDiscordBridgeSpawnEnv;
|
|
3995
|
-
module.exports.projectAgentchattrConfigPath = projectAgentchattrConfigPath;
|
|
3996
|
-
// #236: expose sendViaWebSocket so the chat-ws-send regression test
|
|
3997
|
-
// can verify the ack/body/error paths against a fake AC ws server.
|
|
3998
|
-
module.exports.sendViaWebSocket = sendViaWebSocket;
|
|
3999
2747
|
// #693: expose normalizeMentions for unit tests
|
|
4000
2748
|
module.exports.normalizeMentions = normalizeMentions;
|
|
2749
|
+
// #714: expose for file-chat integration
|
|
2750
|
+
module.exports.getProjectChatMode = getProjectChatMode;
|
|
2751
|
+
// #730: PTY dispatch callback setter
|
|
2752
|
+
module.exports.setPtyDispatchCallback = setPtyDispatchCallback;
|