quadwork 1.8.5 → 1.10.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 (76) hide show
  1. package/README.md +4 -0
  2. package/bin/quadwork.js +8 -0
  3. package/bridges/discord/discord_bridge.py +104 -7
  4. package/out/404.html +1 -1
  5. package/out/__next.__PAGE__.txt +3 -3
  6. package/out/__next._full.txt +13 -13
  7. package/out/__next._head.txt +4 -4
  8. package/out/__next._index.txt +7 -7
  9. package/out/__next._tree.txt +2 -2
  10. package/out/_next/static/chunks/{04jznxp9-kut_.js → 04ui63kyoqv4t.js} +1 -1
  11. package/out/_next/static/chunks/{0q71bcdksran1.js → 0aldkx8l9xukk.js} +1 -1
  12. package/out/_next/static/chunks/{0m439m2ljf2gz.js → 0uf3o~m9.vrpj.js} +14 -14
  13. package/out/_next/static/chunks/0yt_bs94icoma.js +1 -0
  14. package/out/_next/static/chunks/11r-w4ngz479i.css +2 -0
  15. package/out/_not-found/__next._full.txt +12 -12
  16. package/out/_not-found/__next._head.txt +4 -4
  17. package/out/_not-found/__next._index.txt +7 -7
  18. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  19. package/out/_not-found/__next._not-found.txt +3 -3
  20. package/out/_not-found/__next._tree.txt +2 -2
  21. package/out/_not-found.html +1 -1
  22. package/out/_not-found.txt +12 -12
  23. package/out/app-shell/__next._full.txt +12 -12
  24. package/out/app-shell/__next._head.txt +4 -4
  25. package/out/app-shell/__next._index.txt +7 -7
  26. package/out/app-shell/__next._tree.txt +2 -2
  27. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  28. package/out/app-shell/__next.app-shell.txt +3 -3
  29. package/out/app-shell.html +1 -1
  30. package/out/app-shell.txt +12 -12
  31. package/out/index.html +1 -1
  32. package/out/index.txt +13 -13
  33. package/out/project/_/__next._full.txt +13 -13
  34. package/out/project/_/__next._head.txt +4 -4
  35. package/out/project/_/__next._index.txt +7 -7
  36. package/out/project/_/__next._tree.txt +2 -2
  37. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  38. package/out/project/_/__next.project.$d$id.txt +3 -3
  39. package/out/project/_/__next.project.txt +3 -3
  40. package/out/project/_/queue/__next._full.txt +13 -13
  41. package/out/project/_/queue/__next._head.txt +4 -4
  42. package/out/project/_/queue/__next._index.txt +7 -7
  43. package/out/project/_/queue/__next._tree.txt +2 -2
  44. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  45. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  46. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  47. package/out/project/_/queue/__next.project.txt +3 -3
  48. package/out/project/_/queue.html +1 -1
  49. package/out/project/_/queue.txt +13 -13
  50. package/out/project/_.html +1 -1
  51. package/out/project/_.txt +13 -13
  52. package/out/settings/__next._full.txt +13 -13
  53. package/out/settings/__next._head.txt +4 -4
  54. package/out/settings/__next._index.txt +7 -7
  55. package/out/settings/__next._tree.txt +2 -2
  56. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  57. package/out/settings/__next.settings.txt +3 -3
  58. package/out/settings.html +1 -1
  59. package/out/settings.txt +13 -13
  60. package/out/setup/__next._full.txt +13 -13
  61. package/out/setup/__next._head.txt +4 -4
  62. package/out/setup/__next._index.txt +7 -7
  63. package/out/setup/__next._tree.txt +2 -2
  64. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  65. package/out/setup/__next.setup.txt +3 -3
  66. package/out/setup.html +1 -1
  67. package/out/setup.txt +13 -13
  68. package/package.json +1 -1
  69. package/server/agentchattr-registry.js +1 -1
  70. package/server/index.js +232 -6
  71. package/server/routes.js +69 -48
  72. package/out/_next/static/chunks/0bigpiw-1zroq.css +0 -2
  73. package/out/_next/static/chunks/152f2hu-ivy6f.js +0 -1
  74. /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_buildManifest.js +0 -0
  75. /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_clientMiddlewareManifest.js +0 -0
  76. /package/out/_next/static/{_-PNrmPXTJt_5nZEhsDM3 → mxlP6esPG86fhzv01dzCW}/_ssgManifest.js +0 -0
package/out/setup.txt CHANGED
@@ -1,21 +1,21 @@
1
1
  1:"$Sreact.fragment"
2
- 2:I[43688,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
3
- 3:I[26704,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
4
- 4:I[22140,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
5
- 5:I[39756,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
6
- 6:I[37457,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
7
- 7:I[94810,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/09h0i4gh79na..js"],"default"]
8
- 8:I[97367,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"OutletBoundary"]
2
+ 2:I[43688,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
3
+ 3:I[26704,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
4
+ 4:I[22140,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
5
+ 5:I[39756,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
6
+ 6:I[37457,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default"]
7
+ 7:I[94810,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/09h0i4gh79na..js"],"default"]
8
+ 8:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"OutletBoundary"]
9
9
  9:"$Sreact.suspense"
10
- c:I[97367,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"ViewportBoundary"]
11
- e:I[97367,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"MetadataBoundary"]
12
- 10:I[68027,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default",1]
13
- :HL["/_next/static/chunks/0bigpiw-1zroq.css","style"]
10
+ c:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"ViewportBoundary"]
11
+ e:I[97367,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"MetadataBoundary"]
12
+ 10:I[68027,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"default",1]
13
+ :HL["/_next/static/chunks/11r-w4ngz479i.css","style"]
14
14
  :HL["/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
15
- 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0bigpiw-1zroq.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/152f2hu-ivy6f.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","$L3",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L4",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L7",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/09h0i4gh79na..js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,"$@b"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$10",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0bigpiw-1zroq.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"_-PNrmPXTJt_5nZEhsDM3"}
15
+ 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/11r-w4ngz479i.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0yt_bs94icoma.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","$L3",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L4",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L7",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/09h0i4gh79na..js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,null]},null,false,"$@b"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$10",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/11r-w4ngz479i.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"mxlP6esPG86fhzv01dzCW"}
16
16
  11:[]
17
17
  b:"$W11"
18
18
  d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
19
- 12:I[27201,["/_next/static/chunks/152f2hu-ivy6f.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"IconMark"]
19
+ 12:I[27201,["/_next/static/chunks/0yt_bs94icoma.js","/_next/static/chunks/0d3shmwh5_nmn.js"],"IconMark"]
20
20
  a:null
21
21
  f:[["$","title","0",{"children":"QuadWork"}],["$","meta","1",{"name":"description","content":"Unified dashboard for multi-agent coding teams"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.05o2q2p4kvnq_.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L12","3",{}]]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quadwork",
3
- "version": "1.8.5",
3
+ "version": "1.10.0",
4
4
  "description": "Unified dashboard for multi-agent coding teams — 4 AI agents, one terminal",
5
5
  "bin": {
6
6
  "quadwork": "./bin/quadwork.js"
@@ -101,7 +101,7 @@ async function deregisterAgent(serverPort, name, token) {
101
101
  /**
102
102
  * Start a per-agent heartbeat that POSTs /api/heartbeat/{name} every 5s
103
103
  * with bearer auth. AgentChattr considers an agent crashed and removes
104
- * it after ~60s without a heartbeat, so without this every registered
104
+ * it after ~120s without a heartbeat, so without this every registered
105
105
  * QuadWork agent silently disappears from the channel one minute after
106
106
  * registration.
107
107
  *
package/server/index.js CHANGED
@@ -1298,9 +1298,121 @@ Dev: Work on assigned ticket or address review feedback.
1298
1298
  RE1/RE2: Review open PRs. If Dev pushed fixes, re-review. Post verdict on PR AND notify here.
1299
1299
  ALL: Communicate via this chat by tagging agents. Your terminal is NOT visible.`;
1300
1300
 
1301
+ // #518: server-side bridge lifecycle helpers. Stop and start Telegram +
1302
+ // Discord bridges so they respond to batch transitions even when the
1303
+ // operator is on a different project page.
1304
+
1305
+ async function autoStopBridges(projectId, project, qwPort) {
1306
+ if (project?.telegram_auto) {
1307
+ try {
1308
+ await fetch(`http://127.0.0.1:${qwPort}/api/telegram?action=stop`, {
1309
+ method: "POST",
1310
+ headers: { "Content-Type": "application/json" },
1311
+ body: JSON.stringify({ project_id: projectId }),
1312
+ signal: AbortSignal.timeout(5000),
1313
+ });
1314
+ console.log(`[auto-bridge] ${projectId}: telegram bridge auto-stopped`);
1315
+ } catch { /* non-fatal */ }
1316
+ }
1317
+ if (project?.discord_auto) {
1318
+ try {
1319
+ await fetch(`http://127.0.0.1:${qwPort}/api/discord?action=stop`, {
1320
+ method: "POST",
1321
+ headers: { "Content-Type": "application/json" },
1322
+ body: JSON.stringify({ project_id: projectId }),
1323
+ signal: AbortSignal.timeout(5000),
1324
+ });
1325
+ console.log(`[auto-bridge] ${projectId}: discord bridge auto-stopped`);
1326
+ } catch { /* non-fatal */ }
1327
+ }
1328
+ }
1329
+
1330
+ async function autoStartBridges(projectId, project, qwPort) {
1331
+ if (project?.telegram_auto) {
1332
+ try {
1333
+ // Check if already running before starting
1334
+ const st = await fetch(
1335
+ `http://127.0.0.1:${qwPort}/api/telegram?project=${encodeURIComponent(projectId)}`,
1336
+ { signal: AbortSignal.timeout(5000) }
1337
+ );
1338
+ if (st.ok) {
1339
+ const data = await st.json();
1340
+ if (data.running) return; // already running
1341
+ if (!data.configured) return; // not configured — can't start
1342
+ }
1343
+ await fetch(`http://127.0.0.1:${qwPort}/api/telegram?action=start`, {
1344
+ method: "POST",
1345
+ headers: { "Content-Type": "application/json" },
1346
+ body: JSON.stringify({ project_id: projectId }),
1347
+ signal: AbortSignal.timeout(10000),
1348
+ });
1349
+ console.log(`[auto-bridge] ${projectId}: telegram bridge auto-started`);
1350
+ } catch { /* non-fatal */ }
1351
+ }
1352
+ if (project?.discord_auto) {
1353
+ try {
1354
+ const st = await fetch(
1355
+ `http://127.0.0.1:${qwPort}/api/discord?project=${encodeURIComponent(projectId)}`,
1356
+ { signal: AbortSignal.timeout(5000) }
1357
+ );
1358
+ if (st.ok) {
1359
+ const data = await st.json();
1360
+ if (data.running) return;
1361
+ if (!data.configured) return;
1362
+ }
1363
+ await fetch(`http://127.0.0.1:${qwPort}/api/discord?action=start`, {
1364
+ method: "POST",
1365
+ headers: { "Content-Type": "application/json" },
1366
+ body: JSON.stringify({ project_id: projectId }),
1367
+ signal: AbortSignal.timeout(10000),
1368
+ });
1369
+ console.log(`[auto-bridge] ${projectId}: discord bridge auto-started`);
1370
+ } catch { /* non-fatal */ }
1371
+ }
1372
+ }
1373
+
1374
+ // Track previous batch state per project for bridge auto-start detection
1375
+ const _bridgeBatchPrev = new Map();
1376
+
1301
1377
  async function sendTriggerMessage(projectId) {
1302
1378
  const cfg = readConfig();
1303
1379
  const project = cfg.projects && cfg.projects.find((p) => p.id === projectId);
1380
+
1381
+ // #516: server-side auto-stop — check batch progress before sending.
1382
+ // When trigger_auto is enabled, skip the message and stop the trigger
1383
+ // (plus caffeinate) if the batch is already complete. This covers the
1384
+ // case where the operator is on a different page and the client-side
1385
+ // ScheduledTriggerWidget is not mounted to detect completion.
1386
+ if (project && project.trigger_auto) {
1387
+ const qwPort = cfg.port || 8400;
1388
+ try {
1389
+ const bpRes = await fetch(
1390
+ `http://127.0.0.1:${qwPort}/api/batch-progress?project=${encodeURIComponent(projectId)}`
1391
+ );
1392
+ if (bpRes.ok) {
1393
+ const bp = await bpRes.json();
1394
+ if (bp && bp.complete) {
1395
+ console.log(`[auto-trigger] ${projectId}: batch complete, auto-stopped`);
1396
+ stopTrigger(projectId);
1397
+ // Also stop caffeinate if no other triggers remain running
1398
+ // (#441 companion fix). caffeinateProcess is global (not
1399
+ // project-scoped), so only kill it when all work is done.
1400
+ if (caffeinateProcess.process && triggers.size === 0) {
1401
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
1402
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
1403
+ console.log(`[auto-trigger] ${projectId}: caffeinate auto-stopped (no active triggers remain)`);
1404
+ }
1405
+ // #518: also stop bridges when batch completes
1406
+ await autoStopBridges(projectId, project, qwPort);
1407
+ return;
1408
+ }
1409
+ }
1410
+ } catch (err) {
1411
+ // Non-fatal — if batch-progress fails, proceed with the message
1412
+ console.error(`[auto-trigger] ${projectId}: batch-progress check failed:`, err.message);
1413
+ }
1414
+ }
1415
+
1304
1416
  const message = (project && project.trigger_message) || DEFAULT_MESSAGE;
1305
1417
 
1306
1418
  // #401 / quadwork#277: route trigger sends through the local
@@ -1721,6 +1833,67 @@ function syncTriggersFromConfig() {
1721
1833
  }
1722
1834
  }
1723
1835
 
1836
+ // #516: server-side batch-completion poller. Checks every 30s whether
1837
+ // any trigger_auto project's batch is complete, and auto-stops the
1838
+ // trigger (plus caffeinate when no triggers remain). This runs
1839
+ // independently of the trigger tick interval, so completion is
1840
+ // detected within 30s even if the operator is on a different page.
1841
+ // #518: also handles telegram_auto / discord_auto bridge lifecycle
1842
+ // (both start and stop) so bridges respond to batch transitions
1843
+ // even when the operator is viewing a different project page.
1844
+
1845
+ const AUTO_STOP_POLL_INTERVAL_MS = 30_000;
1846
+
1847
+ async function autoStopPollingTick() {
1848
+ const cfg = readConfig();
1849
+ if (!cfg.projects) return;
1850
+
1851
+ for (const project of cfg.projects) {
1852
+ const hasTriggerAuto = project.trigger_auto && triggers.has(project.id);
1853
+ const hasBridgeAuto = project.telegram_auto || project.discord_auto;
1854
+ if (!hasTriggerAuto && !hasBridgeAuto) continue;
1855
+ const qwPort = cfg.port || 8400;
1856
+ try {
1857
+ const res = await fetch(
1858
+ `http://127.0.0.1:${qwPort}/api/batch-progress?project=${encodeURIComponent(project.id)}`
1859
+ );
1860
+ if (!res.ok) continue;
1861
+ const bp = await res.json();
1862
+ const hasItems = bp.items && bp.items.length > 0;
1863
+ const prev = _bridgeBatchPrev.get(project.id);
1864
+ _bridgeBatchPrev.set(project.id, { complete: bp.complete, hasItems });
1865
+
1866
+ if (bp && bp.complete) {
1867
+ if (hasTriggerAuto) {
1868
+ console.log(`[auto-trigger] ${project.id}: batch complete, auto-stopped (poller)`);
1869
+ stopTrigger(project.id);
1870
+ if (caffeinateProcess.process && triggers.size === 0) {
1871
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
1872
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
1873
+ console.log(`[auto-trigger] ${project.id}: caffeinate auto-stopped (no active triggers remain)`);
1874
+ }
1875
+ }
1876
+ // #518: also stop bridges when batch completes
1877
+ if (hasBridgeAuto) {
1878
+ await autoStopBridges(project.id, project, qwPort);
1879
+ }
1880
+ }
1881
+
1882
+ // #518: detect batch-start transition → auto-start bridges
1883
+ if (hasBridgeAuto && hasItems && !bp.complete) {
1884
+ const isNewBatch = !prev || prev.complete || !prev.hasItems;
1885
+ if (isNewBatch) {
1886
+ await autoStartBridges(project.id, project, qwPort);
1887
+ }
1888
+ }
1889
+ } catch {
1890
+ // Non-fatal — retry on next tick
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ setInterval(autoStopPollingTick, AUTO_STOP_POLL_INTERVAL_MS);
1896
+
1724
1897
  // #422 / quadwork#310: auto-continue after loop guard.
1725
1898
  //
1726
1899
  // Per opted-in project, poll AC's /api/status every 10s. When we see
@@ -1983,6 +2156,20 @@ server.listen(PORT, "127.0.0.1", () => {
1983
2156
  }
1984
2157
  } catch {}
1985
2158
  }
2159
+ // #506: refresh Discord bridge script from the npm package on startup.
2160
+ // The Telegram bridge uses git-fetch + pin, but Discord uses a file-copy
2161
+ // pattern. Without this, upgrading QuadWork leaves a stale on-disk script
2162
+ // missing fixes shipped in newer versions.
2163
+ const DISCORD_BRIDGE_SRC = path.join(__dirname, "..", "bridges", "discord", "discord_bridge.py");
2164
+ const DISCORD_BRIDGE_DEST = path.join(os.homedir(), ".quadwork", "agentchattr-discord", "discord_bridge.py");
2165
+ if (fs.existsSync(DISCORD_BRIDGE_SRC) && fs.existsSync(path.dirname(DISCORD_BRIDGE_DEST))) {
2166
+ try {
2167
+ fs.copyFileSync(DISCORD_BRIDGE_SRC, DISCORD_BRIDGE_DEST);
2168
+ console.log("[bridge-refresh] refreshed Discord bridge script from package");
2169
+ } catch (err) {
2170
+ console.warn(`[bridge-refresh] failed to refresh Discord bridge script: ${err.message || err}`);
2171
+ }
2172
+ }
1986
2173
  // #470: patch stale bridge_sender defaults in on-disk bridge scripts.
1987
2174
  // The AC config migration (#457) renames the agent sections, but the
1988
2175
  // bridge scripts themselves may still have old defaults if the operator
@@ -2039,10 +2226,10 @@ server.listen(PORT, "127.0.0.1", () => {
2039
2226
  }
2040
2227
  }
2041
2228
  }
2042
- // #478: patch deployed AgentChattr instances to support force-replace
2043
- // on register. Prevents ghost slot accumulation after restarts.
2229
+ // #478 + #502: patch deployed AgentChattr instances to support force-replace
2230
+ // on register and fix idle-agent crash timeout.
2044
2231
  for (const p of (startupCfg.projects || [])) {
2045
- const acDir = p.agentchattr_dir || path.join(os.homedir(), ".quadwork", p.id, "agentchattr");
2232
+ const acDir = resolveProjectChattr(p.id).dir;
2046
2233
  // Patch registry.py: add force parameter to register()
2047
2234
  const regPath = path.join(acDir, "registry.py");
2048
2235
  if (fs.existsSync(regPath)) {
@@ -2058,17 +2245,35 @@ server.listen(PORT, "127.0.0.1", () => {
2058
2245
  reg = reg.replace(
2059
2246
  " self._expire_reserved()\n\n # Find next free slot",
2060
2247
  " self._expire_reserved()\n\n" +
2061
- " # quadwork#478: force-replace — expire all existing slots for this\n" +
2062
- " # base so the new registration always lands at slot 1.\n" +
2248
+ " # quadwork#478 + #502: force-replace — expire all existing slots\n" +
2249
+ " # for this base so the new registration always lands at slot 1.\n" +
2250
+ " # Also clear _reserved entries: after a crash-timeout the old name\n" +
2251
+ " # lives only in _reserved, so without this the grace period still\n" +
2252
+ " # blocks slot 1 and the agent gets a -2 suffix.\n" +
2063
2253
  " if force:\n" +
2064
2254
  " ghosts = [n for n, i in self._instances.items() if i.base == base]\n" +
2065
2255
  " for name in ghosts:\n" +
2066
2256
  " del self._instances[name]\n" +
2067
- " self._reserved[name] = time.time()\n\n" +
2257
+ " stale_reserved = [rn for rn in self._reserved\n" +
2258
+ " if self._parse_name(rn)[0] == base]\n" +
2259
+ " for rn in stale_reserved:\n" +
2260
+ " del self._reserved[rn]\n\n" +
2068
2261
  " # Find next free slot",
2069
2262
  );
2070
2263
  fs.writeFileSync(regPath, reg);
2071
2264
  console.log(`[ghost-fix] ${p.id}: patched registry.py with force-replace support`);
2265
+ } else if (!reg.includes("stale_reserved")) {
2266
+ // #502: upgrade existing force-replace patch to also clear _reserved
2267
+ reg = reg.replace(
2268
+ /( +)for name in ghosts:\n\1 del self\._instances\[name\]\n\1 self\._reserved\[name\] = time\.time\(\)/,
2269
+ "$1for name in ghosts:\n$1 del self._instances[name]\n" +
2270
+ "$1stale_reserved = [rn for rn in self._reserved\n" +
2271
+ "$1 if self._parse_name(rn)[0] == base]\n" +
2272
+ "$1for rn in stale_reserved:\n" +
2273
+ "$1 del self._reserved[rn]",
2274
+ );
2275
+ fs.writeFileSync(regPath, reg);
2276
+ console.log(`[ghost-fix] ${p.id}: upgraded registry.py force-replace to clear _reserved (#502)`);
2072
2277
  }
2073
2278
  } catch (err) {
2074
2279
  console.warn(`[ghost-fix] ${p.id}: failed to patch registry.py: ${err.message}`);
@@ -2091,6 +2296,27 @@ server.listen(PORT, "127.0.0.1", () => {
2091
2296
  console.warn(`[ghost-fix] ${p.id}: failed to patch app.py: ${err.message}`);
2092
2297
  }
2093
2298
  }
2299
+ // #502: increase crash timeout from 15s to 120s for idle agent tolerance
2300
+ if (fs.existsSync(appPath)) {
2301
+ try {
2302
+ let app = fs.readFileSync(appPath, "utf-8");
2303
+ if (app.includes("_CRASH_TIMEOUT = 15")) {
2304
+ app = app.replace(
2305
+ "_CRASH_TIMEOUT = 15",
2306
+ "_CRASH_TIMEOUT = 120",
2307
+ );
2308
+ // Fix the misleading comment too
2309
+ app = app.replace(
2310
+ "# Crash timeout: if a wrapper hasn't heartbeated for 60s,\n",
2311
+ "# Crash timeout: if a wrapper hasn't heartbeated for 120s,\n",
2312
+ );
2313
+ fs.writeFileSync(appPath, app);
2314
+ console.log(`[idle-fix] ${p.id}: increased crash timeout to 120s (#502)`);
2315
+ }
2316
+ } catch (err) {
2317
+ console.warn(`[idle-fix] ${p.id}: failed to patch app.py crash timeout: ${err.message}`);
2318
+ }
2319
+ }
2094
2320
  }
2095
2321
  // #416: start the AC health monitor
2096
2322
  startAcHealthMonitor();
package/server/routes.js CHANGED
@@ -1069,17 +1069,17 @@ router.get("/api/uploads/:project/:filename", (req, res) => {
1069
1069
 
1070
1070
  // ─── Projects (dashboard aggregation) ──────────────────────────────────────
1071
1071
 
1072
- function ghJson(args) {
1073
- try {
1074
- const out = execFileSync("gh", args, { encoding: "utf-8", timeout: 15000 });
1075
- const parsed = JSON.parse(out);
1076
- return Array.isArray(parsed) ? parsed : [];
1077
- } catch {
1078
- return [];
1079
- }
1080
- }
1072
+ // #512: cache /api/projects results for 60s to eliminate repeated
1073
+ // slow gh CLI calls on every dashboard poll.
1074
+ let _projectsCache = null;
1075
+ let _projectsCacheTs = 0;
1076
+ const PROJECTS_CACHE_TTL = 60_000;
1081
1077
 
1082
1078
  router.get("/api/projects", async (req, res) => {
1079
+ if (_projectsCache && Date.now() - _projectsCacheTs < PROJECTS_CACHE_TTL) {
1080
+ return res.json(_projectsCache);
1081
+ }
1082
+
1083
1083
  const cfg = readConfigFile();
1084
1084
 
1085
1085
  // Fetch active sessions from our own in-memory state (only running PTYs)
@@ -1113,24 +1113,34 @@ router.get("/api/projects", async (req, res) => {
1113
1113
  .slice(-10)
1114
1114
  .reverse();
1115
1115
 
1116
- const numberToProject = {};
1117
- const projectResults = (cfg.projects || []).map((p) => {
1116
+ // #512: build project-id-to-name map from config and a reverse
1117
+ // lookup from chat message to project name via chatMsgsByProject
1118
+ // (which already knows which AC instance each message came from).
1119
+ // This replaces the expensive allPrs/allIssues gh CLI calls that
1120
+ // were only used for the numberToProject mapping.
1121
+ const projectIdToName = {};
1122
+ for (const p of cfg.projects || []) projectIdToName[p.id] = p.name;
1123
+ const msgToProject = new Map();
1124
+ for (const [pid, msgs] of Object.entries(chatMsgsByProject)) {
1125
+ for (const m of msgs) msgToProject.set(m, projectIdToName[pid]);
1126
+ }
1127
+
1128
+ // #512: parallelize gh CLI calls across projects using async exec.
1129
+ // Only fetch open PR count and most recent PR activity — drop the
1130
+ // allPrs/allIssues calls that were only used for numberToProject.
1131
+ async function fetchProjectGhData(p) {
1118
1132
  let openPrs = 0;
1119
1133
  let lastActivity = null;
1120
-
1121
1134
  if (REPO_RE.test(p.repo)) {
1122
- const prs = ghJson(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]);
1123
- openPrs = prs.length;
1124
-
1125
- const recentPrs = ghJson(["pr", "list", "-R", p.repo, "--state", "all", "--json", "updatedAt", "--limit", "1"]);
1126
- lastActivity = recentPrs[0]?.updatedAt || null;
1127
-
1128
- const allPrs = ghJson(["pr", "list", "-R", p.repo, "--state", "all", "--json", "number", "--limit", "100"]);
1129
- for (const pr of allPrs) numberToProject[pr.number] = p.name;
1130
- const allIssues = ghJson(["issue", "list", "-R", p.repo, "--state", "all", "--json", "number", "--limit", "100"]);
1131
- for (const issue of allIssues) numberToProject[issue.number] = p.name;
1135
+ try {
1136
+ const [prs, recentPrs] = await Promise.allSettled([
1137
+ ghJsonExecAsync(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]),
1138
+ ghJsonExecAsync(["pr", "list", "-R", p.repo, "--state", "all", "--json", "updatedAt", "--limit", "1"]),
1139
+ ]);
1140
+ if (prs.status === "fulfilled") openPrs = prs.value.length;
1141
+ if (recentPrs.status === "fulfilled") lastActivity = recentPrs.value[0]?.updatedAt || null;
1142
+ } catch {}
1132
1143
  }
1133
-
1134
1144
  const hasAgents = p.agents && Object.keys(p.agents).length > 0;
1135
1145
  return {
1136
1146
  id: p.id,
@@ -1141,20 +1151,21 @@ router.get("/api/projects", async (req, res) => {
1141
1151
  state: hasAgents && activeProjectIds.has(p.id) ? "active" : "idle",
1142
1152
  lastActivity,
1143
1153
  };
1144
- });
1154
+ }
1155
+
1156
+ const projectResults = await Promise.all(
1157
+ (cfg.projects || []).map((p) => fetchProjectGhData(p))
1158
+ );
1145
1159
 
1146
- // Build activity feed
1160
+ // Build activity feed — use chat-based project association instead
1161
+ // of the dropped numberToProject gh lookup.
1147
1162
  const recentEvents = [];
1148
1163
  for (const m of workflowMsgs) {
1164
+ // First: try text match against repo/project name
1149
1165
  let projectName = (cfg.projects || []).find((p) => m.text.includes(p.repo) || m.text.includes(p.name))?.name;
1150
- if (!projectName) {
1151
- const numMatch = m.text.match(/#(\d+)/);
1152
- if (numMatch) projectName = numberToProject[parseInt(numMatch[1], 10)];
1153
- }
1154
- if (!projectName) {
1155
- const branchMatch = m.text.match(/task\/(\d+)/);
1156
- if (branchMatch) projectName = numberToProject[parseInt(branchMatch[1], 10)];
1157
- }
1166
+ // Second: use the AC instance the message came from
1167
+ if (!projectName) projectName = msgToProject.get(m);
1168
+ // Fallback: single-project installs
1158
1169
  if (!projectName && cfg.projects && cfg.projects.length === 1) {
1159
1170
  projectName = cfg.projects[0].name;
1160
1171
  }
@@ -1169,7 +1180,10 @@ router.get("/api/projects", async (req, res) => {
1169
1180
  if (recentEvents.length >= 10) break;
1170
1181
  }
1171
1182
 
1172
- res.json({ projects: projectResults, recentEvents });
1183
+ const result = { projects: projectResults, recentEvents };
1184
+ _projectsCache = result;
1185
+ _projectsCacheTs = Date.now();
1186
+ res.json(result);
1173
1187
  });
1174
1188
 
1175
1189
  // ─── GitHub Issues / PRs ───────────────────────────────────────────────────
@@ -1881,6 +1895,14 @@ router.post("/api/setup", (req, res) => {
1881
1895
  const clone = exec("gh", ["repo", "clone", body.repo, workingDir]);
1882
1896
  if (!clone.ok) return res.json({ ok: false, error: `Clone failed: ${clone.output}` });
1883
1897
  }
1898
+ // Empty repos have no commits — git worktree add requires at least one.
1899
+ const headCheck = exec("git", ["rev-parse", "HEAD"], { cwd: workingDir });
1900
+ if (!headCheck.ok) {
1901
+ exec("git", ["commit", "--allow-empty", "-m", "Initial commit (created by QuadWork setup)"], { cwd: workingDir });
1902
+ const branchResult = exec("git", ["symbolic-ref", "--short", "HEAD"], { cwd: workingDir });
1903
+ const defaultBranch = branchResult.ok ? branchResult.output : "main";
1904
+ exec("git", ["push", "origin", defaultBranch], { cwd: workingDir });
1905
+ }
1884
1906
  // Sibling dirs: ../projectName-head/, ../projectName-re1/, etc. (matches CLI wizard)
1885
1907
  const projectName = path.basename(workingDir);
1886
1908
  const parentDir = path.dirname(workingDir);
@@ -2293,7 +2315,7 @@ router.post("/api/rename", (req, res) => {
2293
2315
  const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
2294
2316
  // #444: pin agentchattr-telegram to a known commit (same pattern as
2295
2317
  // AGENTCHATTR_PIN in bin/quadwork.js for bcurts/agentchattr).
2296
- const AGENTCHATTR_TELEGRAM_PIN = "4a6b45f1794c612328b9d5ee6d6fcb3f77015abc";
2318
+ const AGENTCHATTR_TELEGRAM_PIN = "03753c5e4f4497fb7a4a4da639faf31a61d9a4ac";
2297
2319
 
2298
2320
  function telegramPidFile(projectId) {
2299
2321
  return path.join(CONFIG_DIR, `tg-bridge-${projectId}.pid`);
@@ -3058,20 +3080,19 @@ router.post("/api/discord", async (req, res) => {
3058
3080
  const venvPip = path.join(venvDir, "bin", "pip");
3059
3081
  let pipOutput = "";
3060
3082
  try {
3061
- // Copy from bundled package dir (not clone #397 decision)
3062
- if (!fs.existsSync(path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"))) {
3063
- fs.cpSync(DISCORD_BRIDGE_SRC, DISCORD_BRIDGE_DIR, { recursive: true });
3064
- } else {
3065
- // On upgrade: overwrite script, keep venv
3066
- fs.cpSync(
3067
- path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
3068
- path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"),
3069
- );
3070
- fs.cpSync(
3071
- path.join(DISCORD_BRIDGE_SRC, "requirements.txt"),
3072
- path.join(DISCORD_BRIDGE_DIR, "requirements.txt"),
3073
- );
3083
+ // #506: always copy bundled bridge files (not just on first install)
3084
+ // so re-installing after a QuadWork upgrade refreshes the script.
3085
+ if (!fs.existsSync(DISCORD_BRIDGE_DIR)) {
3086
+ fs.mkdirSync(DISCORD_BRIDGE_DIR, { recursive: true });
3074
3087
  }
3088
+ fs.cpSync(
3089
+ path.join(DISCORD_BRIDGE_SRC, "discord_bridge.py"),
3090
+ path.join(DISCORD_BRIDGE_DIR, "discord_bridge.py"),
3091
+ );
3092
+ fs.cpSync(
3093
+ path.join(DISCORD_BRIDGE_SRC, "requirements.txt"),
3094
+ path.join(DISCORD_BRIDGE_DIR, "requirements.txt"),
3095
+ );
3075
3096
  if (!fs.existsSync(venvPython)) {
3076
3097
  execFileSync("python3", ["-m", "venv", venvDir], { encoding: "utf-8", timeout: 60000 });
3077
3098
  }