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.
- package/README.md +19 -35
- package/bin/quadwork.js +48 -1118
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +14 -14
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
- package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
- package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
- package/out/_next/static/chunks/{08tog0xc~.es_.js → 0jllnzexn48._.js} +1 -1
- package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
- package/out/_next/static/chunks/0py7102i226n5.js +1 -0
- package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
- package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
- package/out/_next/static/chunks/{11khe5i7gu158.js → 0z.9wnba-t6z8.js} +1 -1
- package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
- package/out/_next/static/chunks/163_ddkdca5q4.js +25 -0
- package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
- package/out/_not-found/__next._full.txt +13 -13
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +13 -13
- package/out/app-shell/__next._full.txt +13 -13
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +8 -8
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +13 -13
- package/out/index.html +1 -1
- package/out/index.txt +14 -14
- package/out/project/_/__next._full.txt +14 -14
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +8 -8
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/queue/__next._full.txt +14 -14
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +8 -8
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +14 -14
- package/out/project/_.html +1 -1
- package/out/project/_.txt +14 -14
- package/out/settings/__next._full.txt +14 -14
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +8 -8
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +14 -14
- package/out/setup/__next._full.txt +14 -14
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +8 -8
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +14 -14
- package/package.json +4 -2
- package/server/ac-restore.js +128 -0
- package/server/bridges/discord.js +244 -0
- package/server/bridges/telegram.js +258 -0
- package/server/config.js +4 -60
- package/server/file-chat.js +318 -0
- package/server/index.js +129 -1294
- package/server/install-agentchattr.js +3 -284
- package/server/mcp-chat-shim.js +171 -0
- package/server/migrate-ac.js +158 -0
- package/server/pty-dispatcher.js +188 -0
- package/server/routes.js +155 -1398
- package/templates/CLAUDE.md +2 -2
- package/templates/OVERNIGHT-QUEUE.md +1 -1
- package/templates/seeds/butler.CLAUDE.md +30 -62
- package/templates/seeds/dev.AGENTS.md +10 -1
- package/templates/seeds/head.AGENTS.md +12 -8
- package/templates/seeds/re1.AGENTS.md +3 -3
- package/templates/seeds/re2.AGENTS.md +3 -3
- package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
- package/bridges/discord/discord_bridge.py +0 -666
- package/bridges/discord/requirements.txt +0 -2
- package/out/_next/static/chunks/08kw.2kplxa.6.css +0 -2
- package/out/_next/static/chunks/0_nm7se0m3twm.js +0 -25
- package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
- package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
- package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
- package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
- package/server/__tests__/rate-limit-handling.test.js +0 -168
- package/server/__tests__/scrub-secrets.test.js +0 -235
- package/server/__tests__/v1110-security-qa.test.js +0 -312
- package/server/agentchattr-registry.js +0 -188
- package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
- package/server/queue-watcher.js +0 -171
- package/server/queue-watcher.test.js +0 -64
- package/server/routes.batchProgress.test.js +0 -94
- package/server/routes.chatWsSend.test.js +0 -161
- package/server/routes.discordBridge.test.js +0 -80
- package/server/routes.parseActiveBatch.test.js +0 -88
- package/server/routes.telegramBridge.test.js +0 -241
- package/templates/config.toml +0 -72
- package/templates/wrapper.py +0 -70
- /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_buildManifest.js +0 -0
- /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → MmPC1Rj12BOy4-HvMJjEX}/_clientMiddlewareManifest.js +0 -0
- /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,
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1754
|
-
|
|
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(
|
|
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
|
-
|
|
2025
|
-
|
|
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
|
|
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.
|
|
2261
|
-
|
|
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
|
|
2308
|
-
start Start the QuadWork dashboard
|
|
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
|
|
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)
|