@minasoft/mina-ai-router 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +67 -16
  2. package/dist/apps/cli/src/index.js +1247 -43
  3. package/dist/apps/http-server/src/index.js +558 -42
  4. package/dist/apps/http-server/src/public/assets/index-Bl059Jd0.js +9 -0
  5. package/dist/apps/http-server/src/public/assets/index-CaPxN_Ez.css +1 -0
  6. package/dist/apps/http-server/src/public/index.html +2 -2
  7. package/dist/apps/mcp-server/src/index.js +54 -7
  8. package/dist/packages/core/src/capability-profile.js +145 -0
  9. package/dist/packages/core/src/index.js +3 -0
  10. package/dist/packages/core/src/mcp-preflight.js +80 -0
  11. package/dist/packages/core/src/registry.js +128 -3
  12. package/dist/packages/core/src/request-store.js +158 -0
  13. package/dist/packages/core/src/response-parser.js +76 -8
  14. package/dist/packages/core/src/router.js +408 -13
  15. package/dist/packages/core/src/version.js +57 -0
  16. package/dist/packages/mcp/src/provider.js +57 -6
  17. package/dist/packages/transports/src/headless/headless-transport.js +13 -8
  18. package/dist/packages/transports/src/tmux/tmux-client.js +334 -0
  19. package/dist/packages/transports/src/tmux/tmux-transport.js +10 -0
  20. package/docs/DEVELOPER-START-GUIDE.md +9 -1
  21. package/docs/GETTING-STARTED.md +10 -5
  22. package/docs/HTTP-UI-MCP.md +39 -13
  23. package/docs/MCP-CLIENT-SETUP.md +56 -3
  24. package/docs/SKILL-INSTALL-GUIDE.md +21 -3
  25. package/docs/TROUBLESHOOTING.md +47 -0
  26. package/docs/USER-START-GUIDE.md +155 -26
  27. package/docs/assets/mina-ai-router-overview.svg +109 -0
  28. package/package.json +8 -2
  29. package/dist/apps/http-server/src/public/assets/index-Be0tne90.js +0 -9
  30. package/dist/apps/http-server/src/public/assets/index-CEhd8YGG.css +0 -1
@@ -14,6 +14,7 @@ const runtimeModuleUrl = (0, node_url_1.pathToFileURL)((0, node_path_1.join)(__d
14
14
  const port = Number(process.env.PORT ?? process.env.MINA_HTTP_PORT ?? 3333);
15
15
  const host = process.env.HOST ?? process.env.MINA_HTTP_HOST ?? "127.0.0.1";
16
16
  const statePath = process.env.MINA_ROUTER_STATE ?? (0, node_path_1.join)(process.cwd(), "data", "router-state.json");
17
+ const agentStaleAfterMs = Number(process.env.MINA_AGENT_STALE_AFTER_MS ?? 15 * 60 * 1000);
17
18
  const context = createContext();
18
19
  let mcpHandlerPromise;
19
20
  function createContext() {
@@ -39,6 +40,7 @@ function createContext() {
39
40
  registry,
40
41
  requestStore,
41
42
  transports,
43
+ agentStaleAfterMs,
42
44
  onStateChanged: baseContext.save,
43
45
  });
44
46
  return {
@@ -54,6 +56,10 @@ const server = (0, node_http_1.createServer)((request, response) => {
54
56
  });
55
57
  });
56
58
  });
59
+ server.on("error", (error) => {
60
+ console.error(`Mina AI Router HTTP server failed to start: ${error instanceof Error ? error.message : String(error)}`);
61
+ throw error instanceof Error ? error : new Error(String(error));
62
+ });
57
63
  server.listen(port, host, () => {
58
64
  console.log(`Mina AI Router HTTP server`);
59
65
  console.log(`UI: http://${host}:${port}/`);
@@ -102,6 +108,13 @@ async function handleRequest(request, response) {
102
108
  sendJson(response, 200, { agent, state: await getUiState() });
103
109
  return;
104
110
  }
111
+ const agentCapabilityRefreshMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/refresh-capabilities$/);
112
+ if (agentCapabilityRefreshMatch && request.method === "POST") {
113
+ const body = await readJsonBody(request);
114
+ const result = await refreshAgentCapabilities(decodeURIComponent(agentCapabilityRefreshMatch[1]), body);
115
+ sendJson(response, 200, { ...result, state: await getUiState() });
116
+ return;
117
+ }
105
118
  const agentDeleteMatch = url.pathname.match(/^\/api\/agents\/([^/]+)$/);
106
119
  if (agentDeleteMatch && request.method === "PATCH") {
107
120
  const body = await readJsonBody(request);
@@ -154,19 +167,27 @@ async function handleRequest(request, response) {
154
167
  }
155
168
  catch (error) {
156
169
  context.save();
157
- sendJson(response, 500, {
170
+ sendJson(response, error instanceof src_1.AgentNotRouteReadyError ? 409 : 500, {
158
171
  error: error instanceof Error ? error.message : String(error),
159
172
  state: await getUiState(),
160
173
  });
161
174
  }
162
175
  return;
163
176
  }
164
- const requestActionMatch = url.pathname.match(/^\/api\/requests\/([^/]+)\/(retry|cancel|archive)$/);
177
+ const requestActionMatch = url.pathname.match(/^\/api\/requests\/([^/]+)\/(retry|cancel|archive|unarchive|interrupt|recover)$/);
165
178
  if (requestActionMatch && request.method === "POST") {
166
179
  const requestId = decodeURIComponent(requestActionMatch[1]);
167
180
  const action = requestActionMatch[2];
168
- const result = await handleRequestAction(requestId, action);
169
- sendJson(response, 200, { result, state: await getUiState() });
181
+ try {
182
+ const result = await handleRequestAction(requestId, action);
183
+ sendJson(response, 200, { result, state: await getUiState() });
184
+ }
185
+ catch (error) {
186
+ sendJson(response, 400, {
187
+ error: error instanceof Error ? error.message : String(error),
188
+ state: await getUiState(),
189
+ });
190
+ }
170
191
  return;
171
192
  }
172
193
  if (url.pathname === "/api/requests/archive-stale" && request.method === "POST") {
@@ -178,8 +199,13 @@ async function handleRequest(request, response) {
178
199
  }
179
200
  if (url.pathname === "/api/setup-codex-pair" && request.method === "POST") {
180
201
  const body = await readJsonBody(request);
181
- const result = setupCodexPair(body);
182
- sendJson(response, 200, result);
202
+ try {
203
+ const result = setupCodexPair(body);
204
+ sendJson(response, 200, result);
205
+ }
206
+ catch (error) {
207
+ sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
208
+ }
183
209
  return;
184
210
  }
185
211
  sendJson(response, 404, { error: "Not found" });
@@ -196,9 +222,15 @@ function archiveStaleRequests(olderThanMs) {
196
222
  if (Number.isFinite(updatedAt) && updatedAt > cutoff) {
197
223
  continue;
198
224
  }
199
- archived.push(context.requestStore.updateStatus(request.id, "archived", {
225
+ const archivedRequest = context.requestStore.updateStatus(request.id, "archived", {
226
+ archivedAt: new Date().toISOString(),
227
+ archivedFromStatus: request.status,
200
228
  error: request.error ?? "Archived as stale by operator.",
201
- }));
229
+ leaseStatus: request.leaseStatus === "active" ? "released" : request.leaseStatus,
230
+ leaseReleasedAt: request.leaseStatus === "active" ? new Date().toISOString() : request.leaseReleasedAt,
231
+ });
232
+ clearAgentLeaseForRequest(archivedRequest);
233
+ archived.push(archivedRequest);
202
234
  }
203
235
  if (archived.length > 0) {
204
236
  context.save();
@@ -208,26 +240,73 @@ function archiveStaleRequests(olderThanMs) {
208
240
  async function handleRequestAction(requestId, action) {
209
241
  const request = context.requestStore.require(requestId);
210
242
  if (action === "retry") {
211
- return context.router.callAgent({
243
+ context.requestStore.assertActionAllowed(request, "retry");
244
+ const result = await context.router.callAgent({
212
245
  sourceAgent: request.sourceAgent,
213
246
  target: request.targetAgent,
214
247
  task: request.task,
248
+ retryOfRequestId: request.id,
215
249
  });
250
+ context.requestStore.recordRetry(request.id, result.requestId);
251
+ context.save();
252
+ return result;
216
253
  }
217
254
  if (action === "cancel") {
218
- const updated = context.requestStore.updateStatus(requestId, "cancelled", {
219
- error: "Cancelled by operator from Mina AI Router UI.",
220
- });
255
+ const updated = context.requestStore.cancel(requestId, "Cancelled by operator from Mina AI Router UI.", "ui");
256
+ clearAgentLeaseForRequest(updated);
221
257
  context.save();
222
258
  return updated;
223
259
  }
260
+ if (action === "interrupt") {
261
+ const updated = interruptRequest(requestId, "ui");
262
+ context.save();
263
+ return updated;
264
+ }
265
+ if (action === "recover") {
266
+ const updated = context.router.recoverRequestLease(requestId, "ui", "Marked recovered by operator from Mina AI Router UI.");
267
+ return updated;
268
+ }
224
269
  if (action === "archive") {
225
- const updated = context.requestStore.updateStatus(requestId, "archived");
270
+ return context.router.archiveRequest(requestId, "ui", "Archived by operator from Mina AI Router UI.");
271
+ }
272
+ if (action === "unarchive") {
273
+ const updated = context.requestStore.unarchive(requestId);
226
274
  context.save();
227
275
  return updated;
228
276
  }
229
277
  throw new Error(`Unsupported request action "${action}".`);
230
278
  }
279
+ function interruptRequest(requestId, source) {
280
+ const request = context.requestStore.require(requestId);
281
+ context.requestStore.assertActionAllowed(request, "interrupt");
282
+ const agentId = request.leaseOwnerAgentId ?? request.targetAgent;
283
+ const agent = context.registry.require(agentId);
284
+ if (agent.transport !== "tmux") {
285
+ throw new Error(`Request "${requestId}" targets ${agent.transport}, not tmux; open the session manually.`);
286
+ }
287
+ const target = agent.tmuxTarget ?? agent.sessionId;
288
+ new src_2.TmuxClient().sendInterrupt(target);
289
+ return context.requestStore.recordInterrupt(requestId, {
290
+ source,
291
+ terminalTarget: target,
292
+ message: `Terminal interrupt sent to ${target}.`,
293
+ });
294
+ }
295
+ function clearAgentLeaseForRequest(request) {
296
+ if (!request.leaseOwnerAgentId) {
297
+ return;
298
+ }
299
+ const agent = context.registry.get(request.leaseOwnerAgentId);
300
+ if (!agent || agent.activeRequestId !== request.id) {
301
+ return;
302
+ }
303
+ context.registry.register({
304
+ ...agent,
305
+ activeRequestId: undefined,
306
+ leaseStatus: "released",
307
+ leaseReleasedAt: new Date().toISOString(),
308
+ });
309
+ }
231
310
  async function handleMcp(request, response, url) {
232
311
  const handler = await getMcpHandler();
233
312
  const body = await readRawBody(request);
@@ -285,14 +364,16 @@ async function getHealth() {
285
364
  const requests = context.router.listRequests();
286
365
  const openRequests = requests.filter((request) => ["created", "sent", "waiting"].includes(request.status));
287
366
  return {
288
- ok: agents.every((agent) => agent.status !== "missing"),
367
+ ok: agents.every((agent) => !["missing", "stale", "needs-attention"].includes(agent.status)),
289
368
  statePath,
290
369
  mcpUrl: `http://${host}:${port}/mcp`,
291
370
  agents: {
292
371
  total: agents.length,
293
372
  available: agents.filter((agent) => agent.status === "available").length,
294
373
  busy: agents.filter((agent) => agent.status === "busy").length,
374
+ stale: agents.filter((agent) => agent.status === "stale").length,
295
375
  missing: agents.filter((agent) => agent.status === "missing").length,
376
+ needsAttention: agents.filter((agent) => agent.status === "needs-attention").length,
296
377
  unknown: agents.filter((agent) => agent.status === "unknown").length,
297
378
  },
298
379
  requests: {
@@ -309,6 +390,7 @@ function registerAgent(body) {
309
390
  const id = requiredString(body.id, "id");
310
391
  const projectRoot = stringValue(body.projectRoot) ?? process.cwd();
311
392
  const capabilityNotice = inferCapabilityNotice(projectRoot);
393
+ const now = new Date().toISOString();
312
394
  const agent = {
313
395
  id,
314
396
  name: stringValue(body.name) ?? id,
@@ -320,22 +402,149 @@ function registerAgent(body) {
320
402
  startupCommand: stringValue(body.startupCommand),
321
403
  capabilitySummary: stringValue(body.capabilitySummary) ?? capabilityNotice.summary,
322
404
  capabilitySources: stringValue(body.capabilitySources) ?? capabilityNotice.sources,
405
+ capabilityProfile: capabilityProfileValue(body.capabilityProfile),
406
+ bootstrapStatus: stringValue(body.bootstrapStatus) ?? "ready",
407
+ registrationSource: stringValue(body.registrationSource) ?? "manual",
408
+ registrationStatus: stringValue(body.registrationStatus) ?? "confirmed",
409
+ lastRegistrationAttemptAt: stringValue(body.lastRegistrationAttemptAt) ?? now,
410
+ confirmedByAgentAt: stringValue(body.confirmedByAgentAt) ?? now,
411
+ sessionFingerprint: stringValue(body.sessionFingerprint) ?? stringValue(body.sessionId) ?? id,
412
+ permissionProfile: stringValue(body.permissionProfile) ?? "default",
413
+ permissionProfileStatus: stringValue(body.permissionProfileStatus) ?? "not-requested",
414
+ permissionProfileDetail: stringValue(body.permissionProfileDetail),
415
+ mcpPreflightStatus: stringValue(body.mcpPreflightStatus),
416
+ mcpPreflightDetail: stringValue(body.mcpPreflightDetail),
417
+ mcpSetupCommand: stringValue(body.mcpSetupCommand),
418
+ mcpVerifyCommand: stringValue(body.mcpVerifyCommand),
419
+ mcpRemoveCommand: stringValue(body.mcpRemoveCommand),
420
+ mcpUrl: stringValue(body.mcpUrl),
323
421
  };
324
- context.registry.register(agent);
422
+ const registered = context.registry.register(agent, {
423
+ capabilitySource: agent.capabilitySummary || agent.capabilitySources ? "generated" : undefined,
424
+ });
325
425
  context.save();
326
- return agent;
426
+ return registered;
427
+ }
428
+ function capabilityProfileValue(value) {
429
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
430
+ return undefined;
431
+ }
432
+ const record = value;
433
+ return {
434
+ projectPurpose: stringValue(record.projectPurpose),
435
+ primaryLanguages: stringArrayValue(record.primaryLanguages),
436
+ keyAreas: stringArrayValue(record.keyAreas),
437
+ canAnswer: stringArrayValue(record.canAnswer),
438
+ cannotAnswerYet: stringArrayValue(record.cannotAnswerYet),
439
+ evidence: stringArrayValue(record.evidence),
440
+ quality: "missing",
441
+ };
442
+ }
443
+ function stringArrayValue(value) {
444
+ if (!Array.isArray(value)) {
445
+ return undefined;
446
+ }
447
+ const values = value.filter((item) => typeof item === "string" && Boolean(item.trim()))
448
+ .map((item) => item.trim());
449
+ return values.length ? values : undefined;
327
450
  }
328
451
  function updateAgent(id, body) {
329
452
  const current = context.registry.require(id);
453
+ const capabilitySummary = stringFieldValue(body, "capabilitySummary");
454
+ const capabilitySources = stringFieldValue(body, "capabilitySources");
330
455
  const next = {
331
456
  ...current,
332
457
  name: stringValue(body.name) ?? current.name,
333
- capabilitySummary: stringFieldValue(body, "capabilitySummary") ?? current.capabilitySummary,
334
- capabilitySources: stringFieldValue(body, "capabilitySources") ?? current.capabilitySources,
335
458
  };
336
459
  context.registry.register(next);
460
+ const updated = capabilitySummary !== undefined || capabilitySources !== undefined
461
+ ? context.registry.updateCapabilities(id, {
462
+ summary: capabilitySummary,
463
+ sources: capabilitySources,
464
+ source: "manual",
465
+ })
466
+ : context.registry.require(id);
337
467
  context.save();
338
- return next;
468
+ return updated;
469
+ }
470
+ async function refreshAgentCapabilities(id, body) {
471
+ const agent = context.registry.require(id);
472
+ const response = await context.router.callAgent({
473
+ target: id,
474
+ task: buildCapabilityRefreshTask(agent),
475
+ timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
476
+ });
477
+ const notice = parseCapabilityRefreshAnswer(response.answer);
478
+ const refreshedAt = new Date().toISOString();
479
+ const updated = context.registry.updateCapabilities(id, {
480
+ summary: notice.capabilitySummary,
481
+ sources: notice.capabilitySources,
482
+ source: "generated",
483
+ refreshedAt,
484
+ profile: notice.capabilityProfile,
485
+ });
486
+ context.save();
487
+ return {
488
+ agent: updated,
489
+ refresh: {
490
+ requestId: response.requestId,
491
+ refreshedAt,
492
+ capabilitySource: updated.capabilitySource,
493
+ },
494
+ };
495
+ }
496
+ function buildCapabilityRefreshTask(agent) {
497
+ return [
498
+ "Refresh your Mina AI Router capability registration for this visible local agent.",
499
+ "",
500
+ "Inspect local project docs and metadata before answering.",
501
+ "Prefer capability docs in this order when present: CLAUDE.md/claude.md, AGENTS.md/agents.md, agent.md, README.md.",
502
+ "If those files are missing, inspect package metadata and the project file tree.",
503
+ "",
504
+ "Return only JSON with these string fields:",
505
+ "{",
506
+ ' "capabilitySummary": "2-5 short bullets or one short paragraph under 800 characters",',
507
+ ' "capabilitySources": "comma-separated file paths or project signals used",',
508
+ ' "capabilityProfile": {',
509
+ ' "projectPurpose": "what this project implements",',
510
+ ' "primaryLanguages": ["TypeScript"],',
511
+ ' "keyAreas": ["router", "MCP endpoint"],',
512
+ ' "canAnswer": ["specific question domains this agent can answer"],',
513
+ ' "cannotAnswerYet": ["known limits"],',
514
+ ' "evidence": ["README.md", "package.json", "src/..."]',
515
+ " }",
516
+ "}",
517
+ "",
518
+ "Do not include markdown fences or extra commentary in the JSON body.",
519
+ "",
520
+ "Registration context:",
521
+ `- id: ${agent.id}`,
522
+ `- agentType: ${agent.agentType}`,
523
+ `- transport: ${agent.transport}`,
524
+ `- sessionId: ${agent.sessionId}`,
525
+ `- projectRoot: ${agent.projectRoot}`,
526
+ ].join("\n");
527
+ }
528
+ function parseCapabilityRefreshAnswer(answer) {
529
+ const trimmed = answer.trim();
530
+ const jsonText = trimmed.startsWith("{") ? trimmed : trimmed.slice(trimmed.indexOf("{"), trimmed.lastIndexOf("}") + 1);
531
+ if (!jsonText || !jsonText.startsWith("{") || !jsonText.endsWith("}")) {
532
+ throw new Error("Capability refresh response did not contain a JSON object.");
533
+ }
534
+ const parsed = JSON.parse(jsonText);
535
+ const capabilitySummary = typeof parsed.capabilitySummary === "string" ? parsed.capabilitySummary.trim() : "";
536
+ const capabilitySources = typeof parsed.capabilitySources === "string" ? parsed.capabilitySources.trim() : "";
537
+ if (!capabilitySummary || !capabilitySources) {
538
+ throw new Error("Capability refresh JSON requires non-empty capabilitySummary and capabilitySources strings.");
539
+ }
540
+ if (capabilitySummary.length > 800) {
541
+ throw new Error("Capability refresh JSON capabilitySummary must be under 800 characters.");
542
+ }
543
+ return {
544
+ capabilitySummary,
545
+ capabilitySources,
546
+ capabilityProfile: capabilityProfileValue(parsed.capabilityProfile),
547
+ };
339
548
  }
340
549
  function listDirectories(body) {
341
550
  const home = process.env.HOME ?? process.cwd();
@@ -381,13 +590,39 @@ function captureAgentTerminal(id) {
381
590
  }
382
591
  const tmux = new src_2.TmuxClient({ captureLines: 120 });
383
592
  const text = tmux.capture(agent.tmuxTarget ?? agent.sessionId);
593
+ const detectedBootstrapPrompt = (0, src_2.detectAgentBootstrapPrompt)(agent, text);
594
+ const bootstrapPrompt = agent.bootstrapStatus === "registration-pending"
595
+ && detectedBootstrapPrompt
596
+ && !["codex-mcp-registration-approval", "mcp-registration-approval", "scoped-command-approval"].includes(detectedBootstrapPrompt.kind)
597
+ ? undefined
598
+ : detectedBootstrapPrompt;
599
+ const resolvedPermission = !bootstrapPrompt && agent.bootstrapStatus === "permission-required";
600
+ const nextAgent = bootstrapPrompt
601
+ ? context.registry.register({
602
+ ...agent,
603
+ bootstrapStatus: bootstrapPrompt.kind === "client-update" ? "client-update-required" : "permission-required",
604
+ })
605
+ : resolvedPermission
606
+ ? context.registry.register({
607
+ ...agent,
608
+ bootstrapStatus: needsSelfRegistration(agent) ? "created" : "created",
609
+ registrationStatus: needsSelfRegistration(agent) ? "pending" : agent.registrationStatus,
610
+ permissionProfileStatus: agent.permissionProfileStatus === "unsupported" ? agent.permissionProfileStatus : "supported",
611
+ permissionProfileDetail: "Previous permission prompt no longer appears in the terminal capture.",
612
+ })
613
+ : agent;
614
+ if (bootstrapPrompt || resolvedPermission) {
615
+ context.save();
616
+ }
384
617
  return {
385
- agent,
618
+ agent: nextAgent,
386
619
  terminal: {
387
620
  text,
388
621
  capturedAt: new Date().toISOString(),
389
- trustPrompt: agent.agentType === "codex" && isCodexTrustPrompt(text),
390
- pendingRegistration: isPendingUiRegistration(agent),
622
+ trustPrompt: Boolean(bootstrapPrompt && bootstrapPrompt.kind !== "client-update"),
623
+ permissionPrompt: bootstrapPrompt,
624
+ actions: terminalActions(nextAgent, bootstrapPrompt),
625
+ pendingRegistration: needsSelfRegistration(nextAgent),
391
626
  },
392
627
  };
393
628
  }
@@ -399,8 +634,16 @@ function sendAgentTerminalInput(id, body) {
399
634
  const target = agent.tmuxTarget ?? agent.sessionId;
400
635
  const text = stringValue(body.text);
401
636
  const enter = body.enter === true;
637
+ const actionId = stringValue(body.actionId);
402
638
  const tmux = new src_2.TmuxClient({ captureLines: 120 });
403
- if (text) {
639
+ const action = actionId ? resolveTerminalAction(agent, actionId) : undefined;
640
+ if (action?.input?.text) {
641
+ tmux.sendText(target, action.input.text);
642
+ }
643
+ else if (action?.input?.enter) {
644
+ tmux.sendEnter(target);
645
+ }
646
+ else if (text) {
404
647
  if (agent.agentType === "codex") {
405
648
  tmux.sendCodexText(target, text);
406
649
  }
@@ -412,10 +655,23 @@ function sendAgentTerminalInput(id, body) {
412
655
  tmux.sendEnter(target);
413
656
  }
414
657
  let registration = "unchanged";
415
- if (enter && !text && isPendingUiRegistration(agent)) {
658
+ const shouldRetryRegistration = (enter && !text && needsSelfRegistration(agent))
659
+ || action?.id === "approve-project-trust"
660
+ || action?.id === "approve-claude-project-trust"
661
+ || action?.id === "skip-codex-update"
662
+ || action?.id === "approve-scoped-registration-command"
663
+ || action?.id === "retry-self-registration";
664
+ if (shouldRetryRegistration && needsSelfRegistration(agent)) {
416
665
  sleep(1200);
417
666
  const capture = tmux.capture(target);
418
- if (!(agent.agentType === "codex" && isCodexTrustPrompt(capture))) {
667
+ const bootstrapPrompt = (0, src_2.detectAgentBootstrapPrompt)(agent, capture);
668
+ const permissionApprovalAttempt = agent.bootstrapStatus === "permission-required"
669
+ && bootstrapPrompt?.kind !== "client-update";
670
+ const updateSkipAttempt = action?.id === "skip-codex-update";
671
+ if (updateSkipAttempt && bootstrapPrompt?.kind === "client-update") {
672
+ registration = "update skip sent";
673
+ }
674
+ else if (!bootstrapPrompt || permissionApprovalAttempt) {
419
675
  if (agent.agentType === "codex") {
420
676
  tmux.sendCodexText(target, buildSelfRegistrationPrompt(agent));
421
677
  }
@@ -423,6 +679,12 @@ function sendAgentTerminalInput(id, body) {
423
679
  tmux.sendText(target, buildSelfRegistrationPrompt(agent));
424
680
  }
425
681
  registration = "registration prompt sent to agent";
682
+ context.registry.register({
683
+ ...agent,
684
+ bootstrapStatus: "registration-pending",
685
+ registrationStatus: "pending",
686
+ });
687
+ context.save();
426
688
  }
427
689
  }
428
690
  return {
@@ -445,6 +707,20 @@ function createTmuxAgent(body) {
445
707
  const sessionId = stringValue(body.sessionId) ?? `${agentType}-${sanitizeName(projectName)}`;
446
708
  const startupCommand = stringValue(body.startupCommand)
447
709
  ?? (agentType === "codex" ? "codex --no-alt-screen" : "claude");
710
+ const now = new Date().toISOString();
711
+ const permissionProfile = resolvePermissionProfile(agentType, stringValue(body.permissionProfile) ?? "default", projectRoot);
712
+ const mcpUrl = `http://${host}:${port}/mcp`;
713
+ const mcpName = stringValue(body.mcpName) ?? "mina-ai-router";
714
+ const explicitConfiguredUrl = stringValue(body.mcpConfiguredUrl);
715
+ const detectedConfiguredUrl = explicitConfiguredUrl
716
+ ?? (body.mcpConfigured === true ? undefined : detectClientMcpConfiguredUrl(agentType, mcpName, mcpUrl, projectRoot));
717
+ const mcpPreflight = (0, src_1.buildMcpPreflight)({
718
+ agentType,
719
+ mcpUrl,
720
+ mcpName,
721
+ configured: body.mcpConfigured === true,
722
+ configuredUrl: detectedConfiguredUrl,
723
+ });
448
724
  const command = startupCommand.split(/\s+/)[0];
449
725
  assertCommandAvailable("tmux");
450
726
  assertCommandAvailable(command);
@@ -456,12 +732,26 @@ function createTmuxAgent(body) {
456
732
  sessionId,
457
733
  projectRoot,
458
734
  startupCommand,
735
+ bootstrapStatus: "created",
736
+ registrationSource: "web-ui",
737
+ registrationStatus: "pending",
738
+ lastRegistrationAttemptAt: now,
739
+ sessionFingerprint: sessionId,
740
+ permissionProfile: permissionProfile.permissionProfile,
741
+ permissionProfileStatus: permissionProfile.permissionProfileStatus,
742
+ permissionProfileDetail: permissionProfile.permissionProfileDetail,
743
+ mcpPreflightStatus: mcpPreflight.mcpPreflightStatus,
744
+ mcpPreflightDetail: mcpPreflight.mcpPreflightDetail,
745
+ mcpSetupCommand: mcpPreflight.mcpSetupCommand,
746
+ mcpVerifyCommand: mcpPreflight.mcpVerifyCommand,
747
+ mcpRemoveCommand: mcpPreflight.mcpRemoveCommand,
748
+ mcpUrl: mcpPreflight.mcpUrl,
459
749
  ...uiCreatedCapabilityNotice(projectRoot),
460
750
  };
461
751
  const tmux = new src_2.TmuxClient();
462
752
  const existed = tmux.hasSession(sessionId);
463
753
  tmux.ensureSession(agent);
464
- context.registry.register(agent);
754
+ let registeredAgent = context.registry.register(agent);
465
755
  context.save();
466
756
  const sendRegistrationPrompt = body.sendRegistrationPrompt !== false;
467
757
  let registration = "registration prompt skipped";
@@ -470,33 +760,102 @@ function createTmuxAgent(body) {
470
760
  const delayMs = typeof body.registerDelayMs === "number" ? body.registerDelayMs : 4000;
471
761
  sleep(delayMs);
472
762
  const capture = tmux.capture(sessionId);
473
- if (agentType === "codex" && isCodexTrustPrompt(capture)) {
474
- registration = "waiting for Codex directory trust approval";
475
- nextAction = `Attach with "tmux attach -t ${sessionId}", approve the Codex trust prompt, then ask the agent to register this session with Mina AI Router.`;
763
+ const bootstrapPrompt = (0, src_2.detectAgentBootstrapPrompt)(agent, capture);
764
+ if (bootstrapPrompt) {
765
+ registration = bootstrapPrompt.kind === "client-update"
766
+ ? "waiting for client update choice"
767
+ : "waiting for permission approval";
768
+ nextAction = bootstrapPrompt.action;
769
+ registeredAgent = context.registry.register({
770
+ ...agent,
771
+ bootstrapStatus: bootstrapPrompt.kind === "client-update" ? "client-update-required" : "permission-required",
772
+ });
773
+ context.save();
774
+ }
775
+ else if (!mcpPreflight.canSendSelfRegistrationPrompt) {
776
+ registration = "waiting for MCP setup";
777
+ nextAction = mcpPreflight.nextAction;
778
+ registeredAgent = context.registry.register({
779
+ ...agent,
780
+ bootstrapStatus: "mcp-configuring",
781
+ });
782
+ context.save();
476
783
  }
477
784
  else {
478
785
  const prompt = buildSelfRegistrationPrompt(agent);
479
- if (agentType === "codex") {
480
- tmux.sendCodexText(sessionId, prompt);
786
+ try {
787
+ if (agentType === "codex") {
788
+ tmux.sendCodexText(sessionId, prompt);
789
+ }
790
+ else {
791
+ tmux.sendText(sessionId, prompt);
792
+ }
481
793
  }
482
- else {
483
- tmux.sendText(sessionId, prompt);
794
+ catch (error) {
795
+ const detail = error instanceof Error ? error.message : String(error);
796
+ registration = "registration prompt failed";
797
+ nextAction = `Resolve the terminal/session blocker for ${sessionId}, then retry agent registration. ${detail}`;
798
+ registeredAgent = context.registry.register({
799
+ ...registeredAgent,
800
+ bootstrapStatus: "failed",
801
+ registrationStatus: "failed",
802
+ mcpPreflightDetail: detail,
803
+ });
804
+ context.save();
805
+ return {
806
+ agent: registeredAgent,
807
+ existed,
808
+ attach: `tmux attach -t ${sessionId}`,
809
+ registration,
810
+ nextAction,
811
+ mcpPreflight,
812
+ permissionProfile,
813
+ state: {
814
+ agents: context.registry.list(),
815
+ requests: context.requestStore.list(),
816
+ },
817
+ };
484
818
  }
485
819
  registration = "registration prompt sent to agent";
820
+ registeredAgent = context.registry.register({
821
+ ...registeredAgent,
822
+ bootstrapStatus: "registration-pending",
823
+ registrationStatus: "pending",
824
+ });
825
+ context.save();
486
826
  }
487
827
  }
488
828
  return {
489
- agent,
829
+ agent: registeredAgent,
490
830
  existed,
491
831
  registration,
492
832
  nextAction,
833
+ permissionProfile,
834
+ mcpPreflight,
493
835
  attachCommand: `tmux attach -t ${sessionId}`,
494
836
  mairAttachCommand: `mair attach ${id}`,
495
837
  };
496
838
  }
839
+ function resolvePermissionProfile(agentType, requestedProfile, projectRoot) {
840
+ if (requestedProfile !== "direct-workspace-read") {
841
+ return {
842
+ permissionProfile: "default",
843
+ permissionProfileStatus: "not-requested",
844
+ permissionProfileDetail: "Default CLI startup. Mina will surface permission prompts instead of hiding them.",
845
+ };
846
+ }
847
+ return {
848
+ permissionProfile: "direct-workspace-read",
849
+ permissionProfileStatus: "unsupported",
850
+ permissionProfileDetail: `No known ${agentType} startup flag in Mina is both direct-read and scoped only to ${projectRoot}. Mina will start the default command and surface permission prompts.`,
851
+ };
852
+ }
497
853
  function setupCodexPair(body) {
498
- const mainRoot = stringValue(body.mainRoot) ?? "/Users/stevenna/WebstormProjects/minasoftai";
499
- const helperRoot = stringValue(body.helperRoot) ?? "/Users/stevenna/PycharmProjects/mina-ralph-loop-bootstrap-nextjs";
854
+ const mainRoot = stringValue(body.mainRoot);
855
+ const helperRoot = stringValue(body.helperRoot);
856
+ if (!mainRoot || !helperRoot) {
857
+ throw new Error("setup-codex-pair is a developer/demo helper. Provide explicit mainRoot and helperRoot. Use mair setup codex or mair setup claude for normal first-run setup.");
858
+ }
500
859
  const helperId = stringValue(body.helperId) ?? "ralph";
501
860
  const sessionId = stringValue(body.sessionId) ?? "mina-ralph-codex";
502
861
  const agent = {
@@ -530,14 +889,18 @@ function buildSelfRegistrationPrompt(agent) {
530
889
  `- agentType: ${agent.agentType}`,
531
890
  `- transport: ${agent.transport}`,
532
891
  `- sessionId: ${agent.sessionId}`,
892
+ `- sessionFingerprint: ${agent.sessionFingerprint ?? agent.sessionId}`,
533
893
  `- projectRoot: ${agent.projectRoot}`,
534
894
  `- startupCommand: ${agent.startupCommand ?? ""}`,
535
895
  "",
896
+ "If Mina already created this agent record, confirm and update that existing id. Do not create a new id for the same session.",
897
+ "",
536
898
  "Before registering, create a concise capability notice for this session:",
537
899
  "- Prefer capability docs in this order when present: CLAUDE.md/claude.md, AGENTS.md/agents.md, agent.md, README.md.",
538
900
  "- If those files are missing, inspect package metadata and the project file tree to infer what this project/agent can help with.",
539
901
  "- Set register_agent capabilitySummary to 2-5 short bullets or one short paragraph under 800 characters.",
540
902
  "- Set register_agent capabilitySources to a comma-separated list of the files or project signals you used.",
903
+ "- Set register_agent sessionFingerprint to the value above.",
541
904
  "After registering, call list_agents and confirm this agent is available.",
542
905
  ].join("\n");
543
906
  }
@@ -672,24 +1035,177 @@ function truncateText(value, maxLength) {
672
1035
  const normalized = value.replace(/\s+/g, " ").trim();
673
1036
  return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1).trim()}...` : normalized;
674
1037
  }
675
- function isCodexTrustPrompt(capture) {
676
- return capture.includes("Do you trust the contents of this directory?")
677
- || capture.includes("Press enter to continue")
678
- || capture.includes("Yes, continue");
679
- }
680
1038
  function isPendingUiRegistration(agent) {
681
1039
  return agent.capabilitySources?.startsWith("created from Mina UI") ?? false;
682
1040
  }
1041
+ function needsSelfRegistration(agent) {
1042
+ if (agent.transport !== "tmux") {
1043
+ return false;
1044
+ }
1045
+ if (agent.registrationStatus === "confirmed") {
1046
+ return false;
1047
+ }
1048
+ if (agent.mcpPreflightStatus === "missing" || agent.mcpPreflightStatus === "stale") {
1049
+ return false;
1050
+ }
1051
+ if (agent.bootstrapStatus === "mcp-configuring" || agent.bootstrapStatus === "failed") {
1052
+ return false;
1053
+ }
1054
+ return agent.registrationStatus === "pending" || isPendingUiRegistration(agent);
1055
+ }
1056
+ function terminalActions(agent, prompt) {
1057
+ if (prompt?.kind === "client-update") {
1058
+ return [
1059
+ {
1060
+ id: "skip-codex-update",
1061
+ label: "Skip Codex Update",
1062
+ description: "Sends the explicit skip choice for the known Codex update prompt. Registration resumes after the prompt clears.",
1063
+ policy: "guided",
1064
+ input: { text: "2" },
1065
+ },
1066
+ {
1067
+ id: "manual-update-choice",
1068
+ label: "Choose Manually",
1069
+ description: "Attach to tmux and choose the update option yourself.",
1070
+ policy: "manual",
1071
+ },
1072
+ ];
1073
+ }
1074
+ if (prompt?.kind === "directory-trust") {
1075
+ return [
1076
+ {
1077
+ id: "approve-project-trust",
1078
+ label: "Approve Project Trust",
1079
+ description: "Sends Enter for the visible project trust prompt after you have reviewed the directory.",
1080
+ policy: "guided",
1081
+ input: { enter: true },
1082
+ },
1083
+ ];
1084
+ }
1085
+ if (prompt?.kind === "claude-folder-trust") {
1086
+ return [
1087
+ {
1088
+ id: "approve-claude-project-trust",
1089
+ label: "Approve Claude Trust",
1090
+ description: "Sends Enter for the visible Claude folder trust prompt after you have reviewed the project path.",
1091
+ policy: "guided",
1092
+ input: { enter: true },
1093
+ },
1094
+ ];
1095
+ }
1096
+ if (prompt?.kind === "mcp-registration-approval") {
1097
+ return [
1098
+ {
1099
+ id: "approve-mcp-registration",
1100
+ label: "Approve Mina MCP Call",
1101
+ description: "Approves option 1 for this scoped Mina register_agent, list_agents, or call_agent MCP call only.",
1102
+ policy: "guided",
1103
+ input: { enter: true },
1104
+ },
1105
+ ];
1106
+ }
1107
+ if (prompt?.kind === "codex-mcp-registration-approval") {
1108
+ return [
1109
+ {
1110
+ id: "approve-codex-mcp-registration",
1111
+ label: "Approve Codex MCP Call",
1112
+ description: "Approves option 1 for this scoped Codex Mina register_agent or list_agents MCP call only.",
1113
+ policy: "guided",
1114
+ input: { enter: true },
1115
+ },
1116
+ ];
1117
+ }
1118
+ if (prompt?.kind === "permission-approval") {
1119
+ return [
1120
+ {
1121
+ id: "manual-permission-approval",
1122
+ label: "Approve In Terminal",
1123
+ description: "Attach to tmux and approve the scoped Claude permission prompt manually.",
1124
+ policy: "manual",
1125
+ },
1126
+ ];
1127
+ }
1128
+ if (prompt?.kind === "scoped-command-approval") {
1129
+ return [
1130
+ {
1131
+ id: "approve-scoped-registration-command",
1132
+ label: "Approve Scoped Command",
1133
+ description: "Approves the visible Mina registration command because it is scoped to this project root.",
1134
+ policy: "guided",
1135
+ input: { enter: true },
1136
+ },
1137
+ ];
1138
+ }
1139
+ if (needsSelfRegistration(agent)) {
1140
+ return [
1141
+ {
1142
+ id: "retry-self-registration",
1143
+ label: "Retry Self Registration",
1144
+ description: "Sends the Mina self-registration prompt to this session again.",
1145
+ policy: "guided",
1146
+ input: {},
1147
+ },
1148
+ ];
1149
+ }
1150
+ return [];
1151
+ }
1152
+ function resolveTerminalAction(agent, actionId) {
1153
+ const capture = new src_2.TmuxClient({ captureLines: 120 }).capture(agent.tmuxTarget ?? agent.sessionId);
1154
+ const prompt = (0, src_2.detectAgentBootstrapPrompt)(agent, capture);
1155
+ const action = terminalActions(agent, prompt).find((candidate) => candidate.id === actionId);
1156
+ if (!action) {
1157
+ throw new Error(`Terminal action "${actionId}" is not available for agent "${agent.id}".`);
1158
+ }
1159
+ return action;
1160
+ }
683
1161
  function assertCommandAvailable(command) {
1162
+ if (commandAvailable(command)) {
1163
+ return;
1164
+ }
1165
+ throw new Error(`Required command "${command}" is not available on PATH.`);
1166
+ }
1167
+ function commandAvailable(command) {
684
1168
  try {
685
1169
  (0, node_child_process_1.execFileSync)("which", [command], {
686
1170
  encoding: "utf8",
687
1171
  stdio: ["ignore", "pipe", "pipe"],
688
1172
  });
1173
+ return true;
689
1174
  }
690
1175
  catch {
691
- throw new Error(`Required command "${command}" is not available on PATH.`);
1176
+ return false;
1177
+ }
1178
+ }
1179
+ function detectClientMcpConfiguredUrl(client, mcpName, mcpUrl, projectRoot) {
1180
+ if (!commandAvailable(client)) {
1181
+ return undefined;
1182
+ }
1183
+ try {
1184
+ const output = (0, node_child_process_1.execFileSync)(client, ["mcp", "get", mcpName], {
1185
+ cwd: projectRoot,
1186
+ encoding: "utf8",
1187
+ stdio: ["ignore", "pipe", "pipe"],
1188
+ });
1189
+ const list = (0, node_child_process_1.execFileSync)(client, ["mcp", "list"], {
1190
+ cwd: projectRoot,
1191
+ encoding: "utf8",
1192
+ stdio: ["ignore", "pipe", "pipe"],
1193
+ });
1194
+ if (!list.includes(mcpName)) {
1195
+ return undefined;
1196
+ }
1197
+ return extractMcpUrl(output, mcpUrl);
1198
+ }
1199
+ catch {
1200
+ return undefined;
1201
+ }
1202
+ }
1203
+ function extractMcpUrl(output, expectedUrl) {
1204
+ if (output.includes(expectedUrl)) {
1205
+ return expectedUrl;
692
1206
  }
1207
+ const match = output.match(/https?:\/\/[^\s"',)]+/);
1208
+ return match?.[0];
693
1209
  }
694
1210
  function sanitizeName(value) {
695
1211
  return value