quadwork 1.19.2 → 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.
- 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/{153f.fj8jlvle.js → 0_lyyn..t63bc.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/13xk0vgfbrcld.css +2 -0
- package/out/_next/static/chunks/14k3bfe537f9_.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 +183 -0
- package/server/bridges/telegram.js +210 -0
- package/server/config.js +4 -60
- package/server/file-chat.js +318 -0
- package/server/index.js +173 -1286
- 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 +149 -1397
- 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 +3 -3
- 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/0_bb~2.5h2ntm.css +0 -2
- package/out/_next/static/chunks/0makcdqkwobp6.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/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), ".quadwork");
|
|
6
|
+
|
|
7
|
+
const instances = new Map();
|
|
8
|
+
|
|
9
|
+
function cursorPath(projectId) {
|
|
10
|
+
return path.join(CONFIG_DIR, `tg-bridge-cursor-${projectId}.json`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function offsetPath(projectId) {
|
|
14
|
+
return path.join(CONFIG_DIR, `tg-bridge-offset-${projectId}.json`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readCursor(projectId) {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(cursorPath(projectId), "utf-8"));
|
|
20
|
+
return data.last_seen_id || 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeCursor(projectId, sinceId) {
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(cursorPath(projectId), JSON.stringify({ last_seen_id: sinceId }), { mode: 0o600 });
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readOffset(projectId) {
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(fs.readFileSync(offsetPath(projectId), "utf-8"));
|
|
35
|
+
return data.offset || 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeOffset(projectId, offset) {
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(offsetPath(projectId), JSON.stringify({ offset }), { mode: 0o600 });
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sendTelegram(botToken, chatId, text) {
|
|
48
|
+
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ chat_id: chatId, text }),
|
|
53
|
+
signal: AbortSignal.timeout(10000),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) throw new Error(`Telegram sendMessage ${res.status}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function pollLoop(projectId, botToken, chatId, qwPort) {
|
|
59
|
+
const inst = instances.get(projectId);
|
|
60
|
+
if (!inst || inst.stopping) return;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
let sinceId = inst.cursor;
|
|
64
|
+
const r = await fetch(
|
|
65
|
+
`http://127.0.0.1:${qwPort}/api/chat?project=${encodeURIComponent(projectId)}&since_id=${sinceId}&limit=50`,
|
|
66
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
67
|
+
);
|
|
68
|
+
if (!r.ok) throw new Error(`Chat API ${r.status}`);
|
|
69
|
+
const messages = await r.json();
|
|
70
|
+
|
|
71
|
+
for (const msg of messages) {
|
|
72
|
+
if (inst.stopping) return;
|
|
73
|
+
if (msg.sender === "tg" || msg.sender === "telegram-bridge" || (msg.sender && msg.sender.startsWith("tg:"))) continue;
|
|
74
|
+
if (inst.forwardedIds.has(msg.id)) continue;
|
|
75
|
+
|
|
76
|
+
const text = `**${msg.sender}**: ${msg.text}`;
|
|
77
|
+
const truncated = text.length > 4000 ? text.slice(0, 4000) + "…" : text;
|
|
78
|
+
await sendTelegram(botToken, chatId, truncated);
|
|
79
|
+
|
|
80
|
+
inst.forwardedIds.add(msg.id);
|
|
81
|
+
if (inst.forwardedIds.size > 2000) {
|
|
82
|
+
const arr = [...inst.forwardedIds];
|
|
83
|
+
inst.forwardedIds = new Set(arr.slice(-1000));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.id > inst.cursor) {
|
|
87
|
+
inst.cursor = msg.id;
|
|
88
|
+
writeCursor(projectId, inst.cursor);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
inst.lastError = err.message;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!inst.stopping) {
|
|
96
|
+
inst.timer = setTimeout(() => pollLoop(projectId, botToken, chatId, qwPort), 2000);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function startTelegramUpdates(projectId, botToken, chatId, qwPort) {
|
|
101
|
+
const inst = instances.get(projectId);
|
|
102
|
+
if (!inst || inst.stopping) return;
|
|
103
|
+
|
|
104
|
+
let offset = readOffset(projectId);
|
|
105
|
+
let retryDelay = 500;
|
|
106
|
+
|
|
107
|
+
async function tick() {
|
|
108
|
+
if (!inst || inst.stopping) return;
|
|
109
|
+
try {
|
|
110
|
+
const allowedUpdates = encodeURIComponent(JSON.stringify(["message"]));
|
|
111
|
+
const url = `https://api.telegram.org/bot${botToken}/getUpdates?offset=${offset}&timeout=10&allowed_updates=${allowedUpdates}`;
|
|
112
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
|
113
|
+
if (!res.ok) throw new Error(`Telegram getUpdates ${res.status}`);
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
retryDelay = 500;
|
|
116
|
+
if (data.ok && data.result) {
|
|
117
|
+
for (const update of data.result) {
|
|
118
|
+
offset = update.update_id + 1;
|
|
119
|
+
writeOffset(projectId, offset);
|
|
120
|
+
const text = update.message?.text;
|
|
121
|
+
const from = update.message?.from?.username || update.message?.from?.first_name || "unknown";
|
|
122
|
+
const msgChatId = String(update.message?.chat?.id);
|
|
123
|
+
if (!text || msgChatId !== String(chatId)) continue;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await fetch(`http://127.0.0.1:${qwPort}/api/chat?project=${encodeURIComponent(projectId)}`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"X-Bridge-Sender": `tg:${from}`,
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
project: projectId,
|
|
134
|
+
text,
|
|
135
|
+
channel: "general",
|
|
136
|
+
}),
|
|
137
|
+
signal: AbortSignal.timeout(5000),
|
|
138
|
+
});
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (!inst.stopping) {
|
|
144
|
+
inst.lastError = err.message;
|
|
145
|
+
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!inst.stopping) {
|
|
150
|
+
inst.updateTimer = setTimeout(tick, retryDelay);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
tick();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function start(projectId, botToken, chatId, qwPort) {
|
|
158
|
+
if (instances.has(projectId)) return;
|
|
159
|
+
|
|
160
|
+
const oldCursor = path.join(CONFIG_DIR, `telegram-bridge-cursor-${projectId}.json`);
|
|
161
|
+
const newCursor = cursorPath(projectId);
|
|
162
|
+
if (!fs.existsSync(newCursor) && fs.existsSync(oldCursor)) {
|
|
163
|
+
try { fs.renameSync(oldCursor, newCursor); } catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const inst = {
|
|
167
|
+
cursor: readCursor(projectId),
|
|
168
|
+
forwardedIds: new Set(),
|
|
169
|
+
timer: null,
|
|
170
|
+
updateTimer: null,
|
|
171
|
+
stopping: false,
|
|
172
|
+
lastError: null,
|
|
173
|
+
startedAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
instances.set(projectId, inst);
|
|
176
|
+
|
|
177
|
+
pollLoop(projectId, botToken, chatId, qwPort).catch((err) => {
|
|
178
|
+
console.error(`[bridge] telegram ${projectId} poll crashed: ${err.message}`);
|
|
179
|
+
inst.lastError = err.message;
|
|
180
|
+
stop(projectId);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
startTelegramUpdates(projectId, botToken, chatId, qwPort).catch((err) => {
|
|
184
|
+
console.error(`[bridge] telegram ${projectId} updates crashed: ${err.message}`);
|
|
185
|
+
inst.lastError = err.message;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log(`[bridge] telegram ${projectId}: started`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stop(projectId) {
|
|
192
|
+
const inst = instances.get(projectId);
|
|
193
|
+
if (!inst) return;
|
|
194
|
+
inst.stopping = true;
|
|
195
|
+
if (inst.timer) clearTimeout(inst.timer);
|
|
196
|
+
if (inst.updateTimer) clearTimeout(inst.updateTimer);
|
|
197
|
+
instances.delete(projectId);
|
|
198
|
+
console.log(`[bridge] telegram ${projectId}: stopped`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRunning(projectId) {
|
|
202
|
+
return instances.has(projectId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getLastError(projectId) {
|
|
206
|
+
const inst = instances.get(projectId);
|
|
207
|
+
return inst?.lastError || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { start, stop, isRunning, getLastError, readCursor };
|
package/server/config.js
CHANGED
|
@@ -6,46 +6,27 @@ const CONFIG_PATH = path.join(os.homedir(), ".quadwork", "config.json");
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_CONFIG = {
|
|
8
8
|
port: 8400,
|
|
9
|
-
agentchattr_url: "http://127.0.0.1:8300",
|
|
10
|
-
agentchattr_dir: path.join(os.homedir(), ".quadwork", "agentchattr"),
|
|
11
|
-
// #405 / quadwork#278: display name used as the chat sender for
|
|
12
|
-
// messages posted from the dashboard. AC's registry name validator
|
|
13
|
-
// accepts 1–32 alphanumeric + dash + underscore characters; mirror
|
|
14
|
-
// that here so the sanitized value matches what AC will accept.
|
|
15
9
|
operator_name: "user",
|
|
16
10
|
projects: [],
|
|
17
11
|
};
|
|
18
12
|
|
|
19
|
-
// Reserved sender names that the operator must NOT be able to claim
|
|
20
|
-
// — these are the registered agent identities (current + legacy
|
|
21
|
-
// aliases) plus AC's own "system" sender. Without this denylist a
|
|
22
|
-
// hand-edited or PUT /api/config'd `operator_name = "head"` would
|
|
23
|
-
// post chat messages with sender:"head", reopening the impersonation
|
|
24
|
-
// vector #230 closed. Case-insensitive match.
|
|
13
|
+
// Reserved sender names that the operator must NOT be able to claim.
|
|
25
14
|
const RESERVED_OPERATOR_NAMES = new Set([
|
|
26
15
|
"head",
|
|
27
16
|
"dev",
|
|
28
17
|
"re1",
|
|
29
18
|
"re2",
|
|
30
|
-
// Legacy agent aliases — preserved in routing logic in a few
|
|
31
|
-
// places, so block them too even though new projects no longer
|
|
32
|
-
// register under these names.
|
|
33
19
|
"reviewer1",
|
|
34
20
|
"reviewer2",
|
|
35
21
|
"t1",
|
|
36
22
|
"t2a",
|
|
37
23
|
"t2b",
|
|
38
24
|
"t3",
|
|
39
|
-
// AC's own broadcast / housekeeping sender.
|
|
40
25
|
"system",
|
|
41
26
|
]);
|
|
42
27
|
|
|
43
|
-
// Sanitize
|
|
44
|
-
//
|
|
45
|
-
// reject any reserved agent identity. Empty / non-string / reserved
|
|
46
|
-
// input falls back to "user". Used both when reading the config (in
|
|
47
|
-
// case the file was hand-edited) and on /api/chat sends (so even a
|
|
48
|
-
// stale on-disk value can't impersonate an agent).
|
|
28
|
+
// Sanitize operator display name: 1–32 alnum + dash + underscore,
|
|
29
|
+
// reject reserved agent identities.
|
|
49
30
|
function sanitizeOperatorName(value) {
|
|
50
31
|
if (typeof value !== "string") return "user";
|
|
51
32
|
const cleaned = value.trim().replace(/[^A-Za-z0-9_-]/g, "");
|
|
@@ -169,43 +150,6 @@ function resolveProjectChattr(projectId) {
|
|
|
169
150
|
};
|
|
170
151
|
}
|
|
171
152
|
|
|
172
|
-
/**
|
|
173
|
-
* Resolve the command + args to spawn AgentChattr from its cloned directory.
|
|
174
|
-
* Returns { command, args, cwd } or null if not fully set up.
|
|
175
|
-
* Requires .venv/bin/python — never falls back to bare python3.
|
|
176
|
-
*/
|
|
177
|
-
function resolveChattrSpawn(agentchattrDir) {
|
|
178
|
-
const dir = agentchattrDir || path.join(os.homedir(), ".quadwork", "agentchattr");
|
|
179
|
-
const runPy = path.join(dir, "run.py");
|
|
180
|
-
const venvPython = path.join(dir, ".venv", "bin", "python");
|
|
181
|
-
if (!fs.existsSync(runPy) || !fs.existsSync(venvPython)) return null;
|
|
182
|
-
return { command: venvPython, args: ["run.py"], cwd: dir };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Fetch AgentChattr's real session token from its HTML and save to project config.
|
|
187
|
-
* AgentChattr generates its own token at startup; this syncs it back.
|
|
188
|
-
*/
|
|
189
|
-
async function syncChattrToken(projectId) {
|
|
190
|
-
const config = readConfig();
|
|
191
|
-
const project = config.projects?.find((p) => p.id === projectId);
|
|
192
|
-
if (!project) return;
|
|
193
|
-
const url = project.agentchattr_url || config.agentchattr_url || "http://127.0.0.1:8300";
|
|
194
|
-
try {
|
|
195
|
-
const res = await fetch(url);
|
|
196
|
-
if (!res.ok) return;
|
|
197
|
-
const html = await res.text();
|
|
198
|
-
const match = html.match(/__SESSION_TOKEN__="([^"]+)"/);
|
|
199
|
-
if (match && match[1]) {
|
|
200
|
-
const realToken = match[1];
|
|
201
|
-
if (project.agentchattr_token !== realToken) {
|
|
202
|
-
project.agentchattr_token = realToken;
|
|
203
|
-
writeSecureFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
} catch {}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
153
|
// --- #540: Secure file/directory helpers ---
|
|
210
154
|
// All paths under ~/.quadwork/ may contain secrets (tokens, configs,
|
|
211
155
|
// chat exports). Use these helpers instead of raw fs calls to ensure
|
|
@@ -230,4 +174,4 @@ function writeConfig(cfg) {
|
|
|
230
174
|
writeSecureFile(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
231
175
|
}
|
|
232
176
|
|
|
233
|
-
module.exports = { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr,
|
|
177
|
+
module.exports = { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, sanitizeOperatorName, CONFIG_PATH, ensureSecureDir, writeSecureFile, writeConfig };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const { ensureSecureDir, writeSecureFile } = require("./config");
|
|
5
|
+
|
|
6
|
+
const MENTION_RE = /@(\w[\w-]*)/g;
|
|
7
|
+
|
|
8
|
+
// Per-project state keyed by projectId
|
|
9
|
+
const projectState = new Map();
|
|
10
|
+
|
|
11
|
+
function getState(projectId) {
|
|
12
|
+
if (!projectState.has(projectId)) {
|
|
13
|
+
projectState.set(projectId, { nextId: null, cache: [] });
|
|
14
|
+
}
|
|
15
|
+
return projectState.get(projectId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function chatDir(projectId) {
|
|
19
|
+
return path.join(os.homedir(), ".quadwork", projectId, "chat");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function chatFile(projectId) {
|
|
23
|
+
return path.join(chatDir(projectId), "general.jsonl");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writerLockPath(projectId) {
|
|
27
|
+
return path.join(chatDir(projectId), ".writer.pid");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseMentions(text) {
|
|
31
|
+
if (typeof text !== "string") return [];
|
|
32
|
+
const mentions = [];
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = MENTION_RE.exec(text)) !== null) {
|
|
35
|
+
mentions.push(m[1].toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
return [...new Set(mentions)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Writer lock ---
|
|
41
|
+
|
|
42
|
+
function acquireWriterLock(projectId) {
|
|
43
|
+
const lockPath = writerLockPath(projectId);
|
|
44
|
+
const dir = chatDir(projectId);
|
|
45
|
+
ensureSecureDir(dir);
|
|
46
|
+
|
|
47
|
+
if (fs.existsSync(lockPath)) {
|
|
48
|
+
let existingPid;
|
|
49
|
+
try {
|
|
50
|
+
existingPid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10);
|
|
51
|
+
} catch {
|
|
52
|
+
// Corrupt lock file — overwrite
|
|
53
|
+
}
|
|
54
|
+
if (existingPid) {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(existingPid, 0);
|
|
57
|
+
throw new Error(`QuadWork already running on this directory (pid ${existingPid})`);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === "ESRCH") {
|
|
60
|
+
console.warn(`[file-chat] Stale writer lock for project ${projectId} (pid ${existingPid}), replacing`);
|
|
61
|
+
} else if (!err.code) {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeSecureFile(lockPath, String(process.pid));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function releaseWriterLock(projectId) {
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(writerLockPath(projectId));
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- ID recovery ---
|
|
78
|
+
|
|
79
|
+
function recoverNextId(projectId) {
|
|
80
|
+
const filePath = chatFile(projectId);
|
|
81
|
+
let maxId = 0;
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(filePath)) return 1;
|
|
84
|
+
|
|
85
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
86
|
+
const lines = content.split("\n");
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (!line.trim()) continue;
|
|
89
|
+
try {
|
|
90
|
+
const record = JSON.parse(line);
|
|
91
|
+
if (typeof record.id === "number" && record.id > maxId) {
|
|
92
|
+
maxId = record.id;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
console.warn(`[file-chat] Skipped corrupt JSONL line in project ${projectId}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return maxId + 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Core functions ---
|
|
103
|
+
|
|
104
|
+
const CACHE_SIZE = 200;
|
|
105
|
+
|
|
106
|
+
function initProject(projectId) {
|
|
107
|
+
const dir = chatDir(projectId);
|
|
108
|
+
ensureSecureDir(dir);
|
|
109
|
+
|
|
110
|
+
acquireWriterLock(projectId);
|
|
111
|
+
|
|
112
|
+
const state = getState(projectId);
|
|
113
|
+
state.nextId = recoverNextId(projectId);
|
|
114
|
+
|
|
115
|
+
// Populate cache from existing file
|
|
116
|
+
const filePath = chatFile(projectId);
|
|
117
|
+
if (fs.existsSync(filePath)) {
|
|
118
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
119
|
+
const lines = content.split("\n");
|
|
120
|
+
const records = [];
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (!line.trim()) continue;
|
|
123
|
+
try {
|
|
124
|
+
records.push(JSON.parse(line));
|
|
125
|
+
} catch {
|
|
126
|
+
// already warned during recoverNextId
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
state.cache = records.slice(-CACHE_SIZE);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`[file-chat] Initialized project ${projectId}, next ID: ${state.nextId}, cached: ${state.cache.length} messages`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function shutdownProject(projectId) {
|
|
136
|
+
releaseWriterLock(projectId);
|
|
137
|
+
projectState.delete(projectId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendMessage(projectId, { sender, channel = "general", text, type = "message" }, _skipLoopGuard = false) {
|
|
141
|
+
const state = getState(projectId);
|
|
142
|
+
if (state.nextId === null) {
|
|
143
|
+
throw new Error(`Project ${projectId} not initialized — call initProject first`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const id = state.nextId++;
|
|
147
|
+
const seq = id; // seq mirrors id for single-channel
|
|
148
|
+
const record = {
|
|
149
|
+
id,
|
|
150
|
+
seq,
|
|
151
|
+
ts: new Date().toISOString(),
|
|
152
|
+
sender: sender || "system",
|
|
153
|
+
channel,
|
|
154
|
+
type,
|
|
155
|
+
text: text || "",
|
|
156
|
+
mentions: parseMentions(text),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const dir = chatDir(projectId);
|
|
160
|
+
ensureSecureDir(dir);
|
|
161
|
+
const filePath = chatFile(projectId);
|
|
162
|
+
|
|
163
|
+
const line = JSON.stringify(record) + "\n";
|
|
164
|
+
|
|
165
|
+
let retries = 3;
|
|
166
|
+
while (retries > 0) {
|
|
167
|
+
try {
|
|
168
|
+
fs.appendFileSync(filePath, line, { mode: 0o600 });
|
|
169
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
170
|
+
break;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
retries--;
|
|
173
|
+
if (retries === 0) {
|
|
174
|
+
console.error(`[file-chat] Append failure for project ${projectId}: ${err.message}`);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
state.cache.push(record);
|
|
182
|
+
if (state.cache.length > CACHE_SIZE) {
|
|
183
|
+
state.cache = state.cache.slice(-CACHE_SIZE);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return record;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readMessages(projectId, { since_id = 0, limit = 50 } = {}) {
|
|
190
|
+
const state = getState(projectId);
|
|
191
|
+
|
|
192
|
+
// Try cache first
|
|
193
|
+
if (state.cache.length > 0) {
|
|
194
|
+
const cacheMinId = state.cache[0].id;
|
|
195
|
+
if (since_id >= cacheMinId || since_id === 0) {
|
|
196
|
+
let results = since_id > 0
|
|
197
|
+
? state.cache.filter((m) => m.id > since_id)
|
|
198
|
+
: state.cache;
|
|
199
|
+
return results.slice(-limit);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fall back to disk
|
|
204
|
+
const filePath = chatFile(projectId);
|
|
205
|
+
if (!fs.existsSync(filePath)) {
|
|
206
|
+
console.warn(`[file-chat] Missing chat file for project ${projectId}, creating empty`);
|
|
207
|
+
const dir = chatDir(projectId);
|
|
208
|
+
ensureSecureDir(dir);
|
|
209
|
+
writeSecureFile(filePath, "");
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
214
|
+
const lines = content.split("\n");
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const line of lines) {
|
|
217
|
+
if (!line.trim()) continue;
|
|
218
|
+
try {
|
|
219
|
+
const record = JSON.parse(line);
|
|
220
|
+
if (since_id > 0 && record.id <= since_id) continue;
|
|
221
|
+
results.push(record);
|
|
222
|
+
} catch {
|
|
223
|
+
console.warn(`[file-chat] Skipped corrupt JSONL line in project ${projectId}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return results.slice(-limit);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getNextId(projectId) {
|
|
231
|
+
const state = getState(projectId);
|
|
232
|
+
if (state.nextId === null) {
|
|
233
|
+
state.nextId = recoverNextId(projectId);
|
|
234
|
+
}
|
|
235
|
+
return state.nextId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// #717: per-project loop guard state
|
|
239
|
+
const _loopGuardState = new Map();
|
|
240
|
+
|
|
241
|
+
function checkLoopGuard(projectId, msg, maxHops = 30) {
|
|
242
|
+
let state = _loopGuardState.get(projectId) || { hops: 0, paused: false };
|
|
243
|
+
|
|
244
|
+
if (msg.sender === "user") {
|
|
245
|
+
const isResume = typeof msg.text === "string" && msg.text.trim() === "/continue";
|
|
246
|
+
state.hops = 0;
|
|
247
|
+
state.paused = false;
|
|
248
|
+
_loopGuardState.set(projectId, state);
|
|
249
|
+
if (isResume) {
|
|
250
|
+
appendMessageInternal(projectId, {
|
|
251
|
+
sender: "system",
|
|
252
|
+
type: "system",
|
|
253
|
+
text: "Loop guard resumed.",
|
|
254
|
+
channel: msg.channel || "general",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (msg.type === "system") return;
|
|
261
|
+
if (state.paused) return;
|
|
262
|
+
|
|
263
|
+
state.hops++;
|
|
264
|
+
if (state.hops >= maxHops) {
|
|
265
|
+
state.paused = true;
|
|
266
|
+
appendMessageInternal(projectId, {
|
|
267
|
+
sender: "system",
|
|
268
|
+
type: "system",
|
|
269
|
+
text: `Loop guard: paused after ${maxHops} agent-to-agent messages with no human reply. Type /continue to resume.`,
|
|
270
|
+
channel: msg.channel || "general",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
_loopGuardState.set(projectId, state);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isLoopGuardPaused(projectId) {
|
|
277
|
+
const state = _loopGuardState.get(projectId);
|
|
278
|
+
return state ? state.paused : false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resetLoopGuard(projectId) {
|
|
282
|
+
_loopGuardState.delete(projectId);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function appendMessageInternal(projectId, opts) {
|
|
286
|
+
return appendMessage(projectId, opts, true);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// #715: per-agent shim tokens for authenticated sends.
|
|
290
|
+
// Map<"projectId:agentId", token>
|
|
291
|
+
const _shimTokens = new Map();
|
|
292
|
+
|
|
293
|
+
function registerShimToken(projectId, agentId, token) {
|
|
294
|
+
_shimTokens.set(`${projectId}:${agentId}`, token);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateShimToken(projectId, agentId, token) {
|
|
298
|
+
return _shimTokens.get(`${projectId}:${agentId}`) === token;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
initProject,
|
|
303
|
+
shutdownProject,
|
|
304
|
+
appendMessage,
|
|
305
|
+
readMessages,
|
|
306
|
+
getNextId,
|
|
307
|
+
parseMentions,
|
|
308
|
+
MENTION_RE,
|
|
309
|
+
checkLoopGuard,
|
|
310
|
+
isLoopGuardPaused,
|
|
311
|
+
resetLoopGuard,
|
|
312
|
+
registerShimToken,
|
|
313
|
+
validateShimToken,
|
|
314
|
+
// exposed for testing
|
|
315
|
+
_getState: getState,
|
|
316
|
+
_chatDir: chatDir,
|
|
317
|
+
_chatFile: chatFile,
|
|
318
|
+
};
|