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/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 = [];
@@ -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 on Windows
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: 'Auto-launch not supported on this platform. Run the command manually, then paste the prompt.'
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('\n').filter(Boolean);
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('\n');
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('\n');
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('\n');
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
- if (!providedToken || providedToken !== LAN_TOKEN) {
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 isLocal = source && (source.includes('localhost:' + PORT) || source.includes('127.0.0.1:' + PORT));
1400
- const isLan = LAN_MODE && getLanIP() && source && source.includes(getLanIP() + ':' + PORT);
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 tasks = readJson(filePath('tasks.json', projectPath)) || [];
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: 'Missing agent field' })); return; }
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('\n').length;
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, lan_token: LAN_MODE ? LAN_TOKEN : null }));
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, lan_token: newMode ? LAN_TOKEN : null }));
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
- fs.writeFileSync(path.join(modDir, assetFile), buf);
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: update\n\n`);
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(() => sseNotifyAll(), 200);
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 {}