let-them-talk 4.2.0 → 5.2.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 +640 -540
- package/README.md +592 -415
- package/cli.js +1089 -589
- package/conversation-templates/autonomous-feature.json +22 -0
- package/conversation-templates/code-review.json +21 -11
- package/conversation-templates/debug-squad.json +21 -11
- package/conversation-templates/feature-build.json +21 -11
- package/conversation-templates/research-write.json +21 -11
- package/dashboard.html +9250 -7771
- package/dashboard.js +1232 -29
- package/office/agents.js +148 -4
- package/office/animation.js +68 -0
- package/office/assets.js +431 -0
- package/office/builder.js +355 -0
- package/office/building-interior.js +261 -0
- package/office/campus-env.js +119 -23
- package/office/car-hud.js +368 -0
- package/office/daynight.js +221 -0
- package/office/economy-hud.js +432 -0
- package/office/economy-ui.js +238 -0
- package/office/environment.js +818 -808
- package/office/face.js +65 -0
- package/office/fast-travel.js +215 -0
- package/office/hq-building.js +295 -0
- package/office/index.js +1095 -423
- package/office/instancing.js +160 -0
- package/office/lod-manager.js +165 -0
- package/office/multiplayer-hud.js +428 -0
- package/office/net-client.js +299 -0
- package/office/particles.js +172 -0
- package/office/player.js +658 -436
- package/office/post-processing.js +82 -0
- package/office/sky.js +319 -0
- package/office/street-furniture.js +308 -0
- package/office/vehicle.js +455 -0
- package/office/world-save.js +91 -0
- package/package.json +59 -59
- package/server.js +7190 -4685
- package/conversation-templates/managed-team.json +0 -12
package/dashboard.js
CHANGED
|
@@ -24,7 +24,7 @@ let LAN_TOKEN = null;
|
|
|
24
24
|
function generateLanToken() {
|
|
25
25
|
const crypto = require('crypto');
|
|
26
26
|
LAN_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
27
|
-
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN); } catch {}
|
|
27
|
+
try { fs.writeFileSync(LAN_TOKEN_FILE, LAN_TOKEN, { mode: 0o600 }); } catch {}
|
|
28
28
|
return LAN_TOKEN;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -128,7 +128,7 @@ function readJsonl(file) {
|
|
|
128
128
|
if (!fs.existsSync(file)) return [];
|
|
129
129
|
const content = fs.readFileSync(file, 'utf8').trim();
|
|
130
130
|
if (!content) return [];
|
|
131
|
-
return content.split(
|
|
131
|
+
return content.split(/\r?\n/).map(line => {
|
|
132
132
|
try { return JSON.parse(line); } catch { return null; }
|
|
133
133
|
}).filter(Boolean);
|
|
134
134
|
}
|
|
@@ -138,15 +138,50 @@ function readJson(file) {
|
|
|
138
138
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// Economy helpers
|
|
142
|
+
function getEconomyLedger(projectPath) {
|
|
143
|
+
const ledgerFile = filePath('economy.jsonl', projectPath);
|
|
144
|
+
if (!fs.existsSync(ledgerFile)) return [];
|
|
145
|
+
try {
|
|
146
|
+
return fs.readFileSync(ledgerFile, 'utf8').trim().split(/\r?\n/)
|
|
147
|
+
.filter(l => l.trim()).map(l => JSON.parse(l));
|
|
148
|
+
} catch { return []; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getBalances(projectPath) {
|
|
152
|
+
const ledger = getEconomyLedger(projectPath);
|
|
153
|
+
const balances = {};
|
|
154
|
+
for (const entry of ledger) {
|
|
155
|
+
if (!balances[entry.agent]) balances[entry.agent] = 0;
|
|
156
|
+
balances[entry.agent] += entry.amount;
|
|
157
|
+
}
|
|
158
|
+
return balances;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function appendEconomyEntry(projectPath, entry) {
|
|
162
|
+
const ledgerFile = filePath('economy.jsonl', projectPath);
|
|
163
|
+
const line = JSON.stringify({ ...entry, timestamp: new Date().toISOString() }) + '\n';
|
|
164
|
+
fs.appendFileSync(ledgerFile, line);
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
function isPidAlive(pid, lastActivity) {
|
|
168
|
+
const STALE_THRESHOLD = 60000; // 60s — if heartbeat updated within this, agent is alive
|
|
169
|
+
|
|
170
|
+
// PRIORITY 1: Trust heartbeat freshness over PID status
|
|
171
|
+
// Heartbeats are written by the actual running process — if fresh, agent is alive
|
|
172
|
+
// regardless of whether process.kill can see the PID
|
|
173
|
+
if (lastActivity) {
|
|
174
|
+
const stale = Date.now() - new Date(lastActivity).getTime();
|
|
175
|
+
if (stale < STALE_THRESHOLD) return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// PRIORITY 2: If heartbeat is stale, check PID as fallback
|
|
142
179
|
try {
|
|
143
180
|
process.kill(pid, 0);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return true;
|
|
149
|
-
} catch { return false; }
|
|
181
|
+
return true; // PID exists — alive even with stale heartbeat
|
|
182
|
+
} catch {
|
|
183
|
+
return false; // PID dead AND heartbeat stale — truly dead
|
|
184
|
+
}
|
|
150
185
|
}
|
|
151
186
|
|
|
152
187
|
// --- Default avatar helpers ---
|
|
@@ -203,15 +238,32 @@ function apiHistory(query) {
|
|
|
203
238
|
history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
204
239
|
|
|
205
240
|
const acks = readJson(filePath('acks.json', projectPath));
|
|
206
|
-
const limit = parseInt(query.get('limit') || '500', 10);
|
|
241
|
+
const limit = Math.min(parseInt(query.get('limit') || '500', 10), 1000);
|
|
242
|
+
const page = parseInt(query.get('page') || '0', 10);
|
|
207
243
|
const threadId = query.get('thread_id');
|
|
208
244
|
|
|
209
245
|
let messages = history;
|
|
210
246
|
if (threadId) {
|
|
211
247
|
messages = messages.filter(m => m.thread_id === threadId || m.id === threadId);
|
|
212
248
|
}
|
|
213
|
-
|
|
249
|
+
|
|
250
|
+
// Scale fix: pagination support for large histories
|
|
251
|
+
const total = messages.length;
|
|
252
|
+
if (page > 0) {
|
|
253
|
+
// Page-based: page 1 = most recent, page 2 = older, etc.
|
|
254
|
+
const start = Math.max(0, total - (page * limit));
|
|
255
|
+
const end = Math.max(0, total - ((page - 1) * limit));
|
|
256
|
+
messages = messages.slice(start, end);
|
|
257
|
+
} else {
|
|
258
|
+
// Default: last N messages (backward compatible)
|
|
259
|
+
messages = messages.slice(-limit);
|
|
260
|
+
}
|
|
261
|
+
|
|
214
262
|
messages.forEach(m => { m.acked = !!acks[m.id]; });
|
|
263
|
+
// Include pagination metadata when page is requested
|
|
264
|
+
if (page > 0) {
|
|
265
|
+
return { messages, total, page, limit, pages: Math.ceil(total / limit) };
|
|
266
|
+
}
|
|
215
267
|
return messages;
|
|
216
268
|
}
|
|
217
269
|
|
|
@@ -230,7 +282,7 @@ function apiChannels(query) {
|
|
|
230
282
|
try {
|
|
231
283
|
if (fs.existsSync(msgFile)) {
|
|
232
284
|
const content = fs.readFileSync(msgFile, 'utf8').trim();
|
|
233
|
-
if (content) msgCount = content.split(
|
|
285
|
+
if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
234
286
|
}
|
|
235
287
|
} catch {}
|
|
236
288
|
result[name] = { description: ch.description || '', members: ch.members, message_count: msgCount };
|
|
@@ -243,6 +295,23 @@ function apiAgents(query) {
|
|
|
243
295
|
const agents = readJson(filePath('agents.json', projectPath));
|
|
244
296
|
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
245
297
|
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
298
|
+
|
|
299
|
+
// Merge per-agent heartbeat files — agents write these during listen loops
|
|
300
|
+
// Without this merge, agents show as dead because agents.json has stale last_activity
|
|
301
|
+
const dataDir = resolveDataDir(projectPath);
|
|
302
|
+
try {
|
|
303
|
+
const hbFiles = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-') && f.endsWith('.json'));
|
|
304
|
+
for (const f of hbFiles) {
|
|
305
|
+
const name = f.slice(10, -5); // 'heartbeat-Backend.json' → 'Backend'
|
|
306
|
+
if (agents[name]) {
|
|
307
|
+
try {
|
|
308
|
+
const hb = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'));
|
|
309
|
+
if (hb.last_activity) agents[name].last_activity = hb.last_activity;
|
|
310
|
+
if (hb.pid) agents[name].pid = hb.pid;
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
246
315
|
const result = {};
|
|
247
316
|
|
|
248
317
|
// Build last message timestamp per agent from history
|
|
@@ -256,6 +325,7 @@ function apiAgents(query) {
|
|
|
256
325
|
const lastActivity = info.last_activity || info.timestamp;
|
|
257
326
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
258
327
|
const profile = profiles[name] || {};
|
|
328
|
+
const isLocal = (() => { try { process.kill(info.pid, 0); return true; } catch { return false; } })();
|
|
259
329
|
result[name] = {
|
|
260
330
|
pid: info.pid,
|
|
261
331
|
alive,
|
|
@@ -273,6 +343,8 @@ function apiAgents(query) {
|
|
|
273
343
|
role: profile.role || '',
|
|
274
344
|
bio: profile.bio || '',
|
|
275
345
|
appearance: profile.appearance || {},
|
|
346
|
+
hostname: info.hostname || null,
|
|
347
|
+
is_remote: !isLocal && alive,
|
|
276
348
|
};
|
|
277
349
|
// Include workspace status for agent intent board
|
|
278
350
|
try {
|
|
@@ -546,6 +618,7 @@ function apiSearchAll(query) {
|
|
|
546
618
|
let total = 0;
|
|
547
619
|
|
|
548
620
|
for (const proj of allProjects) {
|
|
621
|
+
if (proj.path && !validateProjectPath(proj.path)) continue;
|
|
549
622
|
const history = readJsonl(filePath('history.jsonl', proj.path));
|
|
550
623
|
const matches = [];
|
|
551
624
|
for (const m of history) {
|
|
@@ -620,7 +693,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
|
|
620
693
|
</div></div>
|
|
621
694
|
<div class="messages" id="messages"></div>
|
|
622
695
|
<script>
|
|
623
|
-
var msgs=${messagesJson};
|
|
696
|
+
var msgs=${messagesJson.replace(/<\//g, '<\\/')};
|
|
624
697
|
var idx=0,playing=true,timer=null,speed=1000;
|
|
625
698
|
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>')}
|
|
626
699
|
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
@@ -667,6 +740,115 @@ function apiReset(query) {
|
|
|
667
740
|
return { success: true };
|
|
668
741
|
}
|
|
669
742
|
|
|
743
|
+
function apiClearMessages(query) {
|
|
744
|
+
const projectPath = query.get('project') || null;
|
|
745
|
+
const dataDir = resolveDataDir(projectPath);
|
|
746
|
+
for (const f of ['messages.jsonl', 'history.jsonl']) {
|
|
747
|
+
const p = path.join(dataDir, f);
|
|
748
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
749
|
+
}
|
|
750
|
+
if (fs.existsSync(dataDir)) {
|
|
751
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
752
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
753
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { success: true };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function apiNewConversation(query) {
|
|
761
|
+
const projectPath = query.get('project') || null;
|
|
762
|
+
const dataDir = resolveDataDir(projectPath);
|
|
763
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
764
|
+
if (!fs.existsSync(convDir)) fs.mkdirSync(convDir, { recursive: true });
|
|
765
|
+
const now = new Date();
|
|
766
|
+
const stamp = now.toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '') + '-' + Math.random().toString(36).slice(2, 6);
|
|
767
|
+
const baseName = 'conversation-' + stamp;
|
|
768
|
+
const msgSrc = path.join(dataDir, 'messages.jsonl');
|
|
769
|
+
const histSrc = path.join(dataDir, 'history.jsonl');
|
|
770
|
+
if (fs.existsSync(msgSrc)) fs.copyFileSync(msgSrc, path.join(convDir, baseName + '.jsonl'));
|
|
771
|
+
if (fs.existsSync(histSrc)) fs.copyFileSync(histSrc, path.join(convDir, baseName + '-history.jsonl'));
|
|
772
|
+
// Clean up current files
|
|
773
|
+
if (fs.existsSync(msgSrc)) fs.unlinkSync(msgSrc);
|
|
774
|
+
if (fs.existsSync(histSrc)) fs.unlinkSync(histSrc);
|
|
775
|
+
if (fs.existsSync(dataDir)) {
|
|
776
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
777
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
778
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return { success: true, archived: baseName };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function apiListConversations(query) {
|
|
786
|
+
const projectPath = query.get('project') || null;
|
|
787
|
+
const dataDir = resolveDataDir(projectPath);
|
|
788
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
789
|
+
if (!fs.existsSync(convDir)) return { conversations: [] };
|
|
790
|
+
const files = fs.readdirSync(convDir).filter(f => f.startsWith('conversation-') && f.endsWith('.jsonl') && !f.endsWith('-history.jsonl'));
|
|
791
|
+
const conversations = files.map(f => {
|
|
792
|
+
const name = f.replace('.jsonl', '');
|
|
793
|
+
const dateStr = name.replace('conversation-', '').replace(/-/g, function(m, i) {
|
|
794
|
+
// First 2 dashes are date separators, 3rd is T separator, rest are time separators
|
|
795
|
+
return m;
|
|
796
|
+
});
|
|
797
|
+
// Parse date from stamp: YYYY-MM-DDTHH-MM-SS
|
|
798
|
+
const parts = name.replace('conversation-', '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})$/);
|
|
799
|
+
let date = '';
|
|
800
|
+
if (parts) {
|
|
801
|
+
date = parts[1] + '-' + parts[2] + '-' + parts[3] + 'T' + parts[4] + ':' + parts[5] + ':' + parts[6];
|
|
802
|
+
}
|
|
803
|
+
let messageCount = 0;
|
|
804
|
+
try {
|
|
805
|
+
const content = fs.readFileSync(path.join(convDir, f), 'utf8').trim();
|
|
806
|
+
if (content) messageCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
807
|
+
} catch {}
|
|
808
|
+
return { name, date, messageCount };
|
|
809
|
+
});
|
|
810
|
+
conversations.sort((a, b) => b.date.localeCompare(a.date));
|
|
811
|
+
return { conversations };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function apiLoadConversation(query) {
|
|
815
|
+
const projectPath = query.get('project') || null;
|
|
816
|
+
const name = query.get('name');
|
|
817
|
+
if (!name || /[^a-zA-Z0-9_-]/.test(name) || name.length > 100) {
|
|
818
|
+
return { error: 'Invalid conversation name' };
|
|
819
|
+
}
|
|
820
|
+
const dataDir = resolveDataDir(projectPath);
|
|
821
|
+
const convDir = path.join(dataDir, 'conversations');
|
|
822
|
+
const msgFile = path.join(convDir, name + '.jsonl');
|
|
823
|
+
const histFile = path.join(convDir, name + '-history.jsonl');
|
|
824
|
+
if (!fs.existsSync(msgFile)) return { error: 'Conversation not found' };
|
|
825
|
+
// Use file lock to prevent corruption during concurrent writes
|
|
826
|
+
const lockPath = path.join(dataDir, 'messages.jsonl.lock');
|
|
827
|
+
try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); } catch {
|
|
828
|
+
return { error: 'Messages file is locked by another operation. Try again.' };
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
fs.copyFileSync(msgFile, path.join(dataDir, 'messages.jsonl'));
|
|
832
|
+
if (fs.existsSync(histFile)) {
|
|
833
|
+
fs.copyFileSync(histFile, path.join(dataDir, 'history.jsonl'));
|
|
834
|
+
} else {
|
|
835
|
+
const hp = path.join(dataDir, 'history.jsonl');
|
|
836
|
+
if (fs.existsSync(hp)) fs.unlinkSync(hp);
|
|
837
|
+
}
|
|
838
|
+
// Clear stale consumed offsets
|
|
839
|
+
if (fs.existsSync(dataDir)) {
|
|
840
|
+
for (const f of fs.readdirSync(dataDir)) {
|
|
841
|
+
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
842
|
+
fs.unlinkSync(path.join(dataDir, f));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} finally {
|
|
847
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
848
|
+
}
|
|
849
|
+
return { success: true };
|
|
850
|
+
}
|
|
851
|
+
|
|
670
852
|
// Inject a message from the dashboard (system message or nudge to an agent)
|
|
671
853
|
function apiInjectMessage(body, query) {
|
|
672
854
|
const projectPath = query.get('project') || null;
|
|
@@ -677,6 +859,12 @@ function apiInjectMessage(body, query) {
|
|
|
677
859
|
if (!body.to || !body.content) {
|
|
678
860
|
return { error: 'Missing "to" and/or "content" fields' };
|
|
679
861
|
}
|
|
862
|
+
if (typeof body.content !== 'string' || body.content.length > 100000) {
|
|
863
|
+
return { error: 'Message content too long (max 100KB)' };
|
|
864
|
+
}
|
|
865
|
+
if (body.to !== '__all__' && !/^[a-zA-Z0-9_-]{1,20}$/.test(body.to)) {
|
|
866
|
+
return { error: 'Invalid agent name' };
|
|
867
|
+
}
|
|
680
868
|
|
|
681
869
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
682
870
|
const fromName = 'Dashboard';
|
|
@@ -725,6 +913,13 @@ function apiProjects() {
|
|
|
725
913
|
function apiAddProject(body) {
|
|
726
914
|
if (!body.path) return { error: 'Missing "path" field' };
|
|
727
915
|
const absPath = path.resolve(body.path);
|
|
916
|
+
|
|
917
|
+
// Reject root directories and system paths
|
|
918
|
+
const normalized = absPath.replace(/\\/g, '/');
|
|
919
|
+
if (normalized === '/' || normalized === 'C:/' || /^[A-Z]:\/$/i.test(normalized) || /^[A-Z]:\/Windows/i.test(normalized) || normalized.startsWith('/etc') || normalized.startsWith('/usr') || normalized.startsWith('/sys')) {
|
|
920
|
+
return { error: 'Cannot monitor system directories' };
|
|
921
|
+
}
|
|
922
|
+
|
|
728
923
|
if (!fs.existsSync(absPath)) return { error: `Path does not exist: ${absPath}` };
|
|
729
924
|
|
|
730
925
|
// Any existing directory can be added as a project — user explicitly chose it
|
|
@@ -945,6 +1140,80 @@ function apiUpdateTask(body, query) {
|
|
|
945
1140
|
return { success: true, task_id: task.id, status: task.status };
|
|
946
1141
|
}
|
|
947
1142
|
|
|
1143
|
+
// Rules API
|
|
1144
|
+
function apiRules(query) {
|
|
1145
|
+
const projectPath = query.get('project') || null;
|
|
1146
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1147
|
+
if (!fs.existsSync(rulesFile)) return [];
|
|
1148
|
+
try { return JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch { return []; }
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function apiAddRule(body, query) {
|
|
1152
|
+
const projectPath = query.get('project') || null;
|
|
1153
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1154
|
+
if (!body.text || !body.text.trim()) return { error: 'Rule text is required' };
|
|
1155
|
+
|
|
1156
|
+
const crypto = require('crypto');
|
|
1157
|
+
let rules = [];
|
|
1158
|
+
if (fs.existsSync(rulesFile)) {
|
|
1159
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const rule = {
|
|
1163
|
+
id: 'rule_' + crypto.randomBytes(6).toString('hex'),
|
|
1164
|
+
text: body.text.trim(),
|
|
1165
|
+
category: body.category || 'general',
|
|
1166
|
+
priority: body.priority || 'normal',
|
|
1167
|
+
created_by: body.created_by || 'Dashboard',
|
|
1168
|
+
created_at: new Date().toISOString(),
|
|
1169
|
+
active: true
|
|
1170
|
+
};
|
|
1171
|
+
rules.push(rule);
|
|
1172
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1173
|
+
return { success: true, rule };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function apiUpdateRule(body, query) {
|
|
1177
|
+
const projectPath = query.get('project') || null;
|
|
1178
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1179
|
+
if (!body.rule_id) return { error: 'Missing rule_id' };
|
|
1180
|
+
|
|
1181
|
+
let rules = [];
|
|
1182
|
+
if (fs.existsSync(rulesFile)) {
|
|
1183
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const rule = rules.find(r => r.id === body.rule_id);
|
|
1187
|
+
if (!rule) return { error: 'Rule not found' };
|
|
1188
|
+
|
|
1189
|
+
if (body.text !== undefined) rule.text = body.text.trim();
|
|
1190
|
+
if (body.category !== undefined) rule.category = body.category;
|
|
1191
|
+
if (body.priority !== undefined) rule.priority = body.priority;
|
|
1192
|
+
if (body.active !== undefined) rule.active = body.active;
|
|
1193
|
+
rule.updated_at = new Date().toISOString();
|
|
1194
|
+
|
|
1195
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1196
|
+
return { success: true, rule };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function apiDeleteRule(body, query) {
|
|
1200
|
+
const projectPath = query.get('project') || null;
|
|
1201
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
1202
|
+
if (!body.rule_id) return { error: 'Missing rule_id' };
|
|
1203
|
+
|
|
1204
|
+
let rules = [];
|
|
1205
|
+
if (fs.existsSync(rulesFile)) {
|
|
1206
|
+
try { rules = JSON.parse(fs.readFileSync(rulesFile, 'utf8')); } catch {}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const idx = rules.findIndex(r => r.id === body.rule_id);
|
|
1210
|
+
if (idx === -1) return { error: 'Rule not found' };
|
|
1211
|
+
rules.splice(idx, 1);
|
|
1212
|
+
|
|
1213
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules, null, 2));
|
|
1214
|
+
return { success: true };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
948
1217
|
// Auto-discover .agent-bridge directories nearby
|
|
949
1218
|
function apiDiscover() {
|
|
950
1219
|
const found = [];
|
|
@@ -1086,7 +1355,7 @@ async function apiEditMessage(body, query) {
|
|
|
1086
1355
|
// Update in history.jsonl (locked)
|
|
1087
1356
|
await withFileLock(historyFile, () => {
|
|
1088
1357
|
if (fs.existsSync(historyFile)) {
|
|
1089
|
-
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(
|
|
1358
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/).filter(Boolean);
|
|
1090
1359
|
const updated = lines.map(line => {
|
|
1091
1360
|
try {
|
|
1092
1361
|
const msg = JSON.parse(line);
|
|
@@ -1094,6 +1363,7 @@ async function apiEditMessage(body, query) {
|
|
|
1094
1363
|
found = true;
|
|
1095
1364
|
if (!msg.edit_history) msg.edit_history = [];
|
|
1096
1365
|
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
1366
|
+
if (msg.edit_history.length > 10) msg.edit_history = msg.edit_history.slice(-10);
|
|
1097
1367
|
msg.content = content;
|
|
1098
1368
|
msg.edited = true;
|
|
1099
1369
|
msg.edited_at = now;
|
|
@@ -1112,7 +1382,7 @@ async function apiEditMessage(body, query) {
|
|
|
1112
1382
|
if (fs.existsSync(messagesFile)) {
|
|
1113
1383
|
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
1114
1384
|
if (raw) {
|
|
1115
|
-
const lines = raw.split(
|
|
1385
|
+
const lines = raw.split(/\r?\n/);
|
|
1116
1386
|
const updated = lines.map(line => {
|
|
1117
1387
|
try {
|
|
1118
1388
|
const msg = JSON.parse(line);
|
|
@@ -1151,7 +1421,7 @@ async function apiDeleteMessage(body, query) {
|
|
|
1151
1421
|
// Find the message and remove from history.jsonl (locked)
|
|
1152
1422
|
await withFileLock(historyFile, () => {
|
|
1153
1423
|
if (fs.existsSync(historyFile)) {
|
|
1154
|
-
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(
|
|
1424
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/);
|
|
1155
1425
|
for (const line of lines) {
|
|
1156
1426
|
try {
|
|
1157
1427
|
const msg = JSON.parse(line);
|
|
@@ -1182,7 +1452,7 @@ async function apiDeleteMessage(body, query) {
|
|
|
1182
1452
|
// Remove from messages.jsonl (locked independently)
|
|
1183
1453
|
await withFileLock(messagesFile, () => {
|
|
1184
1454
|
if (fs.existsSync(messagesFile)) {
|
|
1185
|
-
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(
|
|
1455
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(/\r?\n/);
|
|
1186
1456
|
const filtered = lines.filter(line => {
|
|
1187
1457
|
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1188
1458
|
});
|
|
@@ -1331,6 +1601,28 @@ function parseBody(req) {
|
|
|
1331
1601
|
});
|
|
1332
1602
|
}
|
|
1333
1603
|
|
|
1604
|
+
// --- Rate limiting ---
|
|
1605
|
+
const apiRateLimits = new Map();
|
|
1606
|
+
function checkRateLimit(ip, limit = 60, windowMs = 60000) {
|
|
1607
|
+
const now = Date.now();
|
|
1608
|
+
const key = ip;
|
|
1609
|
+
if (!apiRateLimits.has(key)) apiRateLimits.set(key, []);
|
|
1610
|
+
const timestamps = apiRateLimits.get(key).filter(t => now - t < windowMs);
|
|
1611
|
+
apiRateLimits.set(key, timestamps);
|
|
1612
|
+
if (timestamps.length >= limit) return false;
|
|
1613
|
+
timestamps.push(now);
|
|
1614
|
+
return true;
|
|
1615
|
+
}
|
|
1616
|
+
// Periodic cleanup to prevent memory leak
|
|
1617
|
+
setInterval(() => {
|
|
1618
|
+
const now = Date.now();
|
|
1619
|
+
for (const [key, timestamps] of apiRateLimits) {
|
|
1620
|
+
const filtered = timestamps.filter(t => now - t < 60000);
|
|
1621
|
+
if (filtered.length === 0) apiRateLimits.delete(key);
|
|
1622
|
+
else apiRateLimits.set(key, filtered);
|
|
1623
|
+
}
|
|
1624
|
+
}, 300000).unref(); // Clean every 5 minutes, .unref() prevents zombie process
|
|
1625
|
+
|
|
1334
1626
|
const server = http.createServer(async (req, res) => {
|
|
1335
1627
|
const url = new URL(req.url, 'http://localhost:' + PORT);
|
|
1336
1628
|
|
|
@@ -1360,7 +1652,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1360
1652
|
const tokenFromQuery = url.searchParams.get('token');
|
|
1361
1653
|
const tokenFromHeader = req.headers['x-ltt-token'];
|
|
1362
1654
|
const providedToken = tokenFromHeader || tokenFromQuery;
|
|
1363
|
-
|
|
1655
|
+
const crypto = require('crypto');
|
|
1656
|
+
if (!providedToken || providedToken.length !== LAN_TOKEN.length || !crypto.timingSafeEqual(Buffer.from(providedToken), Buffer.from(LAN_TOKEN))) {
|
|
1364
1657
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1365
1658
|
res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
|
|
1366
1659
|
return;
|
|
@@ -1396,8 +1689,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1396
1689
|
// Custom header check above is the only protection layer here — allow through
|
|
1397
1690
|
// since local CLI tools (like our own `msg` command) need to work
|
|
1398
1691
|
}
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1692
|
+
const allowedSources = [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`];
|
|
1693
|
+
if (LAN_MODE && getLanIP()) allowedSources.push(`http://${getLanIP()}:${PORT}`);
|
|
1694
|
+
let sourceOrigin = '';
|
|
1695
|
+
try { sourceOrigin = source ? new URL(source).origin : ''; } catch { sourceOrigin = ''; }
|
|
1696
|
+
const isLocal = allowedSources.includes(sourceOrigin);
|
|
1697
|
+
const isLan = isLocal;
|
|
1401
1698
|
if (source && !isLocal && !isLan) {
|
|
1402
1699
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1403
1700
|
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
@@ -1405,6 +1702,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1405
1702
|
}
|
|
1406
1703
|
}
|
|
1407
1704
|
|
|
1705
|
+
// Rate limit API endpoints (only for non-localhost in LAN mode)
|
|
1706
|
+
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
1707
|
+
const isLocalhost = clientIP === '127.0.0.1' || clientIP === '::1' || clientIP === '::ffff:127.0.0.1';
|
|
1708
|
+
if (url.pathname.startsWith('/api/') && !isLocalhost && !checkRateLimit(clientIP, 300, 60000)) {
|
|
1709
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1710
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded. Try again later.' }));
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1408
1714
|
try {
|
|
1409
1715
|
// Validate project parameter on all API endpoints
|
|
1410
1716
|
const projectParam = url.searchParams.get('project');
|
|
@@ -1465,6 +1771,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1465
1771
|
} catch {}
|
|
1466
1772
|
const filePath = searchPaths.find(p => fs.existsSync(p));
|
|
1467
1773
|
if (filePath) {
|
|
1774
|
+
// Verify resolved path is within an allowed directory
|
|
1775
|
+
const resolvedFile = path.resolve(filePath);
|
|
1776
|
+
const allowedDirs = searchPaths.map(p => path.resolve(path.dirname(p)));
|
|
1777
|
+
const isAllowed = allowedDirs.some(dir => resolvedFile.startsWith(dir + path.sep) || resolvedFile === dir);
|
|
1778
|
+
if (!isAllowed) {
|
|
1779
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1780
|
+
}
|
|
1468
1781
|
const ext = path.extname(filePath);
|
|
1469
1782
|
const mimeTypes = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.wasm': 'application/wasm' };
|
|
1470
1783
|
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
@@ -1483,6 +1796,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1483
1796
|
res.writeHead(400); res.end('Bad path'); return;
|
|
1484
1797
|
}
|
|
1485
1798
|
const filePath = path.join(__dirname, 'office', officePath);
|
|
1799
|
+
const resolvedOffice = path.resolve(filePath);
|
|
1800
|
+
const allowedOfficeDir = path.resolve(path.join(__dirname, 'office'));
|
|
1801
|
+
if (!resolvedOffice.startsWith(allowedOfficeDir + path.sep) && resolvedOffice !== allowedOfficeDir) {
|
|
1802
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1803
|
+
}
|
|
1486
1804
|
if (fs.existsSync(filePath)) {
|
|
1487
1805
|
const ext = path.extname(filePath);
|
|
1488
1806
|
const mimeTypes = { '.js': 'application/javascript', '.json': 'application/json' };
|
|
@@ -1502,6 +1820,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1502
1820
|
res.writeHead(400); res.end('Bad path'); return;
|
|
1503
1821
|
}
|
|
1504
1822
|
const filePath = path.join(__dirname, 'mods', modPath);
|
|
1823
|
+
const resolvedMod = path.resolve(filePath);
|
|
1824
|
+
const allowedModDir = path.resolve(path.join(__dirname, 'mods'));
|
|
1825
|
+
if (!resolvedMod.startsWith(allowedModDir + path.sep) && resolvedMod !== allowedModDir) {
|
|
1826
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1827
|
+
}
|
|
1505
1828
|
if (fs.existsSync(filePath)) {
|
|
1506
1829
|
const ext = path.extname(filePath);
|
|
1507
1830
|
const allowedMime = { '.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.png': 'image/png' };
|
|
@@ -1522,7 +1845,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1522
1845
|
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1523
1846
|
res.writeHead(200, {
|
|
1524
1847
|
'Content-Type': 'text/html; charset=utf-8',
|
|
1525
|
-
'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'",
|
|
1848
|
+
'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'; frame-ancestors 'none'",
|
|
1849
|
+
'X-Frame-Options': 'DENY',
|
|
1850
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1851
|
+
'Referrer-Policy': 'no-referrer',
|
|
1526
1852
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1527
1853
|
'Pragma': 'no-cache',
|
|
1528
1854
|
'Expires': '0'
|
|
@@ -1584,6 +1910,133 @@ const server = http.createServer(async (req, res) => {
|
|
|
1584
1910
|
res.end(JSON.stringify({ success: true, removed: agentName }));
|
|
1585
1911
|
});
|
|
1586
1912
|
}
|
|
1913
|
+
// Respawn prompt generator — creates copy-paste prompt to revive a dead agent
|
|
1914
|
+
else if (url.pathname.startsWith('/api/agents/') && url.pathname.endsWith('/respawn-prompt') && req.method === 'GET') {
|
|
1915
|
+
const agentName = decodeURIComponent(url.pathname.split('/')[3]);
|
|
1916
|
+
// Validate agent name (prevent path traversal)
|
|
1917
|
+
if (!agentName || /[^a-zA-Z0-9_-]/.test(agentName) || agentName.length > 20) {
|
|
1918
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1919
|
+
res.end(JSON.stringify({ error: 'Invalid agent name' }));
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
1923
|
+
const dataDir = resolveDataDir(projectPath);
|
|
1924
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
1925
|
+
const profiles = readJson(filePath('profiles.json', projectPath));
|
|
1926
|
+
const tasks = readJson(filePath('tasks.json', projectPath));
|
|
1927
|
+
const config = readJson(filePath('config.json', projectPath));
|
|
1928
|
+
|
|
1929
|
+
if (!agents[agentName]) {
|
|
1930
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1931
|
+
res.end(JSON.stringify({ error: 'Agent not found: ' + agentName }));
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Gather recovery snapshot if exists
|
|
1936
|
+
const recoveryFile = path.join(dataDir, 'recovery-' + agentName + '.json');
|
|
1937
|
+
const recovery = fs.existsSync(recoveryFile) ? readJson(recoveryFile) : null;
|
|
1938
|
+
|
|
1939
|
+
// Gather profile
|
|
1940
|
+
const profile = profiles[agentName] || {};
|
|
1941
|
+
|
|
1942
|
+
// Gather active tasks assigned to this agent
|
|
1943
|
+
const taskList = Array.isArray(tasks) ? tasks : [];
|
|
1944
|
+
const activeTasks = taskList.filter(t => t.assignee === agentName && (t.status === 'in_progress' || t.status === 'pending'));
|
|
1945
|
+
const completedTasks = taskList.filter(t => t.assignee === agentName && t.status === 'done').slice(-5);
|
|
1946
|
+
|
|
1947
|
+
// Gather recent history context (last 15 messages)
|
|
1948
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
1949
|
+
const recentHistory = history.slice(-15).map(m => `[${m.from}→${m.to}]: ${(m.content || '').substring(0, 150)}`).join('\n');
|
|
1950
|
+
|
|
1951
|
+
// Gather who's online
|
|
1952
|
+
const onlineAgents = Object.entries(agents)
|
|
1953
|
+
.filter(([n, a]) => isPidAlive(a.pid, a.last_activity) && n !== agentName)
|
|
1954
|
+
.map(([n]) => n);
|
|
1955
|
+
|
|
1956
|
+
// Gather workspace status
|
|
1957
|
+
let workspaceStatus = '';
|
|
1958
|
+
try {
|
|
1959
|
+
const wsPath = path.join(dataDir, 'workspaces', agentName + '.json');
|
|
1960
|
+
if (fs.existsSync(wsPath)) {
|
|
1961
|
+
const ws = JSON.parse(fs.readFileSync(wsPath, 'utf8'));
|
|
1962
|
+
if (ws._status) workspaceStatus = ws._status;
|
|
1963
|
+
}
|
|
1964
|
+
} catch {}
|
|
1965
|
+
|
|
1966
|
+
// Build the respawn prompt
|
|
1967
|
+
const mode = config.conversation_mode || 'group';
|
|
1968
|
+
let prompt = `You are resuming as agent "${agentName}" in a multi-agent team using Let Them Talk (MCP agent bridge).\n\n`;
|
|
1969
|
+
|
|
1970
|
+
if (profile.role) prompt += `**Your role:** ${profile.role}\n`;
|
|
1971
|
+
if (profile.bio) prompt += `**Your bio:** ${profile.bio}\n`;
|
|
1972
|
+
prompt += '\n';
|
|
1973
|
+
|
|
1974
|
+
prompt += `**Conversation mode:** ${mode}\n`;
|
|
1975
|
+
prompt += `**Agents currently online:** ${onlineAgents.length > 0 ? onlineAgents.join(', ') : 'none'}\n\n`;
|
|
1976
|
+
|
|
1977
|
+
if (activeTasks.length > 0) {
|
|
1978
|
+
prompt += `**Your active tasks:**\n`;
|
|
1979
|
+
for (const t of activeTasks) {
|
|
1980
|
+
prompt += `- [${t.status}] ${t.title}${t.description ? ' — ' + t.description.substring(0, 200) : ''}\n`;
|
|
1981
|
+
}
|
|
1982
|
+
prompt += '\n';
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
if (completedTasks.length > 0) {
|
|
1986
|
+
prompt += `**Tasks you completed before disconnect:**\n`;
|
|
1987
|
+
for (const t of completedTasks) {
|
|
1988
|
+
prompt += `- ${t.title}\n`;
|
|
1989
|
+
}
|
|
1990
|
+
prompt += '\n';
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
if (recovery) {
|
|
1994
|
+
if (recovery.locked_files && recovery.locked_files.length > 0) {
|
|
1995
|
+
prompt += `**Files you had locked:** ${recovery.locked_files.join(', ')} — unlock these or continue editing them.\n\n`;
|
|
1996
|
+
}
|
|
1997
|
+
if (recovery.channels && recovery.channels.length > 0) {
|
|
1998
|
+
prompt += `**Channels you were in:** ${recovery.channels.join(', ')}\n\n`;
|
|
1999
|
+
}
|
|
2000
|
+
if (recovery.decisions_made && recovery.decisions_made.length > 0) {
|
|
2001
|
+
prompt += `**Decisions you made:**\n`;
|
|
2002
|
+
for (const d of recovery.decisions_made) {
|
|
2003
|
+
prompt += `- ${d.decision}${d.reasoning ? ' (reason: ' + d.reasoning + ')' : ''}\n`;
|
|
2004
|
+
}
|
|
2005
|
+
prompt += '\n';
|
|
2006
|
+
}
|
|
2007
|
+
if (recovery.last_messages_sent && recovery.last_messages_sent.length > 0) {
|
|
2008
|
+
prompt += `**Your last messages before disconnect:**\n`;
|
|
2009
|
+
for (const m of recovery.last_messages_sent) {
|
|
2010
|
+
prompt += `- [→${m.to}]: ${m.content}\n`;
|
|
2011
|
+
}
|
|
2012
|
+
prompt += '\n';
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
if (workspaceStatus) {
|
|
2017
|
+
prompt += `**Your last status:** ${workspaceStatus}\n\n`;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
prompt += `**Recent team conversation:**\n${recentHistory}\n\n`;
|
|
2021
|
+
|
|
2022
|
+
prompt += `**Instructions:**\n`;
|
|
2023
|
+
prompt += `1. Register as "${agentName}" using the register tool\n`;
|
|
2024
|
+
prompt += `2. Call get_briefing() for full project context\n`;
|
|
2025
|
+
prompt += `3. Call listen_group() to rejoin the conversation\n`;
|
|
2026
|
+
prompt += `4. Announce you're back and pick up your active tasks\n`;
|
|
2027
|
+
prompt += `5. Stay in listen_group() loop — never stop listening\n`;
|
|
2028
|
+
|
|
2029
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2030
|
+
res.end(JSON.stringify({
|
|
2031
|
+
agent: agentName,
|
|
2032
|
+
status: isPidAlive(agents[agentName].pid, agents[agentName].last_activity) ? 'alive' : 'dead',
|
|
2033
|
+
prompt,
|
|
2034
|
+
prompt_length: prompt.length,
|
|
2035
|
+
has_recovery: !!recovery,
|
|
2036
|
+
active_tasks: activeTasks.length,
|
|
2037
|
+
online_agents: onlineAgents,
|
|
2038
|
+
}));
|
|
2039
|
+
}
|
|
1587
2040
|
else if (url.pathname === '/api/status' && req.method === 'GET') {
|
|
1588
2041
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1589
2042
|
res.end(JSON.stringify(apiStatus(url.searchParams)));
|
|
@@ -1593,9 +2046,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
1593
2046
|
res.end(JSON.stringify(apiStats(url.searchParams)));
|
|
1594
2047
|
}
|
|
1595
2048
|
else if (url.pathname === '/api/reset' && req.method === 'POST') {
|
|
2049
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2050
|
+
if (!body.confirm) {
|
|
2051
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2052
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
1596
2055
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1597
2056
|
res.end(JSON.stringify(apiReset(url.searchParams)));
|
|
1598
2057
|
}
|
|
2058
|
+
else if (url.pathname === '/api/clear-messages' && req.method === 'POST') {
|
|
2059
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2060
|
+
if (!body.confirm) {
|
|
2061
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2062
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2066
|
+
res.end(JSON.stringify(apiClearMessages(url.searchParams)));
|
|
2067
|
+
}
|
|
2068
|
+
else if (url.pathname === '/api/new-conversation' && req.method === 'POST') {
|
|
2069
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2070
|
+
if (!body.confirm) {
|
|
2071
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2072
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2076
|
+
res.end(JSON.stringify(apiNewConversation(url.searchParams)));
|
|
2077
|
+
}
|
|
2078
|
+
else if (url.pathname === '/api/conversations' && req.method === 'GET') {
|
|
2079
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2080
|
+
res.end(JSON.stringify(apiListConversations(url.searchParams)));
|
|
2081
|
+
}
|
|
2082
|
+
else if (url.pathname === '/api/load-conversation' && req.method === 'POST') {
|
|
2083
|
+
const result = apiLoadConversation(url.searchParams);
|
|
2084
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2085
|
+
res.end(JSON.stringify(result));
|
|
2086
|
+
}
|
|
1599
2087
|
// Message injection
|
|
1600
2088
|
else if (url.pathname === '/api/inject' && req.method === 'POST') {
|
|
1601
2089
|
const body = await parseBody(req);
|
|
@@ -1628,6 +2116,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1628
2116
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1629
2117
|
res.end(JSON.stringify(result));
|
|
1630
2118
|
}
|
|
2119
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
2120
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2121
|
+
res.end(JSON.stringify(apiRules(url.searchParams)));
|
|
2122
|
+
}
|
|
2123
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
2124
|
+
const body = await parseBody(req);
|
|
2125
|
+
const action = body.action || 'add';
|
|
2126
|
+
let result;
|
|
2127
|
+
if (action === 'add') result = apiAddRule(body, url.searchParams);
|
|
2128
|
+
else if (action === 'update') result = apiUpdateRule(body, url.searchParams);
|
|
2129
|
+
else if (action === 'delete') result = apiDeleteRule(body, url.searchParams);
|
|
2130
|
+
else result = { error: 'Unknown action. Use: add, update, delete' };
|
|
2131
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2132
|
+
res.end(JSON.stringify(result));
|
|
2133
|
+
}
|
|
1631
2134
|
else if (url.pathname === '/api/search' && req.method === 'GET') {
|
|
1632
2135
|
const projectPath = url.searchParams.get('project') || null;
|
|
1633
2136
|
const query = (url.searchParams.get('q') || '').trim();
|
|
@@ -1671,7 +2174,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1671
2174
|
const history = apiHistory(url.searchParams);
|
|
1672
2175
|
const agents = apiAgents(url.searchParams);
|
|
1673
2176
|
const decisions = readJson(filePath('decisions.json', projectPath)) || [];
|
|
1674
|
-
const
|
|
2177
|
+
const tasksRaw = readJson(filePath('tasks.json', projectPath));
|
|
2178
|
+
const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
|
|
1675
2179
|
const channels = apiChannels(url.searchParams);
|
|
1676
2180
|
const pkg = readJson(path.join(__dirname, 'package.json')) || {};
|
|
1677
2181
|
const result = {
|
|
@@ -1714,6 +2218,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
1714
2218
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1715
2219
|
res.end(JSON.stringify(apiDiscover()));
|
|
1716
2220
|
}
|
|
2221
|
+
// --- World Builder: load/save world layout ---
|
|
2222
|
+
else if (url.pathname === '/api/world-layout' && req.method === 'GET') {
|
|
2223
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2224
|
+
const worldFile = filePath('world-layout.json', projectPath);
|
|
2225
|
+
if (fs.existsSync(worldFile)) {
|
|
2226
|
+
try {
|
|
2227
|
+
const data = JSON.parse(fs.readFileSync(worldFile, 'utf8'));
|
|
2228
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2229
|
+
res.end(JSON.stringify(data));
|
|
2230
|
+
} catch {
|
|
2231
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2232
|
+
res.end('[]');
|
|
2233
|
+
}
|
|
2234
|
+
} else {
|
|
2235
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2236
|
+
res.end('[]');
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
else if (url.pathname === '/api/world-save' && req.method === 'POST') {
|
|
2240
|
+
const body = await parseBody(req);
|
|
2241
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2242
|
+
const worldFile = filePath('world-layout.json', projectPath);
|
|
2243
|
+
if (!Array.isArray(body)) {
|
|
2244
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2245
|
+
res.end(JSON.stringify({ error: 'Expected array of placements' }));
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
// Limit to 1000 placements for safety
|
|
2249
|
+
const placements = body.slice(0, 1000);
|
|
2250
|
+
fs.writeFileSync(worldFile, JSON.stringify(placements, null, 2));
|
|
2251
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2252
|
+
res.end(JSON.stringify({ success: true, count: placements.length }));
|
|
2253
|
+
}
|
|
1717
2254
|
// --- v3.0 API endpoints ---
|
|
1718
2255
|
else if (url.pathname === '/api/profiles' && req.method === 'GET') {
|
|
1719
2256
|
const projectPath = url.searchParams.get('project') || null;
|
|
@@ -1725,7 +2262,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1725
2262
|
const projectPath = url.searchParams.get('project') || null;
|
|
1726
2263
|
const profilesFile = filePath('profiles.json', projectPath);
|
|
1727
2264
|
const profiles = readJson(profilesFile);
|
|
1728
|
-
if (!body.agent) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: '
|
|
2265
|
+
if (!body.agent || !/^[a-zA-Z0-9_-]{1,20}$/.test(body.agent)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid agent name' })); return; }
|
|
1729
2266
|
if (!profiles[body.agent]) profiles[body.agent] = {};
|
|
1730
2267
|
if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
|
|
1731
2268
|
if (body.avatar) {
|
|
@@ -1817,6 +2354,500 @@ const server = http.createServer(async (req, res) => {
|
|
|
1817
2354
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1818
2355
|
res.end(JSON.stringify({ success: true }));
|
|
1819
2356
|
}
|
|
2357
|
+
// ========== Plan Control API (v5.0 Autonomy Engine) ==========
|
|
2358
|
+
|
|
2359
|
+
else if (url.pathname === '/api/plan/status' && req.method === 'GET') {
|
|
2360
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2361
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2362
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2363
|
+
let workflows = [];
|
|
2364
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2365
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2366
|
+
|
|
2367
|
+
// Find the active autonomous workflow (most recent)
|
|
2368
|
+
const activeWf = workflows.filter(w => w.status === 'active' && w.autonomous).pop()
|
|
2369
|
+
|| workflows.filter(w => w.status === 'active').pop();
|
|
2370
|
+
|
|
2371
|
+
if (!activeWf) {
|
|
2372
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2373
|
+
res.end(JSON.stringify({ active: false, message: 'No active plan' }));
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const doneSteps = activeWf.steps.filter(s => s.status === 'done').length;
|
|
2378
|
+
const totalSteps = activeWf.steps.length;
|
|
2379
|
+
const elapsed = Date.now() - new Date(activeWf.created_at).getTime();
|
|
2380
|
+
const activeAgents = Object.entries(agents).filter(([, a]) => {
|
|
2381
|
+
const idle = Date.now() - new Date(a.last_activity || 0).getTime();
|
|
2382
|
+
return idle < 120000;
|
|
2383
|
+
}).length;
|
|
2384
|
+
|
|
2385
|
+
const retryCount = activeWf.steps.filter(s => s.flagged).length;
|
|
2386
|
+
const avgConfidence = activeWf.steps.filter(s => s.verification && s.verification.confidence)
|
|
2387
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2388
|
+
|
|
2389
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2390
|
+
res.end(JSON.stringify({
|
|
2391
|
+
active: true,
|
|
2392
|
+
workflow_id: activeWf.id,
|
|
2393
|
+
name: activeWf.name,
|
|
2394
|
+
status: activeWf.status,
|
|
2395
|
+
autonomous: !!activeWf.autonomous,
|
|
2396
|
+
parallel: !!activeWf.parallel,
|
|
2397
|
+
paused: !!activeWf.paused,
|
|
2398
|
+
progress: { done: doneSteps, total: totalSteps, percent: Math.round((doneSteps / totalSteps) * 100) },
|
|
2399
|
+
elapsed_ms: elapsed,
|
|
2400
|
+
elapsed_human: Math.round(elapsed / 60000) + 'm',
|
|
2401
|
+
agents_active: activeAgents,
|
|
2402
|
+
steps: activeWf.steps.map(s => ({
|
|
2403
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2404
|
+
status: s.status, depends_on: s.depends_on || [],
|
|
2405
|
+
started_at: s.started_at, completed_at: s.completed_at,
|
|
2406
|
+
flagged: !!s.flagged, flag_reason: s.flag_reason || null,
|
|
2407
|
+
confidence: s.verification ? s.verification.confidence : null,
|
|
2408
|
+
verification: s.verification || null,
|
|
2409
|
+
})),
|
|
2410
|
+
retries: retryCount,
|
|
2411
|
+
avg_confidence: Math.round(avgConfidence) || null,
|
|
2412
|
+
created_at: activeWf.created_at,
|
|
2413
|
+
}));
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
else if (url.pathname === '/api/plan/pause' && req.method === 'POST') {
|
|
2417
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2418
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2419
|
+
let workflows = [];
|
|
2420
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2421
|
+
const activeWf = workflows.find(w => w.status === 'active' && w.autonomous);
|
|
2422
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active autonomous plan' })); return; }
|
|
2423
|
+
activeWf.paused = true;
|
|
2424
|
+
activeWf.paused_at = new Date().toISOString();
|
|
2425
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2426
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2427
|
+
// Notify agents
|
|
2428
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN PAUSED] "${activeWf.name}" has been paused by the dashboard. Finish your current step, then wait for resume.` }, url.searchParams);
|
|
2429
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2430
|
+
res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: activeWf.id }));
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
else if (url.pathname === '/api/plan/resume' && req.method === 'POST') {
|
|
2434
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2435
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2436
|
+
let workflows = [];
|
|
2437
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2438
|
+
const pausedWf = workflows.find(w => w.status === 'active' && w.paused);
|
|
2439
|
+
if (!pausedWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No paused plan' })); return; }
|
|
2440
|
+
pausedWf.paused = false;
|
|
2441
|
+
delete pausedWf.paused_at;
|
|
2442
|
+
pausedWf.updated_at = new Date().toISOString();
|
|
2443
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2444
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${pausedWf.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
|
|
2445
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2446
|
+
res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: pausedWf.id }));
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
else if (url.pathname === '/api/plan/stop' && req.method === 'POST') {
|
|
2450
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2451
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2452
|
+
let workflows = [];
|
|
2453
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2454
|
+
const activeWf = workflows.find(w => w.status === 'active');
|
|
2455
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active plan' })); return; }
|
|
2456
|
+
activeWf.status = 'stopped';
|
|
2457
|
+
activeWf.stopped_at = new Date().toISOString();
|
|
2458
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2459
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2460
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${activeWf.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
|
|
2461
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2462
|
+
res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: activeWf.id }));
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
else if (url.pathname.startsWith('/api/plan/skip/') && req.method === 'POST') {
|
|
2466
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2467
|
+
const body = await parseBody(req);
|
|
2468
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2469
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2470
|
+
let workflows = [];
|
|
2471
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2472
|
+
const wfId = body.workflow_id;
|
|
2473
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2474
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2475
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2476
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2477
|
+
step.status = 'done';
|
|
2478
|
+
step.notes = (step.notes || '') + ' [Skipped from dashboard]';
|
|
2479
|
+
step.completed_at = new Date().toISOString();
|
|
2480
|
+
step.skipped = true;
|
|
2481
|
+
// Start any newly ready steps
|
|
2482
|
+
const readySteps = wf.steps.filter(s => {
|
|
2483
|
+
if (s.status !== 'pending') return false;
|
|
2484
|
+
if (!s.depends_on || s.depends_on.length === 0) return true;
|
|
2485
|
+
return s.depends_on.every(depId => { const d = wf.steps.find(x => x.id === depId); return d && d.status === 'done'; });
|
|
2486
|
+
});
|
|
2487
|
+
for (const rs of readySteps) { rs.status = 'in_progress'; rs.started_at = new Date().toISOString(); }
|
|
2488
|
+
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
2489
|
+
wf.updated_at = new Date().toISOString();
|
|
2490
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2491
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2492
|
+
res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: readySteps.map(s => s.id) }));
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
else if (url.pathname.startsWith('/api/plan/reassign/') && req.method === 'POST') {
|
|
2496
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2497
|
+
const body = await parseBody(req);
|
|
2498
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2499
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2500
|
+
if (!body.new_assignee) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'new_assignee required' })); return; }
|
|
2501
|
+
let workflows = [];
|
|
2502
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2503
|
+
const wfId = body.workflow_id;
|
|
2504
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2505
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2506
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2507
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2508
|
+
const oldAssignee = step.assignee;
|
|
2509
|
+
step.assignee = body.new_assignee;
|
|
2510
|
+
wf.updated_at = new Date().toISOString();
|
|
2511
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2512
|
+
apiInjectMessage({ to: body.new_assignee, content: `[REASSIGNED] Step ${stepId} "${step.description}" has been reassigned from ${oldAssignee || 'unassigned'} to you. ${step.status === 'in_progress' ? 'This step is IN PROGRESS — pick it up now.' : 'This step is ' + step.status + '.'}` }, url.searchParams);
|
|
2513
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2514
|
+
res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: oldAssignee, new_assignee: body.new_assignee }));
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
else if (url.pathname === '/api/plan/inject' && req.method === 'POST') {
|
|
2518
|
+
const body = await parseBody(req);
|
|
2519
|
+
if (!body.content) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'content required' })); return; }
|
|
2520
|
+
const result = apiInjectMessage({ to: body.to || '__all__', content: body.content }, url.searchParams);
|
|
2521
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2522
|
+
res.end(JSON.stringify(result));
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
else if (url.pathname === '/api/plan/report' && req.method === 'GET') {
|
|
2526
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2527
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2528
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2529
|
+
let workflows = [];
|
|
2530
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2531
|
+
// Get most recent completed or active workflow
|
|
2532
|
+
const wf = workflows.filter(w => w.status === 'completed').pop() || workflows.filter(w => w.status === 'active').pop();
|
|
2533
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
|
|
2534
|
+
|
|
2535
|
+
const doneSteps = wf.steps.filter(s => s.status === 'done');
|
|
2536
|
+
const flaggedSteps = wf.steps.filter(s => s.flagged);
|
|
2537
|
+
const duration = wf.completed_at ? new Date(wf.completed_at) - new Date(wf.created_at) : Date.now() - new Date(wf.created_at).getTime();
|
|
2538
|
+
const avgConf = doneSteps.filter(s => s.verification && s.verification.confidence)
|
|
2539
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2540
|
+
|
|
2541
|
+
// Count skills learned during this plan
|
|
2542
|
+
let skillCount = 0;
|
|
2543
|
+
if (fs.existsSync(kbFile)) {
|
|
2544
|
+
try {
|
|
2545
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2546
|
+
skillCount = Object.keys(kb).filter(k => k.startsWith('skill_') || k.startsWith('lesson_')).length;
|
|
2547
|
+
} catch {}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// Agent-level performance analytics
|
|
2551
|
+
const agentStats = {};
|
|
2552
|
+
for (const s of wf.steps) {
|
|
2553
|
+
if (!s.assignee) continue;
|
|
2554
|
+
if (!agentStats[s.assignee]) agentStats[s.assignee] = { steps: 0, completed: 0, flagged: 0, total_ms: 0, confidences: [] };
|
|
2555
|
+
agentStats[s.assignee].steps++;
|
|
2556
|
+
if (s.status === 'done') {
|
|
2557
|
+
agentStats[s.assignee].completed++;
|
|
2558
|
+
if (s.completed_at && s.started_at) agentStats[s.assignee].total_ms += new Date(s.completed_at) - new Date(s.started_at);
|
|
2559
|
+
if (s.verification && s.verification.confidence) agentStats[s.assignee].confidences.push(s.verification.confidence);
|
|
2560
|
+
}
|
|
2561
|
+
if (s.flagged) agentStats[s.assignee].flagged++;
|
|
2562
|
+
}
|
|
2563
|
+
const agentPerformance = Object.entries(agentStats).map(([name, stats]) => ({
|
|
2564
|
+
agent: name, steps_assigned: stats.steps, steps_completed: stats.completed, steps_flagged: stats.flagged,
|
|
2565
|
+
avg_duration_ms: stats.completed > 0 ? Math.round(stats.total_ms / stats.completed) : null,
|
|
2566
|
+
avg_confidence: stats.confidences.length > 0 ? Math.round(stats.confidences.reduce((a, b) => a + b, 0) / stats.confidences.length) : null,
|
|
2567
|
+
}));
|
|
2568
|
+
|
|
2569
|
+
// Slowest/fastest steps
|
|
2570
|
+
const stepsWithDuration = wf.steps.filter(s => s.completed_at && s.started_at)
|
|
2571
|
+
.map(s => ({ id: s.id, description: s.description, assignee: s.assignee, duration_ms: new Date(s.completed_at) - new Date(s.started_at) }))
|
|
2572
|
+
.sort((a, b) => b.duration_ms - a.duration_ms);
|
|
2573
|
+
|
|
2574
|
+
// Retry count from workspace data
|
|
2575
|
+
let retryCount = 0;
|
|
2576
|
+
const wsDir = path.join(resolveDataDir(projectPath), 'workspaces');
|
|
2577
|
+
if (fs.existsSync(wsDir)) {
|
|
2578
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2579
|
+
try {
|
|
2580
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2581
|
+
if (ws.retry_history) {
|
|
2582
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2583
|
+
if (Array.isArray(history)) retryCount += history.length;
|
|
2584
|
+
}
|
|
2585
|
+
} catch {}
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2590
|
+
res.end(JSON.stringify({
|
|
2591
|
+
name: wf.name,
|
|
2592
|
+
status: wf.status,
|
|
2593
|
+
steps_done: doneSteps.length,
|
|
2594
|
+
steps_total: wf.steps.length,
|
|
2595
|
+
duration_ms: duration,
|
|
2596
|
+
duration_human: Math.round(duration / 60000) + 'm',
|
|
2597
|
+
avg_confidence: Math.round(avgConf) || null,
|
|
2598
|
+
flagged_steps: flaggedSteps.map(s => ({ id: s.id, description: s.description, reason: s.flag_reason })),
|
|
2599
|
+
skills_learned: skillCount,
|
|
2600
|
+
retries: retryCount,
|
|
2601
|
+
agent_performance: agentPerformance,
|
|
2602
|
+
slowest_step: stepsWithDuration[0] || null,
|
|
2603
|
+
fastest_step: stepsWithDuration[stepsWithDuration.length - 1] || null,
|
|
2604
|
+
steps: wf.steps.map(s => ({
|
|
2605
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2606
|
+
status: s.status, confidence: s.verification ? s.verification.confidence : null,
|
|
2607
|
+
duration_ms: s.completed_at && s.started_at ? new Date(s.completed_at) - new Date(s.started_at) : null,
|
|
2608
|
+
flagged: !!s.flagged, skipped: !!s.skipped,
|
|
2609
|
+
})),
|
|
2610
|
+
created_at: wf.created_at,
|
|
2611
|
+
completed_at: wf.completed_at || null,
|
|
2612
|
+
}));
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
else if (url.pathname === '/api/plan/skills' && req.method === 'GET') {
|
|
2616
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2617
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2618
|
+
let skills = [];
|
|
2619
|
+
if (fs.existsSync(kbFile)) {
|
|
2620
|
+
try {
|
|
2621
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2622
|
+
for (const [key, val] of Object.entries(kb)) {
|
|
2623
|
+
if (key.startsWith('skill_') || key.startsWith('lesson_')) {
|
|
2624
|
+
skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
} catch {}
|
|
2628
|
+
}
|
|
2629
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2630
|
+
res.end(JSON.stringify({ count: skills.length, skills }));
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
else if (url.pathname === '/api/plan/retries' && req.method === 'GET') {
|
|
2634
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2635
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2636
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2637
|
+
let retries = [];
|
|
2638
|
+
if (fs.existsSync(wsDir)) {
|
|
2639
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2640
|
+
try {
|
|
2641
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2642
|
+
if (ws.retry_history) {
|
|
2643
|
+
const agent = file.replace('.json', '');
|
|
2644
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2645
|
+
if (Array.isArray(history)) {
|
|
2646
|
+
for (const entry of history) { retries.push({ agent, ...entry }); }
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
} catch {}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
retries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
|
2653
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2654
|
+
res.end(JSON.stringify({ count: retries.length, retries }));
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// ========== Monitor Agent API ==========
|
|
2658
|
+
|
|
2659
|
+
else if (url.pathname === '/api/monitor/health' && req.method === 'GET') {
|
|
2660
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2661
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2662
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2663
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2664
|
+
const profilesFile = filePath('profiles.json', projectPath);
|
|
2665
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2666
|
+
|
|
2667
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2668
|
+
const profiles = fs.existsSync(profilesFile) ? readJson(profilesFile) : {};
|
|
2669
|
+
let workflows = [];
|
|
2670
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2671
|
+
let tasks = [];
|
|
2672
|
+
if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2673
|
+
|
|
2674
|
+
// Find monitor agent
|
|
2675
|
+
const monitorName = Object.entries(profiles).find(([, p]) => p.role === 'monitor');
|
|
2676
|
+
const now = Date.now();
|
|
2677
|
+
|
|
2678
|
+
// Agent health summary
|
|
2679
|
+
const agentHealth = Object.entries(agents).map(([name, a]) => {
|
|
2680
|
+
const idle = now - new Date(a.last_activity || 0).getTime();
|
|
2681
|
+
return { name, idle_ms: idle, idle_human: Math.round(idle / 1000) + 's', status: idle > 120000 ? 'idle' : idle > 600000 ? 'stuck' : 'active', role: profiles[name] ? profiles[name].role : null };
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
const idleAgents = agentHealth.filter(a => a.status === 'idle').length;
|
|
2685
|
+
const stuckAgents = agentHealth.filter(a => a.status === 'stuck').length;
|
|
2686
|
+
const activeWorkflows = workflows.filter(w => w.status === 'active').length;
|
|
2687
|
+
const pendingTasks = tasks.filter(t => t.status === 'pending').length;
|
|
2688
|
+
const blockedTasks = tasks.filter(t => t.status === 'blocked' || t.status === 'blocked_permanent').length;
|
|
2689
|
+
|
|
2690
|
+
// Monitor intervention log from workspace
|
|
2691
|
+
let interventions = [];
|
|
2692
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2693
|
+
if (monitorName && fs.existsSync(wsDir)) {
|
|
2694
|
+
const monFile = path.join(wsDir, monitorName[0] + '.json');
|
|
2695
|
+
if (fs.existsSync(monFile)) {
|
|
2696
|
+
try {
|
|
2697
|
+
const ws = JSON.parse(fs.readFileSync(monFile, 'utf8'));
|
|
2698
|
+
if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
|
|
2699
|
+
} catch {}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2704
|
+
res.end(JSON.stringify({
|
|
2705
|
+
monitor: monitorName ? { name: monitorName[0], active: true } : { active: false },
|
|
2706
|
+
health: {
|
|
2707
|
+
total_agents: Object.keys(agents).length,
|
|
2708
|
+
active: agentHealth.filter(a => a.status === 'active').length,
|
|
2709
|
+
idle: idleAgents,
|
|
2710
|
+
stuck: stuckAgents,
|
|
2711
|
+
active_workflows: activeWorkflows,
|
|
2712
|
+
pending_tasks: pendingTasks,
|
|
2713
|
+
blocked_tasks: blockedTasks,
|
|
2714
|
+
},
|
|
2715
|
+
agents: agentHealth,
|
|
2716
|
+
interventions: interventions.slice(-20),
|
|
2717
|
+
timestamp: new Date().toISOString(),
|
|
2718
|
+
}));
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// ========== Reputation API ==========
|
|
2722
|
+
|
|
2723
|
+
else if (url.pathname === '/api/reputation' && req.method === 'GET') {
|
|
2724
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2725
|
+
const repFile = filePath('reputation.json', projectPath);
|
|
2726
|
+
const rep = fs.existsSync(repFile) ? readJson(repFile) : {};
|
|
2727
|
+
|
|
2728
|
+
// Calculate scores and build leaderboard
|
|
2729
|
+
const leaderboard = Object.entries(rep).map(([name, r]) => {
|
|
2730
|
+
const score = (r.tasks_completed || 0) * 2
|
|
2731
|
+
+ (r.reviews_done || 0) * 1
|
|
2732
|
+
+ (r.help_given || 0) * 3
|
|
2733
|
+
+ (r.kb_contributions || 0) * 1
|
|
2734
|
+
- (r.retries || 0) * 1
|
|
2735
|
+
- (r.watchdog_nudges || 0) * 2;
|
|
2736
|
+
return {
|
|
2737
|
+
name, score,
|
|
2738
|
+
tasks_completed: r.tasks_completed || 0,
|
|
2739
|
+
reviews_done: r.reviews_done || 0,
|
|
2740
|
+
retries: r.retries || 0,
|
|
2741
|
+
watchdog_nudges: r.watchdog_nudges || 0,
|
|
2742
|
+
help_given: r.help_given || 0,
|
|
2743
|
+
strengths: r.strengths || [],
|
|
2744
|
+
};
|
|
2745
|
+
}).sort((a, b) => b.score - a.score);
|
|
2746
|
+
|
|
2747
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2748
|
+
res.end(JSON.stringify({ leaderboard, timestamp: new Date().toISOString() }));
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// ========== System Stats API ==========
|
|
2752
|
+
|
|
2753
|
+
else if (url.pathname === '/api/stats' && req.method === 'GET') {
|
|
2754
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2755
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2756
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2757
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2758
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2759
|
+
const histFile = path.join(dataDir, 'history.jsonl');
|
|
2760
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2761
|
+
|
|
2762
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2763
|
+
let workflows = []; if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2764
|
+
let tasks = []; if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2765
|
+
let msgCount = 0; if (fs.existsSync(histFile)) { try { const c = fs.readFileSync(histFile, 'utf8').trim(); if (c) msgCount = c.split(/\r?\n/).filter(l => l.trim()).length; } catch {} }
|
|
2766
|
+
let kbKeys = 0; if (fs.existsSync(kbFile)) try { kbKeys = Object.keys(JSON.parse(fs.readFileSync(kbFile, 'utf8'))).length; } catch {}
|
|
2767
|
+
|
|
2768
|
+
const aliveCount = Object.values(agents).filter(a => { const idle = Date.now() - new Date(a.last_activity || 0).getTime(); return idle < 120000; }).length;
|
|
2769
|
+
const activeWf = workflows.filter(w => w.status === 'active');
|
|
2770
|
+
const completedWf = workflows.filter(w => w.status === 'completed');
|
|
2771
|
+
const tasksDone = tasks.filter(t => t.status === 'done').length;
|
|
2772
|
+
const tasksActive = tasks.filter(t => t.status === 'in_progress').length;
|
|
2773
|
+
|
|
2774
|
+
// Heartbeat files count
|
|
2775
|
+
let hbCount = 0;
|
|
2776
|
+
try { hbCount = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-')).length; } catch {}
|
|
2777
|
+
|
|
2778
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2779
|
+
res.end(JSON.stringify({
|
|
2780
|
+
agents: { total: Math.max(Object.keys(agents).length, hbCount), alive: aliveCount },
|
|
2781
|
+
messages: { total: msgCount },
|
|
2782
|
+
tasks: { total: tasks.length, done: tasksDone, active: tasksActive, pending: tasks.length - tasksDone - tasksActive },
|
|
2783
|
+
workflows: { total: workflows.length, active: activeWf.length, completed: completedWf.length },
|
|
2784
|
+
active_plan: activeWf.length > 0 ? { name: activeWf[0].name, progress: activeWf[0].steps.filter(s => s.status === 'done').length + '/' + activeWf[0].steps.length } : null,
|
|
2785
|
+
knowledge_base: { entries: kbKeys },
|
|
2786
|
+
timestamp: new Date().toISOString(),
|
|
2787
|
+
}));
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// ========== Rules API ==========
|
|
2791
|
+
|
|
2792
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
2793
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2794
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2795
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2796
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2797
|
+
res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
2801
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2802
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2803
|
+
try {
|
|
2804
|
+
const body = await parseBody(req);
|
|
2805
|
+
const { text, category } = body;
|
|
2806
|
+
if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
|
|
2807
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2808
|
+
const rule = {
|
|
2809
|
+
id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
2810
|
+
text: text.trim(),
|
|
2811
|
+
category: category || 'custom',
|
|
2812
|
+
created_by: 'dashboard',
|
|
2813
|
+
created_at: new Date().toISOString(),
|
|
2814
|
+
active: true,
|
|
2815
|
+
};
|
|
2816
|
+
rules.push(rule);
|
|
2817
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2818
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
2819
|
+
res.end(JSON.stringify(rule));
|
|
2820
|
+
} catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
|
|
2824
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2825
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2826
|
+
const ruleId = url.pathname.split('/api/rules/')[1];
|
|
2827
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2828
|
+
const idx = rules.findIndex(r => r.id === ruleId);
|
|
2829
|
+
if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2830
|
+
rules.splice(idx, 1);
|
|
2831
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2832
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2833
|
+
res.end(JSON.stringify({ success: true }));
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
|
|
2837
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2838
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2839
|
+
const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
|
|
2840
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2841
|
+
const rule = rules.find(r => r.id === ruleId);
|
|
2842
|
+
if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2843
|
+
rule.active = !rule.active;
|
|
2844
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2845
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2846
|
+
res.end(JSON.stringify(rule));
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// ========== End Rules API ==========
|
|
2850
|
+
|
|
1820
2851
|
else if (url.pathname === '/api/branches' && req.method === 'GET') {
|
|
1821
2852
|
const projectPath = url.searchParams.get('project') || null;
|
|
1822
2853
|
const branchesFile = filePath('branches.json', projectPath);
|
|
@@ -1828,7 +2859,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1828
2859
|
let msgCount = 0;
|
|
1829
2860
|
if (fs.existsSync(histFile)) {
|
|
1830
2861
|
const content = fs.readFileSync(histFile, 'utf8').trim();
|
|
1831
|
-
if (content) msgCount = content.split(
|
|
2862
|
+
if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
1832
2863
|
}
|
|
1833
2864
|
branches[name].message_count = msgCount;
|
|
1834
2865
|
}
|
|
@@ -1887,7 +2918,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1887
2918
|
// Server info (LAN mode detection for frontend)
|
|
1888
2919
|
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
1889
2920
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1890
|
-
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT
|
|
2921
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
|
|
1891
2922
|
}
|
|
1892
2923
|
// Toggle LAN mode (re-bind server live)
|
|
1893
2924
|
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
@@ -1899,7 +2930,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1899
2930
|
if (newMode) generateLanToken();
|
|
1900
2931
|
// Send response first
|
|
1901
2932
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1902
|
-
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT
|
|
2933
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
|
|
1903
2934
|
// Re-bind by stopping the listener and immediately re-listening
|
|
1904
2935
|
// Use setImmediate to let the response flush first
|
|
1905
2936
|
setImmediate(() => {
|
|
@@ -1965,6 +2996,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1965
2996
|
});
|
|
1966
2997
|
res.end(html);
|
|
1967
2998
|
}
|
|
2999
|
+
// (World Builder API endpoints are handled earlier in the route chain by Architect's implementation)
|
|
1968
3000
|
// Server-Sent Events endpoint for real-time updates
|
|
1969
3001
|
else if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
1970
3002
|
if (sseClients.size >= 100) {
|
|
@@ -1972,17 +3004,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
1972
3004
|
res.end(JSON.stringify({ error: 'Too many SSE connections' }));
|
|
1973
3005
|
return;
|
|
1974
3006
|
}
|
|
3007
|
+
// Per-IP SSE limit (max 5 connections per IP)
|
|
3008
|
+
const sseIP = req.socket.remoteAddress || 'unknown';
|
|
3009
|
+
const sseIPCount = [...sseClients].filter(c => c._sseIP === sseIP).length;
|
|
3010
|
+
if (sseIPCount >= 5) {
|
|
3011
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
3012
|
+
res.end(JSON.stringify({ error: 'Too many SSE connections from this IP (max 5)' }));
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
1975
3015
|
res.writeHead(200, {
|
|
1976
3016
|
'Content-Type': 'text/event-stream',
|
|
1977
3017
|
'Cache-Control': 'no-cache',
|
|
1978
3018
|
'Connection': 'keep-alive',
|
|
1979
3019
|
});
|
|
1980
3020
|
res.write(`data: connected\n\n`);
|
|
3021
|
+
res._sseIP = sseIP;
|
|
1981
3022
|
sseClients.add(res);
|
|
1982
3023
|
// Heartbeat every 30s to detect dead connections and prevent proxy timeouts
|
|
1983
3024
|
const heartbeat = setInterval(() => {
|
|
1984
3025
|
try { res.write(`:heartbeat\n\n`); } catch { clearInterval(heartbeat); sseClients.delete(res); }
|
|
1985
3026
|
}, 30000);
|
|
3027
|
+
heartbeat.unref();
|
|
1986
3028
|
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
|
1987
3029
|
}
|
|
1988
3030
|
// --- Mod system API ---
|
|
@@ -2047,7 +3089,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2047
3089
|
return;
|
|
2048
3090
|
}
|
|
2049
3091
|
}
|
|
2050
|
-
|
|
3092
|
+
const resolvedAsset = path.resolve(path.join(modDir, assetFile));
|
|
3093
|
+
if (!resolvedAsset.startsWith(path.resolve(modDir) + path.sep)) {
|
|
3094
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3095
|
+
res.end(JSON.stringify({ error: 'Invalid asset file path' }));
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
fs.writeFileSync(resolvedAsset, buf);
|
|
2051
3099
|
manifest.asset.file = assetFile;
|
|
2052
3100
|
}
|
|
2053
3101
|
// Write manifest
|
|
@@ -2106,6 +3154,134 @@ const server = http.createServer(async (req, res) => {
|
|
|
2106
3154
|
res.end(JSON.stringify({ success: true }));
|
|
2107
3155
|
});
|
|
2108
3156
|
}
|
|
3157
|
+
// --- City API endpoints (AI City Phase 1) ---
|
|
3158
|
+
else if (url.pathname === '/api/city/layout' && req.method === 'GET') {
|
|
3159
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3160
|
+
const cityMapFile = filePath('city-map.json', projectPath);
|
|
3161
|
+
if (fs.existsSync(cityMapFile)) {
|
|
3162
|
+
try {
|
|
3163
|
+
const data = JSON.parse(fs.readFileSync(cityMapFile, 'utf8'));
|
|
3164
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3165
|
+
res.end(JSON.stringify(data));
|
|
3166
|
+
} catch {
|
|
3167
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3168
|
+
res.end(JSON.stringify({ districts: {}, buildings: {} }));
|
|
3169
|
+
}
|
|
3170
|
+
} else {
|
|
3171
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3172
|
+
res.end(JSON.stringify({ districts: {}, buildings: {} }));
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
else if (url.pathname === '/api/city/agents' && req.method === 'GET') {
|
|
3176
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3177
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
3178
|
+
const tasksRaw = readJson(filePath('tasks.json', projectPath));
|
|
3179
|
+
const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
|
|
3180
|
+
const cityAgents = {};
|
|
3181
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
3182
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
3183
|
+
const lastActivity = info.last_activity || info.timestamp;
|
|
3184
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
3185
|
+
const isListening = !!(info.listening_since && alive);
|
|
3186
|
+
const activeTasks = tasks.filter(t => t.assignee === name && t.status !== 'done');
|
|
3187
|
+
let behavior = 'dead';
|
|
3188
|
+
let location = null;
|
|
3189
|
+
if (alive) {
|
|
3190
|
+
if (activeTasks.length > 0) { behavior = 'working'; location = 'office'; }
|
|
3191
|
+
else if (isListening) { behavior = 'listening'; location = 'office'; }
|
|
3192
|
+
else if (idleSeconds > 900) { behavior = 'off_duty'; location = 'residential'; }
|
|
3193
|
+
else if (idleSeconds > 300) { behavior = 'off_duty'; location = 'cafe'; }
|
|
3194
|
+
else { behavior = 'idle'; location = 'office'; }
|
|
3195
|
+
}
|
|
3196
|
+
cityAgents[name] = {
|
|
3197
|
+
alive,
|
|
3198
|
+
behavior,
|
|
3199
|
+
location,
|
|
3200
|
+
branch: info.branch || 'main',
|
|
3201
|
+
idle_seconds: alive ? idleSeconds : null,
|
|
3202
|
+
provider: info.provider || 'unknown',
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3206
|
+
res.end(JSON.stringify(cityAgents));
|
|
3207
|
+
}
|
|
3208
|
+
// Phase 2: Agent activity radio feed for car HUD
|
|
3209
|
+
else if (url.pathname === '/api/city/radio' && req.method === 'GET') {
|
|
3210
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3211
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 50);
|
|
3212
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
3213
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
3214
|
+
const feed = [];
|
|
3215
|
+
// Recent messages (skip system messages, truncate content)
|
|
3216
|
+
const recentMsgs = history.slice(-limit * 2).filter(m => m.from !== '__system__');
|
|
3217
|
+
for (const m of recentMsgs.slice(-limit)) {
|
|
3218
|
+
feed.push({
|
|
3219
|
+
type: 'message',
|
|
3220
|
+
from: m.from,
|
|
3221
|
+
to: m.to === '__group__' ? 'everyone' : m.to,
|
|
3222
|
+
preview: (m.content || '').slice(0, 120),
|
|
3223
|
+
timestamp: m.timestamp,
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
// Agent status updates (who's alive, who just joined/died)
|
|
3227
|
+
const statuses = [];
|
|
3228
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
3229
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
3230
|
+
statuses.push({ name, alive, last_activity: info.last_activity });
|
|
3231
|
+
}
|
|
3232
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3233
|
+
res.end(JSON.stringify({ feed, agents_status: statuses, total_messages: history.length }));
|
|
3234
|
+
}
|
|
3235
|
+
// Phase 3: Economy system
|
|
3236
|
+
else if (url.pathname === '/api/city/economy' && req.method === 'GET') {
|
|
3237
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3238
|
+
const balances = getBalances(projectPath);
|
|
3239
|
+
const ledger = getEconomyLedger(projectPath);
|
|
3240
|
+
const recent = ledger.slice(-20);
|
|
3241
|
+
const totalCredits = Object.values(balances).reduce((s, v) => s + v, 0);
|
|
3242
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3243
|
+
res.end(JSON.stringify({ balances, recent_transactions: recent, total_credits: totalCredits, ledger_entries: ledger.length }));
|
|
3244
|
+
}
|
|
3245
|
+
else if (url.pathname === '/api/city/economy' && req.method === 'POST') {
|
|
3246
|
+
const body = await parseBody(req);
|
|
3247
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3248
|
+
if (!body.agent || !body.amount || !body.reason) {
|
|
3249
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3250
|
+
res.end(JSON.stringify({ error: 'Missing agent, amount, or reason' }));
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const amount = parseInt(body.amount, 10);
|
|
3254
|
+
if (isNaN(amount)) {
|
|
3255
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3256
|
+
res.end(JSON.stringify({ error: 'Invalid amount' }));
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
// Prevent negative balances on spend
|
|
3260
|
+
if (amount < 0) {
|
|
3261
|
+
const balances = getBalances(projectPath);
|
|
3262
|
+
const current = balances[body.agent] || 0;
|
|
3263
|
+
if (current + amount < 0) {
|
|
3264
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3265
|
+
res.end(JSON.stringify({ error: 'Insufficient credits', balance: current, requested: Math.abs(amount) }));
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
appendEconomyEntry(projectPath, { agent: body.agent, amount, reason: body.reason, type: amount > 0 ? 'earn' : 'spend' });
|
|
3270
|
+
const balances = getBalances(projectPath);
|
|
3271
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3272
|
+
res.end(JSON.stringify({ success: true, balance: balances[body.agent] || 0 }));
|
|
3273
|
+
}
|
|
3274
|
+
// Phase 4: Game time endpoint
|
|
3275
|
+
else if (url.pathname === '/api/city/time' && req.method === 'GET') {
|
|
3276
|
+
const speed = parseInt(url.searchParams.get('speed') || '60', 10) || 60;
|
|
3277
|
+
const now = Date.now();
|
|
3278
|
+
const gameMinutes = Math.floor((now / 1000) * speed / 60) % 1440;
|
|
3279
|
+
const hours = Math.floor(gameMinutes / 60);
|
|
3280
|
+
const minutes = gameMinutes % 60;
|
|
3281
|
+
const period = hours < 6 ? 'night' : hours < 8 ? 'dawn' : hours < 18 ? 'day' : hours < 20 ? 'dusk' : 'night';
|
|
3282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3283
|
+
res.end(JSON.stringify({ hours, minutes, period, game_minutes: gameMinutes, speed, formatted: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}` }));
|
|
3284
|
+
}
|
|
2109
3285
|
else {
|
|
2110
3286
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2111
3287
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
@@ -2121,17 +3297,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
2121
3297
|
// Watches data files and pushes updates to connected clients instantly
|
|
2122
3298
|
const sseClients = new Set();
|
|
2123
3299
|
|
|
2124
|
-
function sseNotifyAll() {
|
|
3300
|
+
function sseNotifyAll(changeType) {
|
|
2125
3301
|
// Generate notifications from agent state changes
|
|
2126
3302
|
try {
|
|
2127
3303
|
const agents = readJson(filePath('agents.json'));
|
|
2128
3304
|
generateNotifications(agents);
|
|
2129
3305
|
} catch {}
|
|
2130
3306
|
|
|
3307
|
+
// Send typed change event so client can do targeted fetches
|
|
3308
|
+
const eventData = changeType || 'update';
|
|
2131
3309
|
const dead = [];
|
|
2132
3310
|
for (const res of Array.from(sseClients)) {
|
|
2133
3311
|
try {
|
|
2134
|
-
res.write(`data:
|
|
3312
|
+
res.write(`data: ${(eventData || '').replace(/[\r\n]/g, '')}\n\n`);
|
|
2135
3313
|
} catch {
|
|
2136
3314
|
dead.push(res);
|
|
2137
3315
|
}
|
|
@@ -2151,13 +3329,38 @@ function startFileWatcher() {
|
|
|
2151
3329
|
const dataDir = resolveDataDir();
|
|
2152
3330
|
if (!fs.existsSync(dataDir)) return;
|
|
2153
3331
|
try {
|
|
3332
|
+
// Track pending change types for diff-based SSE
|
|
3333
|
+
let pendingChangeTypes = new Set();
|
|
2154
3334
|
fsWatcher = fs.watch(dataDir, { persistent: false }, (eventType, filename) => {
|
|
2155
3335
|
// Filter: only react to data files, not temp/lock files
|
|
2156
3336
|
if (filename && !filename.endsWith('.json') && !filename.endsWith('.jsonl')) return;
|
|
2157
3337
|
if (filename && filename.endsWith('.lock')) return;
|
|
3338
|
+
// Scale fix: skip heartbeat file changes — they fire 100x/10s at scale
|
|
3339
|
+
// Dashboard already polls agents via /api/agents on its own interval
|
|
3340
|
+
if (filename && filename.startsWith('heartbeat-')) return;
|
|
3341
|
+
|
|
3342
|
+
// Classify change type for targeted client fetches
|
|
3343
|
+
if (filename === 'messages.jsonl' || filename === 'history.jsonl' || (filename && filename.includes('-messages.jsonl'))) {
|
|
3344
|
+
pendingChangeTypes.add('messages');
|
|
3345
|
+
} else if (filename === 'agents.json' || filename === 'profiles.json') {
|
|
3346
|
+
pendingChangeTypes.add('agents');
|
|
3347
|
+
} else if (filename === 'tasks.json') {
|
|
3348
|
+
pendingChangeTypes.add('tasks');
|
|
3349
|
+
} else if (filename === 'workflows.json') {
|
|
3350
|
+
pendingChangeTypes.add('workflows');
|
|
3351
|
+
} else {
|
|
3352
|
+
pendingChangeTypes.add('update');
|
|
3353
|
+
}
|
|
3354
|
+
|
|
2158
3355
|
// Debounce — multiple file changes may fire rapidly
|
|
3356
|
+
// Increased from 200ms to 2000ms for 100-agent scale (prevents SSE flood)
|
|
2159
3357
|
if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
|
|
2160
|
-
sseDebounceTimer = setTimeout(() =>
|
|
3358
|
+
sseDebounceTimer = setTimeout(() => {
|
|
3359
|
+
// Send combined change types: "messages,agents" or just "messages"
|
|
3360
|
+
const changeType = Array.from(pendingChangeTypes).join(',');
|
|
3361
|
+
pendingChangeTypes.clear();
|
|
3362
|
+
sseNotifyAll(changeType);
|
|
3363
|
+
}, 2000);
|
|
2161
3364
|
});
|
|
2162
3365
|
fsWatcher.on('error', () => {}); // ignore watch errors
|
|
2163
3366
|
} catch {}
|