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.
Files changed (83) hide show
  1. package/README.md +28 -0
  2. package/bin/quadwork.js +445 -53
  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/0caq73v0knw_w.js +1 -0
  10. package/out/_next/static/chunks/10b3c4k.q.yw..css +2 -0
  11. package/out/_not-found/__next._full.txt +2 -2
  12. package/out/_not-found/__next._head.txt +1 -1
  13. package/out/_not-found/__next._index.txt +2 -2
  14. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  15. package/out/_not-found/__next._not-found.txt +1 -1
  16. package/out/_not-found/__next._tree.txt +2 -2
  17. package/out/_not-found.html +1 -1
  18. package/out/_not-found.txt +2 -2
  19. package/out/app-shell/__next._full.txt +2 -2
  20. package/out/app-shell/__next._head.txt +1 -1
  21. package/out/app-shell/__next._index.txt +2 -2
  22. package/out/app-shell/__next._tree.txt +2 -2
  23. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  24. package/out/app-shell/__next.app-shell.txt +1 -1
  25. package/out/app-shell.html +1 -1
  26. package/out/app-shell.txt +2 -2
  27. package/out/index.html +1 -1
  28. package/out/index.txt +2 -2
  29. package/out/project/_/__next._full.txt +2 -2
  30. package/out/project/_/__next._head.txt +1 -1
  31. package/out/project/_/__next._index.txt +2 -2
  32. package/out/project/_/__next._tree.txt +2 -2
  33. package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
  34. package/out/project/_/__next.project.$d$id.txt +1 -1
  35. package/out/project/_/__next.project.txt +1 -1
  36. package/out/project/_/memory/__next._full.txt +2 -2
  37. package/out/project/_/memory/__next._head.txt +1 -1
  38. package/out/project/_/memory/__next._index.txt +2 -2
  39. package/out/project/_/memory/__next._tree.txt +2 -2
  40. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
  41. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  42. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  43. package/out/project/_/memory/__next.project.txt +1 -1
  44. package/out/project/_/memory.html +1 -1
  45. package/out/project/_/memory.txt +2 -2
  46. package/out/project/_/queue/__next._full.txt +2 -2
  47. package/out/project/_/queue/__next._head.txt +1 -1
  48. package/out/project/_/queue/__next._index.txt +2 -2
  49. package/out/project/_/queue/__next._tree.txt +2 -2
  50. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  51. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  52. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  53. package/out/project/_/queue/__next.project.txt +1 -1
  54. package/out/project/_/queue.html +1 -1
  55. package/out/project/_/queue.txt +2 -2
  56. package/out/project/_.html +1 -1
  57. package/out/project/_.txt +2 -2
  58. package/out/settings/__next._full.txt +2 -2
  59. package/out/settings/__next._head.txt +1 -1
  60. package/out/settings/__next._index.txt +2 -2
  61. package/out/settings/__next._tree.txt +2 -2
  62. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  63. package/out/settings/__next.settings.txt +1 -1
  64. package/out/settings.html +1 -1
  65. package/out/settings.txt +2 -2
  66. package/out/setup/__next._full.txt +3 -3
  67. package/out/setup/__next._head.txt +1 -1
  68. package/out/setup/__next._index.txt +2 -2
  69. package/out/setup/__next._tree.txt +2 -2
  70. package/out/setup/__next.setup.__PAGE__.txt +2 -2
  71. package/out/setup/__next.setup.txt +1 -1
  72. package/out/setup.html +1 -1
  73. package/out/setup.txt +3 -3
  74. package/package.json +1 -1
  75. package/server/config.js +22 -1
  76. package/server/index.js +201 -14
  77. package/server/install-agentchattr.js +215 -0
  78. package/server/routes.js +65 -19
  79. package/out/_next/static/chunks/0ahp74n0wkel0.js +0 -1
  80. package/out/_next/static/chunks/0s8jbc4nxw6y6.css +0 -2
  81. /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_buildManifest.js +0 -0
  82. /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_clientMiddlewareManifest.js +0 -0
  83. /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 (prefer project working_dir/agentchattr/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 projectConfigToml = project?.working_dir
227
- ? path.join(project.working_dir, "agentchattr", "config.toml")
228
- : null;
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 — use template with placeholder substitution (matches CLI)
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
- if (!fs.existsSync(agentsMd)) {
596
- const seedSrc = path.join(TEMPLATES_DIR, "seeds", `${agent}.AGENTS.md`);
597
- if (fs.existsSync(seedSrc)) {
598
- let content = fs.readFileSync(seedSrc, "utf-8");
599
- content = content.replace(/\{\{reviewer_github_user\}\}/g, reviewerUser);
600
- content = content.replace(/\{\{reviewer_token_path\}\}/g, reviewerTokenPath);
601
- fs.writeFileSync(agentsMd, content);
602
- } else {
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
- // Per-project: isolated config dir + data dir
649
- const projectConfigDir = path.join(workingDir, "agentchattr");
650
- fs.mkdirSync(projectConfigDir, { recursive: true });
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: (backends && backends[agentId]) || "claude",
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 });