quadwork 1.0.17 → 1.2.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 (95) hide show
  1. package/README.md +28 -0
  2. package/bin/quadwork.js +524 -89
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +16 -15
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +6 -5
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/064engxz5n7u9.js +1 -0
  10. package/out/_next/static/chunks/0738cfu-x.0ul.js +24 -0
  11. package/out/_next/static/chunks/{00cs~pv62864f.js → 0o97ax9om2kj1.js} +1 -1
  12. package/out/_next/static/chunks/0r-00ph4jahrl.css +2 -0
  13. package/out/_next/static/chunks/0spbjcw4anq15.js +1 -0
  14. package/out/_next/static/chunks/{0io_y3d0p5v~b.js → 15i5_ay.0ap.6.js} +2 -2
  15. package/out/_next/static/chunks/{turbopack-0sammtvunroor.js → turbopack-0wh29ykoy-rb5.js} +1 -1
  16. package/out/_not-found/__next._full.txt +17 -16
  17. package/out/_not-found/__next._head.txt +4 -4
  18. package/out/_not-found/__next._index.txt +6 -5
  19. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  20. package/out/_not-found/__next._not-found.txt +3 -3
  21. package/out/_not-found/__next._tree.txt +2 -2
  22. package/out/_not-found.html +1 -1
  23. package/out/_not-found.txt +17 -16
  24. package/out/app-shell/__next._full.txt +17 -16
  25. package/out/app-shell/__next._head.txt +4 -4
  26. package/out/app-shell/__next._index.txt +6 -5
  27. package/out/app-shell/__next._tree.txt +2 -2
  28. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  29. package/out/app-shell/__next.app-shell.txt +3 -3
  30. package/out/app-shell.html +1 -1
  31. package/out/app-shell.txt +17 -16
  32. package/out/index.html +1 -1
  33. package/out/index.txt +16 -15
  34. package/out/project/_/__next._full.txt +18 -17
  35. package/out/project/_/__next._head.txt +4 -4
  36. package/out/project/_/__next._index.txt +6 -5
  37. package/out/project/_/__next._tree.txt +2 -2
  38. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  39. package/out/project/_/__next.project.$d$id.txt +3 -3
  40. package/out/project/_/__next.project.txt +3 -3
  41. package/out/project/_/memory/__next._full.txt +18 -17
  42. package/out/project/_/memory/__next._head.txt +4 -4
  43. package/out/project/_/memory/__next._index.txt +6 -5
  44. package/out/project/_/memory/__next._tree.txt +2 -2
  45. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +3 -3
  46. package/out/project/_/memory/__next.project.$d$id.memory.txt +3 -3
  47. package/out/project/_/memory/__next.project.$d$id.txt +3 -3
  48. package/out/project/_/memory/__next.project.txt +3 -3
  49. package/out/project/_/memory.html +1 -1
  50. package/out/project/_/memory.txt +18 -17
  51. package/out/project/_/queue/__next._full.txt +18 -17
  52. package/out/project/_/queue/__next._head.txt +4 -4
  53. package/out/project/_/queue/__next._index.txt +6 -5
  54. package/out/project/_/queue/__next._tree.txt +2 -2
  55. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  56. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  57. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  58. package/out/project/_/queue/__next.project.txt +3 -3
  59. package/out/project/_/queue.html +1 -1
  60. package/out/project/_/queue.txt +18 -17
  61. package/out/project/_.html +1 -1
  62. package/out/project/_.txt +18 -17
  63. package/out/settings/__next._full.txt +18 -17
  64. package/out/settings/__next._head.txt +4 -4
  65. package/out/settings/__next._index.txt +6 -5
  66. package/out/settings/__next._tree.txt +2 -2
  67. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  68. package/out/settings/__next.settings.txt +3 -3
  69. package/out/settings.html +1 -1
  70. package/out/settings.txt +18 -17
  71. package/out/setup/__next._full.txt +18 -17
  72. package/out/setup/__next._head.txt +4 -4
  73. package/out/setup/__next._index.txt +6 -5
  74. package/out/setup/__next._tree.txt +2 -2
  75. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  76. package/out/setup/__next.setup.txt +3 -3
  77. package/out/setup.html +1 -1
  78. package/out/setup.txt +18 -17
  79. package/package.json +2 -1
  80. package/server/config.js +22 -1
  81. package/server/index.js +152 -6
  82. package/server/install-agentchattr.js +215 -0
  83. package/server/routes.js +162 -6
  84. package/templates/OVERNIGHT-QUEUE.md +35 -0
  85. package/templates/seeds/dev.AGENTS.md +9 -0
  86. package/templates/seeds/head.AGENTS.md +29 -2
  87. package/templates/seeds/reviewer1.AGENTS.md +9 -0
  88. package/templates/seeds/reviewer2.AGENTS.md +9 -0
  89. package/out/_next/static/chunks/08fgie1bcjynm.js +0 -1
  90. package/out/_next/static/chunks/0g7f4hvbz_1u~.js +0 -20
  91. package/out/_next/static/chunks/10b3c4k.q.yw..css +0 -2
  92. package/out/_next/static/chunks/14kr4rvjq-2md.js +0 -1
  93. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_buildManifest.js +0 -0
  94. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_clientMiddlewareManifest.js +0 -0
  95. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_ssgManifest.js +0 -0
package/bin/quadwork.js CHANGED
@@ -84,50 +84,178 @@ function findAgentChattr(dir) {
84
84
  }
85
85
 
86
86
  /**
87
- * Clone AgentChattr and set up its venv. Idempotent — safe to re-run.
88
- * Only removes existing directories that are identifiable as failed AgentChattr clones.
89
- * Returns the directory path on success, null on failure.
87
+ * Clone AgentChattr and set up its venv. Idempotent — safe to re-run on
88
+ * the same path, and safe to call repeatedly with different paths in
89
+ * the same process. Designed to support per-project clones (#181).
90
+ *
91
+ * Behavior on re-run:
92
+ * - Fully-installed path → no-op (skips clone, skips venv create, skips pip)
93
+ * - Missing run.py → clones (only after refusing to overwrite
94
+ * unrelated content; see safety rules below)
95
+ * - Missing venv → creates venv and reinstalls requirements
96
+ *
97
+ * Safety rules — never accidentally clean up unrelated directories:
98
+ * - Empty dir → safe to remove
99
+ * - Git repo whose origin contains "agentchattr" → safe to remove
100
+ * - Anything else → refuse, return null
101
+ *
102
+ * On failure, returns null and stores a human-readable reason on
103
+ * `installAgentChattr.lastError` so callers can surface it without
104
+ * changing the return shape.
90
105
  */
106
+ // Stale-lock thresholds for installAgentChattr().
107
+ // Lock files older than this OR whose owning pid is no longer alive are
108
+ // treated as crashed and reclaimed. Tuned to comfortably exceed the longest
109
+ // step (pip install of agentchattr requirements, ~120s timeout).
110
+ const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
111
+ const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
112
+ const INSTALL_LOCK_POLL_MS = 500;
113
+
114
+ function _isPidAlive(pid) {
115
+ if (!pid || !Number.isFinite(pid)) return false;
116
+ try { process.kill(pid, 0); return true; }
117
+ catch (e) { return e.code === "EPERM"; }
118
+ }
119
+
120
+ function _readLock(lockFile) {
121
+ try {
122
+ const raw = fs.readFileSync(lockFile, "utf-8").trim();
123
+ const [pidStr, tsStr] = raw.split(":");
124
+ return { pid: parseInt(pidStr, 10), ts: parseInt(tsStr, 10) || 0 };
125
+ } catch { return null; }
126
+ }
127
+
128
+ function _isLockStale(lockFile) {
129
+ const info = _readLock(lockFile);
130
+ if (!info) return true; // unreadable → assume stale
131
+ if (Date.now() - info.ts > INSTALL_LOCK_STALE_MS) return true;
132
+ if (!_isPidAlive(info.pid)) return true;
133
+ return false;
134
+ }
135
+
91
136
  function installAgentChattr(dir) {
92
137
  dir = dir || getAgentChattrDir();
93
- // Clone if not already present
94
- if (!fs.existsSync(path.join(dir, "run.py"))) {
138
+ installAgentChattr.lastError = null;
139
+ const setError = (msg) => { installAgentChattr.lastError = msg; return null; };
140
+
141
+ // --- Per-target lock to prevent concurrent clones from corrupting each
142
+ // other when two projects (or two web tabs) launch simultaneously. Lock
143
+ // file lives next to the install dir so it's scoped per-target.
144
+ const lockFile = `${dir}.install.lock`;
145
+ try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
146
+ catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
147
+
148
+ let acquired = false;
149
+ const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
150
+ while (!acquired) {
151
+ try {
152
+ // Atomic create: fails if file already exists, no TOCTOU race.
153
+ fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
154
+ acquired = true;
155
+ } catch (e) {
156
+ if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
157
+ // Reclaim if the existing lock is stale (crashed pid or too old).
158
+ // Use rename → unlink instead of unlink directly: rename is atomic,
159
+ // so only one racing process can move the stale lock aside. The
160
+ // others see ENOENT and just retry the wx create. Without this,
161
+ // two processes could both observe the same stale lock, both
162
+ // unlink it (one of those unlinks would target the *next* lock
163
+ // freshly acquired by a third process), and both proceed past the
164
+ // gate concurrently — see review on quadwork#193.
165
+ if (_isLockStale(lockFile)) {
166
+ const sideline = `${lockFile}.stale.${process.pid}.${Date.now()}`;
167
+ try {
168
+ fs.renameSync(lockFile, sideline);
169
+ try { fs.unlinkSync(sideline); } catch {}
170
+ } catch (renameErr) {
171
+ // ENOENT: another process already reclaimed it. Anything else:
172
+ // treat as transient and retry — the next iteration will read
173
+ // whatever is at lockFile now and decide again.
174
+ if (renameErr.code !== "ENOENT") {
175
+ return setError(`Cannot reclaim stale lock ${lockFile}: ${renameErr.message}`);
176
+ }
177
+ }
178
+ continue;
179
+ }
180
+ // Live peer install in progress. After it finishes, the install
181
+ // is likely already done — caller will see a fully-installed path
182
+ // on the next call. While waiting, poll until the lock disappears
183
+ // or we hit the wait deadline.
184
+ if (Date.now() >= deadline) {
185
+ const info = _readLock(lockFile) || { pid: "?", ts: 0 };
186
+ 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.`);
187
+ }
188
+ // Synchronous sleep — installAgentChattr is itself synchronous and
189
+ // is called from the CLI wizard, where blocking is acceptable.
190
+ // Use execSync('sleep') instead of a busy-wait so we don't pin a CPU.
191
+ try { require("child_process").execSync(`sleep ${INSTALL_LOCK_POLL_MS / 1000}`); }
192
+ catch { /* sleep interrupted; loop will recheck */ }
193
+ }
194
+ }
195
+
196
+ try {
197
+ return _installAgentChattrLocked(dir, setError);
198
+ } finally {
199
+ try { fs.unlinkSync(lockFile); } catch {}
200
+ }
201
+ }
202
+
203
+ function _installAgentChattrLocked(dir, setError) {
204
+ const runPy = path.join(dir, "run.py");
205
+ const venvPython = path.join(dir, ".venv", "bin", "python");
206
+ let venvJustCreated = false;
207
+
208
+ // 1. Clone if run.py is missing.
209
+ if (!fs.existsSync(runPy)) {
95
210
  if (fs.existsSync(dir)) {
96
- // Directory exists but no run.py — only clean up if positively identified
97
- const isEmpty = fs.readdirSync(dir).length === 0;
211
+ let entries;
212
+ try { entries = fs.readdirSync(dir); }
213
+ catch (e) { return setError(`Cannot read ${dir}: ${e.message}`); }
214
+ const isEmpty = entries.length === 0;
98
215
  if (isEmpty) {
99
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
216
+ try { fs.rmSync(dir, { recursive: true, force: true }); }
217
+ catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
100
218
  } else if (fs.existsSync(path.join(dir, ".git"))) {
101
- // Verify this is actually an AgentChattr repo by checking the remote
219
+ // Only remove if origin remote positively identifies this as agentchattr.
102
220
  const remote = run(`git -C "${dir}" remote get-url origin 2>/dev/null`);
103
221
  if (remote && remote.includes("agentchattr")) {
104
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
222
+ try { fs.rmSync(dir, { recursive: true, force: true }); }
223
+ catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
105
224
  } else {
106
- // Git repo but not AgentChattr refuse to overwrite
107
- return null;
225
+ return setError(`Refusing to overwrite ${dir}: contains a non-AgentChattr git repo`);
108
226
  }
109
227
  } else {
110
- // Non-empty, non-git directory refuse to overwrite
111
- return null;
228
+ return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
112
229
  }
113
230
  }
114
- fs.mkdirSync(path.dirname(dir), { recursive: true });
231
+ // Ensure parent exists before clone (supports arbitrary nested paths).
232
+ try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
233
+ catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
115
234
  const cloneResult = run(`git clone "${AGENTCHATTR_REPO}" "${dir}" 2>&1`, { timeout: 60000 });
116
- if (cloneResult === null || !fs.existsSync(path.join(dir, "run.py"))) return null;
235
+ if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
236
+ if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
117
237
  }
118
- // Create venv and install deps (always ensure venv is ready)
119
- const venvPython = path.join(dir, ".venv", "bin", "python");
238
+
239
+ // 2. Create venv if missing.
120
240
  if (!fs.existsSync(venvPython)) {
121
241
  const venvResult = run(`python3 -m venv "${path.join(dir, ".venv")}" 2>&1`, { timeout: 60000 });
122
- if (venvResult === null) return null;
242
+ if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
243
+ if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
244
+ venvJustCreated = true;
123
245
  }
124
- const reqFile = path.join(dir, "requirements.txt");
125
- if (fs.existsSync(reqFile)) {
126
- const pipResult = run(`"${venvPython}" -m pip install -r "${reqFile}" 2>&1`, { timeout: 120000 });
127
- if (pipResult === null) return null;
246
+
247
+ // 3. Install requirements only when the venv was just (re)created.
248
+ // This makes re-running on a fully-installed path a true no-op.
249
+ if (venvJustCreated) {
250
+ const reqFile = path.join(dir, "requirements.txt");
251
+ if (fs.existsSync(reqFile)) {
252
+ const pipResult = run(`"${venvPython}" -m pip install -r "${reqFile}" 2>&1`, { timeout: 120000 });
253
+ if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
254
+ }
128
255
  }
129
256
  return dir;
130
257
  }
258
+ installAgentChattr.lastError = null;
131
259
 
132
260
  /**
133
261
  * Get spawn args for launching AgentChattr from its cloned directory.
@@ -725,6 +853,8 @@ async function setupAgents(rl, repo) {
725
853
  seedContent = seedContent.replace(/\{\{reviewer_github_user\}\}/g, "");
726
854
  seedContent = seedContent.replace(/\{\{reviewer_token_path\}\}/g, "");
727
855
  }
856
+ // Batch 25 / #205: substitute the per-project queue file path.
857
+ seedContent = seedContent.replace(/\{\{project_name\}\}/g, projectName);
728
858
  fs.writeFileSync(seedDst, seedContent);
729
859
  }
730
860
  }
@@ -801,25 +931,33 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
801
931
  fs.writeFileSync(configTomlPath, tomlContent);
802
932
  ok(`Wrote ${configTomlPath}`);
803
933
 
804
- // Start AgentChattr if available; optionally skip install attempt
805
- const acDir = findAgentChattr();
934
+ // Phase 2C / #181: clone AgentChattr per-project at
935
+ // ~/.quadwork/{project_id}/agentchattr/. AgentChattr's run.py loads
936
+ // ROOT/config.toml, so each project needs its own clone to avoid
937
+ // multi-instance port conflicts (see master #181). The path is the
938
+ // same one writeQuadWorkConfig() persists in project.agentchattr_dir.
939
+ const perProjectDir = path.join(CONFIG_DIR, setup.projectName, "agentchattr");
940
+ let acDir = findAgentChattr(perProjectDir);
806
941
  let acAvailable = !!acDir;
807
942
  if (!acAvailable && !skipInstall) {
808
- const acSpinner = spinner("Setting up AgentChattr...");
809
- const installResult = installAgentChattr();
943
+ const acSpinner = spinner(`Setting up AgentChattr at ${perProjectDir}...`);
944
+ const installResult = installAgentChattr(perProjectDir);
810
945
  if (installResult) {
811
946
  acSpinner.stop(true);
947
+ acDir = installResult;
812
948
  acAvailable = true;
813
949
  } else {
814
950
  acSpinner.stop(false);
815
- warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
951
+ const reason = installAgentChattr.lastError || "unknown error";
952
+ warn(`AgentChattr install failed at ${perProjectDir}: ${reason}`);
953
+ warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
816
954
  }
817
955
  }
818
956
 
819
957
  // Start AgentChattr server (only if installed)
820
958
  if (acAvailable) {
821
959
  log("Starting AgentChattr server...");
822
- const acSpawn = chattrSpawnArgs(findAgentChattr(), ["--config", configTomlPath]);
960
+ const acSpawn = chattrSpawnArgs(acDir, ["--config", configTomlPath]);
823
961
  if (acSpawn) {
824
962
  const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
825
963
  cwd: acSpawn.cwd,
@@ -836,14 +974,14 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
836
974
  const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
837
975
  fs.writeFileSync(pidFile, String(acProc.pid));
838
976
  } else {
839
- warn("Could not start AgentChattr — check logs in " + (findAgentChattr() || DEFAULT_AGENTCHATTR_DIR));
977
+ warn("Could not start AgentChattr — check logs in " + (acDir || perProjectDir));
840
978
  }
841
979
  } else {
842
980
  warn("AgentChattr run.py not found — skipping auto-start.");
843
981
  }
844
982
  } else {
845
983
  warn("AgentChattr not installed — skipping auto-start.");
846
- log(` → Install: git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
984
+ log(` → Install: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
847
985
  }
848
986
 
849
987
  return configTomlPath;
@@ -1006,6 +1144,30 @@ bridge_sender = "telegram-bridge"
1006
1144
 
1007
1145
  // ─── Write QuadWork Config ──────────────────────────────────────────────────
1008
1146
 
1147
+ /**
1148
+ * Seed ~/.quadwork/{projectName}/OVERNIGHT-QUEUE.md from templates/.
1149
+ * Idempotent — never overwrites an existing file so user and Head
1150
+ * agent edits are preserved across re-runs.
1151
+ */
1152
+ function writeOvernightQueueFile(projectName, repo) {
1153
+ const queueDir = path.join(CONFIG_DIR, projectName);
1154
+ const queuePath = path.join(queueDir, "OVERNIGHT-QUEUE.md");
1155
+ if (fs.existsSync(queuePath)) return false;
1156
+ try { fs.mkdirSync(queueDir, { recursive: true }); }
1157
+ catch (e) { warn(`Could not create ${queueDir}: ${e.message}`); return false; }
1158
+ const templatePath = path.join(TEMPLATES_DIR, "OVERNIGHT-QUEUE.md");
1159
+ if (!fs.existsSync(templatePath)) {
1160
+ warn(`OVERNIGHT-QUEUE.md template missing at ${templatePath}`);
1161
+ return false;
1162
+ }
1163
+ let content = fs.readFileSync(templatePath, "utf-8");
1164
+ content = content.replace(/\{\{project_name\}\}/g, projectName || "");
1165
+ content = content.replace(/\{\{repo\}\}/g, repo || "");
1166
+ fs.writeFileSync(queuePath, content);
1167
+ ok(`Wrote ${queuePath}`);
1168
+ return true;
1169
+ }
1170
+
1009
1171
  function writeQuadWorkConfig(setup) {
1010
1172
  header("Writing QuadWork Config");
1011
1173
 
@@ -1061,6 +1223,15 @@ function writeQuadWorkConfig(setup) {
1061
1223
  project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
1062
1224
  project.mcp_http_port = mcp_http;
1063
1225
  project.mcp_sse_port = mcp_sse;
1226
+ // Per-project AgentChattr clone path (Option B / #181). Each project gets
1227
+ // its own clone so AgentChattr's ROOT/config.toml lookup picks up the right
1228
+ // ports — see master ticket #181.
1229
+ project.agentchattr_dir = path.join(os.homedir(), ".quadwork", setup.projectName, "agentchattr");
1230
+
1231
+ // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
1232
+ // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md. Idempotent — if the file
1233
+ // already exists, preserve the user's / Head agent's edits.
1234
+ writeOvernightQueueFile(setup.projectName, setup.repo);
1064
1235
 
1065
1236
  // Upsert project
1066
1237
  if (existingIdx >= 0) config.projects[existingIdx] = project;
@@ -1102,33 +1273,29 @@ async function cmdInit() {
1102
1273
  writeConfig(config);
1103
1274
  ok(`Wrote ${CONFIG_PATH}`);
1104
1275
 
1105
- // Step 3: Start server
1276
+ // Step 3: Start server in the foreground (Batch 25 / #203).
1277
+ //
1278
+ // Previously cmdInit spawned the server detached and exited, which
1279
+ // left users without logs, without a clear stop story, and
1280
+ // inconsistent with `npx quadwork start` (#169). Now we print the
1281
+ // welcome banner first, schedule the browser open, close the
1282
+ // wizard readline, and then require() the server so it runs in
1283
+ // the user's terminal — Ctrl+C stops it cleanly via the SIGINT
1284
+ // handler below (same pattern cmdStart uses).
1106
1285
  header("Step 3: Starting Dashboard");
1107
1286
  const quadworkDir = path.join(__dirname, "..");
1108
1287
  const serverDir = path.join(quadworkDir, "server");
1109
- let serverPid = null;
1110
- if (fs.existsSync(path.join(serverDir, "index.js"))) {
1111
- const server = spawn("node", [serverDir], {
1112
- stdio: "ignore",
1113
- detached: true,
1114
- env: { ...process.env },
1115
- });
1116
- server.unref();
1117
- if (server.pid) {
1118
- serverPid = server.pid;
1119
- ok(`Server started (PID: ${serverPid})`);
1120
- const pidFile = path.join(CONFIG_DIR, "server.pid");
1121
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
1122
- fs.writeFileSync(pidFile, String(serverPid));
1123
- }
1124
- } else {
1125
- warn("Server not found — run from the quadwork directory");
1288
+ if (!fs.existsSync(path.join(serverDir, "index.js"))) {
1289
+ fail("Server not found. Run from the quadwork directory.");
1290
+ rl.close();
1291
+ process.exit(1);
1126
1292
  }
1127
1293
 
1128
- // Done — celebratory welcome
1129
1294
  const dashPort = parseInt(port, 10) || 8400;
1130
1295
  const dashboardUrl = `http://127.0.0.1:${dashPort}`;
1131
1296
 
1297
+ // Celebratory welcome (printed BEFORE the server takes over stdout
1298
+ // so it stays visible at the top of the scrollback).
1132
1299
  console.log("");
1133
1300
  console.log(` ${c.cyan}${c.bold}╔══════════════════════════════════════════════════════════╗${c.reset}`);
1134
1301
  console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
@@ -1145,12 +1312,8 @@ async function cmdInit() {
1145
1312
  console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
1146
1313
  console.log(` ${c.cyan}${c.bold}╚══════════════════════════════════════════════════════════╝${c.reset}`);
1147
1314
  console.log("");
1148
- if (serverPid) {
1149
- console.log(` ${c.green}*${c.reset} Server running at ${c.cyan}${dashboardUrl}${c.reset} ${c.dim}(PID: ${serverPid})${c.reset}`);
1150
- } else {
1151
- console.log(` ${c.yellow}*${c.reset} Server not started — run ${c.dim}npx quadwork start${c.reset} to launch`);
1152
- }
1153
- console.log(` ${c.green}*${c.reset} Config saved to ${c.dim}${CONFIG_PATH}${c.reset}`);
1315
+ console.log(` ${c.green}*${c.reset} Dashboard: ${c.cyan}${dashboardUrl}${c.reset}`);
1316
+ console.log(` ${c.green}*${c.reset} Config: ${c.dim}${CONFIG_PATH}${c.reset}`);
1154
1317
  console.log("");
1155
1318
  console.log(` ${c.cyan}${c.bold}--- Create Your First Project ---${c.reset}`);
1156
1319
  console.log("");
@@ -1162,19 +1325,40 @@ async function cmdInit() {
1162
1325
  console.log(` ${c.dim}2.${c.reset} Pick models for each agent`);
1163
1326
  console.log(` ${c.dim}3.${c.reset} Hit Start — your team takes it from there`);
1164
1327
  console.log("");
1165
- console.log(` ${c.dim}Commands:${c.reset}`);
1166
- console.log(` ${c.dim}npx --yes quadwork start${c.reset} — start the dashboard (Ctrl+C to stop)`);
1167
- console.log("");
1168
- console.log(` ${c.green}${c.bold}Happy shipping!${c.reset}`);
1328
+ console.log(` ${c.green}${c.bold}Happy shipping!${c.reset} ${c.dim}(Press Ctrl+C to stop.)${c.reset}`);
1169
1329
  console.log("");
1170
1330
 
1171
- // Open browser
1331
+ // Close the wizard readline before requiring the server, otherwise
1332
+ // stdin stays in raw/line-buffered mode and swallows Ctrl+C.
1333
+ rl.close();
1334
+
1335
+ // Schedule browser open after the server has had a moment to bind.
1172
1336
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1173
1337
  setTimeout(() => {
1174
1338
  try { execSync(`${openCmd} ${dashboardUrl}/setup`, { stdio: "ignore" }); } catch {}
1175
1339
  }, 1500);
1176
1340
 
1177
- rl.close();
1341
+ // Run the server in the foreground. require() starts the express
1342
+ // listener in this process, so cmdInit stays alive until Ctrl+C.
1343
+ // Capture the exports so the SIGINT handler can ask the server
1344
+ // to SIGTERM any AgentChattr children it spawned after init (the
1345
+ // user creates a project in /setup and clicks Start → the
1346
+ // dashboard launches python run.py as a detached child and only
1347
+ // the server knows its pid, via its in-memory `chattrProcesses`
1348
+ // Map).
1349
+ const serverExports = require(path.join(serverDir, "index.js"));
1350
+
1351
+ // Graceful shutdown on Ctrl+C. Kill any dashboard-spawned
1352
+ // AgentChattr children first, then exit so the port is released
1353
+ // and no python is orphaned.
1354
+ process.on("SIGINT", () => {
1355
+ console.log("");
1356
+ log("Shutting down...");
1357
+ try { serverExports && serverExports.shutdownChattrProcesses && serverExports.shutdownChattrProcesses(); }
1358
+ catch (e) { warn(`shutdownChattrProcesses failed: ${e.message}`); }
1359
+ ok("Stopped.");
1360
+ process.exit(0);
1361
+ });
1178
1362
  } catch (err) {
1179
1363
  fail(err.message);
1180
1364
  rl.close();
@@ -1184,6 +1368,118 @@ async function cmdInit() {
1184
1368
 
1185
1369
  // ─── Start Command ──────────────────────────────────────────────────────────
1186
1370
 
1371
+ /**
1372
+ * Phase 3 / #181 sub-G: migrate legacy v1 projects to per-project clones.
1373
+ *
1374
+ * Runs eagerly at the top of cmdStart() so users see clear progress before
1375
+ * any agents launch. For each project that doesn't yet have a working
1376
+ * per-project clone:
1377
+ * 1. Compute perProjectDir = ~/.quadwork/{project_id}/agentchattr
1378
+ * 2. installAgentChattr(perProjectDir) — idempotent (#183 + #187)
1379
+ * 3. Copy the existing legacy <working_dir>/agentchattr/config.toml into
1380
+ * the new clone ROOT if it exists. AgentChattr's run.py reads
1381
+ * ROOT/config.toml from the clone dir, so this is what makes the
1382
+ * project actually start from its own clone.
1383
+ * 4. Set project.agentchattr_dir on the config entry and persist.
1384
+ *
1385
+ * Idempotent: if a project already has a working per-project clone with a
1386
+ * config.toml at the ROOT and agentchattr_dir set, it is skipped silently.
1387
+ * The legacy ~/.quadwork/agentchattr/ install is left alone — cleanup is
1388
+ * sub-H (#189).
1389
+ *
1390
+ * The migration never touches worktrees, repo content, or token files;
1391
+ * only the per-project AgentChattr install dir and config.json.
1392
+ */
1393
+ function migrateLegacyProjects(config) {
1394
+ if (!config.projects || config.projects.length === 0) return false;
1395
+
1396
+ const needsMigration = config.projects.filter((p) => {
1397
+ if (!p.id) return false;
1398
+ const target = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
1399
+ const hasClone = fs.existsSync(path.join(target, "run.py")) &&
1400
+ fs.existsSync(path.join(target, ".venv", "bin", "python"));
1401
+ const hasToml = fs.existsSync(path.join(target, "config.toml"));
1402
+ const hasField = !!p.agentchattr_dir;
1403
+ return !(hasField && hasClone && hasToml);
1404
+ });
1405
+
1406
+ if (needsMigration.length === 0) return false;
1407
+
1408
+ header("Migrating legacy projects to per-project AgentChattr clones");
1409
+ let mutated = false;
1410
+ for (const project of needsMigration) {
1411
+ const perProjectDir = path.join(CONFIG_DIR, project.id, "agentchattr");
1412
+ log(` ${project.id} → ${perProjectDir}`);
1413
+
1414
+ // 1. Install (idempotent — no-op if clone is already valid).
1415
+ if (!findAgentChattr(perProjectDir)) {
1416
+ const acSpinner = spinner(` Cloning AgentChattr for ${project.id}...`);
1417
+ const installResult = installAgentChattr(perProjectDir);
1418
+ if (!installResult) {
1419
+ acSpinner.stop(false);
1420
+ const reason = installAgentChattr.lastError || "unknown error";
1421
+ warn(` Migration failed for ${project.id}: ${reason}`);
1422
+ warn(` ${project.id} will keep using the legacy global install until this is resolved.`);
1423
+ continue;
1424
+ }
1425
+ acSpinner.stop(true);
1426
+ }
1427
+
1428
+ // 2. Seed config.toml at the clone ROOT from the legacy in-worktree
1429
+ // location if present. Do not overwrite an existing per-project
1430
+ // config.toml — re-running the migration must be a no-op.
1431
+ //
1432
+ // If the legacy toml exists but the copy fails, we MUST NOT persist
1433
+ // agentchattr_dir — otherwise #186's resolver would switch this
1434
+ // project to a clone that lacks the project's real ports, and
1435
+ // AgentChattr would silently start on run.py defaults. Leaving
1436
+ // agentchattr_dir unset keeps the project on the legacy global
1437
+ // install via #186's fallback ladder until the next attempt.
1438
+ const targetToml = path.join(perProjectDir, "config.toml");
1439
+ let tomlReady = fs.existsSync(targetToml);
1440
+ if (!tomlReady && project.working_dir) {
1441
+ const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1442
+ if (fs.existsSync(legacyToml)) {
1443
+ try {
1444
+ fs.copyFileSync(legacyToml, targetToml);
1445
+ log(` Copied legacy config.toml → ${targetToml}`);
1446
+ tomlReady = true;
1447
+ } catch (e) {
1448
+ warn(` Could not copy ${legacyToml}: ${e.message}`);
1449
+ warn(` ${project.id} migration aborted: legacy config.toml not transferred.`);
1450
+ warn(` ${project.id} will keep using the legacy global install via #186 fallback.`);
1451
+ continue;
1452
+ }
1453
+ } else {
1454
+ // No legacy toml at all (e.g. user removed it). Refuse to migrate
1455
+ // — without a config.toml at the clone ROOT, run.py would start
1456
+ // on built-in defaults and bind to the wrong ports.
1457
+ warn(` ${project.id} has no legacy config.toml at ${legacyToml}; skipping migration.`);
1458
+ warn(` Re-run setup to regenerate config.toml, then 'quadwork start' will retry migration.`);
1459
+ continue;
1460
+ }
1461
+ }
1462
+ if (!tomlReady) {
1463
+ warn(` ${project.id} migration aborted: no config.toml at ${targetToml}.`);
1464
+ continue;
1465
+ }
1466
+
1467
+ // 3. Persist agentchattr_dir on the project entry — only after the
1468
+ // clone has run.py + venv + config.toml all in place.
1469
+ if (project.agentchattr_dir !== perProjectDir) {
1470
+ project.agentchattr_dir = perProjectDir;
1471
+ mutated = true;
1472
+ }
1473
+ }
1474
+
1475
+ if (mutated) {
1476
+ try { writeConfig(config); ok("Updated config.json with per-project agentchattr_dir entries"); }
1477
+ catch (e) { warn(`Failed to write config.json: ${e.message}`); }
1478
+ }
1479
+ log(" Legacy ~/.quadwork/agentchattr/ left in place; remove via cleanup script (#189).");
1480
+ return true;
1481
+ }
1482
+
1187
1483
  function cmdStart() {
1188
1484
  console.log("\n QuadWork Start\n");
1189
1485
 
@@ -1192,6 +1488,11 @@ function cmdStart() {
1192
1488
  warn("No projects configured yet. Create one at the setup page.");
1193
1489
  }
1194
1490
 
1491
+ // Phase 3 / #181: migrate legacy single-install projects to their
1492
+ // own per-project clones before any AgentChattr spawn happens.
1493
+ // Idempotent — a no-op once every project already has a working clone.
1494
+ migrateLegacyProjects(config);
1495
+
1195
1496
  const quadworkDir = path.join(__dirname, "..");
1196
1497
  const port = config.port || 8400;
1197
1498
 
@@ -1209,27 +1510,38 @@ function cmdStart() {
1209
1510
  process.exit(1);
1210
1511
  }
1211
1512
 
1212
- // Start AgentChattr for each project that has a config.toml
1513
+ // Start AgentChattr for each project from its own per-project clone.
1514
+ // Phase 2E / #181: each project entry now has agentchattr_dir, set by
1515
+ // the wizards in #184/#185. Resolve per-project so two projects with
1516
+ // their own clones (and their own ports) can run side by side without
1517
+ // sharing a single global install. Falls back to the legacy global
1518
+ // install dir for v1 entries that have not been migrated yet (#188).
1213
1519
  const acPids = [];
1214
- const acDir = findAgentChattr(config.agentchattr_dir);
1215
- if (acDir) {
1216
- for (const project of config.projects) {
1217
- if (!project.working_dir) continue;
1218
- const configToml = path.join(project.working_dir, "agentchattr", "config.toml");
1219
- if (!fs.existsSync(configToml)) continue;
1220
- const acSpawn = chattrSpawnArgs(acDir, ["--config", configToml]);
1221
- if (!acSpawn) continue;
1222
- const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
1223
- cwd: acSpawn.cwd,
1224
- stdio: "ignore",
1225
- detached: true,
1226
- });
1227
- acProc.on("error", () => {});
1228
- acProc.unref();
1229
- if (acProc.pid) {
1230
- ok(`AgentChattr started for ${project.id} (PID: ${acProc.pid})`);
1231
- acPids.push(acProc.pid);
1232
- }
1520
+ const legacyAcDir = findAgentChattr(config.agentchattr_dir);
1521
+ for (const project of config.projects) {
1522
+ if (!project.working_dir) continue;
1523
+ const projectAcDir = findAgentChattr(project.agentchattr_dir) || legacyAcDir;
1524
+ if (!projectAcDir) continue;
1525
+ // config.toml lives at the clone ROOT for new projects; legacy v1
1526
+ // setups still keep it under <working_dir>/agentchattr/config.toml.
1527
+ const perProjectToml = path.join(projectAcDir, "config.toml");
1528
+ const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1529
+ const configToml = fs.existsSync(perProjectToml)
1530
+ ? perProjectToml
1531
+ : (fs.existsSync(legacyToml) ? legacyToml : null);
1532
+ if (!configToml) continue;
1533
+ const acSpawn = chattrSpawnArgs(projectAcDir, ["--config", configToml]);
1534
+ if (!acSpawn) continue;
1535
+ const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
1536
+ cwd: acSpawn.cwd,
1537
+ stdio: "ignore",
1538
+ detached: true,
1539
+ });
1540
+ acProc.on("error", () => {});
1541
+ acProc.unref();
1542
+ if (acProc.pid) {
1543
+ ok(`AgentChattr started for ${project.id} from ${projectAcDir} (PID: ${acProc.pid})`);
1544
+ acPids.push(acProc.pid);
1233
1545
  }
1234
1546
  }
1235
1547
 
@@ -1240,13 +1552,25 @@ function cmdStart() {
1240
1552
  try { execSync(`${openCmd} ${dashboardUrl}`, { stdio: "ignore" }); } catch {}
1241
1553
  }, 1500);
1242
1554
 
1243
- // Graceful shutdown on Ctrl+C
1555
+ // Run server in foreground. Capture exports so the SIGINT handler
1556
+ // can ask the server to SIGTERM its own chattrProcesses Map too
1557
+ // (dashboard-spawned AgentChattr children aren't in cmdStart's
1558
+ // acPids list).
1559
+ log(`Dashboard: ${dashboardUrl}`);
1560
+ log("Press Ctrl+C to stop.\n");
1561
+ const serverExports = require(path.join(serverDir, "index.js"));
1562
+
1563
+ // Graceful shutdown on Ctrl+C — kills cmdStart's own spawned
1564
+ // AgentChattrs AND anything the dashboard spawned via
1565
+ // /api/agentchattr/{id}/start after init.
1244
1566
  process.on("SIGINT", () => {
1245
1567
  console.log("");
1246
1568
  log("Shutting down...");
1247
1569
  for (const pid of acPids) {
1248
1570
  try { process.kill(pid, "SIGTERM"); } catch {}
1249
1571
  }
1572
+ try { serverExports && serverExports.shutdownChattrProcesses && serverExports.shutdownChattrProcesses(); }
1573
+ catch (e) { warn(`shutdownChattrProcesses failed: ${e.message}`); }
1250
1574
  ok("Stopped.");
1251
1575
  console.log("");
1252
1576
  log("To restart:");
@@ -1254,11 +1578,6 @@ function cmdStart() {
1254
1578
  console.log("");
1255
1579
  process.exit(0);
1256
1580
  });
1257
-
1258
- // Run server in foreground
1259
- log(`Dashboard: ${dashboardUrl}`);
1260
- log("Press Ctrl+C to stop.\n");
1261
- require(path.join(serverDir, "index.js"));
1262
1581
  }
1263
1582
 
1264
1583
  // ─── Stop Command ───────────────────────────────────────────────────────────
@@ -1329,7 +1648,11 @@ async function cmdAddProject() {
1329
1648
 
1330
1649
  writeQuadWorkConfig(setup);
1331
1650
 
1332
- const configTomlPath = path.join(setup.absDir, "agentchattr", "config.toml");
1651
+ // Phase 2C / #181: config.toml lives at the per-project clone ROOT
1652
+ // because AgentChattr's run.py loads ROOT/config.toml and ignores
1653
+ // --config. Must match the install path used inside
1654
+ // writeAgentChattrConfig(): CONFIG_DIR/{projectName}/agentchattr.
1655
+ const configTomlPath = path.join(CONFIG_DIR, setup.projectName, "agentchattr", "config.toml");
1333
1656
  writeAgentChattrConfig(setup, configTomlPath);
1334
1657
 
1335
1658
  header("Project Added");
@@ -1346,6 +1669,112 @@ async function cmdAddProject() {
1346
1669
  }
1347
1670
  }
1348
1671
 
1672
+ // ─── Cleanup Command (#181 sub-H) ───────────────────────────────────────────
1673
+
1674
+ /**
1675
+ * Reclaim disk space taken by per-project AgentChattr clones (~77 MB each)
1676
+ * or by the legacy shared install left behind after migration (#188).
1677
+ *
1678
+ * Usage:
1679
+ * npx quadwork cleanup --project <id>
1680
+ * Removes ~/.quadwork/{id}/ and the matching entry from config.json.
1681
+ * Leaves the user's worktrees and source repos completely alone.
1682
+ *
1683
+ * npx quadwork cleanup --legacy
1684
+ * Removes the legacy shared ~/.quadwork/agentchattr/ install. Refuses
1685
+ * to run unless every project in config.json already has its own
1686
+ * working per-project clone (so nothing falls back onto the legacy
1687
+ * install via #186's resolution ladder).
1688
+ *
1689
+ * Both modes prompt for confirmation before deleting.
1690
+ */
1691
+ async function cmdCleanup() {
1692
+ const args = process.argv.slice(3);
1693
+ const projectFlagIdx = args.indexOf("--project");
1694
+ const projectId = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : null;
1695
+ const legacy = args.includes("--legacy");
1696
+
1697
+ if (!projectId && !legacy) {
1698
+ console.log(`
1699
+ Usage:
1700
+ npx quadwork cleanup --project <id> Remove a project's AgentChattr clone + config entry
1701
+ npx quadwork cleanup --legacy Remove the legacy ~/.quadwork/agentchattr/ install
1702
+ `);
1703
+ process.exit(1);
1704
+ }
1705
+
1706
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1707
+ try {
1708
+ const config = readConfig();
1709
+
1710
+ // --- Per-project cleanup ---
1711
+ if (projectId) {
1712
+ const idx = (config.projects || []).findIndex((p) => p.id === projectId);
1713
+ const projectDir = path.join(CONFIG_DIR, projectId);
1714
+ if (idx < 0 && !fs.existsSync(projectDir)) {
1715
+ warn(`No project '${projectId}' in config and no directory at ${projectDir}.`);
1716
+ return;
1717
+ }
1718
+ header(`Cleanup: ${projectId}`);
1719
+ if (fs.existsSync(projectDir)) log(` Directory: ${projectDir}`);
1720
+ if (idx >= 0) log(` Config entry: ${projectId} (${config.projects[idx].repo || "no repo"})`);
1721
+ log(" Worktrees and source repos will NOT be touched.");
1722
+ const confirm = await askYN(rl, `Delete ${projectDir} and remove the config entry?`, false);
1723
+ if (!confirm) { warn("Aborted."); return; }
1724
+
1725
+ if (fs.existsSync(projectDir)) {
1726
+ try { fs.rmSync(projectDir, { recursive: true, force: true }); ok(`Removed ${projectDir}`); }
1727
+ catch (e) { fail(`Could not remove ${projectDir}: ${e.message}`); return; }
1728
+ }
1729
+ if (idx >= 0) {
1730
+ config.projects.splice(idx, 1);
1731
+ try { writeConfig(config); ok(`Updated ${CONFIG_PATH}`); }
1732
+ catch (e) { fail(`Could not write config: ${e.message}`); return; }
1733
+ }
1734
+ return;
1735
+ }
1736
+
1737
+ // --- Legacy cleanup ---
1738
+ if (legacy) {
1739
+ const legacyDir = path.join(CONFIG_DIR, "agentchattr");
1740
+ if (!fs.existsSync(legacyDir)) {
1741
+ warn(`No legacy install at ${legacyDir}.`);
1742
+ return;
1743
+ }
1744
+ header("Cleanup: legacy ~/.quadwork/agentchattr/");
1745
+
1746
+ // Refuse if any project still depends on the legacy install — i.e.
1747
+ // any project without its own working per-project clone (run.py +
1748
+ // venv + config.toml at ROOT). Mirrors #186's resolution ladder.
1749
+ const stillDepends = [];
1750
+ for (const p of config.projects || []) {
1751
+ if (!p.id) continue;
1752
+ const dir = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
1753
+ const ok = fs.existsSync(path.join(dir, "run.py")) &&
1754
+ fs.existsSync(path.join(dir, ".venv", "bin", "python")) &&
1755
+ fs.existsSync(path.join(dir, "config.toml"));
1756
+ if (!ok) stillDepends.push(p.id);
1757
+ }
1758
+ if (stillDepends.length > 0) {
1759
+ fail(`Refusing to remove legacy install — these projects still depend on it:`);
1760
+ for (const id of stillDepends) console.log(` - ${id}`);
1761
+ warn(`Run 'npx quadwork start' to migrate them (#188), then re-run cleanup --legacy.`);
1762
+ return;
1763
+ }
1764
+
1765
+ log(` Directory: ${legacyDir}`);
1766
+ log(" All projects already have their own per-project clones.");
1767
+ const confirm = await askYN(rl, `Delete ${legacyDir}?`, false);
1768
+ if (!confirm) { warn("Aborted."); return; }
1769
+
1770
+ try { fs.rmSync(legacyDir, { recursive: true, force: true }); ok(`Removed ${legacyDir}`); }
1771
+ catch (e) { fail(`Could not remove ${legacyDir}: ${e.message}`); return; }
1772
+ }
1773
+ } finally {
1774
+ rl.close();
1775
+ }
1776
+ }
1777
+
1349
1778
  // ─── Main ───────────────────────────────────────────────────────────────────
1350
1779
 
1351
1780
  const command = process.argv[2];
@@ -1363,6 +1792,9 @@ switch (command) {
1363
1792
  case "add-project":
1364
1793
  cmdAddProject();
1365
1794
  break;
1795
+ case "cleanup":
1796
+ cmdCleanup();
1797
+ break;
1366
1798
  default:
1367
1799
  console.log(`
1368
1800
  Usage: quadwork <command>
@@ -1372,6 +1804,7 @@ switch (command) {
1372
1804
  start Start the QuadWork dashboard and backend
1373
1805
  stop Stop all QuadWork processes
1374
1806
  add-project Add a project via CLI (alternative to web UI /setup)
1807
+ cleanup Reclaim disk space (--project <id> or --legacy)
1375
1808
 
1376
1809
  Workflow:
1377
1810
  1. npx quadwork init — one-time global setup, opens dashboard
@@ -1382,6 +1815,8 @@ switch (command) {
1382
1815
  npx quadwork init
1383
1816
  npx quadwork start
1384
1817
  npx quadwork stop
1818
+ npx quadwork cleanup --project my-project
1819
+ npx quadwork cleanup --legacy
1385
1820
  `);
1386
1821
  if (command) process.exit(1);
1387
1822
  }