codex-map 0.1.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/server.js ADDED
@@ -0,0 +1,1702 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { randomUUID } = require('crypto');
8
+ const { execFileSync } = require('child_process');
9
+ const matter = require('gray-matter');
10
+ const chokidar = require('chokidar');
11
+ const TOML = require('@iarna/toml');
12
+
13
+ const app = express();
14
+ app.use(express.json({ limit: '2mb' }));
15
+
16
+ const PORT = process.env.PORT || 3131;
17
+ const CODEX_DIR = path.resolve(os.homedir(), '.codex');
18
+ const PINNED_FILE = path.join(CODEX_DIR, 'codex-map-projects.json');
19
+ const SESSION_ROOT = path.join(CODEX_DIR, 'sessions');
20
+ const STATE_DB = path.join(CODEX_DIR, 'state_5.sqlite');
21
+ const PLUGINS_CACHE_DIR = path.join(CODEX_DIR, '.tmp', 'plugins');
22
+ const PLUGINS_ROOT = path.join(PLUGINS_CACHE_DIR, 'plugins');
23
+ const MARKETPLACE_PATH = path.join(PLUGINS_CACHE_DIR, '.agents', 'plugins', 'marketplace.json');
24
+ const MAX_FILE_BYTES = 512 * 1024;
25
+ const TREE_SKIP_DIRS = new Set(['.git', 'cache', 'log', 'logs', '.tmp', 'tmp', 'sqlite', 'vendor_imports']);
26
+
27
+ const cache = new Map();
28
+ const sseClients = new Set();
29
+
30
+ function invalidateCache() {
31
+ cache.clear();
32
+ }
33
+
34
+ function getCacheKey(projectPath) {
35
+ return projectPath ? `project:${path.resolve(projectPath)}` : 'global';
36
+ }
37
+
38
+ async function getCachedScan(projectPath) {
39
+ const key = getCacheKey(projectPath);
40
+ const now = Date.now();
41
+ const hit = cache.get(key);
42
+ if (hit && (now - hit.ts) < 5000) return hit.data;
43
+
44
+ const data = await buildScanResult(projectPath);
45
+ cache.set(key, { ts: now, data });
46
+ return data;
47
+ }
48
+
49
+ function fileExists(filePath) {
50
+ try {
51
+ fs.accessSync(filePath);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function safeReadText(filePath) {
59
+ try {
60
+ return fs.readFileSync(filePath, 'utf8');
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function safeReadJson(filePath) {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function safeReadToml(filePath) {
75
+ try {
76
+ return TOML.parse(fs.readFileSync(filePath, 'utf8'));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function writeText(filePath, content) {
83
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
84
+ fs.writeFileSync(filePath, content, 'utf8');
85
+ }
86
+
87
+ function sqlString(value) {
88
+ return `'${String(value).replace(/'/g, "''")}'`;
89
+ }
90
+
91
+ function querySqliteJson(dbPath, sql) {
92
+ try {
93
+ const output = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
94
+ return output ? JSON.parse(output) : [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ function execSqlite(dbPath, sql) {
101
+ execFileSync('sqlite3', [dbPath, sql], { encoding: 'utf8' });
102
+ }
103
+
104
+ function excerpt(text, len = 220) {
105
+ if (!text) return '';
106
+ const clean = text.replace(/^---[\s\S]*?---\n?/, '').trim();
107
+ return clean.length > len ? `${clean.slice(0, len)}…` : clean;
108
+ }
109
+
110
+ function wordCount(text) {
111
+ return text ? text.trim().split(/\s+/).length : 0;
112
+ }
113
+
114
+ function ensureArray(value) {
115
+ return Array.isArray(value) ? value : value == null ? [] : [value];
116
+ }
117
+
118
+ function resolveIfExists(basePath, ...parts) {
119
+ const target = path.join(basePath, ...parts);
120
+ return fileExists(target) ? target : null;
121
+ }
122
+
123
+ function readAgentsMd(basePath) {
124
+ if (!basePath) return null;
125
+ const filePath = resolveIfExists(basePath, 'AGENTS.md');
126
+ if (!filePath) return null;
127
+ const raw = safeReadText(filePath);
128
+ if (!raw) return null;
129
+ return { path: filePath, raw, excerpt: excerpt(raw, 320) };
130
+ }
131
+
132
+ function buildSkillMeta(frontmatter, fallbackName) {
133
+ const data = frontmatter || {};
134
+ const allowedToolsRaw = data['allowed-tools'] || data.allowedTools || '';
135
+
136
+ return {
137
+ displayName: data.name || fallbackName,
138
+ description: data.description || null,
139
+ allowedTools: allowedToolsRaw
140
+ ? String(allowedToolsRaw).split(',').map(item => item.trim()).filter(Boolean)
141
+ : [],
142
+ argumentHint: data['argument-hint'] || data.argumentHint || null,
143
+ userInvocable: data['user-invocable'] !== false,
144
+ agent: data.agent || null
145
+ };
146
+ }
147
+
148
+ function readSkillsDir(basePath) {
149
+ const skillsDir = path.join(basePath, 'skills');
150
+ if (!fileExists(skillsDir)) return [];
151
+
152
+ const entries = [];
153
+ try {
154
+ const items = fs.readdirSync(skillsDir).sort();
155
+ for (const item of items) {
156
+ const itemPath = path.join(skillsDir, item);
157
+ const stat = fs.statSync(itemPath);
158
+
159
+ if (stat.isDirectory()) {
160
+ const skillFile = resolveIfExists(itemPath, 'SKILL.md') || resolveIfExists(itemPath, `${item}.md`);
161
+ if (!skillFile) continue;
162
+
163
+ const raw = safeReadText(skillFile);
164
+ if (!raw) continue;
165
+
166
+ const parsed = matter(raw);
167
+ entries.push({
168
+ name: item,
169
+ filename: path.basename(skillFile),
170
+ path: skillFile,
171
+ raw,
172
+ body: parsed.content || raw,
173
+ frontmatter: parsed.data || {},
174
+ meta: buildSkillMeta(parsed.data, item),
175
+ excerpt: excerpt(parsed.content || raw, 180),
176
+ hasArgs: raw.includes('$ARGUMENTS'),
177
+ isFolder: true,
178
+ wordCount: wordCount(raw)
179
+ });
180
+ continue;
181
+ }
182
+
183
+ if (!item.endsWith('.md')) continue;
184
+
185
+ const raw = safeReadText(itemPath);
186
+ if (!raw) continue;
187
+
188
+ const parsed = matter(raw);
189
+ entries.push({
190
+ name: item.replace(/\.md$/, ''),
191
+ filename: item,
192
+ path: itemPath,
193
+ raw,
194
+ body: parsed.content || raw,
195
+ frontmatter: parsed.data || {},
196
+ meta: buildSkillMeta(parsed.data, item.replace(/\.md$/, '')),
197
+ excerpt: excerpt(parsed.content || raw, 180),
198
+ hasArgs: raw.includes('$ARGUMENTS'),
199
+ isFolder: false,
200
+ wordCount: wordCount(raw)
201
+ });
202
+ }
203
+ } catch {
204
+ return [];
205
+ }
206
+
207
+ return entries;
208
+ }
209
+
210
+ function getSkillsBaseDir(scope, projectPath) {
211
+ if (scope === 'project') {
212
+ if (!projectPath) throw new Error('Missing projectPath for project-scoped skill operation');
213
+ return path.join(path.resolve(projectPath), '.codex', 'skills');
214
+ }
215
+
216
+ return path.join(CODEX_DIR, 'skills');
217
+ }
218
+
219
+ function sanitizeSkillName(name) {
220
+ return String(name || '').trim().replace(/[^a-zA-Z0-9._-]/g, '_');
221
+ }
222
+
223
+ function resolveSkillFile(baseDir, name) {
224
+ const safeName = sanitizeSkillName(name);
225
+ const directFile = path.join(baseDir, `${safeName}.md`);
226
+ const folderSkill = path.join(baseDir, safeName, 'SKILL.md');
227
+ const folderAlt = path.join(baseDir, safeName, `${safeName}.md`);
228
+
229
+ if (fileExists(directFile)) return directFile;
230
+ if (fileExists(folderSkill)) return folderSkill;
231
+ if (fileExists(folderAlt)) return folderAlt;
232
+ return directFile;
233
+ }
234
+
235
+ function readConfigToml() {
236
+ const filePath = path.join(CODEX_DIR, 'config.toml');
237
+ const raw = safeReadText(filePath);
238
+ const data = raw ? safeReadToml(filePath) : null;
239
+ if (!raw || !data) return null;
240
+
241
+ return {
242
+ path: filePath,
243
+ raw,
244
+ excerpt: excerpt(raw, 420),
245
+ approvalsReviewer: data.approvals_reviewer || null,
246
+ personality: data.personality || null,
247
+ projects: Object.entries(data.projects || {}).map(([projectPath, cfg]) => ({
248
+ path: projectPath,
249
+ trustLevel: cfg.trust_level || null
250
+ })),
251
+ mcpServers: Object.entries(data.mcp_servers || {}).map(([name, cfg]) => ({
252
+ name,
253
+ command: cfg.command || null,
254
+ args: ensureArray(cfg.args),
255
+ envKeys: Object.keys(cfg.env || {}),
256
+ cwd: cfg.cwd || null
257
+ })),
258
+ profiles: Object.entries(data.profiles || {}).map(([name, cfg]) => ({
259
+ name,
260
+ keys: Object.keys(cfg || {})
261
+ })),
262
+ featureFlags: Object.keys(data.features || {}).filter(key => data.features[key])
263
+ };
264
+ }
265
+
266
+ function readConfigTomlDoc() {
267
+ const filePath = path.join(CODEX_DIR, 'config.toml');
268
+ const raw = safeReadText(filePath) || '';
269
+ const data = safeReadToml(filePath) || {};
270
+ return { filePath, raw, data };
271
+ }
272
+
273
+ function writeConfigTomlData(data) {
274
+ const filePath = path.join(CODEX_DIR, 'config.toml');
275
+ const raw = TOML.stringify(data);
276
+ writeText(filePath, raw);
277
+ invalidateCache();
278
+ return summarizeConfigToml(raw, data, filePath);
279
+ }
280
+
281
+ function sessionFilePathFor(id, date = new Date()) {
282
+ const year = String(date.getFullYear());
283
+ const month = String(date.getMonth() + 1).padStart(2, '0');
284
+ const day = String(date.getDate()).padStart(2, '0');
285
+ const stamp = date.toISOString().replace(/\.\d{3}Z$/, '').replace(/:/g, '-');
286
+ return path.join(SESSION_ROOT, year, month, day, `rollout-${stamp}-${id}.jsonl`);
287
+ }
288
+
289
+ function createSessionFile({ id, cwd, title, modelProvider = 'openai', cliVersion = '0.120.0' }) {
290
+ const now = new Date();
291
+ const iso = now.toISOString();
292
+ const filePath = sessionFilePathFor(id, now);
293
+ const rows = [
294
+ {
295
+ timestamp: iso,
296
+ type: 'session_meta',
297
+ payload: {
298
+ id,
299
+ timestamp: iso,
300
+ cwd,
301
+ originator: 'codex-map',
302
+ cli_version: cliVersion,
303
+ source: 'cli',
304
+ model_provider: modelProvider
305
+ }
306
+ },
307
+ {
308
+ timestamp: iso,
309
+ type: 'event_msg',
310
+ payload: {
311
+ type: 'user_message',
312
+ message: title
313
+ }
314
+ }
315
+ ];
316
+
317
+ writeText(filePath, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`);
318
+ return { filePath, timestamp: Math.floor(now.getTime() / 1000) };
319
+ }
320
+
321
+ function summarizeConfigToml(raw, data, filePath) {
322
+ return {
323
+ path: filePath,
324
+ raw,
325
+ excerpt: excerpt(raw, 420),
326
+ approvalsReviewer: data.approvals_reviewer || null,
327
+ personality: data.personality || null,
328
+ projects: Object.entries(data.projects || {}).map(([projectPath, cfg]) => ({
329
+ path: projectPath,
330
+ trustLevel: cfg.trust_level || null
331
+ })),
332
+ mcpServers: Object.entries(data.mcp_servers || {}).map(([name, cfg]) => ({
333
+ name,
334
+ command: cfg.command || null,
335
+ args: ensureArray(cfg.args),
336
+ envKeys: Object.keys(cfg.env || {}),
337
+ env: cfg.env || {},
338
+ cwd: cfg.cwd || null
339
+ })),
340
+ profiles: Object.entries(data.profiles || {}).map(([name, cfg]) => ({
341
+ name,
342
+ keys: Object.keys(cfg || {})
343
+ })),
344
+ featureFlags: Object.keys(data.features || {}).filter(key => data.features[key])
345
+ };
346
+ }
347
+
348
+ function readMcpJson(projectPath) {
349
+ if (!projectPath) return null;
350
+
351
+ const candidates = [
352
+ path.join(projectPath, '.mcp.json'),
353
+ path.join(projectPath, '.codex', '.mcp.json')
354
+ ];
355
+
356
+ for (const candidate of candidates) {
357
+ const data = safeReadJson(candidate);
358
+ if (!data) continue;
359
+ return {
360
+ path: candidate,
361
+ raw: JSON.stringify(data, null, 2),
362
+ servers: Object.keys(data.mcpServers || {}),
363
+ data
364
+ };
365
+ }
366
+
367
+ return null;
368
+ }
369
+
370
+ function readPluginManifests() {
371
+ if (!fileExists(PLUGINS_ROOT)) return [];
372
+
373
+ const marketplace = safeReadJson(MARKETPLACE_PATH) || { plugins: [] };
374
+ const marketplaceMap = new Map((marketplace.plugins || []).map(item => [item.name, item]));
375
+
376
+ const plugins = [];
377
+ try {
378
+ for (const item of fs.readdirSync(PLUGINS_ROOT).sort()) {
379
+ const manifestPath = path.join(PLUGINS_ROOT, item, '.codex-plugin', 'plugin.json');
380
+ const manifest = safeReadJson(manifestPath);
381
+ if (!manifest) continue;
382
+ const market = marketplaceMap.get(item) || {};
383
+
384
+ plugins.push({
385
+ id: manifest.id || item,
386
+ name: manifest.name || manifest.id || item,
387
+ description: manifest.description || null,
388
+ version: manifest.version || null,
389
+ category: market.category || manifest.interface?.category || null,
390
+ displayName: manifest.interface?.displayName || manifest.name || item,
391
+ path: manifestPath,
392
+ tools: (manifest.tools || []).length,
393
+ prompts: (manifest.prompts || []).length
394
+ });
395
+ }
396
+ } catch {
397
+ return [];
398
+ }
399
+
400
+ return plugins;
401
+ }
402
+
403
+ function buildFileTree(basePath, depth = 0, maxDepth = 3) {
404
+ let stat;
405
+ try {
406
+ stat = fs.statSync(basePath);
407
+ } catch {
408
+ return null;
409
+ }
410
+
411
+ const node = {
412
+ name: path.basename(basePath) || basePath,
413
+ path: basePath,
414
+ isDir: stat.isDirectory(),
415
+ size: stat.isDirectory() ? null : stat.size,
416
+ children: []
417
+ };
418
+
419
+ if (!node.isDir || depth >= maxDepth) return node;
420
+ if (TREE_SKIP_DIRS.has(path.basename(basePath))) return node;
421
+
422
+ try {
423
+ const items = fs.readdirSync(basePath).sort();
424
+ for (const item of items) {
425
+ if (item.startsWith('.') && !['.codex', '.mcp.json'].includes(item)) continue;
426
+ const child = buildFileTree(path.join(basePath, item), depth + 1, maxDepth);
427
+ if (child) node.children.push(child);
428
+ }
429
+ } catch {
430
+ return node;
431
+ }
432
+
433
+ return node;
434
+ }
435
+
436
+ function readPinned() {
437
+ const data = safeReadJson(PINNED_FILE);
438
+ return Array.isArray(data?.projects) ? data.projects : [];
439
+ }
440
+
441
+ function writePinned(projects) {
442
+ fs.writeFileSync(PINNED_FILE, JSON.stringify({ projects }, null, 2), 'utf8');
443
+ }
444
+
445
+ function readHistoryEntries(limit = 200) {
446
+ const filePath = path.join(CODEX_DIR, 'history.jsonl');
447
+ if (!fileExists(filePath)) return [];
448
+
449
+ const entries = [];
450
+ const lines = safeReadText(filePath)?.split('\n').filter(Boolean) || [];
451
+ for (const line of lines) {
452
+ try {
453
+ const row = JSON.parse(line);
454
+ entries.push({
455
+ sessionId: row.session_id || null,
456
+ timestamp: row.ts ? new Date(row.ts * 1000).toISOString() : null,
457
+ text: row.text || ''
458
+ });
459
+ } catch {
460
+ continue;
461
+ }
462
+ }
463
+
464
+ entries.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
465
+ return entries.slice(0, limit);
466
+ }
467
+
468
+ function findSessionFile(sessionId) {
469
+ return walkSessionFiles().find(file => file.includes(sessionId)) || null;
470
+ }
471
+
472
+ function readThreadRows(projectPath = null, includeArchived = false) {
473
+ if (!fileExists(STATE_DB)) return [];
474
+ const filters = [];
475
+ if (projectPath) filters.push(`cwd = ${sqlString(path.resolve(projectPath))}`);
476
+ if (!includeArchived) filters.push('archived = 0');
477
+ const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
478
+ return querySqliteJson(STATE_DB, `
479
+ SELECT id, title, cwd, updated_at, created_at, archived, cli_version, model_provider
480
+ FROM threads
481
+ ${where}
482
+ ORDER BY updated_at DESC;
483
+ `);
484
+ }
485
+
486
+ function readSessionIndex() {
487
+ const filePath = path.join(CODEX_DIR, 'session_index.jsonl');
488
+ if (!fileExists(filePath)) return new Map();
489
+
490
+ const index = new Map();
491
+ const lines = safeReadText(filePath)?.split('\n').filter(Boolean) || [];
492
+
493
+ for (const line of lines) {
494
+ try {
495
+ const row = JSON.parse(line);
496
+ if (!row.id) continue;
497
+ index.set(row.id, {
498
+ threadName: row.thread_name || null,
499
+ updatedAt: row.updated_at || null
500
+ });
501
+ } catch {
502
+ continue;
503
+ }
504
+ }
505
+
506
+ return index;
507
+ }
508
+
509
+ function walkSessionFiles() {
510
+ const files = [];
511
+ if (!fileExists(SESSION_ROOT)) return files;
512
+
513
+ function walk(dirPath) {
514
+ let entries = [];
515
+ try {
516
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
517
+ } catch {
518
+ return;
519
+ }
520
+
521
+ for (const entry of entries) {
522
+ const fullPath = path.join(dirPath, entry.name);
523
+ if (entry.isDirectory()) walk(fullPath);
524
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) files.push(fullPath);
525
+ }
526
+ }
527
+
528
+ walk(SESSION_ROOT);
529
+ return files;
530
+ }
531
+
532
+ function toolNameFromEventType(type) {
533
+ const map = {
534
+ exec_command_end: 'exec_command',
535
+ patch_apply_end: 'apply_patch',
536
+ browser_snapshot_end: 'browser_snapshot',
537
+ browser_navigate_end: 'browser_navigate',
538
+ browser_click_end: 'browser_click'
539
+ };
540
+
541
+ return map[type] || null;
542
+ }
543
+
544
+ function parseSessionFile(filePath, sessionIndex) {
545
+ const raw = safeReadText(filePath);
546
+ if (!raw) return null;
547
+
548
+ let meta = null;
549
+ let title = null;
550
+ let startedAt = null;
551
+ let endedAt = null;
552
+ let messageCount = 0;
553
+ let toolCallCount = 0;
554
+ const toolBreakdown = {};
555
+
556
+ for (const line of raw.split('\n').filter(Boolean)) {
557
+ let row;
558
+ try {
559
+ row = JSON.parse(line);
560
+ } catch {
561
+ continue;
562
+ }
563
+
564
+ if (row.timestamp) {
565
+ if (!startedAt) startedAt = row.timestamp;
566
+ endedAt = row.timestamp;
567
+ }
568
+
569
+ if (row.type === 'session_meta') {
570
+ meta = row.payload || {};
571
+ if (!startedAt && row.payload?.timestamp) startedAt = row.payload.timestamp;
572
+ continue;
573
+ }
574
+
575
+ if (row.type === 'event_msg' && row.payload?.type === 'user_message') {
576
+ messageCount += 1;
577
+ if (!title && row.payload.message) title = String(row.payload.message).slice(0, 120);
578
+ continue;
579
+ }
580
+
581
+ if (row.type === 'event_msg' && row.payload?.type === 'agent_message') {
582
+ messageCount += 1;
583
+ continue;
584
+ }
585
+
586
+ if (row.type === 'event_msg') {
587
+ const tool = toolNameFromEventType(row.payload?.type);
588
+ if (!tool) continue;
589
+ toolCallCount += 1;
590
+ toolBreakdown[tool] = (toolBreakdown[tool] || 0) + 1;
591
+ }
592
+ }
593
+
594
+ if (!meta?.id) return null;
595
+
596
+ const indexed = sessionIndex.get(meta.id) || {};
597
+ const stat = fs.statSync(filePath);
598
+
599
+ return {
600
+ id: meta.id,
601
+ title: indexed.threadName || title || '(untitled session)',
602
+ cwd: meta.cwd || null,
603
+ cliVersion: meta.cli_version || null,
604
+ modelProvider: meta.model_provider || null,
605
+ source: meta.source || null,
606
+ startedAt: startedAt || meta.timestamp || null,
607
+ endedAt: endedAt || indexed.updatedAt || null,
608
+ updatedAt: indexed.updatedAt || endedAt || stat.mtime.toISOString(),
609
+ messageCount,
610
+ toolCallCount,
611
+ toolBreakdown,
612
+ filePath,
613
+ fileSize: stat.size
614
+ };
615
+ }
616
+
617
+ function listSessions(projectPath = null) {
618
+ const sessionIndex = readSessionIndex();
619
+ const threadRows = new Map(readThreadRows(projectPath).map(row => [row.id, row]));
620
+ const files = walkSessionFiles();
621
+ const target = projectPath ? path.resolve(projectPath) : null;
622
+ const sessions = [];
623
+
624
+ for (const filePath of files) {
625
+ const session = parseSessionFile(filePath, sessionIndex);
626
+ if (!session) continue;
627
+ if (target && path.resolve(session.cwd || '') !== target) continue;
628
+ const row = threadRows.get(session.id);
629
+ if (row?.archived) continue;
630
+ if (row?.title) session.title = row.title;
631
+ if (row?.updated_at) session.updatedAt = new Date(row.updated_at * 1000).toISOString();
632
+ sessions.push(session);
633
+ }
634
+
635
+ sessions.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
636
+ return sessions;
637
+ }
638
+
639
+ function readSessionDetail(sessionId) {
640
+ const sessionIndex = readSessionIndex();
641
+ const row = readThreadRows(null, true).find(item => item.id === sessionId) || null;
642
+ const filePath = walkSessionFiles().find(file => file.includes(sessionId));
643
+ if (!filePath) return null;
644
+
645
+ const raw = safeReadText(filePath);
646
+ if (!raw) return null;
647
+
648
+ const timeline = [];
649
+ const toolBreakdown = {};
650
+ let meta = null;
651
+
652
+ for (const line of raw.split('\n').filter(Boolean)) {
653
+ let row;
654
+ try {
655
+ row = JSON.parse(line);
656
+ } catch {
657
+ continue;
658
+ }
659
+
660
+ if (row.type === 'session_meta') {
661
+ meta = row.payload || {};
662
+ timeline.push({
663
+ kind: 'meta',
664
+ timestamp: row.timestamp || row.payload?.timestamp || null,
665
+ title: 'Session started',
666
+ body: meta.cwd || ''
667
+ });
668
+ continue;
669
+ }
670
+
671
+ if (row.type === 'event_msg' && row.payload?.type === 'user_message') {
672
+ timeline.push({
673
+ kind: 'user',
674
+ timestamp: row.timestamp || null,
675
+ title: 'User',
676
+ body: row.payload.message || ''
677
+ });
678
+ continue;
679
+ }
680
+
681
+ if (row.type === 'event_msg' && row.payload?.type === 'agent_message') {
682
+ timeline.push({
683
+ kind: 'assistant',
684
+ timestamp: row.timestamp || null,
685
+ title: row.payload.phase === 'final_answer' ? 'Assistant Final' : 'Assistant Update',
686
+ body: row.payload.message || ''
687
+ });
688
+ continue;
689
+ }
690
+
691
+ if (row.type === 'event_msg') {
692
+ const tool = toolNameFromEventType(row.payload?.type);
693
+ if (!tool) continue;
694
+ toolBreakdown[tool] = (toolBreakdown[tool] || 0) + 1;
695
+
696
+ const command = Array.isArray(row.payload.command) ? row.payload.command.join(' ') : '';
697
+ const output = row.payload.output || row.payload.stdout || row.payload.stderr || row.payload.aggregated_output || '';
698
+ timeline.push({
699
+ kind: 'tool',
700
+ timestamp: row.timestamp || null,
701
+ title: tool,
702
+ body: excerpt(command || output, 600)
703
+ });
704
+ }
705
+ }
706
+
707
+ const indexed = sessionIndex.get(sessionId) || {};
708
+ return {
709
+ session: {
710
+ id: sessionId,
711
+ title: row?.title || indexed.threadName || '(untitled session)',
712
+ cwd: row?.cwd || meta?.cwd || null,
713
+ cliVersion: row?.cli_version || meta?.cli_version || null,
714
+ modelProvider: row?.model_provider || meta?.model_provider || null,
715
+ startedAt: meta?.timestamp || null,
716
+ updatedAt: row?.updated_at ? new Date(row.updated_at * 1000).toISOString() : (indexed.updatedAt || null)
717
+ },
718
+ timeline,
719
+ summary: {
720
+ totalItems: timeline.length,
721
+ toolBreakdown
722
+ }
723
+ };
724
+ }
725
+
726
+ function normalizeStatsRange({ days = 14, from = null, to = null } = {}) {
727
+ const end = to ? new Date(`${to}T23:59:59.999Z`) : new Date();
728
+ const start = from ? new Date(`${from}T00:00:00.000Z`) : new Date(end.getTime() - ((days - 1) * 24 * 60 * 60 * 1000));
729
+ return {
730
+ start,
731
+ end,
732
+ period: `${start.toISOString().slice(0, 10)}..${end.toISOString().slice(0, 10)}`
733
+ };
734
+ }
735
+
736
+ function countToolUsage(projectPath = null, options = {}) {
737
+ const range = normalizeStatsRange(options);
738
+ const usage = {};
739
+ let scanned = 0;
740
+
741
+ for (const session of listSessions(projectPath)) {
742
+ const ts = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
743
+ if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
744
+ scanned += 1;
745
+ for (const [tool, count] of Object.entries(session.toolBreakdown || {})) {
746
+ usage[tool] = (usage[tool] || 0) + count;
747
+ }
748
+ }
749
+
750
+ return { toolUsage: usage, sessionsScanned: scanned, period: range.period, from: range.start.toISOString().slice(0, 10), to: range.end.toISOString().slice(0, 10) };
751
+ }
752
+
753
+ function countDailyUsage(projectPath = null, options = {}) {
754
+ const target = projectPath ? path.resolve(projectPath) : null;
755
+ const range = normalizeStatsRange(options);
756
+ const byDate = new Map();
757
+ const filteredSessions = listSessions(target);
758
+ const allowedSessionIds = new Set(filteredSessions.map(session => session.id));
759
+
760
+ for (let ts = range.start.getTime(); ts <= range.end.getTime(); ts += 24 * 60 * 60 * 1000) {
761
+ const date = new Date(ts).toISOString().slice(0, 10);
762
+ byDate.set(date, { date, prompts: 0, sessions: 0, tools: 0 });
763
+ }
764
+
765
+ for (const entry of readHistoryEntries(5000)) {
766
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
767
+ if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
768
+ if (target && !allowedSessionIds.has(entry.sessionId)) continue;
769
+ const date = new Date(ts).toISOString().slice(0, 10);
770
+ const bucket = byDate.get(date);
771
+ if (bucket) bucket.prompts += 1;
772
+ }
773
+
774
+ for (const session of filteredSessions) {
775
+ const ts = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
776
+ if (!ts || ts < range.start.getTime() || ts > range.end.getTime()) continue;
777
+ const date = new Date(ts).toISOString().slice(0, 10);
778
+ const bucket = byDate.get(date);
779
+ if (!bucket) continue;
780
+ bucket.sessions += 1;
781
+ bucket.tools += session.toolCallCount || 0;
782
+ }
783
+
784
+ return Array.from(byDate.values());
785
+ }
786
+
787
+ function buildUsageStats(projectPath = null, options = {}) {
788
+ const daily = countDailyUsage(projectPath, options);
789
+ const tools = countToolUsage(projectPath, options);
790
+ return {
791
+ period: tools.period,
792
+ from: tools.from,
793
+ to: tools.to,
794
+ daily,
795
+ topTools: Object.entries(tools.toolUsage)
796
+ .sort((a, b) => b[1] - a[1])
797
+ .slice(0, 8)
798
+ .map(([name, count]) => ({ name, count })),
799
+ sessionsScanned: tools.sessionsScanned
800
+ };
801
+ }
802
+
803
+ function loadMarketplace() {
804
+ return safeReadJson(MARKETPLACE_PATH) || {
805
+ name: 'openai-curated',
806
+ interface: { displayName: 'Codex official' },
807
+ plugins: []
808
+ };
809
+ }
810
+
811
+ function writeMarketplace(data) {
812
+ writeText(MARKETPLACE_PATH, JSON.stringify(data, null, 2));
813
+ invalidateCache();
814
+ }
815
+
816
+ function pluginManifestPath(name) {
817
+ return path.join(PLUGINS_ROOT, sanitizeSkillName(name), '.codex-plugin', 'plugin.json');
818
+ }
819
+
820
+ function writePluginManifest(name, data) {
821
+ const manifestPath = pluginManifestPath(name);
822
+ writeText(manifestPath, JSON.stringify(data, null, 2));
823
+ return manifestPath;
824
+ }
825
+
826
+ function buildPluginManifest(input) {
827
+ const safeName = sanitizeSkillName(input.name);
828
+ return {
829
+ name: safeName,
830
+ version: input.version || '0.1.0',
831
+ description: input.description || '',
832
+ author: {
833
+ name: input.authorName || 'Codex Map',
834
+ email: input.authorEmail || '',
835
+ url: input.authorUrl || ''
836
+ },
837
+ homepage: input.homepage || '',
838
+ repository: input.repository || '',
839
+ license: input.license || 'MIT',
840
+ keywords: ensureArray(input.keywords).filter(Boolean),
841
+ interface: {
842
+ displayName: input.displayName || safeName,
843
+ shortDescription: input.shortDescription || input.description || '',
844
+ longDescription: input.longDescription || input.description || '',
845
+ developerName: input.authorName || 'Codex Map',
846
+ category: input.category || 'Custom',
847
+ capabilities: ensureArray(input.capabilities).filter(Boolean),
848
+ websiteURL: input.homepage || '',
849
+ privacyPolicyURL: input.privacyPolicyURL || '',
850
+ termsOfServiceURL: input.termsOfServiceURL || '',
851
+ defaultPrompt: ensureArray(input.defaultPrompt).filter(Boolean)
852
+ },
853
+ skills: './skills/',
854
+ apps: './.app.json'
855
+ };
856
+ }
857
+
858
+ function getConfiguredProjects(config) {
859
+ const sessionsByPath = new Map();
860
+ for (const session of listSessions()) {
861
+ if (!session.cwd) continue;
862
+ const key = path.resolve(session.cwd);
863
+ sessionsByPath.set(key, (sessionsByPath.get(key) || 0) + 1);
864
+ }
865
+
866
+ return (config?.projects || []).map(project => {
867
+ const resolved = path.resolve(project.path);
868
+ const hasAgents = fileExists(path.join(resolved, 'AGENTS.md'));
869
+ const hasCodexDir = fileExists(path.join(resolved, '.codex'));
870
+ const hasMcp = fileExists(path.join(resolved, '.mcp.json')) || fileExists(path.join(resolved, '.codex', '.mcp.json'));
871
+ const exists = fileExists(resolved);
872
+ const score = [hasAgents, hasCodexDir, hasMcp].filter(Boolean).length;
873
+
874
+ return {
875
+ path: resolved,
876
+ name: path.basename(resolved),
877
+ trustLevel: project.trustLevel || null,
878
+ exists,
879
+ hasAgents,
880
+ hasCodexDir,
881
+ hasMcp,
882
+ sessionCount: sessionsByPath.get(resolved) || 0,
883
+ status: !exists ? 'missing' : score >= 2 ? 'full' : score === 1 ? 'partial' : 'none'
884
+ };
885
+ });
886
+ }
887
+
888
+ function readProjectConfig(projectPath, config) {
889
+ const resolved = path.resolve(projectPath);
890
+ const projectCodexDir = path.join(resolved, '.codex');
891
+ const trust = (config?.projects || []).find(item => path.resolve(item.path) === resolved) || null;
892
+
893
+ return {
894
+ path: resolved,
895
+ projectName: path.basename(resolved),
896
+ hasCodexDir: fileExists(projectCodexDir),
897
+ agentsMd: readAgentsMd(resolved) || readAgentsMd(projectCodexDir),
898
+ mcpJson: readMcpJson(resolved),
899
+ trustLevel: trust?.trustLevel || null,
900
+ localSkills: fileExists(projectCodexDir) ? readSkillsDir(projectCodexDir) : [],
901
+ fileTree: buildFileTree(resolved, 0, 3)
902
+ };
903
+ }
904
+
905
+ function buildProjectAnalysis(projectPath) {
906
+ const resolved = path.resolve(projectPath);
907
+ const config = readConfigToml();
908
+ const exists = fileExists(resolved);
909
+
910
+ if (!exists) {
911
+ return {
912
+ project: {
913
+ path: resolved,
914
+ name: path.basename(resolved),
915
+ exists: false,
916
+ hasCodexDir: false,
917
+ hasAgentsMd: false,
918
+ hasMcpJson: false,
919
+ trustLevel: null,
920
+ sessionCount: 0,
921
+ status: 'missing',
922
+ warnings: [{ level: 'error', message: 'Path does not exist on disk' }]
923
+ },
924
+ connections: {
925
+ global: {
926
+ skills: { count: readSkillsDir(CODEX_DIR).length },
927
+ mcp: { count: config?.mcpServers?.length || 0 },
928
+ projects: { count: config?.projects?.length || 0 },
929
+ plugins: { count: readPluginManifests().length }
930
+ },
931
+ local: {
932
+ agentsMd: { present: false },
933
+ skills: { count: 0 },
934
+ mcpJson: { present: false }
935
+ }
936
+ }
937
+ };
938
+ }
939
+
940
+ const project = readProjectConfig(resolved, config);
941
+ const sessions = listSessions(resolved);
942
+ const score = [project.hasCodexDir, !!project.agentsMd, !!project.mcpJson, !!project.trustLevel].filter(Boolean).length;
943
+ const warnings = [];
944
+
945
+ if (!project.trustLevel) warnings.push({ level: 'info', message: 'Project is not listed in ~/.codex/config.toml' });
946
+ if (!project.agentsMd) warnings.push({ level: 'warning', message: 'No AGENTS.md found for project-specific instructions' });
947
+ if (!project.mcpJson) warnings.push({ level: 'info', message: 'No .mcp.json found for this project' });
948
+
949
+ return {
950
+ project: {
951
+ path: resolved,
952
+ name: path.basename(resolved),
953
+ exists: true,
954
+ hasCodexDir: project.hasCodexDir,
955
+ hasAgentsMd: !!project.agentsMd,
956
+ hasMcpJson: !!project.mcpJson,
957
+ trustLevel: project.trustLevel,
958
+ sessionCount: sessions.length,
959
+ status: score >= 3 ? 'full' : score >= 1 ? 'partial' : 'none',
960
+ warnings
961
+ },
962
+ connections: {
963
+ global: {
964
+ skills: { count: readSkillsDir(CODEX_DIR).length },
965
+ mcp: { count: config?.mcpServers?.length || 0 },
966
+ projects: { count: config?.projects?.length || 0 },
967
+ plugins: { count: readPluginManifests().length }
968
+ },
969
+ local: {
970
+ agentsMd: { present: !!project.agentsMd },
971
+ skills: { count: project.localSkills.length },
972
+ mcpJson: { present: !!project.mcpJson, servers: project.mcpJson?.servers || [] }
973
+ }
974
+ }
975
+ };
976
+ }
977
+
978
+ async function buildScanResult(projectPath = null) {
979
+ const started = Date.now();
980
+ const config = readConfigToml();
981
+
982
+ const result = {
983
+ meta: {
984
+ scannedAt: new Date().toISOString(),
985
+ globalPath: CODEX_DIR,
986
+ projectPath: projectPath ? path.resolve(projectPath) : null,
987
+ scanDurationMs: 0
988
+ },
989
+ global: {
990
+ agentsMd: readAgentsMd(CODEX_DIR),
991
+ config,
992
+ skills: readSkillsDir(CODEX_DIR),
993
+ plugins: readPluginManifests(),
994
+ history: readHistoryEntries(300),
995
+ projects: getConfiguredProjects(config),
996
+ sessionSummary: {
997
+ total: listSessions().length
998
+ },
999
+ fileTree: buildFileTree(CODEX_DIR, 0, 3)
1000
+ },
1001
+ project: projectPath ? readProjectConfig(projectPath, config) : null
1002
+ };
1003
+
1004
+ result.meta.scanDurationMs = Date.now() - started;
1005
+ return result;
1006
+ }
1007
+
1008
+ function broadcastSSE(event, data) {
1009
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1010
+ for (const res of sseClients) {
1011
+ try {
1012
+ res.write(payload);
1013
+ } catch {
1014
+ sseClients.delete(res);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ setInterval(() => broadcastSSE('heartbeat', { ts: Date.now() }), 30000);
1020
+
1021
+ const watcher = chokidar.watch([
1022
+ path.join(CODEX_DIR, 'config.toml'),
1023
+ path.join(CODEX_DIR, 'history.jsonl'),
1024
+ path.join(CODEX_DIR, 'session_index.jsonl'),
1025
+ path.join(CODEX_DIR, 'skills'),
1026
+ path.join(CODEX_DIR, '.tmp', 'plugins', 'plugins')
1027
+ ].filter(fileExists), {
1028
+ persistent: true,
1029
+ ignoreInitial: true,
1030
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
1031
+ });
1032
+
1033
+ watcher.on('all', (event, filePath) => {
1034
+ invalidateCache();
1035
+ broadcastSSE('file-changed', { event, path: filePath });
1036
+ });
1037
+
1038
+ function isPathAllowed(requestedPath, projectPath) {
1039
+ const resolved = path.resolve(requestedPath);
1040
+ const allowed = [CODEX_DIR];
1041
+ if (projectPath) allowed.push(path.resolve(projectPath));
1042
+ return allowed.some(base => resolved === base || resolved.startsWith(`${base}${path.sep}`));
1043
+ }
1044
+
1045
+ function inspectProjectPath(projectPath) {
1046
+ const resolved = path.resolve(projectPath);
1047
+ const hasAgents = fileExists(path.join(resolved, 'AGENTS.md'));
1048
+ const hasCodexDir = fileExists(path.join(resolved, '.codex'));
1049
+ const hasMcp = fileExists(path.join(resolved, '.mcp.json')) || fileExists(path.join(resolved, '.codex', '.mcp.json'));
1050
+ const score = [hasAgents, hasCodexDir, hasMcp].filter(Boolean).length;
1051
+ return {
1052
+ hasAgents,
1053
+ hasCodexDir,
1054
+ hasMcp,
1055
+ score,
1056
+ status: score >= 2 ? 'full' : score === 1 ? 'partial' : 'none',
1057
+ isProject: score > 0
1058
+ };
1059
+ }
1060
+
1061
+ function discoverProjectPaths(rootPath, showHidden, maxDepth = 4, maxResults = 80) {
1062
+ const root = path.resolve(rootPath);
1063
+ const found = [];
1064
+ const seen = new Set();
1065
+
1066
+ function walk(currentPath, depth) {
1067
+ if (found.length >= maxResults || depth > maxDepth) return;
1068
+
1069
+ let entries = [];
1070
+ try {
1071
+ entries = fs.readdirSync(currentPath, { withFileTypes: true });
1072
+ } catch {
1073
+ return;
1074
+ }
1075
+
1076
+ for (const entry of entries) {
1077
+ if (!entry.isDirectory()) continue;
1078
+ if (!showHidden && entry.name.startsWith('.')) continue;
1079
+ const fullPath = path.join(currentPath, entry.name);
1080
+ const meta = inspectProjectPath(fullPath);
1081
+ if (meta.isProject && !seen.has(fullPath)) {
1082
+ seen.add(fullPath);
1083
+ found.push({
1084
+ name: path.basename(fullPath),
1085
+ path: fullPath,
1086
+ ...meta
1087
+ });
1088
+ if (found.length >= maxResults) return;
1089
+ }
1090
+ walk(fullPath, depth + 1);
1091
+ if (found.length >= maxResults) return;
1092
+ }
1093
+ }
1094
+
1095
+ walk(root, 1);
1096
+ return found;
1097
+ }
1098
+
1099
+ function browseDir(dirPath, showHidden) {
1100
+ const resolved = path.resolve(dirPath);
1101
+ const config = readConfigToml();
1102
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
1103
+ const dirs = entries
1104
+ .filter(entry => entry.isDirectory())
1105
+ .filter(entry => showHidden || !entry.name.startsWith('.'))
1106
+ .map(entry => {
1107
+ const fullPath = path.join(resolved, entry.name);
1108
+ const meta = inspectProjectPath(fullPath);
1109
+ return {
1110
+ name: entry.name,
1111
+ path: fullPath,
1112
+ ...meta
1113
+ };
1114
+ });
1115
+
1116
+ const projectDirs = dirs.filter(dir => dir.isProject);
1117
+ const discovered = discoverProjectPaths(resolved, showHidden)
1118
+ .filter(item => item.path !== resolved && !projectDirs.some(dir => dir.path === item.path));
1119
+ const trustedProjects = getConfiguredProjects(config)
1120
+ .filter(item => item.exists && item.path.startsWith(`${resolved}${path.sep}`))
1121
+ .map(item => ({
1122
+ name: item.name,
1123
+ path: item.path,
1124
+ status: item.status,
1125
+ isProject: item.status !== 'none',
1126
+ score: item.status === 'full' ? 3 : item.status === 'partial' ? 1 : 0,
1127
+ trusted: true
1128
+ }))
1129
+ .filter(item => !projectDirs.some(dir => dir.path === item.path) && !discovered.some(dir => dir.path === item.path));
1130
+ const visibleDirs = projectDirs.length ? projectDirs : dirs;
1131
+ visibleDirs.sort((a, b) => {
1132
+ if (a.score !== b.score) return b.score - a.score;
1133
+ return a.name.localeCompare(b.name);
1134
+ });
1135
+ discovered.sort((a, b) => {
1136
+ if (a.score !== b.score) return b.score - a.score;
1137
+ return a.path.localeCompare(b.path);
1138
+ });
1139
+ trustedProjects.sort((a, b) => a.path.localeCompare(b.path));
1140
+
1141
+ const root = path.parse(resolved).root;
1142
+ const rel = path.relative(root, resolved);
1143
+ const parts = rel ? rel.split(path.sep) : [];
1144
+ const crumbs = [{ name: root || '/', path: root || '/' }];
1145
+ let acc = root;
1146
+
1147
+ for (const part of parts) {
1148
+ acc = path.join(acc, part);
1149
+ crumbs.push({ name: part, path: acc });
1150
+ }
1151
+
1152
+ return {
1153
+ current: resolved,
1154
+ parent: resolved !== root ? path.dirname(resolved) : null,
1155
+ crumbs,
1156
+ dirs: visibleDirs,
1157
+ codexOnly: projectDirs.length > 0,
1158
+ discovered,
1159
+ trustedProjects
1160
+ };
1161
+ }
1162
+
1163
+ app.use(express.static(path.join(__dirname, 'public')));
1164
+
1165
+ app.get('/api/pinned-projects', (req, res) => {
1166
+ res.json({ projects: readPinned() });
1167
+ });
1168
+
1169
+ app.post('/api/pinned-projects', (req, res) => {
1170
+ const projectPath = req.body?.path;
1171
+ if (!projectPath || typeof projectPath !== 'string') {
1172
+ return res.status(400).json({ error: 'Missing path' });
1173
+ }
1174
+
1175
+ const resolved = path.resolve(projectPath);
1176
+ const projects = readPinned();
1177
+ if (!projects.includes(resolved)) {
1178
+ projects.push(resolved);
1179
+ writePinned(projects);
1180
+ }
1181
+
1182
+ res.json({ projects });
1183
+ });
1184
+
1185
+ app.delete('/api/pinned-projects', (req, res) => {
1186
+ const projectPath = req.body?.path;
1187
+ if (!projectPath) return res.status(400).json({ error: 'Missing path' });
1188
+ const resolved = path.resolve(projectPath);
1189
+ const projects = readPinned().filter(item => item !== resolved);
1190
+ writePinned(projects);
1191
+ res.json({ projects });
1192
+ });
1193
+
1194
+ app.get('/api/browse', (req, res) => {
1195
+ const dirPath = req.query.path || os.homedir();
1196
+ const showHidden = req.query.hidden === '1';
1197
+
1198
+ try {
1199
+ res.json(browseDir(dirPath, showHidden));
1200
+ } catch (error) {
1201
+ const parent = path.dirname(path.resolve(dirPath));
1202
+ try {
1203
+ res.json({ ...browseDir(parent, showHidden), error: error.message });
1204
+ } catch {
1205
+ res.status(403).json({ error: error.message });
1206
+ }
1207
+ }
1208
+ });
1209
+
1210
+ app.get('/api/browse/bookmarks', (req, res) => {
1211
+ const home = os.homedir();
1212
+ const bookmarks = [
1213
+ { name: 'Home', path: home },
1214
+ { name: 'Projects', path: '/Volumes/Projects' },
1215
+ { name: 'Codex Home', path: CODEX_DIR },
1216
+ { name: 'Desktop', path: path.join(home, 'Desktop') }
1217
+ ].filter(item => fileExists(item.path));
1218
+
1219
+ res.json({ bookmarks });
1220
+ });
1221
+
1222
+ app.get('/api/scan', async (req, res) => {
1223
+ try {
1224
+ res.json(await getCachedScan(req.query.project || null));
1225
+ } catch (error) {
1226
+ res.status(500).json({ error: error.message, code: 'SCAN_FAILED' });
1227
+ }
1228
+ });
1229
+
1230
+ app.get('/api/project-status', (req, res) => {
1231
+ const projectPath = req.query.path;
1232
+ if (!projectPath) return res.status(400).json({ error: 'Missing path' });
1233
+
1234
+ const resolved = path.resolve(projectPath);
1235
+ if (!fileExists(resolved)) return res.json({ status: 'missing' });
1236
+ const meta = inspectProjectPath(resolved);
1237
+ res.json({ status: meta.status, ...meta });
1238
+ });
1239
+
1240
+ app.get('/api/analyze', (req, res) => {
1241
+ const projectPath = req.query.project;
1242
+ if (!projectPath) return res.status(400).json({ error: 'Missing project parameter' });
1243
+
1244
+ try {
1245
+ res.json(buildProjectAnalysis(projectPath));
1246
+ } catch (error) {
1247
+ res.status(500).json({ error: error.message });
1248
+ }
1249
+ });
1250
+
1251
+ app.get('/api/file', (req, res) => {
1252
+ const filePath = req.query.path;
1253
+ const projectPath = req.query.project || null;
1254
+ if (!filePath) return res.status(400).json({ error: 'Missing path' });
1255
+ if (!isPathAllowed(filePath, projectPath)) return res.status(403).json({ error: 'Forbidden' });
1256
+
1257
+ try {
1258
+ const stat = fs.statSync(filePath);
1259
+ if (stat.size > MAX_FILE_BYTES) {
1260
+ const content = fs.readFileSync(filePath, 'utf8').slice(0, MAX_FILE_BYTES);
1261
+ return res.json({
1262
+ content: `${content}\n\n[... file truncated ...]`,
1263
+ size: stat.size,
1264
+ truncated: true,
1265
+ mtime: stat.mtime.toISOString()
1266
+ });
1267
+ }
1268
+
1269
+ res.json({
1270
+ content: fs.readFileSync(filePath, 'utf8'),
1271
+ size: stat.size,
1272
+ truncated: false,
1273
+ mtime: stat.mtime.toISOString()
1274
+ });
1275
+ } catch (error) {
1276
+ res.status(404).json({ error: error.message });
1277
+ }
1278
+ });
1279
+
1280
+ app.put('/api/file', (req, res) => {
1281
+ const filePath = req.body?.path;
1282
+ const content = req.body?.content;
1283
+ const projectPath = req.body?.projectPath || null;
1284
+ if (!filePath || typeof content !== 'string') return res.status(400).json({ error: 'Missing path or content' });
1285
+ if (!isPathAllowed(filePath, projectPath)) return res.status(403).json({ error: 'Forbidden' });
1286
+
1287
+ try {
1288
+ writeText(filePath, content);
1289
+ invalidateCache();
1290
+ res.json({ ok: true, path: filePath });
1291
+ } catch (error) {
1292
+ res.status(500).json({ error: error.message });
1293
+ }
1294
+ });
1295
+
1296
+ app.get('/api/export', async (req, res) => {
1297
+ try {
1298
+ const data = await getCachedScan(req.query.project || null);
1299
+ const filename = `codex-map-${new Date().toISOString().slice(0, 10)}.json`;
1300
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
1301
+ res.setHeader('Content-Type', 'application/json');
1302
+ res.send(JSON.stringify(data, null, 2));
1303
+ } catch (error) {
1304
+ res.status(500).json({ error: error.message });
1305
+ }
1306
+ });
1307
+
1308
+ app.get('/api/config', (req, res) => {
1309
+ res.json(readConfigToml());
1310
+ });
1311
+
1312
+ app.put('/api/config', (req, res) => {
1313
+ const raw = req.body?.raw;
1314
+ if (typeof raw !== 'string') return res.status(400).json({ error: 'Missing raw config' });
1315
+ try {
1316
+ const data = TOML.parse(raw);
1317
+ writeText(path.join(CODEX_DIR, 'config.toml'), TOML.stringify(data));
1318
+ invalidateCache();
1319
+ res.json(readConfigToml());
1320
+ } catch (error) {
1321
+ res.status(400).json({ error: error.message });
1322
+ }
1323
+ });
1324
+
1325
+ app.post('/api/config/mcp', (req, res) => {
1326
+ try {
1327
+ const { name, command, args, env, cwd } = req.body || {};
1328
+ if (!name || !command) return res.status(400).json({ error: 'Missing name or command' });
1329
+ const doc = readConfigTomlDoc();
1330
+ doc.data.mcp_servers = doc.data.mcp_servers || {};
1331
+ doc.data.mcp_servers[name] = {
1332
+ command,
1333
+ args: ensureArray(args).filter(Boolean),
1334
+ cwd: cwd || undefined,
1335
+ env: env || undefined
1336
+ };
1337
+ res.json(writeConfigTomlData(doc.data));
1338
+ } catch (error) {
1339
+ res.status(400).json({ error: error.message });
1340
+ }
1341
+ });
1342
+
1343
+ app.put('/api/config/mcp/:name', (req, res) => {
1344
+ try {
1345
+ const { newName, command, args, env, cwd } = req.body || {};
1346
+ const doc = readConfigTomlDoc();
1347
+ doc.data.mcp_servers = doc.data.mcp_servers || {};
1348
+ const existing = doc.data.mcp_servers[req.params.name];
1349
+ if (!existing) return res.status(404).json({ error: 'MCP server not found' });
1350
+ delete doc.data.mcp_servers[req.params.name];
1351
+ doc.data.mcp_servers[newName || req.params.name] = {
1352
+ command,
1353
+ args: ensureArray(args).filter(Boolean),
1354
+ cwd: cwd || undefined,
1355
+ env: env || undefined
1356
+ };
1357
+ res.json(writeConfigTomlData(doc.data));
1358
+ } catch (error) {
1359
+ res.status(400).json({ error: error.message });
1360
+ }
1361
+ });
1362
+
1363
+ app.delete('/api/config/mcp/:name', (req, res) => {
1364
+ try {
1365
+ const doc = readConfigTomlDoc();
1366
+ if (!doc.data.mcp_servers?.[req.params.name]) return res.status(404).json({ error: 'MCP server not found' });
1367
+ delete doc.data.mcp_servers[req.params.name];
1368
+ res.json(writeConfigTomlData(doc.data));
1369
+ } catch (error) {
1370
+ res.status(400).json({ error: error.message });
1371
+ }
1372
+ });
1373
+
1374
+ app.post('/api/config/projects', (req, res) => {
1375
+ try {
1376
+ const projectPath = req.body?.path;
1377
+ const trustLevel = req.body?.trustLevel || 'trusted';
1378
+ if (!projectPath) return res.status(400).json({ error: 'Missing path' });
1379
+ const resolved = path.resolve(projectPath);
1380
+ const doc = readConfigTomlDoc();
1381
+ doc.data.projects = doc.data.projects || {};
1382
+ doc.data.projects[resolved] = { ...(doc.data.projects[resolved] || {}), trust_level: trustLevel };
1383
+ res.json(writeConfigTomlData(doc.data));
1384
+ } catch (error) {
1385
+ res.status(400).json({ error: error.message });
1386
+ }
1387
+ });
1388
+
1389
+ app.put('/api/config/projects', (req, res) => {
1390
+ try {
1391
+ const projectPath = req.body?.path;
1392
+ const trustLevel = req.body?.trustLevel || 'trusted';
1393
+ if (!projectPath) return res.status(400).json({ error: 'Missing path' });
1394
+ const resolved = path.resolve(projectPath);
1395
+ const doc = readConfigTomlDoc();
1396
+ if (!doc.data.projects?.[resolved]) return res.status(404).json({ error: 'Project not found' });
1397
+ doc.data.projects[resolved] = { ...(doc.data.projects[resolved] || {}), trust_level: trustLevel };
1398
+ res.json(writeConfigTomlData(doc.data));
1399
+ } catch (error) {
1400
+ res.status(400).json({ error: error.message });
1401
+ }
1402
+ });
1403
+
1404
+ app.delete('/api/config/projects', (req, res) => {
1405
+ try {
1406
+ const projectPath = req.body?.path;
1407
+ if (!projectPath) return res.status(400).json({ error: 'Missing path' });
1408
+ const resolved = path.resolve(projectPath);
1409
+ const doc = readConfigTomlDoc();
1410
+ if (!doc.data.projects?.[resolved]) return res.status(404).json({ error: 'Project not found' });
1411
+ delete doc.data.projects[resolved];
1412
+ res.json(writeConfigTomlData(doc.data));
1413
+ } catch (error) {
1414
+ res.status(400).json({ error: error.message });
1415
+ }
1416
+ });
1417
+
1418
+ app.get('/api/skills', (req, res) => {
1419
+ try {
1420
+ const scope = req.query.scope === 'project' ? 'project' : 'global';
1421
+ const baseDir = getSkillsBaseDir(scope, req.query.projectPath || req.query.project || null);
1422
+ res.json({ skills: readSkillsDir(path.dirname(baseDir)) });
1423
+ } catch (error) {
1424
+ res.status(400).json({ error: error.message });
1425
+ }
1426
+ });
1427
+
1428
+ app.get('/api/skills/:name', (req, res) => {
1429
+ try {
1430
+ const scope = req.query.scope === 'project' ? 'project' : 'global';
1431
+ const baseDir = getSkillsBaseDir(scope, req.query.projectPath || req.query.project || null);
1432
+ const filePath = resolveSkillFile(baseDir, req.params.name);
1433
+ if (!fileExists(filePath)) return res.status(404).json({ error: 'Skill not found' });
1434
+ res.json({ name: req.params.name, path: filePath, content: safeReadText(filePath) });
1435
+ } catch (error) {
1436
+ res.status(400).json({ error: error.message });
1437
+ }
1438
+ });
1439
+
1440
+ app.post('/api/skills', (req, res) => {
1441
+ try {
1442
+ const { name, content, scope, projectPath } = req.body || {};
1443
+ if (!name || !content) return res.status(400).json({ error: 'Missing name or content' });
1444
+ const baseDir = getSkillsBaseDir(scope === 'project' ? 'project' : 'global', projectPath || null);
1445
+ fs.mkdirSync(baseDir, { recursive: true });
1446
+ const filePath = resolveSkillFile(baseDir, name);
1447
+ if (fileExists(filePath)) return res.status(409).json({ error: 'Skill already exists' });
1448
+ fs.writeFileSync(filePath, content, 'utf8');
1449
+ invalidateCache();
1450
+ res.json({ ok: true, path: filePath });
1451
+ } catch (error) {
1452
+ res.status(400).json({ error: error.message });
1453
+ }
1454
+ });
1455
+
1456
+ app.put('/api/skills/:name', (req, res) => {
1457
+ try {
1458
+ const { content, scope, projectPath } = req.body || {};
1459
+ if (!content) return res.status(400).json({ error: 'Missing content' });
1460
+ const baseDir = getSkillsBaseDir(scope === 'project' ? 'project' : 'global', projectPath || null);
1461
+ fs.mkdirSync(baseDir, { recursive: true });
1462
+ const filePath = resolveSkillFile(baseDir, req.params.name);
1463
+ fs.writeFileSync(filePath, content, 'utf8');
1464
+ invalidateCache();
1465
+ res.json({ ok: true, path: filePath });
1466
+ } catch (error) {
1467
+ res.status(400).json({ error: error.message });
1468
+ }
1469
+ });
1470
+
1471
+ app.delete('/api/skills/:name', (req, res) => {
1472
+ try {
1473
+ const scope = req.query.scope === 'project' ? 'project' : 'global';
1474
+ const baseDir = getSkillsBaseDir(scope, req.query.projectPath || null);
1475
+ const filePath = resolveSkillFile(baseDir, req.params.name);
1476
+ if (!fileExists(filePath)) return res.status(404).json({ error: 'Skill not found' });
1477
+ fs.unlinkSync(filePath);
1478
+ invalidateCache();
1479
+ res.json({ ok: true });
1480
+ } catch (error) {
1481
+ res.status(400).json({ error: error.message });
1482
+ }
1483
+ });
1484
+
1485
+ app.post('/api/export/bundle', (req, res) => {
1486
+ const { scope, projectPath } = req.body || {};
1487
+ const isProject = scope === 'project' && projectPath;
1488
+ const baseDir = isProject ? path.join(path.resolve(projectPath), '.codex') : CODEX_DIR;
1489
+
1490
+ const bundle = {
1491
+ version: 1,
1492
+ type: 'codex-map-bundle',
1493
+ exportedAt: new Date().toISOString(),
1494
+ source: {
1495
+ scope: isProject ? 'project' : 'global',
1496
+ path: isProject ? path.resolve(projectPath) : CODEX_DIR
1497
+ },
1498
+ skills: readSkillsDir(baseDir).map(skill => ({ name: skill.name, raw: skill.raw })),
1499
+ agentsMd: isProject
1500
+ ? (readAgentsMd(path.resolve(projectPath))?.raw || null)
1501
+ : (readAgentsMd(CODEX_DIR)?.raw || null)
1502
+ };
1503
+
1504
+ const filename = `codex-map-bundle-${new Date().toISOString().slice(0, 10)}.json`;
1505
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
1506
+ res.setHeader('Content-Type', 'application/json');
1507
+ res.send(JSON.stringify(bundle, null, 2));
1508
+ });
1509
+
1510
+ app.get('/api/plugins', (req, res) => {
1511
+ res.json({ plugins: readPluginManifests(), marketplace: loadMarketplace() });
1512
+ });
1513
+
1514
+ app.post('/api/plugins', (req, res) => {
1515
+ try {
1516
+ const manifest = buildPluginManifest(req.body || {});
1517
+ const safeName = manifest.name;
1518
+ const marketplace = loadMarketplace();
1519
+ if ((marketplace.plugins || []).some(item => item.name === safeName)) {
1520
+ return res.status(409).json({ error: 'Plugin already exists' });
1521
+ }
1522
+ writePluginManifest(safeName, manifest);
1523
+ marketplace.plugins = marketplace.plugins || [];
1524
+ marketplace.plugins.push({
1525
+ name: safeName,
1526
+ source: { source: 'local', path: `./plugins/${safeName}` },
1527
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
1528
+ category: req.body?.category || 'Custom'
1529
+ });
1530
+ writeMarketplace(marketplace);
1531
+ res.json({ ok: true, plugin: safeName });
1532
+ } catch (error) {
1533
+ res.status(400).json({ error: error.message });
1534
+ }
1535
+ });
1536
+
1537
+ app.put('/api/plugins/:name', (req, res) => {
1538
+ try {
1539
+ const oldName = sanitizeSkillName(req.params.name);
1540
+ const nextManifest = buildPluginManifest(req.body || {});
1541
+ const nextName = nextManifest.name;
1542
+ const oldPath = pluginManifestPath(oldName);
1543
+ if (!fileExists(oldPath)) return res.status(404).json({ error: 'Plugin not found' });
1544
+ if (oldName !== nextName) {
1545
+ fs.rmSync(path.join(PLUGINS_ROOT, oldName), { recursive: true, force: true });
1546
+ }
1547
+ writePluginManifest(nextName, nextManifest);
1548
+ const marketplace = loadMarketplace();
1549
+ marketplace.plugins = (marketplace.plugins || []).filter(item => item.name !== oldName);
1550
+ marketplace.plugins.push({
1551
+ name: nextName,
1552
+ source: { source: 'local', path: `./plugins/${nextName}` },
1553
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
1554
+ category: req.body?.category || 'Custom'
1555
+ });
1556
+ writeMarketplace(marketplace);
1557
+ res.json({ ok: true, plugin: nextName });
1558
+ } catch (error) {
1559
+ res.status(400).json({ error: error.message });
1560
+ }
1561
+ });
1562
+
1563
+ app.delete('/api/plugins/:name', (req, res) => {
1564
+ try {
1565
+ const safeName = sanitizeSkillName(req.params.name);
1566
+ fs.rmSync(path.join(PLUGINS_ROOT, safeName), { recursive: true, force: true });
1567
+ const marketplace = loadMarketplace();
1568
+ marketplace.plugins = (marketplace.plugins || []).filter(item => item.name !== safeName);
1569
+ writeMarketplace(marketplace);
1570
+ res.json({ ok: true });
1571
+ } catch (error) {
1572
+ res.status(400).json({ error: error.message });
1573
+ }
1574
+ });
1575
+
1576
+ app.get('/api/sessions', (req, res) => {
1577
+ const projectPath = req.query.project || null;
1578
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
1579
+ const offset = parseInt(req.query.offset, 10) || 0;
1580
+ const sessions = listSessions(projectPath);
1581
+
1582
+ res.json({
1583
+ sessions: sessions.slice(offset, offset + limit),
1584
+ total: sessions.length,
1585
+ offset
1586
+ });
1587
+ });
1588
+
1589
+ app.get('/api/sessions/:id', (req, res) => {
1590
+ const detail = readSessionDetail(req.params.id);
1591
+ if (!detail) return res.status(404).json({ error: 'Session not found' });
1592
+ res.json(detail);
1593
+ });
1594
+
1595
+ app.post('/api/sessions', (req, res) => {
1596
+ try {
1597
+ const title = String(req.body?.title || '').trim() || 'New session';
1598
+ const cwd = path.resolve(req.body?.cwd || '/Volumes/Projects');
1599
+ const modelProvider = String(req.body?.modelProvider || 'openai');
1600
+ const cliVersion = String(req.body?.cliVersion || '0.120.0');
1601
+ const id = randomUUID();
1602
+ const { filePath, timestamp } = createSessionFile({ id, cwd, title, modelProvider, cliVersion });
1603
+
1604
+ execSqlite(STATE_DB, `
1605
+ INSERT INTO threads (
1606
+ id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
1607
+ sandbox_policy, approval_mode, cli_version, first_user_message
1608
+ ) VALUES (
1609
+ ${sqlString(id)},
1610
+ ${sqlString(filePath)},
1611
+ ${timestamp},
1612
+ ${timestamp},
1613
+ 'cli',
1614
+ ${sqlString(modelProvider)},
1615
+ ${sqlString(cwd)},
1616
+ ${sqlString(title)},
1617
+ ${sqlString('{"type":"danger-full-access","writable_roots":[],"network_access":true}')},
1618
+ 'never',
1619
+ ${sqlString(cliVersion)},
1620
+ ${sqlString(title)}
1621
+ );
1622
+ `);
1623
+
1624
+ invalidateCache();
1625
+ res.json(readSessionDetail(id));
1626
+ } catch (error) {
1627
+ res.status(500).json({ error: error.message });
1628
+ }
1629
+ });
1630
+
1631
+ app.put('/api/sessions/:id', (req, res) => {
1632
+ const title = req.body?.title;
1633
+ if (!title) return res.status(400).json({ error: 'Missing title' });
1634
+ try {
1635
+ execSqlite(STATE_DB, `UPDATE threads SET title = ${sqlString(title)}, updated_at = strftime('%s','now') WHERE id = ${sqlString(req.params.id)};`);
1636
+ invalidateCache();
1637
+ res.json({ ok: true });
1638
+ } catch (error) {
1639
+ res.status(500).json({ error: error.message });
1640
+ }
1641
+ });
1642
+
1643
+ app.delete('/api/sessions/:id', (req, res) => {
1644
+ try {
1645
+ const filePath = findSessionFile(req.params.id);
1646
+ if (filePath) fs.rmSync(filePath, { force: true });
1647
+ execSqlite(STATE_DB, `DELETE FROM threads WHERE id = ${sqlString(req.params.id)};`);
1648
+ invalidateCache();
1649
+ res.json({ ok: true });
1650
+ } catch (error) {
1651
+ res.status(500).json({ error: error.message });
1652
+ }
1653
+ });
1654
+
1655
+ app.get('/api/history', (req, res) => {
1656
+ const projectPath = req.query.project ? path.resolve(req.query.project) : null;
1657
+ const limit = Math.min(parseInt(req.query.limit, 10) || 200, 500);
1658
+ const history = readHistoryEntries(limit * 4);
1659
+
1660
+ if (!projectPath) return res.json({ entries: history.slice(0, limit) });
1661
+
1662
+ const allowedIds = new Set(listSessions(projectPath).map(session => session.id));
1663
+ res.json({ entries: history.filter(entry => allowedIds.has(entry.sessionId)).slice(0, limit) });
1664
+ });
1665
+
1666
+ app.get('/api/stats/tools', (req, res) => {
1667
+ const projectPath = req.query.project || null;
1668
+ const days = parseInt(req.query.days, 10) || 30;
1669
+ res.json(countToolUsage(projectPath, {
1670
+ days,
1671
+ from: req.query.from || null,
1672
+ to: req.query.to || null
1673
+ }));
1674
+ });
1675
+
1676
+ app.get('/api/stats/usage', (req, res) => {
1677
+ const projectPath = req.query.project || null;
1678
+ const days = parseInt(req.query.days, 10) || 14;
1679
+ res.json(buildUsageStats(projectPath, {
1680
+ days,
1681
+ from: req.query.from || null,
1682
+ to: req.query.to || null
1683
+ }));
1684
+ });
1685
+
1686
+ app.get('/api/events', (req, res) => {
1687
+ res.setHeader('Content-Type', 'text/event-stream');
1688
+ res.setHeader('Cache-Control', 'no-cache');
1689
+ res.setHeader('Connection', 'keep-alive');
1690
+ res.flushHeaders();
1691
+
1692
+ res.write(`event: connected\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`);
1693
+ sseClients.add(res);
1694
+
1695
+ req.on('close', () => sseClients.delete(res));
1696
+ });
1697
+
1698
+ app.listen(PORT, () => {
1699
+ console.log('\n Codex Map');
1700
+ console.log(' ─────────');
1701
+ console.log(` http://localhost:${PORT}\n`);
1702
+ });