quadwork 0.1.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/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/quadwork.js +686 -0
- package/out/404.html +1 -0
- package/out/__next.__PAGE__.txt +6 -0
- package/out/__next._full.txt +17 -0
- package/out/__next._head.txt +6 -0
- package/out/__next._index.txt +6 -0
- package/out/__next._tree.txt +3 -0
- package/out/_next/static/chunks/0.57eg262w~qg.js +1 -0
- package/out/_next/static/chunks/0.dzh0qf9zq1l.js +2 -0
- package/out/_next/static/chunks/03hi.hdp6l230.js +20 -0
- package/out/_next/static/chunks/03v5eoc-wic6o.js +1 -0
- package/out/_next/static/chunks/03yov._jigv17.js +1 -0
- package/out/_next/static/chunks/03~yq9q893hmn.js +1 -0
- package/out/_next/static/chunks/08fgie1bcjynm.js +1 -0
- package/out/_next/static/chunks/0excsn2a_5qsb.js +4 -0
- package/out/_next/static/chunks/0iqqouh_3i5y5.js +13 -0
- package/out/_next/static/chunks/0jsosmtclw5n5.js +4 -0
- package/out/_next/static/chunks/0ox7p_szjhn69.js +1 -0
- package/out/_next/static/chunks/0r7t_sj_sejq9.js +1 -0
- package/out/_next/static/chunks/13uu.sohs74zg.js +31 -0
- package/out/_next/static/chunks/15kwal..m9r49.css +2 -0
- package/out/_next/static/chunks/17oc2l.ekcs8b.css +1 -0
- package/out/_next/static/chunks/17sk4qv6_d0co.js +1 -0
- package/out/_next/static/chunks/turbopack-06pqx~0d8czn_.js +1 -0
- package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_buildManifest.js +15 -0
- package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_clientMiddlewareManifest.js +1 -0
- package/out/_next/static/eq3ebKZWXVJquNrlYMOZR/_ssgManifest.js +1 -0
- package/out/_next/static/media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2 +0 -0
- package/out/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2 +0 -0
- package/out/_next/static/media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2 +0 -0
- package/out/_next/static/media/favicon.0x3dzn~oxb6tn.ico +0 -0
- package/out/_not-found/__next._full.txt +17 -0
- package/out/_not-found/__next._head.txt +6 -0
- package/out/_not-found/__next._index.txt +6 -0
- package/out/_not-found/__next._not-found.__PAGE__.txt +5 -0
- package/out/_not-found/__next._not-found.txt +5 -0
- package/out/_not-found/__next._tree.txt +2 -0
- package/out/_not-found.html +1 -0
- package/out/_not-found.txt +17 -0
- package/out/favicon.ico +0 -0
- package/out/file.svg +1 -0
- package/out/globe.svg +1 -0
- package/out/index.html +1 -0
- package/out/index.txt +17 -0
- package/out/next.svg +1 -0
- package/out/project/_/__next._full.txt +20 -0
- package/out/project/_/__next._head.txt +6 -0
- package/out/project/_/__next._index.txt +6 -0
- package/out/project/_/__next._tree.txt +4 -0
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +7 -0
- package/out/project/_/__next.project.$d$id.txt +5 -0
- package/out/project/_/__next.project.txt +5 -0
- package/out/project/_/memory/__next._full.txt +19 -0
- package/out/project/_/memory/__next._head.txt +6 -0
- package/out/project/_/memory/__next._index.txt +6 -0
- package/out/project/_/memory/__next._tree.txt +3 -0
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +6 -0
- package/out/project/_/memory/__next.project.$d$id.memory.txt +5 -0
- package/out/project/_/memory/__next.project.$d$id.txt +5 -0
- package/out/project/_/memory/__next.project.txt +5 -0
- package/out/project/_/memory.html +1 -0
- package/out/project/_/memory.txt +19 -0
- package/out/project/_/queue/__next._full.txt +19 -0
- package/out/project/_/queue/__next._head.txt +6 -0
- package/out/project/_/queue/__next._index.txt +6 -0
- package/out/project/_/queue/__next._tree.txt +3 -0
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +6 -0
- package/out/project/_/queue/__next.project.$d$id.queue.txt +5 -0
- package/out/project/_/queue/__next.project.$d$id.txt +5 -0
- package/out/project/_/queue/__next.project.txt +5 -0
- package/out/project/_/queue.html +1 -0
- package/out/project/_/queue.txt +19 -0
- package/out/project/_.html +1 -0
- package/out/project/_.txt +20 -0
- package/out/settings/__next._full.txt +19 -0
- package/out/settings/__next._head.txt +6 -0
- package/out/settings/__next._index.txt +6 -0
- package/out/settings/__next._tree.txt +3 -0
- package/out/settings/__next.settings.__PAGE__.txt +6 -0
- package/out/settings/__next.settings.txt +5 -0
- package/out/settings.html +1 -0
- package/out/settings.txt +19 -0
- package/out/setup/__next._full.txt +19 -0
- package/out/setup/__next._head.txt +6 -0
- package/out/setup/__next._index.txt +6 -0
- package/out/setup/__next._tree.txt +3 -0
- package/out/setup/__next.setup.__PAGE__.txt +6 -0
- package/out/setup/__next.setup.txt +5 -0
- package/out/setup.html +1 -0
- package/out/setup.txt +19 -0
- package/out/vercel.svg +1 -0
- package/out/window.svg +1 -0
- package/package.json +61 -0
- package/server/config.js +63 -0
- package/server/index.js +476 -0
- package/server/routes.js +889 -0
- package/templates/CLAUDE.md +57 -0
- package/templates/config.toml +46 -0
- package/templates/seeds/t1.AGENTS.md +55 -0
- package/templates/seeds/t2a.AGENTS.md +96 -0
- package/templates/seeds/t2b.AGENTS.md +96 -0
- package/templates/seeds/t3.AGENTS.md +80 -0
- package/templates/wrapper.py +70 -0
package/server/routes.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrated Next.js API routes — now served directly from Express.
|
|
3
|
+
* Routes: config, chat, projects, memory, setup, rename, github/issues, github/prs, telegram
|
|
4
|
+
*/
|
|
5
|
+
const express = require("express");
|
|
6
|
+
const { execFileSync, spawn } = require("child_process");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), ".quadwork");
|
|
14
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
15
|
+
const ENV_PATH = path.join(CONFIG_DIR, ".env");
|
|
16
|
+
const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
|
|
17
|
+
const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CONFIG = {
|
|
20
|
+
port: 8400,
|
|
21
|
+
agentchattr_url: "http://127.0.0.1:8300",
|
|
22
|
+
projects: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function readConfigFile() {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") return { ...DEFAULT_CONFIG };
|
|
30
|
+
return { ...DEFAULT_CONFIG };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeConfigFile(cfg) {
|
|
35
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
36
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Config ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
router.get("/api/config", (_req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
45
|
+
res.json(JSON.parse(raw));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === "ENOENT") return res.json(DEFAULT_CONFIG);
|
|
48
|
+
res.status(500).json({ error: "Failed to read config", detail: err.message });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.put("/api/config", (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const body = req.body;
|
|
55
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
56
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(body, null, 2));
|
|
58
|
+
// Trigger sync is handled internally since we're in the same process now
|
|
59
|
+
if (typeof req.app.get("syncTriggers") === "function") {
|
|
60
|
+
req.app.get("syncTriggers")();
|
|
61
|
+
}
|
|
62
|
+
res.json({ ok: true });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
res.status(500).json({ error: "Failed to write config", detail: err.message });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── Chat (AgentChattr proxy) ──────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function getChattrConfig() {
|
|
71
|
+
try {
|
|
72
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
73
|
+
return {
|
|
74
|
+
url: cfg.agentchattr_url || "http://127.0.0.1:8300",
|
|
75
|
+
token: cfg.agentchattr_token || null,
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return { url: "http://127.0.0.1:8300", token: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function chatAuthHeaders(token) {
|
|
83
|
+
if (!token) return {};
|
|
84
|
+
return { "x-session-token": token };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
router.get("/api/chat", async (req, res) => {
|
|
88
|
+
const apiPath = req.query.path || "/api/messages";
|
|
89
|
+
const { url: base, token } = getChattrConfig();
|
|
90
|
+
|
|
91
|
+
const fwd = new URLSearchParams();
|
|
92
|
+
for (const [k, v] of Object.entries(req.query)) {
|
|
93
|
+
if (k !== "path") fwd.set(k, String(v));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const url = `${base}${apiPath}?${fwd.toString()}`;
|
|
97
|
+
try {
|
|
98
|
+
const r = await fetch(url, { headers: chatAuthHeaders(token) });
|
|
99
|
+
if (!r.ok) return res.status(r.status).json({ error: `AgentChattr returned ${r.status}` });
|
|
100
|
+
res.json(await r.json());
|
|
101
|
+
} catch (err) {
|
|
102
|
+
res.status(502).json({ error: "AgentChattr unreachable", detail: err.message });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
router.post("/api/chat", async (req, res) => {
|
|
107
|
+
const { url: base, token } = getChattrConfig();
|
|
108
|
+
try {
|
|
109
|
+
const r = await fetch(`${base}/api/send`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json", ...chatAuthHeaders(token) },
|
|
112
|
+
body: JSON.stringify(req.body),
|
|
113
|
+
});
|
|
114
|
+
if (!r.ok) return res.status(r.status).json({ error: `AgentChattr returned ${r.status}` });
|
|
115
|
+
res.json(await r.json());
|
|
116
|
+
} catch (err) {
|
|
117
|
+
res.status(502).json({ error: "AgentChattr unreachable", detail: err.message });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Projects (dashboard aggregation) ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function ghJson(args) {
|
|
124
|
+
try {
|
|
125
|
+
const out = execFileSync("gh", args, { encoding: "utf-8", timeout: 15000 });
|
|
126
|
+
const parsed = JSON.parse(out);
|
|
127
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
router.get("/api/projects", async (req, res) => {
|
|
134
|
+
const cfg = readConfigFile();
|
|
135
|
+
const chattrUrl = cfg.agentchattr_url || "http://127.0.0.1:8300";
|
|
136
|
+
const chattrToken = cfg.agentchattr_token;
|
|
137
|
+
|
|
138
|
+
// Fetch active sessions from our own in-memory state (only running PTYs)
|
|
139
|
+
const activeSessions = req.app.get("activeSessions") || new Map();
|
|
140
|
+
const activeProjectIds = new Set();
|
|
141
|
+
for (const [, info] of activeSessions) {
|
|
142
|
+
if (info.projectId && info.state === "running") activeProjectIds.add(info.projectId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fetch chat messages
|
|
146
|
+
let chatMsgs = [];
|
|
147
|
+
try {
|
|
148
|
+
const headers = chattrToken ? { "x-session-token": chattrToken } : {};
|
|
149
|
+
const r = await fetch(`${chattrUrl}/api/messages?channel=general&limit=30`, { headers });
|
|
150
|
+
if (r.ok) {
|
|
151
|
+
const data = await r.json();
|
|
152
|
+
chatMsgs = Array.isArray(data) ? data : data.messages || [];
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
|
|
156
|
+
const eventKeywords = /\b(PR|merged|pushed|approved|opened|closed|review|commit)\b/i;
|
|
157
|
+
const workflowMsgs = chatMsgs
|
|
158
|
+
.filter((m) => eventKeywords.test(m.text) && m.sender !== "system")
|
|
159
|
+
.slice(-10)
|
|
160
|
+
.reverse();
|
|
161
|
+
|
|
162
|
+
const numberToProject = {};
|
|
163
|
+
const projectResults = (cfg.projects || []).map((p) => {
|
|
164
|
+
let openPrs = 0;
|
|
165
|
+
let lastActivity = null;
|
|
166
|
+
|
|
167
|
+
if (REPO_RE.test(p.repo)) {
|
|
168
|
+
const prs = ghJson(["pr", "list", "-R", p.repo, "--json", "number", "--limit", "100"]);
|
|
169
|
+
openPrs = prs.length;
|
|
170
|
+
|
|
171
|
+
const recentPrs = ghJson(["pr", "list", "-R", p.repo, "--state", "all", "--json", "updatedAt", "--limit", "1"]);
|
|
172
|
+
lastActivity = recentPrs[0]?.updatedAt || null;
|
|
173
|
+
|
|
174
|
+
const allPrs = ghJson(["pr", "list", "-R", p.repo, "--state", "all", "--json", "number", "--limit", "100"]);
|
|
175
|
+
for (const pr of allPrs) numberToProject[pr.number] = p.name;
|
|
176
|
+
const allIssues = ghJson(["issue", "list", "-R", p.repo, "--state", "all", "--json", "number", "--limit", "100"]);
|
|
177
|
+
for (const issue of allIssues) numberToProject[issue.number] = p.name;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hasAgents = p.agents && Object.keys(p.agents).length > 0;
|
|
181
|
+
return {
|
|
182
|
+
id: p.id,
|
|
183
|
+
name: p.name,
|
|
184
|
+
repo: p.repo,
|
|
185
|
+
agentCount: p.agents ? Object.keys(p.agents).length : 0,
|
|
186
|
+
openPrs,
|
|
187
|
+
state: hasAgents && activeProjectIds.has(p.id) ? "active" : "idle",
|
|
188
|
+
lastActivity,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Build activity feed
|
|
193
|
+
const recentEvents = [];
|
|
194
|
+
for (const m of workflowMsgs) {
|
|
195
|
+
let projectName = (cfg.projects || []).find((p) => m.text.includes(p.repo) || m.text.includes(p.name))?.name;
|
|
196
|
+
if (!projectName) {
|
|
197
|
+
const numMatch = m.text.match(/#(\d+)/);
|
|
198
|
+
if (numMatch) projectName = numberToProject[parseInt(numMatch[1], 10)];
|
|
199
|
+
}
|
|
200
|
+
if (!projectName) {
|
|
201
|
+
const branchMatch = m.text.match(/task\/(\d+)/);
|
|
202
|
+
if (branchMatch) projectName = numberToProject[parseInt(branchMatch[1], 10)];
|
|
203
|
+
}
|
|
204
|
+
if (!projectName && cfg.projects && cfg.projects.length === 1) {
|
|
205
|
+
projectName = cfg.projects[0].name;
|
|
206
|
+
}
|
|
207
|
+
if (projectName) {
|
|
208
|
+
recentEvents.push({
|
|
209
|
+
time: m.time,
|
|
210
|
+
text: m.text.length > 120 ? m.text.slice(0, 120) + "…" : m.text,
|
|
211
|
+
actor: m.sender,
|
|
212
|
+
projectName,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (recentEvents.length >= 10) break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
res.json({ projects: projectResults, recentEvents });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── GitHub Issues / PRs ───────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function getRepo(projectId) {
|
|
224
|
+
try {
|
|
225
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
226
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
227
|
+
const repo = project?.repo;
|
|
228
|
+
if (repo && REPO_RE.test(repo)) return repo;
|
|
229
|
+
return null;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
router.get("/api/github/issues", (req, res) => {
|
|
236
|
+
const repo = getRepo(req.query.project || "");
|
|
237
|
+
if (!repo) return res.status(400).json({ error: "No repo configured for project" });
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const out = execFileSync(
|
|
241
|
+
"gh",
|
|
242
|
+
["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"],
|
|
243
|
+
{ encoding: "utf-8", timeout: 15000 }
|
|
244
|
+
);
|
|
245
|
+
res.json(JSON.parse(out));
|
|
246
|
+
} catch (err) {
|
|
247
|
+
res.status(502).json({ error: "gh issue list failed", detail: err.message });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
router.get("/api/github/prs", (req, res) => {
|
|
252
|
+
const repo = getRepo(req.query.project || "");
|
|
253
|
+
if (!repo) return res.status(400).json({ error: "No repo configured for project" });
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const out = execFileSync(
|
|
257
|
+
"gh",
|
|
258
|
+
["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"],
|
|
259
|
+
{ encoding: "utf-8", timeout: 15000 }
|
|
260
|
+
);
|
|
261
|
+
res.json(JSON.parse(out));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
res.status(502).json({ error: "gh pr list failed", detail: err.message });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── Memory ────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function getProject(projectId) {
|
|
270
|
+
try {
|
|
271
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
272
|
+
return cfg.projects?.find((p) => p.id === projectId) || null;
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getMemoryPaths(project) {
|
|
279
|
+
const workDir = project.working_dir || "";
|
|
280
|
+
return {
|
|
281
|
+
cardsDir: project.memory_cards_dir || path.join(workDir, "..", "agent-memory", "archive", "v2", "cards"),
|
|
282
|
+
sharedMemoryPath: project.shared_memory_path || path.join(workDir, "..", "agent-memory", "central", "short-term", "agent-os.md"),
|
|
283
|
+
butlerDir: project.butler_scripts_dir || path.join(workDir, "..", "agent-memory", "scripts"),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function findMdFiles(dir) {
|
|
288
|
+
const results = [];
|
|
289
|
+
if (!fs.existsSync(dir)) return results;
|
|
290
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
291
|
+
const full = path.join(dir, entry.name);
|
|
292
|
+
if (entry.isDirectory()) results.push(...findMdFiles(full));
|
|
293
|
+
else if (entry.name.endsWith(".md")) results.push(full);
|
|
294
|
+
}
|
|
295
|
+
return results;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseFrontmatter(content) {
|
|
299
|
+
const fm = {};
|
|
300
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
301
|
+
if (!match) return fm;
|
|
302
|
+
for (const line of match[1].split("\n")) {
|
|
303
|
+
const idx = line.indexOf(":");
|
|
304
|
+
if (idx > 0) {
|
|
305
|
+
const key = line.slice(0, idx).trim();
|
|
306
|
+
let val = line.slice(idx + 1).trim();
|
|
307
|
+
if (val.startsWith("[") && val.endsWith("]")) val = val.slice(1, -1).trim();
|
|
308
|
+
fm[key] = val;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return fm;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
router.get("/api/memory", (req, res) => {
|
|
315
|
+
const projectId = req.query.project || "";
|
|
316
|
+
const action = req.query.action || "cards";
|
|
317
|
+
const project = getProject(projectId);
|
|
318
|
+
if (!project) return res.status(404).json({ error: "Project not found" });
|
|
319
|
+
|
|
320
|
+
const paths = getMemoryPaths(project);
|
|
321
|
+
|
|
322
|
+
if (action === "cards") {
|
|
323
|
+
const search = req.query.search || "";
|
|
324
|
+
try {
|
|
325
|
+
const files = findMdFiles(paths.cardsDir);
|
|
326
|
+
const cards = files.map((fullPath) => {
|
|
327
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
328
|
+
const fm = parseFrontmatter(content);
|
|
329
|
+
const relPath = path.relative(paths.cardsDir, fullPath);
|
|
330
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
|
|
331
|
+
const firstLine = body.split("\n")[0]?.replace(/^#\s*/, "").trim();
|
|
332
|
+
return {
|
|
333
|
+
file: relPath,
|
|
334
|
+
title: firstLine || fm.id || path.basename(fullPath, ".md"),
|
|
335
|
+
date: fm.at || "",
|
|
336
|
+
agent: fm.by || "",
|
|
337
|
+
tags: fm.tags || "",
|
|
338
|
+
content: body,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
cards.sort((a, b) => b.date.localeCompare(a.date));
|
|
342
|
+
if (search) {
|
|
343
|
+
const q = search.toLowerCase();
|
|
344
|
+
return res.json(cards.filter((c) =>
|
|
345
|
+
c.title.toLowerCase().includes(q) || c.agent.toLowerCase().includes(q) || c.tags.toLowerCase().includes(q) || c.content.toLowerCase().includes(q)
|
|
346
|
+
));
|
|
347
|
+
}
|
|
348
|
+
return res.json(cards);
|
|
349
|
+
} catch {
|
|
350
|
+
return res.json([]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (action === "status") {
|
|
355
|
+
const agents = project.agents || {};
|
|
356
|
+
const status = {};
|
|
357
|
+
for (const [id, agent] of Object.entries(agents)) {
|
|
358
|
+
const targetPath = path.join(agent.cwd || "", "shared-memory.md");
|
|
359
|
+
if (fs.existsSync(targetPath)) {
|
|
360
|
+
const stat = fs.statSync(targetPath);
|
|
361
|
+
status[id] = { injected: true, lastModified: stat.mtime.toISOString() };
|
|
362
|
+
} else {
|
|
363
|
+
status[id] = { injected: false, lastModified: null };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const sourceExists = fs.existsSync(paths.sharedMemoryPath);
|
|
367
|
+
return res.json({ agents: status, sourceExists });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (action === "shared-memory") {
|
|
371
|
+
try {
|
|
372
|
+
const content = fs.readFileSync(paths.sharedMemoryPath, "utf-8");
|
|
373
|
+
return res.json({ content, path: paths.sharedMemoryPath });
|
|
374
|
+
} catch {
|
|
375
|
+
return res.json({ content: "", path: paths.sharedMemoryPath });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (action === "settings") {
|
|
380
|
+
return res.json({
|
|
381
|
+
memory_cards_dir: project.memory_cards_dir || "",
|
|
382
|
+
shared_memory_path: project.shared_memory_path || "",
|
|
383
|
+
butler_scripts_dir: project.butler_scripts_dir || "",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
res.status(400).json({ error: "Unknown action" });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
router.post("/api/memory", (req, res) => {
|
|
391
|
+
const projectId = req.query.project || "";
|
|
392
|
+
const action = req.query.action || "";
|
|
393
|
+
const project = getProject(projectId);
|
|
394
|
+
if (!project) return res.status(404).json({ error: "Project not found" });
|
|
395
|
+
|
|
396
|
+
const paths = getMemoryPaths(project);
|
|
397
|
+
|
|
398
|
+
if (action === "butler") {
|
|
399
|
+
const allowed = ["butler-scan.sh", "butler-consolidate.sh", "inject.sh"];
|
|
400
|
+
const command = req.body.command;
|
|
401
|
+
if (!allowed.includes(command)) return res.json({ ok: false, error: `Unknown command: ${command}` });
|
|
402
|
+
const scriptPath = path.join(paths.butlerDir, command);
|
|
403
|
+
if (!fs.existsSync(scriptPath)) return res.json({ ok: false, error: `Script not found: ${scriptPath}` });
|
|
404
|
+
try {
|
|
405
|
+
const output = execFileSync("bash", [scriptPath], {
|
|
406
|
+
encoding: "utf-8",
|
|
407
|
+
timeout: 30000,
|
|
408
|
+
cwd: path.dirname(paths.butlerDir),
|
|
409
|
+
});
|
|
410
|
+
return res.json({ ok: true, output });
|
|
411
|
+
} catch (err) {
|
|
412
|
+
return res.json({ ok: false, error: err.message });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (action === "save-memory") {
|
|
417
|
+
try {
|
|
418
|
+
const dir = path.dirname(paths.sharedMemoryPath);
|
|
419
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
420
|
+
fs.writeFileSync(paths.sharedMemoryPath, req.body.content);
|
|
421
|
+
return res.json({ ok: true });
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return res.json({ ok: false, error: err.message });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (action === "save-settings") {
|
|
428
|
+
try {
|
|
429
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
430
|
+
const proj = cfg.projects?.find((p) => p.id === projectId);
|
|
431
|
+
if (!proj) return res.json({ ok: false, error: "Project not found" });
|
|
432
|
+
const s = req.body;
|
|
433
|
+
if (s.memory_cards_dir !== undefined) proj.memory_cards_dir = s.memory_cards_dir || undefined;
|
|
434
|
+
if (s.shared_memory_path !== undefined) proj.shared_memory_path = s.shared_memory_path || undefined;
|
|
435
|
+
if (s.butler_scripts_dir !== undefined) proj.butler_scripts_dir = s.butler_scripts_dir || undefined;
|
|
436
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
437
|
+
return res.json({ ok: true });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return res.json({ ok: false, error: err.message });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
res.status(400).json({ error: "Unknown action" });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ─── Setup ─────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
function exec(cmd, args, opts) {
|
|
449
|
+
try {
|
|
450
|
+
const out = execFileSync(cmd, args, { encoding: "utf-8", timeout: 30000, ...opts });
|
|
451
|
+
return { ok: true, output: out.trim() };
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return { ok: false, output: err.message };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
router.post("/api/setup", (req, res) => {
|
|
458
|
+
const step = req.query.step;
|
|
459
|
+
const body = req.body || {};
|
|
460
|
+
|
|
461
|
+
switch (step) {
|
|
462
|
+
case "verify-repo": {
|
|
463
|
+
const repo = body.repo;
|
|
464
|
+
if (!repo || !REPO_RE.test(repo)) return res.json({ ok: false, error: "Invalid repo format (use owner/repo)" });
|
|
465
|
+
const result = exec("gh", ["repo", "view", repo, "--json", "name,owner"]);
|
|
466
|
+
return res.json({ ok: result.ok, error: result.ok ? undefined : "Cannot access repo. Check gh auth and repo permissions." });
|
|
467
|
+
}
|
|
468
|
+
case "create-worktrees": {
|
|
469
|
+
const workingDir = body.workingDir;
|
|
470
|
+
if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
|
|
471
|
+
if (!fs.existsSync(path.join(workingDir, ".git"))) {
|
|
472
|
+
if (!fs.existsSync(workingDir)) fs.mkdirSync(workingDir, { recursive: true });
|
|
473
|
+
if (!REPO_RE.test(body.repo)) return res.json({ ok: false, error: "Invalid repo" });
|
|
474
|
+
const clone = exec("gh", ["repo", "clone", body.repo, workingDir]);
|
|
475
|
+
if (!clone.ok) return res.json({ ok: false, error: `Clone failed: ${clone.output}` });
|
|
476
|
+
}
|
|
477
|
+
// Sibling dirs: ../projectName-t1/, ../projectName-t2a/, etc. (matches CLI wizard)
|
|
478
|
+
const projectName = path.basename(workingDir);
|
|
479
|
+
const parentDir = path.dirname(workingDir);
|
|
480
|
+
const agents = ["t1", "t2a", "t2b", "t3"];
|
|
481
|
+
const created = [];
|
|
482
|
+
const errors = [];
|
|
483
|
+
for (const agent of agents) {
|
|
484
|
+
const wtDir = path.join(parentDir, `${projectName}-${agent}`);
|
|
485
|
+
if (fs.existsSync(wtDir)) { created.push(`${agent} (exists)`); continue; }
|
|
486
|
+
const branchName = `worktree-${agent}`;
|
|
487
|
+
exec("git", ["branch", branchName, "HEAD"], { cwd: workingDir });
|
|
488
|
+
const result = exec("git", ["worktree", "add", wtDir, branchName], { cwd: workingDir });
|
|
489
|
+
if (result.ok) {
|
|
490
|
+
created.push(agent);
|
|
491
|
+
} else {
|
|
492
|
+
// Fallback: detached worktree
|
|
493
|
+
const result2 = exec("git", ["worktree", "add", "--detach", wtDir, "HEAD"], { cwd: workingDir });
|
|
494
|
+
if (result2.ok) created.push(`${agent} (detached)`);
|
|
495
|
+
else errors.push(`${agent}: ${result.output}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return res.json({ ok: errors.length === 0, created, errors });
|
|
499
|
+
}
|
|
500
|
+
case "seed-files": {
|
|
501
|
+
const workingDir = body.workingDir;
|
|
502
|
+
if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
|
|
503
|
+
// Use directory basename for sibling paths and template substitution (matches CLI)
|
|
504
|
+
const dirName = path.basename(workingDir);
|
|
505
|
+
const parentDir = path.dirname(workingDir);
|
|
506
|
+
const reviewerUser = body.reviewerUser || "";
|
|
507
|
+
const reviewerTokenPath = body.reviewerTokenPath || path.join(os.homedir(), ".quadwork", "reviewer-token");
|
|
508
|
+
const agents = ["t1", "t2a", "t2b", "t3"];
|
|
509
|
+
const seeded = [];
|
|
510
|
+
for (const agent of agents) {
|
|
511
|
+
// Sibling dir layout (matches CLI wizard)
|
|
512
|
+
const wtDir = path.join(parentDir, `${dirName}-${agent}`);
|
|
513
|
+
if (!fs.existsSync(wtDir)) continue;
|
|
514
|
+
|
|
515
|
+
// AGENTS.md — use template with placeholder substitution (matches CLI)
|
|
516
|
+
const agentsMd = path.join(wtDir, "AGENTS.md");
|
|
517
|
+
if (!fs.existsSync(agentsMd)) {
|
|
518
|
+
const seedSrc = path.join(TEMPLATES_DIR, "seeds", `${agent}.AGENTS.md`);
|
|
519
|
+
if (fs.existsSync(seedSrc)) {
|
|
520
|
+
let content = fs.readFileSync(seedSrc, "utf-8");
|
|
521
|
+
content = content.replace(/\{\{reviewer_github_user\}\}/g, reviewerUser);
|
|
522
|
+
content = content.replace(/\{\{reviewer_token_path\}\}/g, reviewerTokenPath);
|
|
523
|
+
fs.writeFileSync(agentsMd, content);
|
|
524
|
+
} else {
|
|
525
|
+
// Fallback stub if template missing
|
|
526
|
+
fs.writeFileSync(agentsMd, `# ${dirName} — ${agent.toUpperCase()} Agent\n\nRepo: ${body.repo}\nRole: ${agent === "t1" ? "Owner" : agent.startsWith("t2") ? "Reviewer" : "Builder"}\n`);
|
|
527
|
+
}
|
|
528
|
+
seeded.push(`${agent}/AGENTS.md`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// CLAUDE.md — use template with placeholder substitution (matches CLI)
|
|
532
|
+
const claudeMd = path.join(wtDir, "CLAUDE.md");
|
|
533
|
+
if (!fs.existsSync(claudeMd)) {
|
|
534
|
+
const claudeSrc = path.join(TEMPLATES_DIR, "CLAUDE.md");
|
|
535
|
+
if (fs.existsSync(claudeSrc)) {
|
|
536
|
+
let content = fs.readFileSync(claudeSrc, "utf-8");
|
|
537
|
+
// CLI uses path.basename(workingDir) for {{project_name}}
|
|
538
|
+
content = content.replace(/\{\{project_name\}\}/g, dirName);
|
|
539
|
+
fs.writeFileSync(claudeMd, content);
|
|
540
|
+
} else {
|
|
541
|
+
fs.writeFileSync(claudeMd, `# ${dirName}\n\nBranch: task/<issue>-<slug>\nCommit: [#<issue>] Short description\nNever push to main.\n`);
|
|
542
|
+
}
|
|
543
|
+
seeded.push(`${agent}/CLAUDE.md`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return res.json({ ok: true, seeded });
|
|
547
|
+
}
|
|
548
|
+
case "agentchattr-config": {
|
|
549
|
+
const workingDir = body.workingDir;
|
|
550
|
+
if (!workingDir) return res.json({ ok: false, error: "Missing working directory" });
|
|
551
|
+
// Use directory basename for sibling paths, display name for metadata
|
|
552
|
+
const dirName = path.basename(workingDir);
|
|
553
|
+
const displayName = body.projectName || dirName;
|
|
554
|
+
const parentDir = path.dirname(workingDir);
|
|
555
|
+
const tomlPaths = [
|
|
556
|
+
path.join(workingDir, "agentchattr", "config.toml"),
|
|
557
|
+
path.join(parentDir, "agentchattr", "config.toml"),
|
|
558
|
+
path.join(os.homedir(), ".agentchattr", "config.toml"),
|
|
559
|
+
];
|
|
560
|
+
let tomlPath = tomlPaths.find((p) => fs.existsSync(p));
|
|
561
|
+
const backends = body.backends;
|
|
562
|
+
if (!tomlPath) {
|
|
563
|
+
const dir = path.join(workingDir, "agentchattr");
|
|
564
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
565
|
+
tomlPath = path.join(dir, "config.toml");
|
|
566
|
+
const agents = ["t1", "t2a", "t2b", "t3"];
|
|
567
|
+
const colors = ["#10a37f", "#22c55e", "#f59e0b", "#da7756"];
|
|
568
|
+
const labels = ["Owner", "Reviewer", "Reviewer", "Builder"];
|
|
569
|
+
let content = `[meta]\nname = "${displayName}"\n\n`;
|
|
570
|
+
agents.forEach((agent, i) => {
|
|
571
|
+
const wtDir = path.join(parentDir, `${dirName}-${agent}`);
|
|
572
|
+
content += `[agents.${agent}]\ncommand = "${(backends && backends[agent]) || "claude"}"\ncwd = "${wtDir}"\ncolor = "${colors[i]}"\nlabel = "${agent.toUpperCase()} ${labels[i]}"\nmcp_inject = "flag"\n\n`;
|
|
573
|
+
});
|
|
574
|
+
fs.writeFileSync(tomlPath, content);
|
|
575
|
+
} else {
|
|
576
|
+
let content = fs.readFileSync(tomlPath, "utf-8");
|
|
577
|
+
const agents = ["t1", "t2a", "t2b", "t3"];
|
|
578
|
+
const colors = ["#10a37f", "#22c55e", "#f59e0b", "#da7756"];
|
|
579
|
+
const labels = ["Owner", "Reviewer", "Reviewer", "Builder"];
|
|
580
|
+
agents.forEach((agent, i) => {
|
|
581
|
+
if (!content.includes(`[agents.${agent}]`)) {
|
|
582
|
+
const wtDir = path.join(parentDir, `${dirName}-${agent}`);
|
|
583
|
+
content += `\n[agents.${agent}]\ncommand = "${(backends && backends[agent]) || "claude"}"\ncwd = "${wtDir}"\ncolor = "${colors[i]}"\nlabel = "${agent.toUpperCase()} ${labels[i]}"\nmcp_inject = "flag"\n`;
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
fs.writeFileSync(tomlPath, content);
|
|
587
|
+
}
|
|
588
|
+
// Restart AgentChattr so config changes take effect
|
|
589
|
+
try {
|
|
590
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
591
|
+
const port = cfg.port || 8400;
|
|
592
|
+
fetch(`http://127.0.0.1:${port}/api/agentchattr/restart`, { method: "POST" }).catch(() => {});
|
|
593
|
+
} catch {}
|
|
594
|
+
return res.json({ ok: true, path: tomlPath });
|
|
595
|
+
}
|
|
596
|
+
case "add-config": {
|
|
597
|
+
const { id, name, repo, workingDir, backends } = body;
|
|
598
|
+
// Use directory basename for sibling paths (matches CLI wizard)
|
|
599
|
+
const dirName = path.basename(workingDir);
|
|
600
|
+
const parentDir = path.dirname(workingDir);
|
|
601
|
+
let cfg;
|
|
602
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
|
|
603
|
+
catch { cfg = { port: 8400, agentchattr_url: "http://127.0.0.1:8300", projects: [] }; }
|
|
604
|
+
if (cfg.projects.some((p) => p.id === id)) return res.json({ ok: true, message: "Project already in config" });
|
|
605
|
+
// Match CLI wizard agent structure: { cwd, command }
|
|
606
|
+
const agents = {};
|
|
607
|
+
for (const agentId of ["t1", "t2a", "t2b", "t3"]) {
|
|
608
|
+
agents[agentId] = {
|
|
609
|
+
cwd: path.join(parentDir, `${dirName}-${agentId}`),
|
|
610
|
+
command: (backends && backends[agentId]) || "claude",
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
cfg.projects.push({ id, name, repo, working_dir: workingDir, agents });
|
|
614
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
615
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
616
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
617
|
+
return res.json({ ok: true });
|
|
618
|
+
}
|
|
619
|
+
default:
|
|
620
|
+
return res.status(400).json({ error: "Unknown step" });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ─── Rename ────────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
function replaceInFile(filePath, oldStr, newStr) {
|
|
627
|
+
try {
|
|
628
|
+
if (!fs.existsSync(filePath)) return false;
|
|
629
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
630
|
+
if (!content.includes(oldStr)) return false;
|
|
631
|
+
fs.writeFileSync(filePath, content.replaceAll(oldStr, newStr));
|
|
632
|
+
return true;
|
|
633
|
+
} catch {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function replaceInFileRegex(filePath, oldStr, newStr) {
|
|
639
|
+
try {
|
|
640
|
+
if (!fs.existsSync(filePath)) return false;
|
|
641
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
642
|
+
const escaped = oldStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
643
|
+
const regex = new RegExp(`\\b${escaped}\\b`, "g");
|
|
644
|
+
if (!regex.test(content)) return false;
|
|
645
|
+
fs.writeFileSync(filePath, content.replace(regex, newStr));
|
|
646
|
+
return true;
|
|
647
|
+
} catch {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
router.post("/api/rename", (req, res) => {
|
|
653
|
+
const { type, projectId, oldName, newName, agentId } = req.body;
|
|
654
|
+
const cfg = readConfigFile();
|
|
655
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
656
|
+
if (!project) return res.status(404).json({ error: "Project not found" });
|
|
657
|
+
|
|
658
|
+
const changes = [];
|
|
659
|
+
const workDir = project.working_dir || "";
|
|
660
|
+
|
|
661
|
+
if (type === "project") {
|
|
662
|
+
project.name = newName;
|
|
663
|
+
changes.push("config.json");
|
|
664
|
+
if (project.trigger_message && project.trigger_message.includes(oldName)) {
|
|
665
|
+
project.trigger_message = project.trigger_message.replaceAll(oldName, newName);
|
|
666
|
+
changes.push("trigger_message");
|
|
667
|
+
}
|
|
668
|
+
if (workDir) {
|
|
669
|
+
const claudeMd = path.join(workDir, "CLAUDE.md");
|
|
670
|
+
if (replaceInFile(claudeMd, oldName, newName)) changes.push("CLAUDE.md");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (type === "agent" && agentId) {
|
|
675
|
+
const agent = project.agents?.[agentId];
|
|
676
|
+
if (agent) {
|
|
677
|
+
const oldDisplayName = oldName || agent.display_name || agentId.toUpperCase();
|
|
678
|
+
agent.display_name = newName;
|
|
679
|
+
changes.push("config.json");
|
|
680
|
+
if (agent.agents_md && agent.agents_md.includes(oldDisplayName)) {
|
|
681
|
+
agent.agents_md = agent.agents_md.replaceAll(oldDisplayName, newName);
|
|
682
|
+
changes.push("agents_md");
|
|
683
|
+
}
|
|
684
|
+
if (project.trigger_message) {
|
|
685
|
+
const oldMention = `@${oldDisplayName.toLowerCase()}`;
|
|
686
|
+
const newMention = `@${newName.toLowerCase()}`;
|
|
687
|
+
if (project.trigger_message.includes(oldMention)) {
|
|
688
|
+
project.trigger_message = project.trigger_message.replaceAll(oldMention, newMention);
|
|
689
|
+
changes.push("trigger_message");
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (workDir) {
|
|
693
|
+
const tomlPaths = [
|
|
694
|
+
path.join(workDir, "agentchattr", "config.toml"),
|
|
695
|
+
path.join(workDir, "..", "agentchattr", "config.toml"),
|
|
696
|
+
path.join(workDir, "config.toml"),
|
|
697
|
+
];
|
|
698
|
+
for (const tomlPath of tomlPaths) {
|
|
699
|
+
if (replaceInFile(tomlPath, `label = "${oldDisplayName}"`, `label = "${newName}"`)) {
|
|
700
|
+
changes.push("agentchattr/config.toml");
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const claudeMd = path.join(workDir, "CLAUDE.md");
|
|
705
|
+
if (replaceInFileRegex(claudeMd, oldDisplayName, newName)) changes.push("CLAUDE.md");
|
|
706
|
+
}
|
|
707
|
+
if (agent.cwd) {
|
|
708
|
+
const agentsMd = path.join(agent.cwd, "AGENTS.md");
|
|
709
|
+
if (replaceInFile(agentsMd, oldDisplayName, newName)) changes.push("AGENTS.md");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
writeConfigFile(cfg);
|
|
715
|
+
|
|
716
|
+
// Sync triggers internally
|
|
717
|
+
if (typeof req.app.get("syncTriggers") === "function") {
|
|
718
|
+
req.app.get("syncTriggers")();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
res.json({ ok: true, changes });
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ─── Telegram ──────────────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
const BRIDGE_DIR = path.join(CONFIG_DIR, "agentchattr-telegram");
|
|
727
|
+
|
|
728
|
+
function telegramPidFile(projectId) {
|
|
729
|
+
return path.join(CONFIG_DIR, `telegram-bridge-${projectId}.pid`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function telegramConfigToml(projectId) {
|
|
733
|
+
return path.join(CONFIG_DIR, `telegram-${projectId}.toml`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isTelegramRunning(projectId) {
|
|
737
|
+
const pf = telegramPidFile(projectId);
|
|
738
|
+
if (!fs.existsSync(pf)) return false;
|
|
739
|
+
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
740
|
+
if (!pid) return false;
|
|
741
|
+
try {
|
|
742
|
+
process.kill(pid, 0);
|
|
743
|
+
return true;
|
|
744
|
+
} catch {
|
|
745
|
+
fs.unlinkSync(pf);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function readEnvToken(key) {
|
|
751
|
+
try {
|
|
752
|
+
const content = fs.readFileSync(ENV_PATH, "utf-8");
|
|
753
|
+
const match = content.match(new RegExp(`^${key}=(.*)$`, "m"));
|
|
754
|
+
return match ? match[1].trim().replace(/^["']|["']$/g, "") : "";
|
|
755
|
+
} catch {
|
|
756
|
+
return "";
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function writeEnvToken(key, value) {
|
|
761
|
+
let content = "";
|
|
762
|
+
try { content = fs.readFileSync(ENV_PATH, "utf-8"); } catch {}
|
|
763
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
764
|
+
const line = `${key}=${value}`;
|
|
765
|
+
if (regex.test(content)) content = content.replace(regex, line);
|
|
766
|
+
else content = content.trimEnd() + (content ? "\n" : "") + line + "\n";
|
|
767
|
+
fs.writeFileSync(ENV_PATH, content, { mode: 0o600 });
|
|
768
|
+
fs.chmodSync(ENV_PATH, 0o600);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function resolveToken(value) {
|
|
772
|
+
if (value.startsWith("env:")) return readEnvToken(value.slice(4)) || "";
|
|
773
|
+
return value;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function envKeyForProject(projectId) {
|
|
777
|
+
return `TELEGRAM_BOT_TOKEN_${projectId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function getProjectTelegram(projectId) {
|
|
781
|
+
try {
|
|
782
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
783
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
784
|
+
if (!project?.telegram) return null;
|
|
785
|
+
return {
|
|
786
|
+
bot_token: resolveToken(project.telegram.bot_token || ""),
|
|
787
|
+
chat_id: project.telegram.chat_id || "",
|
|
788
|
+
agentchattr_url: cfg.agentchattr_url || "http://127.0.0.1:8300",
|
|
789
|
+
};
|
|
790
|
+
} catch {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
router.get("/api/telegram", (req, res) => {
|
|
796
|
+
const projectId = req.query.project || "";
|
|
797
|
+
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
798
|
+
res.json({ running: isTelegramRunning(projectId) });
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
router.post("/api/telegram", async (req, res) => {
|
|
802
|
+
const action = req.query.action;
|
|
803
|
+
const body = req.body || {};
|
|
804
|
+
|
|
805
|
+
switch (action) {
|
|
806
|
+
case "test": {
|
|
807
|
+
const { bot_token, chat_id } = body;
|
|
808
|
+
if (!bot_token || !chat_id) return res.json({ ok: false, error: "Missing bot_token or chat_id" });
|
|
809
|
+
const resolved = resolveToken(bot_token);
|
|
810
|
+
if (!resolved) return res.json({ ok: false, error: "Could not resolve bot token from environment" });
|
|
811
|
+
try {
|
|
812
|
+
const r = await fetch(`https://api.telegram.org/bot${resolved}/getChat?chat_id=${chat_id}`);
|
|
813
|
+
const data = await r.json();
|
|
814
|
+
return res.json({ ok: data.ok, error: data.ok ? undefined : data.description });
|
|
815
|
+
} catch (err) {
|
|
816
|
+
return res.json({ ok: false, error: err.message || "Connection failed" });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
case "install": {
|
|
820
|
+
try {
|
|
821
|
+
if (!fs.existsSync(BRIDGE_DIR)) {
|
|
822
|
+
execFileSync("gh", ["repo", "clone", "realproject7/agentchattr-telegram", BRIDGE_DIR], { encoding: "utf-8", timeout: 30000 });
|
|
823
|
+
}
|
|
824
|
+
execFileSync("pip3", ["install", "-r", path.join(BRIDGE_DIR, "requirements.txt")], { encoding: "utf-8", timeout: 30000 });
|
|
825
|
+
return res.json({ ok: true });
|
|
826
|
+
} catch (err) {
|
|
827
|
+
return res.json({ ok: false, error: err.message || "Install failed" });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
case "start": {
|
|
831
|
+
const projectId = body.project_id;
|
|
832
|
+
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
833
|
+
if (isTelegramRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
|
|
834
|
+
const bridgeScript = path.join(BRIDGE_DIR, "telegram_bridge.py");
|
|
835
|
+
if (!fs.existsSync(bridgeScript)) return res.json({ ok: false, error: "Bridge not installed. Click Install Bridge first." });
|
|
836
|
+
const tg = getProjectTelegram(projectId);
|
|
837
|
+
if (!tg || !tg.bot_token || !tg.chat_id) return res.json({ ok: false, error: "Save bot_token and chat_id in project settings first." });
|
|
838
|
+
const tomlPath = telegramConfigToml(projectId);
|
|
839
|
+
const tomlContent = `[telegram]\nbot_token = "${tg.bot_token}"\nchat_id = "${tg.chat_id}"\n\n[agentchattr]\nurl = "${tg.agentchattr_url}"\n`;
|
|
840
|
+
fs.writeFileSync(tomlPath, tomlContent, { mode: 0o600 });
|
|
841
|
+
fs.chmodSync(tomlPath, 0o600);
|
|
842
|
+
try {
|
|
843
|
+
const child = spawn("python3", [bridgeScript, "--config", tomlPath], { detached: true, stdio: "ignore" });
|
|
844
|
+
child.unref();
|
|
845
|
+
if (child.pid) fs.writeFileSync(telegramPidFile(projectId), String(child.pid));
|
|
846
|
+
return res.json({ ok: true, running: true, pid: child.pid });
|
|
847
|
+
} catch (err) {
|
|
848
|
+
return res.json({ ok: false, error: err.message || "Start failed" });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
case "stop": {
|
|
852
|
+
const projectId = body.project_id;
|
|
853
|
+
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
854
|
+
try {
|
|
855
|
+
const pf = telegramPidFile(projectId);
|
|
856
|
+
if (fs.existsSync(pf)) {
|
|
857
|
+
const pid = parseInt(fs.readFileSync(pf, "utf-8").trim(), 10);
|
|
858
|
+
if (pid) process.kill(pid, "SIGTERM");
|
|
859
|
+
fs.unlinkSync(pf);
|
|
860
|
+
}
|
|
861
|
+
return res.json({ ok: true, running: false });
|
|
862
|
+
} catch (err) {
|
|
863
|
+
return res.json({ ok: false, error: err.message || "Stop failed" });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
case "status":
|
|
867
|
+
return res.json({ running: isTelegramRunning(body.project_id || "") });
|
|
868
|
+
case "save-token": {
|
|
869
|
+
const projectId = body.project_id;
|
|
870
|
+
if (!projectId) return res.json({ ok: false, error: "Missing project_id" });
|
|
871
|
+
const envKey = envKeyForProject(projectId);
|
|
872
|
+
writeEnvToken(envKey, body.bot_token);
|
|
873
|
+
try {
|
|
874
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
875
|
+
const cfg = JSON.parse(raw);
|
|
876
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
877
|
+
if (project?.telegram) {
|
|
878
|
+
project.telegram.bot_token = `env:${envKey}`;
|
|
879
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
880
|
+
}
|
|
881
|
+
} catch {}
|
|
882
|
+
return res.json({ ok: true, env_key: envKey });
|
|
883
|
+
}
|
|
884
|
+
default:
|
|
885
|
+
return res.status(400).json({ error: "Unknown action" });
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
module.exports = router;
|