quadwork 1.0.16 → 1.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.
- package/README.md +28 -0
- package/bin/quadwork.js +445 -53
- 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/0caq73v0knw_w.js +1 -0
- package/out/_next/static/chunks/10b3c4k.q.yw..css +2 -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 +2 -2
- package/out/app-shell/__next._head.txt +1 -1
- package/out/app-shell/__next._index.txt +2 -2
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
- package/out/app-shell/__next.app-shell.txt +1 -1
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/project/_/__next._full.txt +2 -2
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +2 -2
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
- 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 +2 -2
- 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 +1 -1
- 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 +2 -2
- package/out/project/_/queue/__next._full.txt +2 -2
- 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 +1 -1
- 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 +2 -2
- package/out/project/_.html +1 -1
- package/out/project/_.txt +2 -2
- package/out/settings/__next._full.txt +2 -2
- 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 +1 -1
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +2 -2
- 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 +22 -1
- package/server/index.js +201 -14
- package/server/install-agentchattr.js +215 -0
- package/server/routes.js +65 -19
- package/out/_next/static/chunks/0ahp74n0wkel0.js +0 -1
- package/out/_next/static/chunks/0s8jbc4nxw6y6.css +0 -2
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_buildManifest.js +0 -0
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_ssgManifest.js +0 -0
package/server/index.js
CHANGED
|
@@ -131,22 +131,197 @@ const agentSessions = new Map();
|
|
|
131
131
|
// AgentChattr server processes — per-project (key = projectId)
|
|
132
132
|
const chattrProcesses = new Map();
|
|
133
133
|
|
|
134
|
+
// --- MCP auth proxy for Codex (can't pass headers via -c flag) ---
|
|
135
|
+
// Maps "project/agent" → { server, port }
|
|
136
|
+
const mcpProxies = new Map();
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Start a local HTTP proxy that forwards MCP requests with Bearer token.
|
|
140
|
+
* Returns a Promise that resolves to the proxy URL once listening.
|
|
141
|
+
*/
|
|
142
|
+
function startMcpProxy(projectId, agentId, upstreamUrl, token) {
|
|
143
|
+
const key = `${projectId}/${agentId}`;
|
|
144
|
+
const existing = mcpProxies.get(key);
|
|
145
|
+
if (existing) return Promise.resolve(`http://127.0.0.1:${existing.port}/mcp`);
|
|
146
|
+
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const proxyServer = http.createServer((req, res) => {
|
|
149
|
+
const parsedUrl = new URL(req.url, `http://127.0.0.1`);
|
|
150
|
+
const targetUrl = `${upstreamUrl}${parsedUrl.pathname}${parsedUrl.search}`;
|
|
151
|
+
const headers = { ...req.headers, host: new URL(upstreamUrl).host };
|
|
152
|
+
if (token) {
|
|
153
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
154
|
+
headers["x-agent-token"] = token;
|
|
155
|
+
}
|
|
156
|
+
delete headers["content-length"];
|
|
157
|
+
|
|
158
|
+
const chunks = [];
|
|
159
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
160
|
+
req.on("end", () => {
|
|
161
|
+
const body = Buffer.concat(chunks);
|
|
162
|
+
const proxyReq = (upstreamUrl.startsWith("https") ? require("https") : http).request(
|
|
163
|
+
targetUrl,
|
|
164
|
+
{ method: req.method, headers: { ...headers, "content-length": body.length } },
|
|
165
|
+
(proxyRes) => {
|
|
166
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
167
|
+
proxyRes.pipe(res);
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
proxyReq.on("error", (err) => {
|
|
171
|
+
res.writeHead(502);
|
|
172
|
+
res.end(`Proxy error: ${err.message}`);
|
|
173
|
+
});
|
|
174
|
+
proxyReq.end(body);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
proxyServer.on("error", (err) => reject(err));
|
|
179
|
+
proxyServer.listen(0, "127.0.0.1", () => {
|
|
180
|
+
const port = proxyServer.address().port;
|
|
181
|
+
mcpProxies.set(key, { server: proxyServer, port });
|
|
182
|
+
resolve(`http://127.0.0.1:${port}/mcp`);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stopMcpProxy(projectId, agentId) {
|
|
188
|
+
const key = `${projectId}/${agentId}`;
|
|
189
|
+
const proxy = mcpProxies.get(key);
|
|
190
|
+
if (proxy) {
|
|
191
|
+
try { proxy.server.close(); } catch {}
|
|
192
|
+
mcpProxies.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Permission bypass flags per CLI ---
|
|
197
|
+
const PERMISSION_FLAGS = {
|
|
198
|
+
claude: ["--dangerously-skip-permissions"],
|
|
199
|
+
codex: ["--dangerously-bypass-approvals-and-sandbox"],
|
|
200
|
+
gemini: ["--yolo"],
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// --- MCP config generation & agent launch args ---
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Generate a per-agent MCP config file for Claude (--mcp-config).
|
|
207
|
+
* Returns the absolute path to the written JSON file.
|
|
208
|
+
*/
|
|
209
|
+
function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
|
|
210
|
+
const os = require("os");
|
|
211
|
+
const configDir = path.join(os.homedir(), ".quadwork", projectId);
|
|
212
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
213
|
+
const filePath = path.join(configDir, `mcp-${agentId}.json`);
|
|
214
|
+
const url = `http://127.0.0.1:${mcpHttpPort}/mcp`;
|
|
215
|
+
const config = {
|
|
216
|
+
mcpServers: {
|
|
217
|
+
agentchattr: {
|
|
218
|
+
type: "http",
|
|
219
|
+
url,
|
|
220
|
+
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
|
225
|
+
return filePath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build extra launch args for an agent (permission flags + MCP injection).
|
|
230
|
+
* Async because Codex proxy_flag mode needs to await proxy startup.
|
|
231
|
+
*/
|
|
232
|
+
async function buildAgentArgs(projectId, agentId) {
|
|
233
|
+
const cfg = readConfig();
|
|
234
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
235
|
+
if (!project) return [];
|
|
236
|
+
|
|
237
|
+
const agentCfg = project.agents?.[agentId] || {};
|
|
238
|
+
const command = agentCfg.command || "claude";
|
|
239
|
+
const cliBase = command.split("/").pop().split(" ")[0]; // extract base CLI name
|
|
240
|
+
const args = [];
|
|
241
|
+
|
|
242
|
+
// Permission bypass flags
|
|
243
|
+
if (agentCfg.auto_approve !== false) {
|
|
244
|
+
const flags = PERMISSION_FLAGS[cliBase];
|
|
245
|
+
if (flags) args.push(...flags);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MCP config injection
|
|
249
|
+
const mcpHttpPort = project.mcp_http_port;
|
|
250
|
+
const token = project.agentchattr_token;
|
|
251
|
+
if (mcpHttpPort) {
|
|
252
|
+
const injectMode = agentCfg.mcp_inject || (cliBase === "codex" ? "proxy_flag" : cliBase === "gemini" ? "env" : "flag");
|
|
253
|
+
if (injectMode === "flag") {
|
|
254
|
+
// Claude/Kimi: write config file, pass --mcp-config
|
|
255
|
+
const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, token);
|
|
256
|
+
const flag = agentCfg.mcp_flag || "--mcp-config";
|
|
257
|
+
args.push(flag, mcpConfigPath);
|
|
258
|
+
} else if (injectMode === "proxy_flag") {
|
|
259
|
+
// Codex: start local auth proxy, pass proxy URL via -c flag
|
|
260
|
+
const upstreamUrl = `http://127.0.0.1:${mcpHttpPort}`;
|
|
261
|
+
const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, token);
|
|
262
|
+
if (proxyUrl) {
|
|
263
|
+
args.push("-c", `mcp_servers.agentchattr.url="${proxyUrl}"`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return args;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build extra env vars for an agent (MCP injection via env for Gemini).
|
|
273
|
+
*/
|
|
274
|
+
function buildAgentEnv(projectId, agentId) {
|
|
275
|
+
const cfg = readConfig();
|
|
276
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
277
|
+
if (!project) return {};
|
|
278
|
+
|
|
279
|
+
const agentCfg = project.agents?.[agentId] || {};
|
|
280
|
+
const command = agentCfg.command || "claude";
|
|
281
|
+
const cliBase = command.split("/").pop().split(" ")[0];
|
|
282
|
+
const env = {};
|
|
283
|
+
|
|
284
|
+
// Gemini: inject MCP via env var
|
|
285
|
+
if (cliBase === "gemini" && project.mcp_http_port) {
|
|
286
|
+
const os = require("os");
|
|
287
|
+
const configDir = path.join(os.homedir(), ".quadwork", projectId);
|
|
288
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
289
|
+
const settingsPath = path.join(configDir, `mcp-${agentId}-settings.json`);
|
|
290
|
+
const url = `http://127.0.0.1:${project.mcp_http_port}/mcp`;
|
|
291
|
+
const settings = {
|
|
292
|
+
mcpServers: {
|
|
293
|
+
agentchattr: {
|
|
294
|
+
type: "http",
|
|
295
|
+
url,
|
|
296
|
+
...(project.agentchattr_token ? { headers: { Authorization: `Bearer ${project.agentchattr_token}` } } : {}),
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
301
|
+
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = settingsPath;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return env;
|
|
305
|
+
}
|
|
306
|
+
|
|
134
307
|
// Helper: spawn a PTY for a project/agent and register in agentSessions
|
|
135
|
-
function spawnAgentPty(project, agent) {
|
|
308
|
+
async function spawnAgentPty(project, agent) {
|
|
136
309
|
const key = `${project}/${agent}`;
|
|
137
310
|
|
|
138
311
|
const cwd = resolveAgentCwd(project, agent);
|
|
139
312
|
if (!cwd) return { ok: false, error: `Unknown agent: ${key}` };
|
|
140
313
|
|
|
141
314
|
const command = resolveAgentCommand(project, agent) || (process.env.SHELL || "/bin/zsh");
|
|
315
|
+
const args = await buildAgentArgs(project, agent);
|
|
316
|
+
const extraEnv = buildAgentEnv(project, agent);
|
|
142
317
|
|
|
143
318
|
try {
|
|
144
|
-
const term = pty.spawn(command,
|
|
319
|
+
const term = pty.spawn(command, args, {
|
|
145
320
|
name: "xterm-256color",
|
|
146
321
|
cols: 120,
|
|
147
322
|
rows: 30,
|
|
148
323
|
cwd,
|
|
149
|
-
env: process.env,
|
|
324
|
+
env: { ...process.env, ...extraEnv },
|
|
150
325
|
});
|
|
151
326
|
|
|
152
327
|
const session = { projectId: project, agentId: agent, term, ws: null, state: "running", error: null };
|
|
@@ -190,6 +365,9 @@ function stopAgentSession(key) {
|
|
|
190
365
|
session.ws = null;
|
|
191
366
|
session.state = "stopped";
|
|
192
367
|
session.error = null;
|
|
368
|
+
// Clean up MCP auth proxy if running
|
|
369
|
+
const [projectId, agentId] = key.split("/");
|
|
370
|
+
if (projectId && agentId) stopMcpProxy(projectId, agentId);
|
|
193
371
|
}
|
|
194
372
|
|
|
195
373
|
app.get("/api/agents", (_req, res) => {
|
|
@@ -220,12 +398,21 @@ async function handleAgentChattr(req, res) {
|
|
|
220
398
|
const { url: chattrUrl } = resolveProjectChattr(projectId);
|
|
221
399
|
const chattrPort = new URL(chattrUrl).port || "8300";
|
|
222
400
|
|
|
223
|
-
// Find per-project config.toml
|
|
401
|
+
// Find per-project config.toml. Phase 2E / #181: prefer the
|
|
402
|
+
// per-project AgentChattr clone ROOT (where the web/CLI wizards now
|
|
403
|
+
// write it as of #184/#185 — and where run.py actually reads it from).
|
|
404
|
+
// Fall back to the legacy <working_dir>/agentchattr/config.toml for
|
|
405
|
+
// v1 setups that haven't been migrated yet (#188).
|
|
224
406
|
const cfg = readConfig();
|
|
225
407
|
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
408
|
+
const { dir: resolvedAcDir } = resolveProjectChattr(projectId);
|
|
409
|
+
let projectConfigToml = null;
|
|
410
|
+
if (resolvedAcDir && fs.existsSync(path.join(resolvedAcDir, "config.toml"))) {
|
|
411
|
+
projectConfigToml = path.join(resolvedAcDir, "config.toml");
|
|
412
|
+
} else if (project?.working_dir) {
|
|
413
|
+
const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
|
|
414
|
+
if (fs.existsSync(legacyToml)) projectConfigToml = legacyToml;
|
|
415
|
+
}
|
|
229
416
|
|
|
230
417
|
function getProc() {
|
|
231
418
|
return chattrProcesses.get(projectId) || { process: null, state: "stopped", error: null };
|
|
@@ -423,7 +610,7 @@ app.post("/api/agents/:project/reset", async (req, res) => {
|
|
|
423
610
|
|
|
424
611
|
// --- Lifecycle: start spawns PTY (visible in terminal panel) ---
|
|
425
612
|
|
|
426
|
-
app.post("/api/agents/:project/:agent/start", (req, res) => {
|
|
613
|
+
app.post("/api/agents/:project/:agent/start", async (req, res) => {
|
|
427
614
|
const { project, agent } = req.params;
|
|
428
615
|
const key = `${project}/${agent}`;
|
|
429
616
|
|
|
@@ -432,7 +619,7 @@ app.post("/api/agents/:project/:agent/start", (req, res) => {
|
|
|
432
619
|
return res.json({ ok: true, state: "running", message: "Already running" });
|
|
433
620
|
}
|
|
434
621
|
|
|
435
|
-
const result = spawnAgentPty(project, agent);
|
|
622
|
+
const result = await spawnAgentPty(project, agent);
|
|
436
623
|
if (result.ok) {
|
|
437
624
|
res.json({ ok: true, state: "running", pid: result.pid });
|
|
438
625
|
} else {
|
|
@@ -451,14 +638,14 @@ app.post("/api/agents/:project/:agent/stop", (req, res) => {
|
|
|
451
638
|
|
|
452
639
|
// --- Lifecycle: restart ---
|
|
453
640
|
|
|
454
|
-
app.post("/api/agents/:project/:agent/restart", (req, res) => {
|
|
641
|
+
app.post("/api/agents/:project/:agent/restart", async (req, res) => {
|
|
455
642
|
const { project, agent } = req.params;
|
|
456
643
|
const key = `${project}/${agent}`;
|
|
457
644
|
|
|
458
645
|
stopAgentSession(key);
|
|
459
646
|
|
|
460
|
-
setTimeout(() => {
|
|
461
|
-
const result = spawnAgentPty(project, agent);
|
|
647
|
+
setTimeout(async () => {
|
|
648
|
+
const result = await spawnAgentPty(project, agent);
|
|
462
649
|
if (result.ok) {
|
|
463
650
|
res.json({ ok: true, state: "running", pid: result.pid });
|
|
464
651
|
} else {
|
|
@@ -708,7 +895,7 @@ app.use((req, res, next) => {
|
|
|
708
895
|
|
|
709
896
|
const wss = new WebSocketServer({ server, path: "/ws/terminal" });
|
|
710
897
|
|
|
711
|
-
wss.on("connection", (ws, req) => {
|
|
898
|
+
wss.on("connection", async (ws, req) => {
|
|
712
899
|
const params = new URL(req.url, `http://localhost:${PORT}`).searchParams;
|
|
713
900
|
const projectId = params.get("project");
|
|
714
901
|
const agentId = params.get("agent");
|
|
@@ -723,7 +910,7 @@ wss.on("connection", (ws, req) => {
|
|
|
723
910
|
|
|
724
911
|
// If no active PTY, spawn one
|
|
725
912
|
if (!session || !session.term) {
|
|
726
|
-
const result = spawnAgentPty(projectId, agentId);
|
|
913
|
+
const result = await spawnAgentPty(projectId, agentId);
|
|
727
914
|
if (!result.ok) {
|
|
728
915
|
ws.close(1011, "pty-spawn-failed");
|
|
729
916
|
return;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Shared AgentChattr install helper used by both the CLI wizard
|
|
2
|
+
// (bin/quadwork.js) and the web setup route (server/routes.js).
|
|
3
|
+
//
|
|
4
|
+
// Extracted as part of #185 (Phase 2D of master #181) so the web UI
|
|
5
|
+
// can clone AgentChattr per-project without duplicating the locking,
|
|
6
|
+
// idempotency, and cleanup-safety logic that #183 + #187 added.
|
|
7
|
+
//
|
|
8
|
+
// Public API:
|
|
9
|
+
// findAgentChattr(dir) → string|null
|
|
10
|
+
// installAgentChattr(dir) → string|null (.lastError on failure)
|
|
11
|
+
// chattrSpawnArgs(dir, extraArgs) → { command, spawnArgs, cwd } | null
|
|
12
|
+
// AGENTCHATTR_REPO → upstream URL constant
|
|
13
|
+
//
|
|
14
|
+
// Self-contained — depends only on Node built-ins so it's safe to require
|
|
15
|
+
// from anywhere in the project (CLI bin, server routes, future tests).
|
|
16
|
+
|
|
17
|
+
const { execSync } = require("child_process");
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
const AGENTCHATTR_REPO = "https://github.com/bcurts/agentchattr.git";
|
|
22
|
+
|
|
23
|
+
// Stale-lock thresholds for installAgentChattr().
|
|
24
|
+
// Lock files older than this OR whose owning pid is no longer alive are
|
|
25
|
+
// treated as crashed and reclaimed. Tuned to comfortably exceed the longest
|
|
26
|
+
// step (pip install of agentchattr requirements, ~120s timeout).
|
|
27
|
+
const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
|
|
28
|
+
const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
|
|
29
|
+
const INSTALL_LOCK_POLL_MS = 500;
|
|
30
|
+
|
|
31
|
+
function _run(cmd, opts = {}) {
|
|
32
|
+
try { return execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts }).trim(); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _isPidAlive(pid) {
|
|
37
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
38
|
+
try { process.kill(pid, 0); return true; }
|
|
39
|
+
catch (e) { return e.code === "EPERM"; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _readLock(lockFile) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(lockFile, "utf-8").trim();
|
|
45
|
+
const [pidStr, tsStr] = raw.split(":");
|
|
46
|
+
return { pid: parseInt(pidStr, 10), ts: parseInt(tsStr, 10) || 0 };
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _isLockStale(lockFile) {
|
|
51
|
+
const info = _readLock(lockFile);
|
|
52
|
+
if (!info) return true;
|
|
53
|
+
if (Date.now() - info.ts > INSTALL_LOCK_STALE_MS) return true;
|
|
54
|
+
if (!_isPidAlive(info.pid)) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if AgentChattr is fully installed (cloned + venv ready) at `dir`.
|
|
60
|
+
* Returns the directory path if both run.py and .venv/bin/python exist, or null.
|
|
61
|
+
* Caller must pass an explicit `dir` — there is no default.
|
|
62
|
+
*/
|
|
63
|
+
function findAgentChattr(dir) {
|
|
64
|
+
if (!dir) return null;
|
|
65
|
+
if (fs.existsSync(path.join(dir, "run.py")) && fs.existsSync(path.join(dir, ".venv", "bin", "python"))) return dir;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clone AgentChattr and set up its venv at `dir`. Idempotent — safe to
|
|
71
|
+
* re-run on the same path, and safe to call repeatedly with different
|
|
72
|
+
* paths in the same process. Designed to support per-project clones (#181).
|
|
73
|
+
*
|
|
74
|
+
* Behavior on re-run:
|
|
75
|
+
* - Fully-installed path → no-op (skips clone, skips venv create, skips pip)
|
|
76
|
+
* - Missing run.py → clones (only after refusing to overwrite
|
|
77
|
+
* unrelated content; see safety rules below)
|
|
78
|
+
* - Missing venv → creates venv and reinstalls requirements
|
|
79
|
+
*
|
|
80
|
+
* Safety rules — never accidentally clean up unrelated directories:
|
|
81
|
+
* - Empty dir → safe to remove
|
|
82
|
+
* - Git repo whose origin contains "agentchattr" → safe to remove
|
|
83
|
+
* - Anything else → refuse, return null
|
|
84
|
+
*
|
|
85
|
+
* Concurrency: a per-target lock at `${dir}.install.lock` serializes
|
|
86
|
+
* concurrent installs to the same path. Stale locks (dead pid OR older
|
|
87
|
+
* than 10 min) are reclaimed atomically via rename → unlink. Live
|
|
88
|
+
* peers are polled for up to 30s; after that, returns null with a
|
|
89
|
+
* clear lastError.
|
|
90
|
+
*
|
|
91
|
+
* On failure, returns null and stores a human-readable reason on
|
|
92
|
+
* `installAgentChattr.lastError` so callers can surface it without
|
|
93
|
+
* changing the return shape.
|
|
94
|
+
*/
|
|
95
|
+
function installAgentChattr(dir) {
|
|
96
|
+
if (!dir) {
|
|
97
|
+
installAgentChattr.lastError = "installAgentChattr: dir is required";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
installAgentChattr.lastError = null;
|
|
101
|
+
const setError = (msg) => { installAgentChattr.lastError = msg; return null; };
|
|
102
|
+
|
|
103
|
+
// --- Per-target lock ---
|
|
104
|
+
const lockFile = `${dir}.install.lock`;
|
|
105
|
+
try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
|
|
106
|
+
catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
|
|
107
|
+
|
|
108
|
+
let acquired = false;
|
|
109
|
+
const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
|
|
110
|
+
while (!acquired) {
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
|
|
113
|
+
acquired = true;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
|
|
116
|
+
if (_isLockStale(lockFile)) {
|
|
117
|
+
const sideline = `${lockFile}.stale.${process.pid}.${Date.now()}`;
|
|
118
|
+
try {
|
|
119
|
+
fs.renameSync(lockFile, sideline);
|
|
120
|
+
try { fs.unlinkSync(sideline); } catch {}
|
|
121
|
+
} catch (renameErr) {
|
|
122
|
+
if (renameErr.code !== "ENOENT") {
|
|
123
|
+
return setError(`Cannot reclaim stale lock ${lockFile}: ${renameErr.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (Date.now() >= deadline) {
|
|
129
|
+
const info = _readLock(lockFile) || { pid: "?", ts: 0 };
|
|
130
|
+
return setError(`Another install is in progress at ${dir} (pid ${info.pid}); timed out after ${INSTALL_LOCK_WAIT_TOTAL_MS}ms. Re-run after it finishes, or remove ${lockFile} if stale.`);
|
|
131
|
+
}
|
|
132
|
+
try { execSync(`sleep ${INSTALL_LOCK_POLL_MS / 1000}`); }
|
|
133
|
+
catch { /* sleep interrupted; loop will recheck */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return _installAgentChattrLocked(dir, setError);
|
|
139
|
+
} finally {
|
|
140
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
installAgentChattr.lastError = null;
|
|
144
|
+
|
|
145
|
+
function _installAgentChattrLocked(dir, setError) {
|
|
146
|
+
const runPy = path.join(dir, "run.py");
|
|
147
|
+
const venvPython = path.join(dir, ".venv", "bin", "python");
|
|
148
|
+
let venvJustCreated = false;
|
|
149
|
+
|
|
150
|
+
// 1. Clone if run.py is missing.
|
|
151
|
+
if (!fs.existsSync(runPy)) {
|
|
152
|
+
if (fs.existsSync(dir)) {
|
|
153
|
+
let entries;
|
|
154
|
+
try { entries = fs.readdirSync(dir); }
|
|
155
|
+
catch (e) { return setError(`Cannot read ${dir}: ${e.message}`); }
|
|
156
|
+
const isEmpty = entries.length === 0;
|
|
157
|
+
if (isEmpty) {
|
|
158
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
159
|
+
catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
|
|
160
|
+
} else if (fs.existsSync(path.join(dir, ".git"))) {
|
|
161
|
+
const remote = _run(`git -C "${dir}" remote get-url origin 2>/dev/null`);
|
|
162
|
+
if (remote && remote.includes("agentchattr")) {
|
|
163
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
164
|
+
catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
|
|
165
|
+
} else {
|
|
166
|
+
return setError(`Refusing to overwrite ${dir}: contains a non-AgentChattr git repo`);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
|
|
173
|
+
catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
|
|
174
|
+
const cloneResult = _run(`git clone "${AGENTCHATTR_REPO}" "${dir}" 2>&1`, { timeout: 60000 });
|
|
175
|
+
if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
|
|
176
|
+
if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. Create venv if missing.
|
|
180
|
+
if (!fs.existsSync(venvPython)) {
|
|
181
|
+
const venvResult = _run(`python3 -m venv "${path.join(dir, ".venv")}" 2>&1`, { timeout: 60000 });
|
|
182
|
+
if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
|
|
183
|
+
if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
|
|
184
|
+
venvJustCreated = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Install requirements only when the venv was just (re)created.
|
|
188
|
+
if (venvJustCreated) {
|
|
189
|
+
const reqFile = path.join(dir, "requirements.txt");
|
|
190
|
+
if (fs.existsSync(reqFile)) {
|
|
191
|
+
const pipResult = _run(`"${venvPython}" -m pip install -r "${reqFile}" 2>&1`, { timeout: 120000 });
|
|
192
|
+
if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return dir;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get spawn args for launching AgentChattr from its cloned directory.
|
|
200
|
+
* Returns { command, spawnArgs, cwd } or null if not fully installed.
|
|
201
|
+
* Requires .venv/bin/python — never falls back to bare python3.
|
|
202
|
+
*/
|
|
203
|
+
function chattrSpawnArgs(dir, extraArgs) {
|
|
204
|
+
if (!dir) return null;
|
|
205
|
+
const venvPython = path.join(dir, ".venv", "bin", "python");
|
|
206
|
+
if (!fs.existsSync(path.join(dir, "run.py")) || !fs.existsSync(venvPython)) return null;
|
|
207
|
+
return { command: venvPython, spawnArgs: ["run.py", ...(extraArgs || [])], cwd: dir };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
AGENTCHATTR_REPO,
|
|
212
|
+
findAgentChattr,
|
|
213
|
+
installAgentChattr,
|
|
214
|
+
chattrSpawnArgs,
|
|
215
|
+
};
|
package/server/routes.js
CHANGED
|
@@ -69,6 +69,7 @@ router.put("/api/config", (req, res) => {
|
|
|
69
69
|
// ─── Chat (AgentChattr proxy) ──────────────────────────────────────────────
|
|
70
70
|
|
|
71
71
|
const { resolveProjectChattr } = require("./config");
|
|
72
|
+
const { installAgentChattr, findAgentChattr } = require("./install-agentchattr");
|
|
72
73
|
|
|
73
74
|
function getChattrConfig(projectId) {
|
|
74
75
|
const resolved = resolveProjectChattr(projectId);
|
|
@@ -590,21 +591,26 @@ router.post("/api/setup", (req, res) => {
|
|
|
590
591
|
const wtDir = path.join(parentDir, `${dirName}-${agent}`);
|
|
591
592
|
if (!fs.existsSync(wtDir)) continue;
|
|
592
593
|
|
|
593
|
-
// AGENTS.md —
|
|
594
|
+
// AGENTS.md — always (re)write from template so role definitions
|
|
595
|
+
// stay in sync with templates/seeds/ on every project (re)creation.
|
|
596
|
+
// Previously this was guarded by `!exists`, so if a worktree already
|
|
597
|
+
// had any AGENTS.md (stale, hand-edited, or empty) it was preserved
|
|
598
|
+
// forever and agents could launch with no/outdated role definition.
|
|
594
599
|
const agentsMd = path.join(wtDir, "AGENTS.md");
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
// Fallback stub if template missing
|
|
604
|
-
fs.writeFileSync(agentsMd, `# ${dirName} — ${agent.charAt(0).toUpperCase() + agent.slice(1)} Agent\n\nRepo: ${body.repo}\nRole: ${agent === "head" ? "Owner" : agent.startsWith("reviewer") ? "Reviewer" : "Builder"}\n`);
|
|
605
|
-
}
|
|
606
|
-
seeded.push(`${agent}/AGENTS.md`);
|
|
600
|
+
const seedSrc = path.join(TEMPLATES_DIR, "seeds", `${agent}.AGENTS.md`);
|
|
601
|
+
if (!fs.existsSync(seedSrc)) {
|
|
602
|
+
// Hard fail: missing seed means role is undefined. Better to surface
|
|
603
|
+
// the error than silently write a generic stub.
|
|
604
|
+
return res.json({
|
|
605
|
+
ok: false,
|
|
606
|
+
error: `Missing seed template: templates/seeds/${agent}.AGENTS.md`,
|
|
607
|
+
});
|
|
607
608
|
}
|
|
609
|
+
let agentsContent = fs.readFileSync(seedSrc, "utf-8");
|
|
610
|
+
agentsContent = agentsContent.replace(/\{\{reviewer_github_user\}\}/g, reviewerUser);
|
|
611
|
+
agentsContent = agentsContent.replace(/\{\{reviewer_token_path\}\}/g, reviewerTokenPath);
|
|
612
|
+
fs.writeFileSync(agentsMd, agentsContent);
|
|
613
|
+
seeded.push(`${agent}/AGENTS.md`);
|
|
608
614
|
|
|
609
615
|
// CLAUDE.md — use template with placeholder substitution (matches CLI)
|
|
610
616
|
const claudeMd = path.join(wtDir, "CLAUDE.md");
|
|
@@ -645,9 +651,28 @@ router.post("/api/setup", (req, res) => {
|
|
|
645
651
|
const parentDir = path.dirname(workingDir);
|
|
646
652
|
const backends = body.backends;
|
|
647
653
|
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
654
|
+
// Phase 2D / #181: config.toml lives at the per-project AgentChattr
|
|
655
|
+
// clone ROOT (~/.quadwork/{id}/agentchattr/), not inside the user's
|
|
656
|
+
// project working_dir. AgentChattr's run.py loads ROOT/config.toml
|
|
657
|
+
// and ignores --config, so the toml has to be at the same path the
|
|
658
|
+
// clone lives at. Same path matches what writeQuadWorkConfig()
|
|
659
|
+
// persists in agentchattr_dir (#182) and what the CLI wizard
|
|
660
|
+
// writes (#184).
|
|
661
|
+
//
|
|
662
|
+
// We install the clone *here*, before writing config.toml. The
|
|
663
|
+
// install must run first because installAgentChattr() refuses to
|
|
664
|
+
// overwrite a non-empty directory it doesn't recognize — if we
|
|
665
|
+
// mkdir + write config.toml first, the subsequent install in
|
|
666
|
+
// add-config would see "unrelated content" and reject the dir,
|
|
667
|
+
// breaking first-run web project creation (t2a's review of #195).
|
|
668
|
+
const projectConfigDir = path.join(CONFIG_DIR, dirName, "agentchattr");
|
|
669
|
+
if (!findAgentChattr(projectConfigDir)) {
|
|
670
|
+
const installResult = installAgentChattr(projectConfigDir);
|
|
671
|
+
if (!installResult) {
|
|
672
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
673
|
+
return res.json({ ok: false, error: `AgentChattr install failed at ${projectConfigDir}: ${reason}` });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
651
676
|
const dataDir = path.join(projectConfigDir, "data");
|
|
652
677
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
653
678
|
const tomlPath = path.join(projectConfigDir, "config.toml");
|
|
@@ -697,19 +722,25 @@ router.post("/api/setup", (req, res) => {
|
|
|
697
722
|
}
|
|
698
723
|
case "add-config": {
|
|
699
724
|
const { id, name, repo, workingDir, backends } = body;
|
|
725
|
+
const autoApprove = body.auto_approve !== false; // default true
|
|
700
726
|
// Use directory basename for sibling paths (matches CLI wizard)
|
|
701
727
|
const dirName = path.basename(workingDir);
|
|
702
728
|
const parentDir = path.dirname(workingDir);
|
|
703
729
|
let cfg;
|
|
704
730
|
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
|
|
705
|
-
catch { cfg = { port: 8400, agentchattr_url: "http://127.0.0.1:8300", projects: [] }; }
|
|
731
|
+
catch { cfg = { port: 8400, agentchattr_url: "http://127.0.0.1:8300", agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"), projects: [] }; }
|
|
706
732
|
if (cfg.projects.some((p) => p.id === id)) return res.json({ ok: true, message: "Project already in config" });
|
|
707
|
-
// Match CLI wizard agent structure: { cwd, command }
|
|
733
|
+
// Match CLI wizard agent structure: { cwd, command, auto_approve, mcp_inject }
|
|
708
734
|
const agents = {};
|
|
709
735
|
for (const agentId of ["head", "reviewer1", "reviewer2", "dev"]) {
|
|
736
|
+
const cmd = (backends && backends[agentId]) || "claude";
|
|
737
|
+
const cliBase = cmd.split("/").pop().split(" ")[0];
|
|
738
|
+
const injectMode = cliBase === "codex" ? "proxy_flag" : cliBase === "gemini" ? "env" : "flag";
|
|
710
739
|
agents[agentId] = {
|
|
711
740
|
cwd: path.join(parentDir, `${dirName}-${agentId}`),
|
|
712
|
-
command:
|
|
741
|
+
command: cmd,
|
|
742
|
+
auto_approve: autoApprove,
|
|
743
|
+
mcp_inject: injectMode,
|
|
713
744
|
};
|
|
714
745
|
}
|
|
715
746
|
// Use pre-assigned ports/token from agentchattr-config step if provided,
|
|
@@ -732,12 +763,27 @@ router.post("/api/setup", (req, res) => {
|
|
|
732
763
|
while (usedMcpPorts.has(mcp_sse_port)) mcp_sse_port++;
|
|
733
764
|
}
|
|
734
765
|
if (!agentchattr_token) agentchattr_token = crypto.randomBytes(16).toString("hex");
|
|
766
|
+
|
|
767
|
+
// Phase 2D / #181: clone AgentChattr per-project before saving config.
|
|
768
|
+
// The path here must match the one written into agentchattr_dir below
|
|
769
|
+
// and the one agentchattr-config writes config.toml into.
|
|
770
|
+
const perProjectDir = path.join(CONFIG_DIR, id, "agentchattr");
|
|
771
|
+
if (!findAgentChattr(perProjectDir)) {
|
|
772
|
+
const installResult = installAgentChattr(perProjectDir);
|
|
773
|
+
if (!installResult) {
|
|
774
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
775
|
+
return res.json({ ok: false, error: `AgentChattr install failed at ${perProjectDir}: ${reason}` });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
735
779
|
cfg.projects.push({
|
|
736
780
|
id, name, repo, working_dir: workingDir, agents,
|
|
737
781
|
agentchattr_url: `http://127.0.0.1:${chattrPort}`,
|
|
738
782
|
agentchattr_token,
|
|
739
783
|
mcp_http_port,
|
|
740
784
|
mcp_sse_port,
|
|
785
|
+
// Per-project AgentChattr clone path (Option B / #181).
|
|
786
|
+
agentchattr_dir: perProjectDir,
|
|
741
787
|
});
|
|
742
788
|
const dir = path.dirname(CONFIG_PATH);
|
|
743
789
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|