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.
- package/README.md +58 -83
- package/bin/quadwork.js +512 -97
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +2 -2
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +2 -2
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/0ahp74n0wkel0.js +1 -0
- package/out/_next/static/chunks/{0jsosmtclw5n5.js → 0dmi9pk2bd712.js} +3 -3
- package/out/_next/static/chunks/0ezniz80psxr6.js +1 -0
- package/out/_next/static/chunks/0g-nq4.uckan-.js +1 -0
- package/out/_next/static/chunks/0io_y3d0p5v~b.js +2 -0
- package/out/_next/static/chunks/0jt42fqe6jaw6.js +1 -0
- package/out/_next/static/chunks/{03hi.hdp6l230.js → 0q5hwcek8vu2q.js} +12 -12
- package/out/_next/static/chunks/0r_tb4lmfa_yb.js +1 -0
- package/out/_next/static/chunks/0s8jbc4nxw6y6.css +2 -0
- package/out/_next/static/chunks/0z~0.4hivi.f2.js +31 -0
- package/out/_next/static/chunks/135rms05ismy4.js +13 -0
- package/out/_next/static/chunks/14kr4rvjq-2md.js +1 -0
- package/out/_next/static/chunks/turbopack-0sammtvunroor.js +1 -0
- package/out/_not-found/__next._full.txt +2 -2
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +2 -2
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +2 -2
- package/out/app-shell/__next._full.txt +18 -0
- package/out/app-shell/__next._head.txt +6 -0
- package/out/app-shell/__next._index.txt +6 -0
- package/out/app-shell/__next._tree.txt +3 -0
- package/out/app-shell/__next.app-shell.__PAGE__.txt +5 -0
- package/out/app-shell/__next.app-shell.txt +5 -0
- package/out/app-shell.html +1 -0
- package/out/app-shell.txt +18 -0
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/project/_/__next._full.txt +3 -4
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +2 -2
- package/out/project/_/__next._tree.txt +2 -3
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -3
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/memory/__next._full.txt +3 -3
- package/out/project/_/memory/__next._head.txt +1 -1
- package/out/project/_/memory/__next._index.txt +2 -2
- package/out/project/_/memory/__next._tree.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.txt +1 -1
- package/out/project/_/memory/__next.project.txt +1 -1
- package/out/project/_/memory.html +1 -1
- package/out/project/_/memory.txt +3 -3
- package/out/project/_/queue/__next._full.txt +3 -3
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +2 -2
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +3 -3
- package/out/project/_.html +1 -1
- package/out/project/_.txt +3 -4
- package/out/settings/__next._full.txt +3 -3
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +2 -2
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +2 -2
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +3 -3
- package/out/setup/__next._full.txt +3 -3
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +2 -2
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +2 -2
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +3 -3
- package/package.json +1 -1
- package/server/config.js +66 -2
- package/server/index.js +344 -63
- package/server/routes.js +195 -68
- package/templates/CLAUDE.md +16 -17
- package/templates/config.toml +12 -12
- package/templates/seeds/{t3.AGENTS.md → dev.AGENTS.md} +19 -19
- package/templates/seeds/{t1.AGENTS.md → head.AGENTS.md} +18 -18
- package/templates/seeds/{t2a.AGENTS.md → reviewer1.AGENTS.md} +16 -16
- package/templates/seeds/{t2b.AGENTS.md → reviewer2.AGENTS.md} +16 -16
- package/out/_next/static/chunks/0.dzh0qf9zq1l.js +0 -2
- package/out/_next/static/chunks/03yov._jigv17.js +0 -1
- package/out/_next/static/chunks/0iqqouh_3i5y5.js +0 -13
- package/out/_next/static/chunks/13uu.sohs74zg.js +0 -31
- package/out/_next/static/chunks/15kwal..m9r49.css +0 -2
- package/out/_next/static/chunks/17sk4qv6_d0co.js +0 -1
- package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +0 -1
- /package/out/_next/static/{vELqtMegFMn5_6zFOkhtG → 4vrILyy2mh_Ox4JMTaqx8}/_buildManifest.js +0 -0
- /package/out/_next/static/{vELqtMegFMn5_6zFOkhtG → 4vrILyy2mh_Ox4JMTaqx8}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
34
|
-
|
|
131
|
+
// AgentChattr server processes — per-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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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 =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
302
|
+
const proc = getProc();
|
|
303
|
+
if (proc.process) {
|
|
304
|
+
try { proc.process.kill("SIGTERM"); } catch {}
|
|
140
305
|
}
|
|
141
|
-
|
|
306
|
+
setProc({ process: null, state: "stopped", error: null });
|
|
142
307
|
res.json({ ok: true, state: "stopped" });
|
|
143
308
|
} else if (action === "restart") {
|
|
144
|
-
|
|
145
|
-
|
|
309
|
+
const proc = getProc();
|
|
310
|
+
if (proc.process) {
|
|
311
|
+
try { proc.process.kill("SIGTERM"); } catch {}
|
|
146
312
|
}
|
|
147
|
-
|
|
313
|
+
setProc({ process: null, state: "stopped", error: null });
|
|
148
314
|
setTimeout(() => {
|
|
149
315
|
try {
|
|
150
|
-
const child =
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 = `@
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
275
|
-
const 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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|