quadwork 1.19.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) 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/{153f.fj8jlvle.js → 0_lyyn..t63bc.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/13xk0vgfbrcld.css +2 -0
  18. package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
  19. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  20. package/out/_not-found/__next._full.txt +13 -13
  21. package/out/_not-found/__next._head.txt +4 -4
  22. package/out/_not-found/__next._index.txt +8 -8
  23. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  24. package/out/_not-found/__next._not-found.txt +3 -3
  25. package/out/_not-found/__next._tree.txt +2 -2
  26. package/out/_not-found.html +1 -1
  27. package/out/_not-found.txt +13 -13
  28. package/out/app-shell/__next._full.txt +13 -13
  29. package/out/app-shell/__next._head.txt +4 -4
  30. package/out/app-shell/__next._index.txt +8 -8
  31. package/out/app-shell/__next._tree.txt +2 -2
  32. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  33. package/out/app-shell/__next.app-shell.txt +3 -3
  34. package/out/app-shell.html +1 -1
  35. package/out/app-shell.txt +13 -13
  36. package/out/index.html +1 -1
  37. package/out/index.txt +14 -14
  38. package/out/project/_/__next._full.txt +14 -14
  39. package/out/project/_/__next._head.txt +4 -4
  40. package/out/project/_/__next._index.txt +8 -8
  41. package/out/project/_/__next._tree.txt +2 -2
  42. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  43. package/out/project/_/__next.project.$d$id.txt +3 -3
  44. package/out/project/_/__next.project.txt +3 -3
  45. package/out/project/_/queue/__next._full.txt +14 -14
  46. package/out/project/_/queue/__next._head.txt +4 -4
  47. package/out/project/_/queue/__next._index.txt +8 -8
  48. package/out/project/_/queue/__next._tree.txt +2 -2
  49. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  50. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  52. package/out/project/_/queue/__next.project.txt +3 -3
  53. package/out/project/_/queue.html +1 -1
  54. package/out/project/_/queue.txt +14 -14
  55. package/out/project/_.html +1 -1
  56. package/out/project/_.txt +14 -14
  57. package/out/settings/__next._full.txt +14 -14
  58. package/out/settings/__next._head.txt +4 -4
  59. package/out/settings/__next._index.txt +8 -8
  60. package/out/settings/__next._tree.txt +2 -2
  61. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  62. package/out/settings/__next.settings.txt +3 -3
  63. package/out/settings.html +1 -1
  64. package/out/settings.txt +14 -14
  65. package/out/setup/__next._full.txt +14 -14
  66. package/out/setup/__next._head.txt +4 -4
  67. package/out/setup/__next._index.txt +8 -8
  68. package/out/setup/__next._tree.txt +2 -2
  69. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  70. package/out/setup/__next.setup.txt +3 -3
  71. package/out/setup.html +1 -1
  72. package/out/setup.txt +14 -14
  73. package/package.json +4 -2
  74. package/server/ac-restore.js +128 -0
  75. package/server/bridges/discord.js +183 -0
  76. package/server/bridges/telegram.js +210 -0
  77. package/server/config.js +4 -60
  78. package/server/file-chat.js +318 -0
  79. package/server/index.js +173 -1286
  80. package/server/install-agentchattr.js +3 -284
  81. package/server/mcp-chat-shim.js +171 -0
  82. package/server/migrate-ac.js +158 -0
  83. package/server/pty-dispatcher.js +188 -0
  84. package/server/routes.js +149 -1397
  85. package/templates/CLAUDE.md +2 -2
  86. package/templates/OVERNIGHT-QUEUE.md +1 -1
  87. package/templates/seeds/butler.CLAUDE.md +30 -62
  88. package/templates/seeds/dev.AGENTS.md +10 -1
  89. package/templates/seeds/head.AGENTS.md +3 -3
  90. package/templates/seeds/re1.AGENTS.md +3 -3
  91. package/templates/seeds/re2.AGENTS.md +3 -3
  92. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  93. package/bridges/discord/discord_bridge.py +0 -666
  94. package/bridges/discord/requirements.txt +0 -2
  95. package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
  96. package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
  97. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  98. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  99. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  100. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  101. package/server/__tests__/rate-limit-handling.test.js +0 -168
  102. package/server/__tests__/scrub-secrets.test.js +0 -235
  103. package/server/__tests__/v1110-security-qa.test.js +0 -312
  104. package/server/agentchattr-registry.js +0 -188
  105. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  106. package/server/queue-watcher.js +0 -171
  107. package/server/queue-watcher.test.js +0 -64
  108. package/server/routes.batchProgress.test.js +0 -94
  109. package/server/routes.chatWsSend.test.js +0 -161
  110. package/server/routes.discordBridge.test.js +0 -80
  111. package/server/routes.parseActiveBatch.test.js +0 -88
  112. package/server/routes.telegramBridge.test.js +0 -241
  113. package/templates/config.toml +0 -72
  114. package/templates/wrapper.py +0 -70
  115. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
  116. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
  117. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
package/server/routes.js CHANGED
@@ -10,15 +10,26 @@ const path = require("path");
10
10
  const os = require("os");
11
11
 
12
12
  const multer = require("multer");
13
+ const fileChat = require("./file-chat");
14
+ const telegramBridge = require("./bridges/telegram");
15
+ const discordBridge = require("./bridges/discord");
13
16
 
14
17
  const router = express.Router();
15
18
 
19
+ // #730: PTY dispatch callback — set by index.js at startup
20
+ let _ptyDispatchCallback = null;
21
+ function setPtyDispatchCallback(fn) { _ptyDispatchCallback = fn; }
22
+
16
23
  const CONFIG_DIR = path.join(os.homedir(), ".quadwork");
17
24
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
18
25
  const ENV_PATH = path.join(CONFIG_DIR, ".env");
19
26
  const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
20
27
  const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
21
28
 
29
+ function isLocalhost(ip) {
30
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
31
+ }
32
+
22
33
  // ─── GitHub API rate limit tracking (#554) ────────────────────────────────
23
34
  // Shared rate-limit state: periodically refreshed via `gh api rate_limit`.
24
35
  // Server-side gh calls check this before executing and back off when low.
@@ -140,8 +151,6 @@ function cachedGhEndpoint(cacheKey, ghArgs, res, { transform } = {}) {
140
151
 
141
152
  const DEFAULT_CONFIG = {
142
153
  port: 8400,
143
- agentchattr_url: "http://127.0.0.1:8300",
144
- agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"),
145
154
  projects: [],
146
155
  };
147
156
 
@@ -205,10 +214,10 @@ router.put("/api/config", (req, res) => {
205
214
  }
206
215
  });
207
216
 
208
- // ─── Chat (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,41 @@ 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
+ const normalized = messages.map((m) => ({
272
+ ...m,
273
+ time: m.time || (m.ts ? new Date(m.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) : ""),
274
+ }));
275
+ return res.json(normalized);
292
276
  });
293
277
 
294
- // #225 sub-E: send chat messages from the dashboard via the
295
- // AgentChattr WebSocket, not via /api/send.
296
- //
297
- // /api/send requires `Authorization: Bearer <registration_token>` and
298
- // the token must resolve to a registered instance via
299
- // `registry.resolve_token()`. The session_token we store on the
300
- // project entry only authorizes browser/middleware traffic — it is
301
- // NOT a registration token, so /api/send always 401s with
302
- // "missing Authorization: Bearer <token>". The dashboard browser
303
- // already sends through the WebSocket on `/ws?token=<session_token>`
304
- // and the server accepts that path, so we mirror that exact flow
305
- // from the express server: open a one-shot ws, push the message,
306
- // wait briefly for ack, close.
307
- const { WebSocket: NodeWebSocket } = require("ws");
308
- const { syncChattrToken } = require("./config");
309
-
310
- // #236: wait for AgentChattr to echo our message back over the same ws
311
- // connection before resolving, instead of fire-and-forgetting. AC's
312
- // /ws handler does this on every connect:
313
- // 1. Replays history as N `{type:"message", data: msg}` frames.
314
- // 2. Sends one `{type:"status", data: …}` frame (broadcast_status).
315
- // 3. Enters the receive loop and accepts our outgoing frame.
316
- // After our `type:"message"` is processed, AC calls `store.add()`
317
- // which broadcasts the stored record back to all clients (including
318
- // us) as another `{type:"message", data: msg}`.
319
- //
320
- // To get a race-free ack we therefore:
321
- // A. Wait for the first `type:"status"` frame to confirm the
322
- // history replay is done — any `type:"message"` frame seen
323
- // BEFORE that is historical and must be ignored.
324
- // B. Only then send our message and record the highest message
325
- // id observed so far as a correlation baseline.
326
- // C. Accept the first post-send `type:"message"` whose payload
327
- // matches (sender, text, channel, reply_to) AND whose id is
328
- // strictly greater than the baseline (AC ids are monotonically
329
- // increasing from store.add). This eliminates the risk a
330
- // reviewer flagged on #382 round 1: a historical identical
331
- // message from <1.5s ago could have satisfied the old
332
- // heuristic matcher.
333
- // On timeout / early close / 4003, we surface a proper error so the
334
- // /api/chat handler can return a 5xx (or 401) instead of a silent
335
- // {ok:true}.
336
278
  // #693: Auto-normalize bare agent names to @mentions in outbound messages.
337
279
  // Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
338
280
  // Already-prefixed mentions are not double-prefixed; suffixed names like
@@ -340,341 +282,67 @@ const { syncChattrToken } = require("./config");
340
282
  const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
341
283
  function normalizeMentions(text) {
342
284
  if (typeof text !== "string" || !text) return text || "";
343
- 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
- });
285
+ const preserved = [];
286
+ const ph = "\x00CODE\x00";
287
+ let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
288
+ preserved.push(m);
289
+ return ph;
439
290
  });
440
- }
441
-
442
- /**
443
- * #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");
291
+ safe = MENTION_AGENT_NAMES.reduce(
292
+ (t, name) =>
293
+ t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
294
+ const before = str.slice(Math.max(0, offset - 20), offset);
295
+ if (/[=\/]$/.test(before) || /\b(run|exec|npx|start|checkout|switch|rebase|cd|cat|ls|rm|mv|cp|mkdir)\s+$/i.test(before)) return match;
296
+ const after = str.slice(offset + name.length, offset + name.length + 1);
297
+ if (after === "/") return match;
298
+ return `@${name}`;
299
+ }),
300
+ safe,
301
+ );
302
+ let i = 0;
303
+ return safe.replace(new RegExp(ph, "g"), () => preserved[i++] || "");
492
304
  }
493
305
 
494
306
  router.get("/api/loop-guard", (req, res) => {
495
307
  const projectId = req.query.project;
496
308
  if (!projectId) return res.status(400).json({ error: "Missing project" });
497
- const 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
- }
309
+ const value = getProjectMaxHops(projectId);
310
+ return res.json({ value, source: value === 30 ? "default" : "config" });
507
311
  });
508
312
 
509
- router.put("/api/loop-guard", async (req, res) => {
313
+ router.put("/api/loop-guard", (req, res) => {
510
314
  const projectId = req.query.project || req.body?.project;
511
315
  if (!projectId) return res.status(400).json({ error: "Missing project" });
512
316
  const raw = req.body?.value;
513
317
  const value = typeof raw === "number" ? raw : parseInt(raw, 10);
514
- // AC's update_settings handler clamps to [1, 50]; mirror that
515
- // here so we don't write a value AC will silently rewrite.
516
318
  if (!Number.isInteger(value) || value < 4 || value > 50) {
517
319
  return res.status(400).json({ error: "value must be an integer between 4 and 50" });
518
320
  }
519
321
 
520
- // 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 });
322
+ const cfg = readConfigFile();
323
+ const project = (cfg.projects || []).find((p) => p.id === projectId);
324
+ if (!project) return res.status(404).json({ error: "Project not found" });
325
+ project.max_agent_hops = value;
326
+ writeConfigFile(cfg);
327
+ return res.json({ ok: true, value });
653
328
  });
654
329
 
655
330
  // #412 / quadwork#279: project history export + import.
656
331
  //
657
- // Export 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.
332
+ // Export reads messages from file-chat and wraps the array in a
333
+ // small metadata envelope so future imports can warn on project-id
334
+ // mismatch and so a future schema bump can be detected client-side.
661
335
  //
662
336
  // Import accepts the same envelope, validates the shape + size,
663
- // and replays each message 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.
337
+ // and replays each message into the file-chat store — preserving
338
+ // the original sender field for cross-tool consistency.
668
339
 
669
340
  const PROJECT_HISTORY_VERSION = 1;
670
341
  const PROJECT_HISTORY_MAX_BYTES = 10 * 1024 * 1024; // 10 MB cap per issue
671
- const PROJECT_HISTORY_REPLAY_DELAY_MS = 25; // pace AC ws inserts
672
342
 
673
343
  // #414 / quadwork#297: reject imports whose messages claim a
674
- // reserved agent / system sender by default. 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
344
+ // reserved agent / system sender by default. Mirrors the
345
+ // RESERVED_OPERATOR_NAMES denylist from sanitizeOperatorName so
678
346
  // the same identities are blocked across the codebase.
679
347
  const RESERVED_HISTORY_SENDERS = new Set([
680
348
  "head",
@@ -692,29 +360,11 @@ const RESERVED_HISTORY_SENDERS = new Set([
692
360
  "system",
693
361
  ]);
694
362
 
695
- router.get("/api/project-history", async (req, res) => {
363
+ router.get("/api/project-history", (req, res) => {
696
364
  const projectId = req.query.project;
697
365
  if (!projectId) return res.status(400).json({ error: "Missing project" });
698
- const { url: base, token: sessionToken } = getChattrConfig(projectId);
699
- if (!base) return res.status(400).json({ error: "No AgentChattr configured for project" });
700
366
  try {
701
- // 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 : [];
367
+ const messages = fileChat.readMessages(projectId, { limit: 100000 });
718
368
  res.json({
719
369
  version: PROJECT_HISTORY_VERSION,
720
370
  project_id: projectId,
@@ -723,7 +373,7 @@ router.get("/api/project-history", async (req, res) => {
723
373
  messages,
724
374
  });
725
375
  } catch (err) {
726
- res.status(502).json({ error: "Project history export failed", detail: err.message || String(err) });
376
+ res.status(500).json({ error: "Project history export failed", detail: err.message || String(err) });
727
377
  }
728
378
  });
729
379
 
@@ -807,24 +457,9 @@ router.post("/api/project-history", async (req, res) => {
807
457
  }
808
458
  }
809
459
 
810
- 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.
460
+ // Replay each message into the file-chat store. Preserve the
461
+ // original sender so the imported transcript still attributes
462
+ // each line correctly.
828
463
  let imported = 0;
829
464
  let skipped = 0;
830
465
  const errors = [];
@@ -833,24 +468,18 @@ router.post("/api/project-history", async (req, res) => {
833
468
  skipped++;
834
469
  continue;
835
470
  }
836
- const msg = {
837
- text: m.text,
838
- channel: typeof m.channel === "string" && m.channel ? m.channel : "general",
839
- sender: typeof m.sender === "string" && m.sender ? m.sender : "user",
840
- };
841
471
  try {
842
- await sendViaWebSocket(base, sessionToken, msg);
472
+ fileChat.appendMessage(projectId, {
473
+ sender: typeof m.sender === "string" && m.sender ? m.sender : "user",
474
+ text: m.text,
475
+ channel: typeof m.channel === "string" && m.channel ? m.channel : "general",
476
+ type: m.type || "message",
477
+ });
843
478
  imported++;
844
479
  } catch (err) {
845
480
  errors.push(`#${m.id ?? "?"}: ${err.message || String(err)}`);
846
- // Stop on the first error to avoid spamming AC if its ws is down.
847
481
  if (errors.length > 5) break;
848
482
  }
849
- // Tiny delay between sends — AC's ws handler can keep up but
850
- // 10k messages back-to-back hit the recv buffer hard.
851
- if (PROJECT_HISTORY_REPLAY_DELAY_MS > 0) {
852
- await new Promise((r) => setTimeout(r, PROJECT_HISTORY_REPLAY_DELAY_MS));
853
- }
854
483
  }
855
484
  // #414 / quadwork#297 — Issue 2: stamp the import marker on the
856
485
  // project so a re-import of the same file is caught next time.
@@ -1073,99 +702,36 @@ router.get("/api/activity/stats", (_req, res) => {
1073
702
  }
1074
703
  });
1075
704
 
1076
- router.post("/api/chat", async (req, res) => {
705
+ router.post("/api/chat", (req, res) => {
1077
706
  const projectId = req.query.project || req.body.project;
1078
- const { url: base, token: sessionToken } = getChattrConfig(projectId);
1079
- if (!base) return res.status(400).json({ error: "Missing project" });
1080
-
1081
- // #230: ignore any client-supplied sender. /api/chat is the
1082
- // dashboard's send path, so the message must always be attributed
1083
- // to a server-controlled value. Forwarding `req.body.sender` would
1084
- // let any caller hitting QuadWork's /api/chat impersonate an agent
1085
- // identity (t1, t3, …) over the AgentChattr ws path, which the
1086
- // old /api/send flow could not do.
1087
- //
1088
- // #405 / quadwork#278: read the operator's display name from the
1089
- // server-side config file rather than hardcoding "user". The
1090
- // sanitizer matches AC's registry name validator (1–32 alnum +
1091
- // dash + underscore) so even a hand-edited config can't post a
1092
- // value AC will reject (or impersonate an agent), and an empty /
1093
- // missing value falls back to "user".
1094
- let operatorSender = "user";
1095
- try {
1096
- const cfg = readConfigFile();
1097
- operatorSender = sanitizeOperatorName(cfg.operator_name);
1098
- } catch {
1099
- // non-fatal — fall through to "user"
1100
- }
1101
- // #397 / quadwork#262: pass reply_to through to AgentChattr so the
1102
- // dashboard's reply button mirrors AC's native threaded-reply
1103
- // behavior. Only forward when it's a real positive integer — guards
1104
- // against arbitrary client payloads.
1105
- const replyToRaw = req.body?.reply_to;
1106
- const replyTo = (typeof replyToRaw === "number" && Number.isInteger(replyToRaw) && replyToRaw > 0)
1107
- ? replyToRaw
1108
- : null;
1109
- const message = {
1110
- text: typeof req.body?.text === "string" ? req.body.text : "",
1111
- channel: req.body?.channel || "general",
1112
- sender: operatorSender,
1113
- attachments: Array.isArray(req.body?.attachments) ? req.body.attachments : [],
1114
- ...(replyTo !== null ? { reply_to: replyTo } : {}),
1115
- };
1116
- if (!message.text && message.attachments.length === 0) {
1117
- return res.status(400).json({ error: "text or attachments required" });
1118
- }
1119
707
 
1120
- // #693: normalize bare agent names to @mentions (belt-and-suspenders
1121
- // with sendViaWebSocket's own normalization)
1122
- message.text = normalizeMentions(message.text);
1123
-
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
- }
708
+ const text = typeof req.body?.text === "string" ? req.body.text : "";
709
+ if (!text) return res.status(400).json({ error: "text required" });
710
+ const shimSender = req.headers["x-chat-sender"];
711
+ const shimToken = req.headers["x-chat-token"];
712
+ const bridgeSender = req.headers["x-bridge-sender"];
713
+ let sender = "user";
714
+ if (shimSender && shimToken) {
715
+ if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
716
+ return res.status(403).json({ error: "Invalid shim token" });
1165
717
  }
1166
- console.warn(`[chat] send failed for project ${projectId}: ${err && err.message}`);
1167
- return res.status(502).json({ error: "AgentChattr unreachable", detail: err && err.message });
718
+ sender = shimSender;
719
+ } else if (bridgeSender && isLocalhost(req.ip)) {
720
+ sender = bridgeSender;
1168
721
  }
722
+ const msg = fileChat.appendMessage(projectId, {
723
+ sender,
724
+ text: normalizeMentions(text),
725
+ channel: req.body?.channel || "general",
726
+ type: "message",
727
+ });
728
+ // #717: loop guard — count agent hops, pause if threshold reached
729
+ const maxHops = getProjectMaxHops(projectId);
730
+ fileChat.checkLoopGuard(projectId, msg, maxHops);
731
+ if (!fileChat.isLoopGuardPaused(projectId)) {
732
+ if (_ptyDispatchCallback) _ptyDispatchCallback(projectId, msg);
733
+ }
734
+ return res.json({ ok: true, message: msg });
1169
735
  });
1170
736
 
1171
737
  // ─── Image upload (#466) ──────────────────────────────────────────────────
@@ -1253,19 +819,11 @@ router.get("/api/projects", async (req, res) => {
1253
819
 
1254
820
  // Fetch chat messages from all projects (per-project AgentChattr instances)
1255
821
  const chatMsgsByProject = {};
1256
- const chatFetches = (cfg.projects || []).map(async (p) => {
1257
- const { url: chattrUrl, token: chattrToken } = getChattrConfig(p.id);
822
+ for (const p of cfg.projects || []) {
1258
823
  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
- }
824
+ chatMsgsByProject[p.id] = fileChat.readMessages(p.id, { limit: 30 });
1266
825
  } catch {}
1267
- });
1268
- await Promise.allSettled(chatFetches);
826
+ }
1269
827
  // Aggregate all project chat messages for the activity feed
1270
828
  let chatMsgs = Object.values(chatMsgsByProject).flat();
1271
829
 
@@ -2598,98 +2156,6 @@ router.post("/api/setup", (req, res) => {
2598
2156
  }
2599
2157
  return res.json({ ok: true, seeded });
2600
2158
  }
2601
- case "agentchattr-config": {
2602
- const workingDir = body.workingDir;
2603
- if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
2604
- const dirName = path.basename(workingDir);
2605
- const displayName = body.projectName || dirName;
2606
- const parentDir = path.dirname(workingDir);
2607
- const backends = body.backends;
2608
-
2609
- // Phase 2D / #181: config.toml lives at the per-project AgentChattr
2610
- // clone ROOT (~/.quadwork/{id}/agentchattr/), not inside the user's
2611
- // project working_dir. AgentChattr's run.py loads ROOT/config.toml
2612
- // and ignores --config, so the toml has to be at the same path the
2613
- // clone lives at. Same path matches what writeQuadWorkConfig()
2614
- // persists in agentchattr_dir (#182) and what the CLI wizard
2615
- // writes (#184).
2616
- //
2617
- // We install the clone *here*, before writing config.toml. The
2618
- // install must run first because installAgentChattr() refuses to
2619
- // overwrite a non-empty directory it doesn't recognize — if we
2620
- // mkdir + write config.toml first, the subsequent install in
2621
- // add-config would see "unrelated content" and reject the dir,
2622
- // breaking first-run web project creation (t2a's review of #195).
2623
- const projectConfigDir = path.join(CONFIG_DIR, dirName, "agentchattr");
2624
- if (!findAgentChattr(projectConfigDir)) {
2625
- const installResult = installAgentChattr(projectConfigDir);
2626
- if (!installResult) {
2627
- const reason = installAgentChattr.lastError || "unknown error";
2628
- return res.json({ ok: false, error: `AgentChattr install failed at ${projectConfigDir}: ${reason}` });
2629
- }
2630
- }
2631
- const dataDir = path.join(projectConfigDir, "data");
2632
- ensureSecureDir(dataDir);
2633
- const tomlPath = path.join(projectConfigDir, "config.toml");
2634
-
2635
- // Resolve per-project ports: prefer explicit body params (from setup wizard),
2636
- // then fall back to saved config, then defaults
2637
- let chattrPort, mcp_http, mcp_sse;
2638
- if (body.agentchattr_port) {
2639
- chattrPort = String(body.agentchattr_port);
2640
- mcp_http = body.mcp_http_port || 8200;
2641
- mcp_sse = body.mcp_sse_port || 8201;
2642
- } else {
2643
- const projectChattr = resolveProjectChattr(dirName);
2644
- chattrPort = new URL(projectChattr.url).port || "8300";
2645
- mcp_http = projectChattr.mcp_http_port || 8200;
2646
- mcp_sse = projectChattr.mcp_sse_port || 8201;
2647
- }
2648
-
2649
- const agents = ["head", "re1", "re2", "dev"];
2650
- const colors = ["#10a37f", "#22c55e", "#f59e0b", "#da7756"];
2651
- const labels = ["Lead", "Reviewer 1", "Reviewer 2", "Builder"];
2652
-
2653
- // Read or generate token for this project
2654
- const crypto = require("crypto");
2655
- const savedCfg = readConfigFile();
2656
- const savedProject = savedCfg.projects?.find((p) => p.id === dirName);
2657
- const sessionToken = body.agentchattr_token || savedProject?.agentchattr_token || crypto.randomBytes(16).toString("hex");
2658
-
2659
- let content = `[meta]\nname = "${displayName}"\n\n`;
2660
- content += `[server]\nport = ${chattrPort}\nhost = "127.0.0.1"\ndata_dir = "${dataDir}"\n`;
2661
- if (sessionToken) content += `session_token = "${sessionToken}"\n`;
2662
- content += `\n`;
2663
- agents.forEach((agent, i) => {
2664
- const wtDir = path.join(parentDir, `${dirName}-${agent}`);
2665
- content += `[agents.${agent}]\ncommand = "${(backends && backends[agent]) || "claude"}"\ncwd = "${wtDir}"\ncolor = "${colors[i]}"\nlabel = "${labels[i]}"\nmcp_inject = "flag"\n\n`;
2666
- });
2667
- // #592: CLI-based agent sections for AC HEAD compatibility.
2668
- // HEAD AC validates `base` against [agents.*] keys. Add CLI-name
2669
- // sections (deduped) so registration works on both pinned and HEAD AC.
2670
- const seenClis = new Set();
2671
- agents.forEach((agent) => {
2672
- const cmd = (backends && backends[agent]) || "claude";
2673
- const cli = cmd.split("/").pop().split(" ")[0];
2674
- seenClis.add(cli);
2675
- });
2676
- for (const cli of seenClis) {
2677
- const injectMode = cli === "codex" ? "proxy_flag" : cli === "gemini" ? "env" : "flag";
2678
- content += `[agents.${cli}]\ncommand = "${cli}"\nlabel = "${cli}"\nmcp_inject = "${injectMode}"\n\n`;
2679
- }
2680
- // #403 / quadwork#274: raise the loop guard from AC's default
2681
- // of 4 to 30 so autonomous PR review cycles (head→dev→re1+re2→
2682
- // dev→head, ~5 hops) don't fire mid-batch and force the
2683
- // operator to type /continue. AC clamps to [1, 50] internally.
2684
- content += `[routing]\ndefault = "none"\nmax_agent_hops = 30\n\n`;
2685
- content += `[mcp]\nhttp_port = ${mcp_http}\nsse_port = ${mcp_sse}\n`;
2686
- // #607: Bridge agent declarations so AC accepts dc/tg base registration
2687
- content += `\n[agents.tg]\nlabel = "Telegram Bridge"\n\n`;
2688
- content += `[agents.dc]\nlabel = "Discord Bridge"\n`;
2689
- writeSecureFile(tomlPath, content);
2690
-
2691
- return res.json({ ok: true, path: tomlPath, agentchattr_token: sessionToken, agentchattr_port: chattrPort, mcp_http_port: mcp_http, mcp_sse_port: mcp_sse });
2692
- }
2693
2159
  case "add-config": {
2694
2160
  const { id, name, repo, workingDir, backends } = body;
2695
2161
  const autoApprove = body.auto_approve !== false; // default true
@@ -2698,7 +2164,7 @@ router.post("/api/setup", (req, res) => {
2698
2164
  const parentDir = path.dirname(workingDir);
2699
2165
  let cfg;
2700
2166
  try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
2701
- catch { cfg = { port: 8400, agentchattr_url: "http://127.0.0.1:8300", agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"), projects: [] }; }
2167
+ catch { cfg = { port: 8400, projects: [] }; }
2702
2168
  if (cfg.projects.some((p) => p.id === id)) {
2703
2169
  // Project already saved, but still (idempotently) seed the
2704
2170
  // OVERNIGHT-QUEUE.md in case a previous run failed to write
@@ -2728,47 +2194,9 @@ router.post("/api/setup", (req, res) => {
2728
2194
  ...(cliBase === "codex" ? { reasoning_effort: "medium" } : {}),
2729
2195
  };
2730
2196
  }
2731
- // Use pre-assigned ports/token from agentchattr-config step if provided,
2732
- // otherwise auto-assign (direct add-config without prior agentchattr-config)
2733
- const crypto = require("crypto");
2734
- let chattrPort = body.agentchattr_port;
2735
- let mcp_http_port = body.mcp_http_port;
2736
- let mcp_sse_port = body.mcp_sse_port;
2737
- let agentchattr_token = body.agentchattr_token;
2738
- if (!chattrPort) {
2739
- const usedChattrPorts = new Set(cfg.projects.map((p) => {
2740
- try { return parseInt(new URL(p.agentchattr_url).port, 10); } catch { return 0; }
2741
- }).filter(Boolean));
2742
- const usedMcpPorts = new Set(cfg.projects.flatMap((p) => [p.mcp_http_port, p.mcp_sse_port]).filter(Boolean));
2743
- chattrPort = 8300;
2744
- while (usedChattrPorts.has(chattrPort)) chattrPort++;
2745
- mcp_http_port = 8200;
2746
- while (usedMcpPorts.has(mcp_http_port)) mcp_http_port++;
2747
- mcp_sse_port = mcp_http_port + 1;
2748
- while (usedMcpPorts.has(mcp_sse_port)) mcp_sse_port++;
2749
- }
2750
- if (!agentchattr_token) agentchattr_token = crypto.randomBytes(16).toString("hex");
2751
-
2752
- // Phase 2D / #181: clone AgentChattr per-project before saving config.
2753
- // The path here must match the one written into agentchattr_dir below
2754
- // and the one agentchattr-config writes config.toml into.
2755
- const perProjectDir = path.join(CONFIG_DIR, id, "agentchattr");
2756
- if (!findAgentChattr(perProjectDir)) {
2757
- const installResult = installAgentChattr(perProjectDir);
2758
- if (!installResult) {
2759
- const reason = installAgentChattr.lastError || "unknown error";
2760
- return res.json({ ok: false, error: `AgentChattr install failed at ${perProjectDir}: ${reason}` });
2761
- }
2762
- }
2763
-
2764
2197
  cfg.projects.push({
2765
2198
  id, name, repo, working_dir: workingDir, agents,
2766
- 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,
2199
+ chat_mode: "file",
2772
2200
  });
2773
2201
  const dir = path.dirname(CONFIG_PATH);
2774
2202
  ensureSecureDir(dir);
@@ -2778,44 +2206,6 @@ router.post("/api/setup", (req, res) => {
2778
2206
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
2779
2207
  writeOvernightQueueFileSafe(id, name || id, repo);
2780
2208
 
2781
- // Batch 28 / #392 / quadwork#252: auto-spawn the per-project
2782
- // AgentChattr process. The CLI wizard's writeAgentChattrConfig
2783
- // does this; the web wizard previously left the install dormant
2784
- // until the user clicked Restart, so MCP fell through to a stale
2785
- // instance on port 8300. Mirror the loopback-restart pattern
2786
- // already used by the agentchattr-config branch above. Failures
2787
- // are non-fatal — the dashboard's Restart button is still
2788
- // available, and per the issue add-config must still return ok.
2789
- try {
2790
- const qwPort = cfg.port || 8400;
2791
- fetch(
2792
- `http://127.0.0.1:${qwPort}/api/agentchattr/${encodeURIComponent(id)}/restart`,
2793
- { method: "POST" },
2794
- )
2795
- .then(async (r) => {
2796
- // /restart reports spawn failures (e.g. port collision —
2797
- // server/index.js:650-668) as HTTP 500, so a resolved
2798
- // fetch is not the same thing as a successful spawn. Log
2799
- // non-2xx responses with status and body so the operator
2800
- // can see why the auto-spawn silently didn't take.
2801
- if (!r.ok) {
2802
- let detail = "";
2803
- try { detail = (await r.text()).slice(0, 500); } catch {}
2804
- console.warn(
2805
- `[setup] auto-spawn AgentChattr for ${id} returned HTTP ${r.status}: ${detail}`,
2806
- );
2807
- }
2808
- })
2809
- .catch((err) => {
2810
- console.warn(
2811
- `[setup] auto-spawn AgentChattr for ${id} failed:`,
2812
- err.message || err,
2813
- );
2814
- });
2815
- } catch (err) {
2816
- console.warn(`[setup] auto-spawn AgentChattr for ${id} skipped:`, err.message || err);
2817
- }
2818
-
2819
2209
  return res.json({ ok: true });
2820
2210
  }
2821
2211
  default:
@@ -2925,172 +2315,6 @@ router.post("/api/rename", (req, res) => {
2925
2315
 
2926
2316
  // ─── Telegram ──────────────────────────────────────────────────────────────
2927
2317
 
2928
- const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
2929
- // #444: pin agentchattr-telegram to a known commit (same pattern as
2930
- // AGENTCHATTR_PIN in bin/quadwork.js for bcurts/agentchattr).
2931
- const AGENTCHATTR_TELEGRAM_PIN = "045ee18f6d5dbcd0bd45d5ab29f06e2a27382aaf";
2932
-
2933
- function telegramPidFile(projectId) {
2934
- return path.join(CONFIG_DIR, `tg-bridge-${projectId}.pid`);
2935
- }
2936
-
2937
- function telegramConfigToml(projectId) {
2938
- return path.join(CONFIG_DIR, `telegram-${projectId}.toml`);
2939
- }
2940
-
2941
- // #383: path to a project's AgentChattr config.toml. The install
2942
- // handler patches this file to declare the `tg` agent
2943
- // so AC's registry accepts the bridge's register call.
2944
- function projectAgentchattrConfigPath(projectId) {
2945
- return path.join(CONFIG_DIR, projectId, "agentchattr", "config.toml");
2946
- }
2947
-
2948
- // #383 Bug 1: prefer the per-project agentchattr_url. Every project
2949
- // after the first uses a distinct port (8301, 8302, ...), so reading
2950
- // the global default silently routed bridge traffic to the wrong AC
2951
- // instance.
2952
- function resolveProjectAgentchattrUrl(cfg, project) {
2953
- return (
2954
- (project && project.agentchattr_url) ||
2955
- (cfg && cfg.agentchattr_url) ||
2956
- "http://127.0.0.1:8300"
2957
- );
2958
- }
2959
-
2960
- // #383 Bug 2: the upstream bridge only reads `agentchattr_url` from
2961
- // inside `[telegram]`. A separate `[agentchattr]` section is silently
2962
- // ignored and the bridge falls back to its hardcoded :8300 default.
2963
- // #404: accept projectId so we can write a per-project cursor_file
2964
- // path. Without this, multiple project bridges share the same default
2965
- // cursor and clobber each other's position — the project with higher
2966
- // AC message IDs advances the cursor past the other project's range,
2967
- // silently killing AC→TG forwarding for that project.
2968
- function buildTelegramBridgeToml(tg, projectId) {
2969
- const cursorFile = path.join(CONFIG_DIR, `tg-bridge-cursor-${projectId}.json`);
2970
- // #439: migrate old cursor file so the bridge doesn't replay history
2971
- const oldCursor = path.join(CONFIG_DIR, `telegram-bridge-cursor-${projectId}.json`);
2972
- if (!fs.existsSync(cursorFile) && fs.existsSync(oldCursor)) {
2973
- fs.renameSync(oldCursor, cursorFile);
2974
- }
2975
- return (
2976
- `[telegram]\n` +
2977
- `bot_token = "${tg.bot_token}"\n` +
2978
- `chat_id = "${tg.chat_id}"\n` +
2979
- `agentchattr_url = "${tg.agentchattr_url}"\n` +
2980
- `cursor_file = "${cursorFile}"\n` +
2981
- `project_id = "${projectId}"\n`
2982
- );
2983
- }
2984
-
2985
- // #383 Bug 3: AC's registry rejects any base name not pre-declared
2986
- // in config.toml with `400 unknown base`. The bridge registers as
2987
- // `tg` (#439: renamed from `telegram-bridge`), so every per-project
2988
- // AC config must declare it. Idempotent: only appends if the section
2989
- // is not already present. Also migrates old `[agents.telegram-bridge]`.
2990
- function patchAgentchattrConfigForTelegramBridge(tomlText) {
2991
- // #439: migrate old slug if present
2992
- const original = tomlText;
2993
- tomlText = tomlText.replace(/^\[agents\.telegram-bridge\]\s*$/m, "[agents.tg]");
2994
- if (/^\[agents\.tg\]\s*$/m.test(tomlText)) {
2995
- return { text: tomlText, changed: tomlText !== original };
2996
- }
2997
- const sep = tomlText.length === 0 || tomlText.endsWith("\n") ? "" : "\n";
2998
- const block = `\n[agents.tg]\nlabel = "Telegram Bridge"\n`;
2999
- return { text: tomlText + sep + block, changed: true };
3000
- }
3001
-
3002
- // #383 Bug 4: the upstream bridge treats env vars as higher
3003
- // precedence than TOML values. If the parent shell exported
3004
- // TELEGRAM_BOT_TOKEN for a different bot, the bridge silently ran
3005
- // as the wrong identity. Scrub those keys from the child's env so
3006
- // the TOML is the single source of truth.
3007
- function buildTelegramBridgeSpawnEnv(parentEnv) {
3008
- const env = { ...parentEnv };
3009
- delete env.TELEGRAM_BOT_TOKEN;
3010
- delete env.TELEGRAM_CHAT_ID;
3011
- delete env.AGENTCHATTR_URL;
3012
- return env;
3013
- }
3014
-
3015
- // #353: per-project log file for the bridge subprocess. The start
3016
- // handler redirects stdout + stderr here so crashes (ImportError,
3017
- // config parse, auth failure) are recoverable instead of
3018
- // /dev/null'd by `stdio: "ignore"`.
3019
- function telegramBridgeLog(projectId) {
3020
- return path.join(CONFIG_DIR, `tg-bridge-${projectId}.log`);
3021
- }
3022
-
3023
- // Tail the last N lines of a file without reading the whole thing
3024
- // into memory if it is huge. For the bridge log we care about the
3025
- // final crash frame, not historical output.
3026
- function readLastLines(filePath, n) {
3027
- try {
3028
- if (!fs.existsSync(filePath)) return "";
3029
- const stat = fs.statSync(filePath);
3030
- const readBytes = Math.min(stat.size, 64 * 1024);
3031
- if (readBytes === 0) return "";
3032
- const buf = Buffer.alloc(readBytes);
3033
- const fd = fs.openSync(filePath, "r");
3034
- try {
3035
- fs.readSync(fd, buf, 0, readBytes, Math.max(0, stat.size - readBytes));
3036
- } finally {
3037
- fs.closeSync(fd);
3038
- }
3039
- const text = buf.toString("utf-8");
3040
- const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
3041
- return lines.slice(-n).join("\n");
3042
- } catch {
3043
- return "";
3044
- }
3045
- }
3046
-
3047
- // Verify that the bridge's Python runtime has its required modules
3048
- // available. Cheap pre-flight so a missing `requests` install
3049
- // produces a readable error instead of a silent Start → Stopped
3050
- // flicker. Returns { ok: true } on success, { ok: false, error }
3051
- // otherwise. Keep the import list small and close to what the
3052
- // bridge actually needs; add modules here if the bridge gains new
3053
- // hard deps.
3054
- // #380: `pythonPath` defaults to bare `python3` for backward-compat,
3055
- // but the production call sites (install, start) MUST pass the
3056
- // dedicated bridge venv's interpreter (`<BRIDGE_DIR>/.venv/bin/python3`)
3057
- // so the import check runs against the same interpreter the spawn will
3058
- // use. See #379 research ticket for root cause.
3059
- function checkTelegramBridgePythonDeps(pythonPath = "python3") {
3060
- try {
3061
- // Only check the third-party module the bridge actually needs
3062
- // at import time — `requests`. Toml parsing differs between
3063
- // Python versions (tomllib on 3.11+, tomli on 3.10-), and any
3064
- // genuine toml import failure will now be captured in the
3065
- // bridge log file on spawn, so this pre-flight stays narrow
3066
- // and avoids false negatives on older Python installs.
3067
- execFileSync(pythonPath, ["-c", "import requests"], {
3068
- encoding: "utf-8",
3069
- timeout: 10000,
3070
- stdio: ["ignore", "pipe", "pipe"],
3071
- });
3072
- return { ok: true };
3073
- } catch (err) {
3074
- const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
3075
- const msg = stderr.trim() || (err && err.message) || "python3 import check failed";
3076
- return { ok: false, error: msg };
3077
- }
3078
- }
3079
-
3080
- function isTelegramRunning(projectId) {
3081
- const pf = telegramPidFile(projectId);
3082
- if (!fs.existsSync(pf)) return false;
3083
- const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
3084
- if (!pid) return false;
3085
- try {
3086
- process.kill(pid, 0);
3087
- return true;
3088
- } catch {
3089
- fs.unlinkSync(pf);
3090
- return false;
3091
- }
3092
- }
3093
-
3094
2318
  function readEnvToken(key) {
3095
2319
  try {
3096
2320
  const content = fs.readFileSync(ENV_PATH, "utf-8");
@@ -3128,8 +2352,6 @@ function getProjectTelegram(projectId) {
3128
2352
  return {
3129
2353
  bot_token: resolveToken(project.telegram.bot_token || ""),
3130
2354
  chat_id: project.telegram.chat_id || "",
3131
- // #383 Bug 1: prefer per-project URL over the global default.
3132
- agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
3133
2355
  };
3134
2356
  } catch {
3135
2357
  return null;
@@ -3156,11 +2378,8 @@ router.get("/api/telegram", async (req, res) => {
3156
2378
  chatId = project.telegram.chat_id;
3157
2379
  botUsername = project.telegram.bot_username || "";
3158
2380
  }
3159
- bridgeInstalled = fs.existsSync(path.join(BRIDGE_DIR, "telegram_bridge.py"));
2381
+ bridgeInstalled = true;
3160
2382
  } catch {}
3161
- // Lazy-resolve bot username via Telegram getMe the first time
3162
- // after a token is saved. Cache it on the project entry so later
3163
- // requests don't hit the network.
3164
2383
  if (configured && !botUsername && project?.telegram?.bot_token && cfg) {
3165
2384
  try {
3166
2385
  const resolved = resolveToken(project.telegram.bot_token);
@@ -3173,24 +2392,10 @@ router.get("/api/telegram", async (req, res) => {
3173
2392
  try { writeConfig(cfg); } catch {}
3174
2393
  }
3175
2394
  }
3176
- } catch { /* non-fatal — widget will just show no username */ }
3177
- }
3178
- // #353: if the bridge is not running but a log file exists with
3179
- // content, tail it and expose it as `last_error` so the widget
3180
- // can surface runtime crashes (bad token mid-session, network
3181
- // failure, config parse error) that happen after the initial
3182
- // 500 ms post-spawn liveness check and would otherwise just
3183
- // revert the pill to Stopped with no explanation.
3184
- const running = isTelegramRunning(projectId);
3185
- let lastError = "";
3186
- if (!running) {
3187
- const logPath = telegramBridgeLog(projectId);
3188
- try {
3189
- if (fs.existsSync(logPath) && fs.statSync(logPath).size > 0) {
3190
- lastError = readLastLines(logPath, 20);
3191
- }
3192
2395
  } catch {}
3193
2396
  }
2397
+ const running = telegramBridge.isRunning(projectId);
2398
+ const lastError = running ? "" : (telegramBridge.getLastError(projectId) || "");
3194
2399
  res.json({
3195
2400
  running,
3196
2401
  configured,
@@ -3220,238 +2425,37 @@ router.post("/api/telegram", async (req, res) => {
3220
2425
  }
3221
2426
  }
3222
2427
  case "install": {
3223
- // #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 });
2428
+ return res.json({ ok: true, patched_projects: [] });
3308
2429
  }
3309
2430
  case "start": {
3310
2431
  const projectId = body.project_id;
3311
2432
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3312
- if (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
- }
2433
+ if (telegramBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
3327
2434
  const tg = getProjectTelegram(projectId);
3328
2435
  if (!tg || !tg.bot_token || !tg.chat_id) return res.json({ ok: false, error: "Save bot_token and chat_id in project settings first." });
3329
- const tomlPath = telegramConfigToml(projectId);
3330
- // #383 Bug 2: write agentchattr_url inside [telegram]; the
3331
- // bridge's load_config only reads from that section.
3332
- const tomlContent = buildTelegramBridgeToml(tg, projectId);
3333
- writeSecureFile(tomlPath, tomlContent);
3334
- // #353: pre-flight import check so a fresh install with no
3335
- // `requests` module produces a readable error instead of the
3336
- // Start → Running → Stopped flicker that the v1 code path
3337
- // produced with `stdio: "ignore"`.
3338
- const depCheck = checkTelegramBridgePythonDeps(venvPython);
3339
- if (!depCheck.ok) {
3340
- // #372: persist the pre-flight failure to the bridge log
3341
- // file so the GET /api/telegram `last_error` tail picks it
3342
- // up on the next status poll. Without this the widget only
3343
- // sees the error for ~5s before the polling cycle clobbers
3344
- // local error state, producing the "silent fail" symptom
3345
- // (pill flips back to Stopped with no trace of why).
3346
- const msg =
3347
- "Bridge Python dependencies not installed in the dedicated venv. " +
3348
- "Click \"Install Bridge\" to (re)create the venv and install them.\n\n" +
3349
- `Import error: ${depCheck.error}`;
3350
- try {
3351
- fs.writeFileSync(
3352
- telegramBridgeLog(projectId),
3353
- `[${new Date().toISOString()}] pre-flight dep check failed\n${msg}\n`,
3354
- );
3355
- } catch {}
3356
- return res.json({ ok: false, error: msg });
3357
- }
3358
- // #353: capture stdout + stderr to a per-project log file so
3359
- // bridge crashes (bad token, network failure, config parse
3360
- // error, etc.) are recoverable. The handle must be opened
3361
- // BEFORE spawn and passed through stdio so the detached
3362
- // child keeps writing after the parent unrefs it.
3363
- const logPath = telegramBridgeLog(projectId);
3364
- // #353 follow-up: truncate the log at the start of every
3365
- // spawn so the status endpoint's last_error tail only ever
3366
- // reflects the *current* session. Otherwise a previous
3367
- // crash's trace would linger forever and the widget would
3368
- // keep surfacing a stale error even after the operator
3369
- // fixed the underlying problem and restarted cleanly.
3370
- try { fs.writeFileSync(logPath, ""); } catch {}
3371
- let outFd, errFd;
3372
- try {
3373
- outFd = fs.openSync(logPath, "a");
3374
- errFd = fs.openSync(logPath, "a");
3375
- } catch (err) {
3376
- return res.json({ ok: false, error: `Could not open bridge log file: ${err.message}` });
3377
- }
3378
- let child;
3379
2436
  try {
3380
- // #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));
2437
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
2438
+ const qwPort = cfg.port || 8400;
2439
+ telegramBridge.start(projectId, tg.bot_token, tg.chat_id, qwPort);
2440
+ emitSystemMessage(projectId, "Telegram bridge connected");
2441
+ return res.json({ ok: true, running: true });
3392
2442
  } catch (err) {
3393
- try { fs.closeSync(outFd); } catch {}
3394
- try { fs.closeSync(errFd); } catch {}
3395
2443
  return res.json({ ok: false, error: err.message || "Start failed" });
3396
2444
  }
3397
- // Close our copies of the fds in the parent now that the
3398
- // child has inherited them — otherwise the parent holds the
3399
- // log file open forever.
3400
- try { fs.closeSync(outFd); } catch {}
3401
- try { fs.closeSync(errFd); } catch {}
3402
- // #353: liveness check — wait 500ms, then verify the child
3403
- // is still running. If it already died, tail the log file
3404
- // and return those lines as the error.
3405
- await new Promise((r) => setTimeout(r, 500));
3406
- let alive = true;
3407
- try { process.kill(child.pid, 0); } catch { alive = false; }
3408
- if (!alive) {
3409
- const tail = readLastLines(logPath, 20);
3410
- try { fs.unlinkSync(telegramPidFile(projectId)); } catch {}
3411
- return res.json({
3412
- ok: false,
3413
- error:
3414
- "Bridge crashed on start (exited within 500ms).\n\n" +
3415
- `Last log lines (${logPath}):\n${tail || "(log empty)"}`,
3416
- });
3417
- }
3418
- return res.json({ ok: true, running: true, pid: child.pid });
3419
2445
  }
3420
2446
  case "stop": {
3421
2447
  const projectId = body.project_id;
3422
2448
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3423
- // #388: deregister the bridge from AC before killing so the slot
3424
- // clears immediately instead of lingering for 60s as a stale -2/-3.
3425
- // Awaited so a fast stop→start cycle doesn't race the deregister.
3426
2449
  try {
3427
- 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 {}
2450
+ telegramBridge.stop(projectId);
2451
+ emitSystemMessage(projectId, "Telegram bridge disconnected");
3448
2452
  return res.json({ ok: true, running: false });
3449
2453
  } catch (err) {
3450
2454
  return res.json({ ok: false, error: err.message || "Stop failed" });
3451
2455
  }
3452
2456
  }
3453
2457
  case "status":
3454
- return res.json({ running: isTelegramRunning(body.project_id || "") });
2458
+ return res.json({ running: telegramBridge.isRunning(body.project_id || "") });
3455
2459
  case "save-token": {
3456
2460
  const projectId = body.project_id;
3457
2461
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
@@ -3508,89 +2512,6 @@ router.post("/api/telegram", async (req, res) => {
3508
2512
  });
3509
2513
 
3510
2514
  // --- Discord Bridge ---
3511
- // #396/#399: Discord ↔ AgentChattr bridge, bundled in quadwork
3512
- // package at bridges/discord/. Mirrors Telegram bridge patterns.
3513
-
3514
- const DISCORD_BRIDGE_SRC = path.join(__dirname, "..", "bridges", "discord");
3515
- const DISCORD_BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-discord");
3516
-
3517
- function discordPidFile(projectId) {
3518
- return path.join(CONFIG_DIR, `dc-bridge-${projectId}.pid`);
3519
- }
3520
-
3521
- function discordConfigToml(projectId) {
3522
- return path.join(CONFIG_DIR, `discord-${projectId}.toml`);
3523
- }
3524
-
3525
- function discordBridgeLog(projectId) {
3526
- return path.join(CONFIG_DIR, `dc-bridge-${projectId}.log`);
3527
- }
3528
-
3529
- function buildDiscordBridgeToml(dc, projectId) {
3530
- const cursorFile = path.join(CONFIG_DIR, `dc-bridge-cursor-${projectId}.json`);
3531
- // #439: migrate old cursor file so the bridge doesn't replay history
3532
- const oldCursor = path.join(CONFIG_DIR, `discord-bridge-cursor-${projectId}.json`);
3533
- if (!fs.existsSync(cursorFile) && fs.existsSync(oldCursor)) {
3534
- fs.renameSync(oldCursor, cursorFile);
3535
- }
3536
- return (
3537
- `[discord]\n` +
3538
- `bot_token = "${dc.bot_token}"\n` +
3539
- `channel_id = "${dc.channel_id}"\n` +
3540
- `agentchattr_url = "${dc.agentchattr_url}"\n` +
3541
- `cursor_file = "${cursorFile}"\n` +
3542
- `project_id = "${projectId}"\n`
3543
- );
3544
- }
3545
-
3546
- function patchAgentchattrConfigForDiscordBridge(tomlText) {
3547
- // #439: migrate old slug if present
3548
- const original = tomlText;
3549
- tomlText = tomlText.replace(/^\[agents\.discord-bridge\]\s*$/m, "[agents.dc]");
3550
- if (/^\[agents\.dc\]\s*$/m.test(tomlText)) {
3551
- return { text: tomlText, changed: tomlText !== original };
3552
- }
3553
- const sep = tomlText.length === 0 || tomlText.endsWith("\n") ? "" : "\n";
3554
- const block = `\n[agents.dc]\nlabel = "Discord Bridge"\n`;
3555
- return { text: tomlText + sep + block, changed: true };
3556
- }
3557
-
3558
- function buildDiscordBridgeSpawnEnv(parentEnv) {
3559
- const env = { ...parentEnv };
3560
- delete env.DISCORD_BOT_TOKEN;
3561
- delete env.DISCORD_CHANNEL_ID;
3562
- delete env.AGENTCHATTR_URL;
3563
- return env;
3564
- }
3565
-
3566
- function checkDiscordBridgePythonDeps(pythonPath = "python3") {
3567
- try {
3568
- execFileSync(pythonPath, ["-c", "import discord, requests"], {
3569
- encoding: "utf-8",
3570
- timeout: 10000,
3571
- stdio: ["ignore", "pipe", "pipe"],
3572
- });
3573
- return { ok: true };
3574
- } catch (err) {
3575
- const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
3576
- const msg = stderr.trim() || (err && err.message) || "python3 import check failed";
3577
- return { ok: false, error: msg };
3578
- }
3579
- }
3580
-
3581
- function isDiscordRunning(projectId) {
3582
- const pf = discordPidFile(projectId);
3583
- if (!fs.existsSync(pf)) return false;
3584
- const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
3585
- if (!pid) return false;
3586
- try {
3587
- process.kill(pid, 0);
3588
- return true;
3589
- } catch {
3590
- fs.unlinkSync(pf);
3591
- return false;
3592
- }
3593
- }
3594
2515
 
3595
2516
  function discordEnvKeyForProject(projectId) {
3596
2517
  return `DISCORD_BOT_TOKEN_${projectId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
@@ -3604,7 +2525,6 @@ function getProjectDiscord(projectId) {
3604
2525
  return {
3605
2526
  bot_token: resolveToken(project.discord.bot_token || ""),
3606
2527
  channel_id: project.discord.channel_id || "",
3607
- agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
3608
2528
  };
3609
2529
  } catch {
3610
2530
  return null;
@@ -3626,10 +2546,7 @@ router.get("/api/discord", async (req, res) => {
3626
2546
  channelId = project.discord.channel_id;
3627
2547
  botUsername = project.discord.bot_username || "";
3628
2548
  }
3629
- bridgeInstalled = 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.
2549
+ bridgeInstalled = true;
3633
2550
  if (configured && !botUsername && project?.discord?.bot_token && cfg) {
3634
2551
  try {
3635
2552
  const resolved = resolveToken(project.discord.bot_token);
@@ -3644,19 +2561,11 @@ router.get("/api/discord", async (req, res) => {
3644
2561
  try { writeConfig(cfg); } catch {}
3645
2562
  }
3646
2563
  }
3647
- } catch { /* non-fatal — widget will just show no username */ }
2564
+ } catch {}
3648
2565
  }
3649
2566
  } 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
- }
2567
+ const running = discordBridge.isRunning(projectId);
2568
+ const lastError = running ? "" : (discordBridge.getLastError(projectId) || "");
3660
2569
  res.json({
3661
2570
  running,
3662
2571
  configured,
@@ -3691,178 +2600,37 @@ router.post("/api/discord", async (req, res) => {
3691
2600
  }
3692
2601
  }
3693
2602
  case "install": {
3694
- 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 });
2603
+ return res.json({ ok: true, patched_projects: [] });
3764
2604
  }
3765
2605
  case "start": {
3766
2606
  const projectId = body.project_id;
3767
2607
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3768
- if (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
- }
2608
+ if (discordBridge.isRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
3775
2609
  const dc = getProjectDiscord(projectId);
3776
2610
  if (!dc || !dc.bot_token || !dc.channel_id) return res.json({ ok: false, error: "Save bot_token and channel_id in project settings first." });
3777
- const tomlPath = discordConfigToml(projectId);
3778
- const tomlContent = buildDiscordBridgeToml(dc, projectId);
3779
- writeSecureFile(tomlPath, tomlContent);
3780
- const depCheck = checkDiscordBridgePythonDeps(venvPython);
3781
- if (!depCheck.ok) {
3782
- const msg =
3783
- "Bridge Python dependencies not installed in the dedicated venv. " +
3784
- "Click \"Install Bridge\" to (re)create the venv and install them.\n\n" +
3785
- `Import error: ${depCheck.error}`;
3786
- try {
3787
- fs.writeFileSync(
3788
- discordBridgeLog(projectId),
3789
- `[${new Date().toISOString()}] pre-flight dep check failed\n${msg}\n`,
3790
- );
3791
- } catch {}
3792
- return res.json({ ok: false, error: msg });
3793
- }
3794
- const logPath = discordBridgeLog(projectId);
3795
- try { fs.writeFileSync(logPath, ""); } catch {}
3796
- let outFd, errFd;
3797
- try {
3798
- outFd = fs.openSync(logPath, "a");
3799
- errFd = fs.openSync(logPath, "a");
3800
- } catch (err) {
3801
- return res.json({ ok: false, error: `Could not open bridge log file: ${err.message}` });
3802
- }
3803
- let child;
3804
2611
  try {
3805
- 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));
2612
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
2613
+ const qwPort = cfg.port || 8400;
2614
+ await discordBridge.start(projectId, dc.bot_token, dc.channel_id, qwPort);
2615
+ emitSystemMessage(projectId, "Discord bridge connected");
2616
+ return res.json({ ok: true, running: true });
3812
2617
  } catch (err) {
3813
- try { fs.closeSync(outFd); } catch {}
3814
- try { fs.closeSync(errFd); } catch {}
3815
2618
  return res.json({ ok: false, error: err.message || "Start failed" });
3816
2619
  }
3817
- try { fs.closeSync(outFd); } catch {}
3818
- try { fs.closeSync(errFd); } catch {}
3819
- await new Promise((r) => setTimeout(r, 500));
3820
- let alive = true;
3821
- try { process.kill(child.pid, 0); } catch { alive = false; }
3822
- if (!alive) {
3823
- const tail = readLastLines(logPath, 20);
3824
- try { fs.unlinkSync(discordPidFile(projectId)); } catch {}
3825
- return res.json({
3826
- ok: false,
3827
- error:
3828
- "Bridge crashed on start (exited within 500ms).\n\n" +
3829
- `Last log lines (${logPath}):\n${tail || "(log empty)"}`,
3830
- });
3831
- }
3832
- return res.json({ ok: true, running: true, pid: child.pid });
3833
2620
  }
3834
2621
  case "stop": {
3835
2622
  const projectId = body.project_id;
3836
2623
  if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
3837
2624
  try {
3838
- 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 {}
2625
+ discordBridge.stop(projectId);
2626
+ emitSystemMessage(projectId, "Discord bridge disconnected");
3859
2627
  return res.json({ ok: true, running: false });
3860
2628
  } catch (err) {
3861
2629
  return res.json({ ok: false, error: err.message || "Stop failed" });
3862
2630
  }
3863
2631
  }
3864
2632
  case "status":
3865
- return res.json({ running: isDiscordRunning(body.project_id || "") });
2633
+ return res.json({ running: discordBridge.isRunning(body.project_id || "") });
3866
2634
  case "save-config": {
3867
2635
  const projectId = body.project_id;
3868
2636
  const bot_token = typeof body.bot_token === "string" ? body.bot_token.trim() : "";
@@ -3976,25 +2744,9 @@ module.exports.parseActiveBatch = parseActiveBatch;
3976
2744
  // summarizeItems for the batch-progress fixture test.
3977
2745
  module.exports.buildNoPrRow = buildNoPrRow;
3978
2746
  module.exports.summarizeItems = summarizeItems;
3979
- // #353: expose readLastLines for the tg-bridge test.
3980
- module.exports.readLastLines = readLastLines;
3981
- // #380: expose checkTelegramBridgePythonDeps so the bridge test can
3982
- // exercise the venv-path interpreter argument round trip.
3983
- module.exports.checkTelegramBridgePythonDeps = checkTelegramBridgePythonDeps;
3984
- // #383: pure helpers exposed for unit tests in
3985
- // routes.telegramBridge.test.js. No production callers outside
3986
- // this file.
3987
- module.exports.resolveProjectAgentchattrUrl = resolveProjectAgentchattrUrl;
3988
- module.exports.buildTelegramBridgeToml = buildTelegramBridgeToml;
3989
- module.exports.patchAgentchattrConfigForTelegramBridge = patchAgentchattrConfigForTelegramBridge;
3990
- module.exports.buildTelegramBridgeSpawnEnv = buildTelegramBridgeSpawnEnv;
3991
- module.exports.checkDiscordBridgePythonDeps = checkDiscordBridgePythonDeps;
3992
- module.exports.buildDiscordBridgeToml = buildDiscordBridgeToml;
3993
- module.exports.patchAgentchattrConfigForDiscordBridge = patchAgentchattrConfigForDiscordBridge;
3994
- module.exports.buildDiscordBridgeSpawnEnv = buildDiscordBridgeSpawnEnv;
3995
- module.exports.projectAgentchattrConfigPath = projectAgentchattrConfigPath;
3996
- // #236: expose sendViaWebSocket so the chat-ws-send regression test
3997
- // can verify the ack/body/error paths against a fake AC ws server.
3998
- module.exports.sendViaWebSocket = sendViaWebSocket;
3999
2747
  // #693: expose normalizeMentions for unit tests
4000
2748
  module.exports.normalizeMentions = normalizeMentions;
2749
+ // #714: expose for file-chat integration
2750
+ module.exports.getProjectChatMode = getProjectChatMode;
2751
+ // #730: PTY dispatch callback setter
2752
+ module.exports.setPtyDispatchCallback = setPtyDispatchCallback;