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.
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/{153f.fj8jlvle.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 +173 -1286
  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/0_bb~2.5h2ntm.css +0 -2
  96. package/out/_next/static/chunks/0makcdqkwobp6.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/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
  116. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
  117. /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 an operator-supplied display name to match AC's name
44
- // validator (registry.py: 1–32 alnum + dash + underscore) AND to
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, resolveChattrSpawn, syncChattrToken, sanitizeOperatorName, CONFIG_PATH, ensureSecureDir, writeSecureFile, writeConfig };
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
+ };