@monoes/monomindcli 2.7.13 → 2.7.14
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/.claude/commands/mastermind/createorg.md +2 -0
- package/.claude/commands/mastermind/okf-import.md +6 -1
- package/.claude/helpers/handlers/route-handler.cjs +40 -7
- package/.claude/helpers/handlers/session-restore-handler.cjs +24 -6
- package/.claude/skills/mastermind-createorg/SKILL.md +11 -4
- package/README.md +12 -6
- package/dist/src/capabilities/index.d.ts.map +1 -1
- package/dist/src/capabilities/index.js +17 -0
- package/dist/src/capabilities/index.js.map +1 -1
- package/dist/src/capabilities/types.d.ts +0 -11
- package/dist/src/capabilities/types.d.ts.map +1 -1
- package/dist/src/commands/doc.d.ts.map +1 -1
- package/dist/src/commands/doc.js +251 -8
- package/dist/src/commands/doc.js.map +1 -1
- package/dist/src/commands/doctor-project-checks.d.ts +25 -3
- package/dist/src/commands/doctor-project-checks.d.ts.map +1 -1
- package/dist/src/commands/doctor-project-checks.js +111 -8
- package/dist/src/commands/doctor-project-checks.js.map +1 -1
- package/dist/src/commands/doctor.d.ts.map +1 -1
- package/dist/src/commands/doctor.js +18 -2
- package/dist/src/commands/doctor.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +32 -3
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/monograph.d.ts.map +1 -1
- package/dist/src/commands/monograph.js +26 -3
- package/dist/src/commands/monograph.js.map +1 -1
- package/dist/src/commands/org.d.ts +15 -0
- package/dist/src/commands/org.d.ts.map +1 -1
- package/dist/src/commands/org.js +251 -6
- package/dist/src/commands/org.js.map +1 -1
- package/dist/src/init/claudemd-generator.d.ts.map +1 -1
- package/dist/src/init/claudemd-generator.js +4 -1
- package/dist/src/init/claudemd-generator.js.map +1 -1
- package/dist/src/knowledge/document-pipeline.d.ts +77 -4
- package/dist/src/knowledge/document-pipeline.d.ts.map +1 -1
- package/dist/src/knowledge/document-pipeline.js +261 -15
- package/dist/src/knowledge/document-pipeline.js.map +1 -1
- package/dist/src/knowledge/eval/corpus.d.ts +56 -0
- package/dist/src/knowledge/eval/corpus.d.ts.map +1 -0
- package/dist/src/knowledge/eval/corpus.js +126 -0
- package/dist/src/knowledge/eval/corpus.js.map +1 -0
- package/dist/src/knowledge/eval/golden-set.d.ts +62 -0
- package/dist/src/knowledge/eval/golden-set.d.ts.map +1 -0
- package/dist/src/knowledge/eval/golden-set.js +331 -0
- package/dist/src/knowledge/eval/golden-set.js.map +1 -0
- package/dist/src/knowledge/eval/harness.d.ts +221 -0
- package/dist/src/knowledge/eval/harness.d.ts.map +1 -0
- package/dist/src/knowledge/eval/harness.js +610 -0
- package/dist/src/knowledge/eval/harness.js.map +1 -0
- package/dist/src/knowledge/eval/metrics.d.ts +120 -0
- package/dist/src/knowledge/eval/metrics.d.ts.map +1 -0
- package/dist/src/knowledge/eval/metrics.js +243 -0
- package/dist/src/knowledge/eval/metrics.js.map +1 -0
- package/dist/src/knowledge/eval/model-presence.d.ts +48 -0
- package/dist/src/knowledge/eval/model-presence.d.ts.map +1 -0
- package/dist/src/knowledge/eval/model-presence.js +128 -0
- package/dist/src/knowledge/eval/model-presence.js.map +1 -0
- package/dist/src/knowledge/eval/network-guard.d.ts +46 -0
- package/dist/src/knowledge/eval/network-guard.d.ts.map +1 -0
- package/dist/src/knowledge/eval/network-guard.js +112 -0
- package/dist/src/knowledge/eval/network-guard.js.map +1 -0
- package/dist/src/knowledge/eval/retrievers.d.ts +65 -0
- package/dist/src/knowledge/eval/retrievers.d.ts.map +1 -0
- package/dist/src/knowledge/eval/retrievers.js +180 -0
- package/dist/src/knowledge/eval/retrievers.js.map +1 -0
- package/dist/src/knowledge/eval/signals.d.ts +111 -0
- package/dist/src/knowledge/eval/signals.d.ts.map +1 -0
- package/dist/src/knowledge/eval/signals.js +232 -0
- package/dist/src/knowledge/eval/signals.js.map +1 -0
- package/dist/src/mcp-tools/agent-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/agent-tools.js +7 -34
- package/dist/src/mcp-tools/agent-tools.js.map +1 -1
- package/dist/src/mcp-tools/knowledge-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/knowledge-tools.js +88 -6
- package/dist/src/mcp-tools/knowledge-tools.js.map +1 -1
- package/dist/src/mcp-tools/task-tools.d.ts +1 -0
- package/dist/src/mcp-tools/task-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/task-tools.js +76 -111
- package/dist/src/mcp-tools/task-tools.js.map +1 -1
- package/dist/src/mcp-tools/terminal-tools.d.ts.map +1 -1
- package/dist/src/mcp-tools/terminal-tools.js +3 -28
- package/dist/src/mcp-tools/terminal-tools.js.map +1 -1
- package/dist/src/memory/bm25-index.d.ts +137 -0
- package/dist/src/memory/bm25-index.d.ts.map +1 -0
- package/dist/src/memory/bm25-index.js +219 -0
- package/dist/src/memory/bm25-index.js.map +1 -0
- package/dist/src/memory/embedding-operations.d.ts.map +1 -1
- package/dist/src/memory/embedding-operations.js +8 -7
- package/dist/src/memory/embedding-operations.js.map +1 -1
- package/dist/src/memory/ewc-consolidation.d.ts.map +1 -1
- package/dist/src/memory/ewc-consolidation.js +2 -1
- package/dist/src/memory/ewc-consolidation.js.map +1 -1
- package/dist/src/memory/hnsw-operations.d.ts.map +1 -1
- package/dist/src/memory/hnsw-operations.js +4 -3
- package/dist/src/memory/hnsw-operations.js.map +1 -1
- package/dist/src/memory/intelligence.d.ts.map +1 -1
- package/dist/src/memory/intelligence.js +2 -1
- package/dist/src/memory/intelligence.js.map +1 -1
- package/dist/src/memory/memory-bridge.d.ts +34 -0
- package/dist/src/memory/memory-bridge.d.ts.map +1 -1
- package/dist/src/memory/memory-bridge.js +184 -26
- package/dist/src/memory/memory-bridge.js.map +1 -1
- package/dist/src/memory/memory-initializer.d.ts.map +1 -1
- package/dist/src/memory/memory-initializer.js +4 -3
- package/dist/src/memory/memory-initializer.js.map +1 -1
- package/dist/src/memory/memory-schema.d.ts +1 -1
- package/dist/src/memory/memory-schema.js +1 -1
- package/dist/src/memory/text-tokens.d.ts +10 -0
- package/dist/src/memory/text-tokens.d.ts.map +1 -0
- package/dist/src/memory/text-tokens.js +37 -0
- package/dist/src/memory/text-tokens.js.map +1 -0
- package/dist/src/orgrt/daemon.d.ts +16 -1
- package/dist/src/orgrt/daemon.d.ts.map +1 -1
- package/dist/src/orgrt/daemon.js +65 -9
- package/dist/src/orgrt/daemon.js.map +1 -1
- package/dist/src/orgrt/scheduler.d.ts +14 -1
- package/dist/src/orgrt/scheduler.d.ts.map +1 -1
- package/dist/src/orgrt/scheduler.js +51 -5
- package/dist/src/orgrt/scheduler.js.map +1 -1
- package/dist/src/orgrt/session.js +12 -0
- package/dist/src/orgrt/session.js.map +1 -1
- package/dist/src/orgrt/types.d.ts +3 -0
- package/dist/src/orgrt/types.d.ts.map +1 -1
- package/dist/src/orgrt/types.js +7 -1
- package/dist/src/orgrt/types.js.map +1 -1
- package/dist/src/services/crash-reporter.d.ts.map +1 -1
- package/dist/src/services/crash-reporter.js +7 -0
- package/dist/src/services/crash-reporter.js.map +1 -1
- package/dist/src/ui/dashboard.html +13 -10
- package/dist/src/ui/orgs.html +30 -13
- package/dist/src/ui/routes-monograph.mjs +929 -0
- package/dist/src/ui/routes-org.mjs +2513 -0
- package/dist/src/ui/server.mjs +726 -4117
- package/dist/src/utils/json-file.d.ts +11 -0
- package/dist/src/utils/json-file.d.ts.map +1 -1
- package/dist/src/utils/json-file.js +27 -0
- package/dist/src/utils/json-file.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,2513 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { getMmClientCount } from './sse-manager.mjs';
|
|
6
|
+
|
|
7
|
+
export async function handleOrgRoutes(req, res, url, corsOrigin, ctx) {
|
|
8
|
+
// ------------------------------------------------- Org management
|
|
9
|
+
// GET /api/orgs — list all saved org configs
|
|
10
|
+
if (req.method === 'GET' && url === '/api/orgs') {
|
|
11
|
+
try {
|
|
12
|
+
const _orgsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
13
|
+
const _orgsExplicitDir = _orgsQs.get('dir');
|
|
14
|
+
const _orgsServerRoot = path.resolve(_orgsExplicitDir || ctx.projectDir || process.cwd());
|
|
15
|
+
// Collect project dirs to search: explicit dir + known-projects (like sessions API)
|
|
16
|
+
const _orgsProjDirs = new Set([_orgsServerRoot]);
|
|
17
|
+
if (!_orgsExplicitDir) {
|
|
18
|
+
try {
|
|
19
|
+
const _knownOrgsFile = path.join(_orgsServerRoot, 'data', 'known-projects.json');
|
|
20
|
+
if (fs.existsSync(_knownOrgsFile)) {
|
|
21
|
+
JSON.parse(fs.readFileSync(_knownOrgsFile, 'utf8')).forEach(p => _orgsProjDirs.add(p));
|
|
22
|
+
}
|
|
23
|
+
} catch(_) {}
|
|
24
|
+
}
|
|
25
|
+
const _sidecarSuffixRe = /-(approvals|state|activity|goals|routines|projects|members|issues|workspaces|worktrees|environments|plugins|adapters|bootstrap|threads|budgets|project-workspaces|approval-comments|secrets|join-requests|skills)\.json$/;
|
|
26
|
+
const _orgsSeen = new Set();
|
|
27
|
+
let orgs = [];
|
|
28
|
+
for (const _opd of _orgsProjDirs) {
|
|
29
|
+
const orgsDir = path.join(_opd, '.monomind', 'orgs');
|
|
30
|
+
if (!fs.existsSync(orgsDir)) continue;
|
|
31
|
+
const files = fs.readdirSync(orgsDir).filter(f => f.endsWith('.json') && !_sidecarSuffixRe.test(f));
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
try {
|
|
34
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(orgsDir, f), 'utf8'));
|
|
35
|
+
const _lOrgName = cfg.name || '';
|
|
36
|
+
if (!_lOrgName || _orgsSeen.has(_lOrgName)) continue;
|
|
37
|
+
_orgsSeen.add(_lOrgName);
|
|
38
|
+
const _rs = ctx._readRunState(_lOrgName, _opd);
|
|
39
|
+
const _ttl = Math.max((_rs?.checkpointInterval || 600000) * 2, 7200000);
|
|
40
|
+
let running = (_rs?.status === 'running' && (Date.now() - (_rs?.lastEventAt || 0)) < _ttl)
|
|
41
|
+
|| ctx.activeOrgRuns.has(_lOrgName);
|
|
42
|
+
orgs.push({ name: cfg.name, goal: cfg.goal, roles: Array.isArray(cfg.roles) ? cfg.roles : [], topology: cfg.topology, created_at: cfg.created_at, running, status: cfg.status, projectDir: _opd, lastEventAt: _rs?.lastEventAt || null, loop: cfg.loop ? { poll_interval_minutes: cfg.loop.poll_interval_minutes, last_run: cfg.loop.last_run, next_run: cfg.loop.next_run } : undefined });
|
|
43
|
+
} catch(_) {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
47
|
+
res.end(JSON.stringify(orgs));
|
|
48
|
+
} catch(_) { res.writeHead(500); res.end('[]'); }
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// POST /api/orgs/:name/import — import an org config by name (orgs.html upload flow)
|
|
53
|
+
if (req.method === 'POST' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/import$/i.test(url)) {
|
|
54
|
+
let body = '';
|
|
55
|
+
req.on('data', c => { body += c; if (body.length > 2e6) req.destroy(); });
|
|
56
|
+
req.on('end', () => {
|
|
57
|
+
try {
|
|
58
|
+
const urlParts = url.split('/');
|
|
59
|
+
const orgName = decodeURIComponent(urlParts[3]);
|
|
60
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid org name' })); return; }
|
|
61
|
+
const cfg = JSON.parse(body);
|
|
62
|
+
const _importQs = new URL(req.url, 'http://localhost').searchParams;
|
|
63
|
+
const dir = path.resolve(_importQs.get('dir') || ctx.projectDir || process.cwd());
|
|
64
|
+
const orgsDir = path.join(dir, '.monomind', 'orgs');
|
|
65
|
+
fs.mkdirSync(orgsDir, { recursive: true });
|
|
66
|
+
const destFile = path.join(orgsDir, `${orgName}.json`);
|
|
67
|
+
fs.writeFileSync(destFile, JSON.stringify({ ...cfg, name: orgName }, null, 2), 'utf8');
|
|
68
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
69
|
+
res.end(JSON.stringify({ ok: true, name: orgName, file: destFile }));
|
|
70
|
+
} catch (e) {
|
|
71
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
72
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// POST /api/orgs — import / create org from JSON body
|
|
79
|
+
if (req.method === 'POST' && url === '/api/orgs') {
|
|
80
|
+
let body = '';
|
|
81
|
+
req.on('data', c => { body += c; if (body.length > 2e6) req.destroy(); });
|
|
82
|
+
req.on('end', () => {
|
|
83
|
+
try {
|
|
84
|
+
const cfg = JSON.parse(body);
|
|
85
|
+
const qs = new URL(req.url, 'http://localhost').searchParams;
|
|
86
|
+
const dir = qs.get('dir') || cfg.dir || ctx.projectDir || process.cwd();
|
|
87
|
+
const name = (cfg.name || '').toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
88
|
+
if (!name) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid org name' })); return; }
|
|
89
|
+
const orgsDir = path.join(path.resolve(dir), '.monomind', 'orgs');
|
|
90
|
+
fs.mkdirSync(orgsDir, { recursive: true });
|
|
91
|
+
const destFile = path.join(orgsDir, `${name}.json`);
|
|
92
|
+
const cleanCfg = Object.fromEntries(Object.entries({ ...cfg, name }).filter(([k]) => !k.startsWith('_')));
|
|
93
|
+
fs.writeFileSync(destFile, JSON.stringify(cleanCfg, null, 2), 'utf8');
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
95
|
+
res.end(JSON.stringify({ ok: true, name, file: destFile }));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
98
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// GET /api/orgs/:name — get specific org config (exact path: /api/orgs/<slug>)
|
|
105
|
+
if (req.method === 'GET' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}$/i.test(url)) {
|
|
106
|
+
try {
|
|
107
|
+
const orgName = decodeURIComponent(url.slice('/api/orgs/'.length));
|
|
108
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
109
|
+
const _orgsOneQs = new URL(req.url, 'http://localhost').searchParams;
|
|
110
|
+
const _orgsOneRoot = path.resolve(_orgsOneQs.get('dir') || ctx.projectDir || process.cwd());
|
|
111
|
+
const _orgsOneProjDir = ctx._resolveOrgProjectDir(orgName, _orgsOneRoot) || _orgsOneRoot;
|
|
112
|
+
const f = path.join(_orgsOneProjDir, '.monomind', 'orgs', `${orgName}.json`);
|
|
113
|
+
if (!fs.existsSync(f)) { res.writeHead(404); res.end('{"error":"not found"}'); return true; }
|
|
114
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
115
|
+
res.end(fs.readFileSync(f, 'utf8'));
|
|
116
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// GET /api/org/:name — ORG ROOM: rich org data (config + state + tasks + routines + goals)
|
|
121
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}$/i.test(url)) {
|
|
122
|
+
try {
|
|
123
|
+
const orgName = decodeURIComponent(url.slice('/api/org/'.length));
|
|
124
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
125
|
+
const _orgQs = new URL(req.url, 'http://localhost').searchParams;
|
|
126
|
+
const _orgServerRoot = path.resolve(_orgQs.get('dir') || ctx.projectDir || process.cwd());
|
|
127
|
+
// Resolve which project dir actually has this org's config
|
|
128
|
+
const d = ctx._resolveOrgProjectDir(orgName, _orgServerRoot) || _orgServerRoot;
|
|
129
|
+
const orgsDir = path.join(d, '.monomind', 'orgs');
|
|
130
|
+
|
|
131
|
+
const readJsonSafe = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
132
|
+
|
|
133
|
+
const configFile = path.join(orgsDir, `${orgName}.json`);
|
|
134
|
+
if (!fs.existsSync(configFile)) { res.writeHead(404); res.end('{"error":"org not found"}'); return true; }
|
|
135
|
+
const config = readJsonSafe(configFile);
|
|
136
|
+
|
|
137
|
+
const state = readJsonSafe(path.join(orgsDir, `${orgName}-state.json`)) || { agents: {} };
|
|
138
|
+
const goalsData = readJsonSafe(path.join(orgsDir, `${orgName}-goals.json`)) || { goals: [] };
|
|
139
|
+
const routinesData = readJsonSafe(path.join(orgsDir, `${orgName}-routines.json`)) || { routines: [] };
|
|
140
|
+
const approvalsData = readJsonSafe(path.join(orgsDir, `${orgName}-approvals.json`)) || { approvals: [] };
|
|
141
|
+
|
|
142
|
+
// Check running status: stop file absence AND (in-memory ctx.activeOrgRuns OR state-file agents OR active loop file)
|
|
143
|
+
const stopFile = path.join(orgsDir, '.stops', `${orgName}.stop`);
|
|
144
|
+
const _loopsDir = path.join(d, '.monomind', 'loops');
|
|
145
|
+
const _loopRunning = (() => {
|
|
146
|
+
try {
|
|
147
|
+
if (!fs.existsSync(_loopsDir)) return false;
|
|
148
|
+
// Get the org's state file mtime to correlate with loop activity
|
|
149
|
+
const orgStateMtime = (() => {
|
|
150
|
+
try { return fs.statSync(path.join(orgsDir, `${orgName}-state.json`)).mtimeMs; } catch { return 0; }
|
|
151
|
+
})();
|
|
152
|
+
// Also check org's most recent run file mtime
|
|
153
|
+
const orgRunsDir = path.join(ctx._getGitMonomindDir(d) || path.join(d, '.monomind'), 'orgs', orgName, 'runs');
|
|
154
|
+
const orgLastRunMtime = (() => {
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(orgRunsDir)) return 0;
|
|
157
|
+
const runFiles = fs.readdirSync(orgRunsDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('._'));
|
|
158
|
+
if (!runFiles.length) return 0;
|
|
159
|
+
return Math.max(...runFiles.map(f => { try { return fs.statSync(path.join(orgRunsDir, f)).mtimeMs; } catch { return 0; } }));
|
|
160
|
+
} catch { return 0; }
|
|
161
|
+
})();
|
|
162
|
+
const orgLastActivity = Math.max(orgStateMtime, orgLastRunMtime);
|
|
163
|
+
return fs.readdirSync(_loopsDir).some(f => {
|
|
164
|
+
if (!f.endsWith('.json') || f.endsWith('.stop')) return false;
|
|
165
|
+
try {
|
|
166
|
+
const lp = JSON.parse(fs.readFileSync(path.join(_loopsDir, f), 'utf8'));
|
|
167
|
+
if (!lp.command || !lp.command.includes('runorg')) return false;
|
|
168
|
+
if (!['running', 'paused'].includes(lp.status)) return false;
|
|
169
|
+
// Primary match: explicit orgName field (written by runorg command since v1.14.2)
|
|
170
|
+
if (lp.orgName === orgName) return true;
|
|
171
|
+
// Fallback: org name in prompt (early loop files that preserved --org flag)
|
|
172
|
+
if ((lp.prompt || '').includes(orgName)) return true;
|
|
173
|
+
// Heuristic: if loop's lastRunAt is within 3x wait interval of org's last activity
|
|
174
|
+
const waitMs = (lp.wait || 60) * 3 * 1000;
|
|
175
|
+
return orgLastActivity > 0 && Math.abs(orgLastActivity - (lp.lastRunAt || 0)) < waitMs;
|
|
176
|
+
} catch { return false; }
|
|
177
|
+
});
|
|
178
|
+
} catch { return false; }
|
|
179
|
+
})();
|
|
180
|
+
const _runstateData = ctx._readRunState(orgName, d);
|
|
181
|
+
const _runstateTtl = Math.max((_runstateData?.checkpointInterval || 600000) * 2, 7200000);
|
|
182
|
+
const _runstateAlive = _runstateData?.status === 'running' && (Date.now() - (_runstateData?.lastEventAt || 0)) < _runstateTtl;
|
|
183
|
+
const running = !fs.existsSync(stopFile) && (_runstateAlive || ctx.activeOrgRuns.has(orgName) || _loopRunning);
|
|
184
|
+
|
|
185
|
+
// Read real tasks from the task store and group by status column
|
|
186
|
+
const taskStoreData = readJsonSafe(path.join(d, '.monomind', 'tasks', 'store.json'));
|
|
187
|
+
const allTasks = taskStoreData ? Object.values(taskStoreData.tasks || {}) : [];
|
|
188
|
+
const tasks = {
|
|
189
|
+
todo: allTasks.filter(t => t.status === 'pending').map(t => ({ id: t.taskId, description: t.description, status: 'todo', ts: t.createdAt })),
|
|
190
|
+
doing: allTasks.filter(t => t.status === 'in_progress').map(t => ({ id: t.taskId, description: t.description, status: 'doing', ts: t.startedAt || t.createdAt })),
|
|
191
|
+
done: allTasks.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').map(t => ({ id: t.taskId, description: t.description, status: t.status, ts: t.completedAt || t.createdAt })),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const result = { config, state, goals: goalsData.goals, routines: routinesData.routines,
|
|
195
|
+
approvals: approvalsData.approvals, running, tasks,
|
|
196
|
+
runId: _runstateData?.runId || null,
|
|
197
|
+
lastEventAt: _runstateData?.lastEventAt || null,
|
|
198
|
+
agentStates: _runstateData?.agentStates || {} };
|
|
199
|
+
|
|
200
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
201
|
+
res.end(JSON.stringify(result));
|
|
202
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// GET /api/org/:name/activity — recent org events from mastermind-events.jsonl
|
|
207
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/activity$/i.test(url)) {
|
|
208
|
+
try {
|
|
209
|
+
const parts = url.split('/');
|
|
210
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
211
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('[]'); return true; }
|
|
212
|
+
const _actQs = new URL(req.url, 'http://localhost').searchParams;
|
|
213
|
+
const _actServerRoot = path.resolve(_actQs.get('dir') || ctx.projectDir || process.cwd());
|
|
214
|
+
const d = ctx._resolveOrgProjectDir(orgName, _actServerRoot) || _actServerRoot;
|
|
215
|
+
const orgsDir = path.join(d, '.monomind', 'orgs');
|
|
216
|
+
const readJ = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
217
|
+
const events = [];
|
|
218
|
+
|
|
219
|
+
// 1) Global mastermind events that EXPLICITLY belong to this org (strict — no untagged leak)
|
|
220
|
+
const eventsFile = path.join(d, 'data', 'mastermind-events.jsonl');
|
|
221
|
+
if (fs.existsSync(eventsFile)) {
|
|
222
|
+
const lines = fs.readFileSync(eventsFile, 'utf8').split('\n').filter(Boolean);
|
|
223
|
+
for (const l of lines.slice(-1000)) {
|
|
224
|
+
try { const e = JSON.parse(l); if (e && e.org === orgName) events.push(e); } catch(_) {}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 2) Synthesize an org-scoped timeline from this org's own records (real data, distinct per org)
|
|
229
|
+
const cfg = readJ(path.join(orgsDir, `${orgName}.json`));
|
|
230
|
+
if (cfg) {
|
|
231
|
+
const createdMs = cfg.created_at ? Date.parse(cfg.created_at) : null;
|
|
232
|
+
if (createdMs) events.push({ type: 'org:create', ts: createdMs, msg: String(cfg.goal || 'Org created').slice(0, 80) });
|
|
233
|
+
(cfg.roles || []).forEach((r, i) => {
|
|
234
|
+
events.push({ type: 'role:defined', ts: createdMs ? createdMs + (i + 1) * 1000 : null, role: r.title || r.id, msg: r.agent_type || '' });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const goals = readJ(path.join(orgsDir, `${orgName}-goals.json`));
|
|
238
|
+
(goals?.goals || []).forEach(g => events.push({ type: 'goal', ts: Date.parse(g.created_at || g.updated_at || '') || null, role: g.status || '', msg: String(g.text || g.title || g.goal || '').slice(0, 80) }));
|
|
239
|
+
const appr = readJ(path.join(orgsDir, `${orgName}-approvals.json`));
|
|
240
|
+
(appr?.approvals || []).forEach(a => { const ts = (typeof a.ts === 'number') ? a.ts : (Date.parse(a.created_at || a.ts || '') || null); events.push({ type: 'approval', ts, role: a.agent_id || a.requester || '', msg: String(a.title || a.action || '').slice(0, 80) }); });
|
|
241
|
+
const state = readJ(path.join(orgsDir, `${orgName}-state.json`));
|
|
242
|
+
if (state && state.agents) {
|
|
243
|
+
for (const [aid, a] of Object.entries(state.agents)) {
|
|
244
|
+
const raw = a.lastHeartbeat || a.last_seen || a.updated_at || null;
|
|
245
|
+
const ts = (typeof raw === 'number') ? raw : (raw ? Date.parse(raw) : null);
|
|
246
|
+
events.push({ type: 'org:heartbeat', ts, agent: aid, msg: a.status || '' });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const out = events.filter(e => e && e.ts).sort((a, b) => b.ts - a.ts).slice(0, 100);
|
|
251
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
252
|
+
res.end(JSON.stringify(out));
|
|
253
|
+
} catch(_) { res.writeHead(500); res.end('[]'); }
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// GET /api/org/:name/projects — org projects from projects json file
|
|
258
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/projects$/i.test(url)) {
|
|
259
|
+
try {
|
|
260
|
+
const parts = url.split('/');
|
|
261
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
262
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('[]'); return true; }
|
|
263
|
+
const _projsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
264
|
+
const d = path.resolve(_projsQs.get('dir') || ctx.projectDir || process.cwd());
|
|
265
|
+
const projFile = path.join(d, '.monomind', 'orgs', `${orgName}-projects.json`);
|
|
266
|
+
if (!fs.existsSync(projFile)) { res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) }); res.end('[]'); return true; }
|
|
267
|
+
const data = JSON.parse(fs.readFileSync(projFile, 'utf8'));
|
|
268
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
269
|
+
res.end(JSON.stringify(data.projects || []));
|
|
270
|
+
} catch(_) { res.writeHead(500); res.end('[]'); }
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// GET /api/org/:name/members — org member list and join requests
|
|
275
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/members$/i.test(url)) {
|
|
276
|
+
try {
|
|
277
|
+
const parts = url.split('/');
|
|
278
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
279
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return true; }
|
|
280
|
+
const _membersQs = new URL(req.url, 'http://localhost').searchParams;
|
|
281
|
+
const d = path.resolve(_membersQs.get('dir') || ctx.projectDir || process.cwd());
|
|
282
|
+
const membersFile = path.join(d, '.monomind', 'orgs', `${orgName}-members.json`);
|
|
283
|
+
if (!fs.existsSync(membersFile)) {
|
|
284
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
285
|
+
res.end('{"members":[],"join_requests":[]}');
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
const data = JSON.parse(fs.readFileSync(membersFile, 'utf8'));
|
|
289
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
290
|
+
res.end(JSON.stringify(data));
|
|
291
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// GET /api/org/:name/adapters — org adapter registry
|
|
296
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/adapters$/i.test(url)) {
|
|
297
|
+
try {
|
|
298
|
+
const parts = url.split('/');
|
|
299
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
300
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return true; }
|
|
301
|
+
const _adaptersQs = new URL(req.url, 'http://localhost').searchParams;
|
|
302
|
+
const _adaptersRoot = path.resolve(_adaptersQs.get('dir') || ctx.projectDir || process.cwd());
|
|
303
|
+
const d = ctx._resolveOrgProjectDir(orgName, _adaptersRoot) || _adaptersRoot;
|
|
304
|
+
const adaptersFile = path.join(d, '.monomind', 'orgs', `${orgName}-adapters.json`);
|
|
305
|
+
if (!fs.existsSync(adaptersFile)) {
|
|
306
|
+
// Return defaults derived from org config if available
|
|
307
|
+
const orgFile = path.join(d, '.monomind', 'orgs', `${orgName}.json`);
|
|
308
|
+
let defaultAdapter = 'claude-sonnet-4-6';
|
|
309
|
+
try { defaultAdapter = JSON.parse(fs.readFileSync(orgFile, 'utf8'))?.run_config?.ceo_adapter || defaultAdapter; } catch(_) {}
|
|
310
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
311
|
+
res.end(JSON.stringify({ default_adapter: defaultAdapter, adapters: [
|
|
312
|
+
{ type: 'claude-local', label: 'Claude (local CLI)', source: 'built-in', disabled: false, modelsCount: 3 },
|
|
313
|
+
{ type: 'gemini-local', label: 'Gemini (local)', source: 'built-in', disabled: false, modelsCount: 1 },
|
|
314
|
+
{ type: 'http', label: 'HTTP Adapter', source: 'built-in', disabled: true, modelsCount: 0 },
|
|
315
|
+
]}));
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const data = JSON.parse(fs.readFileSync(adaptersFile, 'utf8'));
|
|
319
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
320
|
+
res.end(JSON.stringify(data));
|
|
321
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// GET /api/org/:name/skills — list skills from .claude/skills/ mapped to org roles
|
|
326
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/skills$/i.test(url)) {
|
|
327
|
+
try {
|
|
328
|
+
const parts = url.split('/');
|
|
329
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
330
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return true; }
|
|
331
|
+
const _skillsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
332
|
+
const d = path.resolve(_skillsQs.get('dir') || ctx.projectDir || process.cwd());
|
|
333
|
+
const skillsDir = path.join(d, '.claude', 'skills');
|
|
334
|
+
const orgFile = path.join(d, '.monomind', 'orgs', `${orgName}.json`);
|
|
335
|
+
|
|
336
|
+
// Scan skills directory
|
|
337
|
+
const skills = [];
|
|
338
|
+
if (fs.existsSync(skillsDir)) {
|
|
339
|
+
const scanDir = (dir, prefix) => {
|
|
340
|
+
try {
|
|
341
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
342
|
+
if (entry.isDirectory()) { scanDir(path.join(dir, entry.name), `${entry.name}:`); }
|
|
343
|
+
else if (entry.name.endsWith('.md') && !entry.name.startsWith('_')) {
|
|
344
|
+
const slug = entry.name.replace(/\.md$/, '');
|
|
345
|
+
const content = fs.readFileSync(path.join(dir, entry.name), 'utf8').slice(0, 500);
|
|
346
|
+
const typeMatch = content.match(/^type:\s*(.+)$/m);
|
|
347
|
+
const modeMatch = content.match(/^default_mode:\s*(.+)$/m);
|
|
348
|
+
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
349
|
+
skills.push({
|
|
350
|
+
name: `${prefix}${slug}`,
|
|
351
|
+
slug,
|
|
352
|
+
type: typeMatch ? typeMatch[1].trim() : 'skill',
|
|
353
|
+
default_mode: modeMatch ? modeMatch[1].trim() : 'auto',
|
|
354
|
+
description: descMatch ? descMatch[1].trim() : '',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} catch(_) {}
|
|
359
|
+
};
|
|
360
|
+
scanDir(skillsDir, '');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Map skills enabled per role from org config
|
|
364
|
+
let roleSkillMap = {};
|
|
365
|
+
if (fs.existsSync(orgFile)) {
|
|
366
|
+
try {
|
|
367
|
+
const config = JSON.parse(fs.readFileSync(orgFile, 'utf8'));
|
|
368
|
+
for (const role of (config.roles || [])) {
|
|
369
|
+
roleSkillMap[role.id] = role.skills || [];
|
|
370
|
+
}
|
|
371
|
+
} catch(_) {}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
375
|
+
res.end(JSON.stringify({ skills, role_skill_map: roleSkillMap }));
|
|
376
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// GET /api/org/:name/agent/:roleId — full agent detail: org role + .claude/agents definition
|
|
381
|
+
// (characteristics, skills/expertise, responsibilities, instructions document)
|
|
382
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/agent\/[a-z0-9][a-z0-9_-]{0,63}$/i.test(url)) {
|
|
383
|
+
try {
|
|
384
|
+
const parts = url.split('/');
|
|
385
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
386
|
+
const roleId = decodeURIComponent(parts[5]);
|
|
387
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return true; }
|
|
388
|
+
if (roleId.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(roleId)) { res.writeHead(400); res.end('{}'); return true; }
|
|
389
|
+
const _agentQs = new URL(req.url, 'http://localhost').searchParams;
|
|
390
|
+
const d = path.resolve(_agentQs.get('dir') || ctx.projectDir || process.cwd());
|
|
391
|
+
const orgFile = path.join(d, '.monomind', 'orgs', `${orgName}.json`);
|
|
392
|
+
if (!fs.existsSync(orgFile)) { res.writeHead(404); res.end('{}'); return true; }
|
|
393
|
+
const config = JSON.parse(fs.readFileSync(orgFile, 'utf8'));
|
|
394
|
+
const role = (config.roles || []).find(r => r.id === roleId);
|
|
395
|
+
if (!role) { res.writeHead(404); res.end('{}'); return true; }
|
|
396
|
+
|
|
397
|
+
const agentType = String(role.agent_type || role.type || '').toLowerCase();
|
|
398
|
+
const wanted = [agentType, String(role.id).toLowerCase()].filter(Boolean);
|
|
399
|
+
|
|
400
|
+
// Find a matching agent definition under .claude/agents (recursive); match frontmatter name then filename.
|
|
401
|
+
const agentsDir = path.join(d, '.claude', 'agents');
|
|
402
|
+
let definition = { found: false };
|
|
403
|
+
if (wanted.length && fs.existsSync(agentsDir)) {
|
|
404
|
+
const stack = [agentsDir];
|
|
405
|
+
let nameMatch = null, slugMatch = null;
|
|
406
|
+
while (stack.length && !nameMatch) {
|
|
407
|
+
const dir = stack.pop();
|
|
408
|
+
let entries = [];
|
|
409
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
|
|
410
|
+
for (const e of entries) {
|
|
411
|
+
const full = path.join(dir, e.name);
|
|
412
|
+
if (e.isDirectory()) { stack.push(full); continue; }
|
|
413
|
+
if (!e.name.endsWith('.md') || e.name.startsWith('_')) continue;
|
|
414
|
+
const slug = e.name.replace(/\.md$/, '').toLowerCase();
|
|
415
|
+
let raw = '';
|
|
416
|
+
try { raw = fs.readFileSync(full, 'utf8'); } catch (_) { continue; }
|
|
417
|
+
const fmName = ((raw.match(/^name:\s*(.+)$/m) || [])[1] || '').trim().toLowerCase();
|
|
418
|
+
if (fmName && wanted.includes(fmName)) { nameMatch = { full, raw }; break; }
|
|
419
|
+
if (!slugMatch && wanted.includes(slug)) slugMatch = { full, raw };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const match = nameMatch || slugMatch;
|
|
423
|
+
if (match) {
|
|
424
|
+
definition = ctx.parseAgentDef(match.raw);
|
|
425
|
+
definition.found = true;
|
|
426
|
+
definition.file = path.relative(d, match.full);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
431
|
+
res.end(JSON.stringify({ role, definition }));
|
|
432
|
+
} catch (_) { res.writeHead(500); res.end('{}'); }
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// POST /api/org/:name/agent/:roleId/avatar — set (or clear) a custom avatar for one
|
|
437
|
+
// role, stored inline as a data URL on the role itself so it travels with the org
|
|
438
|
+
// config export/import. Body: { avatarDataUrl } — null/omitted avatarDataUrl clears
|
|
439
|
+
// the custom avatar and reverts to the built-in library picture. `dir` (like every
|
|
440
|
+
// other dir-accepting route in this file, POST included — see /goals above) comes
|
|
441
|
+
// from the query string, not the body.
|
|
442
|
+
if (req.method === 'POST' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/agent\/[a-z0-9][a-z0-9_-]{0,63}\/avatar$/i.test(url)) {
|
|
443
|
+
let body = '';
|
|
444
|
+
for await (const chunk of req) { body += chunk; if (body.length > 8388608) { req.destroy(); break; } }
|
|
445
|
+
try {
|
|
446
|
+
const parts = url.split('/');
|
|
447
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
448
|
+
const roleId = decodeURIComponent(parts[5]);
|
|
449
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{"ok":false,"error":"Invalid org name"}'); return true; }
|
|
450
|
+
if (roleId.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(roleId)) { res.writeHead(400); res.end('{"ok":false,"error":"Invalid role id"}'); return true; }
|
|
451
|
+
const parsed = JSON.parse(body);
|
|
452
|
+
const avatarDataUrl = parsed.avatarDataUrl;
|
|
453
|
+
if (avatarDataUrl != null && (typeof avatarDataUrl !== 'string' || avatarDataUrl.length > 2_000_000 || !/^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(avatarDataUrl))) {
|
|
454
|
+
res.writeHead(400); res.end('{"ok":false,"error":"Invalid or oversized avatarDataUrl"}'); return true;
|
|
455
|
+
}
|
|
456
|
+
const _avatarQs = new URL(req.url, 'http://localhost').searchParams;
|
|
457
|
+
const d = path.resolve(_avatarQs.get('dir') || ctx.projectDir || process.cwd());
|
|
458
|
+
const orgFile = path.join(d, '.monomind', 'orgs', `${orgName}.json`);
|
|
459
|
+
if (!fs.existsSync(orgFile)) { res.writeHead(404); res.end('{"ok":false,"error":"org not found"}'); return true; }
|
|
460
|
+
const config = JSON.parse(fs.readFileSync(orgFile, 'utf8'));
|
|
461
|
+
const role = (config.roles || []).find(r => r.id === roleId);
|
|
462
|
+
if (!role) { res.writeHead(404); res.end('{"ok":false,"error":"role not found"}'); return true; }
|
|
463
|
+
if (avatarDataUrl) role.avatar = avatarDataUrl; else delete role.avatar;
|
|
464
|
+
const tmp = `${orgFile}.tmp`;
|
|
465
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf-8');
|
|
466
|
+
fs.renameSync(tmp, orgFile);
|
|
467
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
468
|
+
res.end(JSON.stringify({ ok: true }));
|
|
469
|
+
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ ok: false, error: String(e.message || e) })); }
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// GET /api/org/:name/search?q=<query> — fuzzy search across org data
|
|
474
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/search(\?.*)?$/i.test(url)) {
|
|
475
|
+
try {
|
|
476
|
+
const urlObj = new URL(`http://x${req.url}`);
|
|
477
|
+
const orgName = decodeURIComponent(urlObj.pathname.split('/')[3]);
|
|
478
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{}'); return true; }
|
|
479
|
+
const q = (urlObj.searchParams.get('q') || '').toLowerCase().trim();
|
|
480
|
+
if (!q || q.length < 2) { res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) }); res.end('{"hits":[]}'); return true; }
|
|
481
|
+
|
|
482
|
+
const d = path.resolve(urlObj.searchParams.get('dir') || ctx.projectDir || process.cwd());
|
|
483
|
+
const orgsDir = path.join(d, '.monomind', 'orgs');
|
|
484
|
+
const readJ = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
485
|
+
|
|
486
|
+
const hits = [];
|
|
487
|
+
const match = (str) => str && str.toLowerCase().includes(q);
|
|
488
|
+
|
|
489
|
+
// Agents
|
|
490
|
+
const config = readJ(path.join(orgsDir, `${orgName}.json`));
|
|
491
|
+
for (const role of (config?.roles || [])) {
|
|
492
|
+
if (match(role.id) || match(role.title) || (role.responsibilities || []).some(r => match(r))) {
|
|
493
|
+
hits.push({ type: 'agent', id: role.id, title: role.title, meta: role.agent_type });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Goals
|
|
498
|
+
const goals = readJ(path.join(orgsDir, `${orgName}-goals.json`));
|
|
499
|
+
for (const g of (goals?.goals || [])) {
|
|
500
|
+
if (match(g.title) || match(g.text) || match(g.goal) || match(g.description)) {
|
|
501
|
+
hits.push({ type: 'goal', id: g.id, title: g.title || g.text || g.goal, meta: g.status || 'open' });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Routines
|
|
506
|
+
const routines = readJ(path.join(orgsDir, `${orgName}-routines.json`));
|
|
507
|
+
for (const r of (routines?.routines || [])) {
|
|
508
|
+
if (match(r.name) || match(r.description)) {
|
|
509
|
+
hits.push({ type: 'routine', id: r.name, title: r.name, meta: r.schedule || '' });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Approvals
|
|
514
|
+
const approvals = readJ(path.join(orgsDir, `${orgName}-approvals.json`));
|
|
515
|
+
for (const a of (approvals?.approvals || [])) {
|
|
516
|
+
if (match(a.title) || match(a.action) || match(a.agent_id)) {
|
|
517
|
+
hits.push({ type: 'approval', id: a.id, title: a.title, meta: a.status });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Projects
|
|
522
|
+
const projects = readJ(path.join(orgsDir, `${orgName}-projects.json`));
|
|
523
|
+
for (const p of (projects?.projects || [])) {
|
|
524
|
+
if (match(p.name) || match(p.description)) {
|
|
525
|
+
hits.push({ type: 'project', id: p.id || p.name, title: p.name, meta: p.status || 'active' });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Issues
|
|
530
|
+
const issuesData = readJ(path.join(orgsDir, `${orgName}-issues.json`));
|
|
531
|
+
for (const i of (issuesData?.issues || [])) {
|
|
532
|
+
if (match(i.title) || match(i.description) || match(i.slug)) {
|
|
533
|
+
hits.push({ type: 'issue', id: i.id || i.slug, title: i.title || i.slug, meta: i.status || 'open' });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Recent activity events
|
|
538
|
+
const eventsFile = path.join(d, 'data', 'mastermind-events.jsonl');
|
|
539
|
+
if (fs.existsSync(eventsFile)) {
|
|
540
|
+
const lines = fs.readFileSync(eventsFile, 'utf8').split('\n').filter(Boolean).slice(-500);
|
|
541
|
+
for (const l of lines) {
|
|
542
|
+
try {
|
|
543
|
+
const e = JSON.parse(l);
|
|
544
|
+
if (e.org === orgName && match(JSON.stringify(e))) {
|
|
545
|
+
hits.push({ type: 'event', id: String(e.ts), title: e.type, meta: e.role || e.task || '' });
|
|
546
|
+
if (hits.length >= 50) break;
|
|
547
|
+
}
|
|
548
|
+
} catch(_) {}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
553
|
+
res.end(JSON.stringify({ q, hits: hits.slice(0, 50) }));
|
|
554
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// GET /api/org/:name/issues — org task/issue list from issues file
|
|
559
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/issues$/i)) {
|
|
560
|
+
try {
|
|
561
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
562
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
563
|
+
const _issuesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
564
|
+
const _issuesDir = path.resolve(_issuesQs.get('dir') || ctx.projectDir || process.cwd());
|
|
565
|
+
const issuesPath = path.join(_issuesDir, '.monomind', 'orgs', `${orgName}-issues.json`);
|
|
566
|
+
let payload = { issues: [] };
|
|
567
|
+
try {
|
|
568
|
+
const raw = JSON.parse(fs.readFileSync(issuesPath, 'utf8'));
|
|
569
|
+
payload.issues = (raw.issues || []).map(i => ({
|
|
570
|
+
id: i.id, slug: i.slug, title: i.title, description: i.description || null,
|
|
571
|
+
status: i.status || 'open',
|
|
572
|
+
priority: i.priority || 'medium', assignee_id: i.assignee_id || null,
|
|
573
|
+
assignee: i.assignee || i.assignee_id || null,
|
|
574
|
+
project_id: i.project_id || null, parent_id: i.parent_id || null,
|
|
575
|
+
created_at: i.created_at, updated_at: i.updated_at
|
|
576
|
+
}));
|
|
577
|
+
} catch(_) { /* file missing is fine */ }
|
|
578
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
579
|
+
res.end(JSON.stringify(payload));
|
|
580
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// GET /api/org/:name/health — aggregate org health metrics
|
|
585
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/health$/i)) {
|
|
586
|
+
try {
|
|
587
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
588
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
589
|
+
const _healthQs = new URL(req.url, 'http://localhost').searchParams;
|
|
590
|
+
const base = path.join(path.resolve(_healthQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
591
|
+
|
|
592
|
+
let agentsRunning = 0, agentsIdle = 0, openIssues = 0, inProgressIssues = 0;
|
|
593
|
+
let budgetUsedTokens = 0, budgetMaxTokens = 0;
|
|
594
|
+
let successRuns = 0, totalRuns = 0;
|
|
595
|
+
|
|
596
|
+
// State: agent statuses
|
|
597
|
+
try {
|
|
598
|
+
const state = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-state.json`), 'utf8'));
|
|
599
|
+
const agents = state.agents || {};
|
|
600
|
+
Object.values(agents).forEach(a => {
|
|
601
|
+
if (a.status === 'running') agentsRunning++;
|
|
602
|
+
else agentsIdle++;
|
|
603
|
+
budgetUsedTokens += (a.tokens_used || ((a.tokens_in || 0) + (a.tokens_out || 0)));
|
|
604
|
+
});
|
|
605
|
+
} catch(_) {}
|
|
606
|
+
|
|
607
|
+
// Budget cap from org config
|
|
608
|
+
try {
|
|
609
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(base, `${orgName}.json`), 'utf8'));
|
|
610
|
+
budgetMaxTokens = cfg.run_config?.budget_tokens || cfg.budget_tokens || 0;
|
|
611
|
+
} catch(_) {}
|
|
612
|
+
|
|
613
|
+
// Issues: open count
|
|
614
|
+
try {
|
|
615
|
+
const iss = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-issues.json`), 'utf8'));
|
|
616
|
+
openIssues = (iss.issues || []).filter(i => i.status === 'open').length;
|
|
617
|
+
inProgressIssues = (iss.issues || []).filter(i => i.status === 'in_progress').length;
|
|
618
|
+
} catch(_) {}
|
|
619
|
+
|
|
620
|
+
// Activity: 7-day success rate
|
|
621
|
+
try {
|
|
622
|
+
const actPath = path.join(base, `${orgName}-activity.jsonl`);
|
|
623
|
+
const lines = fs.readFileSync(actPath, 'utf8').split('\n').filter(Boolean);
|
|
624
|
+
const cutoffMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
625
|
+
lines.forEach(line => {
|
|
626
|
+
try {
|
|
627
|
+
const ev = JSON.parse(line);
|
|
628
|
+
const evMs = typeof ev.ts === 'number' ? ev.ts : (ev.ts ? Date.parse(ev.ts) : 0);
|
|
629
|
+
if (!evMs || evMs < cutoffMs) return;
|
|
630
|
+
totalRuns++;
|
|
631
|
+
if (ev.type && ev.type.includes('complete')) successRuns++;
|
|
632
|
+
} catch(_) {}
|
|
633
|
+
});
|
|
634
|
+
} catch(_) {}
|
|
635
|
+
|
|
636
|
+
const budgetUsedPct = budgetMaxTokens > 0 ? Math.round((budgetUsedTokens / budgetMaxTokens) * 100) : null;
|
|
637
|
+
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : null;
|
|
638
|
+
|
|
639
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
640
|
+
res.end(JSON.stringify({
|
|
641
|
+
agents_running: agentsRunning,
|
|
642
|
+
agents_idle: agentsIdle,
|
|
643
|
+
agents_active: agentsRunning,
|
|
644
|
+
open_issues: openIssues,
|
|
645
|
+
in_progress_issues: inProgressIssues,
|
|
646
|
+
tasks_pending: openIssues + inProgressIssues,
|
|
647
|
+
budget_used_tokens: budgetUsedTokens,
|
|
648
|
+
budget_max_tokens: budgetMaxTokens,
|
|
649
|
+
budget_used_pct: budgetUsedPct,
|
|
650
|
+
run_success_rate_7d: successRate,
|
|
651
|
+
total_runs_7d: totalRuns,
|
|
652
|
+
errors: [],
|
|
653
|
+
}));
|
|
654
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// GET /api/org/:name/environments — org execution environments (strips key material)
|
|
659
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/environments$/i)) {
|
|
660
|
+
try {
|
|
661
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
662
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
663
|
+
const _envsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
664
|
+
const envsPath = path.join(path.resolve(_envsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-environments.json`);
|
|
665
|
+
let payload = { environments: [], default_env: null };
|
|
666
|
+
try {
|
|
667
|
+
const raw = JSON.parse(fs.readFileSync(envsPath, 'utf8'));
|
|
668
|
+
// Strip any accidental key_material or private_key fields — never send to browser
|
|
669
|
+
payload.default_env = raw.default_env || null;
|
|
670
|
+
payload.environments = (raw.environments || []).map(e => {
|
|
671
|
+
const safe = { ...e };
|
|
672
|
+
delete safe.key_material;
|
|
673
|
+
delete safe.private_key;
|
|
674
|
+
delete safe.ssh_key;
|
|
675
|
+
delete safe.password;
|
|
676
|
+
return safe;
|
|
677
|
+
});
|
|
678
|
+
} catch(_) { /* file missing is fine — return empty */ }
|
|
679
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
680
|
+
res.end(JSON.stringify(payload));
|
|
681
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// GET /api/org/:name/workspaces — org workspaces cross-referenced with worktree registry
|
|
686
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/workspaces$/i)) {
|
|
687
|
+
try {
|
|
688
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
689
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
690
|
+
const _wsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
691
|
+
const base = path.join(path.resolve(_wsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
692
|
+
let payload = { workspaces: [] };
|
|
693
|
+
try {
|
|
694
|
+
const wsRaw = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-workspaces.json`), 'utf8'));
|
|
695
|
+
const workspaces = wsRaw.workspaces || [];
|
|
696
|
+
// Optionally cross-reference worktree registry for branch/status enrichment
|
|
697
|
+
let worktreeMap = {};
|
|
698
|
+
try {
|
|
699
|
+
const wtRaw = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-worktrees.json`), 'utf8'));
|
|
700
|
+
(wtRaw.worktrees || []).forEach(wt => { worktreeMap[wt.path] = wt; });
|
|
701
|
+
} catch(_) { /* no worktree registry, that's fine */ }
|
|
702
|
+
payload.workspaces = workspaces.map(w => {
|
|
703
|
+
const wt = w.worktree_path ? worktreeMap[w.worktree_path] : null;
|
|
704
|
+
return wt ? { ...w, branch: w.branch || wt.branch || w.branch } : w;
|
|
705
|
+
});
|
|
706
|
+
} catch(_) { /* file missing is fine */ }
|
|
707
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
708
|
+
res.end(JSON.stringify(payload));
|
|
709
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// GET /api/org/:name/invites — active invites + pending join requests
|
|
714
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/invites(\?.*)?$/i)) {
|
|
715
|
+
try {
|
|
716
|
+
const orgName = decodeURIComponent(url.split('/')[3].split('?')[0]);
|
|
717
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
718
|
+
const _invitesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
719
|
+
const base = path.join(path.resolve(_invitesQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
720
|
+
let payload = { invites: [], join_requests: [] };
|
|
721
|
+
try {
|
|
722
|
+
const raw = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-members.json`), 'utf8'));
|
|
723
|
+
const all = raw.join_requests || [];
|
|
724
|
+
payload.invites = all.filter(r => r.type === 'invite' && r.status === 'pending')
|
|
725
|
+
.map(r => ({ id: r.id, token: r.token ? r.token.slice(0, 8) + '…' : r.id, role: r.role || 'operator', createdAt: r.createdAt || null, expiresAt: r.expiresAt || null, status: r.status }));
|
|
726
|
+
payload.join_requests = all.filter(r => r.type !== 'invite' && r.status === 'pending_approval')
|
|
727
|
+
.map(r => ({ id: r.id, requestType: r.requestType || 'human', role: r.role || 'viewer', createdAt: r.createdAt || null, message: r.message || '' }));
|
|
728
|
+
} catch(_) { /* members file missing */ }
|
|
729
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
730
|
+
res.end(JSON.stringify(payload));
|
|
731
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// GET /api/org/:name/plugins — plugins from registry filtered/merged with org overrides
|
|
736
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/plugins$/i)) {
|
|
737
|
+
try {
|
|
738
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
739
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
740
|
+
const _pluginsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
741
|
+
const base = path.join(path.resolve(_pluginsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind');
|
|
742
|
+
let plugins = [];
|
|
743
|
+
try {
|
|
744
|
+
const reg = JSON.parse(fs.readFileSync(path.join(base, 'plugins', 'registry.json'), 'utf8'));
|
|
745
|
+
plugins = reg.plugins || [];
|
|
746
|
+
// Strip sensitive config fields from output
|
|
747
|
+
plugins = plugins.map(p => {
|
|
748
|
+
const safe = { ...p };
|
|
749
|
+
if (safe.config) {
|
|
750
|
+
safe.config = Object.fromEntries(
|
|
751
|
+
Object.entries(safe.config).map(([k, v]) =>
|
|
752
|
+
(/key|token|secret|password|api/i.test(k) ? [k, '***'] : [k, v])
|
|
753
|
+
)
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
return safe;
|
|
757
|
+
});
|
|
758
|
+
} catch(_) { /* no global registry */ }
|
|
759
|
+
// Merge org-level overrides
|
|
760
|
+
try {
|
|
761
|
+
const orgPlugins = JSON.parse(fs.readFileSync(path.join(base, 'orgs', `${orgName}-plugins.json`), 'utf8'));
|
|
762
|
+
const overrideMap = {};
|
|
763
|
+
(orgPlugins.plugins || []).forEach(p => { overrideMap[p.id] = p; });
|
|
764
|
+
if (Object.keys(overrideMap).length) {
|
|
765
|
+
plugins = plugins.map(p => overrideMap[p.id] ? { ...p, ...overrideMap[p.id], _orgOverride: true } : p);
|
|
766
|
+
}
|
|
767
|
+
} catch(_) { /* no org-level overrides */ }
|
|
768
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
769
|
+
res.end(JSON.stringify({ plugins }));
|
|
770
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// GET /api/org/:name/my-issues — open + in_progress issues (self-assignable queue)
|
|
775
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/my-issues$/i)) {
|
|
776
|
+
try {
|
|
777
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
778
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
779
|
+
const _myIssuesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
780
|
+
const base = path.join(path.resolve(_myIssuesQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
781
|
+
let payload = { issues: [] };
|
|
782
|
+
try {
|
|
783
|
+
const raw = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-issues.json`), 'utf8'));
|
|
784
|
+
// Return open + in_progress issues — the "my issues" queue for the operator
|
|
785
|
+
payload.issues = (raw.issues || [])
|
|
786
|
+
.filter(i => i.status === 'open' || i.status === 'in_progress')
|
|
787
|
+
.map(i => ({
|
|
788
|
+
id: i.id,
|
|
789
|
+
title: i.title || null,
|
|
790
|
+
description: i.description || null,
|
|
791
|
+
status: i.status || 'open',
|
|
792
|
+
priority: i.priority || 'medium',
|
|
793
|
+
assigneeId: i.assigneeId || i.assigned_to || null,
|
|
794
|
+
projectId: i.projectId || i.project_id || null,
|
|
795
|
+
createdAt: i.createdAt || null,
|
|
796
|
+
lastActivityAt: i.lastActivityAt || null,
|
|
797
|
+
updated_at: i.updated_at || i.lastActivityAt || i.updatedAt || i.ts || null,
|
|
798
|
+
ts: i.ts || i.updated_at || i.lastActivityAt || null,
|
|
799
|
+
}));
|
|
800
|
+
} catch(_) { /* issues file missing */ }
|
|
801
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
802
|
+
res.end(JSON.stringify(payload));
|
|
803
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// GET /api/org/:name/agents — agents from roles + merged heartbeat state
|
|
808
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/agents$/i)) {
|
|
809
|
+
try {
|
|
810
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
811
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
812
|
+
const _agentsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
813
|
+
const d = path.resolve(_agentsQs.get('dir') || ctx.projectDir || process.cwd());
|
|
814
|
+
const base = path.join(d, '.monomind', 'orgs');
|
|
815
|
+
const readJsonSafe = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
816
|
+
const config = readJsonSafe(path.join(base, `${orgName}.json`)) || {};
|
|
817
|
+
const stateData = readJsonSafe(path.join(base, `${orgName}-state.json`)) || {};
|
|
818
|
+
const agentState = stateData.agents || stateData.roles
|
|
819
|
+
? (stateData.agents || Object.fromEntries((stateData.roles||[]).map(r => [r.id, r])))
|
|
820
|
+
: {};
|
|
821
|
+
const roles = config.roles || [];
|
|
822
|
+
const agents = roles.map(r => {
|
|
823
|
+
const s = agentState[r.id] || {};
|
|
824
|
+
return {
|
|
825
|
+
id: r.id,
|
|
826
|
+
title: r.title || r.id,
|
|
827
|
+
adapterType: r.agent_type || r.type || null,
|
|
828
|
+
adapterModel: (r.adapter_config && r.adapter_config.model) || (r.adapter && r.adapter.model) || null,
|
|
829
|
+
governance: r.governance || null,
|
|
830
|
+
reportsTo: r.reports_to || null,
|
|
831
|
+
status: s.status || 'idle',
|
|
832
|
+
lastHeartbeat: s.last_heartbeat || s.lastHeartbeat || null,
|
|
833
|
+
tokensIn: s.tokens_in || 0,
|
|
834
|
+
tokensOut: s.tokens_out || 0,
|
|
835
|
+
skills: r.skills || [],
|
|
836
|
+
};
|
|
837
|
+
});
|
|
838
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
839
|
+
res.end(JSON.stringify({ agents }));
|
|
840
|
+
} catch(_) { res.writeHead(500); res.end('{"agents":[]}'); }
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// GET /api/org/:name/approvals — full approvals list with status filter support
|
|
845
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/approvals(\?.*)?$/i)) {
|
|
846
|
+
try {
|
|
847
|
+
const orgName = decodeURIComponent(url.split('/')[3].split('?')[0]);
|
|
848
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
849
|
+
const _approvalsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
850
|
+
const base = path.join(path.resolve(_approvalsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
851
|
+
const readJsonSafe = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
852
|
+
const data = readJsonSafe(path.join(base, `${orgName}-approvals.json`)) || { approvals: [] };
|
|
853
|
+
const approvals = (data.approvals || [])
|
|
854
|
+
.sort((a, b) => new Date(b.createdAt || b.created_at || b.requested_at || 0) - new Date(a.createdAt || a.created_at || a.requested_at || 0))
|
|
855
|
+
.map(a => ({
|
|
856
|
+
id: a.id,
|
|
857
|
+
title: a.title || a.action || null,
|
|
858
|
+
action: a.action || a.title || null,
|
|
859
|
+
description: a.description || a.action || a.title || null,
|
|
860
|
+
status: a.status || 'pending',
|
|
861
|
+
agentId: a.agentId || a.agent_id || null,
|
|
862
|
+
agentTitle: a.agentTitle || null,
|
|
863
|
+
requester: a.requester || a.agentTitle || a.agent_id || a.agentId || null,
|
|
864
|
+
agent: a.agent || a.agent_id || a.agentId || null,
|
|
865
|
+
payload: a.payload || null,
|
|
866
|
+
risk_level: a.risk_level || 'medium',
|
|
867
|
+
created_at: a.created_at || a.createdAt || a.requested_at || null,
|
|
868
|
+
createdAt: a.createdAt || a.created_at || a.requested_at || null,
|
|
869
|
+
updatedAt: a.updatedAt || null,
|
|
870
|
+
resolvedAt: a.resolvedAt || null,
|
|
871
|
+
resolvedBy: a.resolvedBy || null,
|
|
872
|
+
ts: a.ts || null,
|
|
873
|
+
}));
|
|
874
|
+
const pending = approvals.filter(a => a.status === 'pending' || a.status === 'revision_requested').length;
|
|
875
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
876
|
+
res.end(JSON.stringify({ approvals, pending }));
|
|
877
|
+
} catch(_) { res.writeHead(500); res.end('{"approvals":[],"pending":0}'); }
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// GET /api/questions?dir=<ctx.projectDir> — list ask_human questions (pending and
|
|
882
|
+
// answered) for every org in one project. Mirrors the existing -approvals.json
|
|
883
|
+
// sidecar convention, but reads this feature's .monomind/orgs/<org>/questions.json
|
|
884
|
+
// files (one per org dir). Each entry's `answer`/`answeredAt` fields (present once
|
|
885
|
+
// answered, absent while pending) let the dashboard split them into tabs.
|
|
886
|
+
if (req.method === 'GET' && url === '/api/questions') {
|
|
887
|
+
try {
|
|
888
|
+
const _qDir = new URL(req.url, 'http://localhost').searchParams.get('dir') || ctx.projectDir || process.cwd();
|
|
889
|
+
const base = path.join(path.resolve(_qDir), '.monomind', 'orgs');
|
|
890
|
+
const out = [];
|
|
891
|
+
if (fs.existsSync(base)) {
|
|
892
|
+
for (const orgName of fs.readdirSync(base)) {
|
|
893
|
+
const qFile = path.join(base, orgName, 'questions.json');
|
|
894
|
+
if (!fs.existsSync(qFile)) continue;
|
|
895
|
+
let data = { questions: [] };
|
|
896
|
+
try { data = JSON.parse(fs.readFileSync(qFile, 'utf8')); } catch (_) {}
|
|
897
|
+
for (const q of (data.questions || [])) {
|
|
898
|
+
out.push({ org: orgName, ...q });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
903
|
+
res.end(JSON.stringify({ questions: out }));
|
|
904
|
+
} catch (_e) { res.writeHead(500); res.end('{"questions":[]}'); }
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// POST /api/questions/answer — forward a human's answer to the org's live process
|
|
909
|
+
// (looked up via the same file-based broker registry orgrt's cross-process delivery
|
|
910
|
+
// already uses), or fail with a clear error if no process anywhere hosts it.
|
|
911
|
+
if (req.method === 'POST' && url === '/api/questions/answer') {
|
|
912
|
+
let body = '';
|
|
913
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
914
|
+
try {
|
|
915
|
+
const parsed = JSON.parse(body);
|
|
916
|
+
const { dir, org, role, questionId, answer } = parsed;
|
|
917
|
+
if (!org || !role || !questionId || answer === undefined) {
|
|
918
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
919
|
+
res.end(JSON.stringify({ ok: false, error: 'org, role, questionId, answer are required' }));
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
if (String(org).length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(String(org))) {
|
|
923
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
924
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid org name' }));
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
const brokerDir = path.join(os.homedir(), '.monomind', 'orgrt-broker');
|
|
928
|
+
const entryPath = path.join(brokerDir, `${org}.json`);
|
|
929
|
+
let hostUrl = null;
|
|
930
|
+
try {
|
|
931
|
+
const entry = JSON.parse(fs.readFileSync(entryPath, 'utf8'));
|
|
932
|
+
if (Date.now() - entry.updatedAt < 90000) hostUrl = entry.url;
|
|
933
|
+
} catch (_) {}
|
|
934
|
+
if (!hostUrl) {
|
|
935
|
+
// No live process to forward to — the control server has no daemon instance of
|
|
936
|
+
// its own to call autoWake() on. If the org's definition still exists on disk,
|
|
937
|
+
// queue the answer the same way inbox.ts's queueMessage()/drainInbox() already
|
|
938
|
+
// do for offline cross-org messages, so it's delivered whenever the org next
|
|
939
|
+
// starts (manually or via its own schedule) — matching the offline-delivery goal
|
|
940
|
+
// for the dashboard path, not just the direct-daemon path Task 4 already covers.
|
|
941
|
+
const projDir = path.resolve(dir || ctx.projectDir || process.cwd());
|
|
942
|
+
const orgDefFile = path.join(projDir, '.monomind', 'orgs', `${org}.json`);
|
|
943
|
+
if (!fs.existsSync(orgDefFile)) {
|
|
944
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
945
|
+
res.end(JSON.stringify({ ok: false, error: `org "${org}" not found — no running process and no saved definition` }));
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
const qFile = path.join(projDir, '.monomind', 'orgs', org, 'questions.json');
|
|
949
|
+
let qData = { questions: [] };
|
|
950
|
+
try { qData = JSON.parse(fs.readFileSync(qFile, 'utf8')); } catch (_) {}
|
|
951
|
+
const qIdx = (qData.questions || []).findIndex(q => q.questionId === questionId);
|
|
952
|
+
if (qIdx === -1) {
|
|
953
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
954
|
+
res.end(JSON.stringify({ ok: false, error: `question "${questionId}" not found for org "${org}"` }));
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
if (qData.questions[qIdx].answer !== null && qData.questions[qIdx].answer !== undefined) {
|
|
958
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
959
|
+
res.end(JSON.stringify({ ok: true, alreadyAnswered: true }));
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
const answeredQuestion = qData.questions[qIdx];
|
|
963
|
+
qData.questions[qIdx] = { ...answeredQuestion, answer, answeredAt: Date.now() };
|
|
964
|
+
const qTmp = `${qFile}.tmp`;
|
|
965
|
+
fs.writeFileSync(qTmp, JSON.stringify(qData, null, 2));
|
|
966
|
+
fs.renameSync(qTmp, qFile);
|
|
967
|
+
const inboxDir = path.join(projDir, '.monomind', 'orgs', org);
|
|
968
|
+
fs.mkdirSync(inboxDir, { recursive: true });
|
|
969
|
+
fs.appendFileSync(path.join(inboxDir, 'inbox.jsonl'), JSON.stringify({
|
|
970
|
+
fromQualified: 'human', toRole: role, subject: `answer:${questionId}`, body: `question: ${answeredQuestion.question}\n\nanswer: ${answer}`, ts: Date.now(),
|
|
971
|
+
}) + '\n');
|
|
972
|
+
const queuedEvent = { type: 'org:question-answered', org, role, questionId, ts: Date.now(), queued: true };
|
|
973
|
+
ctx.appendToFile(path.join(projDir, 'data', 'mastermind-events.jsonl'), JSON.stringify(queuedEvent) + '\n').catch(() => {});
|
|
974
|
+
ctx.broadcastMm(queuedEvent);
|
|
975
|
+
// Auto-wake: a queued answer is worthless if nothing ever restarts the org to
|
|
976
|
+
// drain it (unlike the direct-daemon path, this control server holds no OrgDaemon
|
|
977
|
+
// instance to call autoWake() on). `org run` itself no-ops if the org's runtime.json
|
|
978
|
+
// already shows a live pid, so this is safe to fire even if another process is
|
|
979
|
+
// racing to start it.
|
|
980
|
+
try {
|
|
981
|
+
const child = spawn('npx', ['-y', 'monomind@latest', 'org', 'run', org], {
|
|
982
|
+
cwd: projDir, detached: true, stdio: 'ignore',
|
|
983
|
+
});
|
|
984
|
+
child.unref();
|
|
985
|
+
} catch (_) { /* best-effort — the answer is still queued on disk either way */ }
|
|
986
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
987
|
+
res.end(JSON.stringify({ ok: true, queued: true, waking: true }));
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
const fwd = await fetch(`${hostUrl}/api/answer-question`, {
|
|
991
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
992
|
+
body: JSON.stringify({ org, role, questionId, answer }),
|
|
993
|
+
signal: AbortSignal.timeout(5000),
|
|
994
|
+
});
|
|
995
|
+
const fwdData = await fwd.json().catch(() => ({}));
|
|
996
|
+
if (fwd.ok && fwdData.ok) {
|
|
997
|
+
const event = { type: 'org:question-answered', org, role, questionId, ts: Date.now() };
|
|
998
|
+
ctx.appendToFile(path.join(path.resolve(dir || ctx.projectDir || process.cwd()), 'data', 'mastermind-events.jsonl'), JSON.stringify(event) + '\n').catch(() => {});
|
|
999
|
+
ctx.broadcastMm(event);
|
|
1000
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1001
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1002
|
+
} else {
|
|
1003
|
+
res.writeHead(fwd.status || 500, { 'Content-Type': 'application/json' });
|
|
1004
|
+
res.end(JSON.stringify({ ok: false, error: fwdData.error || 'answer delivery failed' }));
|
|
1005
|
+
}
|
|
1006
|
+
} catch (_e) { res.writeHead(500); res.end('{"ok":false}'); }
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// POST /api/org/:name/approvals/:id — approve or reject a pending approval request
|
|
1011
|
+
// Body: { action: "approve" | "reject" | "revision_requested" }
|
|
1012
|
+
if (req.method === 'POST' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/approvals\/[^/]+$/i)) {
|
|
1013
|
+
let body = '';
|
|
1014
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
1015
|
+
try {
|
|
1016
|
+
const parts = url.split('/');
|
|
1017
|
+
const orgName = decodeURIComponent(parts[3]);
|
|
1018
|
+
const approvalId = decodeURIComponent(parts[5]);
|
|
1019
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1020
|
+
if (!approvalId) { res.writeHead(400); res.end('{"error":"approval id required"}'); return true; }
|
|
1021
|
+
const parsed = JSON.parse(body);
|
|
1022
|
+
const action = parsed.action;
|
|
1023
|
+
if (!['approve', 'reject', 'revision_requested'].includes(action)) {
|
|
1024
|
+
res.writeHead(400); res.end('{"error":"action must be approve, reject, or revision_requested"}'); return true;
|
|
1025
|
+
}
|
|
1026
|
+
const _postApprovalsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1027
|
+
const base = path.join(path.resolve(_postApprovalsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
1028
|
+
const approvalsFile = path.join(base, `${orgName}-approvals.json`);
|
|
1029
|
+
let data = { approvals: [] };
|
|
1030
|
+
try { data = JSON.parse(fs.readFileSync(approvalsFile, 'utf8')); } catch(_) {}
|
|
1031
|
+
const idx = (data.approvals || []).findIndex(a => a.id === approvalId);
|
|
1032
|
+
if (idx === -1) { res.writeHead(404); res.end('{"error":"approval not found"}'); return true; }
|
|
1033
|
+
const status = action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'revision_requested';
|
|
1034
|
+
data.approvals[idx] = {
|
|
1035
|
+
...data.approvals[idx],
|
|
1036
|
+
status,
|
|
1037
|
+
resolvedAt: new Date().toISOString(),
|
|
1038
|
+
resolvedBy: 'operator',
|
|
1039
|
+
};
|
|
1040
|
+
const tmp = `${approvalsFile}.tmp`;
|
|
1041
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
1042
|
+
fs.renameSync(tmp, approvalsFile);
|
|
1043
|
+
// Emit org:approval:resolved event so boss agent unblocks
|
|
1044
|
+
const event = { type: 'org:approval:resolved', org: orgName, approval_id: approvalId, status, ts: Date.now() };
|
|
1045
|
+
ctx.appendToFile(path.join(path.resolve(_postApprovalsQs.get('dir') || ctx.projectDir || process.cwd()), 'data', 'mastermind-events.jsonl'), JSON.stringify(event) + '\n').catch(() => {});
|
|
1046
|
+
ctx.broadcastMm(event);
|
|
1047
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1048
|
+
res.end(JSON.stringify({ ok: true, status }));
|
|
1049
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// GET /api/org/:name/secrets — masked secrets list (NEVER exposes values)
|
|
1054
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/secrets$/i)) {
|
|
1055
|
+
try {
|
|
1056
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1057
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1058
|
+
const _secretsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1059
|
+
const base = path.join(path.resolve(_secretsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
1060
|
+
const secretsDir = path.join(base, '.secrets');
|
|
1061
|
+
const readJsonSafe = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch(_) { return null; } };
|
|
1062
|
+
// Read secrets index — NEVER expose actual values
|
|
1063
|
+
const indexFile = path.join(secretsDir, `${orgName}-index.json`);
|
|
1064
|
+
const data = readJsonSafe(indexFile) || { secrets: [] };
|
|
1065
|
+
const secrets = (data.secrets || []).map(s => ({
|
|
1066
|
+
name: s.name,
|
|
1067
|
+
purpose: s.purpose || null,
|
|
1068
|
+
maskedRef: s.maskedRef || `${(s.name||'').substring(0,4)}***`,
|
|
1069
|
+
status: s.status || 'active',
|
|
1070
|
+
createdAt: s.createdAt || null,
|
|
1071
|
+
rotatedAt: s.rotatedAt || null,
|
|
1072
|
+
lastUsedAt: s.lastUsedAt || null,
|
|
1073
|
+
usageCount: s.usageCount || 0,
|
|
1074
|
+
}));
|
|
1075
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1076
|
+
res.end(JSON.stringify({ secrets }));
|
|
1077
|
+
} catch(_) { res.writeHead(500); res.end('{"secrets":[]}'); }
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// GET /api/org/:name/budgets — org and per-agent budget data
|
|
1082
|
+
// Returns: { org_budget: {limit_tokens, limit_usd}, agent_budgets: {agentId: {limit_usd}}, agents: [{id, title, tokens_in, tokens_out, total_cost_usd}] }
|
|
1083
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/budgets$/i)) {
|
|
1084
|
+
try {
|
|
1085
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1086
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1087
|
+
const _budgetsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1088
|
+
const base = path.join(path.resolve(_budgetsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
1089
|
+
let budgetData = { org_budget: {}, agent_budgets: {}, period: 'monthly', currency: 'USD' };
|
|
1090
|
+
try { budgetData = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-budgets.json`), 'utf8')); } catch(_) {}
|
|
1091
|
+
// Enrich with per-agent spend from state file.
|
|
1092
|
+
// State file format: { agents: { "<role_id>": { tokens_in, tokens_out, ... } } }
|
|
1093
|
+
let agents = [];
|
|
1094
|
+
try {
|
|
1095
|
+
const state = JSON.parse(fs.readFileSync(path.join(base, `${orgName}-state.json`), 'utf8'));
|
|
1096
|
+
const agentMap = state.agents || {};
|
|
1097
|
+
// Also load role titles from org config for enrichment
|
|
1098
|
+
let roleMap = {};
|
|
1099
|
+
try {
|
|
1100
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(base, `${orgName}.json`), 'utf8'));
|
|
1101
|
+
(cfg.roles || []).forEach(r => { roleMap[r.id] = r.title || r.id; });
|
|
1102
|
+
} catch(_) {}
|
|
1103
|
+
agents = Object.entries(agentMap).map(([id, s]) => ({
|
|
1104
|
+
id,
|
|
1105
|
+
title: roleMap[id] || s.title || id,
|
|
1106
|
+
tokens_in: s.tokens_in || 0,
|
|
1107
|
+
tokens_out: s.tokens_out || 0,
|
|
1108
|
+
tokens_used: s.tokens_used || (s.tokens_in || 0) + (s.tokens_out || 0),
|
|
1109
|
+
total_cost_usd: s.total_cost_usd || 0,
|
|
1110
|
+
}));
|
|
1111
|
+
} catch(_) {}
|
|
1112
|
+
// Scan org run jsonl files for agent:usage events (fallback when state.json has no token data)
|
|
1113
|
+
const _hasTokenData = agents.some(a => a.tokens_in > 0 || a.tokens_out > 0 || a.total_cost_usd > 0);
|
|
1114
|
+
if (!_hasTokenData) {
|
|
1115
|
+
try {
|
|
1116
|
+
const _runsDir = path.join(base, orgName, 'runs');
|
|
1117
|
+
if (fs.existsSync(_runsDir)) {
|
|
1118
|
+
const _usageByRole = {};
|
|
1119
|
+
for (const f of fs.readdirSync(_runsDir)) {
|
|
1120
|
+
if (!f.endsWith('.jsonl') || f.startsWith('._')) continue;
|
|
1121
|
+
const lines = fs.readFileSync(path.join(_runsDir, f), 'utf8').split('\n').filter(Boolean);
|
|
1122
|
+
for (const l of lines) {
|
|
1123
|
+
try {
|
|
1124
|
+
const ev = JSON.parse(l);
|
|
1125
|
+
if (ev.type === 'agent:usage' && ev.role) {
|
|
1126
|
+
const role = String(ev.role).trim();
|
|
1127
|
+
if (!_usageByRole[role]) _usageByRole[role] = { tokens_in: 0, tokens_out: 0, total_cost_usd: 0 };
|
|
1128
|
+
_usageByRole[role].tokens_in += Number(ev.tokens_in) || 0;
|
|
1129
|
+
_usageByRole[role].tokens_out += Number(ev.tokens_out) || 0;
|
|
1130
|
+
_usageByRole[role].total_cost_usd += Number(ev.cost_usd) || 0;
|
|
1131
|
+
}
|
|
1132
|
+
} catch(_) {}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (Object.keys(_usageByRole).length > 0) {
|
|
1136
|
+
// Merge usage into agents list; preserve role titles
|
|
1137
|
+
agents = agents.map(a => {
|
|
1138
|
+
const u = _usageByRole[a.id] || {};
|
|
1139
|
+
return { ...a, tokens_in: u.tokens_in || 0, tokens_out: u.tokens_out || 0, total_cost_usd: u.total_cost_usd || 0 };
|
|
1140
|
+
});
|
|
1141
|
+
// Add any roles that appeared in events but aren't in config
|
|
1142
|
+
for (const [role, u] of Object.entries(_usageByRole)) {
|
|
1143
|
+
if (!agents.find(a => a.id === role)) agents.push({ id: role, title: role, ...u });
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
} catch(_) {}
|
|
1148
|
+
}
|
|
1149
|
+
// Do NOT fall back to zero-value role stubs — empty agents array is the honest signal
|
|
1150
|
+
// that no usage has been tracked yet; the UI shows "No cost data" rather than $0.0000 rows.
|
|
1151
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1152
|
+
res.end(JSON.stringify({ ...budgetData, agents }));
|
|
1153
|
+
} catch(_) { res.writeHead(500); res.end('{"org_budget":{},"agent_budgets":{},"agents":[]}'); }
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// GET /api/org/:name/threads — conversation threads from threads.jsonl
|
|
1158
|
+
// Returns: { threads: [{id, subject, authorId, authorName, issueId, createdAt, messages:[]}] }
|
|
1159
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/threads$/i)) {
|
|
1160
|
+
try {
|
|
1161
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1162
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1163
|
+
const _threadsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1164
|
+
const _threadsRoot = path.resolve(_threadsQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1165
|
+
const _threadsProjDir = ctx._resolveOrgProjectDir(orgName, _threadsRoot) || _threadsRoot;
|
|
1166
|
+
const threadsFile = path.join(_threadsProjDir, '.monomind', 'orgs', `${orgName}-threads.jsonl`);
|
|
1167
|
+
let threads = [];
|
|
1168
|
+
try {
|
|
1169
|
+
const lines = fs.readFileSync(threadsFile, 'utf8').split('\n').filter(l => l.trim());
|
|
1170
|
+
threads = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
|
|
1171
|
+
// Group 'message' entries (from org:comms) by run_id into synthetic thread objects
|
|
1172
|
+
const msgsByRun = {};
|
|
1173
|
+
threads.filter(t => t.type === 'message').forEach(m => {
|
|
1174
|
+
const rid = m.run_id || 'unknown';
|
|
1175
|
+
if (!msgsByRun[rid]) msgsByRun[rid] = { id: `thread-${rid}`, type: 'thread', subject: `Run ${rid}`, run_id: rid, createdAt: m.ts, messages: [] };
|
|
1176
|
+
msgsByRun[rid].messages.push({ from: m.from, to: m.to, msg: m.msg, ts: m.ts });
|
|
1177
|
+
});
|
|
1178
|
+
const syntheticThreads = Object.values(msgsByRun).map(t => ({ ...t, messageCount: t.messages.length, author: t.messages[0]?.from || null }));
|
|
1179
|
+
threads = threads.filter(t => t.type === 'thread' || !t.type).map(t => ({
|
|
1180
|
+
...t,
|
|
1181
|
+
author: t.author || t.authorName || t.createdBy || t.authorId || null,
|
|
1182
|
+
messageCount: t.messageCount != null ? t.messageCount : (Array.isArray(t.messages) ? t.messages.length : (typeof t.messages === 'number' ? t.messages : null)),
|
|
1183
|
+
}));
|
|
1184
|
+
threads = [...threads, ...syntheticThreads];
|
|
1185
|
+
} catch(_) {}
|
|
1186
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1187
|
+
res.end(JSON.stringify({ threads }));
|
|
1188
|
+
} catch(_) { res.writeHead(500); res.end('{"threads":[]}'); }
|
|
1189
|
+
return true;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// GET /api/org/:name/join-requests — pending join requests for this org
|
|
1193
|
+
// Returns: { requests: [{id, requesterId, requesterName, type, status, createdAt, resolvedAt}], pending: N }
|
|
1194
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/join-requests(\?.*)?$/i)) {
|
|
1195
|
+
try {
|
|
1196
|
+
const orgName = decodeURIComponent(url.split('/')[3].split('?')[0]);
|
|
1197
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1198
|
+
const _joinQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1199
|
+
const joinFile = path.join(path.resolve(_joinQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-join-requests.json`);
|
|
1200
|
+
let requests = [];
|
|
1201
|
+
try {
|
|
1202
|
+
const raw = fs.readFileSync(joinFile, 'utf8');
|
|
1203
|
+
const data = JSON.parse(raw);
|
|
1204
|
+
requests = (data.requests || []).map(r => ({
|
|
1205
|
+
id: r.id,
|
|
1206
|
+
requesterId: r.requesterId,
|
|
1207
|
+
requesterName: r.requesterName || r.requesterId,
|
|
1208
|
+
type: r.type || 'human',
|
|
1209
|
+
status: r.status || 'pending_approval',
|
|
1210
|
+
createdAt: r.createdAt,
|
|
1211
|
+
resolvedAt: r.resolvedAt || null,
|
|
1212
|
+
}));
|
|
1213
|
+
} catch(_) {}
|
|
1214
|
+
const pending = requests.filter(r => r.status === 'pending_approval').length;
|
|
1215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1216
|
+
res.end(JSON.stringify({ requests, pending }));
|
|
1217
|
+
} catch(_) { res.writeHead(500); res.end('{"requests":[],"pending":0}'); }
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// GET /api/org/:name/goals — read org goals
|
|
1222
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/goals$/i)) {
|
|
1223
|
+
try {
|
|
1224
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1225
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1226
|
+
const _goalsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1227
|
+
const _goalsRoot = path.resolve(_goalsQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1228
|
+
const _goalsProjDir = ctx._resolveOrgProjectDir(orgName, _goalsRoot) || _goalsRoot;
|
|
1229
|
+
const goalsFile = path.join(_goalsProjDir, '.monomind', 'orgs', `${orgName}-goals.json`);
|
|
1230
|
+
let data = { goals: [] };
|
|
1231
|
+
try { data = JSON.parse(fs.readFileSync(goalsFile, 'utf8')); } catch(_) {}
|
|
1232
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1233
|
+
res.end(JSON.stringify({ goals: data.goals || [] }));
|
|
1234
|
+
} catch(_) { res.writeHead(500); res.end('{"goals":[]}'); }
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// GET /api/org/:name/routines — read org routines (falls back to synthesizing from org config's loop object)
|
|
1239
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/routines$/i)) {
|
|
1240
|
+
try {
|
|
1241
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1242
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1243
|
+
const _routinesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1244
|
+
const _routinesBase = path.resolve(_routinesQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1245
|
+
const _routinesProjDir = ctx._resolveOrgProjectDir(orgName, _routinesBase) || _routinesBase;
|
|
1246
|
+
const routinesFile = path.join(_routinesProjDir, '.monomind', 'orgs', `${orgName}-routines.json`);
|
|
1247
|
+
let data = { routines: [] };
|
|
1248
|
+
try { data = JSON.parse(fs.readFileSync(routinesFile, 'utf8')); } catch(_) {}
|
|
1249
|
+
// Synthesize routines from org config's loop/schedule settings when no explicit routines are defined
|
|
1250
|
+
if (!data.routines || !data.routines.length) {
|
|
1251
|
+
try {
|
|
1252
|
+
const orgCfg = JSON.parse(fs.readFileSync(path.join(_routinesProjDir, '.monomind', 'orgs', `${orgName}.json`), 'utf8'));
|
|
1253
|
+
const loop = orgCfg.loop;
|
|
1254
|
+
if (loop && (loop.poll_interval_minutes || loop.interval_minutes)) {
|
|
1255
|
+
const intervalMin = loop.poll_interval_minutes || loop.interval_minutes;
|
|
1256
|
+
data.routines = [{
|
|
1257
|
+
name: `${orgName}-cycle`,
|
|
1258
|
+
description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org iteration cycle',
|
|
1259
|
+
schedule: `every ${intervalMin}m`,
|
|
1260
|
+
cron: null,
|
|
1261
|
+
enabled: orgCfg.status === 'active',
|
|
1262
|
+
status: orgCfg.status || 'stopped',
|
|
1263
|
+
prompt_file: loop.run_prompt_file || null,
|
|
1264
|
+
source: 'loop-config',
|
|
1265
|
+
lastRun: null,
|
|
1266
|
+
}];
|
|
1267
|
+
} else if (orgCfg.schedule) {
|
|
1268
|
+
data.routines = [{
|
|
1269
|
+
name: `${orgName}-schedule`,
|
|
1270
|
+
description: orgCfg.goal ? orgCfg.goal.slice(0, 120) : 'Org scheduled run',
|
|
1271
|
+
schedule: String(orgCfg.schedule),
|
|
1272
|
+
cron: null,
|
|
1273
|
+
enabled: orgCfg.status === 'active',
|
|
1274
|
+
status: orgCfg.status || 'stopped',
|
|
1275
|
+
source: 'schedule-config',
|
|
1276
|
+
lastRun: null,
|
|
1277
|
+
}];
|
|
1278
|
+
}
|
|
1279
|
+
} catch(_) {}
|
|
1280
|
+
}
|
|
1281
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1282
|
+
res.end(JSON.stringify({ routines: data.routines || [] }));
|
|
1283
|
+
} catch(_) { res.writeHead(500); res.end('{"routines":[]}'); }
|
|
1284
|
+
return true;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// POST /api/org/:name/goals — upsert the org goals file
|
|
1288
|
+
// Body: { goals: [{id, title, description, status, priority, assignee_id, created_at}] }
|
|
1289
|
+
if (req.method === 'POST' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/goals$/i)) {
|
|
1290
|
+
let body = '';
|
|
1291
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
1292
|
+
try {
|
|
1293
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1294
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1295
|
+
const parsed = JSON.parse(body);
|
|
1296
|
+
if (!parsed || !Array.isArray(parsed.goals)) { res.writeHead(400); res.end('{"error":"goals array required"}'); return true; }
|
|
1297
|
+
const _postGoalsQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1298
|
+
const goalsFile = path.join(path.resolve(_postGoalsQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-goals.json`);
|
|
1299
|
+
const tmp = `${goalsFile}.tmp`;
|
|
1300
|
+
const payload = { org: orgName, updated_at: new Date().toISOString(), goals: parsed.goals };
|
|
1301
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8');
|
|
1302
|
+
fs.renameSync(tmp, goalsFile);
|
|
1303
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1304
|
+
res.end(JSON.stringify({ ok: true, count: parsed.goals.length }));
|
|
1305
|
+
} catch(_) { res.writeHead(500); res.end('{"error":"' + String(_).replace(/"/g, '\\"') + '"}'); }
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// POST /api/org/:name/routines — upsert the org routines file
|
|
1310
|
+
// Body: { routines: [{name, description, schedule, enabled, last_run, next_run}] }
|
|
1311
|
+
if (req.method === 'POST' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/routines$/i)) {
|
|
1312
|
+
let body = '';
|
|
1313
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
1314
|
+
try {
|
|
1315
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1316
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1317
|
+
const parsed = JSON.parse(body);
|
|
1318
|
+
if (!parsed || !Array.isArray(parsed.routines)) { res.writeHead(400); res.end('{"error":"routines array required"}'); return true; }
|
|
1319
|
+
const _postRoutinesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1320
|
+
const routinesFile = path.join(path.resolve(_postRoutinesQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs', `${orgName}-routines.json`);
|
|
1321
|
+
const tmp = `${routinesFile}.tmp`;
|
|
1322
|
+
const payload = { org: orgName, updated_at: new Date().toISOString(), routines: parsed.routines };
|
|
1323
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8');
|
|
1324
|
+
fs.renameSync(tmp, routinesFile);
|
|
1325
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1326
|
+
res.end(JSON.stringify({ ok: true, count: parsed.routines.length }));
|
|
1327
|
+
} catch(_) { res.writeHead(500); res.end('{"error":"' + String(_).replace(/"/g, '\\"') + '"}'); }
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// GET /api/org/:name/files — all files related to an org
|
|
1332
|
+
if (req.method === 'GET' && url.match(/^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/files$/i)) {
|
|
1333
|
+
try {
|
|
1334
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1335
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('{"error":"Invalid org name"}'); return true; }
|
|
1336
|
+
const _filesQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1337
|
+
const d = path.resolve(_filesQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1338
|
+
const orgsDir = path.join(d, '.monomind', 'orgs');
|
|
1339
|
+
const files = [];
|
|
1340
|
+
const seen = new Set();
|
|
1341
|
+
const addFile = (fp, type) => {
|
|
1342
|
+
if (seen.has(fp)) return; seen.add(fp);
|
|
1343
|
+
try { const st = fs.statSync(fp); files.push({ name: path.basename(fp), path: fp, type, size: st.size, mtime: st.mtime.toISOString() }); } catch (_) {}
|
|
1344
|
+
};
|
|
1345
|
+
addFile(path.join(orgsDir, orgName + '.json'), 'config');
|
|
1346
|
+
for (const s of ['-state','-approvals','-goals','-routines','-projects','-members','-issues','-threads','-budgets']) {
|
|
1347
|
+
const fp = path.join(orgsDir, orgName + s + '.json');
|
|
1348
|
+
if (fs.existsSync(fp)) addFile(fp, s.slice(1));
|
|
1349
|
+
}
|
|
1350
|
+
const walkDir = (dir, depth) => {
|
|
1351
|
+
if (depth > 3) return;
|
|
1352
|
+
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
1353
|
+
for (const e of entries) {
|
|
1354
|
+
if (e.name.startsWith('.')) continue;
|
|
1355
|
+
const fp = path.join(dir, e.name);
|
|
1356
|
+
if (e.isDirectory()) walkDir(fp, depth + 1);
|
|
1357
|
+
else addFile(fp, 'generated');
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
const orgWorkDir = path.join(orgsDir, orgName);
|
|
1361
|
+
if (fs.existsSync(orgWorkDir)) walkDir(orgWorkDir, 0);
|
|
1362
|
+
let orgCfg = null;
|
|
1363
|
+
try { orgCfg = JSON.parse(fs.readFileSync(path.join(orgsDir, orgName + '.json'), 'utf8')); } catch (_) {}
|
|
1364
|
+
if (orgCfg && Array.isArray(orgCfg.roles)) {
|
|
1365
|
+
const agentsDir = path.join(d, '.claude', 'agents');
|
|
1366
|
+
const walkAgents = (dir) => {
|
|
1367
|
+
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
1368
|
+
for (const e of entries) {
|
|
1369
|
+
if (e.isDirectory()) { walkAgents(path.join(dir, e.name)); continue; }
|
|
1370
|
+
if (!e.name.endsWith('.md')) continue;
|
|
1371
|
+
const fp = path.join(dir, e.name);
|
|
1372
|
+
const base = e.name.replace('.md', '').toLowerCase();
|
|
1373
|
+
if (orgCfg.roles.some(r => base === (r.id||'').toLowerCase() || base === (r.agent_type||'').toLowerCase() || (r.instructions_file||'').endsWith(e.name))) addFile(fp, 'agent-definition');
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
if (fs.existsSync(agentsDir)) walkAgents(agentsDir);
|
|
1377
|
+
}
|
|
1378
|
+
files.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
|
|
1379
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
1380
|
+
res.end(JSON.stringify(files));
|
|
1381
|
+
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// GET /api/file-content — return raw text content of a .monomind file
|
|
1386
|
+
if (req.method === 'GET' && url === '/api/file-content') {
|
|
1387
|
+
try {
|
|
1388
|
+
const _fcQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1389
|
+
const rawPath = _fcQs.get('path');
|
|
1390
|
+
const baseDir = path.resolve(_fcQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1391
|
+
if (!rawPath) { res.writeHead(400); res.end('Missing path'); return true; }
|
|
1392
|
+
const resolved = path.resolve(rawPath);
|
|
1393
|
+
// Security: must be inside .monomind of the project dir
|
|
1394
|
+
const monomindDir = path.join(baseDir, '.monomind');
|
|
1395
|
+
if (!resolved.startsWith(monomindDir + path.sep) && resolved !== monomindDir) {
|
|
1396
|
+
res.writeHead(403); res.end('Forbidden'); return true;
|
|
1397
|
+
}
|
|
1398
|
+
if (!fs.existsSync(resolved)) { res.writeHead(404); res.end('Not found'); return true; }
|
|
1399
|
+
const stat = fs.statSync(resolved);
|
|
1400
|
+
if (!stat.isFile()) { res.writeHead(400); res.end('Not a file'); return true; }
|
|
1401
|
+
if (stat.size > 524288) { res.writeHead(413); res.end('File too large'); return true; }
|
|
1402
|
+
const _fcMime = ctx._detectMimeType(resolved);
|
|
1403
|
+
if (_fcMime.startsWith('image/')) {
|
|
1404
|
+
res.writeHead(200, { 'Content-Type': _fcMime, ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1405
|
+
res.end(fs.readFileSync(resolved));
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
1409
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1410
|
+
res.end(content);
|
|
1411
|
+
} catch(_) { res.writeHead(500); res.end('Internal error'); }
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// DELETE /api/orgs/:name — delete an org config and all associated data files
|
|
1416
|
+
if (req.method === 'DELETE' && url.match(/^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}(\?.*)?$/i)) {
|
|
1417
|
+
try {
|
|
1418
|
+
const orgName = decodeURIComponent(url.split('/')[3].split('?')[0]);
|
|
1419
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1420
|
+
const _delOrgQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1421
|
+
const orgsDir = path.join(path.resolve(_delOrgQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
1422
|
+
const configFile = path.join(orgsDir, `${orgName}.json`);
|
|
1423
|
+
const v1ConfigFile = path.join(orgsDir, `${orgName}.v1.json`);
|
|
1424
|
+
if (!fs.existsSync(configFile) && !fs.existsSync(v1ConfigFile)) { res.writeHead(404); res.end('{"error":"org not found"}'); return true; }
|
|
1425
|
+
// Remove all org-associated files (config + state + data)
|
|
1426
|
+
try { if (fs.existsSync(v1ConfigFile)) fs.unlinkSync(v1ConfigFile); } catch(_) {}
|
|
1427
|
+
const suffixes = ['', '-state', '-goals', '-routines', '-approvals', '-activity', '-issues', '-members', '-projects', '-workspaces', '-worktrees', '-environments', '-plugins', '-adapters', '-budgets', '-threads', '-secrets', '-join-requests', '-bootstrap', '-project-workspaces', '-approval-comments', '-skills'];
|
|
1428
|
+
for (const suf of suffixes) {
|
|
1429
|
+
const f = path.join(orgsDir, `${orgName}${suf}.json`);
|
|
1430
|
+
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch(_) {}
|
|
1431
|
+
const fjsonl = path.join(orgsDir, `${orgName}${suf}.jsonl`);
|
|
1432
|
+
try { if (fs.existsSync(fjsonl)) fs.unlinkSync(fjsonl); } catch(_) {}
|
|
1433
|
+
}
|
|
1434
|
+
// Remove stop file if present
|
|
1435
|
+
try { fs.unlinkSync(path.join(orgsDir, '.stops', `${orgName}.stop`)); } catch(_) {}
|
|
1436
|
+
// Remove org subdirectory under .monomind/orgs/ (legacy flat-file location)
|
|
1437
|
+
try { const orgWorkDir = path.join(orgsDir, orgName); if (fs.existsSync(orgWorkDir)) fs.rmSync(orgWorkDir, { recursive: true, force: true }); } catch(_) {}
|
|
1438
|
+
// Remove org subdirectory under git-safe location (.git/monomind/orgs/<name>/) so run
|
|
1439
|
+
// files written by the worktree-aware path (feat 880f034e) are also cleaned up on delete
|
|
1440
|
+
try {
|
|
1441
|
+
const _delWorkDir = path.resolve(_delOrgQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1442
|
+
const _delGitMonoDir = ctx._getGitMonomindDir(_delWorkDir);
|
|
1443
|
+
if (_delGitMonoDir) {
|
|
1444
|
+
const gitOrgDir = path.join(_delGitMonoDir, 'orgs', orgName);
|
|
1445
|
+
if (fs.existsSync(gitOrgDir)) fs.rmSync(gitOrgDir, { recursive: true, force: true });
|
|
1446
|
+
}
|
|
1447
|
+
} catch(_) {}
|
|
1448
|
+
// Remove loop prompt file if present (created for scheduled orgs by createorg)
|
|
1449
|
+
try { const lpf = path.join(path.resolve(ctx.projectDir || process.cwd()), '.monomind', 'loops', `${orgName}.md`); if (fs.existsSync(lpf)) fs.unlinkSync(lpf); } catch(_) {}
|
|
1450
|
+
// Emit org:delete event
|
|
1451
|
+
const deleteEvent = { type: 'org:delete', org: orgName, ts: Date.now() };
|
|
1452
|
+
ctx.appendToFile(path.join(ctx.projectDir || process.cwd(), 'data', 'mastermind-events.jsonl'), JSON.stringify(deleteEvent) + '\n').catch(() => {});
|
|
1453
|
+
ctx.broadcastMm(deleteEvent);
|
|
1454
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1455
|
+
res.end('{"ok":true}');
|
|
1456
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// POST /api/orgs/:name/stop — send stop signal to a running org
|
|
1461
|
+
if (req.method === 'POST' && url.match(/^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/stop$/i)) {
|
|
1462
|
+
try {
|
|
1463
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1464
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end('Invalid org name'); return true; }
|
|
1465
|
+
const _stopOrgQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1466
|
+
const _stopOrgBase = path.resolve(_stopOrgQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1467
|
+
const stopEvent = { type: 'org:stop', org: orgName, ts: Date.now() };
|
|
1468
|
+
const dataDir = path.join(_stopOrgBase, 'data');
|
|
1469
|
+
try { fs.mkdirSync(dataDir, { recursive: true }); } catch(_) {}
|
|
1470
|
+
ctx.appendToFile(path.join(dataDir, 'mastermind-events.jsonl'), JSON.stringify(stopEvent) + '\n').catch(() => {});
|
|
1471
|
+
// Write stop marker file for boss agent to detect
|
|
1472
|
+
try {
|
|
1473
|
+
const stopDir = path.join(_stopOrgBase, '.monomind', 'orgs', '.stops');
|
|
1474
|
+
fs.mkdirSync(stopDir, { recursive: true });
|
|
1475
|
+
fs.writeFileSync(path.join(stopDir, `${orgName}.stop`), String(Date.now()));
|
|
1476
|
+
} catch(_) {}
|
|
1477
|
+
ctx.broadcastMm(stopEvent);
|
|
1478
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1479
|
+
res.end('{"ok":true}');
|
|
1480
|
+
} catch(_) { res.writeHead(500); res.end('{}'); }
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// POST /api/orgs/:name/copy — copy org config to another project directory
|
|
1485
|
+
if (req.method === 'POST' && url.match(/^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/copy$/i)) {
|
|
1486
|
+
let body = '';
|
|
1487
|
+
for await (const chunk of req) { body += chunk; if (body.length > 2097152) { req.destroy(); break; } }
|
|
1488
|
+
try {
|
|
1489
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1490
|
+
if (orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid org name' })); return true; }
|
|
1491
|
+
let payload = {};
|
|
1492
|
+
try { payload = JSON.parse(body); } catch(_) {}
|
|
1493
|
+
const destination = payload.destination ? String(payload.destination).trim() : '';
|
|
1494
|
+
if (!destination) { res.writeHead(400); res.end(JSON.stringify({ error: 'destination is required' })); return true; }
|
|
1495
|
+
if (!path.isAbsolute(destination)) { res.writeHead(400); res.end(JSON.stringify({ error: 'destination must be an absolute path' })); return true; }
|
|
1496
|
+
const _copyOrgQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1497
|
+
const srcOrgsDir = path.join(path.resolve(_copyOrgQs.get('dir') || ctx.projectDir || process.cwd()), '.monomind', 'orgs');
|
|
1498
|
+
const srcFile = path.join(srcOrgsDir, `${orgName}.json`);
|
|
1499
|
+
if (!fs.existsSync(srcFile)) { res.writeHead(404); res.end(JSON.stringify({ error: 'org not found' })); return true; }
|
|
1500
|
+
const destOrgsDir = path.join(path.resolve(destination), '.monomind', 'orgs');
|
|
1501
|
+
try { fs.mkdirSync(destOrgsDir, { recursive: true }); } catch(_) {}
|
|
1502
|
+
const destFile = path.join(destOrgsDir, `${orgName}.json`);
|
|
1503
|
+
fs.copyFileSync(srcFile, destFile);
|
|
1504
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1505
|
+
res.end(JSON.stringify({ ok: true, destFile }));
|
|
1506
|
+
} catch(e) { res.writeHead(500); res.end(JSON.stringify({ error: String(e.message || e) })); }
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// GET /api/org/:name/runs — list structured run files for an org
|
|
1511
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/runs(\?.*)?$/i.test(url)) {
|
|
1512
|
+
try {
|
|
1513
|
+
const _rQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1514
|
+
const _rOrgName = decodeURIComponent(url.split('/')[3] || '');
|
|
1515
|
+
if (_rOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rOrgName)) { res.writeHead(400); res.end('{"error":"Invalid org name"}'); return true; }
|
|
1516
|
+
const _rExplicitDir = _rQs.get('dir');
|
|
1517
|
+
const _rServerRoot = path.resolve(_rExplicitDir || ctx.projectDir || process.cwd());
|
|
1518
|
+
// Search across known projects (same logic as /api/orgs) unless explicit dir given
|
|
1519
|
+
const _rProjDirs = new Set([_rServerRoot]);
|
|
1520
|
+
if (!_rExplicitDir) {
|
|
1521
|
+
try {
|
|
1522
|
+
const _rKnown = JSON.parse(fs.readFileSync(path.join(_rServerRoot, 'data', 'known-projects.json'), 'utf8'));
|
|
1523
|
+
_rKnown.forEach(p => _rProjDirs.add(p));
|
|
1524
|
+
} catch(_) {}
|
|
1525
|
+
}
|
|
1526
|
+
const _rSeenFiles = new Set();
|
|
1527
|
+
const runs = [];
|
|
1528
|
+
const _parseRun = (filePath, f) => {
|
|
1529
|
+
try {
|
|
1530
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1531
|
+
const allLines = raw.split('\n').filter(Boolean);
|
|
1532
|
+
const parse = l => { try { return JSON.parse(l); } catch { return null; } };
|
|
1533
|
+
// Merge .warm.jsonl (promoted pre-complete events) for accurate event count + metadata
|
|
1534
|
+
const warmFile = filePath.replace(/\.jsonl$/, '.warm.jsonl');
|
|
1535
|
+
let warmLines = [];
|
|
1536
|
+
let warmEvents = [];
|
|
1537
|
+
try { if (fs.existsSync(warmFile)) { warmLines = fs.readFileSync(warmFile, 'utf8').split('\n').filter(Boolean); warmEvents = warmLines.map(parse).filter(Boolean); } } catch(_) {}
|
|
1538
|
+
const combinedLines = [...warmLines, ...allLines];
|
|
1539
|
+
const eventCount = combinedLines.length;
|
|
1540
|
+
const headEvents = (warmEvents.length ? warmEvents : allLines.map(parse).filter(Boolean)).slice(0, 10);
|
|
1541
|
+
const tailEvents = (allLines.map(parse).filter(Boolean).slice(-5).length ? allLines.map(parse).filter(Boolean).slice(-5) : warmEvents.slice(-5));
|
|
1542
|
+
const first = headEvents.find(e => e.type === 'run:start') || headEvents[0];
|
|
1543
|
+
const last = [...(warmEvents.slice(-5)), ...(allLines.map(parse).filter(Boolean).slice(-3))].slice().reverse().find(e => e.type === 'run:complete' || e.type === 'org:complete');
|
|
1544
|
+
const cycles = combinedLines.filter(l => l.includes('"org:checkpoint"')).length;
|
|
1545
|
+
const lastEvent = allLines.map(parse).filter(Boolean).slice(-1)[0] || warmEvents.slice(-1)[0];
|
|
1546
|
+
const ageMs = lastEvent?.ts ? Date.now() - lastEvent.ts : Infinity;
|
|
1547
|
+
const isStale = !last && ageMs > 30 * 60 * 1000;
|
|
1548
|
+
const firstBossComms = headEvents.find(e => e.type === 'org:comms' && (e.from === 'boss' || e.role === 'boss') && e.msg);
|
|
1549
|
+
const derivedGoal = first?.goal || firstBossComms?.msg?.slice(0, 80) || '';
|
|
1550
|
+
return { runId: f.replace(/\.warm\.jsonl$|\.jsonl$/, ''), startedAt: first?.ts || 0, endedAt: last?.ts || 0,
|
|
1551
|
+
status: last ? 'complete' : isStale ? 'stale' : 'running',
|
|
1552
|
+
eventCount, cycleCount: cycles, goal: derivedGoal, bossRole: first?.bossRole || '' };
|
|
1553
|
+
} catch(_) { return null; }
|
|
1554
|
+
};
|
|
1555
|
+
for (const _rpd of _rProjDirs) {
|
|
1556
|
+
// Check both .monomind and .git/monomind locations
|
|
1557
|
+
const _rMonoDir = ctx._getGitMonomindDir(_rpd) || path.join(_rpd, '.monomind');
|
|
1558
|
+
const _rSearchDirs = [path.join(_rMonoDir, 'orgs', _rOrgName, 'runs')];
|
|
1559
|
+
if (_rMonoDir !== path.join(_rpd, '.monomind')) _rSearchDirs.push(path.join(_rpd, '.monomind', 'orgs', _rOrgName, 'runs'));
|
|
1560
|
+
for (const _rDir of _rSearchDirs) {
|
|
1561
|
+
if (!fs.existsSync(_rDir)) continue;
|
|
1562
|
+
// Include .warm.jsonl — completed runs are renamed hot→warm on org:complete and
|
|
1563
|
+
// must stay visible in the chat history dropdown.
|
|
1564
|
+
const files = fs.readdirSync(_rDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('._') && !f.endsWith('.convs.jsonl') && !f.endsWith('.cold.jsonl')).sort().reverse();
|
|
1565
|
+
for (const f of files.slice(0, 50)) {
|
|
1566
|
+
const _rFileId = f.replace(/\.warm\.jsonl$|\.jsonl$/, '');
|
|
1567
|
+
if (_rSeenFiles.has(_rFileId)) continue;
|
|
1568
|
+
_rSeenFiles.add(_rFileId);
|
|
1569
|
+
const r = _parseRun(path.join(_rDir, f), f);
|
|
1570
|
+
if (r) runs.push(r);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
runs.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
|
|
1575
|
+
// Threads fallback: if no run files found, synthesize a run from the org threads file
|
|
1576
|
+
// so orgs whose boss never emitted runId-tagged events still show in the chat dropdown.
|
|
1577
|
+
if (runs.length === 0) {
|
|
1578
|
+
for (const _rpd of _rProjDirs) {
|
|
1579
|
+
const _tf = path.join(_rpd, '.monomind', 'orgs', `${_rOrgName}-threads.jsonl`);
|
|
1580
|
+
if (!fs.existsSync(_tf)) continue;
|
|
1581
|
+
const _tLines = fs.readFileSync(_tf, 'utf8').split('\n').filter(Boolean);
|
|
1582
|
+
if (!_tLines.length) continue;
|
|
1583
|
+
const _tEvs = _tLines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
1584
|
+
if (!_tEvs.length) continue;
|
|
1585
|
+
const _firstTs = _tEvs[0].ts ? (typeof _tEvs[0].ts === 'number' ? _tEvs[0].ts : new Date(_tEvs[0].ts).getTime()) : Date.now();
|
|
1586
|
+
const _lastTs = _tEvs[_tEvs.length-1].ts ? (typeof _tEvs[_tEvs.length-1].ts === 'number' ? _tEvs[_tEvs.length-1].ts : new Date(_tEvs[_tEvs.length-1].ts).getTime()) : _firstTs;
|
|
1587
|
+
runs.push({ id: `threads-${_rOrgName}`, orgName: _rOrgName, goal: `${_rOrgName} threads`, status: 'complete', startedAt: _firstTs, endedAt: _lastTs, eventCount: _tEvs.length, _threadsFile: _tf });
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
1592
|
+
res.end(JSON.stringify(runs));
|
|
1593
|
+
} catch (_) { res.writeHead(500); res.end('[]'); }
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// GET /api/org/:name/runs/:runId — get all events for a specific run
|
|
1598
|
+
if (req.method === 'GET' && /^\/api\/org\/[a-z0-9][a-z0-9_-]{0,63}\/runs\/[a-z0-9][a-z0-9_-]{0,79}(\?.*)?$/i.test(url)) {
|
|
1599
|
+
try {
|
|
1600
|
+
const _rvQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1601
|
+
const _rvParts = url.replace(/\?.*$/, '').split('/');
|
|
1602
|
+
const _rvOrgName = decodeURIComponent(_rvParts[3] || '');
|
|
1603
|
+
const _rvRunId = decodeURIComponent(_rvParts[5] || '');
|
|
1604
|
+
if (_rvOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rvOrgName) ||
|
|
1605
|
+
_rvRunId.length > 80 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_rvRunId)) { res.writeHead(400); res.end('{"error":"Invalid org or run id"}'); return true; }
|
|
1606
|
+
const _rvExplicitDir = _rvQs.get('dir');
|
|
1607
|
+
const _rvServerRoot = path.resolve(_rvExplicitDir || ctx.projectDir || process.cwd());
|
|
1608
|
+
// Threads fallback: threads-${orgName} is a synthetic runId served from threads file
|
|
1609
|
+
if (_rvRunId === `threads-${_rvOrgName}`) {
|
|
1610
|
+
const _rvProjDirsT = new Set([_rvServerRoot]);
|
|
1611
|
+
if (!_rvExplicitDir) { try { JSON.parse(fs.readFileSync(path.join(_rvServerRoot, 'data', 'known-projects.json'), 'utf8')).forEach(p => _rvProjDirsT.add(p)); } catch(_) {} }
|
|
1612
|
+
for (const _rvpd of _rvProjDirsT) {
|
|
1613
|
+
const _tf = path.join(_rvpd, '.monomind', 'orgs', `${_rvOrgName}-threads.jsonl`);
|
|
1614
|
+
if (!fs.existsSync(_tf)) continue;
|
|
1615
|
+
const _tLines = fs.readFileSync(_tf, 'utf8').split('\n').filter(Boolean);
|
|
1616
|
+
const _tEvs = _tLines.map(l => { try { const e = JSON.parse(l); return { type: 'org:comms', from: e.role || e.from || 'agent', to: e.to || 'all', msg: e.message || e.msg || '', ts: e.ts ? (typeof e.ts === 'number' ? e.ts : new Date(e.ts).getTime()) : Date.now(), org: _rvOrgName, runId: _rvRunId }; } catch { return null; } }).filter(Boolean);
|
|
1617
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1618
|
+
res.end(JSON.stringify(_tEvs));
|
|
1619
|
+
return true;
|
|
1620
|
+
}
|
|
1621
|
+
res.writeHead(404); res.end('{"error":"threads file not found"}'); return true;
|
|
1622
|
+
}
|
|
1623
|
+
// Search across known projects
|
|
1624
|
+
const _rvProjDirs = new Set([_rvServerRoot]);
|
|
1625
|
+
if (!_rvExplicitDir) {
|
|
1626
|
+
try {
|
|
1627
|
+
JSON.parse(fs.readFileSync(path.join(_rvServerRoot, 'data', 'known-projects.json'), 'utf8')).forEach(p => _rvProjDirs.add(p));
|
|
1628
|
+
} catch(_) {}
|
|
1629
|
+
}
|
|
1630
|
+
let _rvFile = null;
|
|
1631
|
+
for (const _rvpd of _rvProjDirs) {
|
|
1632
|
+
const _rvMonoDir = ctx._getGitMonomindDir(_rvpd) || path.join(_rvpd, '.monomind');
|
|
1633
|
+
const _candidates = [
|
|
1634
|
+
path.join(_rvMonoDir, 'orgs', _rvOrgName, 'runs', `${_rvRunId}.jsonl`),
|
|
1635
|
+
path.join(_rvMonoDir, 'orgs', _rvOrgName, 'runs', `${_rvRunId}.warm.jsonl`),
|
|
1636
|
+
];
|
|
1637
|
+
if (_rvMonoDir !== path.join(_rvpd, '.monomind')) {
|
|
1638
|
+
_candidates.push(path.join(_rvpd, '.monomind', 'orgs', _rvOrgName, 'runs', `${_rvRunId}.jsonl`));
|
|
1639
|
+
_candidates.push(path.join(_rvpd, '.monomind', 'orgs', _rvOrgName, 'runs', `${_rvRunId}.warm.jsonl`));
|
|
1640
|
+
}
|
|
1641
|
+
for (const c of _candidates) { if (fs.existsSync(c)) { _rvFile = c; break; } }
|
|
1642
|
+
if (_rvFile) break;
|
|
1643
|
+
}
|
|
1644
|
+
if (!_rvFile) { res.writeHead(404); res.end('{"error":"run not found"}'); return true; }
|
|
1645
|
+
const _parseLines = p => { try { return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
|
|
1646
|
+
const events = _parseLines(_rvFile);
|
|
1647
|
+
// Merge .warm.jsonl (pre-run:complete events, including org:comms) if it exists.
|
|
1648
|
+
// When run:complete fires, the hot .jsonl is renamed to .warm.jsonl so all pre-complete
|
|
1649
|
+
// events live there. The current .jsonl then only holds post-complete events (e.g. org:stop).
|
|
1650
|
+
const _rvWarmFile = _rvFile.endsWith('.warm.jsonl') ? _rvFile : _rvFile.replace(/\.jsonl$/, '.warm.jsonl');
|
|
1651
|
+
if (_rvWarmFile !== _rvFile && fs.existsSync(_rvWarmFile)) {
|
|
1652
|
+
events.push(..._parseLines(_rvWarmFile));
|
|
1653
|
+
}
|
|
1654
|
+
// For in-progress runs (no .warm.jsonl), org:comms also go to .convs.jsonl (stripped form).
|
|
1655
|
+
// They're already in .jsonl as full events, so .convs.jsonl would duplicate — skip it.
|
|
1656
|
+
events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
1657
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
1658
|
+
res.end(JSON.stringify(events));
|
|
1659
|
+
} catch (_) { res.writeHead(500); res.end('[]'); }
|
|
1660
|
+
return true;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// GET /api/org/:name/artifact — serve file content for chat "View" button
|
|
1664
|
+
if (req.method === 'GET' && /^\/api\/org\/[^/]+\/artifact/.test(url)) {
|
|
1665
|
+
try {
|
|
1666
|
+
const _artQp = new URL('http://x' + req.url).searchParams;
|
|
1667
|
+
const _rawPath = _artQp.get('path');
|
|
1668
|
+
if (!_rawPath) { res.writeHead(400); res.end(JSON.stringify({ error: 'path required' })); return true; }
|
|
1669
|
+
const _filePath = path.resolve(decodeURIComponent(_rawPath));
|
|
1670
|
+
// Path traversal guard: only allow reads within known project dirs
|
|
1671
|
+
const _allowed = ctx._getAllowedArtifactDirs(ctx.projectDir || process.cwd());
|
|
1672
|
+
const _safe = _allowed.some(d => _filePath.startsWith(d + path.sep) || _filePath === d);
|
|
1673
|
+
if (!_safe) { res.writeHead(403); res.end(JSON.stringify({ error: 'path not allowed' })); return true; }
|
|
1674
|
+
if (!fs.existsSync(_filePath)) { res.writeHead(404); res.end(JSON.stringify({ error: 'file not found' })); return true; }
|
|
1675
|
+
const _mime = ctx._detectMimeType(_filePath);
|
|
1676
|
+
const _size = fs.statSync(_filePath).size;
|
|
1677
|
+
// Reject files >2MB to avoid blocking the event loop
|
|
1678
|
+
if (_size > 2 * 1024 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: 'file too large', size: _size })); return true; }
|
|
1679
|
+
if (!_mime.startsWith('text/') && _mime !== 'application/json') {
|
|
1680
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1681
|
+
res.end(JSON.stringify({ binary: true, mimeType: _mime, size: _size }));
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
const _content = fs.readFileSync(_filePath, 'utf8');
|
|
1685
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1686
|
+
res.end(JSON.stringify({ content: _content, mimeType: _mime, size: _size }));
|
|
1687
|
+
} catch (_e) { res.writeHead(500); res.end(JSON.stringify({ error: 'read failed' })); }
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ------------------------------------------------- Mastermind event system
|
|
1692
|
+
// POST /api/mastermind/event — ingest event from mastermind skill
|
|
1693
|
+
if (req.method === 'POST' && url === '/api/mastermind/event') {
|
|
1694
|
+
await ctx.handleMastermindEvent(req, res, corsOrigin);
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// GET /api/mastermind-stream — SSE for real-time events
|
|
1699
|
+
if (req.method === 'GET' && url === '/api/mastermind-stream') {
|
|
1700
|
+
res.writeHead(200, {
|
|
1701
|
+
'Content-Type': 'text/event-stream',
|
|
1702
|
+
'Cache-Control': 'no-cache',
|
|
1703
|
+
'Connection': 'keep-alive',
|
|
1704
|
+
...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}),
|
|
1705
|
+
});
|
|
1706
|
+
res.write(': connected\n\n');
|
|
1707
|
+
ctx.addMmClient(res);
|
|
1708
|
+
// Replay last 50 events from disk (use ?project= param if provided)
|
|
1709
|
+
try {
|
|
1710
|
+
const _sseQp = new URL('http://x' + req.url).searchParams;
|
|
1711
|
+
const _sseProj = _sseQp.get('project');
|
|
1712
|
+
const root2 = _sseProj || ctx.projectDir || process.cwd();
|
|
1713
|
+
const evFile = path.join(root2, 'data', 'mastermind-events.jsonl');
|
|
1714
|
+
const lines = fs.readFileSync(evFile, 'utf8').trim().split('\n').filter(Boolean).slice(-50);
|
|
1715
|
+
for (const l of lines) res.write(`data: ${l}\n\n`);
|
|
1716
|
+
} catch (_) {}
|
|
1717
|
+
const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { clearInterval(ka); ctx.removeMmClient(res); } }, 20000);
|
|
1718
|
+
req.on('close', () => { ctx.removeMmClient(res); clearInterval(ka); });
|
|
1719
|
+
return true;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// GET /api/mastermind/sessions
|
|
1723
|
+
if (req.method === 'GET' && url.startsWith('/api/mastermind/sessions')) {
|
|
1724
|
+
try {
|
|
1725
|
+
const qp = new URL('http://x' + req.url).searchParams;
|
|
1726
|
+
const filterProject = qp.get('project');
|
|
1727
|
+
const limitParam = Math.min(parseInt(qp.get('limit') || '200', 10) || 200, 500);
|
|
1728
|
+
const serverRoot = ctx.projectDir || process.cwd();
|
|
1729
|
+
// Collect all project dirs to aggregate
|
|
1730
|
+
const projectDirs = new Set([serverRoot]);
|
|
1731
|
+
try {
|
|
1732
|
+
const known = JSON.parse(fs.readFileSync(path.join(serverRoot, 'data', 'known-projects.json'), 'utf8'));
|
|
1733
|
+
known.forEach(p => projectDirs.add(p));
|
|
1734
|
+
} catch (_) {}
|
|
1735
|
+
let allSessions = [];
|
|
1736
|
+
for (const pd of projectDirs) {
|
|
1737
|
+
if (filterProject && pd !== filterProject) continue;
|
|
1738
|
+
const sessDir = path.join(pd, 'data', 'sessions');
|
|
1739
|
+
const indexFile = path.join(sessDir, '_index.json');
|
|
1740
|
+
// ── New format: per-session JSONL + _index.json ──
|
|
1741
|
+
if (fs.existsSync(indexFile)) {
|
|
1742
|
+
try {
|
|
1743
|
+
const idx = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
1744
|
+
const top = idx.slice(0, limitParam);
|
|
1745
|
+
for (const entry of top) {
|
|
1746
|
+
const _sid = String(entry.id || '').trim();
|
|
1747
|
+
if (!_sid || !ctx.SESSION_ID_RE.test(_sid)) continue;
|
|
1748
|
+
let events = [];
|
|
1749
|
+
try {
|
|
1750
|
+
const jl = fs.readFileSync(path.join(sessDir, `${_sid}.jsonl`), 'utf8');
|
|
1751
|
+
events = jl.trim().split('\n').filter(Boolean)
|
|
1752
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
1753
|
+
.filter(Boolean);
|
|
1754
|
+
} catch(_) {}
|
|
1755
|
+
allSessions.push({ ...entry, events, project: pd });
|
|
1756
|
+
}
|
|
1757
|
+
} catch(_) {}
|
|
1758
|
+
} else {
|
|
1759
|
+
// ── Legacy fallback: mastermind-sessions.json ──
|
|
1760
|
+
const f = path.join(pd, 'data', 'mastermind-sessions.json');
|
|
1761
|
+
if (fs.existsSync(f)) {
|
|
1762
|
+
try {
|
|
1763
|
+
const s = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
1764
|
+
s.forEach(sess => { if (!sess.project) sess.project = pd; });
|
|
1765
|
+
allSessions = allSessions.concat(s);
|
|
1766
|
+
} catch (_) {}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
allSessions.sort((a,b) => (b.ts||b.startedAt||0)-(a.ts||a.startedAt||0));
|
|
1771
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1772
|
+
res.end(JSON.stringify(allSessions.slice(0, limitParam)));
|
|
1773
|
+
} catch (_) { res.writeHead(200); res.end('[]'); }
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// GET /api/mastermind/session/:id/trace — human-readable markdown trace
|
|
1778
|
+
if (req.method === 'GET' && url.match(/^\/api\/mastermind\/session\/[^/]+\/trace$/)) {
|
|
1779
|
+
try {
|
|
1780
|
+
const sid = url.split('/')[4];
|
|
1781
|
+
const sessFile = path.join(ctx.projectDir || process.cwd(), 'data', 'sessions', `${sid}.json`);
|
|
1782
|
+
let s = null;
|
|
1783
|
+
if (fs.existsSync(sessFile)) {
|
|
1784
|
+
s = JSON.parse(fs.readFileSync(sessFile, 'utf8'));
|
|
1785
|
+
} else {
|
|
1786
|
+
const f = path.join(ctx.projectDir || process.cwd(), 'data', 'mastermind-sessions.json');
|
|
1787
|
+
const sessions = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
1788
|
+
s = sessions.find(x => x.id === sid);
|
|
1789
|
+
}
|
|
1790
|
+
if (!s) { res.writeHead(404); res.end('Session not found'); return true; }
|
|
1791
|
+
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
1792
|
+
const lines = [`# Mastermind Session Trace: ${s.id}`, ``, `**Prompt:** ${s.prompt || '(none)'}`, `**Status:** ${s.status}`, `**Started:** ${fmt(s.ts)}`, s.endTs ? `**Ended:** ${fmt(s.endTs)}` : '', `**Domains:** ${(s.domains || []).join(', ') || '(none yet)'}`, ``];
|
|
1793
|
+
for (const ev of (s.events || [])) {
|
|
1794
|
+
const t = fmt(ev.ts);
|
|
1795
|
+
if (ev.type === 'session:start') lines.push(`\`${t}\` **SESSION START** — prompt: "${ev.prompt || ''}"`);
|
|
1796
|
+
else if (ev.type === 'domain:dispatch') lines.push(`\`${t}\` **DOMAIN DISPATCH** → \`${ev.domain}\` — ${ev.cmd || ''}`);
|
|
1797
|
+
else if (ev.type === 'agent:spawn') lines.push(`\`${t}\` **AGENT SPAWN** [\`${ev.domain}\`] → agent: \`${ev.agent}\` — ${ev.task || ''}`);
|
|
1798
|
+
else if (ev.type === 'intercom') lines.push(`\`${t}\` **INTERCOM** \`${ev.from}\` → \`${ev.to}\`: ${ev.msg || ''}`);
|
|
1799
|
+
else if (ev.type === 'domain:complete') lines.push(`\`${t}\` **DOMAIN COMPLETE** [\`${ev.domain}\`] status: ${ev.status}${ev.artifacts?.length ? ` — artifacts: ${ev.artifacts.join(', ')}` : ''}`);
|
|
1800
|
+
else if (ev.type === 'session:complete') lines.push(`\`${t}\` **SESSION COMPLETE** — status: ${ev.status}, domains: ${(ev.domains || []).join(', ')}`);
|
|
1801
|
+
else lines.push(`\`${t}\` ${ev.type} ${JSON.stringify(ev)}`);
|
|
1802
|
+
}
|
|
1803
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1804
|
+
res.end(lines.join('\n'));
|
|
1805
|
+
} catch (_) { res.writeHead(500); res.end('Error'); }
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// GET /api/mastermind/session/:id
|
|
1810
|
+
if (req.method === 'GET' && url.startsWith('/api/mastermind/session/')) {
|
|
1811
|
+
try {
|
|
1812
|
+
const sid = url.slice('/api/mastermind/session/'.length);
|
|
1813
|
+
// Check individual session file first
|
|
1814
|
+
const sessFile = path.join(ctx.projectDir || process.cwd(), 'data', 'sessions', `${sid}.json`);
|
|
1815
|
+
if (fs.existsSync(sessFile)) {
|
|
1816
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1817
|
+
res.end(fs.readFileSync(sessFile, 'utf8'));
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
const f = path.join(ctx.projectDir || process.cwd(), 'data', 'mastermind-sessions.json');
|
|
1821
|
+
const sessions = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
1822
|
+
const s = sessions.find(x => x.id === sid);
|
|
1823
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1824
|
+
res.end(JSON.stringify(s || null));
|
|
1825
|
+
} catch (_) { res.writeHead(200); res.end('null'); }
|
|
1826
|
+
return true;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// -------------------------------------------------------- GET /mastermind
|
|
1830
|
+
if (req.method === 'GET' && url === '/mastermind') {
|
|
1831
|
+
// Serve local file if present (dev), otherwise fall back to bundled HTML
|
|
1832
|
+
const root = ctx.projectDir || process.cwd();
|
|
1833
|
+
const htmlPath = path.join(root, 'docs', 'mastermind-diagram.html');
|
|
1834
|
+
let html = ctx.MASTERMIND_DIAGRAM_HTML;
|
|
1835
|
+
try { html = fs.readFileSync(htmlPath, 'utf8'); } catch (_) {}
|
|
1836
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1837
|
+
res.end(html);
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// ----------------------------------------------------------- GET /orgs
|
|
1842
|
+
if (req.method === 'GET' && url === '/orgs') {
|
|
1843
|
+
try {
|
|
1844
|
+
const htmlPath = path.join(ctx.__dirname, 'orgs.html');
|
|
1845
|
+
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
1846
|
+
// Inject this process's auth credential the same way GET / does for
|
|
1847
|
+
// dashboard.html — orgs.html's fetch() calls hit the same now
|
|
1848
|
+
// default-closed /api/* routes and need a way to know the token.
|
|
1849
|
+
html = html.replace('<head>', `<head>\n<meta name="mm-token" content="${ctx.dashboardAuthValue}">`);
|
|
1850
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
|
|
1851
|
+
res.end(html);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
res.writeHead(404);
|
|
1854
|
+
res.end(`orgs.html not found: ${err.message}`);
|
|
1855
|
+
}
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// ------------------------------------------------ GET /orgs-files.js
|
|
1860
|
+
// Files-tab + diff-view script, split out of orgs.html (see orgs.html's
|
|
1861
|
+
// script-src comment). Not a generic static handler — this UI serves each
|
|
1862
|
+
// sibling asset via its own hardcoded route, same as GET /orgs above.
|
|
1863
|
+
if (req.method === 'GET' && url === '/orgs-files.js') {
|
|
1864
|
+
try {
|
|
1865
|
+
const jsPath = path.join(ctx.__dirname, 'orgs-files.js');
|
|
1866
|
+
const js = fs.readFileSync(jsPath, 'utf8');
|
|
1867
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
|
1868
|
+
res.end(js);
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
res.writeHead(404);
|
|
1871
|
+
res.end(`orgs-files.js not found: ${err.message}`);
|
|
1872
|
+
}
|
|
1873
|
+
return true;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// GET /api/mastermind/loops — list all active loop state files
|
|
1877
|
+
if (req.method === 'GET' && url === '/api/mastermind/loops') {
|
|
1878
|
+
try {
|
|
1879
|
+
const loopsDir = path.join(ctx.projectDir || process.cwd(), '.monomind', 'loops');
|
|
1880
|
+
const loops = [];
|
|
1881
|
+
if (fs.existsSync(loopsDir)) {
|
|
1882
|
+
const files = fs.readdirSync(loopsDir).filter(f => f.endsWith('.json') && !f.includes('-hil'));
|
|
1883
|
+
for (const f of files) {
|
|
1884
|
+
try {
|
|
1885
|
+
const d = JSON.parse(fs.readFileSync(path.join(loopsDir, f), 'utf8'));
|
|
1886
|
+
loops.push(d);
|
|
1887
|
+
} catch(_) {}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
loops.sort((a, b) => (b.lastRunAt || 0) - (a.lastRunAt || 0));
|
|
1891
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1892
|
+
res.end(JSON.stringify({ loops }));
|
|
1893
|
+
} catch(_) { res.writeHead(500); res.end('{"loops":[]}'); }
|
|
1894
|
+
return true;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// GET /api/status — live system snapshot for dashboard polling
|
|
1898
|
+
if (req.method === 'GET' && url === '/api/status') {
|
|
1899
|
+
try {
|
|
1900
|
+
const root = ctx.projectDir || process.cwd();
|
|
1901
|
+
// Active org runs: { orgName -> runId }
|
|
1902
|
+
const orgRuns = {};
|
|
1903
|
+
ctx.activeOrgRuns.forEach((runId, org) => { orgRuns[org] = runId; });
|
|
1904
|
+
// Recent events (last 10)
|
|
1905
|
+
let recentEvents = [];
|
|
1906
|
+
try {
|
|
1907
|
+
const evPath = path.join(root, 'data', 'mastermind-events.jsonl');
|
|
1908
|
+
const lines = fs.readFileSync(evPath, 'utf8').split('\n').filter(l => l.trim()).slice(-10);
|
|
1909
|
+
recentEvents = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
|
|
1910
|
+
} catch(_) {}
|
|
1911
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1912
|
+
res.end(JSON.stringify({
|
|
1913
|
+
ts: Date.now(),
|
|
1914
|
+
pid: process.pid,
|
|
1915
|
+
uptime: process.uptime(),
|
|
1916
|
+
dir: root,
|
|
1917
|
+
sseClients: getMmClientCount(),
|
|
1918
|
+
activeOrgs: Object.keys(orgRuns).length,
|
|
1919
|
+
orgRuns,
|
|
1920
|
+
recentEvents,
|
|
1921
|
+
}));
|
|
1922
|
+
} catch(err) {
|
|
1923
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1924
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1925
|
+
}
|
|
1926
|
+
return true;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// GET /api/orgs/:name/runs/current — events from the active run file for an org
|
|
1930
|
+
if (req.method === 'GET' && /^\/api\/orgs\/[^/]+\/runs\/current$/.test(url)) {
|
|
1931
|
+
try {
|
|
1932
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1933
|
+
const _curQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1934
|
+
const root = path.resolve(_curQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1935
|
+
// Validate orgName
|
|
1936
|
+
if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
1937
|
+
res.writeHead(400); res.end('{"error":"invalid org name"}'); return true;
|
|
1938
|
+
}
|
|
1939
|
+
const runId = ctx.activeOrgRuns.get(orgName);
|
|
1940
|
+
const monoDir = ctx._getGitMonomindDir(root) || path.join(root, '.monomind');
|
|
1941
|
+
// Try active run first, then fall back to most recent run file
|
|
1942
|
+
let runFile = null;
|
|
1943
|
+
if (runId) {
|
|
1944
|
+
const candidate = path.join(monoDir, 'orgs', orgName, 'runs', `${runId}.jsonl`);
|
|
1945
|
+
if (fs.existsSync(candidate)) runFile = candidate;
|
|
1946
|
+
}
|
|
1947
|
+
if (!runFile) {
|
|
1948
|
+
const runsDir = path.join(monoDir, 'orgs', orgName, 'runs');
|
|
1949
|
+
if (fs.existsSync(runsDir)) {
|
|
1950
|
+
const files = fs.readdirSync(runsDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('._'));
|
|
1951
|
+
if (files.length) {
|
|
1952
|
+
files.sort();
|
|
1953
|
+
runFile = path.join(runsDir, files[files.length - 1]);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
if (!runFile) { res.writeHead(404); res.end('{"events":[],"runId":null}'); return true; }
|
|
1958
|
+
const detectedRunId = path.basename(runFile, '.jsonl');
|
|
1959
|
+
const lines = fs.readFileSync(runFile, 'utf8').split('\n').filter(l => l.trim()).slice(-100);
|
|
1960
|
+
const events = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
|
|
1961
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1962
|
+
res.end(JSON.stringify({ runId: detectedRunId, events, active: ctx.activeOrgRuns.has(orgName) }));
|
|
1963
|
+
} catch(err) {
|
|
1964
|
+
res.writeHead(500); res.end(JSON.stringify({ error: err.message }));
|
|
1965
|
+
}
|
|
1966
|
+
return true;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// GET /api/orgs/:name/history — per-run outcome summaries (history.jsonl, newest first)
|
|
1970
|
+
if (req.method === 'GET' && /^\/api\/orgs\/[^/]+\/history$/.test(url)) {
|
|
1971
|
+
try {
|
|
1972
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1973
|
+
const _histQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1974
|
+
const root = path.resolve(_histQs.get('dir') || ctx.projectDir || process.cwd());
|
|
1975
|
+
if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
1976
|
+
res.writeHead(400); res.end('{"error":"invalid org name"}'); return true;
|
|
1977
|
+
}
|
|
1978
|
+
const monoDir = ctx._getGitMonomindDir(root) || path.join(root, '.monomind');
|
|
1979
|
+
const histFile = path.join(monoDir, 'orgs', orgName, 'history.jsonl');
|
|
1980
|
+
let runs = [];
|
|
1981
|
+
if (fs.existsSync(histFile)) {
|
|
1982
|
+
runs = fs.readFileSync(histFile, 'utf8').split('\n').filter(l => l.trim())
|
|
1983
|
+
.map(l => { try { return JSON.parse(l); } catch(_) { return null; } })
|
|
1984
|
+
.filter(Boolean).reverse().slice(0, 50);
|
|
1985
|
+
}
|
|
1986
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
1987
|
+
res.end(JSON.stringify({ runs }));
|
|
1988
|
+
} catch(err) {
|
|
1989
|
+
res.writeHead(500); res.end(JSON.stringify({ error: err.message }));
|
|
1990
|
+
}
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// GET /api/orgs/:name/memory — org knowledge-graph stats + glossary + rules
|
|
1995
|
+
if (req.method === 'GET' && /^\/api\/orgs\/[^/]+\/memory$/.test(url)) {
|
|
1996
|
+
try {
|
|
1997
|
+
const orgName = decodeURIComponent(url.split('/')[3]);
|
|
1998
|
+
const _memQs = new URL(req.url, 'http://localhost').searchParams;
|
|
1999
|
+
const root = path.resolve(_memQs.get('dir') || ctx.projectDir || process.cwd());
|
|
2000
|
+
if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
|
|
2001
|
+
res.writeHead(400); res.end('{"error":"invalid org name"}'); return true;
|
|
2002
|
+
}
|
|
2003
|
+
const monoDir = ctx._getGitMonomindDir(root) || path.join(root, '.monomind');
|
|
2004
|
+
const dbPath = path.join(monoDir, 'org-memory');
|
|
2005
|
+
if (!fs.existsSync(dbPath)) {
|
|
2006
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2007
|
+
res.end(JSON.stringify({ nodes: 0, edges: 0, rules: 0, glossary: [], ruleTexts: [] }));
|
|
2008
|
+
return true;
|
|
2009
|
+
}
|
|
2010
|
+
const kg = await import('../memory/memory-kg.js');
|
|
2011
|
+
const [stats, glossary, ruleList] = await Promise.all([
|
|
2012
|
+
kg.kgStats({ dbPath }), kg.kgGlossary({ dbPath, limit: 12 }), kg.kgListRules({ dbPath, limit: 10 }),
|
|
2013
|
+
]);
|
|
2014
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2015
|
+
res.end(JSON.stringify({ ...stats, glossary, ruleTexts: ruleList.map(r => r.rule.slice(0, 200)) }));
|
|
2016
|
+
} catch(err) {
|
|
2017
|
+
res.writeHead(500); res.end(JSON.stringify({ error: err.message }));
|
|
2018
|
+
}
|
|
2019
|
+
return true;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// POST /api/knowledge/search — warm semantic knowledge search for hooks.
|
|
2023
|
+
// The dashboard server is the one long-lived process on the machine, so it
|
|
2024
|
+
// holds the local embedding model warm; per-prompt hook subprocesses (which
|
|
2025
|
+
// cannot afford a model load) POST here and fall back to keyword scoring
|
|
2026
|
+
// when this endpoint is cold/absent. Fully local — model + store on disk.
|
|
2027
|
+
if (req.method === 'POST' && url === '/api/knowledge/search') {
|
|
2028
|
+
let body = '';
|
|
2029
|
+
let overLimit = false;
|
|
2030
|
+
req.on('data', c => {
|
|
2031
|
+
if (overLimit) return;
|
|
2032
|
+
body += c;
|
|
2033
|
+
if (body.length > 64 * 1024) { // queries are prompts, not documents
|
|
2034
|
+
overLimit = true;
|
|
2035
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
2036
|
+
res.end('{"error":"request body too large"}');
|
|
2037
|
+
req.destroy();
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
req.on('end', async () => {
|
|
2041
|
+
if (overLimit) return;
|
|
2042
|
+
try {
|
|
2043
|
+
const payload = JSON.parse(body || '{}');
|
|
2044
|
+
const query = String(payload.query || '').slice(0, 2000);
|
|
2045
|
+
const limit = Math.min(Math.max(parseInt(payload.limit, 10) || 3, 1), 10);
|
|
2046
|
+
const namespace = typeof payload.namespace === 'string' && /^[A-Za-z0-9:_-]{1,128}$/.test(payload.namespace)
|
|
2047
|
+
? payload.namespace : 'knowledge:shared';
|
|
2048
|
+
if (!query) {
|
|
2049
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2050
|
+
res.end('{"error":"query required"}');
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
const bridge = await ctx._getKnowledgeBridge();
|
|
2054
|
+
if (!bridge) {
|
|
2055
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
2056
|
+
res.end('{"error":"knowledge bridge unavailable"}');
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
// scope: project | global | all (default all) — project results get a
|
|
2060
|
+
// small tie boost; global hits are flagged so callers can show origin.
|
|
2061
|
+
const scope = payload.scope === 'project' || payload.scope === 'global' ? payload.scope : 'all';
|
|
2062
|
+
// Rule-based router (no LLM): chunks always run (this endpoint's
|
|
2063
|
+
// bread and butter); the auxiliary surfaces — distilled rules,
|
|
2064
|
+
// knowledge-graph triplets, pattern memories — only spend a query
|
|
2065
|
+
// when the router votes for them or routing is unconfident.
|
|
2066
|
+
let wantRules = true, wantKg = true, wantMemory = true;
|
|
2067
|
+
let rrfFuse = null;
|
|
2068
|
+
try {
|
|
2069
|
+
const routerMod = await import('../memory/query-router.js');
|
|
2070
|
+
const route = routerMod.routeQuery(query);
|
|
2071
|
+
rrfFuse = routerMod.rrfFuse;
|
|
2072
|
+
wantRules = !route.confident || route.surfaces.includes('rules');
|
|
2073
|
+
wantKg = !route.confident || route.surfaces.includes('kg');
|
|
2074
|
+
wantMemory = !route.confident || route.surfaces.includes('memory');
|
|
2075
|
+
} catch { /* router unavailable — keep all surfaces on */ }
|
|
2076
|
+
let kgSearch = null;
|
|
2077
|
+
if (wantKg && scope !== 'global') {
|
|
2078
|
+
try { kgSearch = (await import('../memory/memory-kg.js')).kgSearch; } catch { /* kg unavailable */ }
|
|
2079
|
+
}
|
|
2080
|
+
// Superseded-version filtering — the SAME rule `searchKnowledge` (and
|
|
2081
|
+
// therefore `monomind doc search` / `knowledge_search`) applies. This
|
|
2082
|
+
// endpoint queries the bridge directly for warmth, so without this it
|
|
2083
|
+
// would inject old document versions the Second Brain itself hides.
|
|
2084
|
+
let liveProj = new Set(), liveGlob = new Set(), isSuperseded = () => false, overfetch = (n) => n;
|
|
2085
|
+
// metaProj/metaGlob distinguish "no metadata log" (cannot judge, keep
|
|
2086
|
+
// everything) from "log exists and is empty" (every document was
|
|
2087
|
+
// removed, keep nothing). Without them, `doc remove` of the last
|
|
2088
|
+
// document left its chunks being injected into every prompt.
|
|
2089
|
+
let metaProj = false, metaGlob = false;
|
|
2090
|
+
try {
|
|
2091
|
+
const dp = await import('../knowledge/document-pipeline.js');
|
|
2092
|
+
// CLAUDE_PROJECT_DIR is whatever directory the user opened, which
|
|
2093
|
+
// can be a package subdirectory, while the CLI writes the metadata
|
|
2094
|
+
// log at the project root. Walk up to the nearest one that has it:
|
|
2095
|
+
// resolving the wrong root yields an empty live set, which silently
|
|
2096
|
+
// turns filtering OFF — and superseded rows are the large majority
|
|
2097
|
+
// of a long-lived store, so injection would be dominated by stale
|
|
2098
|
+
// document versions.
|
|
2099
|
+
const resolveKnowledgeRoot = (start) => {
|
|
2100
|
+
let probe = path.resolve(start);
|
|
2101
|
+
for (let up = 0; up < 8; up++) {
|
|
2102
|
+
if (dp.hasKnowledgeMetadata?.(probe)) return probe;
|
|
2103
|
+
const parent = path.dirname(probe);
|
|
2104
|
+
if (parent === probe) break;
|
|
2105
|
+
probe = parent;
|
|
2106
|
+
}
|
|
2107
|
+
return path.resolve(start);
|
|
2108
|
+
};
|
|
2109
|
+
const projRoot = resolveKnowledgeRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
2110
|
+
const globRoot = process.env.MONOMIND_GLOBAL_BRAIN_DIR
|
|
2111
|
+
|| path.join(os.homedir(), '.monomind', 'global-brain');
|
|
2112
|
+
liveProj = dp.liveContentHashes(projRoot);
|
|
2113
|
+
liveGlob = dp.liveContentHashes(globRoot);
|
|
2114
|
+
metaProj = dp.hasKnowledgeMetadata?.(projRoot) ?? liveProj.size > 0;
|
|
2115
|
+
metaGlob = dp.hasKnowledgeMetadata?.(globRoot) ?? liveGlob.size > 0;
|
|
2116
|
+
isSuperseded = dp.isSupersededKey;
|
|
2117
|
+
overfetch = dp.supersededOverfetchLimit;
|
|
2118
|
+
} catch { /* pipeline unavailable — no filtering, same as before */ }
|
|
2119
|
+
const keepLive = (rows, live, hasMeta) => (rows || []).filter(r => !isSuperseded(String(r.key || ''), live, hasMeta)).slice(0, limit);
|
|
2120
|
+
const [projRaw, globRaw, rules, graph, mems] = await Promise.all([
|
|
2121
|
+
scope !== 'global' ? bridge.bridgeSearchEntries({ query, namespace, limit: overfetch(limit, liveProj) }).catch(() => null) : null,
|
|
2122
|
+
scope !== 'project' ? bridge.bridgeSearchEntries({ query, namespace: 'knowledge:global', limit: overfetch(limit, liveGlob), dbPath: '@global' }).catch(() => null) : null,
|
|
2123
|
+
// Distilled rules ("when X do Y") learned from sessions/runs —
|
|
2124
|
+
// small namespace, high injection value, threshold keeps it quiet.
|
|
2125
|
+
scope !== 'global' && wantRules ? bridge.bridgeSearchEntries({ query, namespace: 'rules', limit: 2, threshold: 0.45 }).catch(() => null) : null,
|
|
2126
|
+
// Knowledge-graph triplets: relationship-shaped queries surface
|
|
2127
|
+
// facts no chunk contains. Triplet scores derive from seeded
|
|
2128
|
+
// cosine matches (≥0.35), so they clear callers' relevance floors.
|
|
2129
|
+
scope !== 'global' && kgSearch ? kgSearch({ query, limit: 4 }).catch(() => null) : null,
|
|
2130
|
+
// Past patterns/decisions for "last time / previously" queries.
|
|
2131
|
+
scope !== 'global' && wantMemory ? bridge.bridgeSearchEntries({ query, namespace: 'patterns', limit: 2, threshold: 0.4 }).catch(() => null) : null,
|
|
2132
|
+
]);
|
|
2133
|
+
const lists = [
|
|
2134
|
+
keepLive(projRaw?.results, liveProj, metaProj).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, tags: r.tags, importance: 0.6 })),
|
|
2135
|
+
keepLive(globRaw?.results, liveGlob, metaGlob).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: true, tags: r.tags })),
|
|
2136
|
+
(rules?.results || []).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score + 0.05, global: false, rule: true, tags: r.tags, importance: 0.7 })),
|
|
2137
|
+
(graph?.triplets || []).map((t, i) => ({
|
|
2138
|
+
id: 'kg:' + i + ':' + t.source + '|' + t.relation + '|' + t.target,
|
|
2139
|
+
key: 'kg:' + t.source,
|
|
2140
|
+
content: String(t.source + ' —' + t.relation + '→ ' + t.target + (t.fact && t.fact !== (t.source + ' ' + t.relation + ' ' + t.target) ? ' (' + String(t.fact).slice(0, 300) + ')' : '')).slice(0, 2000),
|
|
2141
|
+
score: t.score, global: false, triplet: true,
|
|
2142
|
+
})),
|
|
2143
|
+
(mems?.results || []).map(r => ({ id: r.id, key: r.key, content: String(r.content || '').slice(0, 2000), score: r.score, global: false, memory: true, tags: r.tags })),
|
|
2144
|
+
];
|
|
2145
|
+
// Rank-fuse across surfaces (raw scores aren't comparable between
|
|
2146
|
+
// cosine chunks and blended triplets); each item keeps its native
|
|
2147
|
+
// score so downstream relevance floors still apply. Fallback: flat
|
|
2148
|
+
// score sort when the router module failed to load.
|
|
2149
|
+
const merged = (rrfFuse
|
|
2150
|
+
? rrfFuse(lists, limit)
|
|
2151
|
+
: lists.flat().sort((a, b) => b.score - a.score).slice(0, limit)
|
|
2152
|
+
).map(({ importance, rrf, ...r }) => r);
|
|
2153
|
+
// Served excerpts are (very likely) injected into the caller's prompt —
|
|
2154
|
+
// that IS usage. Reinforce frequency_weight, per-store, fire-and-forget.
|
|
2155
|
+
try {
|
|
2156
|
+
const projIds = merged.filter(m => !m.global && !m.triplet && m.id).map(m => m.id);
|
|
2157
|
+
const globIds = merged.filter(m => m.global && m.id).map(m => m.id);
|
|
2158
|
+
if (projIds.length) bridge.bridgeRecordUsage?.({ entryIds: projIds }).catch(() => {});
|
|
2159
|
+
if (globIds.length) bridge.bridgeRecordUsage?.({ entryIds: globIds, dbPath: '@global' }).catch(() => {});
|
|
2160
|
+
} catch { /* best effort */ }
|
|
2161
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2162
|
+
res.end(JSON.stringify({
|
|
2163
|
+
method: projRaw?.searchMethod || globRaw?.searchMethod || rules?.searchMethod || mems?.searchMethod
|
|
2164
|
+
|| (merged.length ? 'mixed' : 'none'),
|
|
2165
|
+
results: merged,
|
|
2166
|
+
}));
|
|
2167
|
+
} catch (err) {
|
|
2168
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2169
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// GET /api/mastermind/metrics — aggregate system metrics from token-summary and swarm-activity
|
|
2176
|
+
if (req.method === 'GET' && url === '/api/mastermind/metrics') {
|
|
2177
|
+
try {
|
|
2178
|
+
const base = path.join(ctx.projectDir || process.cwd(), '.monomind', 'metrics');
|
|
2179
|
+
let tokens = {}, swarm = {}, events = [];
|
|
2180
|
+
try { tokens = JSON.parse(fs.readFileSync(path.join(base, 'token-summary.json'), 'utf8')); } catch(_) {}
|
|
2181
|
+
try { swarm = JSON.parse(fs.readFileSync(path.join(base, 'swarm-activity.json'), 'utf8')); } catch(_) {}
|
|
2182
|
+
try {
|
|
2183
|
+
const evPath = path.join(ctx.projectDir || process.cwd(), 'data', 'mastermind-events.jsonl');
|
|
2184
|
+
const lines = fs.readFileSync(evPath, 'utf8').split('\n').filter(l => l.trim()).slice(-20);
|
|
2185
|
+
events = lines.map(l => { try { return JSON.parse(l); } catch(_) { return null; } }).filter(Boolean);
|
|
2186
|
+
} catch(_) {}
|
|
2187
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2188
|
+
res.end(JSON.stringify({ tokens, swarm, recentEvents: events }));
|
|
2189
|
+
} catch(_) { res.writeHead(500); res.end('{"tokens":{},"swarm":{},"recentEvents":[]}'); }
|
|
2190
|
+
return true;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// ------------------------------------------------- GET /api/playbooks
|
|
2194
|
+
// List playbook definitions from <dir>/.monomind/playbooks/*.json.
|
|
2195
|
+
// (Previously only POST was registered, so GET /api/playbooks?dir=... fell
|
|
2196
|
+
// through to the 404 handler.)
|
|
2197
|
+
if (req.method === 'GET' && url === '/api/playbooks') {
|
|
2198
|
+
try {
|
|
2199
|
+
const qp = new URL(req.url, 'http://localhost').searchParams;
|
|
2200
|
+
const dir = qp.get('dir') || ctx.projectDir || process.cwd();
|
|
2201
|
+
const playbookDir = path.join(path.resolve(dir), '.monomind', 'playbooks');
|
|
2202
|
+
const result = [];
|
|
2203
|
+
if (fs.existsSync(playbookDir)) {
|
|
2204
|
+
const files = fs.readdirSync(playbookDir).filter(f => f.endsWith('.json') && !f.startsWith('._'));
|
|
2205
|
+
for (const file of files) {
|
|
2206
|
+
try {
|
|
2207
|
+
const fpath = path.join(playbookDir, file);
|
|
2208
|
+
const def = JSON.parse(fs.readFileSync(fpath, 'utf8'));
|
|
2209
|
+
const stat = fs.statSync(fpath);
|
|
2210
|
+
result.push({ ...def, id: def.id || file.replace('.json', ''), file, modifiedAt: stat.mtimeMs });
|
|
2211
|
+
} catch (_) {}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
2215
|
+
res.end(JSON.stringify(result));
|
|
2216
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
2217
|
+
return true;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// ------------------------------------------------- POST /api/playbooks
|
|
2221
|
+
// Save a playbook definition to .monomind/playbooks/<id>.json
|
|
2222
|
+
if (req.method === 'POST' && url === '/api/playbooks') {
|
|
2223
|
+
try {
|
|
2224
|
+
let body = '';
|
|
2225
|
+
await new Promise((resolve, reject) => {
|
|
2226
|
+
req.on('data', d => { body += d; });
|
|
2227
|
+
req.on('end', resolve);
|
|
2228
|
+
req.on('error', reject);
|
|
2229
|
+
});
|
|
2230
|
+
const pb = JSON.parse(body);
|
|
2231
|
+
if (!pb.id || !pb.name) {
|
|
2232
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2233
|
+
res.end(JSON.stringify({ error: 'id and name are required' }));
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
const dir = ctx.projectDir || process.cwd();
|
|
2237
|
+
const playbookDir = path.join(dir, '.monomind', 'playbooks');
|
|
2238
|
+
fs.mkdirSync(playbookDir, { recursive: true });
|
|
2239
|
+
const filePath = path.join(playbookDir, pb.id + '.json');
|
|
2240
|
+
fs.writeFileSync(filePath, JSON.stringify(pb, null, 2));
|
|
2241
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2242
|
+
res.end(JSON.stringify({ ok: true, id: pb.id, file: pb.id + '.json' }));
|
|
2243
|
+
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
|
|
2244
|
+
return true;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// ------------------------------------------------- GET /api/workflow-defs
|
|
2248
|
+
if (req.method === 'GET' && url === '/api/workflow-defs') {
|
|
2249
|
+
try {
|
|
2250
|
+
const qp = new URL(req.url, 'http://x').searchParams;
|
|
2251
|
+
const dir = qp.get('dir') || ctx.projectDir || process.cwd();
|
|
2252
|
+
const playbookDir = path.join(dir, '.monomind', 'playbooks');
|
|
2253
|
+
const result = [];
|
|
2254
|
+
if (fs.existsSync(playbookDir)) {
|
|
2255
|
+
const files = fs.readdirSync(playbookDir).filter(f => f.endsWith('.json'));
|
|
2256
|
+
for (const file of files) {
|
|
2257
|
+
try {
|
|
2258
|
+
const fpath = path.join(playbookDir, file);
|
|
2259
|
+
const stat = fs.statSync(fpath);
|
|
2260
|
+
const def = JSON.parse(fs.readFileSync(fpath, 'utf8'));
|
|
2261
|
+
const params = (def.params || []).map(p => typeof p === 'string' ? p : (p.name || p.key || ''));
|
|
2262
|
+
result.push({
|
|
2263
|
+
id: def.id || file.replace('.json', ''),
|
|
2264
|
+
name: def.name || file.replace('.json', ''),
|
|
2265
|
+
description: def.description || null,
|
|
2266
|
+
file,
|
|
2267
|
+
nodeCount: Array.isArray(def.nodes) ? def.nodes.length : 0,
|
|
2268
|
+
params,
|
|
2269
|
+
modifiedAt: stat.mtimeMs,
|
|
2270
|
+
});
|
|
2271
|
+
} catch (_) {}
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2275
|
+
res.end(JSON.stringify(result));
|
|
2276
|
+
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// ------------------------------------------------- GET /api/org-coverage
|
|
2281
|
+
// Audit coverage report written by the audit loop to .monomind/audit/coverage.json
|
|
2282
|
+
if (req.method === 'GET' && url === '/api/org-coverage') {
|
|
2283
|
+
try {
|
|
2284
|
+
const qp = new URL(req.url, 'http://localhost').searchParams;
|
|
2285
|
+
const dir = path.resolve(qp.get('dir') || ctx.projectDir || process.cwd());
|
|
2286
|
+
const covPath = path.join(dir, '.monomind', 'audit', 'coverage.json');
|
|
2287
|
+
if (!fs.existsSync(covPath)) {
|
|
2288
|
+
res.writeHead(404, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2289
|
+
res.end('{}');
|
|
2290
|
+
return true;
|
|
2291
|
+
}
|
|
2292
|
+
const coverage = JSON.parse(fs.readFileSync(covPath, 'utf8'));
|
|
2293
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
|
|
2294
|
+
res.end(JSON.stringify(coverage));
|
|
2295
|
+
} catch (e) {
|
|
2296
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2297
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2298
|
+
}
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// ------------------------------------------------- GET /api/workflow-runs
|
|
2303
|
+
if (req.method === 'GET' && url === '/api/workflow-runs') {
|
|
2304
|
+
// Reads from ~/.monomind/browse-runs.json written by the monobrowse dashboard server.
|
|
2305
|
+
try {
|
|
2306
|
+
const runsFile = path.join(os.homedir(), '.monomind', 'browse-runs.json');
|
|
2307
|
+
if (fs.existsSync(runsFile)) {
|
|
2308
|
+
const raw = fs.readFileSync(runsFile, 'utf-8');
|
|
2309
|
+
const runs = JSON.parse(raw);
|
|
2310
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2311
|
+
res.end(JSON.stringify(Array.isArray(runs) ? runs : []));
|
|
2312
|
+
} else {
|
|
2313
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2314
|
+
res.end('[]');
|
|
2315
|
+
}
|
|
2316
|
+
} catch {
|
|
2317
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2318
|
+
res.end('[]');
|
|
2319
|
+
}
|
|
2320
|
+
return true;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// ---- POST /api/orgs/:name/mark-complete — manual STALE recovery ----
|
|
2324
|
+
if (req.method === 'POST' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/mark-complete$/i.test(url)) {
|
|
2325
|
+
const _mcOrgName = decodeURIComponent(url.split('/')[3]);
|
|
2326
|
+
if (_mcOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_mcOrgName)) {
|
|
2327
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid org name' })); return true;
|
|
2328
|
+
}
|
|
2329
|
+
const _mcRoot = ctx.projectDir || process.cwd();
|
|
2330
|
+
const _mcMonoDir = ctx._getGitMonomindDir(_mcRoot) || path.join(_mcRoot, '.monomind');
|
|
2331
|
+
const _mcRunId = ctx.activeOrgRuns.get(_mcOrgName) || ctx._getActiveRunId(_mcOrgName, _mcRoot);
|
|
2332
|
+
if (!_mcRunId) {
|
|
2333
|
+
res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No active run for org: ' + _mcOrgName })); return true;
|
|
2334
|
+
}
|
|
2335
|
+
const _mcEvent = { type: 'run:complete', org: _mcOrgName, runId: _mcRunId, ts: Date.now(), reason: 'manual' };
|
|
2336
|
+
try {
|
|
2337
|
+
const _mcRunFile = path.join(_mcMonoDir, 'orgs', _mcOrgName, 'runs', `${_mcRunId}.jsonl`);
|
|
2338
|
+
if (fs.existsSync(_mcRunFile)) await ctx.appendToFile(_mcRunFile, JSON.stringify(_mcEvent) + '\n');
|
|
2339
|
+
ctx.activeOrgRuns.delete(_mcOrgName);
|
|
2340
|
+
// Clean up ppid-keyed active-run files for this org
|
|
2341
|
+
const _mcCapDir = path.join(ctx.MONOMIND_HOME, '.monomind', 'capture');
|
|
2342
|
+
try {
|
|
2343
|
+
const _mcPpidDir = path.join(_mcCapDir, 'active-runs');
|
|
2344
|
+
if (fs.existsSync(_mcPpidDir)) {
|
|
2345
|
+
fs.readdirSync(_mcPpidDir).filter(f => f.endsWith('.json')).forEach(_pf => {
|
|
2346
|
+
try { const _pd = JSON.parse(fs.readFileSync(path.join(_mcPpidDir, _pf), 'utf8')); if (_pd.org === _mcOrgName) fs.unlinkSync(path.join(_mcPpidDir, _pf)); } catch (_) {}
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
const _mcActiveFile = path.join(_mcCapDir, 'active-run.json');
|
|
2350
|
+
if (fs.existsSync(_mcActiveFile)) {
|
|
2351
|
+
try { const _a = JSON.parse(fs.readFileSync(_mcActiveFile, 'utf8')); if (_a.org === _mcOrgName) fs.unlinkSync(_mcActiveFile); } catch (_) {}
|
|
2352
|
+
}
|
|
2353
|
+
} catch (_) {}
|
|
2354
|
+
ctx._updateRunState(_mcEvent, _mcRoot);
|
|
2355
|
+
ctx.broadcastMm(_mcEvent);
|
|
2356
|
+
const _mcFwdClients = ctx.runStreamClients.get(_mcOrgName);
|
|
2357
|
+
if (_mcFwdClients && _mcFwdClients.size > 0) {
|
|
2358
|
+
const _mcLine = `data: ${JSON.stringify(_mcEvent)}\n\n`;
|
|
2359
|
+
for (const _cl of _mcFwdClients) { try { _cl.write(_mcLine); } catch (_) { _mcFwdClients.delete(_cl); } }
|
|
2360
|
+
}
|
|
2361
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2362
|
+
res.end(JSON.stringify({ ok: true, runId: _mcRunId }));
|
|
2363
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
2364
|
+
return true;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// ---- GET /api/orgs/:name/runs/current/stream — Phase 3 streaming tail ----
|
|
2368
|
+
if (req.method === 'GET' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/runs\/current\/stream$/i.test(url)) {
|
|
2369
|
+
const _stOrgName = decodeURIComponent(url.split('/')[3]);
|
|
2370
|
+
if (_stOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_stOrgName)) {
|
|
2371
|
+
res.writeHead(400); res.end('Invalid org name'); return true;
|
|
2372
|
+
}
|
|
2373
|
+
const _stQs = new URL(req.url, 'http://localhost').searchParams;
|
|
2374
|
+
const _stSince = Math.max(0, parseInt(_stQs.get('since') || '0', 10) || 0);
|
|
2375
|
+
res.writeHead(200, {
|
|
2376
|
+
'Content-Type': 'text/event-stream',
|
|
2377
|
+
'Cache-Control': 'no-cache',
|
|
2378
|
+
'Connection': 'keep-alive',
|
|
2379
|
+
...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}),
|
|
2380
|
+
'X-Accel-Buffering': 'no',
|
|
2381
|
+
});
|
|
2382
|
+
res.write(': connected\n\n');
|
|
2383
|
+
// Register client for live events
|
|
2384
|
+
if (!ctx.runStreamClients.has(_stOrgName)) ctx.runStreamClients.set(_stOrgName, new Set());
|
|
2385
|
+
ctx.runStreamClients.get(_stOrgName).add(res);
|
|
2386
|
+
// Replay events since `since` (SQLite row id cursor; falls back to JSONL line offset)
|
|
2387
|
+
try {
|
|
2388
|
+
let _stReplayedViaSqlite = false;
|
|
2389
|
+
if (ctx._runDb) {
|
|
2390
|
+
// SQLite path: cursor is last row id seen (client sends 0 on first connect)
|
|
2391
|
+
const _stStmt = ctx._runDb.prepare(
|
|
2392
|
+
'SELECT id, raw FROM run_events WHERE org=? AND id > ? ORDER BY id LIMIT 2000'
|
|
2393
|
+
);
|
|
2394
|
+
_stStmt.bind([_stOrgName, _stSince]);
|
|
2395
|
+
let _stLastId = _stSince;
|
|
2396
|
+
while (_stStmt.step()) {
|
|
2397
|
+
const _stRow = _stStmt.getAsObject();
|
|
2398
|
+
try { res.write(`data: ${_stRow.raw}\n\n`); _stLastId = _stRow.id; } catch (_) { break; }
|
|
2399
|
+
}
|
|
2400
|
+
_stStmt.free();
|
|
2401
|
+
if (_stLastId > _stSince) {
|
|
2402
|
+
// SQLite had rows — send cursor and skip JSONL fallback
|
|
2403
|
+
res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: _stLastId })}\n\n`);
|
|
2404
|
+
_stReplayedViaSqlite = true;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
if (!_stReplayedViaSqlite) {
|
|
2408
|
+
// JSONL fallback: SQLite absent or returned 0 rows — read directly from run file.
|
|
2409
|
+
// `since` is a 0-based line offset in this path.
|
|
2410
|
+
const _stRoot = ctx.projectDir || process.cwd();
|
|
2411
|
+
const _stRunId = ctx.activeOrgRuns.get(_stOrgName) || ctx._getActiveRunId(_stOrgName, _stRoot);
|
|
2412
|
+
if (_stRunId) {
|
|
2413
|
+
const _stMono = ctx._getGitMonomindDir(_stRoot) || path.join(_stRoot, '.monomind');
|
|
2414
|
+
const _stRunFile = path.join(_stMono, 'orgs', _stOrgName, 'runs', `${_stRunId}.jsonl`);
|
|
2415
|
+
if (fs.existsSync(_stRunFile)) {
|
|
2416
|
+
const _stLines = fs.readFileSync(_stRunFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
2417
|
+
for (let _i = _stSince; _i < _stLines.length; _i++) {
|
|
2418
|
+
try { res.write(`data: ${_stLines[_i]}\n\n`); } catch (_) { break; }
|
|
2419
|
+
}
|
|
2420
|
+
res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: _stLines.length })}\n\n`);
|
|
2421
|
+
} else {
|
|
2422
|
+
res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: 0 })}\n\n`);
|
|
2423
|
+
}
|
|
2424
|
+
} else {
|
|
2425
|
+
res.write(`data: ${JSON.stringify({ type: 'stream:replay-done', count: 0 })}\n\n`);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
} catch (_) {}
|
|
2429
|
+
const _stKa = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { clearInterval(_stKa); } }, 20000);
|
|
2430
|
+
req.on('close', () => {
|
|
2431
|
+
clearInterval(_stKa);
|
|
2432
|
+
const _stClients = ctx.runStreamClients.get(_stOrgName);
|
|
2433
|
+
if (_stClients) { _stClients.delete(res); if (_stClients.size === 0) ctx.runStreamClients.delete(_stOrgName); }
|
|
2434
|
+
});
|
|
2435
|
+
return true;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// ---- POST /api/orgs/:name/chat — user sends a message to a running org ----
|
|
2439
|
+
if (req.method === 'POST' && /^\/api\/orgs\/[a-z0-9][a-z0-9_-]{0,63}\/chat$/i.test(url)) {
|
|
2440
|
+
let _chBody = '';
|
|
2441
|
+
for await (const chunk of req) { _chBody += chunk; if (_chBody.length > 65536) { req.destroy(); break; } }
|
|
2442
|
+
try {
|
|
2443
|
+
const _chOrgName = decodeURIComponent(url.split('/')[3]);
|
|
2444
|
+
if (_chOrgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(_chOrgName)) {
|
|
2445
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid org name' })); return true;
|
|
2446
|
+
}
|
|
2447
|
+
let _chPayload = {};
|
|
2448
|
+
try { _chPayload = JSON.parse(_chBody); } catch(_) {}
|
|
2449
|
+
const _chText = String(_chPayload.text || '').trim().slice(0, 4096);
|
|
2450
|
+
if (!_chText) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'text is required' })); return true; }
|
|
2451
|
+
const _chQs = new URL(req.url, 'http://localhost').searchParams;
|
|
2452
|
+
const _chServerRoot = path.resolve(_chQs.get('dir') || ctx.projectDir || process.cwd());
|
|
2453
|
+
const _chRoot = ctx._resolveOrgProjectDir(_chOrgName, _chServerRoot) || _chServerRoot;
|
|
2454
|
+
const _chMonoDir = ctx._getGitMonomindDir(_chRoot) || path.join(_chRoot, '.monomind');
|
|
2455
|
+
const _chRunId = ctx.activeOrgRuns.get(_chOrgName) || ctx._getActiveRunId(_chOrgName, _chRoot);
|
|
2456
|
+
const _chEvent = { type: 'user:message', org: _chOrgName, runId: _chRunId || null, text: _chText, ts: Date.now() };
|
|
2457
|
+
// Write to run JSONL if active run exists
|
|
2458
|
+
if (_chRunId) {
|
|
2459
|
+
const _chRunFile = path.join(_chMonoDir, 'orgs', _chOrgName, 'runs', `${_chRunId}.jsonl`);
|
|
2460
|
+
if (fs.existsSync(_chRunFile)) await ctx.appendToFile(_chRunFile, JSON.stringify(_chEvent) + '\n');
|
|
2461
|
+
}
|
|
2462
|
+
// Write to mailbox file so boss agent can pick up on next cycle (durable
|
|
2463
|
+
// record + offline fallback — the live-delivery attempt below is best-effort)
|
|
2464
|
+
const _chMailbox = path.join(_chRoot, '.monomind', 'orgs', `${_chOrgName}-threads.json`);
|
|
2465
|
+
try {
|
|
2466
|
+
let _chThreads = { messages: [] };
|
|
2467
|
+
if (fs.existsSync(_chMailbox)) { try { _chThreads = JSON.parse(fs.readFileSync(_chMailbox, 'utf8')); } catch(_) {} }
|
|
2468
|
+
if (!Array.isArray(_chThreads.messages)) _chThreads.messages = [];
|
|
2469
|
+
_chThreads.messages.push({ text: _chText, ts: _chEvent.ts, status: 'pending' });
|
|
2470
|
+
fs.writeFileSync(_chMailbox, JSON.stringify(_chThreads, null, 2));
|
|
2471
|
+
} catch(_) {}
|
|
2472
|
+
// Broadcast to SSE stream clients
|
|
2473
|
+
ctx.broadcastMm(_chEvent);
|
|
2474
|
+
const _chFwdClients = ctx.runStreamClients.get(_chOrgName);
|
|
2475
|
+
if (_chFwdClients && _chFwdClients.size > 0) {
|
|
2476
|
+
const _chLine = `data: ${JSON.stringify(_chEvent)}\n\n`;
|
|
2477
|
+
for (const _cl of _chFwdClients) { try { _cl.write(_chLine); } catch(_) { _chFwdClients.delete(_cl); } }
|
|
2478
|
+
}
|
|
2479
|
+
// Forward to the org's live process (if any) so the message actually lands in a
|
|
2480
|
+
// running role's mailbox — looked up via the same file-based broker registry
|
|
2481
|
+
// orgrt's cross-process delivery already uses (mirrors POST /api/questions/answer).
|
|
2482
|
+
let _chDelivered = false;
|
|
2483
|
+
try {
|
|
2484
|
+
const _chBrokerEntryPath = path.join(os.homedir(), '.monomind', 'orgrt-broker', `${_chOrgName}.json`);
|
|
2485
|
+
const _chBrokerEntry = JSON.parse(fs.readFileSync(_chBrokerEntryPath, 'utf8'));
|
|
2486
|
+
if (Date.now() - _chBrokerEntry.updatedAt < 90000 && _chBrokerEntry.url) {
|
|
2487
|
+
let _chTargetRole = _chPayload.role;
|
|
2488
|
+
if (!_chTargetRole) {
|
|
2489
|
+
try {
|
|
2490
|
+
const _chCfg = JSON.parse(fs.readFileSync(path.join(_chRoot, '.monomind', 'orgs', `${_chOrgName}.json`), 'utf8'));
|
|
2491
|
+
const _chRoles = Array.isArray(_chCfg.roles) ? _chCfg.roles : [];
|
|
2492
|
+
const _chBoss = _chRoles.find(r => r.type === 'boss' || r.reports_to === null) || _chRoles[0];
|
|
2493
|
+
_chTargetRole = _chBoss?.id;
|
|
2494
|
+
} catch(_) {}
|
|
2495
|
+
}
|
|
2496
|
+
if (_chTargetRole) {
|
|
2497
|
+
const _chFwd = await fetch(`${_chBrokerEntry.url}/api/human-message`, {
|
|
2498
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2499
|
+
body: JSON.stringify({ org: _chOrgName, role: _chTargetRole, text: _chText }),
|
|
2500
|
+
signal: AbortSignal.timeout(5000),
|
|
2501
|
+
});
|
|
2502
|
+
const _chFwdData = await _chFwd.json().catch(() => ({}));
|
|
2503
|
+
_chDelivered = !!(_chFwd.ok && _chFwdData.ok);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
} catch(_) {}
|
|
2507
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
|
|
2508
|
+
res.end(JSON.stringify({ ok: true, delivered: _chDelivered }));
|
|
2509
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); }
|
|
2510
|
+
return true;
|
|
2511
|
+
}
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|