quadwork 0.1.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/bin/quadwork.js +686 -0
  4. package/out/404.html +1 -0
  5. package/out/__next.__PAGE__.txt +6 -0
  6. package/out/__next._full.txt +17 -0
  7. package/out/__next._head.txt +6 -0
  8. package/out/__next._index.txt +6 -0
  9. package/out/__next._tree.txt +3 -0
  10. package/out/_next/static/chunks/0.57eg262w~qg.js +1 -0
  11. package/out/_next/static/chunks/0.dzh0qf9zq1l.js +2 -0
  12. package/out/_next/static/chunks/03hi.hdp6l230.js +20 -0
  13. package/out/_next/static/chunks/03v5eoc-wic6o.js +1 -0
  14. package/out/_next/static/chunks/03yov._jigv17.js +1 -0
  15. package/out/_next/static/chunks/03~yq9q893hmn.js +1 -0
  16. package/out/_next/static/chunks/08fgie1bcjynm.js +1 -0
  17. package/out/_next/static/chunks/0excsn2a_5qsb.js +4 -0
  18. package/out/_next/static/chunks/0iqqouh_3i5y5.js +13 -0
  19. package/out/_next/static/chunks/0jsosmtclw5n5.js +4 -0
  20. package/out/_next/static/chunks/0ox7p_szjhn69.js +1 -0
  21. package/out/_next/static/chunks/0r7t_sj_sejq9.js +1 -0
  22. package/out/_next/static/chunks/13uu.sohs74zg.js +31 -0
  23. package/out/_next/static/chunks/15kwal..m9r49.css +2 -0
  24. package/out/_next/static/chunks/17oc2l.ekcs8b.css +1 -0
  25. package/out/_next/static/chunks/17sk4qv6_d0co.js +1 -0
  26. package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +1 -0
  27. package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_buildManifest.js +15 -0
  28. package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_clientMiddlewareManifest.js +1 -0
  29. package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_ssgManifest.js +1 -0
  30. package/out/_next/static/media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2 +0 -0
  31. package/out/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2 +0 -0
  32. package/out/_next/static/media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2 +0 -0
  33. package/out/_next/static/media/favicon.0x3dzn~oxb6tn.ico +0 -0
  34. package/out/_not-found/__next._full.txt +17 -0
  35. package/out/_not-found/__next._head.txt +6 -0
  36. package/out/_not-found/__next._index.txt +6 -0
  37. package/out/_not-found/__next._not-found.__PAGE__.txt +5 -0
  38. package/out/_not-found/__next._not-found.txt +5 -0
  39. package/out/_not-found/__next._tree.txt +2 -0
  40. package/out/_not-found.html +1 -0
  41. package/out/_not-found.txt +17 -0
  42. package/out/favicon.ico +0 -0
  43. package/out/file.svg +1 -0
  44. package/out/globe.svg +1 -0
  45. package/out/index.html +1 -0
  46. package/out/index.txt +17 -0
  47. package/out/next.svg +1 -0
  48. package/out/project/_/__next._full.txt +20 -0
  49. package/out/project/_/__next._head.txt +6 -0
  50. package/out/project/_/__next._index.txt +6 -0
  51. package/out/project/_/__next._tree.txt +4 -0
  52. package/out/project/_/__next.project.$d$id.__PAGE__.txt +7 -0
  53. package/out/project/_/__next.project.$d$id.txt +5 -0
  54. package/out/project/_/__next.project.txt +5 -0
  55. package/out/project/_/memory/__next._full.txt +19 -0
  56. package/out/project/_/memory/__next._head.txt +6 -0
  57. package/out/project/_/memory/__next._index.txt +6 -0
  58. package/out/project/_/memory/__next._tree.txt +3 -0
  59. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +6 -0
  60. package/out/project/_/memory/__next.project.$d$id.memory.txt +5 -0
  61. package/out/project/_/memory/__next.project.$d$id.txt +5 -0
  62. package/out/project/_/memory/__next.project.txt +5 -0
  63. package/out/project/_/memory.html +1 -0
  64. package/out/project/_/memory.txt +19 -0
  65. package/out/project/_/queue/__next._full.txt +19 -0
  66. package/out/project/_/queue/__next._head.txt +6 -0
  67. package/out/project/_/queue/__next._index.txt +6 -0
  68. package/out/project/_/queue/__next._tree.txt +3 -0
  69. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +6 -0
  70. package/out/project/_/queue/__next.project.$d$id.queue.txt +5 -0
  71. package/out/project/_/queue/__next.project.$d$id.txt +5 -0
  72. package/out/project/_/queue/__next.project.txt +5 -0
  73. package/out/project/_/queue.html +1 -0
  74. package/out/project/_/queue.txt +19 -0
  75. package/out/project/_.html +1 -0
  76. package/out/project/_.txt +20 -0
  77. package/out/settings/__next._full.txt +19 -0
  78. package/out/settings/__next._head.txt +6 -0
  79. package/out/settings/__next._index.txt +6 -0
  80. package/out/settings/__next._tree.txt +3 -0
  81. package/out/settings/__next.settings.__PAGE__.txt +6 -0
  82. package/out/settings/__next.settings.txt +5 -0
  83. package/out/settings.html +1 -0
  84. package/out/settings.txt +19 -0
  85. package/out/setup/__next._full.txt +19 -0
  86. package/out/setup/__next._head.txt +6 -0
  87. package/out/setup/__next._index.txt +6 -0
  88. package/out/setup/__next._tree.txt +3 -0
  89. package/out/setup/__next.setup.__PAGE__.txt +6 -0
  90. package/out/setup/__next.setup.txt +5 -0
  91. package/out/setup.html +1 -0
  92. package/out/setup.txt +19 -0
  93. package/out/vercel.svg +1 -0
  94. package/out/window.svg +1 -0
  95. package/package.json +61 -0
  96. package/server/config.js +63 -0
  97. package/server/index.js +476 -0
  98. package/server/routes.js +889 -0
  99. package/templates/CLAUDE.md +57 -0
  100. package/templates/config.toml +46 -0
  101. package/templates/seeds/t1.AGENTS.md +55 -0
  102. package/templates/seeds/t2a.AGENTS.md +96 -0
  103. package/templates/seeds/t2b.AGENTS.md +96 -0
  104. package/templates/seeds/t3.AGENTS.md +80 -0
  105. package/templates/wrapper.py +70 -0
@@ -0,0 +1,476 @@
1
+ const express = require("express");
2
+ const http = require("http");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const { WebSocketServer } = require("ws");
6
+ const pty = require("node-pty");
7
+ const { spawn } = require("child_process");
8
+ const { readConfig, resolveAgentCwd, resolveAgentCommand } = require("./config");
9
+ const routes = require("./routes");
10
+
11
+ const config = readConfig();
12
+ const PORT = config.port || 8400;
13
+
14
+ const app = express();
15
+ app.use(express.json());
16
+
17
+ // --- Mount migrated API routes (from Next.js) ---
18
+ app.use(routes);
19
+
20
+ const server = http.createServer(app);
21
+
22
+ // --- REST endpoints ---
23
+
24
+ app.get("/api/health", (_req, res) => {
25
+ res.json({ status: "ok" });
26
+ });
27
+
28
+ // --- Unified agent sessions ---
29
+ // Single map: key = "project/agent" → { projectId, agentId, term, ws, state, error }
30
+ // PTY (term) is the source of truth for "running". WS is optional (attaches to view terminal).
31
+ const agentSessions = new Map();
32
+
33
+ // AgentChattr server process (separate — not a PTY agent)
34
+ let chattrProcess = { process: null, state: "stopped", error: null };
35
+
36
+ // Helper: spawn a PTY for a project/agent and register in agentSessions
37
+ function spawnAgentPty(project, agent) {
38
+ const key = `${project}/${agent}`;
39
+
40
+ const cwd = resolveAgentCwd(project, agent);
41
+ if (!cwd) return { ok: false, error: `Unknown agent: ${key}` };
42
+
43
+ const command = resolveAgentCommand(project, agent) || (process.env.SHELL || "/bin/zsh");
44
+
45
+ try {
46
+ const term = pty.spawn(command, [], {
47
+ name: "xterm-256color",
48
+ cols: 120,
49
+ rows: 30,
50
+ cwd,
51
+ env: process.env,
52
+ });
53
+
54
+ const session = { projectId: project, agentId: agent, term, ws: null, state: "running", error: null };
55
+ agentSessions.set(key, session);
56
+
57
+ term.onExit(({ exitCode }) => {
58
+ const current = agentSessions.get(key);
59
+ if (current && current.term === term) {
60
+ current.state = "stopped";
61
+ current.error = exitCode ? `exit:${exitCode}` : null;
62
+ current.term = null;
63
+ // Close WS if attached
64
+ if (current.ws && current.ws.readyState <= 1) {
65
+ current.ws.close(1000, `exited:${exitCode}`);
66
+ }
67
+ current.ws = null;
68
+ }
69
+ });
70
+
71
+ return { ok: true, pid: term.pid };
72
+ } catch (err) {
73
+ agentSessions.set(key, { projectId: project, agentId: agent, term: null, ws: null, state: "error", error: err.message });
74
+ return { ok: false, error: err.message };
75
+ }
76
+ }
77
+
78
+ // Helper: stop an agent session — kill PTY, close WS
79
+ function stopAgentSession(key) {
80
+ const session = agentSessions.get(key);
81
+ if (!session) {
82
+ agentSessions.set(key, { projectId: null, agentId: null, term: null, ws: null, state: "stopped", error: null });
83
+ return;
84
+ }
85
+ if (session.term) {
86
+ try { session.term.kill(); } catch {}
87
+ session.term = null;
88
+ }
89
+ if (session.ws && session.ws.readyState <= 1) {
90
+ session.ws.close(1000, "stopped");
91
+ }
92
+ session.ws = null;
93
+ session.state = "stopped";
94
+ session.error = null;
95
+ }
96
+
97
+ app.get("/api/agents", (_req, res) => {
98
+ const agents = {};
99
+ for (const [key, session] of agentSessions) {
100
+ agents[key] = { state: session.state, error: session.error || null };
101
+ }
102
+ agents["_agentchattr"] = { state: chattrProcess.state, error: chattrProcess.error };
103
+ res.json(agents);
104
+ });
105
+
106
+ app.post("/api/agentchattr/:action", (req, res) => {
107
+ const { action } = req.params;
108
+ const cfg = readConfig();
109
+ const chattrUrl = cfg.agentchattr_url || "http://127.0.0.1:8300";
110
+ const chattrPort = new URL(chattrUrl).port || "8300";
111
+
112
+ if (action === "start") {
113
+ if (chattrProcess.state === "running" && chattrProcess.process) {
114
+ return res.json({ ok: true, state: "running", message: "Already running" });
115
+ }
116
+ try {
117
+ const child = spawn("agentchattr", ["--port", chattrPort], {
118
+ env: process.env,
119
+ stdio: "ignore",
120
+ detached: true,
121
+ });
122
+ child.unref();
123
+ child.on("error", (err) => {
124
+ chattrProcess = { process: null, state: "error", error: err.message };
125
+ });
126
+ child.on("exit", (code) => {
127
+ if (chattrProcess.process === child) {
128
+ chattrProcess = { process: null, state: "stopped", error: code ? `exit:${code}` : null };
129
+ }
130
+ });
131
+ chattrProcess = { process: child, state: "running", error: null };
132
+ res.json({ ok: true, state: "running", pid: child.pid });
133
+ } catch (err) {
134
+ chattrProcess = { process: null, state: "error", error: err.message };
135
+ res.status(500).json({ ok: false, state: "error", error: err.message });
136
+ }
137
+ } else if (action === "stop") {
138
+ if (chattrProcess.process) {
139
+ try { chattrProcess.process.kill("SIGTERM"); } catch {}
140
+ }
141
+ chattrProcess = { process: null, state: "stopped", error: null };
142
+ res.json({ ok: true, state: "stopped" });
143
+ } else if (action === "restart") {
144
+ if (chattrProcess.process) {
145
+ try { chattrProcess.process.kill("SIGTERM"); } catch {}
146
+ }
147
+ chattrProcess = { process: null, state: "stopped", error: null };
148
+ setTimeout(() => {
149
+ try {
150
+ const child = spawn("agentchattr", ["--port", chattrPort], {
151
+ env: process.env,
152
+ stdio: "ignore",
153
+ detached: true,
154
+ });
155
+ child.unref();
156
+ child.on("error", (err) => {
157
+ chattrProcess = { process: null, state: "error", error: err.message };
158
+ });
159
+ child.on("exit", (code) => {
160
+ if (chattrProcess.process === child) {
161
+ chattrProcess = { process: null, state: "stopped", error: code ? `exit:${code}` : null };
162
+ }
163
+ });
164
+ chattrProcess = { process: child, state: "running", error: null };
165
+ res.json({ ok: true, state: "running", pid: child.pid });
166
+ } catch (err) {
167
+ chattrProcess = { process: null, state: "error", error: err.message };
168
+ res.status(500).json({ ok: false, state: "error", error: err.message });
169
+ }
170
+ }, 500);
171
+ } else {
172
+ res.status(400).json({ error: "Unknown action" });
173
+ }
174
+ });
175
+
176
+ // --- Lifecycle: start spawns PTY (visible in terminal panel) ---
177
+
178
+ app.post("/api/agents/:project/:agent/start", (req, res) => {
179
+ const { project, agent } = req.params;
180
+ const key = `${project}/${agent}`;
181
+
182
+ const existing = agentSessions.get(key);
183
+ if (existing && existing.state === "running" && existing.term) {
184
+ return res.json({ ok: true, state: "running", message: "Already running" });
185
+ }
186
+
187
+ const result = spawnAgentPty(project, agent);
188
+ if (result.ok) {
189
+ res.json({ ok: true, state: "running", pid: result.pid });
190
+ } else {
191
+ res.status(result.error?.includes("Unknown") ? 400 : 500).json({ ok: false, state: "error", error: result.error });
192
+ }
193
+ });
194
+
195
+ // --- Lifecycle: stop kills PTY + closes WS ---
196
+
197
+ app.post("/api/agents/:project/:agent/stop", (req, res) => {
198
+ const { project, agent } = req.params;
199
+ const key = `${project}/${agent}`;
200
+ stopAgentSession(key);
201
+ res.json({ ok: true, state: "stopped" });
202
+ });
203
+
204
+ // --- Lifecycle: restart ---
205
+
206
+ app.post("/api/agents/:project/:agent/restart", (req, res) => {
207
+ const { project, agent } = req.params;
208
+ const key = `${project}/${agent}`;
209
+
210
+ stopAgentSession(key);
211
+
212
+ setTimeout(() => {
213
+ const result = spawnAgentPty(project, agent);
214
+ if (result.ok) {
215
+ res.json({ ok: true, state: "running", pid: result.pid });
216
+ } else {
217
+ res.status(500).json({ ok: false, state: "error", error: result.error });
218
+ }
219
+ }, 500);
220
+ });
221
+
222
+ // --- Sessions tracking (for /api/projects dashboard) ---
223
+
224
+ // Expose agentSessions to migrated routes
225
+ app.set("activeSessions", agentSessions);
226
+
227
+ app.get("/api/sessions", (_req, res) => {
228
+ const sessions = [];
229
+ for (const [, info] of agentSessions) {
230
+ if (info.state === "running") {
231
+ sessions.push({ projectId: info.projectId, agentId: info.agentId });
232
+ }
233
+ }
234
+ res.json(sessions);
235
+ });
236
+
237
+ // --- Write to active PTY session ---
238
+
239
+ app.post("/api/agents/:project/:agent/write", (req, res) => {
240
+ const { project, agent } = req.params;
241
+ const key = `${project}/${agent}`;
242
+ const session = agentSessions.get(key);
243
+
244
+ if (!session || !session.term) {
245
+ return res.status(404).json({ ok: false, error: "No active terminal session" });
246
+ }
247
+
248
+ const { text } = req.body || {};
249
+ if (!text) {
250
+ return res.status(400).json({ ok: false, error: "Missing text" });
251
+ }
252
+
253
+ try {
254
+ session.term.write(text);
255
+ res.json({ ok: true });
256
+ } catch (err) {
257
+ res.status(500).json({ ok: false, error: err.message });
258
+ }
259
+ });
260
+
261
+ // --- Scheduled Triggers ---
262
+
263
+ const triggers = new Map();
264
+
265
+ const DEFAULT_MESSAGE = `@t1 @t2a @t2b @t3 — Queue check.
266
+ T1: Merge any PR with both approvals, assign next from queue.
267
+ T3: Work on assigned ticket or address review feedback.
268
+ T2a/T2b: Review open PRs. If T3 pushed fixes, re-review. Post verdict on PR AND notify here.
269
+ ALL: Communicate via this chat by tagging agents. Your terminal is NOT visible.`;
270
+
271
+ async function sendTriggerMessage(projectId) {
272
+ const cfg = readConfig();
273
+ const project = cfg.projects && cfg.projects.find((p) => p.id === projectId);
274
+ const chattrUrl = cfg.agentchattr_url || "http://127.0.0.1:8300";
275
+ const token = cfg.agentchattr_token || "";
276
+ const message = (project && project.trigger_message) || DEFAULT_MESSAGE;
277
+ const headers = { "Content-Type": "application/json" };
278
+ if (token) headers["x-session-token"] = token;
279
+
280
+ const info = triggers.get(projectId);
281
+ try {
282
+ let tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
283
+ const res = await fetch(`${chattrUrl}/api/send${tokenParam}`, {
284
+ method: "POST",
285
+ headers,
286
+ body: JSON.stringify({ text: message, channel: "general", sender: "user" }),
287
+ });
288
+ if (!res.ok) {
289
+ const err = await res.text().catch(() => "");
290
+ console.error(`Trigger send failed for ${projectId}: ${res.status} ${err}`);
291
+ if (info) info.lastError = `${res.status}: ${err.slice(0, 100)}`;
292
+ } else {
293
+ if (info) info.lastError = null;
294
+ }
295
+ } catch (err) {
296
+ console.error(`Trigger send error for ${projectId}:`, err.message);
297
+ if (info) info.lastError = err.message;
298
+ }
299
+
300
+ if (info) {
301
+ info.lastSent = Date.now();
302
+ info.nextAt = Date.now() + info.interval;
303
+ }
304
+ }
305
+
306
+ app.get("/api/triggers", (_req, res) => {
307
+ const result = {};
308
+ for (const [id, info] of triggers) {
309
+ result[id] = {
310
+ enabled: true,
311
+ interval: info.interval,
312
+ lastSent: info.lastSent,
313
+ nextAt: info.nextAt,
314
+ lastError: info.lastError || null,
315
+ };
316
+ }
317
+ res.json(result);
318
+ });
319
+
320
+ app.post("/api/triggers/:project/start", (req, res) => {
321
+ const { project } = req.params;
322
+ const { interval } = req.body || {};
323
+ const ms = (interval || 30) * 60 * 1000;
324
+
325
+ const existing = triggers.get(project);
326
+ if (existing && existing.timer) clearInterval(existing.timer);
327
+
328
+ const timer = setInterval(() => sendTriggerMessage(project), ms);
329
+ triggers.set(project, { interval: ms, timer, lastSent: null, nextAt: Date.now() + ms });
330
+ res.json({ ok: true, enabled: true, interval: ms, nextAt: Date.now() + ms });
331
+ });
332
+
333
+ app.post("/api/triggers/:project/stop", (req, res) => {
334
+ const { project } = req.params;
335
+ const existing = triggers.get(project);
336
+ if (existing && existing.timer) clearInterval(existing.timer);
337
+ triggers.delete(project);
338
+ res.json({ ok: true, enabled: false });
339
+ });
340
+
341
+ app.post("/api/triggers/:project/send-now", (req, res) => {
342
+ const { project } = req.params;
343
+ sendTriggerMessage(project);
344
+ res.json({ ok: true, sent: true });
345
+ });
346
+
347
+ app.post("/api/triggers/sync", (_req, res) => {
348
+ syncTriggersFromConfig();
349
+ res.json({ ok: true });
350
+ });
351
+
352
+ // Expose syncTriggers for migrated routes (config PUT, rename)
353
+ app.set("syncTriggers", syncTriggersFromConfig);
354
+
355
+ // --- Serve static frontend (built Next.js export) ---
356
+
357
+ const outDir = path.join(__dirname, "..", "out");
358
+ if (fs.existsSync(outDir)) {
359
+ app.use(express.static(outDir));
360
+ }
361
+
362
+ // SPA fallback: serve index.html for all non-API, non-WS routes
363
+ app.use((req, res, next) => {
364
+ if (req.method !== "GET" || req.path.startsWith("/api/")) {
365
+ return next();
366
+ }
367
+ const indexPath = path.join(outDir, "index.html");
368
+ if (fs.existsSync(indexPath)) {
369
+ res.sendFile(indexPath);
370
+ } else {
371
+ res.status(503).send("Frontend not built. Run: npm run build");
372
+ }
373
+ });
374
+
375
+ // --- WebSocket + PTY ---
376
+ // WS connects to an existing PTY session (started via lifecycle API)
377
+ // or spawns a new one if none exists.
378
+
379
+ const wss = new WebSocketServer({ server, path: "/ws/terminal" });
380
+
381
+ wss.on("connection", (ws, req) => {
382
+ const params = new URL(req.url, `http://localhost:${PORT}`).searchParams;
383
+ const projectId = params.get("project");
384
+ const agentId = params.get("agent");
385
+
386
+ if (!projectId || !agentId) {
387
+ ws.close(1008, "missing project or agent query params");
388
+ return;
389
+ }
390
+
391
+ const sessionKey = `${projectId}/${agentId}`;
392
+ let session = agentSessions.get(sessionKey);
393
+
394
+ // If no active PTY, spawn one
395
+ if (!session || !session.term) {
396
+ const result = spawnAgentPty(projectId, agentId);
397
+ if (!result.ok) {
398
+ ws.close(1011, "pty-spawn-failed");
399
+ return;
400
+ }
401
+ session = agentSessions.get(sessionKey);
402
+ }
403
+
404
+ // Close previous WS if one was attached
405
+ if (session.ws && session.ws !== ws && session.ws.readyState <= 1) {
406
+ session.ws.close(1000, "replaced");
407
+ }
408
+
409
+ // Attach WS to session
410
+ session.ws = ws;
411
+
412
+ // PTY → client
413
+ const dataHandler = session.term.onData((data) => {
414
+ if (ws.readyState === ws.OPEN) {
415
+ ws.send(data);
416
+ }
417
+ });
418
+
419
+ // Client → PTY
420
+ ws.on("message", (msg) => {
421
+ if (!session.term) return;
422
+ const str = msg.toString();
423
+ try {
424
+ const parsed = JSON.parse(str);
425
+ if (parsed.type === "resize" && parsed.cols && parsed.rows) {
426
+ session.term.resize(parsed.cols, parsed.rows);
427
+ return;
428
+ }
429
+ } catch {}
430
+ session.term.write(str);
431
+ });
432
+
433
+ ws.on("close", () => {
434
+ dataHandler.dispose();
435
+ // Only clear ws reference, don't kill PTY (it stays running for reconnect)
436
+ if (session.ws === ws) {
437
+ session.ws = null;
438
+ }
439
+ });
440
+ });
441
+
442
+ // --- Trigger auto-start from config ---
443
+
444
+ function syncTriggersFromConfig() {
445
+ const cfg = readConfig();
446
+ const activeIds = new Set();
447
+
448
+ if (cfg.projects) {
449
+ for (const project of cfg.projects) {
450
+ if (project.trigger_enabled) {
451
+ activeIds.add(project.id);
452
+ const ms = (project.trigger_interval || 30) * 60 * 1000;
453
+ const existing = triggers.get(project.id);
454
+ if (!existing || existing.interval !== ms) {
455
+ if (existing && existing.timer) clearInterval(existing.timer);
456
+ const timer = setInterval(() => sendTriggerMessage(project.id), ms);
457
+ triggers.set(project.id, { interval: ms, timer, lastSent: null, nextAt: Date.now() + ms, lastError: null });
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ for (const [id, info] of triggers) {
464
+ if (!activeIds.has(id)) {
465
+ if (info.timer) clearInterval(info.timer);
466
+ triggers.delete(id);
467
+ }
468
+ }
469
+ }
470
+
471
+ // --- Start ---
472
+
473
+ server.listen(PORT, "127.0.0.1", () => {
474
+ console.log(`QuadWork server listening on http://127.0.0.1:${PORT}`);
475
+ syncTriggersFromConfig();
476
+ });