quadwork 0.1.1 → 0.1.3

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 (86) hide show
  1. package/README.md +4 -4
  2. package/bin/quadwork.js +285 -94
  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/02ul7y114vj2f.js +13 -0
  10. package/out/_next/static/chunks/{0jsosmtclw5n5.js → 038g944ax83al.js} +1 -1
  11. package/out/_next/static/chunks/0gy_9ugdx7ueh.js +1 -0
  12. package/out/_next/static/chunks/0idtc5k0469of.js +1 -0
  13. package/out/_next/static/chunks/{03hi.hdp6l230.js → 0wda-2lcle8c4.js} +8 -8
  14. package/out/_next/static/chunks/0yxmvmvm1dx_d.css +2 -0
  15. package/out/_not-found/__next._full.txt +2 -2
  16. package/out/_not-found/__next._head.txt +1 -1
  17. package/out/_not-found/__next._index.txt +2 -2
  18. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  19. package/out/_not-found/__next._not-found.txt +1 -1
  20. package/out/_not-found/__next._tree.txt +2 -2
  21. package/out/_not-found.html +1 -1
  22. package/out/_not-found.txt +2 -2
  23. package/out/index.html +1 -1
  24. package/out/index.txt +2 -2
  25. package/out/project/_/__next._full.txt +3 -3
  26. package/out/project/_/__next._head.txt +1 -1
  27. package/out/project/_/__next._index.txt +2 -2
  28. package/out/project/_/__next._tree.txt +2 -2
  29. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
  30. package/out/project/_/__next.project.$d$id.txt +1 -1
  31. package/out/project/_/__next.project.txt +1 -1
  32. package/out/project/_/memory/__next._full.txt +3 -3
  33. package/out/project/_/memory/__next._head.txt +1 -1
  34. package/out/project/_/memory/__next._index.txt +2 -2
  35. package/out/project/_/memory/__next._tree.txt +2 -2
  36. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +2 -2
  37. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  38. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  39. package/out/project/_/memory/__next.project.txt +1 -1
  40. package/out/project/_/memory.html +1 -1
  41. package/out/project/_/memory.txt +3 -3
  42. package/out/project/_/queue/__next._full.txt +3 -3
  43. package/out/project/_/queue/__next._head.txt +1 -1
  44. package/out/project/_/queue/__next._index.txt +2 -2
  45. package/out/project/_/queue/__next._tree.txt +2 -2
  46. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +2 -2
  47. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  48. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  49. package/out/project/_/queue/__next.project.txt +1 -1
  50. package/out/project/_/queue.html +1 -1
  51. package/out/project/_/queue.txt +3 -3
  52. package/out/project/_.html +1 -1
  53. package/out/project/_.txt +3 -3
  54. package/out/settings/__next._full.txt +3 -3
  55. package/out/settings/__next._head.txt +1 -1
  56. package/out/settings/__next._index.txt +2 -2
  57. package/out/settings/__next._tree.txt +2 -2
  58. package/out/settings/__next.settings.__PAGE__.txt +2 -2
  59. package/out/settings/__next.settings.txt +1 -1
  60. package/out/settings.html +1 -1
  61. package/out/settings.txt +3 -3
  62. package/out/setup/__next._full.txt +3 -3
  63. package/out/setup/__next._head.txt +1 -1
  64. package/out/setup/__next._index.txt +2 -2
  65. package/out/setup/__next._tree.txt +2 -2
  66. package/out/setup/__next.setup.__PAGE__.txt +2 -2
  67. package/out/setup/__next.setup.txt +1 -1
  68. package/out/setup.html +1 -1
  69. package/out/setup.txt +3 -3
  70. package/package.json +1 -1
  71. package/server/config.js +42 -2
  72. package/server/index.js +103 -55
  73. package/server/routes.js +104 -66
  74. package/templates/CLAUDE.md +16 -17
  75. package/templates/config.toml +12 -12
  76. package/templates/seeds/{t3.AGENTS.md → dev.AGENTS.md} +19 -19
  77. package/templates/seeds/{t1.AGENTS.md → head.AGENTS.md} +18 -18
  78. package/templates/seeds/{t2b.AGENTS.md → reviewer1.AGENTS.md} +16 -16
  79. package/templates/seeds/{t2a.AGENTS.md → reviewer2.AGENTS.md} +16 -16
  80. package/out/_next/static/chunks/03yov._jigv17.js +0 -1
  81. package/out/_next/static/chunks/0iqqouh_3i5y5.js +0 -13
  82. package/out/_next/static/chunks/15kwal..m9r49.css +0 -2
  83. package/out/_next/static/chunks/17sk4qv6_d0co.js +0 -1
  84. /package/out/_next/static/{Swn5YST3ZJXv1zBHSCNFU → 91YUiFoMbLQ9sZW4uk45J}/_buildManifest.js +0 -0
  85. /package/out/_next/static/{Swn5YST3ZJXv1zBHSCNFU → 91YUiFoMbLQ9sZW4uk45J}/_clientMiddlewareManifest.js +0 -0
  86. /package/out/_next/static/{Swn5YST3ZJXv1zBHSCNFU → 91YUiFoMbLQ9sZW4uk45J}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -84,10 +84,10 @@ Config is stored at `~/.quadwork/config.json`:
84
84
  "repo": "owner/repo",
85
85
  "working_dir": "/path/to/project",
86
86
  "agents": {
87
- "t1": { "cwd": "/path/to/project-t1", "command": "claude" },
88
- "t2a": { "cwd": "/path/to/project-t2a", "command": "claude" },
89
- "t2b": { "cwd": "/path/to/project-t2b", "command": "claude" },
90
- "t3": { "cwd": "/path/to/project-t3", "command": "claude" }
87
+ "head": { "cwd": "/path/to/project-head", "command": "claude" },
88
+ "reviewer1": { "cwd": "/path/to/project-reviewer1", "command": "claude" },
89
+ "reviewer2": { "cwd": "/path/to/project-reviewer2", "command": "claude" },
90
+ "dev": { "cwd": "/path/to/project-dev", "command": "claude" }
91
91
  }
92
92
  }
93
93
  ]
package/bin/quadwork.js CHANGED
@@ -11,15 +11,45 @@ const readline = require("readline");
11
11
  const CONFIG_DIR = path.join(os.homedir(), ".quadwork");
12
12
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
13
13
  const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
14
- const AGENTS = ["t1", "t2a", "t2b", "t3"];
15
-
16
- // ─── Helpers ────────────────────────────────────────────────────────────────
17
-
18
- function log(msg) { console.log(` ${msg}`); }
19
- function ok(msg) { console.log(` ✓ ${msg}`); }
20
- function warn(msg) { console.log(` ${msg}`); }
21
- function fail(msg) { console.error(` ${msg}`); }
22
- function header(msg) { console.log(`\n── ${msg} ${"".repeat(Math.max(0, 58 - msg.length))}\n`); }
14
+ const AGENTS = ["head", "reviewer1", "reviewer2", "dev"];
15
+
16
+ // ─── ANSI Helpers ──────────────────────────────────────────────────────────
17
+
18
+ const isTTY = process.stdout.isTTY;
19
+ const c = isTTY ? {
20
+ reset: "\x1b[0m",
21
+ bold: "\x1b[1m",
22
+ dim: "\x1b[2m",
23
+ green: "\x1b[32m",
24
+ yellow: "\x1b[33m",
25
+ red: "\x1b[31m",
26
+ cyan: "\x1b[36m",
27
+ white: "\x1b[37m",
28
+ } : { reset: "", bold: "", dim: "", green: "", yellow: "", red: "", cyan: "", white: "" };
29
+
30
+ function log(msg) { console.log(` ${c.dim}${msg}${c.reset}`); }
31
+ function ok(msg) { console.log(` ${c.green}✓${c.reset} ${msg}`); }
32
+ function warn(msg) { console.log(` ${c.yellow}⚠ ${msg}${c.reset}`); }
33
+ function fail(msg) { console.error(` ${c.red}✗ ${msg}${c.reset}`); }
34
+ function header(msg) { console.log(`\n ${c.cyan}${c.bold}┌─ ${msg} ${"─".repeat(Math.max(0, 54 - msg.length))}┐${c.reset}\n`); }
35
+
36
+ function spinner(msg) {
37
+ if (!isTTY) {
38
+ console.log(` ${msg}`);
39
+ return { stop(result) { console.log(` ${result ? "✓" : "✗"} ${msg}`); } };
40
+ }
41
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
42
+ let i = 0;
43
+ const id = setInterval(() => {
44
+ process.stdout.write(`\r ${c.cyan}${frames[i++ % frames.length]}${c.reset} ${msg}`);
45
+ }, 80);
46
+ return {
47
+ stop(result) {
48
+ clearInterval(id);
49
+ process.stdout.write(`\r ${result ? `${c.green}✓${c.reset} ${msg}` : `${c.red}✗${c.reset} ${msg}`}${" ".repeat(10)}\n`);
50
+ },
51
+ };
52
+ }
23
53
 
24
54
  function run(cmd, opts = {}) {
25
55
  try {
@@ -35,26 +65,91 @@ function which(cmd) {
35
65
 
36
66
  function ask(rl, question, defaultVal) {
37
67
  return new Promise((resolve) => {
38
- const suffix = defaultVal ? ` (${defaultVal})` : "";
39
- rl.question(` ${question}${suffix}: `, (answer) => {
68
+ const suffix = defaultVal ? ` ${c.dim}[${defaultVal}]${c.reset}` : "";
69
+ rl.question(` ${c.bold}${question}${c.reset}${suffix}${c.cyan} > ${c.reset}`, (answer) => {
40
70
  resolve(answer.trim() || defaultVal || "");
41
71
  });
42
72
  });
43
73
  }
44
74
 
75
+ function askSecret(rl, question) {
76
+ return new Promise((resolve) => {
77
+ const stdin = process.stdin;
78
+ const stdout = process.stdout;
79
+ stdout.write(` ${c.bold}${question}${c.reset}${c.cyan} > ${c.reset}`);
80
+ let secret = "";
81
+ const wasRaw = stdin.isRaw;
82
+ stdin.setRawMode(true);
83
+ stdin.resume();
84
+ const onData = (ch) => {
85
+ // Iterate per character to handle pasted multi-char input
86
+ const str = ch.toString("utf-8");
87
+ for (const c of str) {
88
+ if (c === "\n" || c === "\r") {
89
+ stdin.setRawMode(wasRaw || false);
90
+ stdin.removeListener("data", onData);
91
+ stdout.write("\n");
92
+ resolve(secret);
93
+ return;
94
+ } else if (c === "\u007F" || c === "\b") {
95
+ if (secret.length > 0) {
96
+ secret = secret.slice(0, -1);
97
+ stdout.write("\b \b");
98
+ }
99
+ } else if (c === "\u0003") {
100
+ process.exit(1);
101
+ } else if (c >= " ") {
102
+ secret += c;
103
+ stdout.write("*");
104
+ }
105
+ }
106
+ };
107
+ stdin.on("data", onData);
108
+ });
109
+ }
110
+
111
+ function maskValue(val) {
112
+ if (!val || val.length < 8) return "****";
113
+ return val.slice(0, 4) + "***" + val.slice(-3);
114
+ }
115
+
45
116
  function askYN(rl, question, defaultYes = false) {
46
117
  return new Promise((resolve) => {
47
118
  const hint = defaultYes ? "Y/n" : "y/N";
48
- rl.question(` ${question} [${hint}]: `, (answer) => {
119
+ rl.question(` ${c.bold}${question}${c.reset} ${c.dim}[${hint}]${c.reset}${c.cyan} > ${c.reset}`, (answer) => {
49
120
  const a = answer.trim().toLowerCase();
50
121
  resolve(a === "" ? defaultYes : a === "y" || a === "yes");
51
122
  });
52
123
  });
53
124
  }
54
125
 
126
+ // Migration: rename old agent keys to new ones
127
+ const AGENT_KEY_MAP = { t1: "head", t2a: "reviewer1", t2b: "reviewer2", t3: "dev" };
128
+
129
+ function migrateAgentKeys(config) {
130
+ let changed = false;
131
+ if (config.projects) {
132
+ for (const project of config.projects) {
133
+ if (!project.agents) continue;
134
+ for (const [oldKey, newKey] of Object.entries(AGENT_KEY_MAP)) {
135
+ if (project.agents[oldKey] && !project.agents[newKey]) {
136
+ project.agents[newKey] = project.agents[oldKey];
137
+ delete project.agents[oldKey];
138
+ changed = true;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ if (changed) {
144
+ try { writeConfig(config); } catch {}
145
+ }
146
+ return config;
147
+ }
148
+
55
149
  function readConfig() {
56
150
  try {
57
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
151
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
152
+ return migrateAgentKeys(config);
58
153
  } catch {
59
154
  return { port: 8400, agentchattr_url: "http://127.0.0.1:8300", projects: [] };
60
155
  }
@@ -67,6 +162,8 @@ function writeConfig(config) {
67
162
 
68
163
  // ─── Prerequisites ──────────────────────────────────────────────────────────
69
164
 
165
+ let agentChattrFound = false;
166
+
70
167
  function checkPrereqs() {
71
168
  header("Step 1: Prerequisites");
72
169
  let allOk = true;
@@ -90,7 +187,7 @@ function checkPrereqs() {
90
187
 
91
188
  // AgentChattr
92
189
  const acVer = run("agentchattr --version") || run("python3 -m agentchattr --version");
93
- if (acVer) ok(`AgentChattr ${acVer}`);
190
+ if (acVer) { ok(`AgentChattr ${acVer}`); agentChattrFound = true; }
94
191
  else { warn("AgentChattr not found — install: pip install agentchattr"); allOk = false; }
95
192
 
96
193
  // gh CLI
@@ -132,10 +229,12 @@ async function setupGitHub(rl) {
132
229
  }
133
230
 
134
231
  // Verify repo exists
232
+ const sp = spinner(`Verifying ${repo}...`);
135
233
  const repoCheck = run(`gh repo view ${repo} --json name 2>&1`);
136
234
  if (repoCheck && repoCheck.includes('"name"')) {
137
- ok(`Repo ${repo} verified`);
235
+ sp.stop(true);
138
236
  } else {
237
+ sp.stop(false);
139
238
  fail(`Cannot access ${repo} — check permissions`);
140
239
  return null;
141
240
  }
@@ -171,7 +270,8 @@ async function setupAgents(rl, repo) {
171
270
  }
172
271
  }
173
272
 
174
- log("Path to your local clone of the repo. Worktrees will be created as sibling directories.");
273
+ log("Path to your local clone of the repo. Four worktrees will be created next to it");
274
+ log("(e.g., project-head/, project-reviewer1/, project-reviewer2/, project-dev/).");
175
275
  const projectDir = await ask(rl, "Project directory", process.cwd());
176
276
  const absDir = path.resolve(projectDir);
177
277
 
@@ -187,12 +287,12 @@ async function setupAgents(rl, repo) {
187
287
  }
188
288
 
189
289
  // Prompt for reviewer credentials (optional)
190
- log("A separate reviewer account lets T2a/T2b approve PRs independently. You can set this up later in Settings.");
191
- const wantReviewer = await askYN(rl, "Use a separate GitHub account for reviewers (T2a/T2b)?", false);
290
+ log("A separate reviewer account lets Reviewer1/Reviewer2 approve PRs independently. You can set this up later in Settings.");
291
+ const wantReviewer = await askYN(rl, "Use a separate GitHub account for reviewers (Reviewer1/Reviewer2)?", false);
192
292
  let reviewerUser = "";
193
293
  let reviewerTokenPath = "";
194
294
  if (wantReviewer) {
195
- log("GitHub username for the reviewer account (used in T2a/T2b seed files for PR reviews).");
295
+ log("GitHub username for the reviewer account (used in Reviewer1/Reviewer2 seed files for PR reviews).");
196
296
  reviewerUser = await ask(rl, "Reviewer GitHub username", "");
197
297
  log("Path to a file containing a GitHub PAT for the reviewer account.");
198
298
  reviewerTokenPath = await ask(rl, "Reviewer token file path", path.join(os.homedir(), ".quadwork", "reviewer-token"));
@@ -200,25 +300,19 @@ async function setupAgents(rl, repo) {
200
300
 
201
301
  const projectName = path.basename(absDir);
202
302
  log(`Project: ${projectName}`);
203
- log("Creating worktrees for 4 agents...\n");
303
+ const wtSpinner = spinner("Creating worktrees and seeding files...");
204
304
 
205
305
  const worktrees = {};
306
+ let wtFailed = null;
206
307
  for (const agent of AGENTS) {
207
308
  const wtDir = path.join(path.dirname(absDir), `${projectName}-${agent}`);
208
- if (fs.existsSync(wtDir)) {
209
- ok(`Worktree exists: ${agent} → ${wtDir}`);
210
- } else {
309
+ if (!fs.existsSync(wtDir)) {
211
310
  const branchName = `worktree-${agent}`;
212
- // Create branch if needed
213
311
  run(`git -C "${absDir}" branch ${branchName} HEAD 2>&1`);
214
312
  const result = run(`git -C "${absDir}" worktree add "${wtDir}" ${branchName} 2>&1`);
215
- if (result !== null) {
216
- ok(`Created worktree: ${agent} → ${wtDir}`);
217
- } else {
218
- // Try without branch (detached)
313
+ if (!result) {
219
314
  const result2 = run(`git -C "${absDir}" worktree add --detach "${wtDir}" HEAD 2>&1`);
220
- if (result2 !== null) ok(`Created worktree (detached): ${agent} ${wtDir}`);
221
- else { fail(`Failed to create worktree for ${agent}`); return null; }
315
+ if (!result2) { wtFailed = agent; break; }
222
316
  }
223
317
  }
224
318
  worktrees[agent] = wtDir;
@@ -238,10 +332,15 @@ async function setupAgents(rl, repo) {
238
332
  seedContent = seedContent.replace(/\{\{reviewer_token_path\}\}/g, "");
239
333
  }
240
334
  fs.writeFileSync(seedDst, seedContent);
241
- log(` Copied ${agent}.AGENTS.md`);
242
335
  }
243
336
  }
244
337
 
338
+ if (wtFailed) {
339
+ wtSpinner.stop(false);
340
+ fail(`Failed to create worktree for ${wtFailed}`);
341
+ return null;
342
+ }
343
+
245
344
  // Copy CLAUDE.md to each worktree
246
345
  const claudeSrc = path.join(TEMPLATES_DIR, "CLAUDE.md");
247
346
  if (fs.existsSync(claudeSrc)) {
@@ -249,25 +348,25 @@ async function setupAgents(rl, repo) {
249
348
  claudeContent = claudeContent.replace(/\{\{project_name\}\}/g, projectName);
250
349
  for (const agent of AGENTS) {
251
350
  const dst = path.join(worktrees[agent], "CLAUDE.md");
252
- // Don't overwrite if CLAUDE.md already exists
253
351
  if (!fs.existsSync(dst)) {
254
352
  fs.writeFileSync(dst, claudeContent);
255
353
  }
256
354
  }
257
- ok("Copied CLAUDE.md to all worktrees");
258
355
  }
259
356
 
357
+ wtSpinner.stop(true);
358
+
260
359
  return { projectName, absDir, worktrees, repo, backend, backends };
261
360
  }
262
361
 
263
362
  // ─── AgentChattr Config ─────────────────────────────────────────────────────
264
363
 
265
- function writeAgentChattrConfig(setup, configTomlPath) {
364
+ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } = {}) {
266
365
  header("Step 4: AgentChattr Setup");
267
366
 
268
367
  let tomlContent = fs.readFileSync(path.join(TEMPLATES_DIR, "config.toml"), "utf-8");
269
368
  for (const agent of AGENTS) {
270
- tomlContent = tomlContent.replace(`{{${agent}_cwd}}`, setup.worktrees[agent]);
369
+ tomlContent = tomlContent.replace(new RegExp(`\\{\\{${agent}_cwd\\}\\}`, "g"), setup.worktrees[agent]);
271
370
  }
272
371
  // Replace placeholders
273
372
  tomlContent = tomlContent.replace(/\{\{project_name\}\}/g, setup.projectName);
@@ -281,25 +380,45 @@ function writeAgentChattrConfig(setup, configTomlPath) {
281
380
  );
282
381
  }
283
382
 
383
+ // Per-project: isolated data dir and port
384
+ const dataDir = path.join(path.dirname(configTomlPath), "data");
385
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
386
+ // Read assigned port from config (set by writeQuadWorkConfig)
387
+ const existingConfig = readConfig();
388
+ const existingProject = existingConfig.projects?.find((p) => p.id === setup.projectName);
389
+ const chattrPort = existingProject?.agentchattr_url
390
+ ? new URL(existingProject.agentchattr_url).port
391
+ : "8300";
392
+ const mcpHttp = existingProject?.mcp_http_port || 8200;
393
+ const mcpSse = existingProject?.mcp_sse_port || 8201;
394
+ tomlContent = tomlContent.replace(/^port = \d+/m, `port = ${chattrPort}`);
395
+ tomlContent = tomlContent.replace(/^data_dir = .+/m, `data_dir = "${dataDir}"`);
396
+ // Add session_token to [server] section if project has one
397
+ const sessionToken = existingProject?.agentchattr_token || "";
398
+ if (sessionToken) {
399
+ tomlContent = tomlContent.replace(/^(data_dir = .+)$/m, `$1\nsession_token = "${sessionToken}"`);
400
+ }
401
+ tomlContent = tomlContent.replace(/^http_port = \d+/m, `http_port = ${mcpHttp}`);
402
+ tomlContent = tomlContent.replace(/^sse_port = \d+/m, `sse_port = ${mcpSse}`);
403
+
284
404
  // Write config.toml
285
405
  const configDir = path.dirname(configTomlPath);
286
406
  if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
287
407
  fs.writeFileSync(configTomlPath, tomlContent);
288
408
  ok(`Wrote ${configTomlPath}`);
289
409
 
290
- // Install AgentChattr if missing, then start it
291
- // Only check for the actual binary — python3 -m availability doesn't mean the CLI is in PATH
410
+ // Start AgentChattr if available; optionally skip install attempt
292
411
  let acAvailable = which("agentchattr");
293
- if (!acAvailable) {
294
- log("Installing AgentChattr...");
412
+ if (!acAvailable && !skipInstall) {
413
+ const acSpinner = spinner("Installing AgentChattr...");
295
414
  const installResult = run("pip install agentchattr 2>&1");
296
415
  if (installResult !== null) {
297
- ok("Installed AgentChattr");
298
- // Re-check that the binary is actually in PATH after install
416
+ acSpinner.stop(true);
299
417
  acAvailable = which("agentchattr");
300
418
  if (!acAvailable) warn("agentchattr binary not found in PATH after install");
301
419
  } else {
302
- warn("Failed to install AgentChattr — install manually: pip install agentchattr");
420
+ acSpinner.stop(false);
421
+ warn("Install manually: pip install agentchattr");
303
422
  }
304
423
  }
305
424
 
@@ -316,8 +435,9 @@ function writeAgentChattrConfig(setup, configTomlPath) {
316
435
  acProc.unref();
317
436
  if (acProc.pid) {
318
437
  ok(`AgentChattr started (PID: ${acProc.pid})`);
319
- const pidFile = path.join(CONFIG_DIR, "agentchattr.pid");
438
+ // Per-project PID file
320
439
  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
440
+ const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
321
441
  fs.writeFileSync(pidFile, String(acProc.pid));
322
442
  } else {
323
443
  warn("Could not start AgentChattr — start manually: agentchattr --config " + configTomlPath);
@@ -340,10 +460,10 @@ async function setupAddons(rl, setup, configTomlPath) {
340
460
  if (wantTelegram) {
341
461
  const telegramDir = path.join(path.dirname(setup.absDir), "agentchattr-telegram");
342
462
  if (!fs.existsSync(telegramDir)) {
343
- log("Cloning agentchattr-telegram...");
463
+ const cloneSpinner = spinner("Cloning agentchattr-telegram...");
344
464
  const cloneResult = run(`git clone https://github.com/realproject7/agentchattr-telegram.git "${telegramDir}" 2>&1`);
345
- if (cloneResult !== null) ok("Cloned agentchattr-telegram");
346
- else warn("Failed to clone — you can set it up manually later");
465
+ cloneSpinner.stop(cloneResult !== null);
466
+ if (!cloneResult) warn("You can set it up manually later");
347
467
  } else {
348
468
  ok("agentchattr-telegram already present");
349
469
  }
@@ -351,12 +471,20 @@ async function setupAddons(rl, setup, configTomlPath) {
351
471
  if (fs.existsSync(telegramDir)) {
352
472
  const reqFile = path.join(telegramDir, "requirements.txt");
353
473
  if (fs.existsSync(reqFile)) {
354
- run(`pip install -r "${reqFile}" 2>&1`);
355
- ok("Installed Telegram Bridge dependencies");
474
+ const tgSpinner = spinner("Installing Telegram Bridge dependencies...");
475
+ const tgResult = run(`pip install -r "${reqFile}" 2>&1`);
476
+ tgSpinner.stop(tgResult !== null);
356
477
  }
357
478
 
358
- const botToken = await ask(rl, "Telegram bot token", "");
479
+ log("Create a bot via @BotFather on Telegram (https://t.me/BotFather), then copy the token.");
480
+ const botToken = await askSecret(rl, "Telegram bot token");
481
+ log("To find your chat ID:");
482
+ log(" 1. Open your bot on Telegram and send it any message (e.g., 'hi')");
483
+ log(" 2. Run: curl https://api.telegram.org/bot<TOKEN>/getUpdates");
484
+ log(" 3. Look for \"chat\":{\"id\":123456789,...} — the number is your chat ID");
485
+ log(" Note: Returns empty if no messages have been sent to the bot yet.");
359
486
  const chatId = await ask(rl, "Telegram chat ID", "");
487
+ log("Need help? See https://github.com/realproject7/agentchattr-telegram#readme");
360
488
 
361
489
  if (botToken && chatId) {
362
490
  // Write bot token to ~/.quadwork/.env (never stored in config files)
@@ -373,7 +501,7 @@ async function setupAddons(rl, setup, configTomlPath) {
373
501
  }
374
502
  fs.writeFileSync(envPath, envContent, { mode: 0o600 });
375
503
  fs.chmodSync(envPath, 0o600);
376
- ok(`Saved bot token to ${envPath}`);
504
+ ok(`Saved bot token (${maskValue(botToken)}) to ${envPath}`);
377
505
 
378
506
  // Persist telegram settings for writeQuadWorkConfig (env reference, not plaintext)
379
507
  setup.telegram = {
@@ -382,12 +510,17 @@ async function setupAddons(rl, setup, configTomlPath) {
382
510
  bridge_dir: telegramDir,
383
511
  };
384
512
 
513
+ // Resolve per-project AgentChattr URL
514
+ const projectCfg = readConfig();
515
+ const projectEntry = projectCfg.projects?.find((p) => p.id === setup.projectName);
516
+ const projectChattrUrl = projectEntry?.agentchattr_url || "http://127.0.0.1:8300";
517
+
385
518
  // Append telegram section to config.toml (token read from env at runtime)
386
519
  const telegramSection = `
387
520
  [telegram]
388
521
  bot_token = "env:${envKey}"
389
522
  chat_id = "${chatId}"
390
- agentchattr_url = "http://127.0.0.1:8300"
523
+ agentchattr_url = "${projectChattrUrl}"
391
524
  poll_interval = 2
392
525
  bridge_sender = "telegram-bridge"
393
526
  `;
@@ -399,7 +532,7 @@ bridge_sender = "telegram-bridge"
399
532
  if (fs.existsSync(bridgeScript)) {
400
533
  log("Starting Telegram bridge...");
401
534
  const bridgeToml = path.join(CONFIG_DIR, `telegram-${setup.projectName}.toml`);
402
- const bridgeTomlContent = `[telegram]\nbot_token = "${botToken}"\nchat_id = "${chatId}"\n\n[agentchattr]\nurl = "http://127.0.0.1:8300"\n`;
535
+ const bridgeTomlContent = `[telegram]\nbot_token = "${botToken}"\nchat_id = "${chatId}"\n\n[agentchattr]\nurl = "${projectChattrUrl}"\n`;
403
536
  fs.writeFileSync(bridgeToml, bridgeTomlContent, { mode: 0o600 });
404
537
  fs.chmodSync(bridgeToml, 0o600);
405
538
  const bridgeProc = spawn("python3", [bridgeScript, "--config", bridgeToml], {
@@ -425,10 +558,10 @@ bridge_sender = "telegram-bridge"
425
558
  if (wantMemory) {
426
559
  const memoryDir = path.join(path.dirname(setup.absDir), "agent-memory");
427
560
  if (!fs.existsSync(memoryDir)) {
428
- log("Cloning agent-memory...");
561
+ const memSpinner = spinner("Cloning agent-memory...");
429
562
  const cloneResult = run(`git clone https://github.com/realproject7/agent-memory.git "${memoryDir}" 2>&1`);
430
- if (cloneResult !== null) ok("Cloned agent-memory");
431
- else warn("Failed to clone — you can set it up manually later");
563
+ memSpinner.stop(cloneResult !== null);
564
+ if (!cloneResult) warn("You can set it up manually later");
432
565
  } else {
433
566
  ok("agent-memory already present");
434
567
  }
@@ -504,9 +637,25 @@ function writeQuadWorkConfig(setup) {
504
637
  };
505
638
  }
506
639
 
640
+ // Auto-assign per-project AgentChattr and MCP ports (scan existing to avoid collisions)
641
+ const existingIdx = config.projects.findIndex((p) => p.id === setup.projectName);
642
+ const usedChattrPorts = new Set(config.projects.map((p) => {
643
+ try { return parseInt(new URL(p.agentchattr_url).port, 10); } catch { return 0; }
644
+ }).filter(Boolean));
645
+ const usedMcpPorts = new Set(config.projects.flatMap((p) => [p.mcp_http_port, p.mcp_sse_port]).filter(Boolean));
646
+ let chattrPort = 8300;
647
+ while (usedChattrPorts.has(chattrPort)) chattrPort++;
648
+ let mcp_http = 8200;
649
+ while (usedMcpPorts.has(mcp_http)) mcp_http++;
650
+ let mcp_sse = mcp_http + 1;
651
+ while (usedMcpPorts.has(mcp_sse)) mcp_sse++;
652
+ project.agentchattr_url = `http://127.0.0.1:${chattrPort}`;
653
+ project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
654
+ project.mcp_http_port = mcp_http;
655
+ project.mcp_sse_port = mcp_sse;
656
+
507
657
  // Upsert project
508
- const idx = config.projects.findIndex((p) => p.id === setup.projectName);
509
- if (idx >= 0) config.projects[idx] = project;
658
+ if (existingIdx >= 0) config.projects[existingIdx] = project;
510
659
  else config.projects.push(project);
511
660
 
512
661
  writeConfig(config);
@@ -516,49 +665,78 @@ function writeQuadWorkConfig(setup) {
516
665
  // ─── Init Command ───────────────────────────────────────────────────────────
517
666
 
518
667
  async function cmdInit() {
519
- console.log("\n QuadWork Init — 4-agent coding team setup\n");
668
+ console.log("");
669
+ console.log(` ${c.cyan}${c.bold}╔══════════════════════════════════════════╗${c.reset}`);
670
+ console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.white}${c.bold}QuadWork Init${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
671
+ console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.dim}Global setup — projects via web UI${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
672
+ console.log(` ${c.cyan}${c.bold}╚══════════════════════════════════════════╝${c.reset}`);
673
+ console.log(`\n ${c.dim}Tip: Press Enter to accept defaults shown in [brackets].${c.reset}\n`);
520
674
 
521
675
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
522
676
 
523
677
  try {
524
678
  // Step 1: Prerequisites
679
+ header("Step 1: Prerequisites");
525
680
  const prereqsOk = checkPrereqs();
526
681
  if (!prereqsOk) {
527
682
  const proceed = await askYN(rl, "Some prerequisites missing. Continue anyway?", false);
528
683
  if (!proceed) { rl.close(); process.exit(1); }
529
684
  }
530
685
 
531
- // Step 2: GitHub
532
- const repo = await setupGitHub(rl);
533
- if (!repo) { rl.close(); process.exit(1); }
534
-
535
- // Step 3: Agents
536
- const setup = await setupAgents(rl, repo);
537
- if (!setup) { rl.close(); process.exit(1); }
538
-
539
- // Step 4: AgentChattr config
540
- const configTomlPath = path.join(setup.absDir, "config.toml");
541
- writeAgentChattrConfig(setup, configTomlPath);
542
-
543
- // Step 5: Optional add-ons
544
- await setupAddons(rl, setup, configTomlPath);
545
-
546
- // Write QuadWork config
547
- writeQuadWorkConfig(setup);
686
+ // Step 2: Global config
687
+ header("Step 2: Global Configuration");
688
+ const port = await ask(rl, "Dashboard port", "8400");
689
+ const defaultBackend = which("claude") ? "claude" : which("codex") ? "codex" : "claude";
690
+ const backend = await ask(rl, "Default CLI backend (claude/codex)", defaultBackend);
691
+
692
+ // Write global config
693
+ const config = readConfig();
694
+ config.port = parseInt(port, 10) || 8400;
695
+ config.default_backend = backend;
696
+ writeConfig(config);
697
+ ok(`Wrote ${CONFIG_PATH}`);
698
+
699
+ // Step 3: Start server
700
+ header("Step 3: Starting Dashboard");
701
+ const quadworkDir = path.join(__dirname, "..");
702
+ const serverDir = path.join(quadworkDir, "server");
703
+ if (fs.existsSync(path.join(serverDir, "index.js"))) {
704
+ const server = spawn("node", [serverDir], {
705
+ stdio: "ignore",
706
+ detached: true,
707
+ env: { ...process.env },
708
+ });
709
+ server.unref();
710
+ if (server.pid) {
711
+ ok(`Server started (PID: ${server.pid})`);
712
+ const pidFile = path.join(CONFIG_DIR, "server.pid");
713
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
714
+ fs.writeFileSync(pidFile, String(server.pid));
715
+ }
716
+ } else {
717
+ warn("Server not found — run from the quadwork directory");
718
+ }
548
719
 
549
720
  // Done
721
+ const dashPort = parseInt(port, 10) || 8400;
722
+ const dashboardUrl = `http://127.0.0.1:${dashPort}`;
723
+
550
724
  header("Setup Complete");
551
- log(`Project: ${setup.projectName}`);
552
- log(`Repo: ${setup.repo}`);
553
- log(`Worktrees: ${AGENTS.map((a) => `${a}/`).join(", ")}`);
554
- log(`Config: ${CONFIG_PATH}`);
555
- log(`AgentChattr: ${configTomlPath}`);
725
+ log(`Config: ${CONFIG_PATH}`);
726
+ log(`Dashboard: ${dashboardUrl}`);
727
+ log(`Backend: ${backend}`);
556
728
  log("");
557
729
  log("Next steps:");
558
- log(" npx quadwork start — launch dashboard + agents");
730
+ log(` Open ${c.cyan}${dashboardUrl}/setup${c.reset} to create your first project`);
559
731
  log(" npx quadwork stop — stop all processes");
560
732
  log("");
561
733
 
734
+ // Open browser
735
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
736
+ setTimeout(() => {
737
+ try { execSync(`${openCmd} ${dashboardUrl}/setup`, { stdio: "ignore" }); } catch {}
738
+ }, 1500);
739
+
562
740
  rl.close();
563
741
  } catch (err) {
564
742
  fail(err.message);
@@ -608,11 +786,12 @@ function cmdStart() {
608
786
  const pidFile = path.join(CONFIG_DIR, "server.pid");
609
787
  fs.writeFileSync(pidFile, String(server.pid));
610
788
 
611
- // Start AgentChattr if installed and config.toml exists for first project
612
- const firstProject = config.projects[0];
613
- if (firstProject && which("agentchattr")) {
614
- const configToml = path.join(firstProject.working_dir, "config.toml");
615
- if (fs.existsSync(configToml)) {
789
+ // Start AgentChattr for each project that has a config.toml
790
+ if (which("agentchattr")) {
791
+ for (const project of config.projects) {
792
+ if (!project.working_dir) continue;
793
+ const configToml = path.join(project.working_dir, "agentchattr", "config.toml");
794
+ if (!fs.existsSync(configToml)) continue;
616
795
  const acProc = spawn("agentchattr", ["--config", configToml], {
617
796
  stdio: "ignore",
618
797
  detached: true,
@@ -620,8 +799,8 @@ function cmdStart() {
620
799
  acProc.on("error", () => {});
621
800
  acProc.unref();
622
801
  if (acProc.pid) {
623
- ok(`AgentChattr started (PID: ${acProc.pid})`);
624
- fs.writeFileSync(path.join(CONFIG_DIR, "agentchattr.pid"), String(acProc.pid));
802
+ ok(`AgentChattr started for ${project.id} (PID: ${acProc.pid})`);
803
+ fs.writeFileSync(path.join(CONFIG_DIR, `agentchattr-${project.id}.pid`), String(acProc.pid));
625
804
  }
626
805
  }
627
806
  }
@@ -660,7 +839,15 @@ function cmdStop() {
660
839
 
661
840
  let stopped = 0;
662
841
  if (stopPid("Telegram bridge", "telegram-bridge.pid")) stopped++;
842
+
843
+ // Stop per-project AgentChattr instances
844
+ const config = readConfig();
845
+ for (const project of (config.projects || [])) {
846
+ if (stopPid(`AgentChattr (${project.id})`, `agentchattr-${project.id}.pid`)) stopped++;
847
+ }
848
+ // Also stop legacy single-instance PID if present
663
849
  if (stopPid("AgentChattr", "agentchattr.pid")) stopped++;
850
+
664
851
  if (stopPid("Server", "server.pid")) stopped++;
665
852
 
666
853
  if (stopped === 0) warn("No running processes found");
@@ -682,11 +869,11 @@ async function cmdAddProject() {
682
869
  const setup = await setupAgents(rl, repo);
683
870
  if (!setup) { rl.close(); process.exit(1); }
684
871
 
685
- const configTomlPath = path.join(setup.absDir, "config.toml");
686
- writeAgentChattrConfig(setup, configTomlPath);
687
-
688
872
  writeQuadWorkConfig(setup);
689
873
 
874
+ const configTomlPath = path.join(setup.absDir, "agentchattr", "config.toml");
875
+ writeAgentChattrConfig(setup, configTomlPath);
876
+
690
877
  header("Project Added");
691
878
  log(`Project: ${setup.projectName}`);
692
879
  log(`Repo: ${setup.repo}`);
@@ -723,16 +910,20 @@ switch (command) {
723
910
  Usage: quadwork <command>
724
911
 
725
912
  Commands:
726
- init Set up a new QuadWork 4-agent environment
913
+ init Global setup (prereqs, port, backend) then open web UI
727
914
  start Start the QuadWork dashboard and backend
728
915
  stop Stop all QuadWork processes
729
- add-project Add a project to an existing QuadWork setup
916
+ add-project Add a project via CLI (alternative to web UI /setup)
917
+
918
+ Workflow:
919
+ 1. npx quadwork init — one-time global setup, opens dashboard
920
+ 2. Open /setup in browser — create projects with guided web UI
921
+ 3. npx quadwork stop — stop everything when done
730
922
 
731
923
  Examples:
732
924
  npx quadwork init
733
925
  npx quadwork start
734
926
  npx quadwork stop
735
- npx quadwork add-project
736
927
  `);
737
928
  if (command) process.exit(1);
738
929
  }