@minasoft/mina-ai-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/apps/cli/src/index.js +574 -0
- package/dist/apps/http-server/src/index.js +755 -0
- package/dist/apps/mcp-server/src/index.js +308 -0
- package/dist/packages/core/src/file-state.js +35 -0
- package/dist/packages/core/src/ids.js +8 -0
- package/dist/packages/core/src/index.js +24 -0
- package/dist/packages/core/src/prompt-envelope.js +27 -0
- package/dist/packages/core/src/registry.js +34 -0
- package/dist/packages/core/src/request-store.js +50 -0
- package/dist/packages/core/src/response-parser.js +33 -0
- package/dist/packages/core/src/router.js +100 -0
- package/dist/packages/core/src/types.js +2 -0
- package/dist/packages/mcp/src/provider.js +177 -0
- package/dist/packages/transports/src/headless/headless-transport.js +31 -0
- package/dist/packages/transports/src/index.js +22 -0
- package/dist/packages/transports/src/tmux/tmux-client.js +126 -0
- package/dist/packages/transports/src/tmux/tmux-transport.js +54 -0
- package/dist/packages/transports/src/transport-registry.js +20 -0
- package/dist/packages/transports/src/zmux/zmux-client.js +18 -0
- package/dist/packages/transports/src/zmux/zmux-transport.js +36 -0
- package/docs/DEVELOPER-START-GUIDE.md +111 -0
- package/docs/GETTING-STARTED.md +32 -0
- package/docs/HTTP-UI-MCP.md +142 -0
- package/docs/MCP-CLIENT-SETUP.md +71 -0
- package/docs/SKILL-INSTALL-GUIDE.md +96 -0
- package/docs/TROUBLESHOOTING.md +75 -0
- package/docs/USER-START-GUIDE.md +187 -0
- package/docs/assets/mair-agent-details.jpg +0 -0
- package/docs/assets/mair-live-flow.jpg +0 -0
- package/docs/assets/mair-terminal-preview.jpg +0 -0
- package/package.json +47 -0
- package/skills/mina-ai-router-agent/SKILL.md +64 -0
- package/skills/mina-ai-router-agent/agents/openai.yaml +4 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const node_http_1 = require("node:http");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const node_url_1 = require("node:url");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const src_1 = require("../../../packages/core/src");
|
|
10
|
+
const provider_1 = require("../../../packages/mcp/src/provider");
|
|
11
|
+
const src_2 = require("../../../packages/transports/src");
|
|
12
|
+
const importEsm = new Function("specifier", "return import(specifier)");
|
|
13
|
+
const runtimeModuleUrl = (0, node_url_1.pathToFileURL)((0, node_path_1.join)(__dirname, "../../../../node_modules/@minasoft/mcp-runtime/dist/index.js")).href;
|
|
14
|
+
const port = Number(process.env.PORT ?? process.env.MINA_HTTP_PORT ?? 3333);
|
|
15
|
+
const host = process.env.HOST ?? process.env.MINA_HTTP_HOST ?? "127.0.0.1";
|
|
16
|
+
const statePath = process.env.MINA_ROUTER_STATE ?? (0, node_path_1.join)(process.cwd(), "data", "router-state.json");
|
|
17
|
+
const context = createContext();
|
|
18
|
+
let mcpHandlerPromise;
|
|
19
|
+
function createContext() {
|
|
20
|
+
const fileState = new src_1.FileState(statePath);
|
|
21
|
+
const state = fileState.load();
|
|
22
|
+
const registry = new src_1.AgentRegistry(state.agents);
|
|
23
|
+
const requestStore = new src_1.RequestStore(state.requests);
|
|
24
|
+
const transports = new src_2.DefaultTransportRegistry()
|
|
25
|
+
.register("mock", new src_2.HeadlessTransport())
|
|
26
|
+
.register("headless", new src_2.HeadlessTransport())
|
|
27
|
+
.register("tmux", new src_2.TmuxTransport())
|
|
28
|
+
.register("zmux", new src_2.ZmuxTransport());
|
|
29
|
+
const baseContext = {
|
|
30
|
+
fileState,
|
|
31
|
+
registry,
|
|
32
|
+
requestStore,
|
|
33
|
+
save: () => fileState.save({
|
|
34
|
+
agents: registry.list(),
|
|
35
|
+
requests: requestStore.list(),
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
const router = new src_1.AgentRouter({
|
|
39
|
+
registry,
|
|
40
|
+
requestStore,
|
|
41
|
+
transports,
|
|
42
|
+
onStateChanged: baseContext.save,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
...baseContext,
|
|
46
|
+
transports,
|
|
47
|
+
router,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const server = (0, node_http_1.createServer)((request, response) => {
|
|
51
|
+
handleRequest(request, response).catch((error) => {
|
|
52
|
+
sendJson(response, 500, {
|
|
53
|
+
error: error instanceof Error ? error.message : String(error),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
server.listen(port, host, () => {
|
|
58
|
+
console.log(`Mina AI Router HTTP server`);
|
|
59
|
+
console.log(`UI: http://${host}:${port}/`);
|
|
60
|
+
console.log(`MCP: http://${host}:${port}/mcp`);
|
|
61
|
+
console.log(`State: ${statePath}`);
|
|
62
|
+
});
|
|
63
|
+
async function handleRequest(request, response) {
|
|
64
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
|
65
|
+
if (request.method === "OPTIONS") {
|
|
66
|
+
sendOptions(response);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (url.pathname === "/" && request.method === "GET") {
|
|
70
|
+
sendHtml(response, renderAppHtml());
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === "/mcp" && request.method === "POST") {
|
|
74
|
+
await handleMcp(request, response, url);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (url.pathname === "/api/state" && request.method === "GET") {
|
|
78
|
+
sendJson(response, 200, await getUiState());
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (url.pathname === "/api/health" && request.method === "GET") {
|
|
82
|
+
sendJson(response, 200, await getHealth());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (url.pathname === "/api/fs/directories" && request.method === "POST") {
|
|
86
|
+
const body = await readJsonBody(request);
|
|
87
|
+
sendJson(response, 200, listDirectories(body));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (url.pathname === "/api/register" && request.method === "POST") {
|
|
91
|
+
const body = await readJsonBody(request);
|
|
92
|
+
const agent = registerAgent(body);
|
|
93
|
+
sendJson(response, 200, { agent, state: await getUiState() });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const agentDeleteMatch = url.pathname.match(/^\/api\/agents\/([^/]+)$/);
|
|
97
|
+
if (agentDeleteMatch && request.method === "PATCH") {
|
|
98
|
+
const body = await readJsonBody(request);
|
|
99
|
+
const agent = updateAgent(decodeURIComponent(agentDeleteMatch[1]), body);
|
|
100
|
+
sendJson(response, 200, { agent, state: await getUiState() });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (agentDeleteMatch && request.method === "DELETE") {
|
|
104
|
+
const agent = context.registry.unregister(decodeURIComponent(agentDeleteMatch[1]));
|
|
105
|
+
context.save();
|
|
106
|
+
sendJson(response, 200, { agent, state: await getUiState() });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const agentRestartMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/restart$/);
|
|
110
|
+
if (agentRestartMatch && request.method === "POST") {
|
|
111
|
+
const agent = restartAgent(decodeURIComponent(agentRestartMatch[1]));
|
|
112
|
+
sendJson(response, 200, { agent, state: await getUiState() });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const agentTerminalMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/terminal$/);
|
|
116
|
+
if (agentTerminalMatch && request.method === "GET") {
|
|
117
|
+
sendJson(response, 200, captureAgentTerminal(decodeURIComponent(agentTerminalMatch[1])));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const agentTerminalInputMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/terminal\/input$/);
|
|
121
|
+
if (agentTerminalInputMatch && request.method === "POST") {
|
|
122
|
+
const body = await readJsonBody(request);
|
|
123
|
+
sendJson(response, 200, sendAgentTerminalInput(decodeURIComponent(agentTerminalInputMatch[1]), body));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname === "/api/agents/create-tmux" && request.method === "POST") {
|
|
127
|
+
const body = await readJsonBody(request);
|
|
128
|
+
const result = createTmuxAgent(body);
|
|
129
|
+
sendJson(response, 200, { ...result, state: await getUiState() });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (url.pathname === "/api/ask" && request.method === "POST") {
|
|
133
|
+
const body = await readJsonBody(request);
|
|
134
|
+
const target = typeof body.target === "string" ? body.target : "";
|
|
135
|
+
const task = typeof body.task === "string" ? body.task : "";
|
|
136
|
+
const timeoutMs = typeof body.timeoutMs === "number" ? body.timeoutMs : 300000;
|
|
137
|
+
if (!target || !task) {
|
|
138
|
+
sendJson(response, 400, { error: "target and task are required" });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const result = await context.router.callAgent({ target, task, timeoutMs });
|
|
143
|
+
context.save();
|
|
144
|
+
sendJson(response, 200, { result, state: await getUiState() });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
context.save();
|
|
148
|
+
sendJson(response, 500, {
|
|
149
|
+
error: error instanceof Error ? error.message : String(error),
|
|
150
|
+
state: await getUiState(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const requestActionMatch = url.pathname.match(/^\/api\/requests\/([^/]+)\/(retry|cancel|archive)$/);
|
|
156
|
+
if (requestActionMatch && request.method === "POST") {
|
|
157
|
+
const requestId = decodeURIComponent(requestActionMatch[1]);
|
|
158
|
+
const action = requestActionMatch[2];
|
|
159
|
+
const result = await handleRequestAction(requestId, action);
|
|
160
|
+
sendJson(response, 200, { result, state: await getUiState() });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (url.pathname === "/api/requests/archive-stale" && request.method === "POST") {
|
|
164
|
+
const body = await readJsonBody(request);
|
|
165
|
+
const olderThanMs = typeof body.olderThanMs === "number" ? body.olderThanMs : 30 * 60 * 1000;
|
|
166
|
+
const archived = archiveStaleRequests(olderThanMs);
|
|
167
|
+
sendJson(response, 200, { archived, state: await getUiState() });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname === "/api/setup-codex-pair" && request.method === "POST") {
|
|
171
|
+
const body = await readJsonBody(request);
|
|
172
|
+
const result = setupCodexPair(body);
|
|
173
|
+
sendJson(response, 200, result);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
sendJson(response, 404, { error: "Not found" });
|
|
177
|
+
}
|
|
178
|
+
function archiveStaleRequests(olderThanMs) {
|
|
179
|
+
const staleStatuses = new Set(["created", "sent", "waiting"]);
|
|
180
|
+
const cutoff = Date.now() - olderThanMs;
|
|
181
|
+
const archived = [];
|
|
182
|
+
for (const request of context.requestStore.list()) {
|
|
183
|
+
if (!staleStatuses.has(request.status)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const updatedAt = Date.parse(request.updatedAt);
|
|
187
|
+
if (Number.isFinite(updatedAt) && updatedAt > cutoff) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
archived.push(context.requestStore.updateStatus(request.id, "archived", {
|
|
191
|
+
error: request.error ?? "Archived as stale by operator.",
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
if (archived.length > 0) {
|
|
195
|
+
context.save();
|
|
196
|
+
}
|
|
197
|
+
return archived;
|
|
198
|
+
}
|
|
199
|
+
async function handleRequestAction(requestId, action) {
|
|
200
|
+
const request = context.requestStore.require(requestId);
|
|
201
|
+
if (action === "retry") {
|
|
202
|
+
return context.router.callAgent({
|
|
203
|
+
sourceAgent: request.sourceAgent,
|
|
204
|
+
target: request.targetAgent,
|
|
205
|
+
task: request.task,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (action === "cancel") {
|
|
209
|
+
const updated = context.requestStore.updateStatus(requestId, "cancelled", {
|
|
210
|
+
error: "Cancelled by operator from Mina AI Router UI.",
|
|
211
|
+
});
|
|
212
|
+
context.save();
|
|
213
|
+
return updated;
|
|
214
|
+
}
|
|
215
|
+
if (action === "archive") {
|
|
216
|
+
const updated = context.requestStore.updateStatus(requestId, "archived");
|
|
217
|
+
context.save();
|
|
218
|
+
return updated;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`Unsupported request action "${action}".`);
|
|
221
|
+
}
|
|
222
|
+
async function handleMcp(request, response, url) {
|
|
223
|
+
const handler = await getMcpHandler();
|
|
224
|
+
const body = await readRawBody(request);
|
|
225
|
+
const headers = new Headers();
|
|
226
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
headers.set(key, value.join(", "));
|
|
229
|
+
}
|
|
230
|
+
else if (value !== undefined) {
|
|
231
|
+
headers.set(key, value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const fetchRequest = new Request(url.href, {
|
|
235
|
+
method: request.method,
|
|
236
|
+
headers,
|
|
237
|
+
body,
|
|
238
|
+
});
|
|
239
|
+
const fetchResponse = await handler(fetchRequest);
|
|
240
|
+
response.statusCode = fetchResponse.status;
|
|
241
|
+
fetchResponse.headers.forEach((value, key) => {
|
|
242
|
+
response.setHeader(key, value);
|
|
243
|
+
});
|
|
244
|
+
response.end(Buffer.from(await fetchResponse.arrayBuffer()));
|
|
245
|
+
}
|
|
246
|
+
async function getMcpHandler() {
|
|
247
|
+
if (mcpHandlerPromise) {
|
|
248
|
+
return mcpHandlerPromise;
|
|
249
|
+
}
|
|
250
|
+
mcpHandlerPromise = importEsm(runtimeModuleUrl).then((runtime) => {
|
|
251
|
+
const provider = (0, provider_1.createMinaMcpProvider)(context);
|
|
252
|
+
return runtime.createMcpFetchHandler(provider, {
|
|
253
|
+
cors: {
|
|
254
|
+
allowedOrigins: ["*"],
|
|
255
|
+
},
|
|
256
|
+
async context(request) {
|
|
257
|
+
return {
|
|
258
|
+
requestId: request.headers.get("x-request-id") ?? undefined,
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
return mcpHandlerPromise;
|
|
264
|
+
}
|
|
265
|
+
async function getUiState() {
|
|
266
|
+
hydratePendingCapabilityNotices();
|
|
267
|
+
return {
|
|
268
|
+
statePath,
|
|
269
|
+
mcpUrl: `http://${host}:${port}/mcp`,
|
|
270
|
+
agents: await context.router.listAgentStatuses(),
|
|
271
|
+
requests: context.router.listRequests(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
async function getHealth() {
|
|
275
|
+
const agents = await context.router.listAgentStatuses();
|
|
276
|
+
const requests = context.router.listRequests();
|
|
277
|
+
const openRequests = requests.filter((request) => ["created", "sent", "waiting"].includes(request.status));
|
|
278
|
+
return {
|
|
279
|
+
ok: agents.every((agent) => agent.status !== "missing"),
|
|
280
|
+
statePath,
|
|
281
|
+
mcpUrl: `http://${host}:${port}/mcp`,
|
|
282
|
+
agents: {
|
|
283
|
+
total: agents.length,
|
|
284
|
+
available: agents.filter((agent) => agent.status === "available").length,
|
|
285
|
+
busy: agents.filter((agent) => agent.status === "busy").length,
|
|
286
|
+
missing: agents.filter((agent) => agent.status === "missing").length,
|
|
287
|
+
unknown: agents.filter((agent) => agent.status === "unknown").length,
|
|
288
|
+
},
|
|
289
|
+
requests: {
|
|
290
|
+
total: requests.length,
|
|
291
|
+
open: openRequests.length,
|
|
292
|
+
waiting: requests.filter((request) => request.status === "waiting").length,
|
|
293
|
+
answered: requests.filter((request) => request.status === "answered").length,
|
|
294
|
+
failed: requests.filter((request) => ["failed", "timeout", "cancelled"].includes(request.status)).length,
|
|
295
|
+
archived: requests.filter((request) => request.status === "archived").length,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function registerAgent(body) {
|
|
300
|
+
const id = requiredString(body.id, "id");
|
|
301
|
+
const projectRoot = stringValue(body.projectRoot) ?? process.cwd();
|
|
302
|
+
const capabilityNotice = inferCapabilityNotice(projectRoot);
|
|
303
|
+
const agent = {
|
|
304
|
+
id,
|
|
305
|
+
name: stringValue(body.name) ?? id,
|
|
306
|
+
agentType: stringValue(body.agentType) ?? "codex",
|
|
307
|
+
transport: (stringValue(body.transport) ?? "tmux"),
|
|
308
|
+
sessionId: stringValue(body.sessionId) ?? id,
|
|
309
|
+
projectRoot,
|
|
310
|
+
tmuxTarget: stringValue(body.tmuxTarget),
|
|
311
|
+
startupCommand: stringValue(body.startupCommand),
|
|
312
|
+
capabilitySummary: stringValue(body.capabilitySummary) ?? capabilityNotice.summary,
|
|
313
|
+
capabilitySources: stringValue(body.capabilitySources) ?? capabilityNotice.sources,
|
|
314
|
+
};
|
|
315
|
+
context.registry.register(agent);
|
|
316
|
+
context.save();
|
|
317
|
+
return agent;
|
|
318
|
+
}
|
|
319
|
+
function updateAgent(id, body) {
|
|
320
|
+
const current = context.registry.require(id);
|
|
321
|
+
const next = {
|
|
322
|
+
...current,
|
|
323
|
+
name: stringValue(body.name) ?? current.name,
|
|
324
|
+
capabilitySummary: stringFieldValue(body, "capabilitySummary") ?? current.capabilitySummary,
|
|
325
|
+
capabilitySources: stringFieldValue(body, "capabilitySources") ?? current.capabilitySources,
|
|
326
|
+
};
|
|
327
|
+
context.registry.register(next);
|
|
328
|
+
context.save();
|
|
329
|
+
return next;
|
|
330
|
+
}
|
|
331
|
+
function listDirectories(body) {
|
|
332
|
+
const home = process.env.HOME ?? process.cwd();
|
|
333
|
+
const requestedPath = stringValue(body.path) ?? home;
|
|
334
|
+
const directoryPath = (0, node_path_1.resolve)(requestedPath);
|
|
335
|
+
if (!(0, node_fs_1.existsSync)(directoryPath)) {
|
|
336
|
+
throw new Error(`Directory does not exist: ${directoryPath}`);
|
|
337
|
+
}
|
|
338
|
+
if (!(0, node_fs_1.statSync)(directoryPath).isDirectory()) {
|
|
339
|
+
throw new Error(`Path is not a directory: ${directoryPath}`);
|
|
340
|
+
}
|
|
341
|
+
const showHidden = body.showHidden === true;
|
|
342
|
+
const entries = (0, node_fs_1.readdirSync)(directoryPath, { withFileTypes: true })
|
|
343
|
+
.filter((entry) => entry.isDirectory())
|
|
344
|
+
.filter((entry) => showHidden || !entry.name.startsWith("."))
|
|
345
|
+
.map((entry) => ({
|
|
346
|
+
name: entry.name,
|
|
347
|
+
path: (0, node_path_1.join)(directoryPath, entry.name),
|
|
348
|
+
}))
|
|
349
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
350
|
+
return {
|
|
351
|
+
path: directoryPath,
|
|
352
|
+
parent: (0, node_path_1.dirname)(directoryPath),
|
|
353
|
+
home,
|
|
354
|
+
entries,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function restartAgent(id) {
|
|
358
|
+
const agent = context.registry.require(id);
|
|
359
|
+
if (agent.transport !== "tmux") {
|
|
360
|
+
throw new Error(`Agent "${id}" uses transport "${agent.transport}", not tmux.`);
|
|
361
|
+
}
|
|
362
|
+
const tmux = new src_2.TmuxClient();
|
|
363
|
+
tmux.killSession(agent.sessionId);
|
|
364
|
+
tmux.ensureSession(agent);
|
|
365
|
+
context.save();
|
|
366
|
+
return agent;
|
|
367
|
+
}
|
|
368
|
+
function captureAgentTerminal(id) {
|
|
369
|
+
const agent = context.registry.require(id);
|
|
370
|
+
if (agent.transport !== "tmux") {
|
|
371
|
+
throw new Error(`Agent "${id}" uses transport "${agent.transport}", not tmux.`);
|
|
372
|
+
}
|
|
373
|
+
const tmux = new src_2.TmuxClient({ captureLines: 120 });
|
|
374
|
+
const text = tmux.capture(agent.tmuxTarget ?? agent.sessionId);
|
|
375
|
+
return {
|
|
376
|
+
agent,
|
|
377
|
+
terminal: {
|
|
378
|
+
text,
|
|
379
|
+
capturedAt: new Date().toISOString(),
|
|
380
|
+
trustPrompt: agent.agentType === "codex" && isCodexTrustPrompt(text),
|
|
381
|
+
pendingRegistration: isPendingUiRegistration(agent),
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function sendAgentTerminalInput(id, body) {
|
|
386
|
+
const agent = context.registry.require(id);
|
|
387
|
+
if (agent.transport !== "tmux") {
|
|
388
|
+
throw new Error(`Agent "${id}" uses transport "${agent.transport}", not tmux.`);
|
|
389
|
+
}
|
|
390
|
+
const target = agent.tmuxTarget ?? agent.sessionId;
|
|
391
|
+
const text = stringValue(body.text);
|
|
392
|
+
const enter = body.enter === true;
|
|
393
|
+
const tmux = new src_2.TmuxClient({ captureLines: 120 });
|
|
394
|
+
if (text) {
|
|
395
|
+
if (agent.agentType === "codex") {
|
|
396
|
+
tmux.sendCodexText(target, text);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
tmux.sendText(target, text);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else if (enter) {
|
|
403
|
+
tmux.sendEnter(target);
|
|
404
|
+
}
|
|
405
|
+
let registration = "unchanged";
|
|
406
|
+
if (enter && !text && isPendingUiRegistration(agent)) {
|
|
407
|
+
sleep(1200);
|
|
408
|
+
const capture = tmux.capture(target);
|
|
409
|
+
if (!(agent.agentType === "codex" && isCodexTrustPrompt(capture))) {
|
|
410
|
+
if (agent.agentType === "codex") {
|
|
411
|
+
tmux.sendCodexText(target, buildSelfRegistrationPrompt(agent));
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
tmux.sendText(target, buildSelfRegistrationPrompt(agent));
|
|
415
|
+
}
|
|
416
|
+
registration = "registration prompt sent to agent";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
registration,
|
|
422
|
+
...captureAgentTerminal(id),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function createTmuxAgent(body) {
|
|
426
|
+
const agentType = stringValue(body.agentType) ?? "codex";
|
|
427
|
+
if (agentType !== "codex" && agentType !== "claude") {
|
|
428
|
+
throw new Error("agentType must be codex or claude.");
|
|
429
|
+
}
|
|
430
|
+
const projectRoot = requiredString(body.projectRoot, "projectRoot");
|
|
431
|
+
if (!(0, node_fs_1.existsSync)(projectRoot)) {
|
|
432
|
+
throw new Error(`Project root does not exist: ${projectRoot}`);
|
|
433
|
+
}
|
|
434
|
+
const projectName = stringValue(body.name) ?? (0, node_path_1.basename)(projectRoot);
|
|
435
|
+
const id = stringValue(body.id) ?? sanitizeName(projectName);
|
|
436
|
+
const sessionId = stringValue(body.sessionId) ?? `${agentType}-${sanitizeName(projectName)}`;
|
|
437
|
+
const startupCommand = stringValue(body.startupCommand)
|
|
438
|
+
?? (agentType === "codex" ? "codex --no-alt-screen" : "claude");
|
|
439
|
+
const command = startupCommand.split(/\s+/)[0];
|
|
440
|
+
assertCommandAvailable("tmux");
|
|
441
|
+
assertCommandAvailable(command);
|
|
442
|
+
const agent = {
|
|
443
|
+
id,
|
|
444
|
+
name: id,
|
|
445
|
+
agentType,
|
|
446
|
+
transport: "tmux",
|
|
447
|
+
sessionId,
|
|
448
|
+
projectRoot,
|
|
449
|
+
startupCommand,
|
|
450
|
+
...uiCreatedCapabilityNotice(projectRoot),
|
|
451
|
+
};
|
|
452
|
+
const tmux = new src_2.TmuxClient();
|
|
453
|
+
const existed = tmux.hasSession(sessionId);
|
|
454
|
+
tmux.ensureSession(agent);
|
|
455
|
+
context.registry.register(agent);
|
|
456
|
+
context.save();
|
|
457
|
+
const sendRegistrationPrompt = body.sendRegistrationPrompt !== false;
|
|
458
|
+
let registration = "registration prompt skipped";
|
|
459
|
+
let nextAction;
|
|
460
|
+
if (sendRegistrationPrompt) {
|
|
461
|
+
const delayMs = typeof body.registerDelayMs === "number" ? body.registerDelayMs : 4000;
|
|
462
|
+
sleep(delayMs);
|
|
463
|
+
const capture = tmux.capture(sessionId);
|
|
464
|
+
if (agentType === "codex" && isCodexTrustPrompt(capture)) {
|
|
465
|
+
registration = "waiting for Codex directory trust approval";
|
|
466
|
+
nextAction = `Attach with "tmux attach -t ${sessionId}", approve the Codex trust prompt, then ask the agent to register this session with Mina AI Router.`;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
const prompt = buildSelfRegistrationPrompt(agent);
|
|
470
|
+
if (agentType === "codex") {
|
|
471
|
+
tmux.sendCodexText(sessionId, prompt);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
tmux.sendText(sessionId, prompt);
|
|
475
|
+
}
|
|
476
|
+
registration = "registration prompt sent to agent";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
agent,
|
|
481
|
+
existed,
|
|
482
|
+
registration,
|
|
483
|
+
nextAction,
|
|
484
|
+
attachCommand: `tmux attach -t ${sessionId}`,
|
|
485
|
+
mairAttachCommand: `mair attach ${id}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function setupCodexPair(body) {
|
|
489
|
+
const mainRoot = stringValue(body.mainRoot) ?? "/Users/stevenna/WebstormProjects/minasoftai";
|
|
490
|
+
const helperRoot = stringValue(body.helperRoot) ?? "/Users/stevenna/PycharmProjects/mina-ralph-loop-bootstrap-nextjs";
|
|
491
|
+
const helperId = stringValue(body.helperId) ?? "ralph";
|
|
492
|
+
const sessionId = stringValue(body.sessionId) ?? "mina-ralph-codex";
|
|
493
|
+
const agent = {
|
|
494
|
+
id: helperId,
|
|
495
|
+
name: helperId,
|
|
496
|
+
agentType: "codex",
|
|
497
|
+
transport: "tmux",
|
|
498
|
+
sessionId,
|
|
499
|
+
projectRoot: helperRoot,
|
|
500
|
+
startupCommand: stringValue(body.startupCommand) ?? "codex --no-alt-screen",
|
|
501
|
+
};
|
|
502
|
+
new src_2.TmuxClient().ensureSession(agent);
|
|
503
|
+
context.registry.register(agent);
|
|
504
|
+
context.save();
|
|
505
|
+
return {
|
|
506
|
+
statePath,
|
|
507
|
+
mainRoot,
|
|
508
|
+
helper: agent,
|
|
509
|
+
mcpUrl: `http://${host}:${port}/mcp`,
|
|
510
|
+
codexMcpAdd: `codex mcp add mina-ai-router --url http://${host}:${port}/mcp`,
|
|
511
|
+
startMainCodex: `cd ${mainRoot} && codex --no-alt-screen`,
|
|
512
|
+
attachHelper: `tmux attach -t ${sessionId}`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function buildSelfRegistrationPrompt(agent) {
|
|
516
|
+
return [
|
|
517
|
+
"Use Mina AI Router MCP register_agent to register this visible CLI session.",
|
|
518
|
+
"Collect any missing context yourself when possible, but use these values as authoritative:",
|
|
519
|
+
`- id: ${agent.id}`,
|
|
520
|
+
`- name: ${agent.name}`,
|
|
521
|
+
`- agentType: ${agent.agentType}`,
|
|
522
|
+
`- transport: ${agent.transport}`,
|
|
523
|
+
`- sessionId: ${agent.sessionId}`,
|
|
524
|
+
`- projectRoot: ${agent.projectRoot}`,
|
|
525
|
+
`- startupCommand: ${agent.startupCommand ?? ""}`,
|
|
526
|
+
"",
|
|
527
|
+
"Before registering, create a concise capability notice for this session:",
|
|
528
|
+
"- Prefer capability docs in this order when present: CLAUDE.md/claude.md, AGENTS.md/agents.md, agent.md, README.md.",
|
|
529
|
+
"- If those files are missing, inspect package metadata and the project file tree to infer what this project/agent can help with.",
|
|
530
|
+
"- Set register_agent capabilitySummary to 2-5 short bullets or one short paragraph under 800 characters.",
|
|
531
|
+
"- Set register_agent capabilitySources to a comma-separated list of the files or project signals you used.",
|
|
532
|
+
"After registering, call list_agents and confirm this agent is available.",
|
|
533
|
+
].join("\n");
|
|
534
|
+
}
|
|
535
|
+
function hydratePendingCapabilityNotices() {
|
|
536
|
+
let changed = false;
|
|
537
|
+
for (const agent of context.registry.list()) {
|
|
538
|
+
if (agent.capabilitySummary === "Pending self-registration capability notice."
|
|
539
|
+
&& agent.capabilitySources === "created from Mina UI") {
|
|
540
|
+
const notice = uiCreatedCapabilityNotice(agent.projectRoot);
|
|
541
|
+
context.registry.register({
|
|
542
|
+
...agent,
|
|
543
|
+
capabilitySummary: notice.capabilitySummary,
|
|
544
|
+
capabilitySources: notice.capabilitySources,
|
|
545
|
+
});
|
|
546
|
+
changed = true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (changed) {
|
|
550
|
+
context.save();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function uiCreatedCapabilityNotice(projectRoot) {
|
|
554
|
+
const notice = inferCapabilityNotice(projectRoot);
|
|
555
|
+
return {
|
|
556
|
+
capabilitySummary: notice.summary,
|
|
557
|
+
capabilitySources: `created from Mina UI; ${notice.sources}`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function inferCapabilityNotice(projectRoot) {
|
|
561
|
+
const root = (0, node_path_1.resolve)(projectRoot);
|
|
562
|
+
const docCandidates = [
|
|
563
|
+
"CLAUDE.md",
|
|
564
|
+
"claude.md",
|
|
565
|
+
"AGENTS.md",
|
|
566
|
+
"agents.md",
|
|
567
|
+
"agent.md",
|
|
568
|
+
"README.md",
|
|
569
|
+
];
|
|
570
|
+
for (const candidate of docCandidates) {
|
|
571
|
+
const filePath = (0, node_path_1.join)(root, candidate);
|
|
572
|
+
if (!(0, node_fs_1.existsSync)(filePath) || (0, node_fs_1.statSync)(filePath).isDirectory()) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const summary = summarizeMarkdown(readSmallText(filePath), (0, node_path_1.basename)(root));
|
|
576
|
+
if (summary) {
|
|
577
|
+
return {
|
|
578
|
+
summary,
|
|
579
|
+
sources: `inferred from ${candidate}`,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const packagePath = (0, node_path_1.join)(root, "package.json");
|
|
584
|
+
if ((0, node_fs_1.existsSync)(packagePath) && !(0, node_fs_1.statSync)(packagePath).isDirectory()) {
|
|
585
|
+
try {
|
|
586
|
+
const packageJson = JSON.parse(readSmallText(packagePath));
|
|
587
|
+
const name = typeof packageJson.name === "string" ? packageJson.name : (0, node_path_1.basename)(root);
|
|
588
|
+
const description = typeof packageJson.description === "string" && packageJson.description.trim()
|
|
589
|
+
? packageJson.description.trim()
|
|
590
|
+
: "JavaScript/TypeScript project";
|
|
591
|
+
const scripts = packageJson.scripts && typeof packageJson.scripts === "object"
|
|
592
|
+
? Object.keys(packageJson.scripts).slice(0, 5)
|
|
593
|
+
: [];
|
|
594
|
+
const deps = {
|
|
595
|
+
...objectValue(packageJson.dependencies),
|
|
596
|
+
...objectValue(packageJson.devDependencies),
|
|
597
|
+
};
|
|
598
|
+
const frameworks = Object.keys(deps)
|
|
599
|
+
.filter((dependency) => ["next", "react", "vue", "svelte", "express", "fastify", "nestjs", "vite", "typescript"].includes(dependency))
|
|
600
|
+
.slice(0, 5);
|
|
601
|
+
return {
|
|
602
|
+
summary: [
|
|
603
|
+
`${name}: ${description}.`,
|
|
604
|
+
frameworks.length ? `Stack signals: ${frameworks.join(", ")}.` : "",
|
|
605
|
+
scripts.length ? `Useful scripts: ${scripts.join(", ")}.` : "",
|
|
606
|
+
].filter(Boolean).join(" "),
|
|
607
|
+
sources: "inferred from package.json",
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return {
|
|
612
|
+
summary: `${(0, node_path_1.basename)(root)} appears to be a JavaScript/TypeScript project. Use project files to answer implementation, test, and architecture questions.`,
|
|
613
|
+
sources: "inferred from package.json",
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const pyprojectPath = (0, node_path_1.join)(root, "pyproject.toml");
|
|
618
|
+
if ((0, node_fs_1.existsSync)(pyprojectPath) && !(0, node_fs_1.statSync)(pyprojectPath).isDirectory()) {
|
|
619
|
+
const text = readSmallText(pyprojectPath);
|
|
620
|
+
const name = text.match(/^name\s*=\s*"([^"]+)"/m)?.[1] ?? (0, node_path_1.basename)(root);
|
|
621
|
+
const description = text.match(/^description\s*=\s*"([^"]+)"/m)?.[1] ?? "Python project";
|
|
622
|
+
return {
|
|
623
|
+
summary: `${name}: ${description}. Use this agent for Python implementation, tests, CLI/runtime questions, and project-specific debugging.`,
|
|
624
|
+
sources: "inferred from pyproject.toml",
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const entries = safeDirectoryEntries(root)
|
|
628
|
+
.filter((entry) => !entry.startsWith("."))
|
|
629
|
+
.slice(0, 8);
|
|
630
|
+
return {
|
|
631
|
+
summary: `${(0, node_path_1.basename)(root)} project agent. Use it for questions about this repository's files, structure, implementation details, and local test workflow.${entries.length ? ` Top-level entries: ${entries.join(", ")}.` : ""}`,
|
|
632
|
+
sources: "inferred from project directory",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function summarizeMarkdown(text, fallbackName) {
|
|
636
|
+
const lines = text
|
|
637
|
+
.split(/\r?\n/)
|
|
638
|
+
.map((line) => line.replace(/^#+\s*/, "").replace(/^[-*]\s+/, "").trim())
|
|
639
|
+
.filter((line) => line && !line.startsWith("```") && !line.startsWith("|"));
|
|
640
|
+
const useful = lines
|
|
641
|
+
.filter((line) => !/^#{1,6}\s*$/.test(line))
|
|
642
|
+
.slice(0, 5)
|
|
643
|
+
.join(" ");
|
|
644
|
+
return truncateText(useful || `${fallbackName} project agent. Use it for project-specific implementation, debugging, testing, and architecture questions.`, 800);
|
|
645
|
+
}
|
|
646
|
+
function readSmallText(filePath) {
|
|
647
|
+
return (0, node_fs_1.readFileSync)(filePath, "utf8").slice(0, 12000);
|
|
648
|
+
}
|
|
649
|
+
function safeDirectoryEntries(directoryPath) {
|
|
650
|
+
try {
|
|
651
|
+
return (0, node_fs_1.readdirSync)(directoryPath).sort((left, right) => left.localeCompare(right));
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function objectValue(value) {
|
|
658
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
659
|
+
? value
|
|
660
|
+
: {};
|
|
661
|
+
}
|
|
662
|
+
function truncateText(value, maxLength) {
|
|
663
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
664
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1).trim()}...` : normalized;
|
|
665
|
+
}
|
|
666
|
+
function isCodexTrustPrompt(capture) {
|
|
667
|
+
return capture.includes("Do you trust the contents of this directory?")
|
|
668
|
+
|| capture.includes("Press enter to continue")
|
|
669
|
+
|| capture.includes("Yes, continue");
|
|
670
|
+
}
|
|
671
|
+
function isPendingUiRegistration(agent) {
|
|
672
|
+
return agent.capabilitySources?.startsWith("created from Mina UI") ?? false;
|
|
673
|
+
}
|
|
674
|
+
function assertCommandAvailable(command) {
|
|
675
|
+
try {
|
|
676
|
+
(0, node_child_process_1.execFileSync)("which", [command], {
|
|
677
|
+
encoding: "utf8",
|
|
678
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
throw new Error(`Required command "${command}" is not available on PATH.`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function sanitizeName(value) {
|
|
686
|
+
return value
|
|
687
|
+
.trim()
|
|
688
|
+
.toLowerCase()
|
|
689
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
690
|
+
.replace(/^-+|-+$/g, "") || "agent";
|
|
691
|
+
}
|
|
692
|
+
function sleep(milliseconds) {
|
|
693
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
697
|
+
}
|
|
698
|
+
function requiredString(value, field) {
|
|
699
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
700
|
+
throw new Error(`${field} is required`);
|
|
701
|
+
}
|
|
702
|
+
return value.trim();
|
|
703
|
+
}
|
|
704
|
+
function stringValue(value) {
|
|
705
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
706
|
+
}
|
|
707
|
+
function stringFieldValue(body, field) {
|
|
708
|
+
return Object.prototype.hasOwnProperty.call(body, field) && typeof body[field] === "string"
|
|
709
|
+
? body[field].trim()
|
|
710
|
+
: undefined;
|
|
711
|
+
}
|
|
712
|
+
async function readJsonBody(request) {
|
|
713
|
+
const raw = await readRawBody(request);
|
|
714
|
+
if (!raw.trim()) {
|
|
715
|
+
return {};
|
|
716
|
+
}
|
|
717
|
+
return JSON.parse(raw);
|
|
718
|
+
}
|
|
719
|
+
function readRawBody(request) {
|
|
720
|
+
return new Promise((resolve, reject) => {
|
|
721
|
+
const chunks = [];
|
|
722
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
723
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
724
|
+
request.on("error", reject);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
function sendOptions(response) {
|
|
728
|
+
response.statusCode = 204;
|
|
729
|
+
setCors(response);
|
|
730
|
+
response.end();
|
|
731
|
+
}
|
|
732
|
+
function sendJson(response, status, value) {
|
|
733
|
+
const body = JSON.stringify(value, null, 2);
|
|
734
|
+
response.statusCode = status;
|
|
735
|
+
setCors(response);
|
|
736
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
737
|
+
response.end(body);
|
|
738
|
+
}
|
|
739
|
+
function sendHtml(response, body) {
|
|
740
|
+
response.statusCode = 200;
|
|
741
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
742
|
+
response.end(body);
|
|
743
|
+
}
|
|
744
|
+
function setCors(response) {
|
|
745
|
+
response.setHeader("access-control-allow-origin", "*");
|
|
746
|
+
response.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
|
|
747
|
+
response.setHeader("access-control-allow-headers", "content-type,mcp-protocol-version,x-request-id");
|
|
748
|
+
}
|
|
749
|
+
function renderAppHtml() {
|
|
750
|
+
const builtPath = (0, node_path_1.join)(__dirname, "ui.html");
|
|
751
|
+
if ((0, node_fs_1.existsSync)(builtPath)) {
|
|
752
|
+
return (0, node_fs_1.readFileSync)(builtPath, "utf8");
|
|
753
|
+
}
|
|
754
|
+
return (0, node_fs_1.readFileSync)((0, node_path_1.join)(process.cwd(), "apps", "http-server", "src", "ui.html"), "utf8");
|
|
755
|
+
}
|