quadwork 0.1.2 → 1.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 (104) hide show
  1. package/README.md +58 -83
  2. package/bin/quadwork.js +512 -97
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +1 -1
  5. package/out/__next._full.txt +2 -2
  6. package/out/__next._head.txt +1 -1
  7. package/out/__next._index.txt +2 -2
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/0ahp74n0wkel0.js +1 -0
  10. package/out/_next/static/chunks/{0jsosmtclw5n5.js → 0dmi9pk2bd712.js} +3 -3
  11. package/out/_next/static/chunks/0ezniz80psxr6.js +1 -0
  12. package/out/_next/static/chunks/0g-nq4.uckan-.js +1 -0
  13. package/out/_next/static/chunks/0io_y3d0p5v~b.js +2 -0
  14. package/out/_next/static/chunks/0jt42fqe6jaw6.js +1 -0
  15. package/out/_next/static/chunks/{03hi.hdp6l230.js → 0q5hwcek8vu2q.js} +12 -12
  16. package/out/_next/static/chunks/0r_tb4lmfa_yb.js +1 -0
  17. package/out/_next/static/chunks/0s8jbc4nxw6y6.css +2 -0
  18. package/out/_next/static/chunks/0z~0.4hivi.f2.js +31 -0
  19. package/out/_next/static/chunks/135rms05ismy4.js +13 -0
  20. package/out/_next/static/chunks/14kr4rvjq-2md.js +1 -0
  21. package/out/_next/static/chunks/turbopack-0sammtvunroor.js +1 -0
  22. package/out/_not-found/__next._full.txt +2 -2
  23. package/out/_not-found/__next._head.txt +1 -1
  24. package/out/_not-found/__next._index.txt +2 -2
  25. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  26. package/out/_not-found/__next._not-found.txt +1 -1
  27. package/out/_not-found/__next._tree.txt +2 -2
  28. package/out/_not-found.html +1 -1
  29. package/out/_not-found.txt +2 -2
  30. package/out/app-shell/__next._full.txt +18 -0
  31. package/out/app-shell/__next._head.txt +6 -0
  32. package/out/app-shell/__next._index.txt +6 -0
  33. package/out/app-shell/__next._tree.txt +3 -0
  34. package/out/app-shell/__next.app-shell.__PAGE__.txt +5 -0
  35. package/out/app-shell/__next.app-shell.txt +5 -0
  36. package/out/app-shell.html +1 -0
  37. package/out/app-shell.txt +18 -0
  38. package/out/index.html +1 -1
  39. package/out/index.txt +2 -2
  40. package/out/project/_/__next._full.txt +3 -4
  41. package/out/project/_/__next._head.txt +1 -1
  42. package/out/project/_/__next._index.txt +2 -2
  43. package/out/project/_/__next._tree.txt +2 -3
  44. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -3
  45. package/out/project/_/__next.project.$d$id.txt +1 -1
  46. package/out/project/_/__next.project.txt +1 -1
  47. package/out/project/_/memory/__next._full.txt +3 -3
  48. package/out/project/_/memory/__next._head.txt +1 -1
  49. package/out/project/_/memory/__next._index.txt +2 -2
  50. package/out/project/_/memory/__next._tree.txt +2 -2
  51. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +2 -2
  52. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  53. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  54. package/out/project/_/memory/__next.project.txt +1 -1
  55. package/out/project/_/memory.html +1 -1
  56. package/out/project/_/memory.txt +3 -3
  57. package/out/project/_/queue/__next._full.txt +3 -3
  58. package/out/project/_/queue/__next._head.txt +1 -1
  59. package/out/project/_/queue/__next._index.txt +2 -2
  60. package/out/project/_/queue/__next._tree.txt +2 -2
  61. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +2 -2
  62. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  63. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  64. package/out/project/_/queue/__next.project.txt +1 -1
  65. package/out/project/_/queue.html +1 -1
  66. package/out/project/_/queue.txt +3 -3
  67. package/out/project/_.html +1 -1
  68. package/out/project/_.txt +3 -4
  69. package/out/settings/__next._full.txt +3 -3
  70. package/out/settings/__next._head.txt +1 -1
  71. package/out/settings/__next._index.txt +2 -2
  72. package/out/settings/__next._tree.txt +2 -2
  73. package/out/settings/__next.settings.__PAGE__.txt +2 -2
  74. package/out/settings/__next.settings.txt +1 -1
  75. package/out/settings.html +1 -1
  76. package/out/settings.txt +3 -3
  77. package/out/setup/__next._full.txt +3 -3
  78. package/out/setup/__next._head.txt +1 -1
  79. package/out/setup/__next._index.txt +2 -2
  80. package/out/setup/__next._tree.txt +2 -2
  81. package/out/setup/__next.setup.__PAGE__.txt +2 -2
  82. package/out/setup/__next.setup.txt +1 -1
  83. package/out/setup.html +1 -1
  84. package/out/setup.txt +3 -3
  85. package/package.json +1 -1
  86. package/server/config.js +66 -2
  87. package/server/index.js +344 -63
  88. package/server/routes.js +195 -68
  89. package/templates/CLAUDE.md +16 -17
  90. package/templates/config.toml +12 -12
  91. package/templates/seeds/{t3.AGENTS.md → dev.AGENTS.md} +19 -19
  92. package/templates/seeds/{t1.AGENTS.md → head.AGENTS.md} +18 -18
  93. package/templates/seeds/{t2a.AGENTS.md → reviewer1.AGENTS.md} +16 -16
  94. package/templates/seeds/{t2b.AGENTS.md → reviewer2.AGENTS.md} +16 -16
  95. package/out/_next/static/chunks/0.dzh0qf9zq1l.js +0 -2
  96. package/out/_next/static/chunks/03yov._jigv17.js +0 -1
  97. package/out/_next/static/chunks/0iqqouh_3i5y5.js +0 -13
  98. package/out/_next/static/chunks/13uu.sohs74zg.js +0 -31
  99. package/out/_next/static/chunks/15kwal..m9r49.css +0 -2
  100. package/out/_next/static/chunks/17sk4qv6_d0co.js +0 -1
  101. package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +0 -1
  102. /package/out/_next/static/{vELqtMegFMn5_6zFOkhtG → 4vrILyy2mh_Ox4JMTaqx8}/_buildManifest.js +0 -0
  103. /package/out/_next/static/{vELqtMegFMn5_6zFOkhtG → 4vrILyy2mh_Ox4JMTaqx8}/_clientMiddlewareManifest.js +0 -0
  104. /package/out/_next/static/{vELqtMegFMn5_6zFOkhtG → 4vrILyy2mh_Ox4JMTaqx8}/_ssgManifest.js +0 -0
package/server/index.js CHANGED
@@ -5,9 +5,10 @@ const fs = require("fs");
5
5
  const { WebSocketServer } = require("ws");
6
6
  const pty = require("node-pty");
7
7
  const { spawn } = require("child_process");
8
- const { readConfig, resolveAgentCwd, resolveAgentCommand } = require("./config");
8
+ const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, syncChattrToken } = require("./config");
9
9
  const routes = require("./routes");
10
10
 
11
+ const net = require("net");
11
12
  const config = readConfig();
12
13
  const PORT = config.port || 8400;
13
14
 
@@ -25,13 +26,110 @@ app.get("/api/health", (_req, res) => {
25
26
  res.json({ status: "ok" });
26
27
  });
27
28
 
29
+ // --- CLI status detection ---
30
+
31
+ const { execSync } = require("child_process");
32
+
33
+ function isCliInstalled(cmd) {
34
+ try {
35
+ execSync(`which ${cmd}`, { encoding: "utf-8", stdio: "pipe" });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ app.get("/api/cli-status", (_req, res) => {
43
+ res.json({
44
+ claude: isCliInstalled("claude"),
45
+ codex: isCliInstalled("codex"),
46
+ });
47
+ });
48
+
49
+ // --- Port availability check ---
50
+
51
+ function checkPort(port) {
52
+ return new Promise((resolve) => {
53
+ const srv = net.createServer();
54
+ srv.once("error", () => resolve(false));
55
+ srv.once("listening", () => { srv.close(); resolve(true); });
56
+ srv.listen(port, "127.0.0.1");
57
+ });
58
+ }
59
+
60
+ app.get("/api/port-check", async (req, res) => {
61
+ const port = parseInt(req.query.port, 10);
62
+ if (!port || port < 1 || port > 65535) {
63
+ return res.status(400).json({ error: "Invalid port" });
64
+ }
65
+ const free = await checkPort(port);
66
+ res.json({ port, free });
67
+ });
68
+
69
+ app.get("/api/port-check/auto", async (req, res) => {
70
+ const start = parseInt(req.query.start, 10) || 8300;
71
+ const count = Math.min(parseInt(req.query.count, 10) || 3, 10);
72
+ const results = [];
73
+ let port = start;
74
+ for (let i = 0; i < count; i++) {
75
+ while (!(await checkPort(port)) && port < 65535) port++;
76
+ results.push(port);
77
+ port++;
78
+ }
79
+ res.json({ ports: results });
80
+ });
81
+
82
+ // --- Caffeinate (sleep prevention) ---
83
+
84
+ let caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
85
+
86
+ app.post("/api/caffeinate/start", (req, res) => {
87
+ if (process.platform !== "darwin") {
88
+ return res.status(400).json({ ok: false, error: "Sleep prevention is only available on macOS" });
89
+ }
90
+ // Kill existing if running
91
+ if (caffeinateProcess.process) {
92
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
93
+ }
94
+ const duration = req.body?.duration || 0; // seconds, 0 = indefinite
95
+ const args = ["-d", "-i", "-s"];
96
+ if (duration > 0) args.push("-t", String(duration));
97
+ const child = spawn("caffeinate", args, { stdio: "ignore", detached: true });
98
+ child.unref();
99
+ child.on("exit", () => {
100
+ if (caffeinateProcess.process === child) {
101
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
102
+ }
103
+ });
104
+ caffeinateProcess = { process: child, pid: child.pid, startedAt: Date.now(), duration: duration || null };
105
+ res.json({ ok: true, active: true, pid: child.pid, duration });
106
+ });
107
+
108
+ app.post("/api/caffeinate/stop", (_req, res) => {
109
+ if (caffeinateProcess.process) {
110
+ try { caffeinateProcess.process.kill("SIGTERM"); } catch {}
111
+ }
112
+ caffeinateProcess = { process: null, pid: null, startedAt: null, duration: null };
113
+ res.json({ ok: true, active: false });
114
+ });
115
+
116
+ app.get("/api/caffeinate/status", (_req, res) => {
117
+ const active = !!(caffeinateProcess.process && caffeinateProcess.pid);
118
+ let remaining = null;
119
+ if (active && caffeinateProcess.duration && caffeinateProcess.startedAt) {
120
+ const elapsed = Math.floor((Date.now() - caffeinateProcess.startedAt) / 1000);
121
+ remaining = Math.max(0, caffeinateProcess.duration - elapsed);
122
+ }
123
+ res.json({ active, pid: caffeinateProcess.pid, remaining, platform: process.platform });
124
+ });
125
+
28
126
  // --- Unified agent sessions ---
29
127
  // Single map: key = "project/agent" → { projectId, agentId, term, ws, state, error }
30
128
  // PTY (term) is the source of truth for "running". WS is optional (attaches to view terminal).
31
129
  const agentSessions = new Map();
32
130
 
33
- // AgentChattr server process (separate not a PTY agent)
34
- let chattrProcess = { process: null, state: "stopped", error: null };
131
+ // AgentChattr server processesper-project (key = projectId)
132
+ const chattrProcesses = new Map();
35
133
 
36
134
  // Helper: spawn a PTY for a project/agent and register in agentSessions
37
135
  function spawnAgentPty(project, agent) {
@@ -99,78 +197,178 @@ app.get("/api/agents", (_req, res) => {
99
197
  for (const [key, session] of agentSessions) {
100
198
  agents[key] = { state: session.state, error: session.error || null };
101
199
  }
102
- agents["_agentchattr"] = { state: chattrProcess.state, error: chattrProcess.error };
200
+ for (const [pid, proc] of chattrProcesses) {
201
+ agents[`_agentchattr/${pid}`] = { state: proc.state, error: proc.error };
202
+ }
103
203
  res.json(agents);
104
204
  });
105
205
 
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";
206
+ // Per-project AgentChattr lifecycle: /api/agentchattr/:project/:action
207
+ // Backward compat: /api/agentchattr/:action uses first project
208
+ function handleAgentChattr(req, res) {
209
+ let projectId, action;
210
+ if (req.params.action) {
211
+ projectId = req.params.projectOrAction;
212
+ action = req.params.action;
213
+ } else {
214
+ // Backward compat: single-param = action, use first project
215
+ action = req.params.projectOrAction;
216
+ const cfg = readConfig();
217
+ projectId = cfg.projects?.[0]?.id || "_default";
218
+ }
219
+
220
+ const { url: chattrUrl } = resolveProjectChattr(projectId);
110
221
  const chattrPort = new URL(chattrUrl).port || "8300";
111
222
 
223
+ // Find per-project config.toml (prefer project working_dir/agentchattr/config.toml)
224
+ const cfg = readConfig();
225
+ const project = cfg.projects?.find((p) => p.id === projectId);
226
+ const projectConfigToml = project?.working_dir
227
+ ? path.join(project.working_dir, "agentchattr", "config.toml")
228
+ : null;
229
+
230
+ function getProc() {
231
+ return chattrProcesses.get(projectId) || { process: null, state: "stopped", error: null };
232
+ }
233
+ function setProc(val) {
234
+ chattrProcesses.set(projectId, val);
235
+ }
236
+
237
+ function regenerateConfigToml() {
238
+ // If project has a config.toml, update the port to match current config
239
+ if (!projectConfigToml || !fs.existsSync(projectConfigToml)) return;
240
+ try {
241
+ let content = fs.readFileSync(projectConfigToml, "utf-8");
242
+ content = content.replace(/^port = \d+/m, `port = ${chattrPort}`);
243
+ fs.writeFileSync(projectConfigToml, content);
244
+ } catch {}
245
+ }
246
+
247
+ function spawnChattr() {
248
+ // Sync config.toml port before starting
249
+ regenerateConfigToml();
250
+
251
+ // Use project config.toml if available (isolated data dir + ports), otherwise fall back to --port
252
+ const args = (projectConfigToml && fs.existsSync(projectConfigToml))
253
+ ? ["--config", projectConfigToml]
254
+ : ["--port", chattrPort];
255
+ const child = spawn("agentchattr", args, {
256
+ env: process.env,
257
+ stdio: "ignore",
258
+ detached: true,
259
+ });
260
+
261
+ // If pid is undefined, spawn failed (binary not found)
262
+ if (!child.pid) {
263
+ setProc({ process: null, state: "error", error: "Failed to start AgentChattr — is it installed? Run: pipx install agentchattr" });
264
+ // Still register error handler to prevent unhandled error crash
265
+ child.on("error", () => {});
266
+ return null;
267
+ }
268
+
269
+ child.unref();
270
+ child.on("error", (err) => {
271
+ setProc({ process: null, state: "error", error: err.message });
272
+ });
273
+ child.on("exit", (code) => {
274
+ const cur = getProc();
275
+ if (cur.process === child) {
276
+ setProc({ process: null, state: "stopped", error: code ? `exit:${code}` : null });
277
+ }
278
+ });
279
+ setProc({ process: child, state: "running", error: null });
280
+ return child;
281
+ }
282
+
112
283
  if (action === "start") {
113
- if (chattrProcess.state === "running" && chattrProcess.process) {
284
+ const proc = getProc();
285
+ if (proc.state === "running" && proc.process) {
114
286
  return res.json({ ok: true, state: "running", message: "Already running" });
115
287
  }
116
288
  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 };
289
+ const child = spawnChattr();
290
+ if (!child) {
291
+ const errProc = getProc();
292
+ return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
293
+ }
294
+ // Sync token after AgentChattr starts (it generates its own)
295
+ setTimeout(() => syncChattrToken(projectId), 2000);
132
296
  res.json({ ok: true, state: "running", pid: child.pid });
133
297
  } catch (err) {
134
- chattrProcess = { process: null, state: "error", error: err.message };
298
+ setProc({ process: null, state: "error", error: err.message });
135
299
  res.status(500).json({ ok: false, state: "error", error: err.message });
136
300
  }
137
301
  } else if (action === "stop") {
138
- if (chattrProcess.process) {
139
- try { chattrProcess.process.kill("SIGTERM"); } catch {}
302
+ const proc = getProc();
303
+ if (proc.process) {
304
+ try { proc.process.kill("SIGTERM"); } catch {}
140
305
  }
141
- chattrProcess = { process: null, state: "stopped", error: null };
306
+ setProc({ process: null, state: "stopped", error: null });
142
307
  res.json({ ok: true, state: "stopped" });
143
308
  } else if (action === "restart") {
144
- if (chattrProcess.process) {
145
- try { chattrProcess.process.kill("SIGTERM"); } catch {}
309
+ const proc = getProc();
310
+ if (proc.process) {
311
+ try { proc.process.kill("SIGTERM"); } catch {}
146
312
  }
147
- chattrProcess = { process: null, state: "stopped", error: null };
313
+ setProc({ process: null, state: "stopped", error: null });
148
314
  setTimeout(() => {
149
315
  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 };
316
+ const child = spawnChattr();
317
+ if (!child) {
318
+ const errProc = getProc();
319
+ return res.status(500).json({ ok: false, state: "error", error: errProc.error || "Failed to start AgentChattr" });
320
+ }
321
+ // Sync token after AgentChattr restarts
322
+ setTimeout(() => syncChattrToken(projectId), 2000);
165
323
  res.json({ ok: true, state: "running", pid: child.pid });
166
324
  } catch (err) {
167
- chattrProcess = { process: null, state: "error", error: err.message };
325
+ setProc({ process: null, state: "error", error: err.message });
168
326
  res.status(500).json({ ok: false, state: "error", error: err.message });
169
327
  }
170
328
  }, 500);
171
329
  } else {
172
330
  res.status(400).json({ error: "Unknown action" });
173
331
  }
332
+ }
333
+ app.post("/api/agentchattr/:projectOrAction/:action", handleAgentChattr);
334
+ app.post("/api/agentchattr/:projectOrAction", handleAgentChattr);
335
+
336
+ // --- Reset agents: deregister all registered slots ---
337
+ // AgentChattr doesn't expose staleness metadata, so this clears all slots.
338
+ // Agents' wrapper heartbeat will auto-re-register with clean names.
339
+
340
+ app.post("/api/agents/:project/reset", async (req, res) => {
341
+ const projectId = req.params.project;
342
+ const { url: chattrUrl, token: chattrToken } = resolveProjectChattr(projectId);
343
+ const headers = {};
344
+ if (chattrToken) headers["x-session-token"] = chattrToken;
345
+
346
+ try {
347
+ // Fetch current agent status from AgentChattr
348
+ const statusRes = await fetch(`${chattrUrl}/api/status`, { headers });
349
+ if (!statusRes.ok) {
350
+ return res.status(statusRes.status).json({ ok: false, error: `AgentChattr status failed: ${statusRes.status}` });
351
+ }
352
+ const status = await statusRes.json();
353
+ const slots = status.agents || status.slots || [];
354
+
355
+ let cleared = 0;
356
+ for (const agent of slots) {
357
+ const name = typeof agent === "string" ? agent : agent.name || agent.sender;
358
+ if (!name) continue;
359
+ try {
360
+ const dereg = await fetch(`${chattrUrl}/api/deregister/${encodeURIComponent(name)}`, {
361
+ method: "POST",
362
+ headers,
363
+ });
364
+ if (dereg.ok) cleared++;
365
+ } catch {}
366
+ }
367
+
368
+ res.json({ ok: true, cleared, total: slots.length });
369
+ } catch (err) {
370
+ res.status(500).json({ ok: false, error: err.message });
371
+ }
174
372
  });
175
373
 
176
374
  // --- Lifecycle: start spawns PTY (visible in terminal panel) ---
@@ -262,17 +460,17 @@ app.post("/api/agents/:project/:agent/write", (req, res) => {
262
460
 
263
461
  const triggers = new Map();
264
462
 
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.
463
+ const DEFAULT_MESSAGE = `@head @reviewer1 @reviewer2 @dev — Queue check.
464
+ Head: Merge any PR with both approvals, assign next from queue.
465
+ Dev: Work on assigned ticket or address review feedback.
466
+ Reviewer1/Reviewer2: Review open PRs. If Dev pushed fixes, re-review. Post verdict on PR AND notify here.
269
467
  ALL: Communicate via this chat by tagging agents. Your terminal is NOT visible.`;
270
468
 
271
469
  async function sendTriggerMessage(projectId) {
272
470
  const cfg = readConfig();
273
471
  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 || "";
472
+ const { url: chattrUrl, token: chattrToken } = resolveProjectChattr(projectId);
473
+ const token = chattrToken || "";
276
474
  const message = (project && project.trigger_message) || DEFAULT_MESSAGE;
277
475
  const headers = { "Content-Type": "application/json" };
278
476
  if (token) headers["x-session-token"] = token;
@@ -312,29 +510,60 @@ app.get("/api/triggers", (_req, res) => {
312
510
  lastSent: info.lastSent,
313
511
  nextAt: info.nextAt,
314
512
  lastError: info.lastError || null,
513
+ expiresAt: info.expiresAt || null,
315
514
  };
316
515
  }
317
516
  res.json(result);
318
517
  });
319
518
 
519
+ function stopTrigger(project) {
520
+ const existing = triggers.get(project);
521
+ if (existing) {
522
+ if (existing.timer) clearInterval(existing.timer);
523
+ if (existing.durationTimer) clearTimeout(existing.durationTimer);
524
+ }
525
+ triggers.delete(project);
526
+ }
527
+
320
528
  app.post("/api/triggers/:project/start", (req, res) => {
321
529
  const { project } = req.params;
322
- const { interval } = req.body || {};
530
+ const { interval, duration } = req.body || {};
323
531
  const ms = (interval || 30) * 60 * 1000;
532
+ const durationMs = duration ? duration * 60 * 1000 : 0; // duration in minutes, 0 = indefinite
324
533
 
325
534
  const existing = triggers.get(project);
326
- if (existing && existing.timer) clearInterval(existing.timer);
535
+ if (existing) {
536
+ if (existing.timer) clearInterval(existing.timer);
537
+ if (existing.durationTimer) clearTimeout(existing.durationTimer);
538
+ }
327
539
 
328
540
  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 });
541
+ const expiresAt = durationMs > 0 ? Date.now() + durationMs : null;
542
+
543
+ const triggerInfo = {
544
+ interval: ms,
545
+ timer,
546
+ lastSent: null,
547
+ nextAt: Date.now() + ms,
548
+ lastError: null,
549
+ expiresAt,
550
+ durationTimer: null,
551
+ };
552
+
553
+ // Auto-stop after duration
554
+ if (durationMs > 0) {
555
+ triggerInfo.durationTimer = setTimeout(() => {
556
+ stopTrigger(project);
557
+ }, durationMs);
558
+ }
559
+
560
+ triggers.set(project, triggerInfo);
561
+ res.json({ ok: true, enabled: true, interval: ms, nextAt: Date.now() + ms, expiresAt });
331
562
  });
332
563
 
333
564
  app.post("/api/triggers/:project/stop", (req, res) => {
334
565
  const { project } = req.params;
335
- const existing = triggers.get(project);
336
- if (existing && existing.timer) clearInterval(existing.timer);
337
- triggers.delete(project);
566
+ stopTrigger(project);
338
567
  res.json({ ok: true, enabled: false });
339
568
  });
340
569
 
@@ -354,16 +583,62 @@ app.set("syncTriggers", syncTriggersFromConfig);
354
583
 
355
584
  // --- Serve static frontend (built Next.js export) ---
356
585
 
586
+ // Strip trailing slashes (redirect /settings/ → /settings, /setup/ → /setup)
587
+ app.use((req, res, next) => {
588
+ if (req.path !== "/" && req.path.endsWith("/")) {
589
+ const clean = req.path.slice(0, -1);
590
+ const query = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
591
+ return res.redirect(301, clean + query);
592
+ }
593
+ next();
594
+ });
595
+
357
596
  const outDir = path.join(__dirname, "..", "out");
597
+
598
+ // HEAD requests: express.static extensions don't resolve when a same-name
599
+ // directory exists (e.g. /settings dir shadows settings.html for HEAD).
600
+ // Resolve extensionless HEAD requests to their .html file explicitly.
601
+ app.use((req, res, next) => {
602
+ if (req.method !== "HEAD" || req.path.startsWith("/api/") || path.extname(req.path)) {
603
+ return next();
604
+ }
605
+ const htmlPath = path.join(outDir, req.path + ".html");
606
+ if (fs.existsSync(htmlPath)) {
607
+ return res.sendFile(htmlPath);
608
+ }
609
+ next();
610
+ });
611
+
358
612
  if (fs.existsSync(outDir)) {
359
- app.use(express.static(outDir));
613
+ app.use(express.static(outDir, { redirect: false, extensions: ["html"] }));
360
614
  }
361
615
 
362
- // SPA fallback: serve index.html for all non-API, non-WS routes
616
+ // SPA fallback: serve the pre-rendered template for dynamic routes,
617
+ // fall back to index.html for everything else.
363
618
  app.use((req, res, next) => {
364
- if (req.method !== "GET" || req.path.startsWith("/api/")) {
619
+ if ((req.method !== "GET" && req.method !== "HEAD") || req.path.startsWith("/api/")) {
365
620
  return next();
366
621
  }
622
+
623
+ // Dynamic routes → serve their pre-rendered template (has the right JS chunks).
624
+ // Hydration #418 is cosmetic — dashboard renders and functions correctly.
625
+ // NOTE: app-shell.html does NOT work — it has no route JS chunks and renders blank.
626
+ const dynamicRoutes = [
627
+ { pattern: /^\/project\/[^/]+\/memory\/?$/, template: "project/_/memory.html" },
628
+ { pattern: /^\/project\/[^/]+\/queue\/?$/, template: "project/_/queue.html" },
629
+ { pattern: /^\/project\/[^/]+\/?$/, template: "project/_.html" },
630
+ ];
631
+
632
+ for (const route of dynamicRoutes) {
633
+ if (route.pattern.test(req.path)) {
634
+ const templatePath = path.join(outDir, route.template);
635
+ if (fs.existsSync(templatePath)) {
636
+ return res.sendFile(templatePath);
637
+ }
638
+ }
639
+ }
640
+
641
+ // Everything else → index.html
367
642
  const indexPath = path.join(outDir, "index.html");
368
643
  if (fs.existsSync(indexPath)) {
369
644
  res.sendFile(indexPath);
@@ -463,6 +738,7 @@ function syncTriggersFromConfig() {
463
738
  for (const [id, info] of triggers) {
464
739
  if (!activeIds.has(id)) {
465
740
  if (info.timer) clearInterval(info.timer);
741
+ if (info.durationTimer) clearTimeout(info.durationTimer);
466
742
  triggers.delete(id);
467
743
  }
468
744
  }
@@ -473,4 +749,9 @@ function syncTriggersFromConfig() {
473
749
  server.listen(PORT, "127.0.0.1", () => {
474
750
  console.log(`QuadWork server listening on http://127.0.0.1:${PORT}`);
475
751
  syncTriggersFromConfig();
752
+ // Sync AgentChattr tokens for all projects on startup
753
+ const startupCfg = readConfig();
754
+ for (const p of (startupCfg.projects || [])) {
755
+ syncChattrToken(p.id);
756
+ }
476
757
  });