let-them-talk 3.5.0 → 3.6.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/CHANGELOG.md +57 -0
- package/README.md +62 -21
- package/cli.js +16 -9
- package/conversation-templates/code-review.json +11 -0
- package/conversation-templates/debug-squad.json +11 -0
- package/conversation-templates/feature-build.json +11 -0
- package/conversation-templates/managed-team.json +12 -0
- package/conversation-templates/research-write.json +11 -0
- package/dashboard.html +7389 -5720
- package/dashboard.js +2017 -1766
- package/mods/built-in-accessories.json +122 -0
- package/mods/registry.json +4 -0
- package/office/accessories.js +265 -0
- package/office/agents.js +376 -0
- package/office/animation.js +337 -0
- package/office/appearance.js +56 -0
- package/office/character.js +208 -0
- package/office/constants.js +62 -0
- package/office/environment.js +805 -0
- package/office/face.js +258 -0
- package/office/hair.js +183 -0
- package/office/index.js +337 -0
- package/office/mod-loader.js +257 -0
- package/office/monitors.js +113 -0
- package/office/outfits.js +212 -0
- package/office/scene.js +75 -0
- package/office/spectator-camera.js +177 -0
- package/office/state.js +25 -0
- package/package.json +58 -56
- package/server.js +2704 -2196
- package/templates/managed.json +26 -0
package/dashboard.js
CHANGED
|
@@ -1,1766 +1,2017 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const http = require('http');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const { spawn } = require('child_process');
|
|
7
|
-
|
|
8
|
-
// --- File-level mutex for serializing read-then-write operations ---
|
|
9
|
-
const lockMap = new Map();
|
|
10
|
-
function withFileLock(filePath, fn) {
|
|
11
|
-
const prev = lockMap.get(filePath) || Promise.resolve();
|
|
12
|
-
const next = prev.then(fn, fn);
|
|
13
|
-
lockMap.set(filePath, next.then(() => {}, () => {}));
|
|
14
|
-
return next;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
|
|
18
|
-
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
19
|
-
let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
20
|
-
|
|
21
|
-
const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
|
|
22
|
-
let LAN_TOKEN = null;
|
|
23
|
-
|
|
24
|
-
function generateLanToken() {
|
|
25
|
-
const crypto = require('crypto');
|
|
26
|
-
LAN_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
27
|
-
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN); } catch {}
|
|
28
|
-
return LAN_TOKEN;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function loadLanToken() {
|
|
32
|
-
if (fs.existsSync(LAN_TOKEN_FILE)) {
|
|
33
|
-
try { LAN_TOKEN = fs.readFileSync(LAN_TOKEN_FILE, 'utf8').trim(); } catch {}
|
|
34
|
-
}
|
|
35
|
-
if (!LAN_TOKEN) generateLanToken();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Load or generate token on startup
|
|
39
|
-
loadLanToken();
|
|
40
|
-
|
|
41
|
-
function persistLanMode() {
|
|
42
|
-
try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function getLanIP() {
|
|
46
|
-
const interfaces = os.networkInterfaces();
|
|
47
|
-
let fallback = null;
|
|
48
|
-
for (const name of Object.keys(interfaces)) {
|
|
49
|
-
for (const iface of interfaces[name]) {
|
|
50
|
-
if (iface.family === 'IPv4' && !iface.internal) {
|
|
51
|
-
// Prefer real LAN IPs (192.168.x, 10.x, 172.16-31.x) over link-local (169.254.x)
|
|
52
|
-
if (!iface.address.startsWith('169.254.')) return iface.address;
|
|
53
|
-
if (!fallback) fallback = iface.address;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return fallback;
|
|
58
|
-
}
|
|
59
|
-
const DEFAULT_DATA_DIR = process.env.AGENT_BRIDGE_DATA || path.join(process.cwd(), '.agent-bridge');
|
|
60
|
-
const HTML_FILE = path.join(__dirname, 'dashboard.html');
|
|
61
|
-
const LOGO_FILE = path.join(__dirname, 'logo.png');
|
|
62
|
-
const PROJECTS_FILE = path.join(__dirname, 'projects.json');
|
|
63
|
-
|
|
64
|
-
// --- Multi-project support ---
|
|
65
|
-
|
|
66
|
-
function getProjects() {
|
|
67
|
-
if (!fs.existsSync(PROJECTS_FILE)) return [];
|
|
68
|
-
try { return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); } catch { return []; }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function saveProjects(projects) {
|
|
72
|
-
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check if a directory has actual data files (not just an empty dir)
|
|
76
|
-
function hasDataFiles(dir) {
|
|
77
|
-
if (!fs.existsSync(dir)) return false;
|
|
78
|
-
try {
|
|
79
|
-
const files = fs.readdirSync(dir);
|
|
80
|
-
return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
|
|
81
|
-
} catch { return false; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Resolve data dir: explicit project path > env var > cwd > legacy fallback
|
|
85
|
-
// Prefers directories with actual data files over empty ones
|
|
86
|
-
function resolveDataDir(projectPath) {
|
|
87
|
-
if (projectPath) {
|
|
88
|
-
const dir = path.join(projectPath, '.agent-bridge');
|
|
89
|
-
const dataDir = path.join(projectPath, 'data');
|
|
90
|
-
// Prefer whichever has data
|
|
91
|
-
if (hasDataFiles(dir)) return dir;
|
|
92
|
-
if (hasDataFiles(dataDir)) return dataDir;
|
|
93
|
-
if (fs.existsSync(dir)) return dir;
|
|
94
|
-
if (fs.existsSync(dataDir)) return dataDir;
|
|
95
|
-
return dir;
|
|
96
|
-
}
|
|
97
|
-
const legacyDir = path.join(__dirname, 'data');
|
|
98
|
-
// Prefer dir with actual data files
|
|
99
|
-
if (hasDataFiles(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
100
|
-
if (hasDataFiles(legacyDir)) return legacyDir;
|
|
101
|
-
if (fs.existsSync(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
102
|
-
if (fs.existsSync(legacyDir)) return legacyDir;
|
|
103
|
-
return DEFAULT_DATA_DIR;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function filePath(name, projectPath) {
|
|
107
|
-
return path.join(resolveDataDir(projectPath), name);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Validate project path is registered or is the default
|
|
111
|
-
function validateProjectPath(projectPath) {
|
|
112
|
-
if (!projectPath) return true;
|
|
113
|
-
const absPath = path.resolve(projectPath);
|
|
114
|
-
const projects = getProjects();
|
|
115
|
-
const cwd = path.resolve(process.cwd());
|
|
116
|
-
const scriptDir = path.resolve(__dirname);
|
|
117
|
-
if (absPath === cwd || absPath === scriptDir) return true;
|
|
118
|
-
return projects.some(p => path.resolve(p.path) === absPath);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function htmlEscape(s) {
|
|
122
|
-
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// --- Shared helpers ---
|
|
126
|
-
|
|
127
|
-
function readJsonl(file) {
|
|
128
|
-
if (!fs.existsSync(file)) return [];
|
|
129
|
-
const content = fs.readFileSync(file, 'utf8').trim();
|
|
130
|
-
if (!content) return [];
|
|
131
|
-
return content.split('\n').map(line => {
|
|
132
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
133
|
-
}).filter(Boolean);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function readJson(file) {
|
|
137
|
-
if (!fs.existsSync(file)) return {};
|
|
138
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function isPidAlive(pid, lastActivity) {
|
|
142
|
-
try {
|
|
143
|
-
process.kill(pid, 0);
|
|
144
|
-
if (lastActivity) {
|
|
145
|
-
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
146
|
-
if (stale > 30000) return false; // 30s = 3 missed heartbeats
|
|
147
|
-
}
|
|
148
|
-
return true;
|
|
149
|
-
} catch { return false; }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// --- Default avatar helpers ---
|
|
153
|
-
const BUILT_IN_AVATARS = [
|
|
154
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2358a6ff'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Crect x='20' y='38' width='24' height='4' rx='2' fill='%23fff'/%3E%3Crect x='14' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3Crect x='44' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3C/svg%3E",
|
|
155
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%233fb950'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 46 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
156
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23d29922'/%3E%3Crect x='16' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Crect x='36' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M24 40 H40' stroke='%23fff' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
157
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f85149'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M22 40 Q32 34 42 40' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
158
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23bc8cff'/%3E%3Ccircle cx='22' cy='28' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='28' r='4' fill='%23fff'/%3E%3Cpath d='M16 18 L22 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M48 18 L42 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cellipse cx='32' cy='42' rx='8' ry='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
159
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f778ba'/%3E%3Ccircle cx='24' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='40' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='24' cy='26' r='3' fill='%23333'/%3E%3Ccircle cx='40' cy='26' r='3' fill='%23333'/%3E%3Cpath d='M26 40 Q32 46 38 40' stroke='%23fff' fill='none' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
160
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2379c0ff'/%3E%3Crect x='17' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Crect x='37' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Cpath d='M22 38 L32 44 L42 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E",
|
|
161
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%237ee787'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%23333'/%3E%3Ccircle cx='43' cy='25' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 48 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
162
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23e3b341'/%3E%3Cpath d='M18 22 L26 30 L18 30Z' fill='%23fff'/%3E%3Cpath d='M46 22 L38 30 L46 30Z' fill='%23fff'/%3E%3Crect x='24' y='38' width='16' height='6' rx='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
163
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23ffa198'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='27' r='2.5' fill='%23333'/%3E%3Ccircle cx='42' cy='27' r='2.5' fill='%23333'/%3E%3Cellipse cx='32' cy='42' rx='6' ry='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
164
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%230969da'/%3E%3Crect x='16' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Crect x='34' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%230969da'/%3E%3Ccircle cx='41' cy='25' r='2' fill='%230969da'/%3E%3Crect x='26' y='38' width='12' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E",
|
|
165
|
-
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%238250df'/%3E%3Ccircle cx='24' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='40' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='24' cy='24' r='2' fill='%238250df'/%3E%3Ccircle cx='40' cy='24' r='2' fill='%238250df'/%3E%3Cpath d='M20 38 Q32 50 44 38' stroke='%23fff' fill='none' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='32' cy='10' r='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
166
|
-
];
|
|
167
|
-
|
|
168
|
-
function hashName(name) {
|
|
169
|
-
let h = 0;
|
|
170
|
-
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
|
171
|
-
return Math.abs(h);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function getDefaultAvatar(name) {
|
|
175
|
-
return BUILT_IN_AVATARS[hashName(name) % BUILT_IN_AVATARS.length];
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// --- API handlers ---
|
|
179
|
-
|
|
180
|
-
function apiHistory(query) {
|
|
181
|
-
const projectPath = query.get('project') || null;
|
|
182
|
-
const branch = query.get('branch') || null;
|
|
183
|
-
if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) {
|
|
184
|
-
return { error: 'Invalid branch name' };
|
|
185
|
-
}
|
|
186
|
-
const histFile = branch && branch !== 'main'
|
|
187
|
-
? filePath(`branch-${branch}-history.jsonl`, projectPath)
|
|
188
|
-
: filePath('history.jsonl', projectPath);
|
|
189
|
-
const history = readJsonl(histFile);
|
|
190
|
-
const acks = readJson(filePath('acks.json', projectPath));
|
|
191
|
-
const limit = parseInt(query.get('limit') || '500', 10);
|
|
192
|
-
const threadId = query.get('thread_id');
|
|
193
|
-
|
|
194
|
-
let messages = history;
|
|
195
|
-
if (threadId) {
|
|
196
|
-
messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
|
|
197
|
-
}
|
|
198
|
-
messages = messages.slice(-limit);
|
|
199
|
-
messages.forEach(m => { m.acked = !!acks[m.id]; });
|
|
200
|
-
return messages;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function apiAgents(query) {
|
|
204
|
-
const projectPath = query.get('project') || null;
|
|
205
|
-
const agents = readJson(filePath('agents.json', projectPath));
|
|
206
|
-
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
207
|
-
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
208
|
-
const result = {};
|
|
209
|
-
|
|
210
|
-
// Build last message timestamp per agent from history
|
|
211
|
-
const lastMessageTime = {};
|
|
212
|
-
for (const m of history) {
|
|
213
|
-
lastMessageTime[m.from] = m.timestamp;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
for (const [name, info] of Object.entries(agents)) {
|
|
217
|
-
const alive = isPidAlive(info.pid, info.last_activity);
|
|
218
|
-
const lastActivity = info.last_activity || info.timestamp;
|
|
219
|
-
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
220
|
-
const profile = profiles[name] || {};
|
|
221
|
-
result[name] = {
|
|
222
|
-
pid: info.pid,
|
|
223
|
-
alive,
|
|
224
|
-
registered_at: info.timestamp,
|
|
225
|
-
last_activity: lastActivity,
|
|
226
|
-
last_message: lastMessageTime[name] || null,
|
|
227
|
-
idle_seconds: alive ? idleSeconds : null,
|
|
228
|
-
status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
|
|
229
|
-
listening_since: info.listening_since || null,
|
|
230
|
-
is_listening: !!(info.listening_since && alive),
|
|
231
|
-
provider: info.provider || 'unknown',
|
|
232
|
-
branch: info.branch || 'main',
|
|
233
|
-
display_name: profile.display_name || name,
|
|
234
|
-
avatar: profile.avatar || getDefaultAvatar(name),
|
|
235
|
-
role: profile.role || '',
|
|
236
|
-
bio: profile.bio || '',
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.
|
|
532
|
-
.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
.
|
|
543
|
-
.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
var
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
</
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
.
|
|
718
|
-
.
|
|
719
|
-
|
|
720
|
-
.
|
|
721
|
-
.
|
|
722
|
-
.
|
|
723
|
-
.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
.
|
|
733
|
-
.
|
|
734
|
-
.
|
|
735
|
-
.
|
|
736
|
-
.
|
|
737
|
-
.
|
|
738
|
-
.
|
|
739
|
-
.
|
|
740
|
-
.
|
|
741
|
-
.msg
|
|
742
|
-
.msg
|
|
743
|
-
.
|
|
744
|
-
.msg-
|
|
745
|
-
.msg-
|
|
746
|
-
.msg-
|
|
747
|
-
.msg-
|
|
748
|
-
.msg-
|
|
749
|
-
.
|
|
750
|
-
.
|
|
751
|
-
.
|
|
752
|
-
.
|
|
753
|
-
.
|
|
754
|
-
.
|
|
755
|
-
.
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
var
|
|
788
|
-
var
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
</
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
if (!
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
950
|
-
if (
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (!
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
name: '
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
name: '
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
};
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
if (
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
'
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
'Content-Type': '
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
res.
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
res.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
});
|
|
1672
|
-
res.end(
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
res.writeHead(
|
|
1692
|
-
res.end(JSON.stringify(
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
res.
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
server.listen(PORT,
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
})
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
// --- File-level mutex for serializing read-then-write operations ---
|
|
9
|
+
const lockMap = new Map();
|
|
10
|
+
function withFileLock(filePath, fn) {
|
|
11
|
+
const prev = lockMap.get(filePath) || Promise.resolve();
|
|
12
|
+
const next = prev.then(fn, fn);
|
|
13
|
+
lockMap.set(filePath, next.then(() => {}, () => {}));
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PORT = parseInt(process.env.AGENT_BRIDGE_PORT || '3000', 10);
|
|
18
|
+
const LAN_STATE_FILE = path.join(__dirname, '.lan-mode');
|
|
19
|
+
let LAN_MODE = process.env.AGENT_BRIDGE_LAN === 'true' || (fs.existsSync(LAN_STATE_FILE) && fs.readFileSync(LAN_STATE_FILE, 'utf8').trim() === 'true');
|
|
20
|
+
|
|
21
|
+
const LAN_TOKEN_FILE = path.join(__dirname, '.lan-token');
|
|
22
|
+
let LAN_TOKEN = null;
|
|
23
|
+
|
|
24
|
+
function generateLanToken() {
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
LAN_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
27
|
+
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN); } catch {}
|
|
28
|
+
return LAN_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadLanToken() {
|
|
32
|
+
if (fs.existsSync(LAN_TOKEN_FILE)) {
|
|
33
|
+
try { LAN_TOKEN = fs.readFileSync(LAN_TOKEN_FILE, 'utf8').trim(); } catch {}
|
|
34
|
+
}
|
|
35
|
+
if (!LAN_TOKEN) generateLanToken();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load or generate token on startup
|
|
39
|
+
loadLanToken();
|
|
40
|
+
|
|
41
|
+
function persistLanMode() {
|
|
42
|
+
try { fs.writeFileSync(LAN_STATE_FILE, LAN_MODE ? 'true' : 'false'); } catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getLanIP() {
|
|
46
|
+
const interfaces = os.networkInterfaces();
|
|
47
|
+
let fallback = null;
|
|
48
|
+
for (const name of Object.keys(interfaces)) {
|
|
49
|
+
for (const iface of interfaces[name]) {
|
|
50
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
51
|
+
// Prefer real LAN IPs (192.168.x, 10.x, 172.16-31.x) over link-local (169.254.x)
|
|
52
|
+
if (!iface.address.startsWith('169.254.')) return iface.address;
|
|
53
|
+
if (!fallback) fallback = iface.address;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
const DEFAULT_DATA_DIR = process.env.AGENT_BRIDGE_DATA || path.join(process.cwd(), '.agent-bridge');
|
|
60
|
+
const HTML_FILE = path.join(__dirname, 'dashboard.html');
|
|
61
|
+
const LOGO_FILE = path.join(__dirname, 'logo.png');
|
|
62
|
+
const PROJECTS_FILE = path.join(__dirname, 'projects.json');
|
|
63
|
+
|
|
64
|
+
// --- Multi-project support ---
|
|
65
|
+
|
|
66
|
+
function getProjects() {
|
|
67
|
+
if (!fs.existsSync(PROJECTS_FILE)) return [];
|
|
68
|
+
try { return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); } catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveProjects(projects) {
|
|
72
|
+
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if a directory has actual data files (not just an empty dir)
|
|
76
|
+
function hasDataFiles(dir) {
|
|
77
|
+
if (!fs.existsSync(dir)) return false;
|
|
78
|
+
try {
|
|
79
|
+
const files = fs.readdirSync(dir);
|
|
80
|
+
return files.some(f => f.endsWith('.jsonl') || f === 'agents.json');
|
|
81
|
+
} catch { return false; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve data dir: explicit project path > env var > cwd > legacy fallback
|
|
85
|
+
// Prefers directories with actual data files over empty ones
|
|
86
|
+
function resolveDataDir(projectPath) {
|
|
87
|
+
if (projectPath) {
|
|
88
|
+
const dir = path.join(projectPath, '.agent-bridge');
|
|
89
|
+
const dataDir = path.join(projectPath, 'data');
|
|
90
|
+
// Prefer whichever has data
|
|
91
|
+
if (hasDataFiles(dir)) return dir;
|
|
92
|
+
if (hasDataFiles(dataDir)) return dataDir;
|
|
93
|
+
if (fs.existsSync(dir)) return dir;
|
|
94
|
+
if (fs.existsSync(dataDir)) return dataDir;
|
|
95
|
+
return dir;
|
|
96
|
+
}
|
|
97
|
+
const legacyDir = path.join(__dirname, 'data');
|
|
98
|
+
// Prefer dir with actual data files
|
|
99
|
+
if (hasDataFiles(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
100
|
+
if (hasDataFiles(legacyDir)) return legacyDir;
|
|
101
|
+
if (fs.existsSync(DEFAULT_DATA_DIR)) return DEFAULT_DATA_DIR;
|
|
102
|
+
if (fs.existsSync(legacyDir)) return legacyDir;
|
|
103
|
+
return DEFAULT_DATA_DIR;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function filePath(name, projectPath) {
|
|
107
|
+
return path.join(resolveDataDir(projectPath), name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Validate project path is registered or is the default
|
|
111
|
+
function validateProjectPath(projectPath) {
|
|
112
|
+
if (!projectPath) return true;
|
|
113
|
+
const absPath = path.resolve(projectPath);
|
|
114
|
+
const projects = getProjects();
|
|
115
|
+
const cwd = path.resolve(process.cwd());
|
|
116
|
+
const scriptDir = path.resolve(__dirname);
|
|
117
|
+
if (absPath === cwd || absPath === scriptDir) return true;
|
|
118
|
+
return projects.some(p => path.resolve(p.path) === absPath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function htmlEscape(s) {
|
|
122
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Shared helpers ---
|
|
126
|
+
|
|
127
|
+
function readJsonl(file) {
|
|
128
|
+
if (!fs.existsSync(file)) return [];
|
|
129
|
+
const content = fs.readFileSync(file, 'utf8').trim();
|
|
130
|
+
if (!content) return [];
|
|
131
|
+
return content.split('\n').map(line => {
|
|
132
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
133
|
+
}).filter(Boolean);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(file) {
|
|
137
|
+
if (!fs.existsSync(file)) return {};
|
|
138
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPidAlive(pid, lastActivity) {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(pid, 0);
|
|
144
|
+
if (lastActivity) {
|
|
145
|
+
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
146
|
+
if (stale > 30000) return false; // 30s = 3 missed heartbeats
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
} catch { return false; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Default avatar helpers ---
|
|
153
|
+
const BUILT_IN_AVATARS = [
|
|
154
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2358a6ff'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Crect x='20' y='38' width='24' height='4' rx='2' fill='%23fff'/%3E%3Crect x='14' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3Crect x='44' y='12' width='6' height='10' rx='3' fill='%2358a6ff' stroke='%23fff' stroke-width='1.5'/%3E%3C/svg%3E",
|
|
155
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%233fb950'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 46 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
156
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23d29922'/%3E%3Crect x='16' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Crect x='36' y='22' width='12' height='8' rx='2' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M24 40 H40' stroke='%23fff' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
157
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f85149'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='22' cy='26' r='2' fill='%23333'/%3E%3Ccircle cx='42' cy='26' r='2' fill='%23333'/%3E%3Cpath d='M22 40 Q32 34 42 40' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
158
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23bc8cff'/%3E%3Ccircle cx='22' cy='28' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='28' r='4' fill='%23fff'/%3E%3Cpath d='M16 18 L22 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M48 18 L42 24' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cellipse cx='32' cy='42' rx='8' ry='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
159
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23f778ba'/%3E%3Ccircle cx='24' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='40' cy='26' r='6' fill='%23fff'/%3E%3Ccircle cx='24' cy='26' r='3' fill='%23333'/%3E%3Ccircle cx='40' cy='26' r='3' fill='%23333'/%3E%3Cpath d='M26 40 Q32 46 38 40' stroke='%23fff' fill='none' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
160
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%2379c0ff'/%3E%3Crect x='17' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Crect x='37' y='23' width='10' height='6' rx='3' fill='%23fff'/%3E%3Cpath d='M22 38 L32 44 L42 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E",
|
|
161
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%237ee787'/%3E%3Ccircle cx='22' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='4' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%23333'/%3E%3Ccircle cx='43' cy='25' r='2' fill='%23333'/%3E%3Cpath d='M20 38 Q32 48 44 38' stroke='%23fff' fill='none' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E",
|
|
162
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23e3b341'/%3E%3Cpath d='M18 22 L26 30 L18 30Z' fill='%23fff'/%3E%3Cpath d='M46 22 L38 30 L46 30Z' fill='%23fff'/%3E%3Crect x='24' y='38' width='16' height='6' rx='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
163
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23ffa198'/%3E%3Ccircle cx='22' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='42' cy='26' r='5' fill='%23fff'/%3E%3Ccircle cx='22' cy='27' r='2.5' fill='%23333'/%3E%3Ccircle cx='42' cy='27' r='2.5' fill='%23333'/%3E%3Cellipse cx='32' cy='42' rx='6' ry='3' fill='%23fff'/%3E%3C/svg%3E",
|
|
164
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%230969da'/%3E%3Crect x='16' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Crect x='34' y='20' width='14' height='10' rx='2' fill='%23fff'/%3E%3Ccircle cx='23' cy='25' r='2' fill='%230969da'/%3E%3Ccircle cx='41' cy='25' r='2' fill='%230969da'/%3E%3Crect x='26' y='38' width='12' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E",
|
|
165
|
+
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%238250df'/%3E%3Ccircle cx='24' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='40' cy='24' r='5' fill='%23fff'/%3E%3Ccircle cx='24' cy='24' r='2' fill='%238250df'/%3E%3Ccircle cx='40' cy='24' r='2' fill='%238250df'/%3E%3Cpath d='M20 38 Q32 50 44 38' stroke='%23fff' fill='none' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='32' cy='10' r='4' fill='%23fff'/%3E%3C/svg%3E",
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
function hashName(name) {
|
|
169
|
+
let h = 0;
|
|
170
|
+
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
|
171
|
+
return Math.abs(h);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getDefaultAvatar(name) {
|
|
175
|
+
return BUILT_IN_AVATARS[hashName(name) % BUILT_IN_AVATARS.length];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- API handlers ---
|
|
179
|
+
|
|
180
|
+
function apiHistory(query) {
|
|
181
|
+
const projectPath = query.get('project') || null;
|
|
182
|
+
const branch = query.get('branch') || null;
|
|
183
|
+
if (branch && !/^[a-zA-Z0-9_-]{1,64}$/.test(branch)) {
|
|
184
|
+
return { error: 'Invalid branch name' };
|
|
185
|
+
}
|
|
186
|
+
const histFile = branch && branch !== 'main'
|
|
187
|
+
? filePath(`branch-${branch}-history.jsonl`, projectPath)
|
|
188
|
+
: filePath('history.jsonl', projectPath);
|
|
189
|
+
const history = readJsonl(histFile);
|
|
190
|
+
const acks = readJson(filePath('acks.json', projectPath));
|
|
191
|
+
const limit = parseInt(query.get('limit') || '500', 10);
|
|
192
|
+
const threadId = query.get('thread_id');
|
|
193
|
+
|
|
194
|
+
let messages = history;
|
|
195
|
+
if (threadId) {
|
|
196
|
+
messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
|
|
197
|
+
}
|
|
198
|
+
messages = messages.slice(-limit);
|
|
199
|
+
messages.forEach(m => { m.acked = !!acks[m.id]; });
|
|
200
|
+
return messages;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function apiAgents(query) {
|
|
204
|
+
const projectPath = query.get('project') || null;
|
|
205
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
206
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
207
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
208
|
+
const result = {};
|
|
209
|
+
|
|
210
|
+
// Build last message timestamp per agent from history
|
|
211
|
+
const lastMessageTime = {};
|
|
212
|
+
for (const m of history) {
|
|
213
|
+
lastMessageTime[m.from] = m.timestamp;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
217
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
218
|
+
const lastActivity = info.last_activity || info.timestamp;
|
|
219
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
220
|
+
const profile = profiles[name] || {};
|
|
221
|
+
result[name] = {
|
|
222
|
+
pid: info.pid,
|
|
223
|
+
alive,
|
|
224
|
+
registered_at: info.timestamp,
|
|
225
|
+
last_activity: lastActivity,
|
|
226
|
+
last_message: lastMessageTime[name] || null,
|
|
227
|
+
idle_seconds: alive ? idleSeconds : null,
|
|
228
|
+
status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
|
|
229
|
+
listening_since: info.listening_since || null,
|
|
230
|
+
is_listening: !!(info.listening_since && alive),
|
|
231
|
+
provider: info.provider || 'unknown',
|
|
232
|
+
branch: info.branch || 'main',
|
|
233
|
+
display_name: profile.display_name || name,
|
|
234
|
+
avatar: profile.avatar || getDefaultAvatar(name),
|
|
235
|
+
role: profile.role || '',
|
|
236
|
+
bio: profile.bio || '',
|
|
237
|
+
appearance: profile.appearance || {},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function apiStatus(query) {
|
|
244
|
+
const projectPath = query.get('project') || null;
|
|
245
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
246
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
247
|
+
const threads = new Set();
|
|
248
|
+
history.forEach(m => { if (m.thread_id) threads.add(m.thread_id); });
|
|
249
|
+
|
|
250
|
+
const agentEntries = Object.entries(agents);
|
|
251
|
+
const aliveCount = agentEntries.filter(([, a]) => isPidAlive(a.pid, a.last_activity)).length;
|
|
252
|
+
const sleepingCount = agentEntries.filter(([, a]) => {
|
|
253
|
+
if (!isPidAlive(a.pid, a.last_activity)) return false;
|
|
254
|
+
const lastActivity = a.last_activity || a.timestamp;
|
|
255
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
256
|
+
return idleSeconds > 60;
|
|
257
|
+
}).length;
|
|
258
|
+
|
|
259
|
+
// Include managed mode status if active
|
|
260
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
261
|
+
const result = {
|
|
262
|
+
messageCount: history.length,
|
|
263
|
+
agentCount: agentEntries.length,
|
|
264
|
+
aliveCount,
|
|
265
|
+
sleepingCount,
|
|
266
|
+
threadCount: threads.size,
|
|
267
|
+
conversation_mode: config.conversation_mode || 'direct',
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (config.conversation_mode === 'managed' && config.managed) {
|
|
271
|
+
result.managed = {
|
|
272
|
+
manager: config.managed.manager,
|
|
273
|
+
phase: config.managed.phase,
|
|
274
|
+
floor: config.managed.floor,
|
|
275
|
+
turn_current: config.managed.turn_current,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function apiStats(query) {
|
|
283
|
+
const projectPath = query.get('project') || null;
|
|
284
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
285
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
286
|
+
|
|
287
|
+
// Per-agent stats
|
|
288
|
+
const perAgent = {};
|
|
289
|
+
let totalMessages = history.length;
|
|
290
|
+
const hourBuckets = new Array(24).fill(0);
|
|
291
|
+
|
|
292
|
+
for (let i = 0; i < history.length; i++) {
|
|
293
|
+
const m = history[i];
|
|
294
|
+
const from = m.from || 'unknown';
|
|
295
|
+
if (!perAgent[from]) {
|
|
296
|
+
perAgent[from] = { messages: 0, responseTimes: [], hours: new Array(24).fill(0) };
|
|
297
|
+
}
|
|
298
|
+
perAgent[from].messages++;
|
|
299
|
+
const ts = new Date(m.timestamp);
|
|
300
|
+
const hour = ts.getHours();
|
|
301
|
+
perAgent[from].hours[hour]++;
|
|
302
|
+
hourBuckets[hour]++;
|
|
303
|
+
|
|
304
|
+
// Compute response time if this is a reply
|
|
305
|
+
if (m.reply_to) {
|
|
306
|
+
for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
|
|
307
|
+
if (history[j].id === m.reply_to) {
|
|
308
|
+
const delta = ts.getTime() - new Date(history[j].timestamp).getTime();
|
|
309
|
+
if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Build per-agent summary
|
|
317
|
+
const agentStats = {};
|
|
318
|
+
let busiestAgent = null;
|
|
319
|
+
let busiestCount = 0;
|
|
320
|
+
for (const [name, data] of Object.entries(perAgent)) {
|
|
321
|
+
const avgResponseMs = data.responseTimes.length
|
|
322
|
+
? Math.round(data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length)
|
|
323
|
+
: null;
|
|
324
|
+
const peakHour = data.hours.indexOf(Math.max(...data.hours));
|
|
325
|
+
agentStats[name] = {
|
|
326
|
+
messages: data.messages,
|
|
327
|
+
avg_response_ms: avgResponseMs,
|
|
328
|
+
peak_hour: peakHour,
|
|
329
|
+
};
|
|
330
|
+
if (data.messages > busiestCount) {
|
|
331
|
+
busiestCount = data.messages;
|
|
332
|
+
busiestAgent = name;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Conversation velocity (messages per minute over last 10 minutes)
|
|
337
|
+
const tenMinAgo = Date.now() - 600000;
|
|
338
|
+
const recentCount = history.filter(m => new Date(m.timestamp).getTime() > tenMinAgo).length;
|
|
339
|
+
const velocity = Math.round((recentCount / 10) * 10) / 10;
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
total_messages: totalMessages,
|
|
343
|
+
busiest_agent: busiestAgent,
|
|
344
|
+
velocity_per_min: velocity,
|
|
345
|
+
hour_distribution: hourBuckets,
|
|
346
|
+
agents: agentStats,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- v3.4: Notification Tracking ---
|
|
351
|
+
let notificationHistory = [];
|
|
352
|
+
let prevAgentState = {};
|
|
353
|
+
|
|
354
|
+
function generateNotifications(currentAgents) {
|
|
355
|
+
const crypto = require('crypto');
|
|
356
|
+
const now = new Date().toISOString();
|
|
357
|
+
|
|
358
|
+
for (const [name, agent] of Object.entries(currentAgents)) {
|
|
359
|
+
const prev = prevAgentState[name];
|
|
360
|
+
const isAlive = agent.pid ? isPidAlive(agent.pid, agent.last_activity) : false;
|
|
361
|
+
const isListening = !!agent.listening;
|
|
362
|
+
|
|
363
|
+
if (prev) {
|
|
364
|
+
if (!prev.alive && isAlive) {
|
|
365
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
366
|
+
}
|
|
367
|
+
if (prev.alive && !isAlive) {
|
|
368
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_offline', agent: name, message: `${name} went offline`, timestamp: now });
|
|
369
|
+
}
|
|
370
|
+
if (!prev.listening && isListening) {
|
|
371
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_listening', agent: name, message: `${name} started listening`, timestamp: now });
|
|
372
|
+
}
|
|
373
|
+
if (prev.listening && !isListening) {
|
|
374
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_busy', agent: name, message: `${name} stopped listening`, timestamp: now });
|
|
375
|
+
}
|
|
376
|
+
} else if (isAlive) {
|
|
377
|
+
notificationHistory.push({ id: crypto.randomBytes(8).toString('hex'), type: 'agent_online', agent: name, message: `${name} came online`, timestamp: now });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
prevAgentState[name] = { alive: isAlive, listening: isListening };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Trim to max 50
|
|
384
|
+
if (notificationHistory.length > 50) {
|
|
385
|
+
notificationHistory = notificationHistory.slice(-50);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function apiNotifications() {
|
|
390
|
+
return notificationHistory;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --- v3.4: Performance Scoring ---
|
|
394
|
+
function apiScores(query) {
|
|
395
|
+
const projectPath = query.get('project') || null;
|
|
396
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
397
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
398
|
+
|
|
399
|
+
const perAgent = {};
|
|
400
|
+
const totalMessages = history.length;
|
|
401
|
+
const allAgentNames = new Set();
|
|
402
|
+
|
|
403
|
+
// Gather per-agent data
|
|
404
|
+
for (let i = 0; i < history.length; i++) {
|
|
405
|
+
const m = history[i];
|
|
406
|
+
const from = m.from || 'unknown';
|
|
407
|
+
allAgentNames.add(from);
|
|
408
|
+
if (m.to) allAgentNames.add(m.to);
|
|
409
|
+
if (!perAgent[from]) perAgent[from] = { messages: 0, responseTimes: [], peers: new Set() };
|
|
410
|
+
perAgent[from].messages++;
|
|
411
|
+
if (m.to) perAgent[from].peers.add(m.to);
|
|
412
|
+
|
|
413
|
+
if (m.reply_to) {
|
|
414
|
+
for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
|
|
415
|
+
if (history[j].id === m.reply_to) {
|
|
416
|
+
const delta = new Date(m.timestamp).getTime() - new Date(history[j].timestamp).getTime();
|
|
417
|
+
if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta / 1000);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const totalAgents = allAgentNames.size;
|
|
425
|
+
const maxMessages = Math.max(1, ...Object.values(perAgent).map(d => d.messages));
|
|
426
|
+
|
|
427
|
+
const result = {};
|
|
428
|
+
const scores = [];
|
|
429
|
+
|
|
430
|
+
for (const [name, data] of Object.entries(perAgent)) {
|
|
431
|
+
const avgResponseSec = data.responseTimes.length
|
|
432
|
+
? data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length
|
|
433
|
+
: Infinity;
|
|
434
|
+
|
|
435
|
+
// Responsiveness (30 pts)
|
|
436
|
+
let responsiveness;
|
|
437
|
+
if (avgResponseSec < 10) responsiveness = 30;
|
|
438
|
+
else if (avgResponseSec < 30) responsiveness = 25;
|
|
439
|
+
else if (avgResponseSec < 60) responsiveness = 20;
|
|
440
|
+
else if (avgResponseSec < 120) responsiveness = 15;
|
|
441
|
+
else responsiveness = 10;
|
|
442
|
+
|
|
443
|
+
// Activity (30 pts) — linear scale relative to top agent
|
|
444
|
+
const activity = Math.round((data.messages / maxMessages) * 30);
|
|
445
|
+
|
|
446
|
+
// Reliability (20 pts) — uptime based on agent registration
|
|
447
|
+
let reliability = 10;
|
|
448
|
+
const agentInfo = agents[name];
|
|
449
|
+
if (agentInfo) {
|
|
450
|
+
const isAlive = agentInfo.pid ? isPidAlive(agentInfo.pid, agentInfo.last_activity) : false;
|
|
451
|
+
const registered = new Date(agentInfo.registered_at || agentInfo.last_activity).getTime();
|
|
452
|
+
const totalTime = Date.now() - registered;
|
|
453
|
+
if (totalTime > 0 && isAlive) {
|
|
454
|
+
const lastAct = new Date(agentInfo.last_activity).getTime();
|
|
455
|
+
const activeTime = lastAct - registered;
|
|
456
|
+
const uptime = Math.min(1, activeTime / totalTime);
|
|
457
|
+
if (uptime > 0.95) reliability = 20;
|
|
458
|
+
else if (uptime > 0.80) reliability = 15;
|
|
459
|
+
else if (uptime > 0.50) reliability = 10;
|
|
460
|
+
else reliability = 5;
|
|
461
|
+
} else if (!isAlive) {
|
|
462
|
+
reliability = 5;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Collaboration (20 pts)
|
|
467
|
+
const collaboration = totalAgents > 1
|
|
468
|
+
? Math.round((data.peers.size / (totalAgents - 1)) * 20)
|
|
469
|
+
: 20;
|
|
470
|
+
|
|
471
|
+
const score = responsiveness + activity + reliability + collaboration;
|
|
472
|
+
result[name] = { score, responsiveness, activity, reliability, collaboration };
|
|
473
|
+
scores.push({ name, score });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Add ranks
|
|
477
|
+
scores.sort((a, b) => b.score - a.score);
|
|
478
|
+
scores.forEach((s, i) => { result[s.name].rank = i + 1; });
|
|
479
|
+
|
|
480
|
+
return { agents: result };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --- v3.4: Cross-Project Search ---
|
|
484
|
+
function apiSearchAll(query) {
|
|
485
|
+
const q = (query.get('q') || '').toLowerCase();
|
|
486
|
+
const limit = Math.min(parseInt(query.get('limit') || '50', 10), 200);
|
|
487
|
+
if (!q) return { error: 'Missing "q" parameter' };
|
|
488
|
+
|
|
489
|
+
const projects = getProjects();
|
|
490
|
+
// Add default project
|
|
491
|
+
const allProjects = [{ name: path.basename(process.cwd()), path: null }];
|
|
492
|
+
for (const p of projects) allProjects.push(p);
|
|
493
|
+
|
|
494
|
+
const results = [];
|
|
495
|
+
let total = 0;
|
|
496
|
+
|
|
497
|
+
for (const proj of allProjects) {
|
|
498
|
+
const history = readJsonl(filePath('history.jsonl', proj.path));
|
|
499
|
+
const matches = [];
|
|
500
|
+
for (const m of history) {
|
|
501
|
+
if (matches.length >= limit) break;
|
|
502
|
+
const content = (m.content || '').toLowerCase();
|
|
503
|
+
const from = (m.from || '').toLowerCase();
|
|
504
|
+
const to = (m.to || '').toLowerCase();
|
|
505
|
+
if (content.includes(q) || from.includes(q) || to.includes(q)) {
|
|
506
|
+
matches.push({ id: m.id, from: m.from, to: m.to, content: m.content, timestamp: m.timestamp });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (matches.length > 0) {
|
|
510
|
+
results.push({ project: proj.name, path: proj.path || process.cwd(), messages: matches });
|
|
511
|
+
total += matches.length;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { results, total };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// --- v3.4: Replay Export ---
|
|
519
|
+
function apiExportReplay(query) {
|
|
520
|
+
const projectPath = query.get('project') || null;
|
|
521
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
522
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
523
|
+
|
|
524
|
+
const colors = ['#58a6ff','#3fb950','#d29922','#bc8cff','#f778ba','#ff7b72','#79c0ff','#7ee787'];
|
|
525
|
+
const agentColors = {};
|
|
526
|
+
let colorIdx = 0;
|
|
527
|
+
for (const m of history) {
|
|
528
|
+
if (!agentColors[m.from]) agentColors[m.from] = colors[colorIdx++ % colors.length];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const messagesJson = JSON.stringify(history.map(m => ({
|
|
532
|
+
from: m.from, to: m.to, content: m.content, timestamp: m.timestamp, color: agentColors[m.from] || '#58a6ff'
|
|
533
|
+
})));
|
|
534
|
+
|
|
535
|
+
return `<!DOCTYPE html>
|
|
536
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
537
|
+
<title>Let Them Talk — Replay</title>
|
|
538
|
+
<style>
|
|
539
|
+
:root{--bg:#0d1117;--surface:#161b22;--surface-2:#21262d;--border:#30363d;--text:#e6edf3;--dim:#8b949e}
|
|
540
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
541
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
|
542
|
+
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;justify-content:space-between}
|
|
543
|
+
.title{font-size:16px;font-weight:700;color:var(--text)}
|
|
544
|
+
.controls{display:flex;gap:8px;align-items:center}
|
|
545
|
+
.controls button{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 14px;cursor:pointer;font-size:13px}
|
|
546
|
+
.controls button:hover{background:var(--border)}
|
|
547
|
+
.controls select{background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:13px}
|
|
548
|
+
.messages{max-width:800px;margin:20px auto;padding:0 16px}
|
|
549
|
+
.msg{opacity:0;transform:translateY(8px);transition:opacity 0.3s,transform 0.3s;margin-bottom:12px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
|
|
550
|
+
.msg.visible{opacity:1;transform:translateY(0)}
|
|
551
|
+
.msg-header{display:flex;gap:8px;align-items:baseline;margin-bottom:4px;font-size:13px}
|
|
552
|
+
.msg-from{font-weight:700}
|
|
553
|
+
.msg-to{color:var(--dim)}
|
|
554
|
+
.msg-time{color:var(--dim);margin-left:auto;font-size:11px}
|
|
555
|
+
.msg-content{font-size:14px;white-space:pre-wrap;word-break:break-word}
|
|
556
|
+
.msg-content code{background:var(--surface-2);padding:1px 5px;border-radius:3px;font-size:0.9em}
|
|
557
|
+
.msg-content strong{font-weight:700}
|
|
558
|
+
.progress{font-size:12px;color:var(--dim)}
|
|
559
|
+
</style></head><body>
|
|
560
|
+
<div class="header">
|
|
561
|
+
<span class="title">Let Them Talk — Replay</span>
|
|
562
|
+
<div class="controls">
|
|
563
|
+
<button id="btn" onclick="toggle()">Pause</button>
|
|
564
|
+
<label><span style="color:var(--dim);font-size:12px">Speed:</span>
|
|
565
|
+
<select id="speed" onchange="setSpeed(this.value)">
|
|
566
|
+
<option value="2000">Slow</option><option value="1000" selected>Normal</option><option value="500">Fast</option><option value="200">Very Fast</option>
|
|
567
|
+
</select></label>
|
|
568
|
+
<span class="progress" id="progress">0 / 0</span>
|
|
569
|
+
</div></div>
|
|
570
|
+
<div class="messages" id="messages"></div>
|
|
571
|
+
<script>
|
|
572
|
+
var msgs=${messagesJson};
|
|
573
|
+
var idx=0,playing=true,timer=null,speed=1000;
|
|
574
|
+
function md(s){return s.replace(/\`\`\`[\\s\\S]*?\`\`\`/g,function(m){return '<pre><code>'+m.slice(3,-3).replace(/^\\w*\\n/,'')+'</code></pre>'}).replace(/\`([^\`]+)\`/g,'<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>').replace(/^### (.+)$/gm,'<h4 style="margin:8px 0 4px;font-size:14px">$1</h4>').replace(/^## (.+)$/gm,'<h3 style="margin:8px 0 4px;font-size:15px">$1</h3>').replace(/^# (.+)$/gm,'<h2 style="margin:8px 0 4px;font-size:16px">$1</h2>')}
|
|
575
|
+
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
576
|
+
function showNext(){if(idx>=msgs.length){playing=false;document.getElementById('btn').textContent='Done';return}
|
|
577
|
+
var m=msgs[idx],el=document.createElement('div');el.className='msg';
|
|
578
|
+
var t=new Date(m.timestamp);var time=t.toLocaleTimeString();
|
|
579
|
+
el.innerHTML='<div class="msg-header"><span class="msg-from" style="color:'+m.color+'">'+esc(m.from)+'</span><span class="msg-to">→ '+esc(m.to||'all')+'</span><span class="msg-time">'+time+'</span></div><div class="msg-content">'+md(esc(m.content))+'</div>';
|
|
580
|
+
document.getElementById('messages').appendChild(el);
|
|
581
|
+
requestAnimationFrame(function(){el.classList.add('visible')});
|
|
582
|
+
el.scrollIntoView({behavior:'smooth',block:'end'});
|
|
583
|
+
idx++;document.getElementById('progress').textContent=idx+' / '+msgs.length;
|
|
584
|
+
if(playing)timer=setTimeout(showNext,speed)}
|
|
585
|
+
function toggle(){if(idx>=msgs.length){idx=0;document.getElementById('messages').innerHTML='';playing=true;document.getElementById('btn').textContent='Pause';showNext();return}
|
|
586
|
+
playing=!playing;document.getElementById('btn').textContent=playing?'Pause':'Play';if(playing)showNext();else clearTimeout(timer)}
|
|
587
|
+
function setSpeed(v){speed=parseInt(v)}
|
|
588
|
+
showNext();
|
|
589
|
+
</script></body></html>`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function apiReset(query) {
|
|
593
|
+
const projectPath = query.get('project') || null;
|
|
594
|
+
const dataDir = resolveDataDir(projectPath);
|
|
595
|
+
const fixedFiles = ['messages.jsonl', 'history.jsonl', 'agents.json', 'acks.json', 'tasks.json', 'profiles.json', 'workflows.json', 'branches.json', 'read_receipts.json', 'permissions.json', 'config.json'];
|
|
596
|
+
for (const f of fixedFiles) {
|
|
597
|
+
const p = path.join(dataDir, f);
|
|
598
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
599
|
+
}
|
|
600
|
+
if (fs.existsSync(dataDir)) {
|
|
601
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
602
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
603
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
604
|
+
}
|
|
605
|
+
if (f.startsWith('branch-') && (f.endsWith('-messages.jsonl') || f.endsWith('-history.jsonl'))) {
|
|
606
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Remove workspaces dir
|
|
611
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
612
|
+
if (fs.existsSync(wsDir)) {
|
|
613
|
+
for (const f of fs.readdirSync(wsDir)) fs.unlinkSync(path.join(wsDir, f));
|
|
614
|
+
try { fs.rmdirSync(wsDir); } catch {}
|
|
615
|
+
}
|
|
616
|
+
return { success: true };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Inject a message from the dashboard (system message or nudge to an agent)
|
|
620
|
+
function apiInjectMessage(body, query) {
|
|
621
|
+
const projectPath = query.get('project') || null;
|
|
622
|
+
const dataDir = resolveDataDir(projectPath);
|
|
623
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
624
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
625
|
+
|
|
626
|
+
if (!body.to || !body.content) {
|
|
627
|
+
return { error: 'Missing "to" and/or "content" fields' };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
631
|
+
const fromName = 'Dashboard';
|
|
632
|
+
const now = new Date().toISOString();
|
|
633
|
+
|
|
634
|
+
// Broadcast to all agents
|
|
635
|
+
if (body.to === '__all__') {
|
|
636
|
+
const agents = readJson(path.join(dataDir, 'agents.json'));
|
|
637
|
+
const ids = [];
|
|
638
|
+
for (const name of Object.keys(agents)) {
|
|
639
|
+
const msg = {
|
|
640
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
641
|
+
from: fromName,
|
|
642
|
+
to: name,
|
|
643
|
+
content: body.content,
|
|
644
|
+
timestamp: now,
|
|
645
|
+
system: true,
|
|
646
|
+
};
|
|
647
|
+
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
648
|
+
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
649
|
+
ids.push(msg.id);
|
|
650
|
+
}
|
|
651
|
+
return { success: true, messageIds: ids, broadcast: true };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const msg = {
|
|
655
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
656
|
+
from: fromName,
|
|
657
|
+
to: body.to,
|
|
658
|
+
content: body.content,
|
|
659
|
+
timestamp: now,
|
|
660
|
+
system: true,
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
fs.appendFileSync(messagesFile, JSON.stringify(msg) + '\n');
|
|
664
|
+
fs.appendFileSync(historyFile, JSON.stringify(msg) + '\n');
|
|
665
|
+
|
|
666
|
+
return { success: true, messageId: msg.id };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Multi-project management
|
|
670
|
+
function apiProjects() {
|
|
671
|
+
return getProjects();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function apiAddProject(body) {
|
|
675
|
+
if (!body.path) return { error: 'Missing "path" field' };
|
|
676
|
+
const absPath = path.resolve(body.path);
|
|
677
|
+
if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
|
|
678
|
+
|
|
679
|
+
// Any existing directory can be added as a project — user explicitly chose it
|
|
680
|
+
|
|
681
|
+
const projects = getProjects();
|
|
682
|
+
const name = body.name || path.basename(absPath);
|
|
683
|
+
if (projects.find(p => p.path === absPath)) return { error: 'Project already added' };
|
|
684
|
+
|
|
685
|
+
// Create .agent-bridge directory if it doesn't exist
|
|
686
|
+
const abDir = path.join(absPath, '.agent-bridge');
|
|
687
|
+
if (!fs.existsSync(abDir)) fs.mkdirSync(abDir, { recursive: true });
|
|
688
|
+
|
|
689
|
+
// Set up MCP config so agents can use it
|
|
690
|
+
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
691
|
+
ensureMCPConfig('claude', serverPath, absPath);
|
|
692
|
+
|
|
693
|
+
projects.push({ name, path: absPath, added_at: new Date().toISOString() });
|
|
694
|
+
saveProjects(projects);
|
|
695
|
+
return { success: true, project: { name, path: absPath } };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function apiRemoveProject(body) {
|
|
699
|
+
if (!body.path) return { error: 'Missing "path" field' };
|
|
700
|
+
const absPath = path.resolve(body.path);
|
|
701
|
+
let projects = getProjects();
|
|
702
|
+
const before = projects.length;
|
|
703
|
+
projects = projects.filter(p => p.path !== absPath);
|
|
704
|
+
if (projects.length === before) return { error: 'Project not found' };
|
|
705
|
+
saveProjects(projects);
|
|
706
|
+
return { success: true };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Export conversation as self-contained HTML
|
|
710
|
+
function apiExportHtml(query) {
|
|
711
|
+
const projectPath = query.get('project') || null;
|
|
712
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
713
|
+
const acks = readJson(filePath('acks.json', projectPath));
|
|
714
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
715
|
+
history.forEach(m => { m.acked = !!acks[m.id]; });
|
|
716
|
+
|
|
717
|
+
const agentNames = [...new Set(history.map(m => m.from))];
|
|
718
|
+
const exportDate = new Date().toLocaleString();
|
|
719
|
+
|
|
720
|
+
const startTime = history.length > 0 ? new Date(history[0].timestamp).toLocaleString() : '';
|
|
721
|
+
const endTime = history.length > 0 ? new Date(history[history.length - 1].timestamp).toLocaleString() : '';
|
|
722
|
+
const duration = history.length > 1 ? Math.round((new Date(history[history.length-1].timestamp) - new Date(history[0].timestamp)) / 60000) : 0;
|
|
723
|
+
const durationStr = duration > 60 ? Math.floor(duration/60) + 'h ' + (duration%60) + 'm' : duration + ' minutes';
|
|
724
|
+
|
|
725
|
+
return `<!DOCTYPE html>
|
|
726
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
727
|
+
<title>Let Them Talk — Conversation Export</title>
|
|
728
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%230d1117'/><path d='M20 30 Q20 20 30 20 H70 Q80 20 80 30 V55 Q80 65 70 65 H55 L40 80 V65 H30 Q20 65 20 55Z' fill='%2358a6ff'/><circle cx='38' cy='42' r='5' fill='%230d1117'/><circle cx='55' cy='42' r='5' fill='%230d1117'/></svg>">
|
|
729
|
+
<style>
|
|
730
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
731
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e6edf3;min-height:100vh}
|
|
732
|
+
.export-header{background:linear-gradient(180deg,#0f0f18 0%,#0a0a0f 100%);padding:40px 24px 32px;text-align:center;border-bottom:1px solid #1e1e2e}
|
|
733
|
+
.logo{font-size:28px;font-weight:800;background:linear-gradient(135deg,#58a6ff,#bc8cff);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-1px}
|
|
734
|
+
.export-meta{margin-top:12px;display:flex;justify-content:center;gap:20px;flex-wrap:wrap}
|
|
735
|
+
.meta-item{font-size:12px;color:#8888a0}
|
|
736
|
+
.meta-val{color:#58a6ff;font-weight:600}
|
|
737
|
+
.agent-chips{display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
|
738
|
+
.agent-chip{display:flex;align-items:center;gap:6px;background:#161622;border:1px solid #1e1e2e;border-radius:20px;padding:4px 12px 4px 4px;font-size:12px}
|
|
739
|
+
.agent-chip .dot{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff}
|
|
740
|
+
.messages{max-width:860px;margin:0 auto;padding:20px 24px}
|
|
741
|
+
.msg{display:flex;gap:10px;padding:10px 14px;border-radius:8px;margin-bottom:2px}
|
|
742
|
+
.msg:hover{background:#161622}
|
|
743
|
+
.avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:#fff;flex-shrink:0}
|
|
744
|
+
.msg-body{flex:1;min-width:0}
|
|
745
|
+
.msg-header{display:flex;gap:6px;align-items:baseline;margin-bottom:3px;flex-wrap:wrap}
|
|
746
|
+
.msg-from{font-weight:600;font-size:13px}
|
|
747
|
+
.msg-arrow{color:#555568;font-size:11px}
|
|
748
|
+
.msg-to{font-size:12px;color:#8888a0}
|
|
749
|
+
.msg-time{font-size:10px;color:#555568}
|
|
750
|
+
.msg-content{font-size:13px;line-height:1.6;word-break:break-word}
|
|
751
|
+
.msg-content strong{font-weight:700}
|
|
752
|
+
.msg-content em{font-style:italic;color:#8888a0}
|
|
753
|
+
.msg-content code{background:#1e1e2e;padding:1px 5px;border-radius:4px;font-size:12px;font-family:Consolas,monospace;color:#d29922}
|
|
754
|
+
.msg-content pre{background:#0f0f18;border:1px solid #1e1e2e;border-radius:6px;padding:12px;margin:6px 0;overflow-x:auto;font-size:12px;font-family:Consolas,monospace}
|
|
755
|
+
.msg-content pre code{background:none;color:#e6edf3;padding:0}
|
|
756
|
+
.msg-content h1,.msg-content h2,.msg-content h3{margin:8px 0 4px;font-weight:700}
|
|
757
|
+
.msg-content h1{font-size:18px;border-bottom:1px solid #1e1e2e;padding-bottom:4px}
|
|
758
|
+
.msg-content h2{font-size:16px}
|
|
759
|
+
.msg-content h3{font-size:14px}
|
|
760
|
+
.msg-content ul,.msg-content ol{padding-left:20px;margin:4px 0}
|
|
761
|
+
.msg-content table{border-collapse:collapse;margin:6px 0;font-size:12px}
|
|
762
|
+
.msg-content th,.msg-content td{border:1px solid #1e1e2e;padding:4px 8px;text-align:left}
|
|
763
|
+
.msg-content th{background:#161622}
|
|
764
|
+
.badge{font-size:9px;padding:1px 5px;border-radius:8px;font-weight:600}
|
|
765
|
+
.badge-ack{background:rgba(63,185,80,0.15);color:#3fb950}
|
|
766
|
+
.date-sep{display:flex;align-items:center;gap:12px;padding:12px 14px 6px;color:#555568;font-size:11px;font-weight:600}
|
|
767
|
+
.date-sep::before,.date-sep::after{content:'';flex:1;height:1px;background:#1e1e2e}
|
|
768
|
+
.footer{border-top:1px solid #1e1e2e;padding:24px;text-align:center;font-size:11px;color:#555568}
|
|
769
|
+
.footer a{color:#8888a0;text-decoration:none}
|
|
770
|
+
.footer a:hover{color:#58a6ff}
|
|
771
|
+
</style></head><body>
|
|
772
|
+
<div class="export-header">
|
|
773
|
+
<div class="logo">Let Them Talk</div>
|
|
774
|
+
<div class="export-meta">
|
|
775
|
+
<span class="meta-item"><span class="meta-val">${history.length}</span> messages</span>
|
|
776
|
+
<span class="meta-item"><span class="meta-val">${agentNames.length}</span> agents</span>
|
|
777
|
+
<span class="meta-item"><span class="meta-val">${durationStr}</span> duration</span>
|
|
778
|
+
<span class="meta-item">Exported ${htmlEscape(exportDate)}</span>
|
|
779
|
+
</div>
|
|
780
|
+
<div class="agent-chips" id="agent-chips"></div>
|
|
781
|
+
</div>
|
|
782
|
+
<div class="messages" id="messages"></div>
|
|
783
|
+
<div class="footer">Generated by <a href="https://github.com/Dekelelz/let-them-talk" target="_blank">Let Them Talk</a> · BSL 1.1</div>
|
|
784
|
+
<script>
|
|
785
|
+
var COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#bc8cff','#f778ba','#79c0ff','#7ee787','#e3b341','#ffa198'];
|
|
786
|
+
var colorMap={},ci=0;
|
|
787
|
+
var data=${JSON.stringify(history).replace(/<\//g, '<\\/')};
|
|
788
|
+
function esc(t){var d=document.createElement('div');d.textContent=t;return d.innerHTML}
|
|
789
|
+
function fmt(t){
|
|
790
|
+
var h=esc(t);
|
|
791
|
+
h=h.replace(/\`\`\`(\\w*)\\n([\\s\\S]*?)\`\`\`/g,function(_,l,c){return '<pre><code>'+c+'</code></pre>'});
|
|
792
|
+
h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
|
|
793
|
+
h=h.replace(/\\*\\*\\*(.+?)\\*\\*\\*/g,'<strong><em>$1</em></strong>');
|
|
794
|
+
h=h.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>');
|
|
795
|
+
h=h.replace(/\\*(.+?)\\*/g,'<em>$1</em>');
|
|
796
|
+
h=h.replace(/^### (.+)/gm,'<h3>$1</h3>');
|
|
797
|
+
h=h.replace(/^## (.+)/gm,'<h2>$1</h2>');
|
|
798
|
+
h=h.replace(/^# (.+)/gm,'<h1>$1</h1>');
|
|
799
|
+
h=h.replace(/^[\\-\\*] (.+)/gm,'<li>$1</li>');
|
|
800
|
+
return h}
|
|
801
|
+
function color(n){if(!colorMap[n]){colorMap[n]=COLORS[ci%COLORS.length];ci++}return colorMap[n]}
|
|
802
|
+
var chips='';
|
|
803
|
+
var seen={};
|
|
804
|
+
for(var a=0;a<data.length;a++){var f=data[a].from;if(!seen[f]){seen[f]=true;var c=color(f);chips+='<div class="agent-chip"><div class="dot" style="background:'+c+'">'+f.charAt(0).toUpperCase()+'</div>'+esc(f)+'</div>'}}
|
|
805
|
+
document.getElementById('agent-chips').innerHTML=chips;
|
|
806
|
+
var html='';var lastDate='';
|
|
807
|
+
for(var i=0;i<data.length;i++){var m=data[i];var c=color(m.from);
|
|
808
|
+
var msgDate=new Date(m.timestamp).toLocaleDateString();
|
|
809
|
+
if(msgDate!==lastDate){var today=new Date().toLocaleDateString();var label=msgDate===today?'Today':msgDate;html+='<div class="date-sep">'+label+'</div>';lastDate=msgDate}
|
|
810
|
+
var badges='';if(m.acked)badges+='<span class="badge badge-ack">ACK</span>';
|
|
811
|
+
html+='<div class="msg"><div class="avatar" style="background:'+c+'">'+m.from.charAt(0).toUpperCase()+'</div><div class="msg-body"><div class="msg-header"><span class="msg-from" style="color:'+c+'">'+esc(m.from)+'</span><span class="msg-arrow">→</span><span class="msg-to">'+esc(m.to)+'</span><span class="msg-time">'+new Date(m.timestamp).toLocaleTimeString()+'</span>'+badges+'</div><div class="msg-content">'+fmt(m.content)+'</div></div></div>'}
|
|
812
|
+
document.getElementById('messages').innerHTML=html;
|
|
813
|
+
</script></body></html>`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Timeline API — agent activity over time for heatmap visualization
|
|
817
|
+
function apiTimeline(query) {
|
|
818
|
+
const projectPath = query.get('project') || null;
|
|
819
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
820
|
+
if (history.length === 0) return { agents: {}, duration_seconds: 0 };
|
|
821
|
+
|
|
822
|
+
const agents = {};
|
|
823
|
+
const startTime = new Date(history[0].timestamp).getTime();
|
|
824
|
+
const endTime = new Date(history[history.length - 1].timestamp).getTime();
|
|
825
|
+
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
|
826
|
+
|
|
827
|
+
// Build activity windows per agent — each message marks a 30s "active" window
|
|
828
|
+
for (const m of history) {
|
|
829
|
+
if (!agents[m.from]) {
|
|
830
|
+
agents[m.from] = { message_count: 0, active_seconds: 0, gaps: [], timestamps: [] };
|
|
831
|
+
}
|
|
832
|
+
agents[m.from].message_count++;
|
|
833
|
+
agents[m.from].timestamps.push(m.timestamp);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Calculate activity percentage and response gaps
|
|
837
|
+
for (const [name, data] of Object.entries(agents)) {
|
|
838
|
+
const ts = data.timestamps.map(t => new Date(t).getTime());
|
|
839
|
+
let activeSeconds = 0;
|
|
840
|
+
for (let i = 0; i < ts.length; i++) {
|
|
841
|
+
activeSeconds += 30; // each message = ~30s of activity
|
|
842
|
+
if (i > 0) {
|
|
843
|
+
const gap = Math.floor((ts[i] - ts[i - 1]) / 1000);
|
|
844
|
+
if (gap > 60) {
|
|
845
|
+
data.gaps.push({ after_message: i, gap_seconds: gap });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
data.active_seconds = Math.min(activeSeconds, durationSeconds || 1);
|
|
850
|
+
data.activity_pct = durationSeconds > 0 ? Math.round((data.active_seconds / durationSeconds) * 100) : 100;
|
|
851
|
+
delete data.timestamps; // don't send raw timestamps
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
agents,
|
|
856
|
+
duration_seconds: durationSeconds,
|
|
857
|
+
start_time: history[0].timestamp,
|
|
858
|
+
end_time: history[history.length - 1].timestamp,
|
|
859
|
+
total_messages: history.length,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Tasks API
|
|
864
|
+
function apiTasks(query) {
|
|
865
|
+
const projectPath = query.get('project') || null;
|
|
866
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
867
|
+
if (!fs.existsSync(tasksFile)) return [];
|
|
868
|
+
try { return JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch { return []; }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function apiUpdateTask(body, query) {
|
|
872
|
+
const projectPath = query.get('project') || null;
|
|
873
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
874
|
+
if (!body.task_id || !body.status) return { error: 'Missing task_id or status' };
|
|
875
|
+
|
|
876
|
+
let tasks = [];
|
|
877
|
+
if (fs.existsSync(tasksFile)) {
|
|
878
|
+
try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const task = tasks.find(t => t.id === body.task_id);
|
|
882
|
+
if (!task) return { error: 'Task not found' };
|
|
883
|
+
|
|
884
|
+
const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
|
|
885
|
+
if (!validStatuses.includes(body.status)) return { error: 'Invalid status. Must be: ' + validStatuses.join(', ') };
|
|
886
|
+
task.status = body.status;
|
|
887
|
+
task.updated_at = new Date().toISOString();
|
|
888
|
+
if (body.notes) {
|
|
889
|
+
if (!Array.isArray(task.notes)) task.notes = [];
|
|
890
|
+
task.notes.push({ by: 'Dashboard', text: body.notes, at: new Date().toISOString() });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2));
|
|
894
|
+
return { success: true, task_id: task.id, status: task.status };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Auto-discover .agent-bridge directories nearby
|
|
898
|
+
function apiDiscover() {
|
|
899
|
+
const found = [];
|
|
900
|
+
const checked = new Set();
|
|
901
|
+
const existing = new Set(getProjects().map(p => p.path));
|
|
902
|
+
|
|
903
|
+
function scanDir(dir, depth, maxDepth) {
|
|
904
|
+
maxDepth = maxDepth || 3;
|
|
905
|
+
if (depth > maxDepth || checked.has(dir)) return;
|
|
906
|
+
checked.add(dir);
|
|
907
|
+
try {
|
|
908
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
909
|
+
for (const entry of entries) {
|
|
910
|
+
if (!entry.isDirectory()) continue;
|
|
911
|
+
if (entry.name.startsWith('.') && entry.name !== '.agent-bridge') continue;
|
|
912
|
+
if (entry.name === 'node_modules') continue;
|
|
913
|
+
const fullPath = path.join(dir, entry.name);
|
|
914
|
+
if (entry.name === '.agent-bridge' && hasDataFiles(fullPath)) {
|
|
915
|
+
const projectPath = dir;
|
|
916
|
+
if (!existing.has(projectPath)) {
|
|
917
|
+
found.push({ name: path.basename(projectPath), path: projectPath, dataDir: fullPath });
|
|
918
|
+
}
|
|
919
|
+
} else if (depth < maxDepth) {
|
|
920
|
+
scanDir(fullPath, depth + 1, maxDepth);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
} catch {}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Scan from cwd, parent, home, Desktop, and common project locations
|
|
927
|
+
const cwd = process.cwd();
|
|
928
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
929
|
+
scanDir(cwd, 0);
|
|
930
|
+
scanDir(path.dirname(cwd), 1);
|
|
931
|
+
if (home) {
|
|
932
|
+
scanDir(home, 0);
|
|
933
|
+
scanDir(path.join(home, 'Desktop'), 0);
|
|
934
|
+
scanDir(path.join(home, 'Documents'), 0);
|
|
935
|
+
scanDir(path.join(home, 'Projects'), 0);
|
|
936
|
+
scanDir(path.join(home, 'Desktop', 'Claude Projects'), 0);
|
|
937
|
+
scanDir(path.join(home, 'Desktop', 'Projects'), 0);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return found;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// --- Agent Launcher ---
|
|
944
|
+
|
|
945
|
+
function ensureMCPConfig(cli, serverPath, projectDir) {
|
|
946
|
+
const abDir = path.join(projectDir, '.agent-bridge').replace(/\\/g, '/');
|
|
947
|
+
if (cli === 'claude') {
|
|
948
|
+
const mcpConfigPath = path.join(projectDir, '.mcp.json');
|
|
949
|
+
let mcpConfig = { mcpServers: {} };
|
|
950
|
+
if (fs.existsSync(mcpConfigPath)) {
|
|
951
|
+
try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {}; } catch {}
|
|
952
|
+
}
|
|
953
|
+
if (!mcpConfig.mcpServers['agent-bridge']) {
|
|
954
|
+
mcpConfig.mcpServers['agent-bridge'] = { command: 'node', args: [serverPath], env: { AGENT_BRIDGE_DATA_DIR: abDir } };
|
|
955
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
|
|
956
|
+
}
|
|
957
|
+
} else if (cli === 'gemini') {
|
|
958
|
+
const geminiDir = path.join(projectDir, '.gemini');
|
|
959
|
+
const settingsPath = path.join(geminiDir, 'settings.json');
|
|
960
|
+
if (!fs.existsSync(geminiDir)) fs.mkdirSync(geminiDir, { recursive: true });
|
|
961
|
+
let settings = { mcpServers: {} };
|
|
962
|
+
if (fs.existsSync(settingsPath)) {
|
|
963
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); if (!settings.mcpServers) settings.mcpServers = {}; } catch {}
|
|
964
|
+
}
|
|
965
|
+
if (!settings.mcpServers['agent-bridge']) {
|
|
966
|
+
settings.mcpServers['agent-bridge'] = { command: 'node', args: [serverPath], env: { AGENT_BRIDGE_DATA_DIR: abDir } };
|
|
967
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
968
|
+
}
|
|
969
|
+
} else if (cli === 'codex') {
|
|
970
|
+
const codexDir = path.join(projectDir, '.codex');
|
|
971
|
+
const configPath = path.join(codexDir, 'config.toml');
|
|
972
|
+
if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
|
|
973
|
+
let config = '';
|
|
974
|
+
if (fs.existsSync(configPath)) config = fs.readFileSync(configPath, 'utf8');
|
|
975
|
+
if (!config.includes('[mcp_servers.agent-bridge]')) {
|
|
976
|
+
config += `\n[mcp_servers.agent-bridge]\ncommand = "node"\nargs = [${JSON.stringify(serverPath)}]\n\n[mcp_servers.agent-bridge.env]\nAGENT_BRIDGE_DATA_DIR = ${JSON.stringify(abDir)}\n`;
|
|
977
|
+
fs.writeFileSync(configPath, config);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function apiLaunchAgent(body) {
|
|
983
|
+
const { cli, project_dir, agent_name, prompt } = body;
|
|
984
|
+
if (!cli || !['claude', 'gemini', 'codex'].includes(cli)) {
|
|
985
|
+
return { error: 'Invalid cli type. Must be: claude, gemini, or codex' };
|
|
986
|
+
}
|
|
987
|
+
if (project_dir && !validateProjectPath(project_dir)) {
|
|
988
|
+
return { error: 'Project directory not registered. Add it via the dashboard first.' };
|
|
989
|
+
}
|
|
990
|
+
const projectDir = project_dir || process.cwd();
|
|
991
|
+
if (!fs.existsSync(projectDir)) {
|
|
992
|
+
return { error: 'Project directory does not exist: ' + projectDir };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const serverPath = path.join(__dirname, 'server.js').replace(/\\/g, '/');
|
|
996
|
+
ensureMCPConfig(cli, serverPath, projectDir);
|
|
997
|
+
|
|
998
|
+
const cliCommands = { claude: 'claude', gemini: 'gemini', codex: 'codex' };
|
|
999
|
+
const cliCmd = cliCommands[cli];
|
|
1000
|
+
const safeName = (agent_name || '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
|
|
1001
|
+
const launchPrompt = prompt || (safeName ? `You are agent "${safeName}". Use the register tool to register as "${safeName}", then use listen to wait for messages.` : `Register with the agent-bridge MCP tools and use listen to wait for messages.`);
|
|
1002
|
+
|
|
1003
|
+
// Try to launch terminal on Windows
|
|
1004
|
+
if (process.platform === 'win32') {
|
|
1005
|
+
spawn('cmd', ['/c', 'start', 'cmd', '/k', cliCmd], { cwd: projectDir, shell: false, detached: true, stdio: 'ignore' });
|
|
1006
|
+
return { success: true, launched: true, cli, project_dir: projectDir, prompt: launchPrompt };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Non-Windows: return command for manual execution
|
|
1010
|
+
return {
|
|
1011
|
+
success: true,
|
|
1012
|
+
launched: false,
|
|
1013
|
+
cli,
|
|
1014
|
+
project_dir: projectDir,
|
|
1015
|
+
command: `cd "${projectDir}" && ${cliCmd}`,
|
|
1016
|
+
prompt: launchPrompt,
|
|
1017
|
+
message: 'Auto-launch not supported on this platform. Run the command manually, then paste the prompt.'
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// --- v3.4: Message Edit ---
|
|
1022
|
+
async function apiEditMessage(body, query) {
|
|
1023
|
+
const projectPath = query.get('project') || null;
|
|
1024
|
+
const { id, content } = body;
|
|
1025
|
+
if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
|
|
1026
|
+
if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
|
|
1027
|
+
|
|
1028
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1029
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
1030
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
1031
|
+
|
|
1032
|
+
let found = false;
|
|
1033
|
+
const now = new Date().toISOString();
|
|
1034
|
+
|
|
1035
|
+
// Update in history.jsonl (locked)
|
|
1036
|
+
await withFileLock(historyFile, () => {
|
|
1037
|
+
if (fs.existsSync(historyFile)) {
|
|
1038
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
1039
|
+
const updated = lines.map(line => {
|
|
1040
|
+
try {
|
|
1041
|
+
const msg = JSON.parse(line);
|
|
1042
|
+
if (msg.id === id) {
|
|
1043
|
+
found = true;
|
|
1044
|
+
if (!msg.edit_history) msg.edit_history = [];
|
|
1045
|
+
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
1046
|
+
msg.content = content;
|
|
1047
|
+
msg.edited = true;
|
|
1048
|
+
msg.edited_at = now;
|
|
1049
|
+
return JSON.stringify(msg);
|
|
1050
|
+
}
|
|
1051
|
+
return line;
|
|
1052
|
+
} catch { return line; }
|
|
1053
|
+
});
|
|
1054
|
+
if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// Also update in messages.jsonl (locked independently)
|
|
1059
|
+
if (found) {
|
|
1060
|
+
await withFileLock(messagesFile, () => {
|
|
1061
|
+
if (fs.existsSync(messagesFile)) {
|
|
1062
|
+
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
1063
|
+
if (raw) {
|
|
1064
|
+
const lines = raw.split('\n');
|
|
1065
|
+
const updated = lines.map(line => {
|
|
1066
|
+
try {
|
|
1067
|
+
const msg = JSON.parse(line);
|
|
1068
|
+
if (msg.id === id) {
|
|
1069
|
+
msg.content = content;
|
|
1070
|
+
msg.edited = true;
|
|
1071
|
+
msg.edited_at = now;
|
|
1072
|
+
return JSON.stringify(msg);
|
|
1073
|
+
}
|
|
1074
|
+
return line;
|
|
1075
|
+
} catch { return line; }
|
|
1076
|
+
});
|
|
1077
|
+
fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (!found) return { error: 'Message not found' };
|
|
1084
|
+
return { success: true, id, edited_at: now };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// --- v3.4: Message Delete ---
|
|
1088
|
+
async function apiDeleteMessage(body, query) {
|
|
1089
|
+
const projectPath = query.get('project') || null;
|
|
1090
|
+
const { id } = body;
|
|
1091
|
+
if (!id) return { error: 'Missing "id" field' };
|
|
1092
|
+
|
|
1093
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1094
|
+
const historyFile = path.join(dataDir, 'history.jsonl');
|
|
1095
|
+
const messagesFile = path.join(dataDir, 'messages.jsonl');
|
|
1096
|
+
|
|
1097
|
+
let found = false;
|
|
1098
|
+
let msgFrom = null;
|
|
1099
|
+
|
|
1100
|
+
// Find the message and remove from history.jsonl (locked)
|
|
1101
|
+
await withFileLock(historyFile, () => {
|
|
1102
|
+
if (fs.existsSync(historyFile)) {
|
|
1103
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
|
|
1104
|
+
for (const line of lines) {
|
|
1105
|
+
try {
|
|
1106
|
+
const msg = JSON.parse(line);
|
|
1107
|
+
if (msg.id === id) { found = true; msgFrom = msg.from; break; }
|
|
1108
|
+
} catch {}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (found) {
|
|
1112
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
1113
|
+
if (allowed.includes(msgFrom)) {
|
|
1114
|
+
const filtered = lines.filter(line => {
|
|
1115
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1116
|
+
});
|
|
1117
|
+
fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
if (!found) return { error: 'Message not found' };
|
|
1124
|
+
|
|
1125
|
+
// Only allow deleting dashboard-injected or system messages
|
|
1126
|
+
const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
|
|
1127
|
+
if (!allowed.includes(msgFrom)) {
|
|
1128
|
+
return { error: 'Can only delete messages sent from Dashboard or system' };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Remove from messages.jsonl (locked independently)
|
|
1132
|
+
await withFileLock(messagesFile, () => {
|
|
1133
|
+
if (fs.existsSync(messagesFile)) {
|
|
1134
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
|
|
1135
|
+
const filtered = lines.filter(line => {
|
|
1136
|
+
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1137
|
+
});
|
|
1138
|
+
fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
return { success: true, id };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// --- v3.4: Conversation Templates ---
|
|
1146
|
+
function apiGetConversationTemplates() {
|
|
1147
|
+
const templatesDir = path.join(__dirname, 'conversation-templates');
|
|
1148
|
+
if (!fs.existsSync(templatesDir)) {
|
|
1149
|
+
// Return built-in templates
|
|
1150
|
+
return getBuiltInConversationTemplates();
|
|
1151
|
+
}
|
|
1152
|
+
const custom = fs.readdirSync(templatesDir)
|
|
1153
|
+
.filter(f => f.endsWith('.json'))
|
|
1154
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
|
|
1155
|
+
.filter(Boolean);
|
|
1156
|
+
return [...getBuiltInConversationTemplates(), ...custom];
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function getBuiltInConversationTemplates() {
|
|
1160
|
+
return [
|
|
1161
|
+
{
|
|
1162
|
+
id: 'code-review',
|
|
1163
|
+
name: 'Code Review Pipeline',
|
|
1164
|
+
description: 'Developer writes code, Reviewer checks it, Tester validates',
|
|
1165
|
+
agents: [
|
|
1166
|
+
{ name: 'Developer', role: 'Developer', prompt: 'You are a developer. Write code as instructed. After completing, send your code to Reviewer for review.' },
|
|
1167
|
+
{ name: 'Reviewer', role: 'Code Reviewer', prompt: 'You are a code reviewer. Wait for code from Developer. Review it for bugs, style, and best practices. Send feedback back to Developer or approve and forward to Tester.' },
|
|
1168
|
+
{ name: 'Tester', role: 'QA Tester', prompt: 'You are a QA tester. Wait for approved code from Reviewer. Write and run tests. Report results back to the team.' }
|
|
1169
|
+
],
|
|
1170
|
+
workflow: { name: 'Code Review', steps: ['Write Code', 'Review', 'Test', 'Approve'] }
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
id: 'debug-squad',
|
|
1174
|
+
name: 'Debug Squad',
|
|
1175
|
+
description: 'Investigator finds the bug, Fixer patches it, Verifier confirms the fix',
|
|
1176
|
+
agents: [
|
|
1177
|
+
{ name: 'Investigator', role: 'Bug Investigator', prompt: 'You investigate bugs. Analyze error logs, trace code paths, and identify root causes. Send findings to Fixer.' },
|
|
1178
|
+
{ name: 'Fixer', role: 'Bug Fixer', prompt: 'You fix bugs. Wait for findings from Investigator. Implement fixes and send to Verifier for confirmation.' },
|
|
1179
|
+
{ name: 'Verifier', role: 'Fix Verifier', prompt: 'You verify bug fixes. Wait for patches from Fixer. Test the fix and confirm resolution or send back for more work.' }
|
|
1180
|
+
],
|
|
1181
|
+
workflow: { name: 'Bug Fix', steps: ['Investigate', 'Fix', 'Verify', 'Close'] }
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
id: 'feature-build',
|
|
1185
|
+
name: 'Feature Development',
|
|
1186
|
+
description: 'Architect designs, Builder implements, Reviewer approves',
|
|
1187
|
+
agents: [
|
|
1188
|
+
{ name: 'Architect', role: 'Software Architect', prompt: 'You are a software architect. Design the feature architecture, define interfaces, and create the implementation plan. Send the plan to Builder.' },
|
|
1189
|
+
{ name: 'Builder', role: 'Developer', prompt: 'You are a developer. Wait for architecture plans from Architect. Implement the feature following the design. Send completed code to Reviewer.' },
|
|
1190
|
+
{ name: 'Reviewer', role: 'Senior Reviewer', prompt: 'You are a senior reviewer. Review implementations from Builder against the architecture from Architect. Approve or request changes.' }
|
|
1191
|
+
],
|
|
1192
|
+
workflow: { name: 'Feature Dev', steps: ['Design', 'Implement', 'Review', 'Ship'] }
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
id: 'research-write',
|
|
1196
|
+
name: 'Research & Write',
|
|
1197
|
+
description: 'Researcher gathers info, Writer creates content, Editor polishes',
|
|
1198
|
+
agents: [
|
|
1199
|
+
{ name: 'Researcher', role: 'Researcher', prompt: 'You are a researcher. Gather information on the given topic. Organize findings and send a research brief to Writer.' },
|
|
1200
|
+
{ name: 'Writer', role: 'Writer', prompt: 'You are a writer. Wait for research from Researcher. Write clear, well-structured content based on the findings. Send to Editor.' },
|
|
1201
|
+
{ name: 'Editor', role: 'Editor', prompt: 'You are an editor. Review and polish content from Writer. Check for clarity, accuracy, and style. Send back final version or request revisions.' }
|
|
1202
|
+
],
|
|
1203
|
+
workflow: { name: 'Content Pipeline', steps: ['Research', 'Draft', 'Edit', 'Publish'] }
|
|
1204
|
+
}
|
|
1205
|
+
];
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function apiLaunchConversationTemplate(body, query) {
|
|
1209
|
+
const projectPath = query.get('project') || null;
|
|
1210
|
+
const { template_id } = body;
|
|
1211
|
+
if (!template_id) return { error: 'Missing template_id' };
|
|
1212
|
+
|
|
1213
|
+
const templates = apiGetConversationTemplates();
|
|
1214
|
+
const template = templates.find(t => t.id === template_id);
|
|
1215
|
+
if (!template) return { error: 'Template not found: ' + template_id };
|
|
1216
|
+
|
|
1217
|
+
// Return the template config for the frontend to display launch instructions
|
|
1218
|
+
return {
|
|
1219
|
+
success: true,
|
|
1220
|
+
template,
|
|
1221
|
+
instructions: template.agents.map(a => ({
|
|
1222
|
+
agent_name: a.name,
|
|
1223
|
+
role: a.role,
|
|
1224
|
+
prompt: `You are "${a.name}" with role "${a.role}". ${a.prompt}\n\nFirst register yourself with: register(name="${a.name}"), then update_profile(role="${a.role}"). Then enter listen mode.`
|
|
1225
|
+
}))
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// --- v3.4: Agent Permissions ---
|
|
1230
|
+
function apiUpdatePermissions(body, query) {
|
|
1231
|
+
const projectPath = query.get('project') || null;
|
|
1232
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1233
|
+
const permFile = path.join(dataDir, 'permissions.json');
|
|
1234
|
+
|
|
1235
|
+
const { agent, permissions } = body;
|
|
1236
|
+
if (!agent || !permissions) return { error: 'Missing "agent" and/or "permissions" fields' };
|
|
1237
|
+
|
|
1238
|
+
let perms = {};
|
|
1239
|
+
if (fs.existsSync(permFile)) {
|
|
1240
|
+
try { perms = JSON.parse(fs.readFileSync(permFile, 'utf8')); } catch {}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// permissions: { can_read: [agents...] or "*", can_write_to: [agents...] or "*", is_admin: bool }
|
|
1244
|
+
const allowed = {};
|
|
1245
|
+
if (permissions.can_read !== undefined) allowed.can_read = permissions.can_read;
|
|
1246
|
+
if (permissions.can_write_to !== undefined) allowed.can_write_to = permissions.can_write_to;
|
|
1247
|
+
if (permissions.is_admin !== undefined) allowed.is_admin = !!permissions.is_admin;
|
|
1248
|
+
perms[agent] = {
|
|
1249
|
+
...perms[agent],
|
|
1250
|
+
...allowed,
|
|
1251
|
+
updated_at: new Date().toISOString()
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
1255
|
+
fs.writeFileSync(permFile, JSON.stringify(perms, null, 2));
|
|
1256
|
+
return { success: true, agent, permissions: perms[agent] };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// --- HTTP Server ---
|
|
1260
|
+
|
|
1261
|
+
// Load HTML at startup (re-read on each request in dev for hot-reload)
|
|
1262
|
+
let htmlContent = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1263
|
+
|
|
1264
|
+
const MAX_BODY = 1 * 1024 * 1024; // 1 MB
|
|
1265
|
+
|
|
1266
|
+
function parseBody(req) {
|
|
1267
|
+
return new Promise((resolve, reject) => {
|
|
1268
|
+
let data = '';
|
|
1269
|
+
req.on('data', chunk => {
|
|
1270
|
+
data += chunk;
|
|
1271
|
+
if (data.length > MAX_BODY) {
|
|
1272
|
+
req.destroy();
|
|
1273
|
+
reject(new Error('Request body too large'));
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
req.on('end', () => {
|
|
1277
|
+
try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON body')); }
|
|
1278
|
+
});
|
|
1279
|
+
req.on('error', reject);
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const server = http.createServer(async (req, res) => {
|
|
1284
|
+
const url = new URL(req.url, 'http://localhost:' + PORT);
|
|
1285
|
+
|
|
1286
|
+
const allowedOrigin = `http://localhost:${PORT}`;
|
|
1287
|
+
const reqOrigin = req.headers.origin;
|
|
1288
|
+
const lanIP = getLanIP();
|
|
1289
|
+
const lanOrigin = lanIP ? `http://${lanIP}:${PORT}` : null;
|
|
1290
|
+
const trustedOrigins = [allowedOrigin, `http://127.0.0.1:${PORT}`];
|
|
1291
|
+
if (LAN_MODE && lanOrigin) trustedOrigins.push(lanOrigin);
|
|
1292
|
+
if (reqOrigin && trustedOrigins.includes(reqOrigin)) {
|
|
1293
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin);
|
|
1294
|
+
}
|
|
1295
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
1296
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-LTT-Request, X-LTT-Token');
|
|
1297
|
+
|
|
1298
|
+
if (req.method === 'OPTIONS') {
|
|
1299
|
+
res.writeHead(204);
|
|
1300
|
+
res.end();
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// LAN auth token — required for non-localhost requests when LAN mode is active
|
|
1305
|
+
if (LAN_MODE) {
|
|
1306
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1307
|
+
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
|
|
1308
|
+
if (!isLocalhost) {
|
|
1309
|
+
const tokenFromQuery = url.searchParams.get('token');
|
|
1310
|
+
const tokenFromHeader = req.headers['x-ltt-token'];
|
|
1311
|
+
const providedToken = tokenFromHeader || tokenFromQuery;
|
|
1312
|
+
if (!providedToken || providedToken !== LAN_TOKEN) {
|
|
1313
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1314
|
+
res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// CSRF + DNS rebinding protection: validate Host, Origin, and custom header on mutating requests
|
|
1321
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
1322
|
+
// Check Host header to block DNS rebinding attacks
|
|
1323
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '');
|
|
1324
|
+
const validHosts = ['localhost', '127.0.0.1'];
|
|
1325
|
+
if (LAN_MODE && getLanIP()) validHosts.push(getLanIP());
|
|
1326
|
+
if (!validHosts.includes(host)) {
|
|
1327
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1328
|
+
res.end(JSON.stringify({ error: 'Forbidden: invalid host' }));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
// Require custom header — browsers block cross-origin custom headers without preflight,
|
|
1332
|
+
// which our CORS policy won't approve for foreign origins. This closes the no-Origin gap.
|
|
1333
|
+
if (!req.headers['x-ltt-request']) {
|
|
1334
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1335
|
+
res.end(JSON.stringify({ error: 'Forbidden: missing X-LTT-Request header' }));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// Check Origin header to block cross-site requests
|
|
1339
|
+
// Empty origin is NOT trusted — requires at least the custom header (checked above)
|
|
1340
|
+
const origin = req.headers.origin || '';
|
|
1341
|
+
const referer = req.headers.referer || '';
|
|
1342
|
+
const source = origin || referer;
|
|
1343
|
+
if (!source) {
|
|
1344
|
+
// No origin/referer — non-browser client (curl, scripts, etc.)
|
|
1345
|
+
// Custom header check above is the only protection layer here — allow through
|
|
1346
|
+
// since local CLI tools (like our own `msg` command) need to work
|
|
1347
|
+
}
|
|
1348
|
+
const isLocal = source && (source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT));
|
|
1349
|
+
const isLan = LAN_MODE && getLanIP() && source && source.includes(getLanIP() + ':' + PORT);
|
|
1350
|
+
if (source && !isLocal && !isLan) {
|
|
1351
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1352
|
+
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
try {
|
|
1358
|
+
// Validate project parameter on all API endpoints
|
|
1359
|
+
const projectParam = url.searchParams.get('project');
|
|
1360
|
+
if (projectParam && url.pathname.startsWith('/api/') && !validateProjectPath(projectParam)) {
|
|
1361
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1362
|
+
res.end(JSON.stringify({ error: 'Project path not registered. Add it via /api/projects first.' }));
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Serve logo image
|
|
1367
|
+
if (url.pathname === '/logo.png') {
|
|
1368
|
+
if (fs.existsSync(LOGO_FILE)) {
|
|
1369
|
+
const logo = fs.readFileSync(LOGO_FILE);
|
|
1370
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
|
|
1371
|
+
res.end(logo);
|
|
1372
|
+
} else {
|
|
1373
|
+
res.writeHead(404);
|
|
1374
|
+
res.end('Logo not found');
|
|
1375
|
+
}
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Serve static library files from node_modules (Three.js etc.)
|
|
1380
|
+
if (url.pathname.startsWith('/lib/')) {
|
|
1381
|
+
const libPath = url.pathname.replace('/lib/', '');
|
|
1382
|
+
// Sanitize: prevent path traversal
|
|
1383
|
+
if (libPath.includes('..') || libPath.includes('\\')) {
|
|
1384
|
+
res.writeHead(400); res.end('Bad path'); return;
|
|
1385
|
+
}
|
|
1386
|
+
const filePath = path.join(__dirname, '..', 'node_modules', libPath);
|
|
1387
|
+
if (fs.existsSync(filePath)) {
|
|
1388
|
+
const ext = path.extname(filePath);
|
|
1389
|
+
const mimeTypes = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.wasm': 'application/wasm' };
|
|
1390
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
1391
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=604800' });
|
|
1392
|
+
res.end(fs.readFileSync(filePath));
|
|
1393
|
+
} else {
|
|
1394
|
+
res.writeHead(404); res.end('Not found');
|
|
1395
|
+
}
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Serve 3D office modules from agent-bridge/office/
|
|
1400
|
+
if (url.pathname.startsWith('/office/')) {
|
|
1401
|
+
const officePath = url.pathname.replace('/office/', '');
|
|
1402
|
+
if (officePath.includes('..') || officePath.includes('\\')) {
|
|
1403
|
+
res.writeHead(400); res.end('Bad path'); return;
|
|
1404
|
+
}
|
|
1405
|
+
const filePath = path.join(__dirname, 'office', officePath);
|
|
1406
|
+
if (fs.existsSync(filePath)) {
|
|
1407
|
+
const ext = path.extname(filePath);
|
|
1408
|
+
const mimeTypes = { '.js': 'application/javascript', '.json': 'application/json' };
|
|
1409
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
1410
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
1411
|
+
res.end(fs.readFileSync(filePath));
|
|
1412
|
+
} else {
|
|
1413
|
+
res.writeHead(404); res.end('Not found');
|
|
1414
|
+
}
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Serve mod assets from agent-bridge/mods/
|
|
1419
|
+
if (url.pathname.startsWith('/mods/')) {
|
|
1420
|
+
const modPath = url.pathname.replace('/mods/', '');
|
|
1421
|
+
if (modPath.includes('..') || modPath.includes('\\')) {
|
|
1422
|
+
res.writeHead(400); res.end('Bad path'); return;
|
|
1423
|
+
}
|
|
1424
|
+
const filePath = path.join(__dirname, 'mods', modPath);
|
|
1425
|
+
if (fs.existsSync(filePath)) {
|
|
1426
|
+
const ext = path.extname(filePath);
|
|
1427
|
+
const allowedMime = { '.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.png': 'image/png' };
|
|
1428
|
+
const contentType = allowedMime[ext];
|
|
1429
|
+
if (!contentType) {
|
|
1430
|
+
res.writeHead(403); res.end('File type not allowed'); return;
|
|
1431
|
+
}
|
|
1432
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' });
|
|
1433
|
+
res.end(fs.readFileSync(filePath));
|
|
1434
|
+
} else {
|
|
1435
|
+
res.writeHead(404); res.end('Not found');
|
|
1436
|
+
}
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Serve dashboard HTML (always re-read for hot reload)
|
|
1441
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
1442
|
+
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1443
|
+
res.writeHead(200, {
|
|
1444
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1445
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'",
|
|
1446
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1447
|
+
'Pragma': 'no-cache',
|
|
1448
|
+
'Expires': '0'
|
|
1449
|
+
});
|
|
1450
|
+
res.end(html);
|
|
1451
|
+
}
|
|
1452
|
+
// Existing APIs (now with ?project= param support)
|
|
1453
|
+
else if (url.pathname === '/api/history' && req.method === 'GET') {
|
|
1454
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1455
|
+
res.end(JSON.stringify(apiHistory(url.searchParams)));
|
|
1456
|
+
}
|
|
1457
|
+
else if (url.pathname === '/api/agents' && req.method === 'GET') {
|
|
1458
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1459
|
+
res.end(JSON.stringify(apiAgents(url.searchParams)));
|
|
1460
|
+
}
|
|
1461
|
+
else if (url.pathname === '/api/agents' && req.method === 'DELETE') {
|
|
1462
|
+
const body = await parseBody(req);
|
|
1463
|
+
if (!body.name) {
|
|
1464
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1465
|
+
res.end(JSON.stringify({ error: 'Missing agent name' }));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const agentName = body.name;
|
|
1469
|
+
const dataDir = resolveDataDir(url.searchParams.get('project'));
|
|
1470
|
+
const agentsFile = path.join(dataDir, 'agents.json');
|
|
1471
|
+
const profilesFile = path.join(dataDir, 'profiles.json');
|
|
1472
|
+
await withFileLock(agentsFile, () => {
|
|
1473
|
+
// Remove from agents.json
|
|
1474
|
+
if (fs.existsSync(agentsFile)) {
|
|
1475
|
+
const agents = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
|
|
1476
|
+
if (!agents[agentName]) {
|
|
1477
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1478
|
+
res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
delete agents[agentName];
|
|
1482
|
+
fs.writeFileSync(agentsFile, JSON.stringify(agents, null, 2));
|
|
1483
|
+
}
|
|
1484
|
+
// Remove from profiles.json
|
|
1485
|
+
if (fs.existsSync(profilesFile)) {
|
|
1486
|
+
const profiles = JSON.parse(fs.readFileSync(profilesFile, 'utf8'));
|
|
1487
|
+
delete profiles[agentName];
|
|
1488
|
+
fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
|
|
1489
|
+
}
|
|
1490
|
+
// Remove consumed file
|
|
1491
|
+
const consumedFile = path.join(dataDir, 'consumed-' + agentName + '.json');
|
|
1492
|
+
if (fs.existsSync(consumedFile)) fs.unlinkSync(consumedFile);
|
|
1493
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1494
|
+
res.end(JSON.stringify({ success: true, removed: agentName }));
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
else if (url.pathname === '/api/status' && req.method === 'GET') {
|
|
1498
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1499
|
+
res.end(JSON.stringify(apiStatus(url.searchParams)));
|
|
1500
|
+
}
|
|
1501
|
+
else if (url.pathname === '/api/stats' && req.method === 'GET') {
|
|
1502
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1503
|
+
res.end(JSON.stringify(apiStats(url.searchParams)));
|
|
1504
|
+
}
|
|
1505
|
+
else if (url.pathname === '/api/reset' && req.method === 'POST') {
|
|
1506
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1507
|
+
res.end(JSON.stringify(apiReset(url.searchParams)));
|
|
1508
|
+
}
|
|
1509
|
+
// Message injection
|
|
1510
|
+
else if (url.pathname === '/api/inject' && req.method === 'POST') {
|
|
1511
|
+
const body = await parseBody(req);
|
|
1512
|
+
const result = apiInjectMessage(body, url.searchParams);
|
|
1513
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1514
|
+
res.end(JSON.stringify(result));
|
|
1515
|
+
}
|
|
1516
|
+
// Multi-project management
|
|
1517
|
+
else if (url.pathname === '/api/projects' && req.method === 'GET') {
|
|
1518
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1519
|
+
res.end(JSON.stringify(apiProjects()));
|
|
1520
|
+
}
|
|
1521
|
+
else if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
1522
|
+
const body = await parseBody(req);
|
|
1523
|
+
const result = apiAddProject(body);
|
|
1524
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1525
|
+
res.end(JSON.stringify(result));
|
|
1526
|
+
}
|
|
1527
|
+
else if (url.pathname === '/api/timeline' && req.method === 'GET') {
|
|
1528
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1529
|
+
res.end(JSON.stringify(apiTimeline(url.searchParams)));
|
|
1530
|
+
}
|
|
1531
|
+
else if (url.pathname === '/api/tasks' && req.method === 'GET') {
|
|
1532
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1533
|
+
res.end(JSON.stringify(apiTasks(url.searchParams)));
|
|
1534
|
+
}
|
|
1535
|
+
else if (url.pathname === '/api/tasks' && req.method === 'POST') {
|
|
1536
|
+
const body = await parseBody(req);
|
|
1537
|
+
const result = apiUpdateTask(body, url.searchParams);
|
|
1538
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1539
|
+
res.end(JSON.stringify(result));
|
|
1540
|
+
}
|
|
1541
|
+
else if (url.pathname === '/api/export' && req.method === 'GET') {
|
|
1542
|
+
const html = apiExportHtml(url.searchParams);
|
|
1543
|
+
res.writeHead(200, {
|
|
1544
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1545
|
+
'Content-Disposition': 'attachment; filename="conversation-' + new Date().toISOString().slice(0, 10) + '.html"',
|
|
1546
|
+
});
|
|
1547
|
+
res.end(html);
|
|
1548
|
+
}
|
|
1549
|
+
else if (url.pathname === '/api/discover' && req.method === 'POST') {
|
|
1550
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1551
|
+
res.end(JSON.stringify(apiDiscover()));
|
|
1552
|
+
}
|
|
1553
|
+
// --- v3.0 API endpoints ---
|
|
1554
|
+
else if (url.pathname === '/api/profiles' && req.method === 'GET') {
|
|
1555
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1556
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1557
|
+
res.end(JSON.stringify(readJson(filePath('profiles.json', projectPath))));
|
|
1558
|
+
}
|
|
1559
|
+
else if (url.pathname === '/api/profiles' && req.method === 'POST') {
|
|
1560
|
+
const body = await parseBody(req);
|
|
1561
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1562
|
+
const profilesFile = filePath('profiles.json', projectPath);
|
|
1563
|
+
const profiles = readJson(profilesFile);
|
|
1564
|
+
if (!body.agent) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Missing agent field' })); return; }
|
|
1565
|
+
if (!profiles[body.agent]) profiles[body.agent] = {};
|
|
1566
|
+
if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
|
|
1567
|
+
if (body.avatar) {
|
|
1568
|
+
if (body.avatar.length > 65536) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Avatar too large (max 64KB)' })); return; }
|
|
1569
|
+
profiles[body.agent].avatar = body.avatar;
|
|
1570
|
+
}
|
|
1571
|
+
if (body.bio !== undefined) profiles[body.agent].bio = (body.bio || '').substring(0, 200);
|
|
1572
|
+
if (body.role !== undefined) profiles[body.agent].role = (body.role || '').substring(0, 30);
|
|
1573
|
+
if (body.appearance !== undefined && typeof body.appearance === 'object') {
|
|
1574
|
+
const validKeys = ['head_color', 'hair_style', 'hair_color', 'eye_style', 'mouth_style', 'shirt_color', 'pants_color', 'shoe_color', 'glasses', 'glasses_color', 'headwear', 'headwear_color', 'neckwear', 'neckwear_color'];
|
|
1575
|
+
const enumValidation = {
|
|
1576
|
+
hair_style: ['none', 'short', 'spiky', 'long', 'ponytail', 'bob'],
|
|
1577
|
+
eye_style: ['dots', 'anime', 'glasses', 'sleepy'],
|
|
1578
|
+
mouth_style: ['smile', 'neutral', 'open'],
|
|
1579
|
+
glasses: ['none', 'round', 'square', 'sunglasses'],
|
|
1580
|
+
headwear: ['none', 'beanie', 'cap', 'headphones', 'headband'],
|
|
1581
|
+
neckwear: ['none', 'tie', 'bowtie', 'lanyard'],
|
|
1582
|
+
};
|
|
1583
|
+
const cleaned = {};
|
|
1584
|
+
for (const [k, v] of Object.entries(body.appearance)) {
|
|
1585
|
+
if (!validKeys.includes(k) || typeof v !== 'string' || v.length > 20) continue;
|
|
1586
|
+
if (enumValidation[k] && !enumValidation[k].includes(v)) continue;
|
|
1587
|
+
cleaned[k] = v;
|
|
1588
|
+
}
|
|
1589
|
+
profiles[body.agent].appearance = Object.assign(profiles[body.agent].appearance || {}, cleaned);
|
|
1590
|
+
}
|
|
1591
|
+
profiles[body.agent].updated_at = new Date().toISOString();
|
|
1592
|
+
fs.writeFileSync(profilesFile, JSON.stringify(profiles, null, 2));
|
|
1593
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1594
|
+
res.end(JSON.stringify({ success: true }));
|
|
1595
|
+
}
|
|
1596
|
+
else if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
1597
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1598
|
+
const agentParam = url.searchParams.get('agent');
|
|
1599
|
+
if (agentParam && !/^[a-zA-Z0-9_-]{1,20}$/.test(agentParam)) {
|
|
1600
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1601
|
+
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1605
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
1606
|
+
const result = {};
|
|
1607
|
+
if (agentParam) {
|
|
1608
|
+
const wsFile = path.join(wsDir, agentParam + '.json');
|
|
1609
|
+
result[agentParam] = fs.existsSync(wsFile) ? readJson(wsFile) : {};
|
|
1610
|
+
} else if (fs.existsSync(wsDir)) {
|
|
1611
|
+
for (const f of fs.readdirSync(wsDir).filter(x => x.endsWith('.json'))) {
|
|
1612
|
+
const name = f.replace('.json', '');
|
|
1613
|
+
result[name] = readJson(path.join(wsDir, f));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1617
|
+
res.end(JSON.stringify(result));
|
|
1618
|
+
}
|
|
1619
|
+
else if (url.pathname === '/api/workflows' && req.method === 'GET') {
|
|
1620
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1621
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
1622
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1623
|
+
res.end(JSON.stringify(fs.existsSync(wfFile) ? JSON.parse(fs.readFileSync(wfFile, 'utf8')) : []));
|
|
1624
|
+
}
|
|
1625
|
+
else if (url.pathname === '/api/workflows' && req.method === 'POST') {
|
|
1626
|
+
const body = await parseBody(req);
|
|
1627
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1628
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
1629
|
+
let workflows = [];
|
|
1630
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
1631
|
+
if (body.action === 'advance' && body.workflow_id) {
|
|
1632
|
+
const wf = workflows.find(w => w.id === body.workflow_id);
|
|
1633
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
1634
|
+
const curr = wf.steps.find(s => s.status === 'in_progress');
|
|
1635
|
+
if (curr) { curr.status = 'done'; curr.completed_at = new Date().toISOString(); if (body.notes) curr.notes = body.notes; }
|
|
1636
|
+
const next = wf.steps.find(s => s.status === 'pending');
|
|
1637
|
+
if (next) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); } else { wf.status = 'completed'; }
|
|
1638
|
+
wf.updated_at = new Date().toISOString();
|
|
1639
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
1640
|
+
} else if (body.action === 'skip' && body.workflow_id && body.step_id) {
|
|
1641
|
+
const wf = workflows.find(w => w.id === body.workflow_id);
|
|
1642
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
1643
|
+
const step = wf.steps.find(s => s.id === body.step_id);
|
|
1644
|
+
if (step) { step.status = 'done'; step.notes = 'Skipped from dashboard'; step.completed_at = new Date().toISOString(); }
|
|
1645
|
+
const next = wf.steps.find(s => s.status === 'pending');
|
|
1646
|
+
if (next && !wf.steps.find(s => s.status === 'in_progress')) { next.status = 'in_progress'; next.started_at = new Date().toISOString(); }
|
|
1647
|
+
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
1648
|
+
wf.updated_at = new Date().toISOString();
|
|
1649
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
1650
|
+
} else {
|
|
1651
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid action' })); return;
|
|
1652
|
+
}
|
|
1653
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1654
|
+
res.end(JSON.stringify({ success: true }));
|
|
1655
|
+
}
|
|
1656
|
+
else if (url.pathname === '/api/branches' && req.method === 'GET') {
|
|
1657
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1658
|
+
const branchesFile = filePath('branches.json', projectPath);
|
|
1659
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1660
|
+
let branches = fs.existsSync(branchesFile) ? readJson(branchesFile) : {};
|
|
1661
|
+
// Add message counts
|
|
1662
|
+
for (const [name, info] of Object.entries(branches)) {
|
|
1663
|
+
const histFile = name === 'main' ? path.join(dataDir, 'history.jsonl') : path.join(dataDir, `branch-${name}-history.jsonl`);
|
|
1664
|
+
let msgCount = 0;
|
|
1665
|
+
if (fs.existsSync(histFile)) {
|
|
1666
|
+
const content = fs.readFileSync(histFile, 'utf8').trim();
|
|
1667
|
+
if (content) msgCount = content.split('\n').length;
|
|
1668
|
+
}
|
|
1669
|
+
branches[name].message_count = msgCount;
|
|
1670
|
+
}
|
|
1671
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1672
|
+
res.end(JSON.stringify(branches));
|
|
1673
|
+
}
|
|
1674
|
+
else if (url.pathname === '/api/projects' && req.method === 'DELETE') {
|
|
1675
|
+
const body = await parseBody(req);
|
|
1676
|
+
const result = apiRemoveProject(body);
|
|
1677
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1678
|
+
res.end(JSON.stringify(result));
|
|
1679
|
+
}
|
|
1680
|
+
// --- v3.4: Message Edit ---
|
|
1681
|
+
else if (url.pathname === '/api/message' && req.method === 'PUT') {
|
|
1682
|
+
const body = await parseBody(req);
|
|
1683
|
+
const result = await apiEditMessage(body, url.searchParams);
|
|
1684
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1685
|
+
res.end(JSON.stringify(result));
|
|
1686
|
+
}
|
|
1687
|
+
// --- v3.4: Message Delete ---
|
|
1688
|
+
else if (url.pathname === '/api/message' && req.method === 'DELETE') {
|
|
1689
|
+
const body = await parseBody(req);
|
|
1690
|
+
const result = await apiDeleteMessage(body, url.searchParams);
|
|
1691
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1692
|
+
res.end(JSON.stringify(result));
|
|
1693
|
+
}
|
|
1694
|
+
// --- v3.4: Conversation Templates ---
|
|
1695
|
+
else if (url.pathname === '/api/conversation-templates' && req.method === 'GET') {
|
|
1696
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1697
|
+
res.end(JSON.stringify(apiGetConversationTemplates()));
|
|
1698
|
+
}
|
|
1699
|
+
else if (url.pathname === '/api/conversation-templates/launch' && req.method === 'POST') {
|
|
1700
|
+
const body = await parseBody(req);
|
|
1701
|
+
const result = apiLaunchConversationTemplate(body, url.searchParams);
|
|
1702
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1703
|
+
res.end(JSON.stringify(result));
|
|
1704
|
+
}
|
|
1705
|
+
// --- v3.4: Agent Permissions ---
|
|
1706
|
+
else if (url.pathname === '/api/permissions' && req.method === 'GET') {
|
|
1707
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1708
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1709
|
+
res.end(JSON.stringify(readJson(filePath('permissions.json', projectPath))));
|
|
1710
|
+
}
|
|
1711
|
+
else if (url.pathname === '/api/permissions' && req.method === 'POST') {
|
|
1712
|
+
const body = await parseBody(req);
|
|
1713
|
+
const result = apiUpdatePermissions(body, url.searchParams);
|
|
1714
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1715
|
+
res.end(JSON.stringify(result));
|
|
1716
|
+
}
|
|
1717
|
+
// --- v3.4: Read Receipts ---
|
|
1718
|
+
else if (url.pathname === '/api/read-receipts' && req.method === 'GET') {
|
|
1719
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1720
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1721
|
+
res.end(JSON.stringify(readJson(filePath('read_receipts.json', projectPath))));
|
|
1722
|
+
}
|
|
1723
|
+
// Server info (LAN mode detection for frontend)
|
|
1724
|
+
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
1725
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1726
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
|
|
1727
|
+
}
|
|
1728
|
+
// Toggle LAN mode (re-bind server live)
|
|
1729
|
+
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
1730
|
+
const newMode = !LAN_MODE;
|
|
1731
|
+
const lanIP = getLanIP();
|
|
1732
|
+
LAN_MODE = newMode;
|
|
1733
|
+
persistLanMode();
|
|
1734
|
+
// Regenerate token when enabling LAN mode
|
|
1735
|
+
if (newMode) generateLanToken();
|
|
1736
|
+
// Send response first
|
|
1737
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1738
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
|
|
1739
|
+
// Re-bind by stopping the listener and immediately re-listening
|
|
1740
|
+
// Use setImmediate to let the response flush first
|
|
1741
|
+
setImmediate(() => {
|
|
1742
|
+
// Drop SSE clients
|
|
1743
|
+
for (const client of sseClients) { try { client.end(); } catch {} }
|
|
1744
|
+
sseClients.clear();
|
|
1745
|
+
// Stop listening (don't use server.close which waits for all connections)
|
|
1746
|
+
server.listening = false;
|
|
1747
|
+
if (server._handle) {
|
|
1748
|
+
server._handle.close();
|
|
1749
|
+
server._handle = null;
|
|
1750
|
+
}
|
|
1751
|
+
server.listen(PORT, newMode ? '0.0.0.0' : '127.0.0.1', () => {
|
|
1752
|
+
console.log(newMode
|
|
1753
|
+
? ` LAN mode enabled — http://${lanIP}:${PORT}`
|
|
1754
|
+
: ' LAN mode disabled — localhost only');
|
|
1755
|
+
startFileWatcher(); // restart file watcher
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
// Templates API
|
|
1760
|
+
else if (url.pathname === '/api/templates' && req.method === 'GET') {
|
|
1761
|
+
const templatesDir = path.join(__dirname, 'templates');
|
|
1762
|
+
let templates = [];
|
|
1763
|
+
if (fs.existsSync(templatesDir)) {
|
|
1764
|
+
templates = fs.readdirSync(templatesDir)
|
|
1765
|
+
.filter(f => f.endsWith('.json'))
|
|
1766
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
|
|
1767
|
+
.filter(Boolean);
|
|
1768
|
+
}
|
|
1769
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1770
|
+
res.end(JSON.stringify(templates));
|
|
1771
|
+
}
|
|
1772
|
+
// Agent launcher
|
|
1773
|
+
else if (url.pathname === '/api/launch' && req.method === 'POST') {
|
|
1774
|
+
const body = await parseBody(req);
|
|
1775
|
+
const result = apiLaunchAgent(body);
|
|
1776
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1777
|
+
res.end(JSON.stringify(result));
|
|
1778
|
+
}
|
|
1779
|
+
// --- v3.4: Notifications ---
|
|
1780
|
+
else if (url.pathname === '/api/notifications' && req.method === 'GET') {
|
|
1781
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1782
|
+
res.end(JSON.stringify(apiNotifications()));
|
|
1783
|
+
}
|
|
1784
|
+
// --- v3.4: Performance Scores ---
|
|
1785
|
+
else if (url.pathname === '/api/scores' && req.method === 'GET') {
|
|
1786
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1787
|
+
res.end(JSON.stringify(apiScores(url.searchParams)));
|
|
1788
|
+
}
|
|
1789
|
+
// --- v3.4: Cross-Project Search ---
|
|
1790
|
+
else if (url.pathname === '/api/search-all' && req.method === 'GET') {
|
|
1791
|
+
const result = apiSearchAll(url.searchParams);
|
|
1792
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1793
|
+
res.end(JSON.stringify(result));
|
|
1794
|
+
}
|
|
1795
|
+
// --- v3.4: Replay Export ---
|
|
1796
|
+
else if (url.pathname === '/api/export-replay' && req.method === 'GET') {
|
|
1797
|
+
const html = apiExportReplay(url.searchParams);
|
|
1798
|
+
res.writeHead(200, {
|
|
1799
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1800
|
+
'Content-Disposition': 'attachment; filename="replay-' + new Date().toISOString().slice(0, 10) + '.html"',
|
|
1801
|
+
});
|
|
1802
|
+
res.end(html);
|
|
1803
|
+
}
|
|
1804
|
+
// Server-Sent Events endpoint for real-time updates
|
|
1805
|
+
else if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
1806
|
+
if (sseClients.size >= 100) {
|
|
1807
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1808
|
+
res.end(JSON.stringify({ error: 'Too many SSE connections' }));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
res.writeHead(200, {
|
|
1812
|
+
'Content-Type': 'text/event-stream',
|
|
1813
|
+
'Cache-Control': 'no-cache',
|
|
1814
|
+
'Connection': 'keep-alive',
|
|
1815
|
+
});
|
|
1816
|
+
res.write(`data: connected\n\n`);
|
|
1817
|
+
sseClients.add(res);
|
|
1818
|
+
req.on('close', () => sseClients.delete(res));
|
|
1819
|
+
}
|
|
1820
|
+
// --- Mod system API ---
|
|
1821
|
+
else if (url.pathname === '/api/mods' && req.method === 'GET') {
|
|
1822
|
+
const registryFile = path.join(__dirname, 'mods', 'registry.json');
|
|
1823
|
+
const registry = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, 'utf8')) : { version: 1, mods: {} };
|
|
1824
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1825
|
+
res.end(JSON.stringify(registry));
|
|
1826
|
+
}
|
|
1827
|
+
else if (url.pathname === '/api/mods' && req.method === 'POST') {
|
|
1828
|
+
const body = await parseBody(req);
|
|
1829
|
+
if (!body.manifest) {
|
|
1830
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1831
|
+
res.end(JSON.stringify({ error: 'Missing manifest' }));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const manifest = body.manifest;
|
|
1835
|
+
// Validate manifest
|
|
1836
|
+
const requiredFields = ['id', 'name', 'version', 'author', 'type', 'category'];
|
|
1837
|
+
const validTypes = ['accessory', 'hairstyle', 'outfit', 'character', 'environment'];
|
|
1838
|
+
const idPattern = /^[a-z0-9_-]{1,40}$/;
|
|
1839
|
+
const errors = [];
|
|
1840
|
+
for (const f of requiredFields) { if (!manifest[f]) errors.push('Missing: ' + f); }
|
|
1841
|
+
if (manifest.id && !idPattern.test(manifest.id)) errors.push('Invalid id format');
|
|
1842
|
+
if (manifest.type && !validTypes.includes(manifest.type)) errors.push('Invalid type');
|
|
1843
|
+
if (!manifest.asset || !manifest.asset.format) errors.push('Missing asset definition');
|
|
1844
|
+
if (manifest.asset && !['glb', 'gltf', 'procedural'].includes(manifest.asset.format)) errors.push('Invalid asset format');
|
|
1845
|
+
if (errors.length > 0) {
|
|
1846
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1847
|
+
res.end(JSON.stringify({ error: 'Validation failed', errors }));
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
// Check for file if GLB mod (must be uploaded separately)
|
|
1851
|
+
if (manifest.asset.format === 'glb' || manifest.asset.format === 'gltf') {
|
|
1852
|
+
const modDir = path.join(__dirname, 'mods', manifest.id);
|
|
1853
|
+
if (!fs.existsSync(modDir)) fs.mkdirSync(modDir, { recursive: true });
|
|
1854
|
+
// If glbData is provided as base64, write it
|
|
1855
|
+
if (body.glbData) {
|
|
1856
|
+
const allowedExts = ['.glb', '.gltf', '.json', '.png'];
|
|
1857
|
+
const assetFile = manifest.asset.file || (manifest.id + '.glb');
|
|
1858
|
+
const ext = path.extname(assetFile);
|
|
1859
|
+
if (!allowedExts.includes(ext)) {
|
|
1860
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1861
|
+
res.end(JSON.stringify({ error: 'File type not allowed: ' + ext }));
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
// Size check
|
|
1865
|
+
const typeLimits = { accessory: 200*1024, hairstyle: 300*1024, outfit: 500*1024, character: 1024*1024, environment: 2*1024*1024 };
|
|
1866
|
+
const maxSize = typeLimits[manifest.type] || 200*1024;
|
|
1867
|
+
const buf = Buffer.from(body.glbData, 'base64');
|
|
1868
|
+
if (buf.length > maxSize) {
|
|
1869
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1870
|
+
res.end(JSON.stringify({ error: 'File too large: ' + (buf.length/1024).toFixed(0) + 'KB > ' + (maxSize/1024) + 'KB limit' }));
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
// GLB magic bytes check
|
|
1874
|
+
if (ext === '.glb' && buf.length >= 4) {
|
|
1875
|
+
const magic = buf.readUInt32LE(0);
|
|
1876
|
+
if (magic !== 0x46546C67) {
|
|
1877
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1878
|
+
res.end(JSON.stringify({ error: 'Invalid GLB file (bad magic bytes)' }));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
fs.writeFileSync(path.join(modDir, assetFile), buf);
|
|
1883
|
+
manifest.asset.file = assetFile;
|
|
1884
|
+
}
|
|
1885
|
+
// Write manifest
|
|
1886
|
+
fs.writeFileSync(path.join(modDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
1887
|
+
}
|
|
1888
|
+
// Add to registry
|
|
1889
|
+
await withFileLock(path.join(__dirname, 'mods', 'registry.json'), () => {
|
|
1890
|
+
const registryFile = path.join(__dirname, 'mods', 'registry.json');
|
|
1891
|
+
const registry = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, 'utf8')) : { version: 1, mods: {} };
|
|
1892
|
+
if (registry.mods[manifest.id]) {
|
|
1893
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1894
|
+
res.end(JSON.stringify({ error: 'Mod ID already exists: ' + manifest.id }));
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
registry.mods[manifest.id] = manifest;
|
|
1898
|
+
fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
|
1899
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1900
|
+
res.end(JSON.stringify({ success: true, id: manifest.id }));
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
else if (url.pathname === '/api/mods' && req.method === 'DELETE') {
|
|
1904
|
+
const body = await parseBody(req);
|
|
1905
|
+
if (!body.id) {
|
|
1906
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1907
|
+
res.end(JSON.stringify({ error: 'Missing mod id' }));
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const modId = body.id;
|
|
1911
|
+
if (!/^[a-z0-9_-]{1,40}$/.test(modId)) {
|
|
1912
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1913
|
+
res.end(JSON.stringify({ error: 'Invalid mod id' }));
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
// Don't allow deleting built-in mods
|
|
1917
|
+
if (modId.startsWith('builtin-')) {
|
|
1918
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1919
|
+
res.end(JSON.stringify({ error: 'Cannot delete built-in mods' }));
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
await withFileLock(path.join(__dirname, 'mods', 'registry.json'), () => {
|
|
1923
|
+
const registryFile = path.join(__dirname, 'mods', 'registry.json');
|
|
1924
|
+
const registry = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, 'utf8')) : { version: 1, mods: {} };
|
|
1925
|
+
if (!registry.mods[modId]) {
|
|
1926
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1927
|
+
res.end(JSON.stringify({ error: 'Mod not found: ' + modId }));
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
delete registry.mods[modId];
|
|
1931
|
+
fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
|
1932
|
+
// Clean up mod directory if it exists
|
|
1933
|
+
const modDir = path.join(__dirname, 'mods', modId);
|
|
1934
|
+
if (fs.existsSync(modDir)) {
|
|
1935
|
+
try { fs.rmSync(modDir, { recursive: true }); } catch (e) { /* best effort */ }
|
|
1936
|
+
}
|
|
1937
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1938
|
+
res.end(JSON.stringify({ success: true }));
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
else {
|
|
1942
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1943
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1944
|
+
}
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
console.error('Server error:', err.message);
|
|
1947
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1948
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
// --- Server-Sent Events for real-time updates ---
|
|
1953
|
+
// Watches data files and pushes updates to connected clients instantly
|
|
1954
|
+
const sseClients = new Set();
|
|
1955
|
+
|
|
1956
|
+
function sseNotifyAll() {
|
|
1957
|
+
// Generate notifications from agent state changes
|
|
1958
|
+
try {
|
|
1959
|
+
const agents = readJson(filePath('agents.json'));
|
|
1960
|
+
generateNotifications(agents);
|
|
1961
|
+
} catch {}
|
|
1962
|
+
|
|
1963
|
+
for (const res of sseClients) {
|
|
1964
|
+
try {
|
|
1965
|
+
res.write(`data: update\n\n`);
|
|
1966
|
+
} catch {
|
|
1967
|
+
sseClients.delete(res);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// Watch data directory for changes and push SSE notifications
|
|
1973
|
+
let fsWatcher = null;
|
|
1974
|
+
let sseDebounceTimer = null;
|
|
1975
|
+
|
|
1976
|
+
function startFileWatcher() {
|
|
1977
|
+
const dataDir = resolveDataDir();
|
|
1978
|
+
if (!fs.existsSync(dataDir)) return;
|
|
1979
|
+
try {
|
|
1980
|
+
fsWatcher = fs.watch(dataDir, { persistent: false }, () => {
|
|
1981
|
+
// Debounce — multiple file changes may fire rapidly
|
|
1982
|
+
if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
|
|
1983
|
+
sseDebounceTimer = setTimeout(() => sseNotifyAll(), 200);
|
|
1984
|
+
});
|
|
1985
|
+
fsWatcher.on('error', () => {}); // ignore watch errors
|
|
1986
|
+
} catch {}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
startFileWatcher();
|
|
1990
|
+
|
|
1991
|
+
server.on('error', (err) => {
|
|
1992
|
+
if (err.code === 'EADDRINUSE') {
|
|
1993
|
+
console.error(`\n Error: Port ${PORT} is already in use.`);
|
|
1994
|
+
console.error(` Another dashboard may be running. Try:`);
|
|
1995
|
+
console.error(` - Kill it: npx kill-port ${PORT}`);
|
|
1996
|
+
console.error(` - Or use a different port: AGENT_BRIDGE_PORT=3001 npx let-them-talk dashboard\n`);
|
|
1997
|
+
process.exit(1);
|
|
1998
|
+
}
|
|
1999
|
+
throw err;
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
|
|
2003
|
+
const dataDir = resolveDataDir();
|
|
2004
|
+
const lanIP = getLanIP();
|
|
2005
|
+
console.log('');
|
|
2006
|
+
console.log(' Let Them Talk - Agent Bridge Dashboard v3.5.1');
|
|
2007
|
+
console.log(' ============================================');
|
|
2008
|
+
console.log(' Dashboard: http://localhost:' + PORT);
|
|
2009
|
+
if (LAN_MODE && lanIP) {
|
|
2010
|
+
console.log(' LAN access: http://' + lanIP + ':' + PORT);
|
|
2011
|
+
console.log(' WARNING: LAN mode enabled — accessible to anyone on your network');
|
|
2012
|
+
}
|
|
2013
|
+
console.log(' Data dir: ' + dataDir);
|
|
2014
|
+
console.log(' Projects: ' + getProjects().length + ' registered');
|
|
2015
|
+
console.log(' Updates: SSE (real-time) + polling fallback (2s)');
|
|
2016
|
+
console.log('');
|
|
2017
|
+
});
|