quadwork 1.19.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +19 -35
  2. package/bin/quadwork.js +48 -1118
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +14 -14
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
  10. package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
  11. package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
  12. package/out/_next/static/chunks/{08tog0xc~.es_.js → 0jllnzexn48._.js} +1 -1
  13. package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
  14. package/out/_next/static/chunks/0py7102i226n5.js +1 -0
  15. package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
  16. package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
  17. package/out/_next/static/chunks/{11khe5i7gu158.js → 0z.9wnba-t6z8.js} +1 -1
  18. package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
  19. package/out/_next/static/chunks/163_ddkdca5q4.js +25 -0
  20. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  21. package/out/_not-found/__next._full.txt +13 -13
  22. package/out/_not-found/__next._head.txt +4 -4
  23. package/out/_not-found/__next._index.txt +8 -8
  24. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  25. package/out/_not-found/__next._not-found.txt +3 -3
  26. package/out/_not-found/__next._tree.txt +2 -2
  27. package/out/_not-found.html +1 -1
  28. package/out/_not-found.txt +13 -13
  29. package/out/app-shell/__next._full.txt +13 -13
  30. package/out/app-shell/__next._head.txt +4 -4
  31. package/out/app-shell/__next._index.txt +8 -8
  32. package/out/app-shell/__next._tree.txt +2 -2
  33. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  34. package/out/app-shell/__next.app-shell.txt +3 -3
  35. package/out/app-shell.html +1 -1
  36. package/out/app-shell.txt +13 -13
  37. package/out/index.html +1 -1
  38. package/out/index.txt +14 -14
  39. package/out/project/_/__next._full.txt +14 -14
  40. package/out/project/_/__next._head.txt +4 -4
  41. package/out/project/_/__next._index.txt +8 -8
  42. package/out/project/_/__next._tree.txt +2 -2
  43. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  44. package/out/project/_/__next.project.$d$id.txt +3 -3
  45. package/out/project/_/__next.project.txt +3 -3
  46. package/out/project/_/queue/__next._full.txt +14 -14
  47. package/out/project/_/queue/__next._head.txt +4 -4
  48. package/out/project/_/queue/__next._index.txt +8 -8
  49. package/out/project/_/queue/__next._tree.txt +2 -2
  50. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  52. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  53. package/out/project/_/queue/__next.project.txt +3 -3
  54. package/out/project/_/queue.html +1 -1
  55. package/out/project/_/queue.txt +14 -14
  56. package/out/project/_.html +1 -1
  57. package/out/project/_.txt +14 -14
  58. package/out/settings/__next._full.txt +14 -14
  59. package/out/settings/__next._head.txt +4 -4
  60. package/out/settings/__next._index.txt +8 -8
  61. package/out/settings/__next._tree.txt +2 -2
  62. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  63. package/out/settings/__next.settings.txt +3 -3
  64. package/out/settings.html +1 -1
  65. package/out/settings.txt +14 -14
  66. package/out/setup/__next._full.txt +14 -14
  67. package/out/setup/__next._head.txt +4 -4
  68. package/out/setup/__next._index.txt +8 -8
  69. package/out/setup/__next._tree.txt +2 -2
  70. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  71. package/out/setup/__next.setup.txt +3 -3
  72. package/out/setup.html +1 -1
  73. package/out/setup.txt +14 -14
  74. package/package.json +4 -2
  75. package/server/ac-restore.js +128 -0
  76. package/server/bridges/discord.js +244 -0
  77. package/server/bridges/telegram.js +258 -0
  78. package/server/config.js +4 -60
  79. package/server/file-chat.js +318 -0
  80. package/server/index.js +129 -1294
  81. package/server/install-agentchattr.js +3 -284
  82. package/server/mcp-chat-shim.js +171 -0
  83. package/server/migrate-ac.js +158 -0
  84. package/server/pty-dispatcher.js +188 -0
  85. package/server/routes.js +155 -1398
  86. package/templates/CLAUDE.md +2 -2
  87. package/templates/OVERNIGHT-QUEUE.md +1 -1
  88. package/templates/seeds/butler.CLAUDE.md +30 -62
  89. package/templates/seeds/dev.AGENTS.md +10 -1
  90. package/templates/seeds/head.AGENTS.md +12 -8
  91. package/templates/seeds/re1.AGENTS.md +3 -3
  92. package/templates/seeds/re2.AGENTS.md +3 -3
  93. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  94. package/bridges/discord/discord_bridge.py +0 -666
  95. package/bridges/discord/requirements.txt +0 -2
  96. package/out/_next/static/chunks/08kw.2kplxa.6.css +0 -2
  97. package/out/_next/static/chunks/0_nm7se0m3twm.js +0 -25
  98. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  99. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  100. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  101. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  102. package/server/__tests__/rate-limit-handling.test.js +0 -168
  103. package/server/__tests__/scrub-secrets.test.js +0 -235
  104. package/server/__tests__/v1110-security-qa.test.js +0 -312
  105. package/server/agentchattr-registry.js +0 -188
  106. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  107. package/server/queue-watcher.js +0 -171
  108. package/server/queue-watcher.test.js +0 -64
  109. package/server/routes.batchProgress.test.js +0 -94
  110. package/server/routes.chatWsSend.test.js +0 -161
  111. package/server/routes.discordBridge.test.js +0 -80
  112. package/server/routes.parseActiveBatch.test.js +0 -88
  113. package/server/routes.telegramBridge.test.js +0 -241
  114. package/templates/config.toml +0 -72
  115. package/templates/wrapper.py +0 -70
  116. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_buildManifest.js +0 -0
  117. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_clientMiddlewareManifest.js +0 -0
  118. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_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 (AgentChattr proxy) ──────────────────────────────────────────────
217
+ // ─── Chat (file-based) ────────────────────────────────────────────────────
209
218
 
210
- const { resolveProjectChattr, sanitizeOperatorName, ensureSecureDir, writeSecureFile, writeConfig } = require("./config");
211
- const { installAgentChattr, findAgentChattr } = require("./install-agentchattr");
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,40 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
231
240
  } catch { /* non-fatal */ }
232
241
  }
233
242
 
234
- function getChattrConfig(projectId) {
235
- const resolved = resolveProjectChattr(projectId);
236
- return { url: resolved.url, token: resolved.token };
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 chatAuthHeaders(token) {
240
- if (!token) return {};
241
- return { "x-session-token": token };
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
- router.get("/api/chat", async (req, res) => {
245
- const projectId = req.query.project;
246
- const apiPath = req.query.path || "/api/messages";
247
- const { url: base, token } = getChattrConfig(projectId);
257
+ function emitSystemMessage(projectId, text) {
258
+ try {
259
+ fileChat.appendMessage(projectId, { sender: "system", type: "system", text });
260
+ } catch {}
261
+ }
248
262
 
249
- const buildUrl = (tok) => {
250
- const fwd = new URLSearchParams();
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
- try {
259
- const r = await fetch(buildUrl(token), { headers: chatAuthHeaders(token) });
260
- // #448: on 401/403, re-sync the session token from AC and retry
261
- // once. The stored token may be stale after an AC restart.
262
- // #487: also retry on 5xx — a temporary AC outage (e.g. 502) may
263
- // precede a restart that regenerates the session token.
264
- if ((r.status === 401 || r.status === 403 || r.status >= 500) && projectId) {
265
- try { await syncChattrToken(projectId); } catch {}
266
- const { token: refreshed } = getChattrConfig(projectId);
267
- if (refreshed && refreshed !== token) {
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
+ // #783: return raw ISO `ts` only. The previous server-side `time`
272
+ // field used the server's local time, which on UTC VPS hosts gave
273
+ // wrong-timezone display. The frontend formats `ts` in the browser.
274
+ return res.json(messages);
292
275
  });
293
276
 
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
277
  // #693: Auto-normalize bare agent names to @mentions in outbound messages.
337
278
  // Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
338
279
  // Already-prefixed mentions are not double-prefixed; suffixed names like
@@ -340,341 +281,67 @@ const { syncChattrToken } = require("./config");
340
281
  const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
341
282
  function normalizeMentions(text) {
342
283
  if (typeof text !== "string" || !text) return text || "";
343
- return MENTION_AGENT_NAMES.reduce(
344
- (t, name) => t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), `@${name}`),
345
- text,
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
- });
284
+ const preserved = [];
285
+ const ph = "\x00CODE\x00";
286
+ let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
287
+ preserved.push(m);
288
+ return ph;
439
289
  });
440
- }
441
-
442
- /**
443
- * #403 / quadwork#274: send an arbitrary AC ws event (not a chat
444
- * message). Used for `update_settings` so the loop guard widget can
445
- * push the new max_agent_hops to the running AgentChattr without a
446
- * full restart. Mirrors sendViaWebSocket but lets the caller pick
447
- * the event type.
448
- */
449
- function sendWsEvent(baseUrl, sessionToken, event) {
450
- return new Promise((resolve, reject) => {
451
- const wsUrl = `${baseUrl.replace(/^http/, "ws")}/ws?token=${encodeURIComponent(sessionToken || "")}`;
452
- const ws = new NodeWebSocket(wsUrl);
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");
290
+ safe = MENTION_AGENT_NAMES.reduce(
291
+ (t, name) =>
292
+ t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
293
+ const before = str.slice(Math.max(0, offset - 20), offset);
294
+ if (/[=\/]$/.test(before) || /\b(run|exec|npx|start|checkout|switch|rebase|cd|cat|ls|rm|mv|cp|mkdir)\s+$/i.test(before)) return match;
295
+ const after = str.slice(offset + name.length, offset + name.length + 1);
296
+ if (after === "/") return match;
297
+ return `@${name}`;
298
+ }),
299
+ safe,
300
+ );
301
+ let i = 0;
302
+ return safe.replace(new RegExp(ph, "g"), () => preserved[i++] || "");
492
303
  }
493
304
 
494
305
  router.get("/api/loop-guard", (req, res) => {
495
306
  const projectId = req.query.project;
496
307
  if (!projectId) return res.status(400).json({ error: "Missing project" });
497
- const tomlPath = resolveProjectConfigToml(projectId);
498
- if (!tomlPath || !fs.existsSync(tomlPath)) return res.json({ value: 30, source: "default" });
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
- }
308
+ const value = getProjectMaxHops(projectId);
309
+ return res.json({ value, source: value === 30 ? "default" : "config" });
507
310
  });
508
311
 
509
- router.put("/api/loop-guard", async (req, res) => {
312
+ router.put("/api/loop-guard", (req, res) => {
510
313
  const projectId = req.query.project || req.body?.project;
511
314
  if (!projectId) return res.status(400).json({ error: "Missing project" });
512
315
  const raw = req.body?.value;
513
316
  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
317
  if (!Number.isInteger(value) || value < 4 || value > 50) {
517
318
  return res.status(400).json({ error: "value must be an integer between 4 and 50" });
518
319
  }
519
320
 
520
- // 1. Persist to config.toml so the next restart picks it up.
521
- const tomlPath = resolveProjectConfigToml(projectId);
522
- if (!tomlPath || !fs.existsSync(tomlPath)) {
523
- return res.status(404).json({ error: "config.toml not found for project" });
524
- }
525
- // Capture the previous value before rewriting so we can decide
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 });
321
+ const cfg = readConfigFile();
322
+ const project = (cfg.projects || []).find((p) => p.id === projectId);
323
+ if (!project) return res.status(404).json({ error: "Project not found" });
324
+ project.max_agent_hops = value;
325
+ writeConfigFile(cfg);
326
+ return res.json({ ok: true, value });
653
327
  });
654
328
 
655
329
  // #412 / quadwork#279: project history export + import.
656
330
  //
657
- // Export proxies AC's /api/messages for the project channel and
658
- // wraps the array in a small metadata envelope so future imports
659
- // can warn on project-id mismatch and so a future schema bump can
660
- // be detected client-side.
331
+ // Export reads messages from file-chat and wraps the array in a
332
+ // small metadata envelope so future imports can warn on project-id
333
+ // mismatch and so a future schema bump can be detected client-side.
661
334
  //
662
335
  // Import accepts the same envelope, validates the shape + size,
663
- // and replays each message back into the project's AgentChattr
664
- // instance via sendViaWebSocket preserving the original sender
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.
336
+ // and replays each message into the file-chat store — preserving
337
+ // the original sender field for cross-tool consistency.
668
338
 
669
339
  const PROJECT_HISTORY_VERSION = 1;
670
340
  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
341
 
673
342
  // #414 / quadwork#297: reject imports whose messages claim a
674
- // reserved agent / system sender by default. This closes the only
675
- // path in QuadWork that lets a client-supplied sender reach AC
676
- // (every other route hardcodes / sanitizes to operator). Mirrors
677
- // the RESERVED_OPERATOR_NAMES denylist from sanitizeOperatorName so
343
+ // reserved agent / system sender by default. Mirrors the
344
+ // RESERVED_OPERATOR_NAMES denylist from sanitizeOperatorName so
678
345
  // the same identities are blocked across the codebase.
679
346
  const RESERVED_HISTORY_SENDERS = new Set([
680
347
  "head",
@@ -692,29 +359,11 @@ const RESERVED_HISTORY_SENDERS = new Set([
692
359
  "system",
693
360
  ]);
694
361
 
695
- router.get("/api/project-history", async (req, res) => {
362
+ router.get("/api/project-history", (req, res) => {
696
363
  const projectId = req.query.project;
697
364
  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
365
  try {
701
- // AC's /api/messages accepts a bearer token in the Authorization
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 : [];
366
+ const messages = fileChat.readMessages(projectId, { limit: 100000 });
718
367
  res.json({
719
368
  version: PROJECT_HISTORY_VERSION,
720
369
  project_id: projectId,
@@ -723,7 +372,7 @@ router.get("/api/project-history", async (req, res) => {
723
372
  messages,
724
373
  });
725
374
  } catch (err) {
726
- res.status(502).json({ error: "Project history export failed", detail: err.message || String(err) });
375
+ res.status(500).json({ error: "Project history export failed", detail: err.message || String(err) });
727
376
  }
728
377
  });
729
378
 
@@ -807,24 +456,9 @@ router.post("/api/project-history", async (req, res) => {
807
456
  }
808
457
  }
809
458
 
810
- const { url: base, token: sessionToken } = getChattrConfig(projectId);
811
- if (!base) return res.status(400).json({ error: "No AgentChattr configured for project" });
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.
459
+ // Replay each message into the file-chat store. Preserve the
460
+ // original sender so the imported transcript still attributes
461
+ // each line correctly.
828
462
  let imported = 0;
829
463
  let skipped = 0;
830
464
  const errors = [];
@@ -833,24 +467,18 @@ router.post("/api/project-history", async (req, res) => {
833
467
  skipped++;
834
468
  continue;
835
469
  }
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
470
  try {
842
- await sendViaWebSocket(base, sessionToken, msg);
471
+ fileChat.appendMessage(projectId, {
472
+ sender: typeof m.sender === "string" && m.sender ? m.sender : "user",
473
+ text: m.text,
474
+ channel: typeof m.channel === "string" && m.channel ? m.channel : "general",
475
+ type: m.type || "message",
476
+ });
843
477
  imported++;
844
478
  } catch (err) {
845
479
  errors.push(`#${m.id ?? "?"}: ${err.message || String(err)}`);
846
- // Stop on the first error to avoid spamming AC if its ws is down.
847
480
  if (errors.length > 5) break;
848
481
  }
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
482
  }
855
483
  // #414 / quadwork#297 — Issue 2: stamp the import marker on the
856
484
  // project so a re-import of the same file is caught next time.
@@ -1073,99 +701,36 @@ router.get("/api/activity/stats", (_req, res) => {
1073
701
  }
1074
702
  });
1075
703
 
1076
- router.post("/api/chat", async (req, res) => {
704
+ router.post("/api/chat", (req, res) => {
1077
705
  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
-
1120
- // #693: normalize bare agent names to @mentions (belt-and-suspenders
1121
- // with sendViaWebSocket's own normalization)
1122
- message.text = normalizeMentions(message.text);
1123
706
 
1124
- const attemptSend = () => sendViaWebSocket(base, sessionToken, message);
1125
-
1126
- try {
1127
- // #236: sendViaWebSocket now waits for AC's broadcast echo and
1128
- // returns `{ok, message}` where `message` is the stored record
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
- }
707
+ const text = typeof req.body?.text === "string" ? req.body.text : "";
708
+ if (!text) return res.status(400).json({ error: "text required" });
709
+ const shimSender = req.headers["x-chat-sender"];
710
+ const shimToken = req.headers["x-chat-token"];
711
+ const bridgeSender = req.headers["x-bridge-sender"];
712
+ let sender = "user";
713
+ if (shimSender && shimToken) {
714
+ if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
715
+ return res.status(403).json({ error: "Invalid shim token" });
1165
716
  }
1166
- console.warn(`[chat] send failed for project ${projectId}: ${err && err.message}`);
1167
- return res.status(502).json({ error: "AgentChattr unreachable", detail: err && err.message });
717
+ sender = shimSender;
718
+ } else if (bridgeSender && isLocalhost(req.ip)) {
719
+ sender = bridgeSender;
720
+ }
721
+ const msg = fileChat.appendMessage(projectId, {
722
+ sender,
723
+ text: normalizeMentions(text),
724
+ channel: req.body?.channel || "general",
725
+ type: "message",
726
+ });
727
+ // #717: loop guard — count agent hops, pause if threshold reached
728
+ const maxHops = getProjectMaxHops(projectId);
729
+ fileChat.checkLoopGuard(projectId, msg, maxHops);
730
+ if (!fileChat.isLoopGuardPaused(projectId)) {
731
+ if (_ptyDispatchCallback) _ptyDispatchCallback(projectId, msg);
1168
732
  }
733
+ return res.json({ ok: true, message: msg });
1169
734
  });
1170
735
 
1171
736
  // ─── Image upload (#466) ──────────────────────────────────────────────────
@@ -1253,19 +818,11 @@ router.get("/api/projects", async (req, res) => {
1253
818
 
1254
819
  // Fetch chat messages from all projects (per-project AgentChattr instances)
1255
820
  const chatMsgsByProject = {};
1256
- const chatFetches = (cfg.projects || []).map(async (p) => {
1257
- const { url: chattrUrl, token: chattrToken } = getChattrConfig(p.id);
821
+ for (const p of cfg.projects || []) {
1258
822
  try {
1259
- const headers = chattrToken ? { "x-session-token": chattrToken } : {};
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
- }
823
+ chatMsgsByProject[p.id] = fileChat.readMessages(p.id, { limit: 30 });
1266
824
  } catch {}
1267
- });
1268
- await Promise.allSettled(chatFetches);
825
+ }
1269
826
  // Aggregate all project chat messages for the activity feed
1270
827
  let chatMsgs = Object.values(chatMsgsByProject).flat();
1271
828
 
@@ -1333,7 +890,7 @@ router.get("/api/projects", async (req, res) => {
1333
890
  }
1334
891
  if (projectName) {
1335
892
  recentEvents.push({
1336
- time: m.time,
893
+ ts: m.ts,
1337
894
  text: m.text.length > 120 ? m.text.slice(0, 120) + "…" : m.text,
1338
895
  actor: m.sender,
1339
896
  projectName,
@@ -2598,98 +2155,6 @@ router.post("/api/setup", (req, res) => {
2598
2155
  }
2599
2156
  return res.json({ ok: true, seeded });
2600
2157
  }
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
2158
  case "add-config": {
2694
2159
  const { id, name, repo, workingDir, backends } = body;
2695
2160
  const autoApprove = body.auto_approve !== false; // default true
@@ -2698,7 +2163,7 @@ router.post("/api/setup", (req, res) => {
2698
2163
  const parentDir = path.dirname(workingDir);
2699
2164
  let cfg;
2700
2165
  try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
2701
- catch { cfg = { port: 8400, agentchattr_url: "http://127.0.0.1:8300", agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"), projects: [] }; }
2166
+ catch { cfg = { port: 8400, projects: [] }; }
2702
2167
  if (cfg.projects.some((p) => p.id === id)) {
2703
2168
  // Project already saved, but still (idempotently) seed the
2704
2169
  // OVERNIGHT-QUEUE.md in case a previous run failed to write
@@ -2728,94 +2193,24 @@ router.post("/api/setup", (req, res) => {
2728
2193
  ...(cliBase === "codex" ? { reasoning_effort: "medium" } : {}),
2729
2194
  };
2730
2195
  }
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
2196
  cfg.projects.push({
2765
2197
  id, name, repo, working_dir: workingDir, agents,
2766
- agentchattr_url: `http://127.0.0.1:${chattrPort}`,
2767
- agentchattr_token,
2768
- mcp_http_port,
2769
- mcp_sse_port,
2770
- // Per-project AgentChattr clone path (Option B / #181).
2771
- agentchattr_dir: perProjectDir,
2198
+ chat_mode: "file",
2772
2199
  });
2773
2200
  const dir = path.dirname(CONFIG_PATH);
2774
2201
  ensureSecureDir(dir);
2775
2202
  writeConfig(cfg);
2776
2203
 
2204
+ // #775: initialize file-chat for the new project so the first
2205
+ // chat send doesn't error with "Project not initialized" before
2206
+ // the next server restart. Boot init only runs for projects
2207
+ // already in config.json at startup.
2208
+ fileChat.initProject(id);
2209
+
2777
2210
  // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
2778
2211
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
2779
2212
  writeOvernightQueueFileSafe(id, name || id, repo);
2780
2213
 
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
2214
  return res.json({ ok: true });
2820
2215
  }
2821
2216
  default:
@@ -2925,172 +2320,6 @@ router.post("/api/rename", (req, res) => {
2925
2320
 
2926
2321
  // ─── Telegram ──────────────────────────────────────────────────────────────
2927
2322
 
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
2323
  function readEnvToken(key) {
3095
2324
  try {
3096
2325
  const content = fs.readFileSync(ENV_PATH, "utf-8");
@@ -3128,8 +2357,6 @@ function getProjectTelegram(projectId) {
3128
2357
  return {
3129
2358
  bot_token: resolveToken(project.telegram.bot_token || ""),
3130
2359
  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
2360
  };
3134
2361
  } catch {
3135
2362
  return null;
@@ -3156,11 +2383,8 @@ router.get("/api/telegram", async (req, res) => {
3156
2383
  chatId = project.telegram.chat_id;
3157
2384
  botUsername = project.telegram.bot_username || "";
3158
2385
  }
3159
- bridgeInstalled = fs.existsSync(path.join(BRIDGE_DIR, "telegram_bridge.py"));
2386
+ bridgeInstalled = true;
3160
2387
  } 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
2388
  if (configured && !botUsername && project?.telegram?.bot_token && cfg) {
3165
2389
  try {
3166
2390
  const resolved = resolveToken(project.telegram.bot_token);
@@ -3173,24 +2397,10 @@ router.get("/api/telegram", async (req, res) => {
3173
2397
  try { writeConfig(cfg); } catch {}
3174
2398
  }
3175
2399
  }
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
2400
  } catch {}
3193
2401
  }
2402
+ const running = telegramBridge.isRunning(projectId);
2403
+ const lastError = running ? "" : (telegramBridge.getLastError(projectId) || "");
3194
2404
  res.json({
3195
2405
  running,
3196
2406
  configured,
@@ -3220,238 +2430,37 @@ router.post("/api/telegram", async (req, res) => {
3220
2430
  }
3221
2431
  }
3222
2432
  case "install": {
3223
- // #380: create a dedicated bridge venv at
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 });
2433
+ return res.json({ ok: true, patched_projects: [] });
3308
2434
  }
3309
2435
  case "start": {
3310
2436
  const projectId = body.project_id;
3311
2437
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3312
- if (isTelegramRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
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
- }
2438
+ if (telegramBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
3327
2439
  const tg = getProjectTelegram(projectId);
3328
2440
  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
2441
  try {
3380
- // #383 Bug 4: scrub TELEGRAM_*/AGENTCHATTR_URL from the child
3381
- // env so an operator shell that exports a different bot's
3382
- // token (common on machines running AC2) can't silently
3383
- // override the TOML. Makes the TOML the single source of
3384
- // truth for the bridge's identity.
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));
2442
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
2443
+ const qwPort = cfg.port || 8400;
2444
+ await telegramBridge.start(projectId, tg.bot_token, tg.chat_id, qwPort);
2445
+ emitSystemMessage(projectId, "Telegram bridge connected");
2446
+ return res.json({ ok: true, running: true });
3392
2447
  } catch (err) {
3393
- try { fs.closeSync(outFd); } catch {}
3394
- try { fs.closeSync(errFd); } catch {}
3395
2448
  return res.json({ ok: false, error: err.message || "Start failed" });
3396
2449
  }
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
2450
  }
3420
2451
  case "stop": {
3421
2452
  const projectId = body.project_id;
3422
2453
  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
2454
  try {
3427
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
3428
- const project = cfg.projects?.find((p) => p.id === projectId);
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 {}
2455
+ telegramBridge.stop(projectId);
2456
+ emitSystemMessage(projectId, "Telegram bridge disconnected");
3448
2457
  return res.json({ ok: true, running: false });
3449
2458
  } catch (err) {
3450
2459
  return res.json({ ok: false, error: err.message || "Stop failed" });
3451
2460
  }
3452
2461
  }
3453
2462
  case "status":
3454
- return res.json({ running: isTelegramRunning(body.project_id || "") });
2463
+ return res.json({ running: telegramBridge.isRunning(body.project_id || "") });
3455
2464
  case "save-token": {
3456
2465
  const projectId = body.project_id;
3457
2466
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
@@ -3508,89 +2517,6 @@ router.post("/api/telegram", async (req, res) => {
3508
2517
  });
3509
2518
 
3510
2519
  // --- 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
2520
 
3595
2521
  function discordEnvKeyForProject(projectId) {
3596
2522
  return `DISCORD_BOT_TOKEN_${projectId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
@@ -3604,7 +2530,6 @@ function getProjectDiscord(projectId) {
3604
2530
  return {
3605
2531
  bot_token: resolveToken(project.discord.bot_token || ""),
3606
2532
  channel_id: project.discord.channel_id || "",
3607
- agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
3608
2533
  };
3609
2534
  } catch {
3610
2535
  return null;
@@ -3626,10 +2551,7 @@ router.get("/api/discord", async (req, res) => {
3626
2551
  channelId = project.discord.channel_id;
3627
2552
  botUsername = project.discord.bot_username || "";
3628
2553
  }
3629
- bridgeInstalled = fs.existsSync(path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"));
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.
2554
+ bridgeInstalled = true;
3633
2555
  if (configured && !botUsername && project?.discord?.bot_token && cfg) {
3634
2556
  try {
3635
2557
  const resolved = resolveToken(project.discord.bot_token);
@@ -3644,19 +2566,11 @@ router.get("/api/discord", async (req, res) => {
3644
2566
  try { writeConfig(cfg); } catch {}
3645
2567
  }
3646
2568
  }
3647
- } catch { /* non-fatal — widget will just show no username */ }
2569
+ } catch {}
3648
2570
  }
3649
2571
  } catch {}
3650
- const running = isDiscordRunning(projectId);
3651
- let lastError = "";
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
- }
2572
+ const running = discordBridge.isRunning(projectId);
2573
+ const lastError = running ? "" : (discordBridge.getLastError(projectId) || "");
3660
2574
  res.json({
3661
2575
  running,
3662
2576
  configured,
@@ -3691,178 +2605,37 @@ router.post("/api/discord", async (req, res) => {
3691
2605
  }
3692
2606
  }
3693
2607
  case "install": {
3694
- const venvDir = path.join(DISCORD_BRIDGE_DIR, ".venv");
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 });
2608
+ return res.json({ ok: true, patched_projects: [] });
3764
2609
  }
3765
2610
  case "start": {
3766
2611
  const projectId = body.project_id;
3767
2612
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3768
- if (isDiscordRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
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
- }
2613
+ if (discordBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
3775
2614
  const dc = getProjectDiscord(projectId);
3776
2615
  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
2616
  try {
3805
- child = spawn(venvPython, [bridgeScript, "--config", tomlPath], {
3806
- detached: true,
3807
- stdio: ["ignore", outFd, errFd],
3808
- env: buildDiscordBridgeSpawnEnv(process.env),
3809
- });
3810
- child.unref();
3811
- if (child.pid) fs.writeFileSync(discordPidFile(projectId), String(child.pid));
2617
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
2618
+ const qwPort = cfg.port || 8400;
2619
+ await discordBridge.start(projectId, dc.bot_token, dc.channel_id, qwPort);
2620
+ emitSystemMessage(projectId, "Discord bridge connected");
2621
+ return res.json({ ok: true, running: true });
3812
2622
  } catch (err) {
3813
- try { fs.closeSync(outFd); } catch {}
3814
- try { fs.closeSync(errFd); } catch {}
3815
2623
  return res.json({ ok: false, error: err.message || "Start failed" });
3816
2624
  }
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
2625
  }
3834
2626
  case "stop": {
3835
2627
  const projectId = body.project_id;
3836
2628
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3837
2629
  try {
3838
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
3839
- const project = cfg.projects?.find((p) => p.id === projectId);
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 {}
2630
+ discordBridge.stop(projectId);
2631
+ emitSystemMessage(projectId, "Discord bridge disconnected");
3859
2632
  return res.json({ ok: true, running: false });
3860
2633
  } catch (err) {
3861
2634
  return res.json({ ok: false, error: err.message || "Stop failed" });
3862
2635
  }
3863
2636
  }
3864
2637
  case "status":
3865
- return res.json({ running: isDiscordRunning(body.project_id || "") });
2638
+ return res.json({ running: discordBridge.isRunning(body.project_id || "") });
3866
2639
  case "save-config": {
3867
2640
  const projectId = body.project_id;
3868
2641
  const bot_token = typeof body.bot_token === "string" ? body.bot_token.trim() : "";
@@ -3976,25 +2749,9 @@ module.exports.parseActiveBatch = parseActiveBatch;
3976
2749
  // summarizeItems for the batch-progress fixture test.
3977
2750
  module.exports.buildNoPrRow = buildNoPrRow;
3978
2751
  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
2752
  // #693: expose normalizeMentions for unit tests
4000
2753
  module.exports.normalizeMentions = normalizeMentions;
2754
+ // #714: expose for file-chat integration
2755
+ module.exports.getProjectChatMode = getProjectChatMode;
2756
+ // #730: PTY dispatch callback setter
2757
+ module.exports.setPtyDispatchCallback = setPtyDispatchCallback;