quadwork 1.0.17 → 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 (79) hide show
  1. package/README.md +28 -0
  2. package/bin/quadwork.js +436 -52
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +1 -1
  5. package/out/__next._full.txt +1 -1
  6. package/out/__next._head.txt +1 -1
  7. package/out/__next._index.txt +1 -1
  8. package/out/__next._tree.txt +1 -1
  9. package/out/_not-found/__next._full.txt +1 -1
  10. package/out/_not-found/__next._head.txt +1 -1
  11. package/out/_not-found/__next._index.txt +1 -1
  12. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  13. package/out/_not-found/__next._not-found.txt +1 -1
  14. package/out/_not-found/__next._tree.txt +1 -1
  15. package/out/_not-found.html +1 -1
  16. package/out/_not-found.txt +1 -1
  17. package/out/app-shell/__next._full.txt +1 -1
  18. package/out/app-shell/__next._head.txt +1 -1
  19. package/out/app-shell/__next._index.txt +1 -1
  20. package/out/app-shell/__next._tree.txt +1 -1
  21. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  22. package/out/app-shell/__next.app-shell.txt +1 -1
  23. package/out/app-shell.html +1 -1
  24. package/out/app-shell.txt +1 -1
  25. package/out/index.html +1 -1
  26. package/out/index.txt +1 -1
  27. package/out/project/_/__next._full.txt +1 -1
  28. package/out/project/_/__next._head.txt +1 -1
  29. package/out/project/_/__next._index.txt +1 -1
  30. package/out/project/_/__next._tree.txt +1 -1
  31. package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
  32. package/out/project/_/__next.project.$d$id.txt +1 -1
  33. package/out/project/_/__next.project.txt +1 -1
  34. package/out/project/_/memory/__next._full.txt +1 -1
  35. package/out/project/_/memory/__next._head.txt +1 -1
  36. package/out/project/_/memory/__next._index.txt +1 -1
  37. package/out/project/_/memory/__next._tree.txt +1 -1
  38. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
  39. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  40. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  41. package/out/project/_/memory/__next.project.txt +1 -1
  42. package/out/project/_/memory.html +1 -1
  43. package/out/project/_/memory.txt +1 -1
  44. package/out/project/_/queue/__next._full.txt +1 -1
  45. package/out/project/_/queue/__next._head.txt +1 -1
  46. package/out/project/_/queue/__next._index.txt +1 -1
  47. package/out/project/_/queue/__next._tree.txt +1 -1
  48. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  49. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  50. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  51. package/out/project/_/queue/__next.project.txt +1 -1
  52. package/out/project/_/queue.html +1 -1
  53. package/out/project/_/queue.txt +1 -1
  54. package/out/project/_.html +1 -1
  55. package/out/project/_.txt +1 -1
  56. package/out/settings/__next._full.txt +1 -1
  57. package/out/settings/__next._head.txt +1 -1
  58. package/out/settings/__next._index.txt +1 -1
  59. package/out/settings/__next._tree.txt +1 -1
  60. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  61. package/out/settings/__next.settings.txt +1 -1
  62. package/out/settings.html +1 -1
  63. package/out/settings.txt +1 -1
  64. package/out/setup/__next._full.txt +1 -1
  65. package/out/setup/__next._head.txt +1 -1
  66. package/out/setup/__next._index.txt +1 -1
  67. package/out/setup/__next._tree.txt +1 -1
  68. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  69. package/out/setup/__next.setup.txt +1 -1
  70. package/out/setup.html +1 -1
  71. package/out/setup.txt +1 -1
  72. package/package.json +1 -1
  73. package/server/config.js +22 -1
  74. package/server/index.js +13 -4
  75. package/server/install-agentchattr.js +215 -0
  76. package/server/routes.js +38 -3
  77. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → zx5_zAjM3qhPvkFrygZp8}/_buildManifest.js +0 -0
  78. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → zx5_zAjM3qhPvkFrygZp8}/_clientMiddlewareManifest.js +0 -0
  79. /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → zx5_zAjM3qhPvkFrygZp8}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -64,9 +64,37 @@ Every task follows a GitHub workflow: Issue → Branch → PR → 2 Reviews →
64
64
  | `npx quadwork init` | One-time setup — installs prerequisites and opens the dashboard |
65
65
  | `npx quadwork start` | Start the dashboard server |
66
66
  | `npx quadwork stop` | Stop all processes |
67
+ | `npx quadwork cleanup --project <id>` | Remove a project's AgentChattr clone and config entry |
68
+ | `npx quadwork cleanup --legacy` | Remove the legacy `~/.quadwork/agentchattr/` install after migration |
67
69
 
68
70
  After init, create projects from the web UI at `http://127.0.0.1:8400/setup`.
69
71
 
72
+ ### Disk usage
73
+
74
+ Each project gets its own AgentChattr clone at `~/.quadwork/{project_id}/agentchattr/` (~77 MB). For example:
75
+
76
+ | Projects | Disk |
77
+ |---------:|-----:|
78
+ | 1 | ~77 MB |
79
+ | 5 | ~385 MB |
80
+ | 10 | ~770 MB |
81
+
82
+ Per-project clones are necessary so multiple projects can run AgentChattr simultaneously without port conflicts (each clone has its own `config.toml`, ports, and data directory).
83
+
84
+ To free space, delete unused projects from the dashboard or run:
85
+
86
+ ```sh
87
+ npx quadwork cleanup --project <id>
88
+ ```
89
+
90
+ Existing v1 users are auto-migrated to per-project clones on the next `npx quadwork start`. After all projects are migrated, the legacy shared install can be removed with:
91
+
92
+ ```sh
93
+ npx quadwork cleanup --legacy
94
+ ```
95
+
96
+ `cleanup --legacy` refuses to run unless every project already has its own working per-project clone, so it can never strand a project on a missing install.
97
+
70
98
  ## Configuration
71
99
 
72
100
  Config is stored at `~/.quadwork/config.json`:
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.
@@ -801,25 +929,33 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
801
929
  fs.writeFileSync(configTomlPath, tomlContent);
802
930
  ok(`Wrote ${configTomlPath}`);
803
931
 
804
- // Start AgentChattr if available; optionally skip install attempt
805
- const acDir = findAgentChattr();
932
+ // Phase 2C / #181: clone AgentChattr per-project at
933
+ // ~/.quadwork/{project_id}/agentchattr/. AgentChattr's run.py loads
934
+ // ROOT/config.toml, so each project needs its own clone to avoid
935
+ // multi-instance port conflicts (see master #181). The path is the
936
+ // same one writeQuadWorkConfig() persists in project.agentchattr_dir.
937
+ const perProjectDir = path.join(CONFIG_DIR, setup.projectName, "agentchattr");
938
+ let acDir = findAgentChattr(perProjectDir);
806
939
  let acAvailable = !!acDir;
807
940
  if (!acAvailable && !skipInstall) {
808
- const acSpinner = spinner("Setting up AgentChattr...");
809
- const installResult = installAgentChattr();
941
+ const acSpinner = spinner(`Setting up AgentChattr at ${perProjectDir}...`);
942
+ const installResult = installAgentChattr(perProjectDir);
810
943
  if (installResult) {
811
944
  acSpinner.stop(true);
945
+ acDir = installResult;
812
946
  acAvailable = true;
813
947
  } else {
814
948
  acSpinner.stop(false);
815
- warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
949
+ const reason = installAgentChattr.lastError || "unknown error";
950
+ warn(`AgentChattr install failed at ${perProjectDir}: ${reason}`);
951
+ warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
816
952
  }
817
953
  }
818
954
 
819
955
  // Start AgentChattr server (only if installed)
820
956
  if (acAvailable) {
821
957
  log("Starting AgentChattr server...");
822
- const acSpawn = chattrSpawnArgs(findAgentChattr(), ["--config", configTomlPath]);
958
+ const acSpawn = chattrSpawnArgs(acDir, ["--config", configTomlPath]);
823
959
  if (acSpawn) {
824
960
  const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
825
961
  cwd: acSpawn.cwd,
@@ -836,14 +972,14 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
836
972
  const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
837
973
  fs.writeFileSync(pidFile, String(acProc.pid));
838
974
  } else {
839
- warn("Could not start AgentChattr — check logs in " + (findAgentChattr() || DEFAULT_AGENTCHATTR_DIR));
975
+ warn("Could not start AgentChattr — check logs in " + (acDir || perProjectDir));
840
976
  }
841
977
  } else {
842
978
  warn("AgentChattr run.py not found — skipping auto-start.");
843
979
  }
844
980
  } else {
845
981
  warn("AgentChattr not installed — skipping auto-start.");
846
- log(` → Install: git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
982
+ log(` → Install: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
847
983
  }
848
984
 
849
985
  return configTomlPath;
@@ -1061,6 +1197,10 @@ function writeQuadWorkConfig(setup) {
1061
1197
  project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
1062
1198
  project.mcp_http_port = mcp_http;
1063
1199
  project.mcp_sse_port = mcp_sse;
1200
+ // Per-project AgentChattr clone path (Option B / #181). Each project gets
1201
+ // its own clone so AgentChattr's ROOT/config.toml lookup picks up the right
1202
+ // ports — see master ticket #181.
1203
+ project.agentchattr_dir = path.join(os.homedir(), ".quadwork", setup.projectName, "agentchattr");
1064
1204
 
1065
1205
  // Upsert project
1066
1206
  if (existingIdx >= 0) config.projects[existingIdx] = project;
@@ -1184,6 +1324,118 @@ async function cmdInit() {
1184
1324
 
1185
1325
  // ─── Start Command ──────────────────────────────────────────────────────────
1186
1326
 
1327
+ /**
1328
+ * Phase 3 / #181 sub-G: migrate legacy v1 projects to per-project clones.
1329
+ *
1330
+ * Runs eagerly at the top of cmdStart() so users see clear progress before
1331
+ * any agents launch. For each project that doesn't yet have a working
1332
+ * per-project clone:
1333
+ * 1. Compute perProjectDir = ~/.quadwork/{project_id}/agentchattr
1334
+ * 2. installAgentChattr(perProjectDir) — idempotent (#183 + #187)
1335
+ * 3. Copy the existing legacy <working_dir>/agentchattr/config.toml into
1336
+ * the new clone ROOT if it exists. AgentChattr's run.py reads
1337
+ * ROOT/config.toml from the clone dir, so this is what makes the
1338
+ * project actually start from its own clone.
1339
+ * 4. Set project.agentchattr_dir on the config entry and persist.
1340
+ *
1341
+ * Idempotent: if a project already has a working per-project clone with a
1342
+ * config.toml at the ROOT and agentchattr_dir set, it is skipped silently.
1343
+ * The legacy ~/.quadwork/agentchattr/ install is left alone — cleanup is
1344
+ * sub-H (#189).
1345
+ *
1346
+ * The migration never touches worktrees, repo content, or token files;
1347
+ * only the per-project AgentChattr install dir and config.json.
1348
+ */
1349
+ function migrateLegacyProjects(config) {
1350
+ if (!config.projects || config.projects.length === 0) return false;
1351
+
1352
+ const needsMigration = config.projects.filter((p) => {
1353
+ if (!p.id) return false;
1354
+ const target = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
1355
+ const hasClone = fs.existsSync(path.join(target, "run.py")) &&
1356
+ fs.existsSync(path.join(target, ".venv", "bin", "python"));
1357
+ const hasToml = fs.existsSync(path.join(target, "config.toml"));
1358
+ const hasField = !!p.agentchattr_dir;
1359
+ return !(hasField && hasClone && hasToml);
1360
+ });
1361
+
1362
+ if (needsMigration.length === 0) return false;
1363
+
1364
+ header("Migrating legacy projects to per-project AgentChattr clones");
1365
+ let mutated = false;
1366
+ for (const project of needsMigration) {
1367
+ const perProjectDir = path.join(CONFIG_DIR, project.id, "agentchattr");
1368
+ log(` ${project.id} → ${perProjectDir}`);
1369
+
1370
+ // 1. Install (idempotent — no-op if clone is already valid).
1371
+ if (!findAgentChattr(perProjectDir)) {
1372
+ const acSpinner = spinner(` Cloning AgentChattr for ${project.id}...`);
1373
+ const installResult = installAgentChattr(perProjectDir);
1374
+ if (!installResult) {
1375
+ acSpinner.stop(false);
1376
+ const reason = installAgentChattr.lastError || "unknown error";
1377
+ warn(` Migration failed for ${project.id}: ${reason}`);
1378
+ warn(` ${project.id} will keep using the legacy global install until this is resolved.`);
1379
+ continue;
1380
+ }
1381
+ acSpinner.stop(true);
1382
+ }
1383
+
1384
+ // 2. Seed config.toml at the clone ROOT from the legacy in-worktree
1385
+ // location if present. Do not overwrite an existing per-project
1386
+ // config.toml — re-running the migration must be a no-op.
1387
+ //
1388
+ // If the legacy toml exists but the copy fails, we MUST NOT persist
1389
+ // agentchattr_dir — otherwise #186's resolver would switch this
1390
+ // project to a clone that lacks the project's real ports, and
1391
+ // AgentChattr would silently start on run.py defaults. Leaving
1392
+ // agentchattr_dir unset keeps the project on the legacy global
1393
+ // install via #186's fallback ladder until the next attempt.
1394
+ const targetToml = path.join(perProjectDir, "config.toml");
1395
+ let tomlReady = fs.existsSync(targetToml);
1396
+ if (!tomlReady && project.working_dir) {
1397
+ const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1398
+ if (fs.existsSync(legacyToml)) {
1399
+ try {
1400
+ fs.copyFileSync(legacyToml, targetToml);
1401
+ log(` Copied legacy config.toml → ${targetToml}`);
1402
+ tomlReady = true;
1403
+ } catch (e) {
1404
+ warn(` Could not copy ${legacyToml}: ${e.message}`);
1405
+ warn(` ${project.id} migration aborted: legacy config.toml not transferred.`);
1406
+ warn(` ${project.id} will keep using the legacy global install via #186 fallback.`);
1407
+ continue;
1408
+ }
1409
+ } else {
1410
+ // No legacy toml at all (e.g. user removed it). Refuse to migrate
1411
+ // — without a config.toml at the clone ROOT, run.py would start
1412
+ // on built-in defaults and bind to the wrong ports.
1413
+ warn(` ${project.id} has no legacy config.toml at ${legacyToml}; skipping migration.`);
1414
+ warn(` Re-run setup to regenerate config.toml, then 'quadwork start' will retry migration.`);
1415
+ continue;
1416
+ }
1417
+ }
1418
+ if (!tomlReady) {
1419
+ warn(` ${project.id} migration aborted: no config.toml at ${targetToml}.`);
1420
+ continue;
1421
+ }
1422
+
1423
+ // 3. Persist agentchattr_dir on the project entry — only after the
1424
+ // clone has run.py + venv + config.toml all in place.
1425
+ if (project.agentchattr_dir !== perProjectDir) {
1426
+ project.agentchattr_dir = perProjectDir;
1427
+ mutated = true;
1428
+ }
1429
+ }
1430
+
1431
+ if (mutated) {
1432
+ try { writeConfig(config); ok("Updated config.json with per-project agentchattr_dir entries"); }
1433
+ catch (e) { warn(`Failed to write config.json: ${e.message}`); }
1434
+ }
1435
+ log(" Legacy ~/.quadwork/agentchattr/ left in place; remove via cleanup script (#189).");
1436
+ return true;
1437
+ }
1438
+
1187
1439
  function cmdStart() {
1188
1440
  console.log("\n QuadWork Start\n");
1189
1441
 
@@ -1192,6 +1444,11 @@ function cmdStart() {
1192
1444
  warn("No projects configured yet. Create one at the setup page.");
1193
1445
  }
1194
1446
 
1447
+ // Phase 3 / #181: migrate legacy single-install projects to their
1448
+ // own per-project clones before any AgentChattr spawn happens.
1449
+ // Idempotent — a no-op once every project already has a working clone.
1450
+ migrateLegacyProjects(config);
1451
+
1195
1452
  const quadworkDir = path.join(__dirname, "..");
1196
1453
  const port = config.port || 8400;
1197
1454
 
@@ -1209,27 +1466,38 @@ function cmdStart() {
1209
1466
  process.exit(1);
1210
1467
  }
1211
1468
 
1212
- // Start AgentChattr for each project that has a config.toml
1469
+ // Start AgentChattr for each project from its own per-project clone.
1470
+ // Phase 2E / #181: each project entry now has agentchattr_dir, set by
1471
+ // the wizards in #184/#185. Resolve per-project so two projects with
1472
+ // their own clones (and their own ports) can run side by side without
1473
+ // sharing a single global install. Falls back to the legacy global
1474
+ // install dir for v1 entries that have not been migrated yet (#188).
1213
1475
  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
- }
1476
+ const legacyAcDir = findAgentChattr(config.agentchattr_dir);
1477
+ for (const project of config.projects) {
1478
+ if (!project.working_dir) continue;
1479
+ const projectAcDir = findAgentChattr(project.agentchattr_dir) || legacyAcDir;
1480
+ if (!projectAcDir) continue;
1481
+ // config.toml lives at the clone ROOT for new projects; legacy v1
1482
+ // setups still keep it under <working_dir>/agentchattr/config.toml.
1483
+ const perProjectToml = path.join(projectAcDir, "config.toml");
1484
+ const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1485
+ const configToml = fs.existsSync(perProjectToml)
1486
+ ? perProjectToml
1487
+ : (fs.existsSync(legacyToml) ? legacyToml : null);
1488
+ if (!configToml) continue;
1489
+ const acSpawn = chattrSpawnArgs(projectAcDir, ["--config", configToml]);
1490
+ if (!acSpawn) continue;
1491
+ const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
1492
+ cwd: acSpawn.cwd,
1493
+ stdio: "ignore",
1494
+ detached: true,
1495
+ });
1496
+ acProc.on("error", () => {});
1497
+ acProc.unref();
1498
+ if (acProc.pid) {
1499
+ ok(`AgentChattr started for ${project.id} from ${projectAcDir} (PID: ${acProc.pid})`);
1500
+ acPids.push(acProc.pid);
1233
1501
  }
1234
1502
  }
1235
1503
 
@@ -1329,7 +1597,11 @@ async function cmdAddProject() {
1329
1597
 
1330
1598
  writeQuadWorkConfig(setup);
1331
1599
 
1332
- const configTomlPath = path.join(setup.absDir, "agentchattr", "config.toml");
1600
+ // Phase 2C / #181: config.toml lives at the per-project clone ROOT
1601
+ // because AgentChattr's run.py loads ROOT/config.toml and ignores
1602
+ // --config. Must match the install path used inside
1603
+ // writeAgentChattrConfig(): CONFIG_DIR/{projectName}/agentchattr.
1604
+ const configTomlPath = path.join(CONFIG_DIR, setup.projectName, "agentchattr", "config.toml");
1333
1605
  writeAgentChattrConfig(setup, configTomlPath);
1334
1606
 
1335
1607
  header("Project Added");
@@ -1346,6 +1618,112 @@ async function cmdAddProject() {
1346
1618
  }
1347
1619
  }
1348
1620
 
1621
+ // ─── Cleanup Command (#181 sub-H) ───────────────────────────────────────────
1622
+
1623
+ /**
1624
+ * Reclaim disk space taken by per-project AgentChattr clones (~77 MB each)
1625
+ * or by the legacy shared install left behind after migration (#188).
1626
+ *
1627
+ * Usage:
1628
+ * npx quadwork cleanup --project <id>
1629
+ * Removes ~/.quadwork/{id}/ and the matching entry from config.json.
1630
+ * Leaves the user's worktrees and source repos completely alone.
1631
+ *
1632
+ * npx quadwork cleanup --legacy
1633
+ * Removes the legacy shared ~/.quadwork/agentchattr/ install. Refuses
1634
+ * to run unless every project in config.json already has its own
1635
+ * working per-project clone (so nothing falls back onto the legacy
1636
+ * install via #186's resolution ladder).
1637
+ *
1638
+ * Both modes prompt for confirmation before deleting.
1639
+ */
1640
+ async function cmdCleanup() {
1641
+ const args = process.argv.slice(3);
1642
+ const projectFlagIdx = args.indexOf("--project");
1643
+ const projectId = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : null;
1644
+ const legacy = args.includes("--legacy");
1645
+
1646
+ if (!projectId && !legacy) {
1647
+ console.log(`
1648
+ Usage:
1649
+ npx quadwork cleanup --project <id> Remove a project's AgentChattr clone + config entry
1650
+ npx quadwork cleanup --legacy Remove the legacy ~/.quadwork/agentchattr/ install
1651
+ `);
1652
+ process.exit(1);
1653
+ }
1654
+
1655
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1656
+ try {
1657
+ const config = readConfig();
1658
+
1659
+ // --- Per-project cleanup ---
1660
+ if (projectId) {
1661
+ const idx = (config.projects || []).findIndex((p) => p.id === projectId);
1662
+ const projectDir = path.join(CONFIG_DIR, projectId);
1663
+ if (idx < 0 && !fs.existsSync(projectDir)) {
1664
+ warn(`No project '${projectId}' in config and no directory at ${projectDir}.`);
1665
+ return;
1666
+ }
1667
+ header(`Cleanup: ${projectId}`);
1668
+ if (fs.existsSync(projectDir)) log(` Directory: ${projectDir}`);
1669
+ if (idx >= 0) log(` Config entry: ${projectId} (${config.projects[idx].repo || "no repo"})`);
1670
+ log(" Worktrees and source repos will NOT be touched.");
1671
+ const confirm = await askYN(rl, `Delete ${projectDir} and remove the config entry?`, false);
1672
+ if (!confirm) { warn("Aborted."); return; }
1673
+
1674
+ if (fs.existsSync(projectDir)) {
1675
+ try { fs.rmSync(projectDir, { recursive: true, force: true }); ok(`Removed ${projectDir}`); }
1676
+ catch (e) { fail(`Could not remove ${projectDir}: ${e.message}`); return; }
1677
+ }
1678
+ if (idx >= 0) {
1679
+ config.projects.splice(idx, 1);
1680
+ try { writeConfig(config); ok(`Updated ${CONFIG_PATH}`); }
1681
+ catch (e) { fail(`Could not write config: ${e.message}`); return; }
1682
+ }
1683
+ return;
1684
+ }
1685
+
1686
+ // --- Legacy cleanup ---
1687
+ if (legacy) {
1688
+ const legacyDir = path.join(CONFIG_DIR, "agentchattr");
1689
+ if (!fs.existsSync(legacyDir)) {
1690
+ warn(`No legacy install at ${legacyDir}.`);
1691
+ return;
1692
+ }
1693
+ header("Cleanup: legacy ~/.quadwork/agentchattr/");
1694
+
1695
+ // Refuse if any project still depends on the legacy install — i.e.
1696
+ // any project without its own working per-project clone (run.py +
1697
+ // venv + config.toml at ROOT). Mirrors #186's resolution ladder.
1698
+ const stillDepends = [];
1699
+ for (const p of config.projects || []) {
1700
+ if (!p.id) continue;
1701
+ const dir = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
1702
+ const ok = fs.existsSync(path.join(dir, "run.py")) &&
1703
+ fs.existsSync(path.join(dir, ".venv", "bin", "python")) &&
1704
+ fs.existsSync(path.join(dir, "config.toml"));
1705
+ if (!ok) stillDepends.push(p.id);
1706
+ }
1707
+ if (stillDepends.length > 0) {
1708
+ fail(`Refusing to remove legacy install — these projects still depend on it:`);
1709
+ for (const id of stillDepends) console.log(` - ${id}`);
1710
+ warn(`Run 'npx quadwork start' to migrate them (#188), then re-run cleanup --legacy.`);
1711
+ return;
1712
+ }
1713
+
1714
+ log(` Directory: ${legacyDir}`);
1715
+ log(" All projects already have their own per-project clones.");
1716
+ const confirm = await askYN(rl, `Delete ${legacyDir}?`, false);
1717
+ if (!confirm) { warn("Aborted."); return; }
1718
+
1719
+ try { fs.rmSync(legacyDir, { recursive: true, force: true }); ok(`Removed ${legacyDir}`); }
1720
+ catch (e) { fail(`Could not remove ${legacyDir}: ${e.message}`); return; }
1721
+ }
1722
+ } finally {
1723
+ rl.close();
1724
+ }
1725
+ }
1726
+
1349
1727
  // ─── Main ───────────────────────────────────────────────────────────────────
1350
1728
 
1351
1729
  const command = process.argv[2];
@@ -1363,6 +1741,9 @@ switch (command) {
1363
1741
  case "add-project":
1364
1742
  cmdAddProject();
1365
1743
  break;
1744
+ case "cleanup":
1745
+ cmdCleanup();
1746
+ break;
1366
1747
  default:
1367
1748
  console.log(`
1368
1749
  Usage: quadwork <command>
@@ -1372,6 +1753,7 @@ switch (command) {
1372
1753
  start Start the QuadWork dashboard and backend
1373
1754
  stop Stop all QuadWork processes
1374
1755
  add-project Add a project via CLI (alternative to web UI /setup)
1756
+ cleanup Reclaim disk space (--project <id> or --legacy)
1375
1757
 
1376
1758
  Workflow:
1377
1759
  1. npx quadwork init — one-time global setup, opens dashboard
@@ -1382,6 +1764,8 @@ switch (command) {
1382
1764
  npx quadwork init
1383
1765
  npx quadwork start
1384
1766
  npx quadwork stop
1767
+ npx quadwork cleanup --project my-project
1768
+ npx quadwork cleanup --legacy
1385
1769
  `);
1386
1770
  if (command) process.exit(1);
1387
1771
  }