let-them-talk 4.3.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/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('\n').map(line => {
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
- if (lastActivity) {
145
- const stale = Date.now() - new Date(lastActivity).getTime();
146
- if (stale > 30000) return false; // 30s = 3 missed heartbeats
147
- }
148
- return true;
149
- } catch { return false; }
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
- messages = messages.slice(-limit);
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('\n').length;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
@@ -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('\n').filter(Boolean);
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('\n');
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('\n');
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('\n');
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
- if (!providedToken || providedToken !== LAN_TOKEN) {
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 isLocal = source && (source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT));
1400
- const isLan = LAN_MODE && getLanIP() && source && source.includes(getLanIP() + ':' + PORT);
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'
@@ -1720,9 +2046,44 @@ const server = http.createServer(async (req, res) => {
1720
2046
  res.end(JSON.stringify(apiStats(url.searchParams)));
1721
2047
  }
1722
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
+ }
1723
2055
  res.writeHead(200, { 'Content-Type': 'application/json' });
1724
2056
  res.end(JSON.stringify(apiReset(url.searchParams)));
1725
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
+ }
1726
2087
  // Message injection
1727
2088
  else if (url.pathname === '/api/inject' && req.method === 'POST') {
1728
2089
  const body = await parseBody(req);
@@ -1755,6 +2116,21 @@ const server = http.createServer(async (req, res) => {
1755
2116
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1756
2117
  res.end(JSON.stringify(result));
1757
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
+ }
1758
2134
  else if (url.pathname === '/api/search' && req.method === 'GET') {
1759
2135
  const projectPath = url.searchParams.get('project') || null;
1760
2136
  const query = (url.searchParams.get('q') || '').trim();
@@ -1798,7 +2174,8 @@ const server = http.createServer(async (req, res) => {
1798
2174
  const history = apiHistory(url.searchParams);
1799
2175
  const agents = apiAgents(url.searchParams);
1800
2176
  const decisions = readJson(filePath('decisions.json', projectPath)) || [];
1801
- const tasks = readJson(filePath('tasks.json', projectPath)) || [];
2177
+ const tasksRaw = readJson(filePath('tasks.json', projectPath));
2178
+ const tasks = Array.isArray(tasksRaw) ? tasksRaw : (tasksRaw && tasksRaw.tasks ? tasksRaw.tasks : []);
1802
2179
  const channels = apiChannels(url.searchParams);
1803
2180
  const pkg = readJson(path.join(__dirname, 'package.json')) || {};
1804
2181
  const result = {
@@ -1885,7 +2262,7 @@ const server = http.createServer(async (req, res) => {
1885
2262
  const projectPath = url.searchParams.get('project') || null;
1886
2263
  const profilesFile = filePath('profiles.json', projectPath);
1887
2264
  const profiles = readJson(profilesFile);
1888
- if (!body.agent) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Missing agent field' })); return; }
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; }
1889
2266
  if (!profiles[body.agent]) profiles[body.agent] = {};
1890
2267
  if (body.display_name) profiles[body.agent].display_name = body.display_name.substring(0, 30);
1891
2268
  if (body.avatar) {
@@ -1977,6 +2354,500 @@ const server = http.createServer(async (req, res) => {
1977
2354
  res.writeHead(200, { 'Content-Type': 'application/json' });
1978
2355
  res.end(JSON.stringify({ success: true }));
1979
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
+
1980
2851
  else if (url.pathname === '/api/branches' && req.method === 'GET') {
1981
2852
  const projectPath = url.searchParams.get('project') || null;
1982
2853
  const branchesFile = filePath('branches.json', projectPath);
@@ -1988,7 +2859,7 @@ const server = http.createServer(async (req, res) => {
1988
2859
  let msgCount = 0;
1989
2860
  if (fs.existsSync(histFile)) {
1990
2861
  const content = fs.readFileSync(histFile, 'utf8').trim();
1991
- if (content) msgCount = content.split('\n').length;
2862
+ if (content) msgCount = content.split(/\r?\n/).filter(l => l.trim()).length;
1992
2863
  }
1993
2864
  branches[name].message_count = msgCount;
1994
2865
  }
@@ -2047,7 +2918,7 @@ const server = http.createServer(async (req, res) => {
2047
2918
  // Server info (LAN mode detection for frontend)
2048
2919
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
2049
2920
  res.writeHead(200, { 'Content-Type': 'application/json' });
2050
- res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT, lan_token: LAN_MODE ? LAN_TOKEN : null }));
2921
+ res.end(JSON.stringify({ lan_mode: LAN_MODE, lan_ip: getLanIP(), port: PORT }));
2051
2922
  }
2052
2923
  // Toggle LAN mode (re-bind server live)
2053
2924
  else if (url.pathname === '/api/toggle-lan' && req.method === 'POST') {
@@ -2059,7 +2930,7 @@ const server = http.createServer(async (req, res) => {
2059
2930
  if (newMode) generateLanToken();
2060
2931
  // Send response first
2061
2932
  res.writeHead(200, { 'Content-Type': 'application/json' });
2062
- res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT, lan_token: newMode ? LAN_TOKEN : null }));
2933
+ res.end(JSON.stringify({ lan_mode: newMode, lan_ip: lanIP, port: PORT }));
2063
2934
  // Re-bind by stopping the listener and immediately re-listening
2064
2935
  // Use setImmediate to let the response flush first
2065
2936
  setImmediate(() => {
@@ -2133,17 +3004,27 @@ const server = http.createServer(async (req, res) => {
2133
3004
  res.end(JSON.stringify({ error: 'Too many SSE connections' }));
2134
3005
  return;
2135
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
+ }
2136
3015
  res.writeHead(200, {
2137
3016
  'Content-Type': 'text/event-stream',
2138
3017
  'Cache-Control': 'no-cache',
2139
3018
  'Connection': 'keep-alive',
2140
3019
  });
2141
3020
  res.write(`data: connected\n\n`);
3021
+ res._sseIP = sseIP;
2142
3022
  sseClients.add(res);
2143
3023
  // Heartbeat every 30s to detect dead connections and prevent proxy timeouts
2144
3024
  const heartbeat = setInterval(() => {
2145
3025
  try { res.write(`:heartbeat\n\n`); } catch { clearInterval(heartbeat); sseClients.delete(res); }
2146
3026
  }, 30000);
3027
+ heartbeat.unref();
2147
3028
  req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
2148
3029
  }
2149
3030
  // --- Mod system API ---
@@ -2208,7 +3089,13 @@ const server = http.createServer(async (req, res) => {
2208
3089
  return;
2209
3090
  }
2210
3091
  }
2211
- fs.writeFileSync(path.join(modDir, assetFile), buf);
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);
2212
3099
  manifest.asset.file = assetFile;
2213
3100
  }
2214
3101
  // Write manifest
@@ -2267,6 +3154,134 @@ const server = http.createServer(async (req, res) => {
2267
3154
  res.end(JSON.stringify({ success: true }));
2268
3155
  });
2269
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
+ }
2270
3285
  else {
2271
3286
  res.writeHead(404, { 'Content-Type': 'application/json' });
2272
3287
  res.end(JSON.stringify({ error: 'Not found' }));
@@ -2282,17 +3297,19 @@ const server = http.createServer(async (req, res) => {
2282
3297
  // Watches data files and pushes updates to connected clients instantly
2283
3298
  const sseClients = new Set();
2284
3299
 
2285
- function sseNotifyAll() {
3300
+ function sseNotifyAll(changeType) {
2286
3301
  // Generate notifications from agent state changes
2287
3302
  try {
2288
3303
  const agents = readJson(filePath('agents.json'));
2289
3304
  generateNotifications(agents);
2290
3305
  } catch {}
2291
3306
 
3307
+ // Send typed change event so client can do targeted fetches
3308
+ const eventData = changeType || 'update';
2292
3309
  const dead = [];
2293
3310
  for (const res of Array.from(sseClients)) {
2294
3311
  try {
2295
- res.write(`data: update\n\n`);
3312
+ res.write(`data: ${(eventData || '').replace(/[\r\n]/g, '')}\n\n`);
2296
3313
  } catch {
2297
3314
  dead.push(res);
2298
3315
  }
@@ -2312,13 +3329,38 @@ function startFileWatcher() {
2312
3329
  const dataDir = resolveDataDir();
2313
3330
  if (!fs.existsSync(dataDir)) return;
2314
3331
  try {
3332
+ // Track pending change types for diff-based SSE
3333
+ let pendingChangeTypes = new Set();
2315
3334
  fsWatcher = fs.watch(dataDir, { persistent: false }, (eventType, filename) => {
2316
3335
  // Filter: only react to data files, not temp/lock files
2317
3336
  if (filename && !filename.endsWith('.json') && !filename.endsWith('.jsonl')) return;
2318
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
+
2319
3355
  // Debounce — multiple file changes may fire rapidly
3356
+ // Increased from 200ms to 2000ms for 100-agent scale (prevents SSE flood)
2320
3357
  if (sseDebounceTimer) clearTimeout(sseDebounceTimer);
2321
- sseDebounceTimer = setTimeout(() => sseNotifyAll(), 200);
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);
2322
3364
  });
2323
3365
  fsWatcher.on('error', () => {}); // ignore watch errors
2324
3366
  } catch {}