let-them-talk 4.3.0 → 5.2.5
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 -582
- 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 -7964
- package/dashboard.js +1074 -35
- package/office/building-interior.js +261 -0
- 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/fast-travel.js +215 -0
- package/office/hq-building.js +295 -0
- package/office/index.js +1095 -1046
- 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 -658
- 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/package.json +59 -59
- package/server.js +7214 -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 = [];
|
|
@@ -1051,7 +1320,7 @@ function apiLaunchAgent(body) {
|
|
|
1051
1320
|
const safeName = (agent_name || '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
|
|
1052
1321
|
const launchPrompt = prompt || (safeName ? `You are agent "${safeName}". Use the register tool to register as "${safeName}", then use listen to wait for messages.` : `Register with the agent-bridge MCP tools and use listen to wait for messages.`);
|
|
1053
1322
|
|
|
1054
|
-
// Try to launch terminal
|
|
1323
|
+
// Try to launch terminal — user pastes prompt from clipboard after CLI loads
|
|
1055
1324
|
if (process.platform === 'win32') {
|
|
1056
1325
|
spawn('cmd', ['/c', 'start', 'cmd', '/k', cliCmd], { cwd: projectDir, shell: false, detached: true, stdio: 'ignore' });
|
|
1057
1326
|
return { success: true, launched: true, cli, project_dir: projectDir, prompt: launchPrompt };
|
|
@@ -1059,13 +1328,10 @@ function apiLaunchAgent(body) {
|
|
|
1059
1328
|
|
|
1060
1329
|
// Non-Windows: return command for manual execution
|
|
1061
1330
|
return {
|
|
1062
|
-
success: true,
|
|
1063
|
-
launched: false,
|
|
1064
|
-
cli,
|
|
1065
|
-
project_dir: projectDir,
|
|
1331
|
+
success: true, launched: false, cli, project_dir: projectDir,
|
|
1066
1332
|
command: `cd "${projectDir}" && ${cliCmd}`,
|
|
1067
1333
|
prompt: launchPrompt,
|
|
1068
|
-
message: '
|
|
1334
|
+
message: 'Run the command in a terminal, then paste the prompt.'
|
|
1069
1335
|
};
|
|
1070
1336
|
}
|
|
1071
1337
|
|
|
@@ -1086,7 +1352,7 @@ async function apiEditMessage(body, query) {
|
|
|
1086
1352
|
// Update in history.jsonl (locked)
|
|
1087
1353
|
await withFileLock(historyFile, () => {
|
|
1088
1354
|
if (fs.existsSync(historyFile)) {
|
|
1089
|
-
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(
|
|
1355
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/).filter(Boolean);
|
|
1090
1356
|
const updated = lines.map(line => {
|
|
1091
1357
|
try {
|
|
1092
1358
|
const msg = JSON.parse(line);
|
|
@@ -1094,6 +1360,7 @@ async function apiEditMessage(body, query) {
|
|
|
1094
1360
|
found = true;
|
|
1095
1361
|
if (!msg.edit_history) msg.edit_history = [];
|
|
1096
1362
|
msg.edit_history.push({ content: msg.content, edited_at: now });
|
|
1363
|
+
if (msg.edit_history.length > 10) msg.edit_history = msg.edit_history.slice(-10);
|
|
1097
1364
|
msg.content = content;
|
|
1098
1365
|
msg.edited = true;
|
|
1099
1366
|
msg.edited_at = now;
|
|
@@ -1112,7 +1379,7 @@ async function apiEditMessage(body, query) {
|
|
|
1112
1379
|
if (fs.existsSync(messagesFile)) {
|
|
1113
1380
|
const raw = fs.readFileSync(messagesFile, 'utf8').trim();
|
|
1114
1381
|
if (raw) {
|
|
1115
|
-
const lines = raw.split(
|
|
1382
|
+
const lines = raw.split(/\r?\n/);
|
|
1116
1383
|
const updated = lines.map(line => {
|
|
1117
1384
|
try {
|
|
1118
1385
|
const msg = JSON.parse(line);
|
|
@@ -1151,7 +1418,7 @@ async function apiDeleteMessage(body, query) {
|
|
|
1151
1418
|
// Find the message and remove from history.jsonl (locked)
|
|
1152
1419
|
await withFileLock(historyFile, () => {
|
|
1153
1420
|
if (fs.existsSync(historyFile)) {
|
|
1154
|
-
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(
|
|
1421
|
+
const lines = fs.readFileSync(historyFile, 'utf8').trim().split(/\r?\n/);
|
|
1155
1422
|
for (const line of lines) {
|
|
1156
1423
|
try {
|
|
1157
1424
|
const msg = JSON.parse(line);
|
|
@@ -1182,7 +1449,7 @@ async function apiDeleteMessage(body, query) {
|
|
|
1182
1449
|
// Remove from messages.jsonl (locked independently)
|
|
1183
1450
|
await withFileLock(messagesFile, () => {
|
|
1184
1451
|
if (fs.existsSync(messagesFile)) {
|
|
1185
|
-
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(
|
|
1452
|
+
const lines = fs.readFileSync(messagesFile, 'utf8').trim().split(/\r?\n/);
|
|
1186
1453
|
const filtered = lines.filter(line => {
|
|
1187
1454
|
try { return JSON.parse(line).id !== id; } catch { return true; }
|
|
1188
1455
|
});
|
|
@@ -1331,6 +1598,28 @@ function parseBody(req) {
|
|
|
1331
1598
|
});
|
|
1332
1599
|
}
|
|
1333
1600
|
|
|
1601
|
+
// --- Rate limiting ---
|
|
1602
|
+
const apiRateLimits = new Map();
|
|
1603
|
+
function checkRateLimit(ip, limit = 60, windowMs = 60000) {
|
|
1604
|
+
const now = Date.now();
|
|
1605
|
+
const key = ip;
|
|
1606
|
+
if (!apiRateLimits.has(key)) apiRateLimits.set(key, []);
|
|
1607
|
+
const timestamps = apiRateLimits.get(key).filter(t => now - t < windowMs);
|
|
1608
|
+
apiRateLimits.set(key, timestamps);
|
|
1609
|
+
if (timestamps.length >= limit) return false;
|
|
1610
|
+
timestamps.push(now);
|
|
1611
|
+
return true;
|
|
1612
|
+
}
|
|
1613
|
+
// Periodic cleanup to prevent memory leak
|
|
1614
|
+
setInterval(() => {
|
|
1615
|
+
const now = Date.now();
|
|
1616
|
+
for (const [key, timestamps] of apiRateLimits) {
|
|
1617
|
+
const filtered = timestamps.filter(t => now - t < 60000);
|
|
1618
|
+
if (filtered.length === 0) apiRateLimits.delete(key);
|
|
1619
|
+
else apiRateLimits.set(key, filtered);
|
|
1620
|
+
}
|
|
1621
|
+
}, 300000).unref(); // Clean every 5 minutes, .unref() prevents zombie process
|
|
1622
|
+
|
|
1334
1623
|
const server = http.createServer(async (req, res) => {
|
|
1335
1624
|
const url = new URL(req.url, 'http://localhost:' + PORT);
|
|
1336
1625
|
|
|
@@ -1360,7 +1649,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1360
1649
|
const tokenFromQuery = url.searchParams.get('token');
|
|
1361
1650
|
const tokenFromHeader = req.headers['x-ltt-token'];
|
|
1362
1651
|
const providedToken = tokenFromHeader || tokenFromQuery;
|
|
1363
|
-
|
|
1652
|
+
const crypto = require('crypto');
|
|
1653
|
+
if (!providedToken || providedToken.length !== LAN_TOKEN.length || !crypto.timingSafeEqual(Buffer.from(providedToken), Buffer.from(LAN_TOKEN))) {
|
|
1364
1654
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1365
1655
|
res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing LAN token' }));
|
|
1366
1656
|
return;
|
|
@@ -1396,8 +1686,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1396
1686
|
// Custom header check above is the only protection layer here — allow through
|
|
1397
1687
|
// since local CLI tools (like our own `msg` command) need to work
|
|
1398
1688
|
}
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1689
|
+
const allowedSources = [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`];
|
|
1690
|
+
if (LAN_MODE && getLanIP()) allowedSources.push(`http://${getLanIP()}:${PORT}`);
|
|
1691
|
+
let sourceOrigin = '';
|
|
1692
|
+
try { sourceOrigin = source ? new URL(source).origin : ''; } catch { sourceOrigin = ''; }
|
|
1693
|
+
const isLocal = allowedSources.includes(sourceOrigin);
|
|
1694
|
+
const isLan = isLocal;
|
|
1401
1695
|
if (source && !isLocal && !isLan) {
|
|
1402
1696
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1403
1697
|
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
|
|
@@ -1405,6 +1699,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1405
1699
|
}
|
|
1406
1700
|
}
|
|
1407
1701
|
|
|
1702
|
+
// Rate limit API endpoints (only for non-localhost in LAN mode)
|
|
1703
|
+
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
1704
|
+
const isLocalhost = clientIP === '127.0.0.1' || clientIP === '::1' || clientIP === '::ffff:127.0.0.1';
|
|
1705
|
+
if (url.pathname.startsWith('/api/') && !isLocalhost && !checkRateLimit(clientIP, 300, 60000)) {
|
|
1706
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1707
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded. Try again later.' }));
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1408
1711
|
try {
|
|
1409
1712
|
// Validate project parameter on all API endpoints
|
|
1410
1713
|
const projectParam = url.searchParams.get('project');
|
|
@@ -1465,6 +1768,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1465
1768
|
} catch {}
|
|
1466
1769
|
const filePath = searchPaths.find(p => fs.existsSync(p));
|
|
1467
1770
|
if (filePath) {
|
|
1771
|
+
// Verify resolved path is within an allowed directory
|
|
1772
|
+
const resolvedFile = path.resolve(filePath);
|
|
1773
|
+
const allowedDirs = searchPaths.map(p => path.resolve(path.dirname(p)));
|
|
1774
|
+
const isAllowed = allowedDirs.some(dir => resolvedFile.startsWith(dir + path.sep) || resolvedFile === dir);
|
|
1775
|
+
if (!isAllowed) {
|
|
1776
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1777
|
+
}
|
|
1468
1778
|
const ext = path.extname(filePath);
|
|
1469
1779
|
const mimeTypes = { '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.wasm': 'application/wasm' };
|
|
1470
1780
|
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
@@ -1483,6 +1793,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1483
1793
|
res.writeHead(400); res.end('Bad path'); return;
|
|
1484
1794
|
}
|
|
1485
1795
|
const filePath = path.join(__dirname, 'office', officePath);
|
|
1796
|
+
const resolvedOffice = path.resolve(filePath);
|
|
1797
|
+
const allowedOfficeDir = path.resolve(path.join(__dirname, 'office'));
|
|
1798
|
+
if (!resolvedOffice.startsWith(allowedOfficeDir + path.sep) && resolvedOffice !== allowedOfficeDir) {
|
|
1799
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1800
|
+
}
|
|
1486
1801
|
if (fs.existsSync(filePath)) {
|
|
1487
1802
|
const ext = path.extname(filePath);
|
|
1488
1803
|
const mimeTypes = { '.js': 'application/javascript', '.json': 'application/json' };
|
|
@@ -1502,6 +1817,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1502
1817
|
res.writeHead(400); res.end('Bad path'); return;
|
|
1503
1818
|
}
|
|
1504
1819
|
const filePath = path.join(__dirname, 'mods', modPath);
|
|
1820
|
+
const resolvedMod = path.resolve(filePath);
|
|
1821
|
+
const allowedModDir = path.resolve(path.join(__dirname, 'mods'));
|
|
1822
|
+
if (!resolvedMod.startsWith(allowedModDir + path.sep) && resolvedMod !== allowedModDir) {
|
|
1823
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1824
|
+
}
|
|
1505
1825
|
if (fs.existsSync(filePath)) {
|
|
1506
1826
|
const ext = path.extname(filePath);
|
|
1507
1827
|
const allowedMime = { '.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.png': 'image/png' };
|
|
@@ -1522,7 +1842,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1522
1842
|
const html = fs.readFileSync(HTML_FILE, 'utf8');
|
|
1523
1843
|
res.writeHead(200, {
|
|
1524
1844
|
'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'",
|
|
1845
|
+
'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'",
|
|
1846
|
+
'X-Frame-Options': 'DENY',
|
|
1847
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1848
|
+
'Referrer-Policy': 'no-referrer',
|
|
1526
1849
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
1527
1850
|
'Pragma': 'no-cache',
|
|
1528
1851
|
'Expires': '0'
|
|
@@ -1720,9 +2043,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
1720
2043
|
res.end(JSON.stringify(apiStats(url.searchParams)));
|
|
1721
2044
|
}
|
|
1722
2045
|
else if (url.pathname === '/api/reset' && req.method === 'POST') {
|
|
2046
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2047
|
+
if (!body.confirm) {
|
|
2048
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2049
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
1723
2052
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1724
2053
|
res.end(JSON.stringify(apiReset(url.searchParams)));
|
|
1725
2054
|
}
|
|
2055
|
+
else if (url.pathname === '/api/clear-messages' && req.method === 'POST') {
|
|
2056
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2057
|
+
if (!body.confirm) {
|
|
2058
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2059
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2063
|
+
res.end(JSON.stringify(apiClearMessages(url.searchParams)));
|
|
2064
|
+
}
|
|
2065
|
+
else if (url.pathname === '/api/new-conversation' && req.method === 'POST') {
|
|
2066
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
2067
|
+
if (!body.confirm) {
|
|
2068
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2069
|
+
res.end(JSON.stringify({ error: 'Destructive action requires { "confirm": true } in request body' }));
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2073
|
+
res.end(JSON.stringify(apiNewConversation(url.searchParams)));
|
|
2074
|
+
}
|
|
2075
|
+
else if (url.pathname === '/api/conversations' && req.method === 'GET') {
|
|
2076
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2077
|
+
res.end(JSON.stringify(apiListConversations(url.searchParams)));
|
|
2078
|
+
}
|
|
2079
|
+
else if (url.pathname === '/api/load-conversation' && req.method === 'POST') {
|
|
2080
|
+
const result = apiLoadConversation(url.searchParams);
|
|
2081
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2082
|
+
res.end(JSON.stringify(result));
|
|
2083
|
+
}
|
|
1726
2084
|
// Message injection
|
|
1727
2085
|
else if (url.pathname === '/api/inject' && req.method === 'POST') {
|
|
1728
2086
|
const body = await parseBody(req);
|
|
@@ -1755,6 +2113,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1755
2113
|
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
1756
2114
|
res.end(JSON.stringify(result));
|
|
1757
2115
|
}
|
|
2116
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
2117
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2118
|
+
res.end(JSON.stringify(apiRules(url.searchParams)));
|
|
2119
|
+
}
|
|
2120
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
2121
|
+
const body = await parseBody(req);
|
|
2122
|
+
const action = body.action || 'add';
|
|
2123
|
+
let result;
|
|
2124
|
+
if (action === 'add') result = apiAddRule(body, url.searchParams);
|
|
2125
|
+
else if (action === 'update') result = apiUpdateRule(body, url.searchParams);
|
|
2126
|
+
else if (action === 'delete') result = apiDeleteRule(body, url.searchParams);
|
|
2127
|
+
else result = { error: 'Unknown action. Use: add, update, delete' };
|
|
2128
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2129
|
+
res.end(JSON.stringify(result));
|
|
2130
|
+
}
|
|
1758
2131
|
else if (url.pathname === '/api/search' && req.method === 'GET') {
|
|
1759
2132
|
const projectPath = url.searchParams.get('project') || null;
|
|
1760
2133
|
const query = (url.searchParams.get('q') || '').trim();
|
|
@@ -1798,7 +2171,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1798
2171
|
const history = apiHistory(url.searchParams);
|
|
1799
2172
|
const agents = apiAgents(url.searchParams);
|
|
1800
2173
|
const decisions = readJson(filePath('decisions.json', projectPath)) || [];
|
|
1801
|
-
const
|
|
2174
|
+
const tasksRaw = readJson(filePath('tasks.json', projectPath));
|
|
2175
|
+
const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
|
|
1802
2176
|
const channels = apiChannels(url.searchParams);
|
|
1803
2177
|
const pkg = readJson(path.join(__dirname, 'package.json')) || {};
|
|
1804
2178
|
const result = {
|
|
@@ -1885,7 +2259,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1885
2259
|
const projectPath = url.searchParams.get('project') || null;
|
|
1886
2260
|
const profilesFile = filePath('profiles.json', projectPath);
|
|
1887
2261
|
const profiles = readJson(profilesFile);
|
|
1888
|
-
if (!body.agent) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: '
|
|
2262
|
+
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; }
|
|
1889
2263
|
if (!profiles[body.agent]) profiles[body.agent] = {};
|
|
1890
2264
|
if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
|
|
1891
2265
|
if (body.avatar) {
|
|
@@ -1977,6 +2351,500 @@ const server = http.createServer(async (req, res) => {
|
|
|
1977
2351
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1978
2352
|
res.end(JSON.stringify({ success: true }));
|
|
1979
2353
|
}
|
|
2354
|
+
// ========== Plan Control API (v5.0 Autonomy Engine) ==========
|
|
2355
|
+
|
|
2356
|
+
else if (url.pathname === '/api/plan/status' && req.method === 'GET') {
|
|
2357
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2358
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2359
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2360
|
+
let workflows = [];
|
|
2361
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2362
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2363
|
+
|
|
2364
|
+
// Find the active autonomous workflow (most recent)
|
|
2365
|
+
const activeWf = workflows.filter(w => w.status === 'active' && w.autonomous).pop()
|
|
2366
|
+
|| workflows.filter(w => w.status === 'active').pop();
|
|
2367
|
+
|
|
2368
|
+
if (!activeWf) {
|
|
2369
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2370
|
+
res.end(JSON.stringify({ active: false, message: 'No active plan' }));
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
const doneSteps = activeWf.steps.filter(s => s.status === 'done').length;
|
|
2375
|
+
const totalSteps = activeWf.steps.length;
|
|
2376
|
+
const elapsed = Date.now() - new Date(activeWf.created_at).getTime();
|
|
2377
|
+
const activeAgents = Object.entries(agents).filter(([, a]) => {
|
|
2378
|
+
const idle = Date.now() - new Date(a.last_activity || 0).getTime();
|
|
2379
|
+
return idle < 120000;
|
|
2380
|
+
}).length;
|
|
2381
|
+
|
|
2382
|
+
const retryCount = activeWf.steps.filter(s => s.flagged).length;
|
|
2383
|
+
const avgConfidence = activeWf.steps.filter(s => s.verification && s.verification.confidence)
|
|
2384
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2385
|
+
|
|
2386
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2387
|
+
res.end(JSON.stringify({
|
|
2388
|
+
active: true,
|
|
2389
|
+
workflow_id: activeWf.id,
|
|
2390
|
+
name: activeWf.name,
|
|
2391
|
+
status: activeWf.status,
|
|
2392
|
+
autonomous: !!activeWf.autonomous,
|
|
2393
|
+
parallel: !!activeWf.parallel,
|
|
2394
|
+
paused: !!activeWf.paused,
|
|
2395
|
+
progress: { done: doneSteps, total: totalSteps, percent: Math.round((doneSteps / totalSteps) * 100) },
|
|
2396
|
+
elapsed_ms: elapsed,
|
|
2397
|
+
elapsed_human: Math.round(elapsed / 60000) + 'm',
|
|
2398
|
+
agents_active: activeAgents,
|
|
2399
|
+
steps: activeWf.steps.map(s => ({
|
|
2400
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2401
|
+
status: s.status, depends_on: s.depends_on || [],
|
|
2402
|
+
started_at: s.started_at, completed_at: s.completed_at,
|
|
2403
|
+
flagged: !!s.flagged, flag_reason: s.flag_reason || null,
|
|
2404
|
+
confidence: s.verification ? s.verification.confidence : null,
|
|
2405
|
+
verification: s.verification || null,
|
|
2406
|
+
})),
|
|
2407
|
+
retries: retryCount,
|
|
2408
|
+
avg_confidence: Math.round(avgConfidence) || null,
|
|
2409
|
+
created_at: activeWf.created_at,
|
|
2410
|
+
}));
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
else if (url.pathname === '/api/plan/pause' && req.method === 'POST') {
|
|
2414
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2415
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2416
|
+
let workflows = [];
|
|
2417
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2418
|
+
const activeWf = workflows.find(w => w.status === 'active' && w.autonomous);
|
|
2419
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active autonomous plan' })); return; }
|
|
2420
|
+
activeWf.paused = true;
|
|
2421
|
+
activeWf.paused_at = new Date().toISOString();
|
|
2422
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2423
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2424
|
+
// Notify agents
|
|
2425
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN PAUSED] "${activeWf.name}" has been paused by the dashboard. Finish your current step, then wait for resume.` }, url.searchParams);
|
|
2426
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2427
|
+
res.end(JSON.stringify({ success: true, message: 'Plan paused', workflow_id: activeWf.id }));
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
else if (url.pathname === '/api/plan/resume' && req.method === 'POST') {
|
|
2431
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2432
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2433
|
+
let workflows = [];
|
|
2434
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2435
|
+
const pausedWf = workflows.find(w => w.status === 'active' && w.paused);
|
|
2436
|
+
if (!pausedWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No paused plan' })); return; }
|
|
2437
|
+
pausedWf.paused = false;
|
|
2438
|
+
delete pausedWf.paused_at;
|
|
2439
|
+
pausedWf.updated_at = new Date().toISOString();
|
|
2440
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2441
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN RESUMED] "${pausedWf.name}" has been resumed. Call get_work() to continue.` }, url.searchParams);
|
|
2442
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2443
|
+
res.end(JSON.stringify({ success: true, message: 'Plan resumed', workflow_id: pausedWf.id }));
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
else if (url.pathname === '/api/plan/stop' && req.method === 'POST') {
|
|
2447
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2448
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2449
|
+
let workflows = [];
|
|
2450
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2451
|
+
const activeWf = workflows.find(w => w.status === 'active');
|
|
2452
|
+
if (!activeWf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active plan' })); return; }
|
|
2453
|
+
activeWf.status = 'stopped';
|
|
2454
|
+
activeWf.stopped_at = new Date().toISOString();
|
|
2455
|
+
activeWf.updated_at = new Date().toISOString();
|
|
2456
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2457
|
+
apiInjectMessage({ to: '__all__', content: `[PLAN STOPPED] "${activeWf.name}" has been stopped by the dashboard. All work on this plan should cease.` }, url.searchParams);
|
|
2458
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2459
|
+
res.end(JSON.stringify({ success: true, message: 'Plan stopped', workflow_id: activeWf.id }));
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
else if (url.pathname.startsWith('/api/plan/skip/') && req.method === 'POST') {
|
|
2463
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2464
|
+
const body = await parseBody(req);
|
|
2465
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2466
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2467
|
+
let workflows = [];
|
|
2468
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2469
|
+
const wfId = body.workflow_id;
|
|
2470
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2471
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2472
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2473
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2474
|
+
step.status = 'done';
|
|
2475
|
+
step.notes = (step.notes || '') + ' [Skipped from dashboard]';
|
|
2476
|
+
step.completed_at = new Date().toISOString();
|
|
2477
|
+
step.skipped = true;
|
|
2478
|
+
// Start any newly ready steps
|
|
2479
|
+
const readySteps = wf.steps.filter(s => {
|
|
2480
|
+
if (s.status !== 'pending') return false;
|
|
2481
|
+
if (!s.depends_on || s.depends_on.length === 0) return true;
|
|
2482
|
+
return s.depends_on.every(depId => { const d = wf.steps.find(x => x.id === depId); return d && d.status === 'done'; });
|
|
2483
|
+
});
|
|
2484
|
+
for (const rs of readySteps) { rs.status = 'in_progress'; rs.started_at = new Date().toISOString(); }
|
|
2485
|
+
if (!wf.steps.find(s => s.status === 'pending' || s.status === 'in_progress')) wf.status = 'completed';
|
|
2486
|
+
wf.updated_at = new Date().toISOString();
|
|
2487
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2488
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2489
|
+
res.end(JSON.stringify({ success: true, skipped_step: stepId, ready_steps: readySteps.map(s => s.id) }));
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
else if (url.pathname.startsWith('/api/plan/reassign/') && req.method === 'POST') {
|
|
2493
|
+
const stepId = parseInt(url.pathname.split('/').pop(), 10);
|
|
2494
|
+
const body = await parseBody(req);
|
|
2495
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2496
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2497
|
+
if (!body.new_assignee) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'new_assignee required' })); return; }
|
|
2498
|
+
let workflows = [];
|
|
2499
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2500
|
+
const wfId = body.workflow_id;
|
|
2501
|
+
const wf = wfId ? workflows.find(w => w.id === wfId) : workflows.find(w => w.status === 'active');
|
|
2502
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Workflow not found' })); return; }
|
|
2503
|
+
const step = wf.steps.find(s => s.id === stepId);
|
|
2504
|
+
if (!step) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Step not found: ' + stepId })); return; }
|
|
2505
|
+
const oldAssignee = step.assignee;
|
|
2506
|
+
step.assignee = body.new_assignee;
|
|
2507
|
+
wf.updated_at = new Date().toISOString();
|
|
2508
|
+
fs.writeFileSync(wfFile, JSON.stringify(workflows, null, 2));
|
|
2509
|
+
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);
|
|
2510
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2511
|
+
res.end(JSON.stringify({ success: true, step_id: stepId, old_assignee: oldAssignee, new_assignee: body.new_assignee }));
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
else if (url.pathname === '/api/plan/inject' && req.method === 'POST') {
|
|
2515
|
+
const body = await parseBody(req);
|
|
2516
|
+
if (!body.content) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'content required' })); return; }
|
|
2517
|
+
const result = apiInjectMessage({ to: body.to || '__all__', content: body.content }, url.searchParams);
|
|
2518
|
+
res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
|
|
2519
|
+
res.end(JSON.stringify(result));
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
else if (url.pathname === '/api/plan/report' && req.method === 'GET') {
|
|
2523
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2524
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2525
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2526
|
+
let workflows = [];
|
|
2527
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2528
|
+
// Get most recent completed or active workflow
|
|
2529
|
+
const wf = workflows.filter(w => w.status === 'completed').pop() || workflows.filter(w => w.status === 'active').pop();
|
|
2530
|
+
if (!wf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No plan found' })); return; }
|
|
2531
|
+
|
|
2532
|
+
const doneSteps = wf.steps.filter(s => s.status === 'done');
|
|
2533
|
+
const flaggedSteps = wf.steps.filter(s => s.flagged);
|
|
2534
|
+
const duration = wf.completed_at ? new Date(wf.completed_at) - new Date(wf.created_at) : Date.now() - new Date(wf.created_at).getTime();
|
|
2535
|
+
const avgConf = doneSteps.filter(s => s.verification && s.verification.confidence)
|
|
2536
|
+
.reduce((sum, s, _, arr) => sum + s.verification.confidence / arr.length, 0);
|
|
2537
|
+
|
|
2538
|
+
// Count skills learned during this plan
|
|
2539
|
+
let skillCount = 0;
|
|
2540
|
+
if (fs.existsSync(kbFile)) {
|
|
2541
|
+
try {
|
|
2542
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2543
|
+
skillCount = Object.keys(kb).filter(k => k.startsWith('skill_') || k.startsWith('lesson_')).length;
|
|
2544
|
+
} catch {}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Agent-level performance analytics
|
|
2548
|
+
const agentStats = {};
|
|
2549
|
+
for (const s of wf.steps) {
|
|
2550
|
+
if (!s.assignee) continue;
|
|
2551
|
+
if (!agentStats[s.assignee]) agentStats[s.assignee] = { steps: 0, completed: 0, flagged: 0, total_ms: 0, confidences: [] };
|
|
2552
|
+
agentStats[s.assignee].steps++;
|
|
2553
|
+
if (s.status === 'done') {
|
|
2554
|
+
agentStats[s.assignee].completed++;
|
|
2555
|
+
if (s.completed_at && s.started_at) agentStats[s.assignee].total_ms += new Date(s.completed_at) - new Date(s.started_at);
|
|
2556
|
+
if (s.verification && s.verification.confidence) agentStats[s.assignee].confidences.push(s.verification.confidence);
|
|
2557
|
+
}
|
|
2558
|
+
if (s.flagged) agentStats[s.assignee].flagged++;
|
|
2559
|
+
}
|
|
2560
|
+
const agentPerformance = Object.entries(agentStats).map(([name, stats]) => ({
|
|
2561
|
+
agent: name, steps_assigned: stats.steps, steps_completed: stats.completed, steps_flagged: stats.flagged,
|
|
2562
|
+
avg_duration_ms: stats.completed > 0 ? Math.round(stats.total_ms / stats.completed) : null,
|
|
2563
|
+
avg_confidence: stats.confidences.length > 0 ? Math.round(stats.confidences.reduce((a, b) => a + b, 0) / stats.confidences.length) : null,
|
|
2564
|
+
}));
|
|
2565
|
+
|
|
2566
|
+
// Slowest/fastest steps
|
|
2567
|
+
const stepsWithDuration = wf.steps.filter(s => s.completed_at && s.started_at)
|
|
2568
|
+
.map(s => ({ id: s.id, description: s.description, assignee: s.assignee, duration_ms: new Date(s.completed_at) - new Date(s.started_at) }))
|
|
2569
|
+
.sort((a, b) => b.duration_ms - a.duration_ms);
|
|
2570
|
+
|
|
2571
|
+
// Retry count from workspace data
|
|
2572
|
+
let retryCount = 0;
|
|
2573
|
+
const wsDir = path.join(resolveDataDir(projectPath), 'workspaces');
|
|
2574
|
+
if (fs.existsSync(wsDir)) {
|
|
2575
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2576
|
+
try {
|
|
2577
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2578
|
+
if (ws.retry_history) {
|
|
2579
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2580
|
+
if (Array.isArray(history)) retryCount += history.length;
|
|
2581
|
+
}
|
|
2582
|
+
} catch {}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2587
|
+
res.end(JSON.stringify({
|
|
2588
|
+
name: wf.name,
|
|
2589
|
+
status: wf.status,
|
|
2590
|
+
steps_done: doneSteps.length,
|
|
2591
|
+
steps_total: wf.steps.length,
|
|
2592
|
+
duration_ms: duration,
|
|
2593
|
+
duration_human: Math.round(duration / 60000) + 'm',
|
|
2594
|
+
avg_confidence: Math.round(avgConf) || null,
|
|
2595
|
+
flagged_steps: flaggedSteps.map(s => ({ id: s.id, description: s.description, reason: s.flag_reason })),
|
|
2596
|
+
skills_learned: skillCount,
|
|
2597
|
+
retries: retryCount,
|
|
2598
|
+
agent_performance: agentPerformance,
|
|
2599
|
+
slowest_step: stepsWithDuration[0] || null,
|
|
2600
|
+
fastest_step: stepsWithDuration[stepsWithDuration.length - 1] || null,
|
|
2601
|
+
steps: wf.steps.map(s => ({
|
|
2602
|
+
id: s.id, description: s.description, assignee: s.assignee,
|
|
2603
|
+
status: s.status, confidence: s.verification ? s.verification.confidence : null,
|
|
2604
|
+
duration_ms: s.completed_at && s.started_at ? new Date(s.completed_at) - new Date(s.started_at) : null,
|
|
2605
|
+
flagged: !!s.flagged, skipped: !!s.skipped,
|
|
2606
|
+
})),
|
|
2607
|
+
created_at: wf.created_at,
|
|
2608
|
+
completed_at: wf.completed_at || null,
|
|
2609
|
+
}));
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
else if (url.pathname === '/api/plan/skills' && req.method === 'GET') {
|
|
2613
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2614
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2615
|
+
let skills = [];
|
|
2616
|
+
if (fs.existsSync(kbFile)) {
|
|
2617
|
+
try {
|
|
2618
|
+
const kb = JSON.parse(fs.readFileSync(kbFile, 'utf8'));
|
|
2619
|
+
for (const [key, val] of Object.entries(kb)) {
|
|
2620
|
+
if (key.startsWith('skill_') || key.startsWith('lesson_')) {
|
|
2621
|
+
skills.push({ key, content: val.content, learned_by: val.updated_by, learned_at: val.updated_at });
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
} catch {}
|
|
2625
|
+
}
|
|
2626
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2627
|
+
res.end(JSON.stringify({ count: skills.length, skills }));
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
else if (url.pathname === '/api/plan/retries' && req.method === 'GET') {
|
|
2631
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2632
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2633
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2634
|
+
let retries = [];
|
|
2635
|
+
if (fs.existsSync(wsDir)) {
|
|
2636
|
+
for (const file of fs.readdirSync(wsDir)) {
|
|
2637
|
+
try {
|
|
2638
|
+
const ws = JSON.parse(fs.readFileSync(path.join(wsDir, file), 'utf8'));
|
|
2639
|
+
if (ws.retry_history) {
|
|
2640
|
+
const agent = file.replace('.json', '');
|
|
2641
|
+
const history = typeof ws.retry_history === 'string' ? JSON.parse(ws.retry_history) : ws.retry_history;
|
|
2642
|
+
if (Array.isArray(history)) {
|
|
2643
|
+
for (const entry of history) { retries.push({ agent, ...entry }); }
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
} catch {}
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
retries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
|
2650
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2651
|
+
res.end(JSON.stringify({ count: retries.length, retries }));
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// ========== Monitor Agent API ==========
|
|
2655
|
+
|
|
2656
|
+
else if (url.pathname === '/api/monitor/health' && req.method === 'GET') {
|
|
2657
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2658
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2659
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2660
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2661
|
+
const profilesFile = filePath('profiles.json', projectPath);
|
|
2662
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2663
|
+
|
|
2664
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2665
|
+
const profiles = fs.existsSync(profilesFile) ? readJson(profilesFile) : {};
|
|
2666
|
+
let workflows = [];
|
|
2667
|
+
if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2668
|
+
let tasks = [];
|
|
2669
|
+
if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2670
|
+
|
|
2671
|
+
// Find monitor agent
|
|
2672
|
+
const monitorName = Object.entries(profiles).find(([, p]) => p.role === 'monitor');
|
|
2673
|
+
const now = Date.now();
|
|
2674
|
+
|
|
2675
|
+
// Agent health summary
|
|
2676
|
+
const agentHealth = Object.entries(agents).map(([name, a]) => {
|
|
2677
|
+
const idle = now - new Date(a.last_activity || 0).getTime();
|
|
2678
|
+
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 };
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
const idleAgents = agentHealth.filter(a => a.status === 'idle').length;
|
|
2682
|
+
const stuckAgents = agentHealth.filter(a => a.status === 'stuck').length;
|
|
2683
|
+
const activeWorkflows = workflows.filter(w => w.status === 'active').length;
|
|
2684
|
+
const pendingTasks = tasks.filter(t => t.status === 'pending').length;
|
|
2685
|
+
const blockedTasks = tasks.filter(t => t.status === 'blocked' || t.status === 'blocked_permanent').length;
|
|
2686
|
+
|
|
2687
|
+
// Monitor intervention log from workspace
|
|
2688
|
+
let interventions = [];
|
|
2689
|
+
const wsDir = path.join(dataDir, 'workspaces');
|
|
2690
|
+
if (monitorName && fs.existsSync(wsDir)) {
|
|
2691
|
+
const monFile = path.join(wsDir, monitorName[0] + '.json');
|
|
2692
|
+
if (fs.existsSync(monFile)) {
|
|
2693
|
+
try {
|
|
2694
|
+
const ws = JSON.parse(fs.readFileSync(monFile, 'utf8'));
|
|
2695
|
+
if (ws._monitor_log) interventions = typeof ws._monitor_log === 'string' ? JSON.parse(ws._monitor_log) : ws._monitor_log;
|
|
2696
|
+
} catch {}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2701
|
+
res.end(JSON.stringify({
|
|
2702
|
+
monitor: monitorName ? { name: monitorName[0], active: true } : { active: false },
|
|
2703
|
+
health: {
|
|
2704
|
+
total_agents: Object.keys(agents).length,
|
|
2705
|
+
active: agentHealth.filter(a => a.status === 'active').length,
|
|
2706
|
+
idle: idleAgents,
|
|
2707
|
+
stuck: stuckAgents,
|
|
2708
|
+
active_workflows: activeWorkflows,
|
|
2709
|
+
pending_tasks: pendingTasks,
|
|
2710
|
+
blocked_tasks: blockedTasks,
|
|
2711
|
+
},
|
|
2712
|
+
agents: agentHealth,
|
|
2713
|
+
interventions: interventions.slice(-20),
|
|
2714
|
+
timestamp: new Date().toISOString(),
|
|
2715
|
+
}));
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// ========== Reputation API ==========
|
|
2719
|
+
|
|
2720
|
+
else if (url.pathname === '/api/reputation' && req.method === 'GET') {
|
|
2721
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2722
|
+
const repFile = filePath('reputation.json', projectPath);
|
|
2723
|
+
const rep = fs.existsSync(repFile) ? readJson(repFile) : {};
|
|
2724
|
+
|
|
2725
|
+
// Calculate scores and build leaderboard
|
|
2726
|
+
const leaderboard = Object.entries(rep).map(([name, r]) => {
|
|
2727
|
+
const score = (r.tasks_completed || 0) * 2
|
|
2728
|
+
+ (r.reviews_done || 0) * 1
|
|
2729
|
+
+ (r.help_given || 0) * 3
|
|
2730
|
+
+ (r.kb_contributions || 0) * 1
|
|
2731
|
+
- (r.retries || 0) * 1
|
|
2732
|
+
- (r.watchdog_nudges || 0) * 2;
|
|
2733
|
+
return {
|
|
2734
|
+
name, score,
|
|
2735
|
+
tasks_completed: r.tasks_completed || 0,
|
|
2736
|
+
reviews_done: r.reviews_done || 0,
|
|
2737
|
+
retries: r.retries || 0,
|
|
2738
|
+
watchdog_nudges: r.watchdog_nudges || 0,
|
|
2739
|
+
help_given: r.help_given || 0,
|
|
2740
|
+
strengths: r.strengths || [],
|
|
2741
|
+
};
|
|
2742
|
+
}).sort((a, b) => b.score - a.score);
|
|
2743
|
+
|
|
2744
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2745
|
+
res.end(JSON.stringify({ leaderboard, timestamp: new Date().toISOString() }));
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// ========== System Stats API ==========
|
|
2749
|
+
|
|
2750
|
+
else if (url.pathname === '/api/stats' && req.method === 'GET') {
|
|
2751
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2752
|
+
const dataDir = resolveDataDir(projectPath);
|
|
2753
|
+
const agentsFile = filePath('agents.json', projectPath);
|
|
2754
|
+
const wfFile = filePath('workflows.json', projectPath);
|
|
2755
|
+
const tasksFile = filePath('tasks.json', projectPath);
|
|
2756
|
+
const histFile = path.join(dataDir, 'history.jsonl');
|
|
2757
|
+
const kbFile = filePath('kb.json', projectPath);
|
|
2758
|
+
|
|
2759
|
+
const agents = fs.existsSync(agentsFile) ? readJson(agentsFile) : {};
|
|
2760
|
+
let workflows = []; if (fs.existsSync(wfFile)) try { workflows = JSON.parse(fs.readFileSync(wfFile, 'utf8')); } catch {}
|
|
2761
|
+
let tasks = []; if (fs.existsSync(tasksFile)) try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
|
|
2762
|
+
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 {} }
|
|
2763
|
+
let kbKeys = 0; if (fs.existsSync(kbFile)) try { kbKeys = Object.keys(JSON.parse(fs.readFileSync(kbFile, 'utf8'))).length; } catch {}
|
|
2764
|
+
|
|
2765
|
+
const aliveCount = Object.values(agents).filter(a => { const idle = Date.now() - new Date(a.last_activity || 0).getTime(); return idle < 120000; }).length;
|
|
2766
|
+
const activeWf = workflows.filter(w => w.status === 'active');
|
|
2767
|
+
const completedWf = workflows.filter(w => w.status === 'completed');
|
|
2768
|
+
const tasksDone = tasks.filter(t => t.status === 'done').length;
|
|
2769
|
+
const tasksActive = tasks.filter(t => t.status === 'in_progress').length;
|
|
2770
|
+
|
|
2771
|
+
// Heartbeat files count
|
|
2772
|
+
let hbCount = 0;
|
|
2773
|
+
try { hbCount = fs.readdirSync(dataDir).filter(f => f.startsWith('heartbeat-')).length; } catch {}
|
|
2774
|
+
|
|
2775
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2776
|
+
res.end(JSON.stringify({
|
|
2777
|
+
agents: { total: Math.max(Object.keys(agents).length, hbCount), alive: aliveCount },
|
|
2778
|
+
messages: { total: msgCount },
|
|
2779
|
+
tasks: { total: tasks.length, done: tasksDone, active: tasksActive, pending: tasks.length - tasksDone - tasksActive },
|
|
2780
|
+
workflows: { total: workflows.length, active: activeWf.length, completed: completedWf.length },
|
|
2781
|
+
active_plan: activeWf.length > 0 ? { name: activeWf[0].name, progress: activeWf[0].steps.filter(s => s.status === 'done').length + '/' + activeWf[0].steps.length } : null,
|
|
2782
|
+
knowledge_base: { entries: kbKeys },
|
|
2783
|
+
timestamp: new Date().toISOString(),
|
|
2784
|
+
}));
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// ========== Rules API ==========
|
|
2788
|
+
|
|
2789
|
+
else if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
2790
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2791
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2792
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2793
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2794
|
+
res.end(JSON.stringify(Array.isArray(rules) ? rules : []));
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
else if (url.pathname === '/api/rules' && req.method === 'POST') {
|
|
2798
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2799
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2800
|
+
try {
|
|
2801
|
+
const body = await parseBody(req);
|
|
2802
|
+
const { text, category } = body;
|
|
2803
|
+
if (!text || !text.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'Rule text required' })); return; }
|
|
2804
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2805
|
+
const rule = {
|
|
2806
|
+
id: 'rule_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
2807
|
+
text: text.trim(),
|
|
2808
|
+
category: category || 'custom',
|
|
2809
|
+
created_by: 'dashboard',
|
|
2810
|
+
created_at: new Date().toISOString(),
|
|
2811
|
+
active: true,
|
|
2812
|
+
};
|
|
2813
|
+
rules.push(rule);
|
|
2814
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2815
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
2816
|
+
res.end(JSON.stringify(rule));
|
|
2817
|
+
} catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); }
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
else if (url.pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
|
|
2821
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2822
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2823
|
+
const ruleId = url.pathname.split('/api/rules/')[1];
|
|
2824
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2825
|
+
const idx = rules.findIndex(r => r.id === ruleId);
|
|
2826
|
+
if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2827
|
+
rules.splice(idx, 1);
|
|
2828
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2829
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2830
|
+
res.end(JSON.stringify({ success: true }));
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
else if (url.pathname.startsWith('/api/rules/') && url.pathname.endsWith('/toggle') && req.method === 'POST') {
|
|
2834
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
2835
|
+
const rulesFile = filePath('rules.json', projectPath);
|
|
2836
|
+
const ruleId = url.pathname.split('/api/rules/')[1].replace('/toggle', '');
|
|
2837
|
+
const rules = fs.existsSync(rulesFile) ? readJson(rulesFile) : [];
|
|
2838
|
+
const rule = rules.find(r => r.id === ruleId);
|
|
2839
|
+
if (!rule) { res.writeHead(404); res.end(JSON.stringify({ error: 'Rule not found' })); return; }
|
|
2840
|
+
rule.active = !rule.active;
|
|
2841
|
+
fs.writeFileSync(rulesFile, JSON.stringify(rules));
|
|
2842
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2843
|
+
res.end(JSON.stringify(rule));
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// ========== End Rules API ==========
|
|
2847
|
+
|
|
1980
2848
|
else if (url.pathname === '/api/branches' && req.method === 'GET') {
|
|
1981
2849
|
const projectPath = url.searchParams.get('project') || null;
|
|
1982
2850
|
const branchesFile = filePath('branches.json', projectPath);
|
|
@@ -1988,7 +2856,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1988
2856
|
let msgCount = 0;
|
|
1989
2857
|
if (fs.existsSync(histFile)) {
|
|
1990
2858
|
const content = fs.readFileSync(histFile, 'utf8').trim();
|
|
1991
|
-
if (content) msgCount = content.split(
|
|
2859
|
+
if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
|
|
1992
2860
|
}
|
|
1993
2861
|
branches[name].message_count = msgCount;
|
|
1994
2862
|
}
|
|
@@ -2047,7 +2915,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2047
2915
|
// Server info (LAN mode detection for frontend)
|
|
2048
2916
|
else if (url.pathname === '/api/server-info' && req.method === 'GET') {
|
|
2049
2917
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2050
|
-
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT
|
|
2918
|
+
res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
|
|
2051
2919
|
}
|
|
2052
2920
|
// Toggle LAN mode (re-bind server live)
|
|
2053
2921
|
else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
|
|
@@ -2059,7 +2927,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2059
2927
|
if (newMode) generateLanToken();
|
|
2060
2928
|
// Send response first
|
|
2061
2929
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2062
|
-
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT
|
|
2930
|
+
res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
|
|
2063
2931
|
// Re-bind by stopping the listener and immediately re-listening
|
|
2064
2932
|
// Use setImmediate to let the response flush first
|
|
2065
2933
|
setImmediate(() => {
|
|
@@ -2133,17 +3001,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
2133
3001
|
res.end(JSON.stringify({ error: 'Too many SSE connections' }));
|
|
2134
3002
|
return;
|
|
2135
3003
|
}
|
|
3004
|
+
// Per-IP SSE limit (max 5 connections per IP)
|
|
3005
|
+
const sseIP = req.socket.remoteAddress || 'unknown';
|
|
3006
|
+
const sseIPCount = [...sseClients].filter(c => c._sseIP === sseIP).length;
|
|
3007
|
+
if (sseIPCount >= 5) {
|
|
3008
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
3009
|
+
res.end(JSON.stringify({ error: 'Too many SSE connections from this IP (max 5)' }));
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
2136
3012
|
res.writeHead(200, {
|
|
2137
3013
|
'Content-Type': 'text/event-stream',
|
|
2138
3014
|
'Cache-Control': 'no-cache',
|
|
2139
3015
|
'Connection': 'keep-alive',
|
|
2140
3016
|
});
|
|
2141
3017
|
res.write(`data: connected\n\n`);
|
|
3018
|
+
res._sseIP = sseIP;
|
|
2142
3019
|
sseClients.add(res);
|
|
2143
3020
|
// Heartbeat every 30s to detect dead connections and prevent proxy timeouts
|
|
2144
3021
|
const heartbeat = setInterval(() => {
|
|
2145
3022
|
try { res.write(`:heartbeat\n\n`); } catch { clearInterval(heartbeat); sseClients.delete(res); }
|
|
2146
3023
|
}, 30000);
|
|
3024
|
+
heartbeat.unref();
|
|
2147
3025
|
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
|
|
2148
3026
|
}
|
|
2149
3027
|
// --- Mod system API ---
|
|
@@ -2208,7 +3086,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2208
3086
|
return;
|
|
2209
3087
|
}
|
|
2210
3088
|
}
|
|
2211
|
-
|
|
3089
|
+
const resolvedAsset = path.resolve(path.join(modDir, assetFile));
|
|
3090
|
+
if (!resolvedAsset.startsWith(path.resolve(modDir) + path.sep)) {
|
|
3091
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3092
|
+
res.end(JSON.stringify({ error: 'Invalid asset file path' }));
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
fs.writeFileSync(resolvedAsset, buf);
|
|
2212
3096
|
manifest.asset.file = assetFile;
|
|
2213
3097
|
}
|
|
2214
3098
|
// Write manifest
|
|
@@ -2267,6 +3151,134 @@ const server = http.createServer(async (req, res) => {
|
|
|
2267
3151
|
res.end(JSON.stringify({ success: true }));
|
|
2268
3152
|
});
|
|
2269
3153
|
}
|
|
3154
|
+
// --- City API endpoints (AI City Phase 1) ---
|
|
3155
|
+
else if (url.pathname === '/api/city/layout' && req.method === 'GET') {
|
|
3156
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3157
|
+
const cityMapFile = filePath('city-map.json', projectPath);
|
|
3158
|
+
if (fs.existsSync(cityMapFile)) {
|
|
3159
|
+
try {
|
|
3160
|
+
const data = JSON.parse(fs.readFileSync(cityMapFile, 'utf8'));
|
|
3161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3162
|
+
res.end(JSON.stringify(data));
|
|
3163
|
+
} catch {
|
|
3164
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3165
|
+
res.end(JSON.stringify({ districts: {}, buildings: {} }));
|
|
3166
|
+
}
|
|
3167
|
+
} else {
|
|
3168
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3169
|
+
res.end(JSON.stringify({ districts: {}, buildings: {} }));
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
else if (url.pathname === '/api/city/agents' && req.method === 'GET') {
|
|
3173
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3174
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
3175
|
+
const tasksRaw = readJson(filePath('tasks.json', projectPath));
|
|
3176
|
+
const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
|
|
3177
|
+
const cityAgents = {};
|
|
3178
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
3179
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
3180
|
+
const lastActivity = info.last_activity || info.timestamp;
|
|
3181
|
+
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
3182
|
+
const isListening = !!(info.listening_since && alive);
|
|
3183
|
+
const activeTasks = tasks.filter(t => t.assignee === name && t.status !== 'done');
|
|
3184
|
+
let behavior = 'dead';
|
|
3185
|
+
let location = null;
|
|
3186
|
+
if (alive) {
|
|
3187
|
+
if (activeTasks.length > 0) { behavior = 'working'; location = 'office'; }
|
|
3188
|
+
else if (isListening) { behavior = 'listening'; location = 'office'; }
|
|
3189
|
+
else if (idleSeconds > 900) { behavior = 'off_duty'; location = 'residential'; }
|
|
3190
|
+
else if (idleSeconds > 300) { behavior = 'off_duty'; location = 'cafe'; }
|
|
3191
|
+
else { behavior = 'idle'; location = 'office'; }
|
|
3192
|
+
}
|
|
3193
|
+
cityAgents[name] = {
|
|
3194
|
+
alive,
|
|
3195
|
+
behavior,
|
|
3196
|
+
location,
|
|
3197
|
+
branch: info.branch || 'main',
|
|
3198
|
+
idle_seconds: alive ? idleSeconds : null,
|
|
3199
|
+
provider: info.provider || 'unknown',
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3203
|
+
res.end(JSON.stringify(cityAgents));
|
|
3204
|
+
}
|
|
3205
|
+
// Phase 2: Agent activity radio feed for car HUD
|
|
3206
|
+
else if (url.pathname === '/api/city/radio' && req.method === 'GET') {
|
|
3207
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3208
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 50);
|
|
3209
|
+
const history = readJsonl(filePath('history.jsonl', projectPath));
|
|
3210
|
+
const agents = readJson(filePath('agents.json', projectPath));
|
|
3211
|
+
const feed = [];
|
|
3212
|
+
// Recent messages (skip system messages, truncate content)
|
|
3213
|
+
const recentMsgs = history.slice(-limit * 2).filter(m => m.from !== '__system__');
|
|
3214
|
+
for (const m of recentMsgs.slice(-limit)) {
|
|
3215
|
+
feed.push({
|
|
3216
|
+
type: 'message',
|
|
3217
|
+
from: m.from,
|
|
3218
|
+
to: m.to === '__group__' ? 'everyone' : m.to,
|
|
3219
|
+
preview: (m.content || '').slice(0, 120),
|
|
3220
|
+
timestamp: m.timestamp,
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
// Agent status updates (who's alive, who just joined/died)
|
|
3224
|
+
const statuses = [];
|
|
3225
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
3226
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
3227
|
+
statuses.push({ name, alive, last_activity: info.last_activity });
|
|
3228
|
+
}
|
|
3229
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3230
|
+
res.end(JSON.stringify({ feed, agents_status: statuses, total_messages: history.length }));
|
|
3231
|
+
}
|
|
3232
|
+
// Phase 3: Economy system
|
|
3233
|
+
else if (url.pathname === '/api/city/economy' && req.method === 'GET') {
|
|
3234
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3235
|
+
const balances = getBalances(projectPath);
|
|
3236
|
+
const ledger = getEconomyLedger(projectPath);
|
|
3237
|
+
const recent = ledger.slice(-20);
|
|
3238
|
+
const totalCredits = Object.values(balances).reduce((s, v) => s + v, 0);
|
|
3239
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3240
|
+
res.end(JSON.stringify({ balances, recent_transactions: recent, total_credits: totalCredits, ledger_entries: ledger.length }));
|
|
3241
|
+
}
|
|
3242
|
+
else if (url.pathname === '/api/city/economy' && req.method === 'POST') {
|
|
3243
|
+
const body = await parseBody(req);
|
|
3244
|
+
const projectPath = url.searchParams.get('project') || null;
|
|
3245
|
+
if (!body.agent || !body.amount || !body.reason) {
|
|
3246
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3247
|
+
res.end(JSON.stringify({ error: 'Missing agent, amount, or reason' }));
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
const amount = parseInt(body.amount, 10);
|
|
3251
|
+
if (isNaN(amount)) {
|
|
3252
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3253
|
+
res.end(JSON.stringify({ error: 'Invalid amount' }));
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
// Prevent negative balances on spend
|
|
3257
|
+
if (amount < 0) {
|
|
3258
|
+
const balances = getBalances(projectPath);
|
|
3259
|
+
const current = balances[body.agent] || 0;
|
|
3260
|
+
if (current + amount < 0) {
|
|
3261
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
3262
|
+
res.end(JSON.stringify({ error: 'Insufficient credits', balance: current, requested: Math.abs(amount) }));
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
appendEconomyEntry(projectPath, { agent: body.agent, amount, reason: body.reason, type: amount > 0 ? 'earn' : 'spend' });
|
|
3267
|
+
const balances = getBalances(projectPath);
|
|
3268
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3269
|
+
res.end(JSON.stringify({ success: true, balance: balances[body.agent] || 0 }));
|
|
3270
|
+
}
|
|
3271
|
+
// Phase 4: Game time endpoint
|
|
3272
|
+
else if (url.pathname === '/api/city/time' && req.method === 'GET') {
|
|
3273
|
+
const speed = parseInt(url.searchParams.get('speed') || '60', 10) || 60;
|
|
3274
|
+
const now = Date.now();
|
|
3275
|
+
const gameMinutes = Math.floor((now / 1000) * speed / 60) % 1440;
|
|
3276
|
+
const hours = Math.floor(gameMinutes / 60);
|
|
3277
|
+
const minutes = gameMinutes % 60;
|
|
3278
|
+
const period = hours < 6 ? 'night' : hours < 8 ? 'dawn' : hours < 18 ? 'day' : hours < 20 ? 'dusk' : 'night';
|
|
3279
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
3280
|
+
res.end(JSON.stringify({ hours, minutes, period, game_minutes: gameMinutes, speed, formatted: `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}` }));
|
|
3281
|
+
}
|
|
2270
3282
|
else {
|
|
2271
3283
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2272
3284
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
@@ -2282,17 +3294,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
2282
3294
|
// Watches data files and pushes updates to connected clients instantly
|
|
2283
3295
|
const sseClients = new Set();
|
|
2284
3296
|
|
|
2285
|
-
function sseNotifyAll() {
|
|
3297
|
+
function sseNotifyAll(changeType) {
|
|
2286
3298
|
// Generate notifications from agent state changes
|
|
2287
3299
|
try {
|
|
2288
3300
|
const agents = readJson(filePath('agents.json'));
|
|
2289
3301
|
generateNotifications(agents);
|
|
2290
3302
|
} catch {}
|
|
2291
3303
|
|
|
3304
|
+
// Send typed change event so client can do targeted fetches
|
|
3305
|
+
const eventData = changeType || 'update';
|
|
2292
3306
|
const dead = [];
|
|
2293
3307
|
for (const res of Array.from(sseClients)) {
|
|
2294
3308
|
try {
|
|
2295
|
-
res.write(`data:
|
|
3309
|
+
res.write(`data: ${(eventData || '').replace(/[\r\n]/g, '')}\n\n`);
|
|
2296
3310
|
} catch {
|
|
2297
3311
|
dead.push(res);
|
|
2298
3312
|
}
|
|
@@ -2312,13 +3326,38 @@ function startFileWatcher() {
|
|
|
2312
3326
|
const dataDir = resolveDataDir();
|
|
2313
3327
|
if (!fs.existsSync(dataDir)) return;
|
|
2314
3328
|
try {
|
|
3329
|
+
// Track pending change types for diff-based SSE
|
|
3330
|
+
let pendingChangeTypes = new Set();
|
|
2315
3331
|
fsWatcher = fs.watch(dataDir, { persistent: false }, (eventType, filename) => {
|
|
2316
3332
|
// Filter: only react to data files, not temp/lock files
|
|
2317
3333
|
if (filename && !filename.endsWith('.json') && !filename.endsWith('.jsonl')) return;
|
|
2318
3334
|
if (filename && filename.endsWith('.lock')) return;
|
|
3335
|
+
// Scale fix: skip heartbeat file changes — they fire 100x/10s at scale
|
|
3336
|
+
// Dashboard already polls agents via /api/agents on its own interval
|
|
3337
|
+
if (filename && filename.startsWith('heartbeat-')) return;
|
|
3338
|
+
|
|
3339
|
+
// Classify change type for targeted client fetches
|
|
3340
|
+
if (filename === 'messages.jsonl' || filename === 'history.jsonl' || (filename && filename.includes('-messages.jsonl'))) {
|
|
3341
|
+
pendingChangeTypes.add('messages');
|
|
3342
|
+
} else if (filename === 'agents.json' || filename === 'profiles.json') {
|
|
3343
|
+
pendingChangeTypes.add('agents');
|
|
3344
|
+
} else if (filename === 'tasks.json') {
|
|
3345
|
+
pendingChangeTypes.add('tasks');
|
|
3346
|
+
} else if (filename === 'workflows.json') {
|
|
3347
|
+
pendingChangeTypes.add('workflows');
|
|
3348
|
+
} else {
|
|
3349
|
+
pendingChangeTypes.add('update');
|
|
3350
|
+
}
|
|
3351
|
+
|
|
2319
3352
|
// Debounce — multiple file changes may fire rapidly
|
|
3353
|
+
// Increased from 200ms to 2000ms for 100-agent scale (prevents SSE flood)
|
|
2320
3354
|
if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
|
|
2321
|
-
sseDebounceTimer = setTimeout(() =>
|
|
3355
|
+
sseDebounceTimer = setTimeout(() => {
|
|
3356
|
+
// Send combined change types: "messages,agents" or just "messages"
|
|
3357
|
+
const changeType = Array.from(pendingChangeTypes).join(',');
|
|
3358
|
+
pendingChangeTypes.clear();
|
|
3359
|
+
sseNotifyAll(changeType);
|
|
3360
|
+
}, 2000);
|
|
2322
3361
|
});
|
|
2323
3362
|
fsWatcher.on('error', () => {}); // ignore watch errors
|
|
2324
3363
|
} catch {}
|