quadwork 1.19.3 → 2.0.1

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 (118) hide show
  1. package/README.md +19 -35
  2. package/bin/quadwork.js +48 -1118
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +14 -14
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
  10. package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
  11. package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
  12. package/out/_next/static/chunks/{08tog0xc~.es_.js → 0jllnzexn48._.js} +1 -1
  13. package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
  14. package/out/_next/static/chunks/0py7102i226n5.js +1 -0
  15. package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
  16. package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
  17. package/out/_next/static/chunks/{11khe5i7gu158.js → 0z.9wnba-t6z8.js} +1 -1
  18. package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
  19. package/out/_next/static/chunks/163_ddkdca5q4.js +25 -0
  20. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  21. package/out/_not-found/__next._full.txt +13 -13
  22. package/out/_not-found/__next._head.txt +4 -4
  23. package/out/_not-found/__next._index.txt +8 -8
  24. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  25. package/out/_not-found/__next._not-found.txt +3 -3
  26. package/out/_not-found/__next._tree.txt +2 -2
  27. package/out/_not-found.html +1 -1
  28. package/out/_not-found.txt +13 -13
  29. package/out/app-shell/__next._full.txt +13 -13
  30. package/out/app-shell/__next._head.txt +4 -4
  31. package/out/app-shell/__next._index.txt +8 -8
  32. package/out/app-shell/__next._tree.txt +2 -2
  33. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  34. package/out/app-shell/__next.app-shell.txt +3 -3
  35. package/out/app-shell.html +1 -1
  36. package/out/app-shell.txt +13 -13
  37. package/out/index.html +1 -1
  38. package/out/index.txt +14 -14
  39. package/out/project/_/__next._full.txt +14 -14
  40. package/out/project/_/__next._head.txt +4 -4
  41. package/out/project/_/__next._index.txt +8 -8
  42. package/out/project/_/__next._tree.txt +2 -2
  43. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  44. package/out/project/_/__next.project.$d$id.txt +3 -3
  45. package/out/project/_/__next.project.txt +3 -3
  46. package/out/project/_/queue/__next._full.txt +14 -14
  47. package/out/project/_/queue/__next._head.txt +4 -4
  48. package/out/project/_/queue/__next._index.txt +8 -8
  49. package/out/project/_/queue/__next._tree.txt +2 -2
  50. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  52. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  53. package/out/project/_/queue/__next.project.txt +3 -3
  54. package/out/project/_/queue.html +1 -1
  55. package/out/project/_/queue.txt +14 -14
  56. package/out/project/_.html +1 -1
  57. package/out/project/_.txt +14 -14
  58. package/out/settings/__next._full.txt +14 -14
  59. package/out/settings/__next._head.txt +4 -4
  60. package/out/settings/__next._index.txt +8 -8
  61. package/out/settings/__next._tree.txt +2 -2
  62. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  63. package/out/settings/__next.settings.txt +3 -3
  64. package/out/settings.html +1 -1
  65. package/out/settings.txt +14 -14
  66. package/out/setup/__next._full.txt +14 -14
  67. package/out/setup/__next._head.txt +4 -4
  68. package/out/setup/__next._index.txt +8 -8
  69. package/out/setup/__next._tree.txt +2 -2
  70. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  71. package/out/setup/__next.setup.txt +3 -3
  72. package/out/setup.html +1 -1
  73. package/out/setup.txt +14 -14
  74. package/package.json +4 -2
  75. package/server/ac-restore.js +128 -0
  76. package/server/bridges/discord.js +244 -0
  77. package/server/bridges/telegram.js +258 -0
  78. package/server/config.js +4 -60
  79. package/server/file-chat.js +318 -0
  80. package/server/index.js +129 -1294
  81. package/server/install-agentchattr.js +3 -284
  82. package/server/mcp-chat-shim.js +171 -0
  83. package/server/migrate-ac.js +158 -0
  84. package/server/pty-dispatcher.js +188 -0
  85. package/server/routes.js +155 -1398
  86. package/templates/CLAUDE.md +2 -2
  87. package/templates/OVERNIGHT-QUEUE.md +1 -1
  88. package/templates/seeds/butler.CLAUDE.md +30 -62
  89. package/templates/seeds/dev.AGENTS.md +10 -1
  90. package/templates/seeds/head.AGENTS.md +12 -8
  91. package/templates/seeds/re1.AGENTS.md +3 -3
  92. package/templates/seeds/re2.AGENTS.md +3 -3
  93. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  94. package/bridges/discord/discord_bridge.py +0 -666
  95. package/bridges/discord/requirements.txt +0 -2
  96. package/out/_next/static/chunks/08kw.2kplxa.6.css +0 -2
  97. package/out/_next/static/chunks/0_nm7se0m3twm.js +0 -25
  98. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  99. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  100. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  101. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  102. package/server/__tests__/rate-limit-handling.test.js +0 -168
  103. package/server/__tests__/scrub-secrets.test.js +0 -235
  104. package/server/__tests__/v1110-security-qa.test.js +0 -312
  105. package/server/agentchattr-registry.js +0 -188
  106. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  107. package/server/queue-watcher.js +0 -171
  108. package/server/queue-watcher.test.js +0 -64
  109. package/server/routes.batchProgress.test.js +0 -94
  110. package/server/routes.chatWsSend.test.js +0 -161
  111. package/server/routes.discordBridge.test.js +0 -80
  112. package/server/routes.parseActiveBatch.test.js +0 -88
  113. package/server/routes.telegramBridge.test.js +0 -241
  114. package/templates/config.toml +0 -72
  115. package/templates/wrapper.py +0 -70
  116. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_buildManifest.js +0 -0
  117. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_clientMiddlewareManifest.js +0 -0
  118. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_ssgManifest.js +0 -0
package/bin/quadwork.js CHANGED
@@ -12,22 +12,6 @@ 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
14
  const AGENTS = ["head", "re1", "re2", "dev"];
15
- const DEFAULT_AGENTCHATTR_DIR = path.join(CONFIG_DIR, "agentchattr");
16
- const AGENTCHATTR_REPO = "https://github.com/bcurts/agentchattr.git";
17
- // #348: pinned AgentChattr commit shipped with this QuadWork
18
- // release. The install path clones the default branch and then
19
- // checks this commit out so two fresh installs on different days
20
- // produce byte-identical clones. When bumping this pin,
21
- // deliberately test against the new upstream commit first and
22
- // note the update in docs/RELEASING.md.
23
- //
24
- // On checkout failure (e.g. upstream force-pushed the commit
25
- // away), the install path falls back to the default branch with
26
- // a loud warning instead of hard-failing — see installAgentChattr
27
- // below.
28
- const AGENTCHATTR_PIN = "3e71d4267572579e7ffeb83576645f90932c1849";
29
- // #444: same pattern for realproject7/agentchattr-telegram.
30
- const AGENTCHATTR_TELEGRAM_PIN = "4a6b45f1794c612328b9d5ee6d6fcb3f77015abc";
31
15
 
32
16
  // ─── Permission Helpers ────────────────────────────────────────────────────
33
17
 
@@ -86,277 +70,6 @@ function which(cmd) {
86
70
  return run("which", [cmd]) !== null;
87
71
  }
88
72
 
89
- /**
90
- * Resolve the agentchattr_dir from config, falling back to DEFAULT_AGENTCHATTR_DIR.
91
- */
92
- function getAgentChattrDir() {
93
- const config = readConfig();
94
- return config.agentchattr_dir || DEFAULT_AGENTCHATTR_DIR;
95
- }
96
-
97
- /**
98
- * Check if AgentChattr is fully installed (cloned + venv ready).
99
- * Returns the directory path if both run.py and .venv/bin/python exist, or null.
100
- */
101
- function findAgentChattr(dir) {
102
- dir = dir || getAgentChattrDir();
103
- if (fs.existsSync(path.join(dir, "run.py")) && fs.existsSync(path.join(dir, ".venv", "bin", "python"))) return dir;
104
- return null;
105
- }
106
-
107
- /**
108
- * Clone AgentChattr and set up its venv. Idempotent — safe to re-run on
109
- * the same path, and safe to call repeatedly with different paths in
110
- * the same process. Designed to support per-project clones (#181).
111
- *
112
- * Behavior on re-run:
113
- * - Fully-installed path → no-op (skips clone, skips venv create, skips pip)
114
- * - Missing run.py → clones (only after refusing to overwrite
115
- * unrelated content; see safety rules below)
116
- * - Missing venv → creates venv and reinstalls requirements
117
- *
118
- * Safety rules — never accidentally clean up unrelated directories:
119
- * - Empty dir → safe to remove
120
- * - Git repo whose origin contains "agentchattr" → safe to remove
121
- * - Anything else → refuse, return null
122
- *
123
- * On failure, returns null and stores a human-readable reason on
124
- * `installAgentChattr.lastError` so callers can surface it without
125
- * changing the return shape.
126
- */
127
- // Stale-lock thresholds for installAgentChattr().
128
- // Lock files older than this OR whose owning pid is no longer alive are
129
- // treated as crashed and reclaimed. Tuned to comfortably exceed the longest
130
- // step (pip install of agentchattr requirements, ~120s timeout).
131
- const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
132
- const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
133
- const INSTALL_LOCK_POLL_MS = 500;
134
-
135
- function _isPidAlive(pid) {
136
- if (!pid || !Number.isFinite(pid)) return false;
137
- try { process.kill(pid, 0); return true; }
138
- catch (e) { return e.code === "EPERM"; }
139
- }
140
-
141
- function _readLock(lockFile) {
142
- try {
143
- const raw = fs.readFileSync(lockFile, "utf-8").trim();
144
- const [pidStr, tsStr] = raw.split(":");
145
- return { pid: parseInt(pidStr, 10), ts: parseInt(tsStr, 10) || 0 };
146
- } catch { return null; }
147
- }
148
-
149
- function _isLockStale(lockFile) {
150
- const info = _readLock(lockFile);
151
- if (!info) return true; // unreadable → assume stale
152
- if (Date.now() - info.ts > INSTALL_LOCK_STALE_MS) return true;
153
- if (!_isPidAlive(info.pid)) return true;
154
- return false;
155
- }
156
-
157
- function installAgentChattr(dir) {
158
- dir = dir || getAgentChattrDir();
159
- installAgentChattr.lastError = null;
160
- const setError = (msg) => { installAgentChattr.lastError = msg; return null; };
161
-
162
- // --- Per-target lock to prevent concurrent clones from corrupting each
163
- // other when two projects (or two web tabs) launch simultaneously. Lock
164
- // file lives next to the install dir so it's scoped per-target.
165
- const lockFile = `${dir}.install.lock`;
166
- try { ensureSecureDir(path.dirname(lockFile)); }
167
- catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
168
-
169
- let acquired = false;
170
- const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
171
- while (!acquired) {
172
- try {
173
- // Atomic create: fails if file already exists, no TOCTOU race.
174
- fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { mode: 0o600, flag: "wx" });
175
- acquired = true;
176
- } catch (e) {
177
- if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
178
- // Reclaim if the existing lock is stale (crashed pid or too old).
179
- // Use rename → unlink instead of unlink directly: rename is atomic,
180
- // so only one racing process can move the stale lock aside. The
181
- // others see ENOENT and just retry the wx create. Without this,
182
- // two processes could both observe the same stale lock, both
183
- // unlink it (one of those unlinks would target the *next* lock
184
- // freshly acquired by a third process), and both proceed past the
185
- // gate concurrently — see review on quadwork#193.
186
- if (_isLockStale(lockFile)) {
187
- const sideline = `${lockFile}.stale.${process.pid}.${Date.now()}`;
188
- try {
189
- fs.renameSync(lockFile, sideline);
190
- try { fs.unlinkSync(sideline); } catch {}
191
- } catch (renameErr) {
192
- // ENOENT: another process already reclaimed it. Anything else:
193
- // treat as transient and retry — the next iteration will read
194
- // whatever is at lockFile now and decide again.
195
- if (renameErr.code !== "ENOENT") {
196
- return setError(`Cannot reclaim stale lock ${lockFile}: ${renameErr.message}`);
197
- }
198
- }
199
- continue;
200
- }
201
- // Live peer install in progress. After it finishes, the install
202
- // is likely already done — caller will see a fully-installed path
203
- // on the next call. While waiting, poll until the lock disappears
204
- // or we hit the wait deadline.
205
- if (Date.now() >= deadline) {
206
- const info = _readLock(lockFile) || { pid: "?", ts: 0 };
207
- 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.`);
208
- }
209
- // Synchronous sleep — installAgentChattr is itself synchronous and
210
- // is called from the CLI wizard, where blocking is acceptable.
211
- // Use execFileSync('sleep') instead of a busy-wait so we don't pin a CPU.
212
- try { require("child_process").execFileSync("sleep", [String(INSTALL_LOCK_POLL_MS / 1000)], { stdio: "pipe" }); }
213
- catch { /* sleep interrupted; loop will recheck */ }
214
- }
215
- }
216
-
217
- try {
218
- return _installAgentChattrLocked(dir, setError);
219
- } finally {
220
- try { fs.unlinkSync(lockFile); } catch {}
221
- }
222
- }
223
-
224
- function _installAgentChattrLocked(dir, setError) {
225
- const runPy = path.join(dir, "run.py");
226
- const venvPython = path.join(dir, ".venv", "bin", "python");
227
- let venvJustCreated = false;
228
-
229
- // 1. Clone if run.py is missing.
230
- if (!fs.existsSync(runPy)) {
231
- if (fs.existsSync(dir)) {
232
- let entries;
233
- try { entries = fs.readdirSync(dir); }
234
- catch (e) { return setError(`Cannot read ${dir}: ${e.message}`); }
235
- const isEmpty = entries.length === 0;
236
- if (isEmpty) {
237
- try { fs.rmSync(dir, { recursive: true, force: true }); }
238
- catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
239
- } else if (fs.existsSync(path.join(dir, ".git"))) {
240
- // Only remove if origin remote positively identifies this as agentchattr.
241
- const remote = run("git", ["-C", dir, "remote", "get-url", "origin"]);
242
- if (remote && remote.includes("agentchattr")) {
243
- try { fs.rmSync(dir, { recursive: true, force: true }); }
244
- catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
245
- } else {
246
- return setError(`Refusing to overwrite ${dir}: contains a non-AgentChattr git repo`);
247
- }
248
- } else {
249
- return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
250
- }
251
- }
252
- // Ensure parent exists before clone (supports arbitrary nested paths).
253
- try { ensureSecureDir(path.dirname(dir)); }
254
- catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
255
- const cloneResult = run("git", ["clone", AGENTCHATTR_REPO, dir], { timeout: 60000 });
256
- if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
257
- if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
258
- // #348: pin to the known-good AgentChattr commit shipped with
259
- // this QuadWork release. #366: use `checkout -B pinned <sha>`
260
- // so the clone lands on a named local branch ("pinned") whose
261
- // HEAD is the pinned commit, rather than detached HEAD. Named
262
- // HEAD avoids the AC2 worktree-rot class of bug: `git status`
263
- // says `On branch pinned`, downstream tooling that reads the
264
- // current branch sees a stable name, and a future `git pull`
265
- // fails with a clear "no upstream" message instead of a vague
266
- // detached-HEAD warning. -B (capital) is idempotent — it
267
- // force-creates/updates the branch so re-runs are no-ops.
268
- // On failure (commit unreachable / force-pushed away), fall
269
- // back to the default branch with a loud warning instead of
270
- // hard-failing the install.
271
- // #593: Retry with explicit fetch on failure, abort install on persistent failure.
272
- let pinResult = run("git", ["-C", dir, "checkout", "-B", "pinned", AGENTCHATTR_PIN], { timeout: 30000 });
273
- if (pinResult === null) {
274
- log("Pin checkout failed — fetching commit explicitly...");
275
- run("git", ["-C", dir, "fetch", "origin", AGENTCHATTR_PIN], { timeout: 60000 });
276
- pinResult = run("git", ["-C", dir, "checkout", "-B", "pinned", AGENTCHATTR_PIN], { timeout: 30000 });
277
- }
278
- if (pinResult === null) {
279
- return setError(
280
- `Could not check out AgentChattr pin ${AGENTCHATTR_PIN.slice(0, 12)} at ${dir}. ` +
281
- `AC would run an untested HEAD version with known incompatibilities. ` +
282
- `Check your network connection and retry, or manually run:\n` +
283
- ` git -C ${dir} fetch origin ${AGENTCHATTR_PIN}\n` +
284
- ` git -C ${dir} checkout -B pinned ${AGENTCHATTR_PIN}`
285
- );
286
- }
287
- } else {
288
- // #366: existing clone from a pre-fix install. If the clone
289
- // is currently in detached HEAD pointing exactly at the pin,
290
- // migrate it onto the named `pinned` branch in place. This
291
- // is safe because no commits are lost — the SHA is the same,
292
- // we're just attaching a name to it. Skip migration if the
293
- // clone is on a different SHA (drift) or already on a named
294
- // branch (operator may have set up their own work branch on
295
- // top); doctor will flag drift cases.
296
- const headSha = (run("git", ["-C", dir, "rev-parse", "HEAD"]) || "").trim();
297
- const headRef = (run("git", ["-C", dir, "symbolic-ref", "--quiet", "HEAD"]) || "").trim();
298
- if (headSha === AGENTCHATTR_PIN && !headRef) {
299
- const migrateResult = run("git", ["-C", dir, "checkout", "-B", "pinned", AGENTCHATTR_PIN], { timeout: 30000 });
300
- if (migrateResult === null) {
301
- try { console.warn(`[quadwork] WARNING: could not migrate ${dir} from detached HEAD to the 'pinned' branch.`); } catch {}
302
- }
303
- }
304
- }
305
-
306
- // 2. Create venv if missing.
307
- if (!fs.existsSync(venvPython)) {
308
- const venvResult = run("python3", ["-m", "venv", path.join(dir, ".venv")], { timeout: 60000 });
309
- if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
310
- if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
311
- venvJustCreated = true;
312
- }
313
-
314
- // 3. Install requirements only when the venv was just (re)created.
315
- // This makes re-running on a fully-installed path a true no-op.
316
- if (venvJustCreated) {
317
- const reqFile = path.join(dir, "requirements.txt");
318
- if (fs.existsSync(reqFile)) {
319
- const pipResult = run(venvPython, ["-m", "pip", "install", "-r", reqFile], { timeout: 120000 });
320
- if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
321
- }
322
- }
323
- // #629: patch crash timeout before AC's first import
324
- _patchCrashTimeout(dir);
325
- return dir;
326
- }
327
- installAgentChattr.lastError = null;
328
-
329
- function _patchCrashTimeout(dir) {
330
- if (!dir) return;
331
- const appPath = path.join(dir, "app.py");
332
- if (!fs.existsSync(appPath)) return;
333
- try {
334
- let app = fs.readFileSync(appPath, "utf-8");
335
- if (app.includes("_CRASH_TIMEOUT = 15")) {
336
- app = app.replace("_CRASH_TIMEOUT = 15", "_CRASH_TIMEOUT = 120");
337
- app = app.replace(
338
- "# Crash timeout: if a wrapper hasn't heartbeated for 60s,\n",
339
- "# Crash timeout: if a wrapper hasn't heartbeated for 120s,\n",
340
- );
341
- fs.writeFileSync(appPath, app);
342
- log("[idle-fix] patched crash timeout to 120s at clone time (#629)");
343
- }
344
- } catch (err) {
345
- try { console.warn(`[idle-fix] failed to patch crash timeout: ${err.message}`); } catch {}
346
- }
347
- }
348
-
349
- /**
350
- * Get spawn args for launching AgentChattr from its cloned directory.
351
- * Returns { command, spawnArgs, cwd } or null if not fully installed.
352
- * Requires .venv/bin/python — never falls back to bare python3.
353
- */
354
- function chattrSpawnArgs(dir, extraArgs) {
355
- dir = dir || getAgentChattrDir();
356
- const venvPython = path.join(dir, ".venv", "bin", "python");
357
- if (!fs.existsSync(path.join(dir, "run.py")) || !fs.existsSync(venvPython)) return null;
358
- return { command: venvPython, spawnArgs: ["run.py", ...(extraArgs || [])], cwd: dir };
359
- }
360
73
 
361
74
  function ask(rl, question, defaultVal) {
362
75
  return new Promise((resolve) => {
@@ -448,7 +161,7 @@ function readConfig() {
448
161
  const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
449
162
  return migrateAgentKeys(config);
450
163
  } catch {
451
- return { port: 8400, agentchattr_url: "http://127.0.0.1:8300", agentchattr_dir: DEFAULT_AGENTCHATTR_DIR, projects: [] };
164
+ return { port: 8400, projects: [] };
452
165
  }
453
166
  }
454
167
 
@@ -460,8 +173,6 @@ function writeConfig(config) {
460
173
 
461
174
  // ─── Prerequisites ──────────────────────────────────────────────────────────
462
175
 
463
- let agentChattrFound = false;
464
-
465
176
  function detectPlatform() {
466
177
  const p = os.platform();
467
178
  if (p === "darwin") return "macos";
@@ -505,7 +216,6 @@ async function checkPrereqs(rl) {
505
216
  header("Step 1: Prerequisites");
506
217
  const platform = detectPlatform();
507
218
  let allOk = true;
508
- let hasPython = false;
509
219
 
510
220
  // ── 1. Node.js 20+ (must already exist — user ran npx) ──
511
221
  const nodeVer = run("node", ["--version"]);
@@ -543,91 +253,7 @@ async function checkPrereqs(rl) {
543
253
  }
544
254
  }
545
255
 
546
- // ── 3. Python 3.10+ (manual install — guide only) ──
547
- const pyVer = run("python3", ["--version"]);
548
- if (pyVer) {
549
- const parts = pyVer.replace("Python ", "").split(".");
550
- const minor = parseInt(parts[1], 10);
551
- if (parseInt(parts[0], 10) >= 3 && minor >= 10) {
552
- ok(`${pyVer}`);
553
- hasPython = true;
554
- } else {
555
- console.log("");
556
- warn(`${pyVer} found, but version 3.10 or newer is required.`);
557
- log("Python powers the agent communication layer.");
558
- log("Download the latest version from:");
559
- log(` → https://python.org/downloads`);
560
- log("");
561
- log("After installing, close and reopen your terminal, then run:");
562
- log(" → npx quadwork init");
563
- allOk = false;
564
- }
565
- } else {
566
- console.log("");
567
- warn("Python 3 is required but not installed on your system.");
568
- log("");
569
- log("Python powers the agent communication layer. Install it from:");
570
- log(" → https://python.org/downloads (download and run the installer)");
571
- log("");
572
- log("After installing, close and reopen your terminal, then run:");
573
- log(" → npx quadwork init");
574
- allOk = false;
575
- }
576
-
577
- if (!hasPython) {
578
- // Can't continue with AgentChattr without Python
579
- console.log("");
580
- fail("Python is required before we can set up the remaining tools.");
581
- log("Install Python first, then re-run: npx quadwork init");
582
- return false;
583
- }
584
-
585
- // ── 3. AgentChattr (clone + venv — needs Python and git) ──
586
- // #579: skip global install on fresh installs with no projects. Each
587
- // project gets its own per-project clone via the web setup wizard, so
588
- // a global ~/.quadwork/agentchattr/ clone is redundant cruft.
589
- // Only check for an existing install to report status.
590
- const acDir = findAgentChattr();
591
- const config = readConfig();
592
- if (acDir) {
593
- ok(`AgentChattr (${acDir})`);
594
- agentChattrFound = true;
595
- } else if (hasPython && config.projects && config.projects.length > 0) {
596
- // Existing projects but no global install — install globally as
597
- // fallback for legacy projects that don't have per-project clones.
598
- console.log("");
599
- warn("AgentChattr lets your AI agents communicate with each other.");
600
- log("It will be cloned and set up in a virtualenv.");
601
- const doInstall = await askYN(rl, "Install AgentChattr now?", true);
602
- if (doInstall) {
603
- const acSpinner = spinner("Cloning and setting up AgentChattr...");
604
- const result = installAgentChattr();
605
- acSpinner.stop(result !== null);
606
- if (result) {
607
- ok(`AgentChattr installed (${DEFAULT_AGENTCHATTR_DIR})`);
608
- agentChattrFound = true;
609
- } else {
610
- warn("AgentChattr install failed. You can set it up manually:");
611
- log(` → git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
612
- log(` → cd ${DEFAULT_AGENTCHATTR_DIR} && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`);
613
- allOk = false;
614
- }
615
- } else {
616
- warn("AgentChattr skipped — agents won't be able to chat until it's installed.");
617
- log(` → Install later: git clone ${AGENTCHATTR_REPO} ${DEFAULT_AGENTCHATTR_DIR}`);
618
- allOk = false;
619
- }
620
- } else if (hasPython) {
621
- // Fresh install with no projects — AC will be installed per-project
622
- // when the user creates their first project via the dashboard.
623
- ok("AgentChattr will be installed per-project when you create a project.");
624
- agentChattrFound = true;
625
- } else {
626
- warn("AgentChattr requires Python — install Python first, then re-run init.");
627
- allOk = false;
628
- }
629
-
630
- // ── 5. GitHub CLI (independent) ──
256
+ // ── 3. GitHub CLI (independent) ──
631
257
  if (which("gh")) {
632
258
  ok("GitHub CLI (gh)");
633
259
  } else {
@@ -1004,243 +630,6 @@ async function setupAgents(rl, repo) {
1004
630
  return { projectName, absDir, worktrees, repo, backend, backends };
1005
631
  }
1006
632
 
1007
- // ─── AgentChattr Config ─────────────────────────────────────────────────────
1008
-
1009
- function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } = {}) {
1010
- header("Step 4: AgentChattr Setup");
1011
-
1012
- let tomlContent = fs.readFileSync(path.join(TEMPLATES_DIR, "config.toml"), "utf-8");
1013
- for (const agent of AGENTS) {
1014
- tomlContent = tomlContent.replace(new RegExp(`\\{\\{${agent}_cwd\\}\\}`, "g"), setup.worktrees[agent]);
1015
- }
1016
- // Replace placeholders
1017
- tomlContent = tomlContent.replace(/\{\{project_name\}\}/g, setup.projectName);
1018
- tomlContent = tomlContent.replace(/\{\{repo\}\}/g, setup.repo);
1019
- // Replace per-agent commands with chosen backends
1020
- for (const agent of AGENTS) {
1021
- const cmd = (setup.backends && setup.backends[agent]) || setup.backend;
1022
- tomlContent = tomlContent.replace(
1023
- new RegExp(`(\\[agents\\.${agent}\\][\\s\\S]*?command = )"(?:claude|codex)"`),
1024
- `$1"${cmd}"`
1025
- );
1026
- }
1027
-
1028
- // Per-project: isolated data dir and port
1029
- const dataDir = path.join(path.dirname(configTomlPath), "data");
1030
- if (!fs.existsSync(dataDir)) ensureSecureDir(dataDir);
1031
- // Read assigned port from config (set by writeQuadWorkConfig)
1032
- const existingConfig = readConfig();
1033
- const existingProject = existingConfig.projects?.find((p) => p.id === setup.projectName);
1034
- const chattrPort = existingProject?.agentchattr_url
1035
- ? new URL(existingProject.agentchattr_url).port
1036
- : "8300";
1037
- const mcpHttp = existingProject?.mcp_http_port || 8200;
1038
- const mcpSse = existingProject?.mcp_sse_port || 8201;
1039
- tomlContent = tomlContent.replace(/^port = \d+/m, `port = ${chattrPort}`);
1040
- tomlContent = tomlContent.replace(/^data_dir = .+/m, `data_dir = "${dataDir}"`);
1041
- // Add session_token to [server] section if project has one
1042
- const sessionToken = existingProject?.agentchattr_token || "";
1043
- if (sessionToken) {
1044
- tomlContent = tomlContent.replace(/^(data_dir = .+)$/m, `$1\nsession_token = "${sessionToken}"`);
1045
- }
1046
- tomlContent = tomlContent.replace(/^http_port = \d+/m, `http_port = ${mcpHttp}`);
1047
- tomlContent = tomlContent.replace(/^sse_port = \d+/m, `sse_port = ${mcpSse}`);
1048
-
1049
- // Write config.toml
1050
- const configDir = path.dirname(configTomlPath);
1051
- if (!fs.existsSync(configDir)) ensureSecureDir(configDir);
1052
- fs.writeFileSync(configTomlPath, tomlContent);
1053
- ok(`Wrote ${configTomlPath}`);
1054
-
1055
- // Phase 2C / #181: clone AgentChattr per-project at
1056
- // ~/.quadwork/{project_id}/agentchattr/. AgentChattr's run.py loads
1057
- // ROOT/config.toml, so each project needs its own clone to avoid
1058
- // multi-instance port conflicts (see master #181). The path is the
1059
- // same one writeQuadWorkConfig() persists in project.agentchattr_dir.
1060
- const perProjectDir = path.join(CONFIG_DIR, setup.projectName, "agentchattr");
1061
- let acDir = findAgentChattr(perProjectDir);
1062
- let acAvailable = !!acDir;
1063
- if (!acAvailable && !skipInstall) {
1064
- const acSpinner = spinner(`Setting up AgentChattr at ${perProjectDir}...`);
1065
- const installResult = installAgentChattr(perProjectDir);
1066
- if (installResult) {
1067
- acSpinner.stop(true);
1068
- acDir = installResult;
1069
- acAvailable = true;
1070
- } else {
1071
- acSpinner.stop(false);
1072
- const reason = installAgentChattr.lastError || "unknown error";
1073
- warn(`AgentChattr install failed at ${perProjectDir}: ${reason}`);
1074
- warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
1075
- }
1076
- }
1077
-
1078
- // Start AgentChattr server (only if installed)
1079
- if (acAvailable) {
1080
- log("Starting AgentChattr server...");
1081
- const acSpawn = chattrSpawnArgs(acDir, []);
1082
- if (acSpawn) {
1083
- const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
1084
- cwd: acSpawn.cwd,
1085
- stdio: "ignore",
1086
- detached: true,
1087
- });
1088
- acProc.on("error", (err) => {
1089
- warn(`AgentChattr failed to start: ${err.message}`);
1090
- });
1091
- acProc.unref();
1092
- if (acProc.pid) {
1093
- ok(`AgentChattr started (PID: ${acProc.pid})`);
1094
- if (!fs.existsSync(CONFIG_DIR)) ensureSecureDir(CONFIG_DIR);
1095
- const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
1096
- fs.writeFileSync(pidFile, String(acProc.pid));
1097
- } else {
1098
- warn("Could not start AgentChattr — check logs in " + (acDir || perProjectDir));
1099
- }
1100
- } else {
1101
- warn("AgentChattr run.py not found — skipping auto-start.");
1102
- }
1103
- } else {
1104
- warn("AgentChattr not installed — skipping auto-start.");
1105
- log(` → Install: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
1106
- }
1107
-
1108
- return configTomlPath;
1109
- }
1110
-
1111
- // ─── Optional Add-ons ───────────────────────────────────────────────────────
1112
-
1113
- async function setupAddons(rl, setup, configTomlPath) {
1114
- header("Step 5: Optional Add-ons");
1115
-
1116
- // Telegram Bridge
1117
- log("Optional: connect a Telegram bot for remote notifications.");
1118
- const wantTelegram = await askYN(rl, "Set up Telegram Bridge?", false);
1119
- if (wantTelegram) {
1120
- const telegramDir = path.join(path.dirname(setup.absDir), "agentchattr-telegram");
1121
- if (!fs.existsSync(telegramDir)) {
1122
- const cloneSpinner = spinner("Cloning agentchattr-telegram...");
1123
- const cloneResult = run("git", ["clone", "https://github.com/realproject7/agentchattr-telegram.git", telegramDir]);
1124
- cloneSpinner.stop(cloneResult !== null);
1125
- if (!cloneResult) { warn("You can set it up manually later"); }
1126
- } else {
1127
- ok("agentchattr-telegram already present");
1128
- }
1129
- // #444 / #470: pin to a known commit — on fresh clone AND on
1130
- // upgrade (existing clone may be on an older pin with stale
1131
- // bridge_sender defaults).
1132
- if (fs.existsSync(telegramDir)) {
1133
- run("git", ["-C", telegramDir, "fetch", "origin"], { timeout: 30000 });
1134
- const pinResult = run("git", ["-C", telegramDir, "checkout", "-B", "pinned", AGENTCHATTR_TELEGRAM_PIN], { timeout: 30000 });
1135
- if (pinResult === null) {
1136
- try { console.warn(`[quadwork] WARNING: could not check out agentchattr-telegram pin ${AGENTCHATTR_TELEGRAM_PIN} at ${telegramDir}; falling back to default branch.`); } catch {}
1137
- }
1138
- }
1139
-
1140
- if (fs.existsSync(telegramDir)) {
1141
- const reqFile = path.join(telegramDir, "requirements.txt");
1142
- if (fs.existsSync(reqFile)) {
1143
- const tgSpinner = spinner("Installing Telegram Bridge dependencies...");
1144
- const tgResult = run("pip", ["install", "-r", reqFile]);
1145
- tgSpinner.stop(tgResult !== null);
1146
- }
1147
-
1148
- log("Create a bot via @BotFather on Telegram (https://t.me/BotFather), then copy the token.");
1149
- const botToken = await askSecret(rl, "Telegram bot token");
1150
- log("To find your chat ID:");
1151
- log(" 1. Open your bot on Telegram and send it any message (e.g., 'hi')");
1152
- log(" 2. Run: curl https://api.telegram.org/bot<TOKEN>/getUpdates");
1153
- log(" 3. Look for \"chat\":{\"id\":123456789,...} — the number is your chat ID");
1154
- log(" Note: Returns empty if no messages have been sent to the bot yet.");
1155
- const chatId = await ask(rl, "Telegram chat ID", "");
1156
- log("Need help? See https://github.com/realproject7/agentchattr-telegram#readme");
1157
-
1158
- if (botToken && chatId) {
1159
- // Write bot token to ~/.quadwork/.env (never stored in config files)
1160
- const envPath = path.join(CONFIG_DIR, ".env");
1161
- const envKey = `TELEGRAM_BOT_TOKEN_${setup.projectName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
1162
- let envContent = "";
1163
- try { envContent = fs.readFileSync(envPath, "utf-8"); } catch {}
1164
- const envRegex = new RegExp(`^${envKey}=.*$`, "m");
1165
- const envLine = `${envKey}=${botToken}`;
1166
- if (envRegex.test(envContent)) {
1167
- envContent = envContent.replace(envRegex, envLine);
1168
- } else {
1169
- envContent = envContent.trimEnd() + (envContent ? "\n" : "") + envLine + "\n";
1170
- }
1171
- fs.writeFileSync(envPath, envContent, { mode: 0o600 });
1172
- fs.chmodSync(envPath, 0o600);
1173
- ok(`Saved bot token (${maskValue(botToken)}) to ${envPath}`);
1174
-
1175
- // Persist telegram settings for writeQuadWorkConfig (env reference, not plaintext)
1176
- setup.telegram = {
1177
- bot_token: `env:${envKey}`,
1178
- chat_id: chatId,
1179
- bridge_dir: telegramDir,
1180
- };
1181
-
1182
- // Resolve per-project AgentChattr URL
1183
- const projectCfg = readConfig();
1184
- const projectEntry = projectCfg.projects?.find((p) => p.id === setup.projectName);
1185
- const projectChattrUrl = projectEntry?.agentchattr_url || "http://127.0.0.1:8300";
1186
-
1187
- // Append telegram section to config.toml (token read from env at runtime)
1188
- const telegramSection = `
1189
- [telegram]
1190
- bot_token = "env:${envKey}"
1191
- chat_id = "${chatId}"
1192
- agentchattr_url = "${projectChattrUrl}"
1193
- poll_interval = 2
1194
- bridge_sender = "tg"
1195
- `;
1196
- fs.appendFileSync(configTomlPath, telegramSection);
1197
- ok("Added Telegram config to config.toml (token stored in .env)");
1198
-
1199
- // Start Telegram bridge daemon with a resolved config (real token, chmod 600)
1200
- const bridgeScript = path.join(telegramDir, "telegram_bridge.py");
1201
- if (fs.existsSync(bridgeScript)) {
1202
- log("Starting Telegram bridge...");
1203
- const bridgeToml = path.join(CONFIG_DIR, `telegram-${setup.projectName}.toml`);
1204
- // #383 Bug 2: the bridge only reads agentchattr_url from
1205
- // inside [telegram]. A separate [agentchattr] section is
1206
- // silently ignored and the bridge falls back to :8300.
1207
- // #404: cursor_file must be per-project so multiple bridges
1208
- // don't clobber each other's position.
1209
- const cursorFile = path.join(CONFIG_DIR, `tg-bridge-cursor-${setup.projectName}.json`);
1210
- const bridgeTomlContent = `[telegram]\nbot_token = "${botToken}"\nchat_id = "${chatId}"\nagentchattr_url = "${projectChattrUrl}"\ncursor_file = "${cursorFile}"\n`;
1211
- fs.writeFileSync(bridgeToml, bridgeTomlContent, { mode: 0o600 });
1212
- fs.chmodSync(bridgeToml, 0o600);
1213
- // #383 Bug 4: scrub TELEGRAM_*/AGENTCHATTR_URL from the
1214
- // child env so an ambient shell that exported a different
1215
- // bot's token can't silently override the TOML we just
1216
- // wrote. Same fix as server/routes.js Start handler.
1217
- const bridgeEnv = { ...process.env };
1218
- delete bridgeEnv.TELEGRAM_BOT_TOKEN;
1219
- delete bridgeEnv.TELEGRAM_CHAT_ID;
1220
- delete bridgeEnv.AGENTCHATTR_URL;
1221
- const bridgeProc = spawn("python3", [bridgeScript, "--config", bridgeToml], {
1222
- stdio: "ignore",
1223
- detached: true,
1224
- env: bridgeEnv,
1225
- });
1226
- bridgeProc.unref();
1227
- if (bridgeProc.pid) {
1228
- ok(`Telegram bridge started (PID: ${bridgeProc.pid})`);
1229
- const pidFile = path.join(CONFIG_DIR, "tg-bridge.pid");
1230
- fs.writeFileSync(pidFile, String(bridgeProc.pid));
1231
- } else {
1232
- warn("Could not start Telegram bridge — start manually");
1233
- }
1234
- }
1235
- }
1236
- }
1237
- }
1238
-
1239
- // #445: Shared Memory wizard step removed (agent-memory integration deprecated).
1240
-
1241
- return setup;
1242
- }
1243
-
1244
633
  // ─── Write QuadWork Config ──────────────────────────────────────────────────
1245
634
 
1246
635
  /**
@@ -1308,26 +697,10 @@ function writeQuadWorkConfig(setup) {
1308
697
  };
1309
698
  }
1310
699
 
1311
- // Auto-assign per-project AgentChattr and MCP ports (scan existing to avoid collisions)
700
+ // All new projects use file-based chat (AC is deprecated).
701
+ project.chat_mode = "file";
702
+
1312
703
  const existingIdx = config.projects.findIndex((p) => p.id === setup.projectName);
1313
- const usedChattrPorts = new Set(config.projects.map((p) => {
1314
- try { return parseInt(new URL(p.agentchattr_url).port, 10); } catch { return 0; }
1315
- }).filter(Boolean));
1316
- const usedMcpPorts = new Set(config.projects.flatMap((p) => [p.mcp_http_port, p.mcp_sse_port]).filter(Boolean));
1317
- let chattrPort = 8300;
1318
- while (usedChattrPorts.has(chattrPort)) chattrPort++;
1319
- let mcp_http = 8200;
1320
- while (usedMcpPorts.has(mcp_http)) mcp_http++;
1321
- let mcp_sse = mcp_http + 1;
1322
- while (usedMcpPorts.has(mcp_sse)) mcp_sse++;
1323
- project.agentchattr_url = `http://127.0.0.1:${chattrPort}`;
1324
- project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
1325
- project.mcp_http_port = mcp_http;
1326
- project.mcp_sse_port = mcp_sse;
1327
- // Per-project AgentChattr clone path (Option B / #181). Each project gets
1328
- // its own clone so AgentChattr's ROOT/config.toml lookup picks up the right
1329
- // ports — see master ticket #181.
1330
- project.agentchattr_dir = path.join(os.homedir(), ".quadwork", setup.projectName, "agentchattr");
1331
704
 
1332
705
  // Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
1333
706
  // ~/.quadwork/{id}/OVERNIGHT-QUEUE.md. Idempotent — if the file
@@ -1375,9 +748,6 @@ async function cmdInit() {
1375
748
  ok(`Wrote ${CONFIG_PATH}`);
1376
749
 
1377
750
  // #573: Install phase complete — do NOT start the server.
1378
- // The wizard ensures all prerequisites are installed (AC clone,
1379
- // venv, pip) so that `npx quadwork start` boots fast without
1380
- // the 25-50s AC install race.
1381
751
  rl.close();
1382
752
 
1383
753
  console.log("");
@@ -1405,226 +775,7 @@ async function cmdInit() {
1405
775
 
1406
776
  // ─── Start Command ──────────────────────────────────────────────────────────
1407
777
 
1408
- /**
1409
- * Phase 3 / #181 sub-G: migrate legacy v1 projects to per-project clones.
1410
- *
1411
- * Runs eagerly at the top of cmdStart() so users see clear progress before
1412
- * any agents launch. For each project that doesn't yet have a working
1413
- * per-project clone:
1414
- * 1. Compute perProjectDir = ~/.quadwork/{project_id}/agentchattr
1415
- * 2. installAgentChattr(perProjectDir) — idempotent (#183 + #187)
1416
- * 3. Copy the existing legacy <working_dir>/agentchattr/config.toml into
1417
- * the new clone ROOT if it exists. AgentChattr's run.py reads
1418
- * ROOT/config.toml from the clone dir, so this is what makes the
1419
- * project actually start from its own clone.
1420
- * 4. Set project.agentchattr_dir on the config entry and persist.
1421
- *
1422
- * Idempotent: if a project already has a working per-project clone with a
1423
- * config.toml at the ROOT and agentchattr_dir set, it is skipped silently.
1424
- * The legacy ~/.quadwork/agentchattr/ install is left alone — cleanup is
1425
- * sub-H (#189).
1426
- *
1427
- * The migration never touches worktrees, repo content, or token files;
1428
- * only the per-project AgentChattr install dir and config.json.
1429
- */
1430
- function migrateLegacyProjects(config) {
1431
- if (!config.projects || config.projects.length === 0) return false;
1432
-
1433
- const needsMigration = config.projects.filter((p) => {
1434
- if (!p.id) return false;
1435
- const target = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
1436
- const hasClone = fs.existsSync(path.join(target, "run.py")) &&
1437
- fs.existsSync(path.join(target, ".venv", "bin", "python"));
1438
- const hasToml = fs.existsSync(path.join(target, "config.toml"));
1439
- const hasField = !!p.agentchattr_dir;
1440
- return !(hasField && hasClone && hasToml);
1441
- });
1442
-
1443
- if (needsMigration.length === 0) return false;
1444
-
1445
- header("Migrating legacy projects to per-project AgentChattr clones");
1446
- let mutated = false;
1447
- for (const project of needsMigration) {
1448
- const perProjectDir = path.join(CONFIG_DIR, project.id, "agentchattr");
1449
- log(` ${project.id} → ${perProjectDir}`);
1450
-
1451
- // 1. Install (idempotent — no-op if clone is already valid).
1452
- if (!findAgentChattr(perProjectDir)) {
1453
- const acSpinner = spinner(` Cloning AgentChattr for ${project.id}...`);
1454
- const installResult = installAgentChattr(perProjectDir);
1455
- if (!installResult) {
1456
- acSpinner.stop(false);
1457
- const reason = installAgentChattr.lastError || "unknown error";
1458
- warn(` Migration failed for ${project.id}: ${reason}`);
1459
- warn(` ${project.id} will keep using the legacy global install until this is resolved.`);
1460
- continue;
1461
- }
1462
- acSpinner.stop(true);
1463
- }
1464
-
1465
- // 2. Seed config.toml at the clone ROOT from the legacy in-worktree
1466
- // location if present. Do not overwrite an existing per-project
1467
- // config.toml — re-running the migration must be a no-op.
1468
- //
1469
- // If the legacy toml exists but the copy fails, we MUST NOT persist
1470
- // agentchattr_dir — otherwise #186's resolver would switch this
1471
- // project to a clone that lacks the project's real ports, and
1472
- // AgentChattr would silently start on run.py defaults. Leaving
1473
- // agentchattr_dir unset keeps the project on the legacy global
1474
- // install via #186's fallback ladder until the next attempt.
1475
- const targetToml = path.join(perProjectDir, "config.toml");
1476
- let tomlReady = fs.existsSync(targetToml);
1477
- if (!tomlReady && project.working_dir) {
1478
- const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1479
- if (fs.existsSync(legacyToml)) {
1480
- try {
1481
- fs.copyFileSync(legacyToml, targetToml);
1482
- log(` Copied legacy config.toml → ${targetToml}`);
1483
- tomlReady = true;
1484
- } catch (e) {
1485
- warn(` Could not copy ${legacyToml}: ${e.message}`);
1486
- warn(` ${project.id} migration aborted: legacy config.toml not transferred.`);
1487
- warn(` ${project.id} will keep using the legacy global install via #186 fallback.`);
1488
- continue;
1489
- }
1490
- } else {
1491
- // No legacy toml at all (e.g. user removed it). Refuse to migrate
1492
- // — without a config.toml at the clone ROOT, run.py would start
1493
- // on built-in defaults and bind to the wrong ports.
1494
- warn(` ${project.id} has no legacy config.toml at ${legacyToml}; skipping migration.`);
1495
- warn(` Re-run setup to regenerate config.toml, then 'quadwork start' will retry migration.`);
1496
- continue;
1497
- }
1498
- }
1499
- if (!tomlReady) {
1500
- warn(` ${project.id} migration aborted: no config.toml at ${targetToml}.`);
1501
- continue;
1502
- }
1503
-
1504
- // 3. Persist agentchattr_dir on the project entry — only after the
1505
- // clone has run.py + venv + config.toml all in place.
1506
- if (project.agentchattr_dir !== perProjectDir) {
1507
- project.agentchattr_dir = perProjectDir;
1508
- mutated = true;
1509
- }
1510
- }
1511
778
 
1512
- if (mutated) {
1513
- try { writeConfig(config); ok("Updated config.json with per-project agentchattr_dir entries"); }
1514
- catch (e) { warn(`Failed to write config.json: ${e.message}`); }
1515
- }
1516
- log(" Legacy ~/.quadwork/agentchattr/ left in place; remove via cleanup script (#189).");
1517
- return true;
1518
- }
1519
-
1520
- /**
1521
- * #403 / quadwork#274 migration: ensure every per-project config.toml
1522
- * has `[routing] max_agent_hops = 30`. Idempotent + line-based so
1523
- * existing comments and other keys in the [routing] section are
1524
- * preserved.
1525
- *
1526
- * #415 / quadwork#298: previous version skipped projects that had
1527
- * a [routing] section but no max_agent_hops key (e.g. only
1528
- * `default = "none"`). Those projects silently retained AC's
1529
- * default of 4 and kept firing the loop guard mid-cycle. This
1530
- * version handles three cases:
1531
- *
1532
- * 1. max_agent_hops already set → no change
1533
- * 2. [routing] section exists,
1534
- * max_agent_hops missing → insert key under the header
1535
- * 3. no [routing] section at all → append a fresh section
1536
- */
1537
- function migrateLoopGuardDefaults(config) {
1538
- if (!config.projects || config.projects.length === 0) return;
1539
- for (const project of config.projects) {
1540
- if (!project.id) continue;
1541
- const tomlPath = project.agentchattr_dir
1542
- ? path.join(project.agentchattr_dir, "config.toml")
1543
- : path.join(CONFIG_DIR, project.id, "agentchattr", "config.toml");
1544
- if (!fs.existsSync(tomlPath)) continue;
1545
- let content;
1546
- try { content = fs.readFileSync(tomlPath, "utf-8"); } catch { continue; }
1547
- // Case 1: key already present *inside* the [routing] section.
1548
- // We must NOT short-circuit on a same-named key in some other
1549
- // table (e.g. `[other]\nmax_agent_hops = 7`) because that would
1550
- // leave a real partial [routing] section unpatched. Walk the
1551
- // file line-by-line, find the [routing] header, and collect
1552
- // lines until the next [section] header or EOF. JS regex has
1553
- // no \z anchor and no reliable EOF lookahead inside /m, so a
1554
- // line scan is the simplest correct approach and also keeps
1555
- // pathological strings ("default = \"lazy\"") from confusing
1556
- // anything in the matcher.
1557
- const lines = content.split(/\r?\n/);
1558
- let inRouting = false;
1559
- let routingKeyPresent = false;
1560
- for (const line of lines) {
1561
- const headerMatch = line.match(/^\s*\[([^\]]+)\]/);
1562
- if (headerMatch) {
1563
- inRouting = headerMatch[1].trim() === "routing";
1564
- continue;
1565
- }
1566
- if (inRouting && /^\s*max_agent_hops\s*=/.test(line)) {
1567
- routingKeyPresent = true;
1568
- break;
1569
- }
1570
- }
1571
- if (routingKeyPresent) continue;
1572
- let next;
1573
- if (/^\s*\[routing\]/m.test(content)) {
1574
- // Case 2: section exists, key missing. Insert the key on the
1575
- // line right after the [routing] header so it's scoped to the
1576
- // section regardless of what other keys / comments live there.
1577
- // Anchored to ^ so a `[routing]` substring inside a string
1578
- // value can't false-match. The header line is allowed to:
1579
- // - have a trailing inline comment (`[routing] # keep me`)
1580
- // - end the file with no trailing newline at all
1581
- // The line-break group is captured separately and re-emitted,
1582
- // synthesizing a `\n` if the file ended exactly at the header
1583
- // (otherwise the new key would land glued to whatever the
1584
- // bracket was sitting on).
1585
- next = content.replace(
1586
- /^(\s*\[routing\][^\r\n]*)(\r?\n|$)/m,
1587
- (_match, header, lineBreak) => {
1588
- const lb = lineBreak || "\n";
1589
- return `${header}${lb}max_agent_hops = 30\n`;
1590
- },
1591
- );
1592
- } else {
1593
- // Case 3: no section. Append a fresh one at the end of the
1594
- // file with both default + max_agent_hops.
1595
- const trailing = content.endsWith("\n") ? "" : "\n";
1596
- next = content + `${trailing}\n[routing]\ndefault = "none"\nmax_agent_hops = 30\n`;
1597
- }
1598
- if (next === content) continue;
1599
- try {
1600
- fs.writeFileSync(tomlPath, next);
1601
- log(` Loop guard default → ${project.id} (max_agent_hops = 30)`);
1602
- } catch { /* non-fatal */ }
1603
- }
1604
- }
1605
-
1606
- /**
1607
- * #580: Poll AC health endpoint until it responds 200, or timeout.
1608
- * Simple inline implementation for bin/ (no access to server/ modules).
1609
- */
1610
- async function waitForAcHealth(baseUrl, timeoutMs = 30000) {
1611
- const http = require("http");
1612
- const deadline = Date.now() + timeoutMs;
1613
- const healthUrl = `${baseUrl}/`;
1614
- while (Date.now() < deadline) {
1615
- const ok = await new Promise((resolve) => {
1616
- const req = http.get(healthUrl, (res) => {
1617
- res.resume();
1618
- resolve(res.statusCode >= 200 && res.statusCode < 400);
1619
- });
1620
- req.on("error", () => resolve(false));
1621
- req.setTimeout(2000, () => { req.destroy(); resolve(false); });
1622
- });
1623
- if (ok) return true;
1624
- await new Promise((r) => setTimeout(r, 2000));
1625
- }
1626
- return false;
1627
- }
1628
779
 
1629
780
  async function cmdStart() {
1630
781
  console.log("\n QuadWork Start\n");
@@ -1634,19 +785,6 @@ async function cmdStart() {
1634
785
  warn("No projects configured yet. Create one at the setup page.");
1635
786
  }
1636
787
 
1637
- // Phase 3 / #181: migrate legacy single-install projects to their
1638
- // own per-project clones before any AgentChattr spawn happens.
1639
- // Idempotent — a no-op once every project already has a working clone.
1640
- migrateLegacyProjects(config);
1641
-
1642
- // Batch 30 / #403 / quadwork#274: ensure every existing project's
1643
- // AgentChattr config.toml has [routing] max_agent_hops = 30 so the
1644
- // loop guard doesn't fire mid-PR-cycle. Idempotent: leaves any
1645
- // pre-existing routing section alone (only adds the section + key
1646
- // when both are missing). Failures are non-fatal — the project
1647
- // will just keep its current loop guard.
1648
- migrateLoopGuardDefaults(config);
1649
-
1650
788
  const quadworkDir = path.join(__dirname, "..");
1651
789
  const port = config.port || 8400;
1652
790
 
@@ -1664,66 +802,6 @@ async function cmdStart() {
1664
802
  process.exit(1);
1665
803
  }
1666
804
 
1667
- // Start AgentChattr for each project from its own per-project clone.
1668
- // Phase 2E / #181: each project entry now has agentchattr_dir, set by
1669
- // the wizards in #184/#185. Resolve per-project so two projects with
1670
- // their own clones (and their own ports) can run side by side without
1671
- // sharing a single global install. Falls back to the legacy global
1672
- // install dir for v1 entries that have not been migrated yet (#188).
1673
- const acPids = [];
1674
- const legacyAcDir = findAgentChattr(config.agentchattr_dir);
1675
- for (const project of config.projects) {
1676
- if (!project.working_dir) continue;
1677
- const projectAcDir = findAgentChattr(project.agentchattr_dir) || legacyAcDir;
1678
- if (!projectAcDir) continue;
1679
- // config.toml lives at the clone ROOT for new projects; legacy v1
1680
- // setups still keep it under <working_dir>/agentchattr/config.toml.
1681
- const perProjectToml = path.join(projectAcDir, "config.toml");
1682
- const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
1683
- const configToml = fs.existsSync(perProjectToml)
1684
- ? perProjectToml
1685
- : (fs.existsSync(legacyToml) ? legacyToml : null);
1686
- if (!configToml) continue;
1687
- const acSpawn = chattrSpawnArgs(projectAcDir, []);
1688
- if (!acSpawn) continue;
1689
- // #569: redirect AC stdout/stderr to a log file for diagnostics.
1690
- const acLogDir = path.join(CONFIG_DIR, project.id);
1691
- try { fs.mkdirSync(acLogDir, { recursive: true, mode: 0o700 }); } catch {}
1692
- const acLogPath = path.join(acLogDir, "agentchattr.log");
1693
- const acLogFd = fs.openSync(acLogPath, "a");
1694
- const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
1695
- cwd: acSpawn.cwd,
1696
- stdio: ["ignore", acLogFd, acLogFd],
1697
- detached: true,
1698
- });
1699
- fs.closeSync(acLogFd);
1700
- acProc.on("error", () => {});
1701
- acProc.unref();
1702
- if (acProc.pid) {
1703
- // #580: wait for AC to bind its port before declaring success.
1704
- const acUrl = project.agentchattr_url || "http://127.0.0.1:8300";
1705
- const acReady = await waitForAcHealth(acUrl, 30000);
1706
- if (acReady) {
1707
- ok(`AgentChattr started for ${project.id} from ${projectAcDir} (PID: ${acProc.pid})`);
1708
- } else {
1709
- warn(`AgentChattr spawned for ${project.id} (PID: ${acProc.pid}) but did not become ready within 30s`);
1710
- }
1711
- log(` Log: ${acLogPath}`);
1712
- acPids.push(acProc.pid);
1713
- }
1714
- // #579: verify pin checkout after spawn — surface loudly if AC clone
1715
- // drifted from the pinned commit, so operators know they're not on
1716
- // the tested version. The install-time warn() is often buried mid-
1717
- // spinner; this post-spawn check is always visible.
1718
- const headSha = (run("git", ["-C", projectAcDir, "rev-parse", "HEAD"]) || "").trim();
1719
- if (headSha && headSha !== AGENTCHATTR_PIN) {
1720
- warn(`AgentChattr for ${project.id} is NOT on the pinned commit.`);
1721
- log(` Current: ${headSha.slice(0, 12)}`);
1722
- log(` Pinned: ${AGENTCHATTR_PIN.slice(0, 12)}`);
1723
- log(` Run: git -C ${projectAcDir} checkout -B pinned ${AGENTCHATTR_PIN}`);
1724
- }
1725
- }
1726
-
1727
805
  // Open dashboard in browser after a short delay
1728
806
  const dashboardUrl = `http://127.0.0.1:${port}`;
1729
807
  setTimeout(() => {
@@ -1737,24 +815,16 @@ async function cmdStart() {
1737
815
  }, 1500);
1738
816
 
1739
817
  // Run server in foreground. Capture exports so the SIGINT handler
1740
- // can ask the server to SIGTERM its own chattrProcesses Map too
1741
- // (dashboard-spawned AgentChattr children aren't in cmdStart's
1742
- // acPids list).
818
+ // can call shutdown() for a clean exit.
1743
819
  log(`Dashboard: ${dashboardUrl}`);
1744
820
  log("Press Ctrl+C to stop.\n");
1745
821
  const serverExports = require(path.join(serverDir, "index.js"));
1746
822
 
1747
- // Graceful shutdown on Ctrl+C — kills cmdStart's own spawned
1748
- // AgentChattrs AND anything the dashboard spawned via
1749
- // /api/agentchattr/{id}/start after init.
1750
823
  process.on("SIGINT", () => {
1751
824
  console.log("");
1752
825
  log("Shutting down...");
1753
- for (const pid of acPids) {
1754
- try { process.kill(pid, "SIGTERM"); } catch {}
1755
- }
1756
- try { serverExports && serverExports.shutdownChattrProcesses && serverExports.shutdownChattrProcesses(); }
1757
- catch (e) { warn(`shutdownChattrProcesses failed: ${e.message}`); }
826
+ try { serverExports && serverExports.shutdown && serverExports.shutdown(); }
827
+ catch (e) { warn(`shutdown failed: ${e.message}`); }
1758
828
  ok("Stopped.");
1759
829
  console.log("");
1760
830
  log("To restart:");
@@ -1832,13 +902,6 @@ async function cmdAddProject() {
1832
902
 
1833
903
  writeQuadWorkConfig(setup);
1834
904
 
1835
- // Phase 2C / #181: config.toml lives at the per-project clone ROOT
1836
- // because AgentChattr's run.py loads ROOT/config.toml and ignores
1837
- // --config. Must match the install path used inside
1838
- // writeAgentChattrConfig(): CONFIG_DIR/{projectName}/agentchattr.
1839
- const configTomlPath = path.join(CONFIG_DIR, setup.projectName, "agentchattr", "config.toml");
1840
- writeAgentChattrConfig(setup, configTomlPath);
1841
-
1842
905
  header("Project Added");
1843
906
  log(`Project: ${setup.projectName}`);
1844
907
  log(`Repo: ${setup.repo}`);
@@ -1961,59 +1024,12 @@ async function cmdCleanup() {
1961
1024
 
1962
1025
  // ─── Doctor ─────────────────────────────────────────────────────────────────
1963
1026
 
1964
- // #348: show the AgentChattr pin and the actual commit SHA of
1965
- // every per-project clone so operators can spot mismatches. The
1966
- // global clone at DEFAULT_AGENTCHATTR_DIR is checked first, then
1967
- // any clones under ~/.quadwork/<projectId>/agentchattr that are
1968
- // referenced by config.json.
1969
1027
  function cmdDoctor() {
1970
1028
  console.log("");
1971
1029
  console.log("QuadWork doctor");
1972
1030
  console.log("===============");
1973
- console.log(`AgentChattr repo: ${AGENTCHATTR_REPO}`);
1974
- console.log(`Expected pin: ${AGENTCHATTR_PIN}`);
1031
+ console.log("Chat mode: file-based (AC removed)");
1975
1032
  console.log("");
1976
- const cloneShaAt = (dir) => {
1977
- if (!fs.existsSync(path.join(dir, ".git"))) return null;
1978
- const sha = run("git", ["-C", dir, "rev-parse", "HEAD"]);
1979
- return sha ? sha.trim() : null;
1980
- };
1981
- // #366: surface whether the clone is on the named `pinned`
1982
- // branch, on a different named branch, or in detached HEAD.
1983
- // Detached-HEAD-but-on-pin gets a soft warning so operators
1984
- // know to re-run install (which auto-migrates) or re-clone.
1985
- const cloneBranchAt = (dir) => {
1986
- const ref = run("git", ["-C", dir, "symbolic-ref", "--quiet", "HEAD"]);
1987
- if (!ref) return null; // detached
1988
- return ref.trim().replace(/^refs\/heads\//, "");
1989
- };
1990
- const report = (label, dir) => {
1991
- if (!dir || !fs.existsSync(dir)) {
1992
- console.log(` [skip] ${label}: ${dir || "(not configured)"} — missing`);
1993
- return;
1994
- }
1995
- const sha = cloneShaAt(dir);
1996
- if (!sha) {
1997
- console.log(` [warn] ${label}: ${dir} — not a git clone`);
1998
- return;
1999
- }
2000
- const branch = cloneBranchAt(dir);
2001
- let tag;
2002
- if (sha !== AGENTCHATTR_PIN) {
2003
- tag = "DIFF";
2004
- } else if (!branch) {
2005
- tag = "DETACH"; // on-pin but in detached HEAD — re-run install to migrate
2006
- } else if (branch !== "pinned") {
2007
- tag = "BR "; // on-pin but on a non-`pinned` named branch (operator override)
2008
- } else {
2009
- tag = "OK ";
2010
- }
2011
- const branchLabel = branch ? `branch=${branch}` : "branch=(detached)";
2012
- console.log(` [${tag}] ${label}: ${sha} ${branchLabel} (${dir})`);
2013
- };
2014
- // Global clone
2015
- report("global", DEFAULT_AGENTCHATTR_DIR);
2016
- // Per-project clones referenced by config.json
2017
1033
  try {
2018
1034
  const cfg = readConfig();
2019
1035
  const projects = Array.isArray(cfg.projects) ? cfg.projects : [];
@@ -2021,84 +1037,12 @@ function cmdDoctor() {
2021
1037
  console.log(" (no projects in config.json)");
2022
1038
  }
2023
1039
  for (const p of projects) {
2024
- // Per-project clones live under ~/.quadwork/<id>/agentchattr
2025
- // per cmdAddProject's layout, but a project may also override
2026
- // via its own agentchattr_dir field.
2027
- const perProject = p && p.agentchattr_dir
2028
- ? p.agentchattr_dir
2029
- : path.join(CONFIG_DIR, p.id || "", "agentchattr");
2030
- report(`project:${p.id || "(unnamed)"}`, perProject);
1040
+ const chatMode = p.chat_mode || "file";
1041
+ console.log(` project:${p.id || "(unnamed)"} chat_mode=${chatMode} working_dir=${p.working_dir || "(not set)"}`);
2031
1042
  }
2032
1043
  } catch (err) {
2033
1044
  console.log(` (could not enumerate projects: ${err.message})`);
2034
1045
  }
2035
- // #444: agentchattr-telegram pin status
2036
- console.log("");
2037
- console.log(`agentchattr-telegram pin: ${AGENTCHATTR_TELEGRAM_PIN}`);
2038
- const tgBridgeDir = path.join(CONFIG_DIR, "agentchattr-telegram");
2039
- if (!fs.existsSync(tgBridgeDir)) {
2040
- console.log(" [skip] agentchattr-telegram: not installed");
2041
- } else {
2042
- const tgSha = cloneShaAt(tgBridgeDir);
2043
- if (!tgSha) {
2044
- console.log(` [warn] agentchattr-telegram: ${tgBridgeDir} — not a git clone`);
2045
- } else {
2046
- const tgBranch = cloneBranchAt(tgBridgeDir);
2047
- let tgTag;
2048
- if (tgSha !== AGENTCHATTR_TELEGRAM_PIN) {
2049
- tgTag = "DIFF";
2050
- } else if (!tgBranch) {
2051
- tgTag = "DETACH";
2052
- } else if (tgBranch !== "pinned") {
2053
- tgTag = "BR ";
2054
- } else {
2055
- tgTag = "OK ";
2056
- }
2057
- const tgBranchLabel = tgBranch ? `branch=${tgBranch}` : "branch=(detached)";
2058
- console.log(` [${tgTag}] ${tgSha} ${tgBranchLabel} (${tgBridgeDir})`);
2059
- }
2060
- }
2061
-
2062
- console.log("");
2063
- console.log("Legend: [OK ] on pin + on `pinned` branch; [BR ] on pin but on a non-`pinned` named branch; [DETACH] on pin but in detached HEAD (re-run quadwork start to auto-migrate); [DIFF] off-pin (re-clone manually to re-sync)");
2064
-
2065
- // #569: show last 20 lines of AC log for each project to help
2066
- // operators diagnose startup failures.
2067
- console.log("");
2068
- console.log("AgentChattr logs");
2069
- console.log("────────────────");
2070
- try {
2071
- const cfg = readConfig();
2072
- const projects = Array.isArray(cfg.projects) ? cfg.projects : [];
2073
- for (const p of projects) {
2074
- const logPath = path.join(CONFIG_DIR, p.id || "", "agentchattr.log");
2075
- if (!fs.existsSync(logPath)) {
2076
- console.log(` ${p.id}: (no log file)`);
2077
- continue;
2078
- }
2079
- // Bounded tail: read last 8KB instead of the whole file to stay
2080
- // fast on large append-only logs.
2081
- const stat = fs.statSync(logPath);
2082
- const readSize = Math.min(stat.size, 8192);
2083
- const buf = Buffer.alloc(readSize);
2084
- const fd = fs.openSync(logPath, "r");
2085
- fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
2086
- fs.closeSync(fd);
2087
- const lines = buf.toString("utf-8").trimEnd().split("\n");
2088
- // If we read from mid-file, the first line is likely partial — drop it.
2089
- if (readSize < stat.size) lines.shift();
2090
- const tail = lines.slice(-20);
2091
- console.log(` ${p.id}: ${logPath} (last ${tail.length} lines)`);
2092
- for (const line of tail) {
2093
- console.log(` ${line}`);
2094
- }
2095
- }
2096
- if (projects.length === 0) {
2097
- console.log(" (no projects)");
2098
- }
2099
- } catch (err) {
2100
- console.log(` (could not read logs: ${err.message})`);
2101
- }
2102
1046
  console.log("");
2103
1047
  }
2104
1048
 
@@ -2163,52 +1107,7 @@ async function cmdMigrateAgentSlugs() {
2163
1107
  }
2164
1108
  }
2165
1109
 
2166
- // 4. Rewrite per-project AgentChattr config.toml
2167
- const tomlPath = project.agentchattr_dir
2168
- ? path.join(project.agentchattr_dir, "config.toml")
2169
- : path.join(CONFIG_DIR, project.id, "agentchattr", "config.toml");
2170
- if (fs.existsSync(tomlPath)) {
2171
- let toml = fs.readFileSync(tomlPath, "utf-8");
2172
- let tomlChanged = false;
2173
- if (toml.includes("[agents.reviewer1]")) {
2174
- toml = toml.replace(/\[agents\.reviewer1\]/g, "[agents.re1]");
2175
- tomlChanged = true;
2176
- }
2177
- if (toml.includes("[agents.reviewer2]")) {
2178
- toml = toml.replace(/\[agents\.reviewer2\]/g, "[agents.re2]");
2179
- tomlChanged = true;
2180
- }
2181
- // Add label lines if missing
2182
- for (const [slug, label] of Object.entries(LABEL_MAP)) {
2183
- const sectionRe = new RegExp(`(\\[agents\\.${slug}\\][^\\[]*?)(?=\\n\\[|$)`, "s");
2184
- const match = toml.match(sectionRe);
2185
- if (match && !match[1].includes("label =")) {
2186
- toml = toml.replace(sectionRe, `$1label = "${label}"\n`);
2187
- tomlChanged = true;
2188
- }
2189
- }
2190
- if (tomlChanged) {
2191
- fs.writeFileSync(tomlPath, toml);
2192
- changes.push(` config.toml rewritten: ${tomlPath}`);
2193
- }
2194
- }
2195
-
2196
- // 5. Rename queue files (reviewer1_queue.jsonl → re1_queue.jsonl)
2197
- const acDir = project.agentchattr_dir
2198
- || path.join(CONFIG_DIR, project.id, "agentchattr");
2199
- const dataDir = path.join(acDir, "data");
2200
- if (fs.existsSync(dataDir)) {
2201
- for (const [oldKey, newKey] of Object.entries(SLUG_MAP)) {
2202
- const oldQf = path.join(dataDir, `${oldKey}_queue.jsonl`);
2203
- const newQf = path.join(dataDir, `${newKey}_queue.jsonl`);
2204
- if (fs.existsSync(oldQf) && !fs.existsSync(newQf)) {
2205
- fs.renameSync(oldQf, newQf);
2206
- changes.push(` queue file: ${oldKey}_queue.jsonl → ${newKey}_queue.jsonl`);
2207
- }
2208
- }
2209
- }
2210
-
2211
- // 6. Rewrite stale slugs in worktree AGENTS.md and CLAUDE.md files (#479)
1110
+ // 4. Rewrite stale slugs in worktree AGENTS.md and CLAUDE.md files (#479)
2212
1111
  const SEED_REPLACEMENTS = [
2213
1112
  [/@reviewer1/g, "@re1"],
2214
1113
  [/@reviewer2/g, "@re2"],
@@ -2257,8 +1156,35 @@ async function cmdMigrateAgentSlugs() {
2257
1156
  log("");
2258
1157
  log("Next steps:");
2259
1158
  log(" 1. Run 'npx quadwork start' to restart with the new slugs");
2260
- log(" 2. AgentChattr will pick up the renamed config.toml sections");
2261
- log(" 3. Old chat messages keep their original sender — no history rewrite");
1159
+ log(" 2. Old chat messages keep their original sender — no history rewrite");
1160
+ }
1161
+
1162
+ // ─── ac-restore ────────────────────────────────────────────────────────────
1163
+
1164
+ function cmdAcRestore() {
1165
+ const args = process.argv.slice(3);
1166
+ const projectFlagIdx = args.indexOf("--project");
1167
+ const projectFilter = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : null;
1168
+
1169
+ if (projectFlagIdx >= 0 && (!projectFilter || projectFilter.startsWith("--"))) {
1170
+ warn("--project requires a project ID. Usage: npx quadwork ac-restore --project <id>");
1171
+ process.exit(1);
1172
+ }
1173
+
1174
+ if (projectFilter && !projectFilter.match(/^[\w-]+$/)) {
1175
+ warn("Invalid project ID.");
1176
+ process.exit(1);
1177
+ }
1178
+
1179
+ const config = readConfig();
1180
+
1181
+ if (projectFilter && !(config.projects || []).some((p) => p.id === projectFilter)) {
1182
+ warn(`Project '${projectFilter}' not found in config.`);
1183
+ process.exit(1);
1184
+ }
1185
+
1186
+ const { runAcRestore } = require("../server/ac-restore");
1187
+ runAcRestore(config, projectFilter || null);
2262
1188
  }
2263
1189
 
2264
1190
  // ─── Main ───────────────────────────────────────────────────────────────────
@@ -2287,6 +1213,9 @@ switch (command) {
2287
1213
  case "migrate-agent-slugs":
2288
1214
  cmdMigrateAgentSlugs();
2289
1215
  break;
1216
+ case "ac-restore":
1217
+ cmdAcRestore();
1218
+ break;
2290
1219
  case undefined: {
2291
1220
  // #573: No subcommand — smart default based on config state.
2292
1221
  // Config file exists (even with 0 projects) → start, so the user
@@ -2304,13 +1233,14 @@ switch (command) {
2304
1233
  Usage: quadwork <command>
2305
1234
 
2306
1235
  Commands:
2307
- init Run setup wizard (prereqs, port, AgentChattr install)
2308
- start Start the QuadWork dashboard, AgentChattr, and agents
1236
+ init Run setup wizard (prereqs, port)
1237
+ start Start the QuadWork dashboard and agents
2309
1238
  stop Stop all QuadWork processes
2310
1239
  add-project Add a project via CLI (alternative to web UI /setup)
2311
1240
  cleanup Reclaim disk space (--project <id> or --legacy)
2312
- doctor Report the AgentChattr pin + per-project clone SHAs
1241
+ doctor Report project configuration status
2313
1242
  migrate-agent-slugs Rename reviewer1/reviewer2 → re1/re2 in existing projects
1243
+ ac-restore Restore file-chat JSONL back to AC format
2314
1244
 
2315
1245
  Workflow:
2316
1246
  1. npx quadwork init — one-time setup (installs prerequisites)