@minasoft/mina-ai-router 0.1.4 → 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.
@@ -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}/`);
@@ -70,6 +76,15 @@ async function handleRequest(request, response) {
70
76
  sendHtml(response, renderAppHtml());
71
77
  return;
72
78
  }
79
+ if (url.pathname.startsWith("/assets/") && request.method === "GET") {
80
+ sendStaticAsset(response, url.pathname);
81
+ return;
82
+ }
83
+ if (url.pathname === "/favicon.ico" && request.method === "GET") {
84
+ response.statusCode = 204;
85
+ response.end();
86
+ return;
87
+ }
73
88
  if (url.pathname === "/mcp" && request.method === "POST") {
74
89
  await handleMcp(request, response, url);
75
90
  return;
@@ -93,6 +108,13 @@ async function handleRequest(request, response) {
93
108
  sendJson(response, 200, { agent, state: await getUiState() });
94
109
  return;
95
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
+ }
96
118
  const agentDeleteMatch = url.pathname.match(/^\/api\/agents\/([^/]+)$/);
97
119
  if (agentDeleteMatch && request.method === "PATCH") {
98
120
  const body = await readJsonBody(request);
@@ -145,19 +167,27 @@ async function handleRequest(request, response) {
145
167
  }
146
168
  catch (error) {
147
169
  context.save();
148
- sendJson(response, 500, {
170
+ sendJson(response, error instanceof src_1.AgentNotRouteReadyError ? 409 : 500, {
149
171
  error: error instanceof Error ? error.message : String(error),
150
172
  state: await getUiState(),
151
173
  });
152
174
  }
153
175
  return;
154
176
  }
155
- const requestActionMatch = url.pathname.match(/^\/api\/requests\/([^/]+)\/(retry|cancel|archive)$/);
177
+ const requestActionMatch = url.pathname.match(/^\/api\/requests\/([^/]+)\/(retry|cancel|archive|unarchive|interrupt|recover)$/);
156
178
  if (requestActionMatch && request.method === "POST") {
157
179
  const requestId = decodeURIComponent(requestActionMatch[1]);
158
180
  const action = requestActionMatch[2];
159
- const result = await handleRequestAction(requestId, action);
160
- 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
+ }
161
191
  return;
162
192
  }
163
193
  if (url.pathname === "/api/requests/archive-stale" && request.method === "POST") {
@@ -169,8 +199,13 @@ async function handleRequest(request, response) {
169
199
  }
170
200
  if (url.pathname === "/api/setup-codex-pair" && request.method === "POST") {
171
201
  const body = await readJsonBody(request);
172
- const result = setupCodexPair(body);
173
- 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
+ }
174
209
  return;
175
210
  }
176
211
  sendJson(response, 404, { error: "Not found" });
@@ -187,9 +222,15 @@ function archiveStaleRequests(olderThanMs) {
187
222
  if (Number.isFinite(updatedAt) && updatedAt > cutoff) {
188
223
  continue;
189
224
  }
190
- 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,
191
228
  error: request.error ?? "Archived as stale by operator.",
192
- }));
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);
193
234
  }
194
235
  if (archived.length > 0) {
195
236
  context.save();
@@ -199,26 +240,73 @@ function archiveStaleRequests(olderThanMs) {
199
240
  async function handleRequestAction(requestId, action) {
200
241
  const request = context.requestStore.require(requestId);
201
242
  if (action === "retry") {
202
- return context.router.callAgent({
243
+ context.requestStore.assertActionAllowed(request, "retry");
244
+ const result = await context.router.callAgent({
203
245
  sourceAgent: request.sourceAgent,
204
246
  target: request.targetAgent,
205
247
  task: request.task,
248
+ retryOfRequestId: request.id,
206
249
  });
250
+ context.requestStore.recordRetry(request.id, result.requestId);
251
+ context.save();
252
+ return result;
207
253
  }
208
254
  if (action === "cancel") {
209
- const updated = context.requestStore.updateStatus(requestId, "cancelled", {
210
- error: "Cancelled by operator from Mina AI Router UI.",
211
- });
255
+ const updated = context.requestStore.cancel(requestId, "Cancelled by operator from Mina AI Router UI.", "ui");
256
+ clearAgentLeaseForRequest(updated);
257
+ context.save();
258
+ return updated;
259
+ }
260
+ if (action === "interrupt") {
261
+ const updated = interruptRequest(requestId, "ui");
212
262
  context.save();
213
263
  return updated;
214
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
+ }
215
269
  if (action === "archive") {
216
- 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);
217
274
  context.save();
218
275
  return updated;
219
276
  }
220
277
  throw new Error(`Unsupported request action "${action}".`);
221
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
+ }
222
310
  async function handleMcp(request, response, url) {
223
311
  const handler = await getMcpHandler();
224
312
  const body = await readRawBody(request);
@@ -276,14 +364,16 @@ async function getHealth() {
276
364
  const requests = context.router.listRequests();
277
365
  const openRequests = requests.filter((request) => ["created", "sent", "waiting"].includes(request.status));
278
366
  return {
279
- ok: agents.every((agent) => agent.status !== "missing"),
367
+ ok: agents.every((agent) => !["missing", "stale", "needs-attention"].includes(agent.status)),
280
368
  statePath,
281
369
  mcpUrl: `http://${host}:${port}/mcp`,
282
370
  agents: {
283
371
  total: agents.length,
284
372
  available: agents.filter((agent) => agent.status === "available").length,
285
373
  busy: agents.filter((agent) => agent.status === "busy").length,
374
+ stale: agents.filter((agent) => agent.status === "stale").length,
286
375
  missing: agents.filter((agent) => agent.status === "missing").length,
376
+ needsAttention: agents.filter((agent) => agent.status === "needs-attention").length,
287
377
  unknown: agents.filter((agent) => agent.status === "unknown").length,
288
378
  },
289
379
  requests: {
@@ -300,6 +390,7 @@ function registerAgent(body) {
300
390
  const id = requiredString(body.id, "id");
301
391
  const projectRoot = stringValue(body.projectRoot) ?? process.cwd();
302
392
  const capabilityNotice = inferCapabilityNotice(projectRoot);
393
+ const now = new Date().toISOString();
303
394
  const agent = {
304
395
  id,
305
396
  name: stringValue(body.name) ?? id,
@@ -311,22 +402,149 @@ function registerAgent(body) {
311
402
  startupCommand: stringValue(body.startupCommand),
312
403
  capabilitySummary: stringValue(body.capabilitySummary) ?? capabilityNotice.summary,
313
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),
314
421
  };
315
- context.registry.register(agent);
422
+ const registered = context.registry.register(agent, {
423
+ capabilitySource: agent.capabilitySummary || agent.capabilitySources ? "generated" : undefined,
424
+ });
316
425
  context.save();
317
- 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;
318
450
  }
319
451
  function updateAgent(id, body) {
320
452
  const current = context.registry.require(id);
453
+ const capabilitySummary = stringFieldValue(body, "capabilitySummary");
454
+ const capabilitySources = stringFieldValue(body, "capabilitySources");
321
455
  const next = {
322
456
  ...current,
323
457
  name: stringValue(body.name) ?? current.name,
324
- capabilitySummary: stringFieldValue(body, "capabilitySummary") ?? current.capabilitySummary,
325
- capabilitySources: stringFieldValue(body, "capabilitySources") ?? current.capabilitySources,
326
458
  };
327
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);
328
467
  context.save();
329
- 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
+ };
330
548
  }
331
549
  function listDirectories(body) {
332
550
  const home = process.env.HOME ?? process.cwd();
@@ -372,13 +590,39 @@ function captureAgentTerminal(id) {
372
590
  }
373
591
  const tmux = new src_2.TmuxClient({ captureLines: 120 });
374
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
+ }
375
617
  return {
376
- agent,
618
+ agent: nextAgent,
377
619
  terminal: {
378
620
  text,
379
621
  capturedAt: new Date().toISOString(),
380
- trustPrompt: agent.agentType === "codex" && isCodexTrustPrompt(text),
381
- pendingRegistration: isPendingUiRegistration(agent),
622
+ trustPrompt: Boolean(bootstrapPrompt && bootstrapPrompt.kind !== "client-update"),
623
+ permissionPrompt: bootstrapPrompt,
624
+ actions: terminalActions(nextAgent, bootstrapPrompt),
625
+ pendingRegistration: needsSelfRegistration(nextAgent),
382
626
  },
383
627
  };
384
628
  }
@@ -390,8 +634,16 @@ function sendAgentTerminalInput(id, body) {
390
634
  const target = agent.tmuxTarget ?? agent.sessionId;
391
635
  const text = stringValue(body.text);
392
636
  const enter = body.enter === true;
637
+ const actionId = stringValue(body.actionId);
393
638
  const tmux = new src_2.TmuxClient({ captureLines: 120 });
394
- 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) {
395
647
  if (agent.agentType === "codex") {
396
648
  tmux.sendCodexText(target, text);
397
649
  }
@@ -403,10 +655,23 @@ function sendAgentTerminalInput(id, body) {
403
655
  tmux.sendEnter(target);
404
656
  }
405
657
  let registration = "unchanged";
406
- 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)) {
407
665
  sleep(1200);
408
666
  const capture = tmux.capture(target);
409
- 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) {
410
675
  if (agent.agentType === "codex") {
411
676
  tmux.sendCodexText(target, buildSelfRegistrationPrompt(agent));
412
677
  }
@@ -414,6 +679,12 @@ function sendAgentTerminalInput(id, body) {
414
679
  tmux.sendText(target, buildSelfRegistrationPrompt(agent));
415
680
  }
416
681
  registration = "registration prompt sent to agent";
682
+ context.registry.register({
683
+ ...agent,
684
+ bootstrapStatus: "registration-pending",
685
+ registrationStatus: "pending",
686
+ });
687
+ context.save();
417
688
  }
418
689
  }
419
690
  return {
@@ -436,6 +707,20 @@ function createTmuxAgent(body) {
436
707
  const sessionId = stringValue(body.sessionId) ?? `${agentType}-${sanitizeName(projectName)}`;
437
708
  const startupCommand = stringValue(body.startupCommand)
438
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
+ });
439
724
  const command = startupCommand.split(/\s+/)[0];
440
725
  assertCommandAvailable("tmux");
441
726
  assertCommandAvailable(command);
@@ -447,12 +732,26 @@ function createTmuxAgent(body) {
447
732
  sessionId,
448
733
  projectRoot,
449
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,
450
749
  ...uiCreatedCapabilityNotice(projectRoot),
451
750
  };
452
751
  const tmux = new src_2.TmuxClient();
453
752
  const existed = tmux.hasSession(sessionId);
454
753
  tmux.ensureSession(agent);
455
- context.registry.register(agent);
754
+ let registeredAgent = context.registry.register(agent);
456
755
  context.save();
457
756
  const sendRegistrationPrompt = body.sendRegistrationPrompt !== false;
458
757
  let registration = "registration prompt skipped";
@@ -461,33 +760,102 @@ function createTmuxAgent(body) {
461
760
  const delayMs = typeof body.registerDelayMs === "number" ? body.registerDelayMs : 4000;
462
761
  sleep(delayMs);
463
762
  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.`;
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();
467
783
  }
468
784
  else {
469
785
  const prompt = buildSelfRegistrationPrompt(agent);
470
- if (agentType === "codex") {
471
- tmux.sendCodexText(sessionId, prompt);
786
+ try {
787
+ if (agentType === "codex") {
788
+ tmux.sendCodexText(sessionId, prompt);
789
+ }
790
+ else {
791
+ tmux.sendText(sessionId, prompt);
792
+ }
472
793
  }
473
- else {
474
- 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
+ };
475
818
  }
476
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();
477
826
  }
478
827
  }
479
828
  return {
480
- agent,
829
+ agent: registeredAgent,
481
830
  existed,
482
831
  registration,
483
832
  nextAction,
833
+ permissionProfile,
834
+ mcpPreflight,
484
835
  attachCommand: `tmux attach -t ${sessionId}`,
485
836
  mairAttachCommand: `mair attach ${id}`,
486
837
  };
487
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
+ }
488
853
  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";
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
+ }
491
859
  const helperId = stringValue(body.helperId) ?? "ralph";
492
860
  const sessionId = stringValue(body.sessionId) ?? "mina-ralph-codex";
493
861
  const agent = {
@@ -521,14 +889,18 @@ function buildSelfRegistrationPrompt(agent) {
521
889
  `- agentType: ${agent.agentType}`,
522
890
  `- transport: ${agent.transport}`,
523
891
  `- sessionId: ${agent.sessionId}`,
892
+ `- sessionFingerprint: ${agent.sessionFingerprint ?? agent.sessionId}`,
524
893
  `- projectRoot: ${agent.projectRoot}`,
525
894
  `- startupCommand: ${agent.startupCommand ?? ""}`,
526
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
+ "",
527
898
  "Before registering, create a concise capability notice for this session:",
528
899
  "- Prefer capability docs in this order when present: CLAUDE.md/claude.md, AGENTS.md/agents.md, agent.md, README.md.",
529
900
  "- If those files are missing, inspect package metadata and the project file tree to infer what this project/agent can help with.",
530
901
  "- Set register_agent capabilitySummary to 2-5 short bullets or one short paragraph under 800 characters.",
531
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.",
532
904
  "After registering, call list_agents and confirm this agent is available.",
533
905
  ].join("\n");
534
906
  }
@@ -663,25 +1035,178 @@ function truncateText(value, maxLength) {
663
1035
  const normalized = value.replace(/\s+/g, " ").trim();
664
1036
  return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1).trim()}...` : normalized;
665
1037
  }
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
1038
  function isPendingUiRegistration(agent) {
672
1039
  return agent.capabilitySources?.startsWith("created from Mina UI") ?? false;
673
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
+ }
674
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) {
675
1168
  try {
676
1169
  (0, node_child_process_1.execFileSync)("which", [command], {
677
1170
  encoding: "utf8",
678
1171
  stdio: ["ignore", "pipe", "pipe"],
679
1172
  });
1173
+ return true;
680
1174
  }
681
1175
  catch {
682
- throw new Error(`Required command "${command}" is not available on PATH.`);
1176
+ return false;
683
1177
  }
684
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;
1206
+ }
1207
+ const match = output.match(/https?:\/\/[^\s"',)]+/);
1208
+ return match?.[0];
1209
+ }
685
1210
  function sanitizeName(value) {
686
1211
  return value
687
1212
  .trim()
@@ -747,9 +1272,36 @@ function setCors(response) {
747
1272
  response.setHeader("access-control-allow-headers", "content-type,mcp-protocol-version,x-request-id");
748
1273
  }
749
1274
  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");
1275
+ const builtPath = (0, node_path_1.join)(__dirname, "public", "index.html");
1276
+ if (!(0, node_fs_1.existsSync)(builtPath)) {
1277
+ throw new Error("React UI build is missing. Run `npm run build:ui` before starting the HTTP server.");
753
1278
  }
754
- return (0, node_fs_1.readFileSync)((0, node_path_1.join)(process.cwd(), "apps", "http-server", "src", "ui.html"), "utf8");
1279
+ return (0, node_fs_1.readFileSync)(builtPath, "utf8");
1280
+ }
1281
+ function sendStaticAsset(response, pathname) {
1282
+ const publicRoot = (0, node_path_1.join)(__dirname, "public");
1283
+ const filePath = (0, node_path_1.resolve)(publicRoot, `.${pathname}`);
1284
+ if (!filePath.startsWith(publicRoot) || !(0, node_fs_1.existsSync)(filePath) || !(0, node_fs_1.statSync)(filePath).isFile()) {
1285
+ sendJson(response, 404, { error: "Asset not found" });
1286
+ return;
1287
+ }
1288
+ response.statusCode = 200;
1289
+ response.setHeader("content-type", contentTypeFor(filePath));
1290
+ response.end((0, node_fs_1.readFileSync)(filePath));
1291
+ }
1292
+ function contentTypeFor(filePath) {
1293
+ const ext = (0, node_path_1.extname)(filePath);
1294
+ if (ext === ".js")
1295
+ return "text/javascript; charset=utf-8";
1296
+ if (ext === ".css")
1297
+ return "text/css; charset=utf-8";
1298
+ if (ext === ".svg")
1299
+ return "image/svg+xml";
1300
+ if (ext === ".png")
1301
+ return "image/png";
1302
+ if (ext === ".jpg" || ext === ".jpeg")
1303
+ return "image/jpeg";
1304
+ if (ext === ".woff2")
1305
+ return "font/woff2";
1306
+ return "application/octet-stream";
755
1307
  }