quadwork 1.19.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) 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 → 0_lyyn..t63bc.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/13xk0vgfbrcld.css +2 -0
  18. package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
  19. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  20. package/out/_not-found/__next._full.txt +13 -13
  21. package/out/_not-found/__next._head.txt +4 -4
  22. package/out/_not-found/__next._index.txt +8 -8
  23. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  24. package/out/_not-found/__next._not-found.txt +3 -3
  25. package/out/_not-found/__next._tree.txt +2 -2
  26. package/out/_not-found.html +1 -1
  27. package/out/_not-found.txt +13 -13
  28. package/out/app-shell/__next._full.txt +13 -13
  29. package/out/app-shell/__next._head.txt +4 -4
  30. package/out/app-shell/__next._index.txt +8 -8
  31. package/out/app-shell/__next._tree.txt +2 -2
  32. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  33. package/out/app-shell/__next.app-shell.txt +3 -3
  34. package/out/app-shell.html +1 -1
  35. package/out/app-shell.txt +13 -13
  36. package/out/index.html +1 -1
  37. package/out/index.txt +14 -14
  38. package/out/project/_/__next._full.txt +14 -14
  39. package/out/project/_/__next._head.txt +4 -4
  40. package/out/project/_/__next._index.txt +8 -8
  41. package/out/project/_/__next._tree.txt +2 -2
  42. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  43. package/out/project/_/__next.project.$d$id.txt +3 -3
  44. package/out/project/_/__next.project.txt +3 -3
  45. package/out/project/_/queue/__next._full.txt +14 -14
  46. package/out/project/_/queue/__next._head.txt +4 -4
  47. package/out/project/_/queue/__next._index.txt +8 -8
  48. package/out/project/_/queue/__next._tree.txt +2 -2
  49. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  50. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  52. package/out/project/_/queue/__next.project.txt +3 -3
  53. package/out/project/_/queue.html +1 -1
  54. package/out/project/_/queue.txt +14 -14
  55. package/out/project/_.html +1 -1
  56. package/out/project/_.txt +14 -14
  57. package/out/settings/__next._full.txt +14 -14
  58. package/out/settings/__next._head.txt +4 -4
  59. package/out/settings/__next._index.txt +8 -8
  60. package/out/settings/__next._tree.txt +2 -2
  61. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  62. package/out/settings/__next.settings.txt +3 -3
  63. package/out/settings.html +1 -1
  64. package/out/settings.txt +14 -14
  65. package/out/setup/__next._full.txt +14 -14
  66. package/out/setup/__next._head.txt +4 -4
  67. package/out/setup/__next._index.txt +8 -8
  68. package/out/setup/__next._tree.txt +2 -2
  69. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  70. package/out/setup/__next.setup.txt +3 -3
  71. package/out/setup.html +1 -1
  72. package/out/setup.txt +14 -14
  73. package/package.json +4 -2
  74. package/server/ac-restore.js +128 -0
  75. package/server/bridges/discord.js +183 -0
  76. package/server/bridges/telegram.js +210 -0
  77. package/server/config.js +4 -60
  78. package/server/file-chat.js +318 -0
  79. package/server/index.js +129 -1294
  80. package/server/install-agentchattr.js +3 -284
  81. package/server/mcp-chat-shim.js +171 -0
  82. package/server/migrate-ac.js +158 -0
  83. package/server/pty-dispatcher.js +188 -0
  84. package/server/routes.js +149 -1397
  85. package/templates/CLAUDE.md +2 -2
  86. package/templates/OVERNIGHT-QUEUE.md +1 -1
  87. package/templates/seeds/butler.CLAUDE.md +30 -62
  88. package/templates/seeds/dev.AGENTS.md +10 -1
  89. package/templates/seeds/head.AGENTS.md +3 -3
  90. package/templates/seeds/re1.AGENTS.md +3 -3
  91. package/templates/seeds/re2.AGENTS.md +3 -3
  92. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  93. package/bridges/discord/discord_bridge.py +0 -666
  94. package/bridges/discord/requirements.txt +0 -2
  95. package/out/_next/static/chunks/08kw.2kplxa.6.css +0 -2
  96. package/out/_next/static/chunks/0_nm7se0m3twm.js +0 -25
  97. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  98. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  99. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  100. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  101. package/server/__tests__/rate-limit-handling.test.js +0 -168
  102. package/server/__tests__/scrub-secrets.test.js +0 -235
  103. package/server/__tests__/v1110-security-qa.test.js +0 -312
  104. package/server/agentchattr-registry.js +0 -188
  105. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  106. package/server/queue-watcher.js +0 -171
  107. package/server/queue-watcher.test.js +0 -64
  108. package/server/routes.batchProgress.test.js +0 -94
  109. package/server/routes.chatWsSend.test.js +0 -161
  110. package/server/routes.discordBridge.test.js +0 -80
  111. package/server/routes.parseActiveBatch.test.js +0 -88
  112. package/server/routes.telegramBridge.test.js +0 -241
  113. package/templates/config.toml +0 -72
  114. package/templates/wrapper.py +0 -70
  115. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
  116. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
  117. /package/out/_next/static/{D66Um4H226QD5y4w5xTKq → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
@@ -1,64 +1,11 @@
1
- // Shared AgentChattr install helper used by both the CLI wizard
2
- // (bin/quadwork.js) and the web setup route (server/routes.js).
3
- //
4
- // Extracted as part of #185 (Phase 2D of master #181) so the web UI
5
- // can clone AgentChattr per-project without duplicating the locking,
6
- // idempotency, and cleanup-safety logic that #183 + #187 added.
7
- //
8
- // Public API:
9
- // findAgentChattr(dir) → string|null
10
- // installAgentChattr(dir) → string|null (.lastError on failure)
11
- // chattrSpawnArgs(dir, extraArgs) → { command, spawnArgs, cwd } | null
12
- // AGENTCHATTR_REPO → upstream URL constant
13
- //
14
- // Self-contained — depends only on Node built-ins so it's safe to require
15
- // from anywhere in the project (CLI bin, server routes, future tests).
1
+ // Retained for `npx quadwork cleanup --legacy` locates AC directories on disk.
16
2
 
17
- const { execFileSync } = require("child_process");
18
3
  const fs = require("fs");
19
4
  const path = require("path");
20
5
 
21
- const AGENTCHATTR_REPO = "https://github.com/bcurts/agentchattr.git";
22
-
23
- // Stale-lock thresholds for installAgentChattr().
24
- // Lock files older than this OR whose owning pid is no longer alive are
25
- // treated as crashed and reclaimed. Tuned to comfortably exceed the longest
26
- // step (pip install of agentchattr requirements, ~120s timeout).
27
- const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
28
- const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
29
- const INSTALL_LOCK_POLL_MS = 500;
30
-
31
- function _run(cmd, args = [], opts = {}) {
32
- try { return execFileSync(cmd, args, { encoding: "utf-8", stdio: "pipe", ...opts }).trim(); }
33
- catch { return null; }
34
- }
35
-
36
- function _isPidAlive(pid) {
37
- if (!pid || !Number.isFinite(pid)) return false;
38
- try { process.kill(pid, 0); return true; }
39
- catch (e) { return e.code === "EPERM"; }
40
- }
41
-
42
- function _readLock(lockFile) {
43
- try {
44
- const raw = fs.readFileSync(lockFile, "utf-8").trim();
45
- const [pidStr, tsStr] = raw.split(":");
46
- return { pid: parseInt(pidStr, 10), ts: parseInt(tsStr, 10) || 0 };
47
- } catch { return null; }
48
- }
49
-
50
- function _isLockStale(lockFile) {
51
- const info = _readLock(lockFile);
52
- if (!info) return true;
53
- if (Date.now() - info.ts > INSTALL_LOCK_STALE_MS) return true;
54
- if (!_isPidAlive(info.pid)) return true;
55
- return false;
56
- }
57
-
58
6
  /**
59
- * Check if AgentChattr is fully installed (cloned + venv ready) at `dir`.
7
+ * Check if AgentChattr is installed (cloned + venv ready) at `dir`.
60
8
  * Returns the directory path if both run.py and .venv/bin/python exist, or null.
61
- * Caller must pass an explicit `dir` — there is no default.
62
9
  */
63
10
  function findAgentChattr(dir) {
64
11
  if (!dir) return null;
@@ -66,232 +13,4 @@ function findAgentChattr(dir) {
66
13
  return null;
67
14
  }
68
15
 
69
- /**
70
- * Clone AgentChattr and set up its venv at `dir`. Idempotent — safe to
71
- * re-run on the same path, and safe to call repeatedly with different
72
- * paths in the same process. Designed to support per-project clones (#181).
73
- *
74
- * Behavior on re-run:
75
- * - Fully-installed path → no-op (skips clone, skips venv create, skips pip)
76
- * - Missing run.py → clones (only after refusing to overwrite
77
- * unrelated content; see safety rules below)
78
- * - Missing venv → creates venv and reinstalls requirements
79
- *
80
- * Safety rules — never accidentally clean up unrelated directories:
81
- * - Empty dir → safe to remove
82
- * - Git repo whose origin contains "agentchattr" → safe to remove
83
- * - Anything else → refuse, return null
84
- *
85
- * Concurrency: a per-target lock at `${dir}.install.lock` serializes
86
- * concurrent installs to the same path. Stale locks (dead pid OR older
87
- * than 10 min) are reclaimed atomically via rename → unlink. Live
88
- * peers are polled for up to 30s; after that, returns null with a
89
- * clear lastError.
90
- *
91
- * On failure, returns null and stores a human-readable reason on
92
- * `installAgentChattr.lastError` so callers can surface it without
93
- * changing the return shape.
94
- */
95
- function installAgentChattr(dir) {
96
- if (!dir) {
97
- installAgentChattr.lastError = "installAgentChattr: dir is required";
98
- return null;
99
- }
100
- installAgentChattr.lastError = null;
101
- const setError = (msg) => { installAgentChattr.lastError = msg; return null; };
102
-
103
- // --- Per-target lock ---
104
- const lockFile = `${dir}.install.lock`;
105
- try { fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 }); }
106
- catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
107
-
108
- let acquired = false;
109
- const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
110
- while (!acquired) {
111
- try {
112
- fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { mode: 0o600, flag: "wx" });
113
- try { fs.chmodSync(lockFile, 0o600); } catch {}
114
- acquired = true;
115
- } catch (e) {
116
- if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
117
- if (_isLockStale(lockFile)) {
118
- const sideline = `${lockFile}.stale.${process.pid}.${Date.now()}`;
119
- try {
120
- fs.renameSync(lockFile, sideline);
121
- try { fs.unlinkSync(sideline); } catch {}
122
- } catch (renameErr) {
123
- if (renameErr.code !== "ENOENT") {
124
- return setError(`Cannot reclaim stale lock ${lockFile}: ${renameErr.message}`);
125
- }
126
- }
127
- continue;
128
- }
129
- if (Date.now() >= deadline) {
130
- const info = _readLock(lockFile) || { pid: "?", ts: 0 };
131
- 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.`);
132
- }
133
- try { execFileSync("sleep", [String(INSTALL_LOCK_POLL_MS / 1000)], { stdio: "pipe" }); }
134
- catch { /* sleep interrupted; loop will recheck */ }
135
- }
136
- }
137
-
138
- try {
139
- return _installAgentChattrLocked(dir, setError);
140
- } finally {
141
- try { fs.unlinkSync(lockFile); } catch {}
142
- }
143
- }
144
- installAgentChattr.lastError = null;
145
-
146
- function _installAgentChattrLocked(dir, setError) {
147
- const runPy = path.join(dir, "run.py");
148
- const venvPython = path.join(dir, ".venv", "bin", "python");
149
- let venvJustCreated = false;
150
-
151
- // 1. Clone if run.py is missing.
152
- if (!fs.existsSync(runPy)) {
153
- if (fs.existsSync(dir)) {
154
- let entries;
155
- try { entries = fs.readdirSync(dir); }
156
- catch (e) { return setError(`Cannot read ${dir}: ${e.message}`); }
157
- const isEmpty = entries.length === 0;
158
- if (isEmpty) {
159
- try { fs.rmSync(dir, { recursive: true, force: true }); }
160
- catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
161
- } else if (fs.existsSync(path.join(dir, ".git"))) {
162
- const remote = _run("git", ["-C", dir, "remote", "get-url", "origin"]);
163
- if (remote && remote.includes("agentchattr")) {
164
- try { fs.rmSync(dir, { recursive: true, force: true }); }
165
- catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
166
- } else {
167
- return setError(`Refusing to overwrite ${dir}: contains a non-AgentChattr git repo`);
168
- }
169
- } else {
170
- return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
171
- }
172
- }
173
- try { fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 }); }
174
- catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
175
- const cloneResult = _run("git", ["clone", AGENTCHATTR_REPO, dir], { timeout: 60000 });
176
- if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
177
- if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
178
- }
179
-
180
- // 2. Create venv if missing.
181
- if (!fs.existsSync(venvPython)) {
182
- const venvResult = _run("python3", ["-m", "venv", path.join(dir, ".venv")], { timeout: 60000 });
183
- if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
184
- if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
185
- venvJustCreated = true;
186
- }
187
-
188
- // 3. Install requirements only when the venv was just (re)created.
189
- if (venvJustCreated) {
190
- const reqFile = path.join(dir, "requirements.txt");
191
- if (fs.existsSync(reqFile)) {
192
- const pipResult = _run(venvPython, ["-m", "pip", "install", "-r", reqFile], { timeout: 120000 });
193
- if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
194
- }
195
- }
196
- // #388: patch sender-column overflow CSS after clone/install
197
- patchAgentchattrCss(dir);
198
- // #629: patch crash timeout before AC's first import
199
- patchCrashTimeout(dir);
200
- return dir;
201
- }
202
-
203
- /**
204
- * Get spawn args for launching AgentChattr from its cloned directory.
205
- * Returns { command, spawnArgs, cwd } or null if not fully installed.
206
- * Requires .venv/bin/python — never falls back to bare python3.
207
- */
208
- function chattrSpawnArgs(dir, extraArgs) {
209
- if (!dir) return null;
210
- const venvPython = path.join(dir, ".venv", "bin", "python");
211
- if (!fs.existsSync(path.join(dir, "run.py")) || !fs.existsSync(venvPython)) return null;
212
- return { command: venvPython, spawnArgs: ["run.py", ...(extraArgs || [])], cwd: dir };
213
- }
214
-
215
- /**
216
- * #388: Patch AgentChattr's static files for sender-column overflow.
217
- * Idempotent — skips if the marker is already present.
218
- * Called after install and after update (git pull overwrites static/).
219
- *
220
- * CSS: cap .msg-sender width with ellipsis truncation.
221
- * JS: add title attribute to .msg-sender spans for hover tooltip.
222
- */
223
- function patchAgentchattrCss(dir) {
224
- if (!dir) return;
225
- // --- CSS patch ---
226
- const cssPath = path.join(dir, "static", "style.css");
227
- if (fs.existsSync(cssPath)) {
228
- try {
229
- const content = fs.readFileSync(cssPath, "utf-8");
230
- if (!content.includes("/* quadwork#388 sender-overflow fix */")) {
231
- const patch = `
232
- /* quadwork#388 sender-overflow fix */
233
- .msg-sender {
234
- max-width: 80px;
235
- overflow: hidden;
236
- text-overflow: ellipsis;
237
- white-space: nowrap;
238
- display: inline-block;
239
- vertical-align: middle;
240
- }
241
- `;
242
- fs.writeFileSync(cssPath, content + patch);
243
- }
244
- } catch {}
245
- }
246
- // --- JS patch: add title attribute to .msg-sender for hover tooltip ---
247
- const jsPath = path.join(dir, "static", "chat.js");
248
- if (fs.existsSync(jsPath)) {
249
- try {
250
- const content = fs.readFileSync(jsPath, "utf-8");
251
- if (!content.includes("quadwork#388")) {
252
- // Add title= to the msg-sender span so truncated names show full on hover
253
- const patched = content.replace(
254
- /(<span class="msg-sender" style="color: \$\{senderColor\}">)/g,
255
- `<span class="msg-sender" title="\${escapeHtml(msg.sender)}" style="color: \${senderColor}">`,
256
- );
257
- if (patched !== content) {
258
- fs.writeFileSync(jsPath, patched + "\n// quadwork#388\n");
259
- }
260
- }
261
- } catch {}
262
- }
263
- }
264
-
265
- /**
266
- * #629: Patch AC's crash timeout from 15s to 120s.
267
- * Must run at clone time (before any `python run.py`) so the first
268
- * AC process imports the patched value. Idempotent.
269
- */
270
- function patchCrashTimeout(dir) {
271
- if (!dir) return;
272
- const appPath = path.join(dir, "app.py");
273
- if (!fs.existsSync(appPath)) return;
274
- try {
275
- let app = fs.readFileSync(appPath, "utf-8");
276
- if (app.includes("_CRASH_TIMEOUT = 15")) {
277
- app = app.replace("_CRASH_TIMEOUT = 15", "_CRASH_TIMEOUT = 120");
278
- app = app.replace(
279
- "# Crash timeout: if a wrapper hasn't heartbeated for 60s,\n",
280
- "# Crash timeout: if a wrapper hasn't heartbeated for 120s,\n",
281
- );
282
- fs.writeFileSync(appPath, app);
283
- console.log(`[idle-fix] patched crash timeout to 120s at clone time (#629): ${dir}`);
284
- }
285
- } catch (err) {
286
- console.warn(`[idle-fix] failed to patch crash timeout in ${appPath}: ${err.message}`);
287
- }
288
- }
289
-
290
- module.exports = {
291
- AGENTCHATTR_REPO,
292
- findAgentChattr,
293
- installAgentChattr,
294
- chattrSpawnArgs,
295
- patchAgentchattrCss,
296
- patchCrashTimeout,
297
- };
16
+ module.exports = { findAgentChattr };
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const http = require("http");
5
+ const readline = require("readline");
6
+
7
+ const args = process.argv.slice(2);
8
+ function flag(name) {
9
+ const i = args.indexOf(`--${name}`);
10
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
11
+ }
12
+
13
+ const PROJECT = flag("project");
14
+ const AGENT = flag("agent");
15
+ const PORT = flag("port");
16
+ const TOKEN = flag("token");
17
+
18
+ if (!PROJECT || !AGENT || !PORT) {
19
+ process.stderr.write("Usage: node mcp-chat-shim.js --project <id> --agent <id> --port <port> [--token <token>]\n");
20
+ process.exit(1);
21
+ }
22
+
23
+ const BASE = `http://127.0.0.1:${PORT}`;
24
+
25
+ const TOOLS = [
26
+ {
27
+ name: "chat_send",
28
+ description: "Send a message to the project chat",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ channel: { type: "string", default: "general" },
33
+ message: { type: "string" },
34
+ },
35
+ required: ["message"],
36
+ },
37
+ },
38
+ {
39
+ name: "chat_read",
40
+ description: "Read recent messages from project chat",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ channel: { type: "string", default: "general" },
45
+ limit: { type: "number", default: 50 },
46
+ since_id: { type: "number" },
47
+ },
48
+ },
49
+ },
50
+ ];
51
+
52
+ function jsonRpc(id, result) {
53
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
54
+ }
55
+
56
+ function jsonRpcError(id, code, message) {
57
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
58
+ }
59
+
60
+ function httpRequest(method, urlPath, body, extraHeaders) {
61
+ return new Promise((resolve, reject) => {
62
+ const url = new URL(urlPath, BASE);
63
+ const opts = {
64
+ hostname: url.hostname,
65
+ port: url.port,
66
+ path: url.pathname + url.search,
67
+ method,
68
+ headers: { "Content-Type": "application/json", ...extraHeaders },
69
+ };
70
+ const req = http.request(opts, (res) => {
71
+ let data = "";
72
+ res.on("data", (c) => (data += c));
73
+ res.on("end", () => {
74
+ try {
75
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
76
+ } catch {
77
+ resolve({ status: res.statusCode, body: data });
78
+ }
79
+ });
80
+ });
81
+ req.on("error", reject);
82
+ if (body) req.write(JSON.stringify(body));
83
+ req.end();
84
+ });
85
+ }
86
+
87
+ async function handleToolCall(id, name, params) {
88
+ try {
89
+ if (name === "chat_send") {
90
+ const res = await httpRequest("POST", `/api/chat?project=${encodeURIComponent(PROJECT)}`, {
91
+ text: params.message || "",
92
+ channel: params.channel || "general",
93
+ }, { "X-Chat-Sender": AGENT, ...(TOKEN ? { "X-Chat-Token": TOKEN } : {}) });
94
+ if (res.status >= 400) {
95
+ return jsonRpcError(id, -32000, `API error ${res.status}: ${JSON.stringify(res.body)}`);
96
+ }
97
+ return jsonRpc(id, { content: [{ type: "text", text: JSON.stringify(res.body) }] });
98
+ }
99
+
100
+ if (name === "chat_read") {
101
+ const qs = new URLSearchParams({ project: PROJECT });
102
+ if (params.channel) qs.set("channel", params.channel);
103
+ if (params.limit) qs.set("limit", String(params.limit));
104
+ if (params.since_id) qs.set("since_id", String(params.since_id));
105
+ const res = await httpRequest("GET", `/api/chat?${qs.toString()}`);
106
+ if (res.status >= 400) {
107
+ return jsonRpcError(id, -32000, `API error ${res.status}: ${JSON.stringify(res.body)}`);
108
+ }
109
+ return jsonRpc(id, { content: [{ type: "text", text: JSON.stringify(res.body) }] });
110
+ }
111
+
112
+ return jsonRpcError(id, -32601, `Unknown tool: ${name}`);
113
+ } catch (err) {
114
+ return jsonRpcError(id, -32000, err.message);
115
+ }
116
+ }
117
+
118
+ // --- MCP stdio protocol ---
119
+
120
+ async function handleMessage(msg) {
121
+ const { id, method, params } = msg;
122
+
123
+ if (method === "initialize") {
124
+ return jsonRpc(id, {
125
+ protocolVersion: "2024-11-05",
126
+ capabilities: { tools: {} },
127
+ serverInfo: { name: "quadwork-chat", version: "1.0.0" },
128
+ });
129
+ }
130
+
131
+ if (method === "initialized") {
132
+ return null;
133
+ }
134
+
135
+ if (method === "tools/list") {
136
+ return jsonRpc(id, { tools: TOOLS });
137
+ }
138
+
139
+ if (method === "tools/call") {
140
+ return handleToolCall(id, params?.name, params?.arguments || {});
141
+ }
142
+
143
+ if (method === "ping") {
144
+ return jsonRpc(id, {});
145
+ }
146
+
147
+ if (id != null) {
148
+ return jsonRpcError(id, -32601, `Method not found: ${method}`);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
154
+
155
+ rl.on("line", async (line) => {
156
+ let msg;
157
+ try {
158
+ msg = JSON.parse(line);
159
+ } catch {
160
+ process.stdout.write(jsonRpcError(null, -32700, "Parse error") + "\n");
161
+ return;
162
+ }
163
+ const response = await handleMessage(msg);
164
+ if (response) {
165
+ process.stdout.write(response + "\n");
166
+ }
167
+ });
168
+
169
+ rl.on("close", () => {
170
+ process.exit(0);
171
+ });
@@ -0,0 +1,158 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { ensureSecureDir, writeSecureFile } = require("./config");
5
+ const { parseMentions } = require("./file-chat");
6
+
7
+ const KNOWN_FIELDS = new Set([
8
+ "id", "timestamp", "ts", "sender", "channel", "type", "text", "message", "mentions",
9
+ ]);
10
+
11
+ function convertAcRecord(record, nextId) {
12
+ let ts;
13
+ if (record.timestamp) {
14
+ const raw = Number(record.timestamp);
15
+ ts = !isNaN(raw) && raw > 0
16
+ ? new Date(raw * 1000).toISOString()
17
+ : String(record.timestamp);
18
+ } else if (record.ts) {
19
+ const raw = Number(record.ts);
20
+ ts = !isNaN(raw) && raw > 0
21
+ ? new Date(raw * 1000).toISOString()
22
+ : String(record.ts);
23
+ } else {
24
+ ts = new Date().toISOString();
25
+ }
26
+
27
+ const text = record.text || record.message || "";
28
+ const known = {
29
+ id: nextId,
30
+ seq: nextId,
31
+ ts,
32
+ sender: record.sender || "unknown",
33
+ channel: record.channel || "general",
34
+ type: record.type === "system" ? "system" : "message",
35
+ text,
36
+ mentions: parseMentions(text),
37
+ };
38
+
39
+ const legacy = {};
40
+ for (const [key, val] of Object.entries(record)) {
41
+ if (!KNOWN_FIELDS.has(key)) {
42
+ legacy[key] = val;
43
+ }
44
+ }
45
+ if (record.type && record.type !== known.type) {
46
+ legacy.type = record.type;
47
+ }
48
+ if (Object.keys(legacy).length > 0) known._legacy = legacy;
49
+ return known;
50
+ }
51
+
52
+ function migrateProject(projectId) {
53
+ const chatDir = path.join(os.homedir(), ".quadwork", projectId, "chat");
54
+ const migratedPath = path.join(chatDir, ".migrated");
55
+ const targetPath = path.join(chatDir, "general.jsonl");
56
+
57
+ if (fs.existsSync(migratedPath)) return null;
58
+
59
+ if (fs.existsSync(targetPath)) {
60
+ console.log(`[migration] ${projectId}: general.jsonl already exists without .migrated — skipping (Phase 1 test project)`);
61
+ return null;
62
+ }
63
+
64
+ const acLogPath = path.join(
65
+ os.homedir(), ".quadwork", projectId, "agentchattr", "data", "agentchattr_log.jsonl"
66
+ );
67
+ if (!fs.existsSync(acLogPath)) return null;
68
+
69
+ const content = fs.readFileSync(acLogPath, "utf-8");
70
+ const lines = content.split("\n");
71
+ const converted = [];
72
+ let nextId = 0;
73
+ let skipped = 0;
74
+
75
+ for (const line of lines) {
76
+ if (!line.trim()) continue;
77
+ try {
78
+ const record = JSON.parse(line);
79
+ converted.push(convertAcRecord(record, nextId));
80
+ nextId++;
81
+ } catch {
82
+ skipped++;
83
+ }
84
+ }
85
+
86
+ if (converted.length === 0 && skipped === 0) return null;
87
+
88
+ if (converted.length === 0 && skipped > 0) {
89
+ console.log(`[migration] ${projectId}: AC log has ${skipped} lines but none are valid JSON — skipping`);
90
+ return null;
91
+ }
92
+
93
+ ensureSecureDir(chatDir);
94
+
95
+ const tmpPath = targetPath + ".tmp";
96
+ const output = converted.map((r) => JSON.stringify(r)).join("\n") + "\n";
97
+ writeSecureFile(tmpPath, output);
98
+
99
+ const verifyContent = fs.readFileSync(tmpPath, "utf-8");
100
+ const verifyLines = verifyContent.trim().split("\n");
101
+ let verified = 0;
102
+ for (const vl of verifyLines) {
103
+ try {
104
+ JSON.parse(vl);
105
+ verified++;
106
+ } catch {
107
+ fs.unlinkSync(tmpPath);
108
+ throw new Error(`Migration validation failed for ${projectId}: corrupt line in tmp file`);
109
+ }
110
+ }
111
+ if (verified !== converted.length) {
112
+ fs.unlinkSync(tmpPath);
113
+ throw new Error(`Migration validation failed for ${projectId}: expected ${converted.length} records, got ${verified}`);
114
+ }
115
+
116
+ fs.renameSync(tmpPath, targetPath);
117
+ try { fs.chmodSync(targetPath, 0o600); } catch {}
118
+
119
+ const systemMsg = {
120
+ id: nextId,
121
+ seq: nextId,
122
+ ts: new Date().toISOString(),
123
+ sender: "system",
124
+ channel: "general",
125
+ type: "system",
126
+ text: `Chat history migrated from AC (${converted.length} messages)`,
127
+ mentions: [],
128
+ };
129
+ fs.appendFileSync(targetPath, JSON.stringify(systemMsg) + "\n", { mode: 0o600 });
130
+
131
+ const manifest = {
132
+ migrated_at: new Date().toISOString(),
133
+ source: acLogPath,
134
+ messages: converted.length,
135
+ skipped,
136
+ };
137
+ writeSecureFile(migratedPath, JSON.stringify(manifest, null, 2) + "\n");
138
+
139
+ console.log(`[migration] ${projectId}: migrated ${converted.length} messages, ${skipped} skipped (invalid JSON)`);
140
+ return { messages: converted.length, skipped };
141
+ }
142
+
143
+ function runAcMigration(config) {
144
+ const failed = [];
145
+ const projects = config.projects || [];
146
+ for (const project of projects) {
147
+ if (!project || !project.id) continue;
148
+ try {
149
+ migrateProject(project.id);
150
+ } catch (err) {
151
+ console.error(`[migration] ${project.id}: failed — ${err.message}`);
152
+ failed.push(project.id);
153
+ }
154
+ }
155
+ return failed;
156
+ }
157
+
158
+ module.exports = { runAcMigration, migrateProject, convertAcRecord };