From 3b8e0ea5946827f067689b7b8e59b576abfbd1b6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 14 May 2026 21:11:45 +0300 Subject: [PATCH] =?UTF-8?q?FEAT=20-=20TASKER:=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20Codex=20Agent=20API=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Gateway=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plane-app/docker-compose.yaml | 3 + plane-app/plane.env | 3 + plane-app/plane.env.staging.example | 3 + plane-src/apps/api/plane/app/urls/__init__.py | 2 + .../apps/api/plane/app/urls/codex_agents.py | 54 ++++ .../apps/api/plane/app/views/__init__.py | 10 + .../apps/api/plane/app/views/codex_agents.py | 292 ++++++++++++++++++ .../settings/codex-agent-api-settings.tsx | 234 +++++++++++++- .../services/workspace-codex-agent.service.ts | 128 ++++++++ 9 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 plane-src/apps/api/plane/app/urls/codex_agents.py create mode 100644 plane-src/apps/api/plane/app/views/codex_agents.py create mode 100644 plane-src/apps/web/core/services/workspace-codex-agent.service.ts diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index c68e859..466ef18 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -79,6 +79,9 @@ x-app-env: &app-env PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3} PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check} PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3} + PLANE_NODEDC_AGENT_GATEWAY_URL: ${PLANE_NODEDC_AGENT_GATEWAY_URL:-} + PLANE_NODEDC_AGENT_GATEWAY_TOKEN: ${PLANE_NODEDC_AGENT_GATEWAY_TOKEN:-} + PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS: ${PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS:-5} GUNICORN_WORKERS: 1 POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_HOST: ${POSTHOG_HOST:-} diff --git a/plane-app/plane.env b/plane-app/plane.env index 01119ee..02f4f79 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -117,3 +117,6 @@ PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consu PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3 PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3 +PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100 +PLANE_NODEDC_AGENT_GATEWAY_TOKEN=local-dev-codex-agent-gateway-token-change-me +PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5 diff --git a/plane-app/plane.env.staging.example b/plane-app/plane.env.staging.example index 188e83a..ef26f55 100644 --- a/plane-app/plane.env.staging.example +++ b/plane-app/plane.env.staging.example @@ -94,4 +94,7 @@ PLANE_NODEDC_HANDOFF_URL=https://launcher.staging.nodedc.example/api/internal/ha PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3 PLANE_NODEDC_WORKSPACE_POLICY_URL=https://launcher.staging.nodedc.example/api/internal/access/check PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3 +PLANE_NODEDC_AGENT_GATEWAY_URL=https://codex-api.staging.nodedc.example +PLANE_NODEDC_AGENT_GATEWAY_TOKEN=replace-with-codex-agent-gateway-internal-token +PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS=5 PLANE_NODEDC_WORKSPACE_CREATION_MODE=any_authorized_user diff --git a/plane-src/apps/api/plane/app/urls/__init__.py b/plane-src/apps/api/plane/app/urls/__init__.py index e45bf84..46b8070 100644 --- a/plane-src/apps/api/plane/app/urls/__init__.py +++ b/plane-src/apps/api/plane/app/urls/__init__.py @@ -5,6 +5,7 @@ from .analytic import urlpatterns as analytic_urls from .api import urlpatterns as api_urls from .asset import urlpatterns as asset_urls +from .codex_agents import urlpatterns as codex_agent_urls from .cycle import urlpatterns as cycle_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls @@ -28,6 +29,7 @@ from .voice_tasker import urlpatterns as voice_tasker_urls urlpatterns = [ *analytic_urls, *asset_urls, + *codex_agent_urls, *cycle_urls, *estimate_urls, *external_urls, diff --git a/plane-src/apps/api/plane/app/urls/codex_agents.py b/plane-src/apps/api/plane/app/urls/codex_agents.py new file mode 100644 index 0000000..a50ca66 --- /dev/null +++ b/plane-src/apps/api/plane/app/urls/codex_agents.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.app.views import ( + CodexAgentDetailEndpoint, + CodexAgentGrantListEndpoint, + CodexAgentListEndpoint, + CodexAgentRevokeEndpoint, + CodexAgentSetupEndpoint, + CodexAgentTokenListEndpoint, + CodexAgentTokenRevokeEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//codex-agent-api/agents/", + CodexAgentListEndpoint.as_view(), + name="codex-agent-api-agents", + ), + path( + "workspaces//codex-agent-api/agents//", + CodexAgentDetailEndpoint.as_view(), + name="codex-agent-api-agent-detail", + ), + path( + "workspaces//codex-agent-api/agents//revoke/", + CodexAgentRevokeEndpoint.as_view(), + name="codex-agent-api-agent-revoke", + ), + path( + "workspaces//codex-agent-api/agents//grants/", + CodexAgentGrantListEndpoint.as_view(), + name="codex-agent-api-agent-grants", + ), + path( + "workspaces//codex-agent-api/agents//tokens/", + CodexAgentTokenListEndpoint.as_view(), + name="codex-agent-api-agent-tokens", + ), + path( + "workspaces//codex-agent-api/agents//tokens//revoke/", + CodexAgentTokenRevokeEndpoint.as_view(), + name="codex-agent-api-agent-token-revoke", + ), + path( + "workspaces//codex-agent-api/agents//setup/", + CodexAgentSetupEndpoint.as_view(), + name="codex-agent-api-agent-setup", + ), +] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 0ffb664..781df5f 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -174,6 +174,16 @@ from .module.archive import ModuleArchiveUnarchiveEndpoint from .api import ApiTokenEndpoint +from .codex_agents import ( + CodexAgentDetailEndpoint, + CodexAgentGrantListEndpoint, + CodexAgentListEndpoint, + CodexAgentRevokeEndpoint, + CodexAgentSetupEndpoint, + CodexAgentTokenListEndpoint, + CodexAgentTokenRevokeEndpoint, +) + from .page.base import ( PageViewSet, PageFavoriteViewSet, diff --git a/plane-src/apps/api/plane/app/views/codex_agents.py b/plane-src/apps/api/plane/app/views/codex_agents.py new file mode 100644 index 0000000..0ddc44e --- /dev/null +++ b/plane-src/apps/api/plane/app/views/codex_agents.py @@ -0,0 +1,292 @@ +# Python imports +import os +from urllib.parse import quote + +# Third party imports +import requests +from django.core.exceptions import ValidationError +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import ROLE, allow_permission +from plane.app.views.base import BaseAPIView +from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy +from plane.db.models import Project, Workspace + + +def get_gateway_config(): + base_url = ( + os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_URL", "").strip() + or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_URL", "").strip() + or os.environ.get("NODEDC_AGENT_GATEWAY_URL", "").strip() + ).rstrip("/") + token = ( + os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TOKEN", "").strip() + or os.environ.get("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN", "").strip() + ) + timeout = float(os.environ.get("PLANE_NODEDC_AGENT_GATEWAY_TIMEOUT_SECONDS", "5") or "5") + return base_url, token, timeout + + +def owner_path(user): + return quote(str(user.id), safe="") + + +def agent_path(agent_id): + return quote(str(agent_id), safe="") + + +def token_path(token_id): + return quote(str(token_id), safe="") + + +def require_gateway_config(): + base_url, token, timeout = get_gateway_config() + if not base_url or not token: + return None, Response( + { + "ok": False, + "error": "codex_agent_gateway_not_configured", + "message": "NODE.DC Codex Agent Gateway URL/token is not configured.", + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return (base_url, token, timeout), None + + +def require_codex_agent_entitlement(user, slug): + workspace_policy = get_nodedc_workspace_creation_policy(user, workspace_slug=slug) + service_modules = workspace_policy.get("service_modules") or {} + if not service_modules.get("codex_agents"): + return None, Response( + { + "ok": False, + "error": "codex_agents_not_entitled", + "message": "Codex Agent API is not enabled for this NODE.DC user/workspace.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + return workspace_policy, None + + +def require_workspace(slug): + try: + return Workspace.objects.get(slug=slug), None + except Workspace.DoesNotExist: + return None, Response( + {"ok": False, "error": "workspace_not_found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + +def validate_project_in_workspace(workspace, project_id): + if not project_id: + return None + + try: + exists = Project.objects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists() + except ValidationError: + exists = False + + if not exists: + return Response( + { + "ok": False, + "error": "project_not_found", + "message": "Project is not available in this workspace.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + return None + + +def gateway_request(method, path, payload=None): + config, error_response = require_gateway_config() + if error_response is not None: + return error_response + + base_url, token, timeout = config + try: + response = requests.request( + method, + f"{base_url}{path}", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + json=payload, + timeout=timeout, + ) + except requests.RequestException: + return Response( + { + "ok": False, + "error": "codex_agent_gateway_unavailable", + "message": "NODE.DC Codex Agent Gateway is unavailable.", + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + try: + data = response.json() + except ValueError: + data = { + "ok": False, + "error": "codex_agent_gateway_invalid_response", + "message": "NODE.DC Codex Agent Gateway returned a non-JSON response.", + } + + return Response(data, status=response.status_code) + + +class CodexAgentEntitledEndpoint(BaseAPIView): + def require_entitlement(self, request, slug): + _, entitlement_error = require_codex_agent_entitlement(request.user, slug) + return entitlement_error + + +class CodexAgentListEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents") + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + display_name = str(request.data.get("display_name") or "").strip() + if not display_name: + return Response( + {"ok": False, "error": "display_name_required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = { + "display_name": display_name, + "owner_email": request.user.email or None, + "avatar_url": request.data.get("avatar_url") or None, + } + return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents", payload) + + +class CodexAgentDetailEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}") + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def patch(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + payload = {"actor_user_id": str(request.user.id)} + if "display_name" in request.data: + payload["display_name"] = request.data.get("display_name") + if "avatar_url" in request.data: + payload["avatar_url"] = request.data.get("avatar_url") or None + return gateway_request("PATCH", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}", payload) + + +class CodexAgentRevokeEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request( + "POST", + f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/revoke", + {"actor_user_id": str(request.user.id)}, + ) + + +class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants") + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + workspace, workspace_error = require_workspace(slug) + if workspace_error is not None: + return workspace_error + + project_id = request.data.get("project_id") + project_error = validate_project_in_workspace(workspace, project_id) + if project_error is not None: + return project_error + + payload = { + "workspace_slug": slug, + "project_id": str(project_id) if project_id else None, + "scopes": request.data.get("scopes") or [], + "mode": request.data.get("mode") or "voluntary", + } + return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants", payload) + + +class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens") + + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + payload = { + "name": request.data.get("name") or "Local Codex token", + "expires_at": request.data.get("expires_at") or None, + } + return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens", payload) + + +class CodexAgentTokenRevokeEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, agent_id, token_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request( + "POST", + f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/tokens/{token_path(token_id)}/revoke", + {"actor_user_id": str(request.user.id)}, + ) + + +class CodexAgentSetupEndpoint(CodexAgentEntitledEndpoint): + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") + def get(self, request, slug, agent_id): + entitlement_error = self.require_entitlement(request, slug) + if entitlement_error is not None: + return entitlement_error + + return gateway_request("GET", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/setup") diff --git a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx index 7ecd3f4..7f67cff 100644 --- a/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx @@ -4,16 +4,40 @@ * See the LICENSE file for details. */ +import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react"; import useSWR from "swr"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // components import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; // services +import { ProjectService } from "@/services/project/project.service"; +import { + WorkspaceCodexAgentService, + type TCodexAgentCreateTokenResponse, +} from "@/services/workspace-codex-agent.service"; import { WorkspaceService } from "@/services/workspace.service"; +const TASK_AUTHOR_SCOPES = [ + "workspace:read", + "project:read", + "project:member:add_existing", + "issue:read", + "issue:create", + "issue:update", + "issue:move", + "issue:comment", + "issue:label", + "issue:assign", + "issue:structured_blocks:write", +]; + +const codexAgentService = new WorkspaceCodexAgentService(); +const projectService = new ProjectService(); const workspaceService = new WorkspaceService(); type TProps = { @@ -23,12 +47,80 @@ type TProps = { export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) { const { showHeading = true, workspaceSlug } = props; + const [isCreatingAgent, setIsCreatingAgent] = useState(false); + const [newAgentName, setNewAgentName] = useState("Local Codex"); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [tokenPacket, setTokenPacket] = useState(null); const { currentWorkspace } = useWorkspace(); const { data: nodedcWorkspacePolicy, isLoading } = useSWR( workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null, () => workspaceService.getNodeDCWorkspacePolicy(workspaceSlug) ); const isCodexAgentEntitled = nodedcWorkspacePolicy?.service_modules?.codex_agents === true; + const { + data: codexAgentsPayload, + error: codexAgentsError, + isLoading: areAgentsLoading, + mutate: mutateCodexAgents, + } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_AGENTS_${workspaceSlug}` : null, () => + codexAgentService.listAgents(workspaceSlug) + ); + const { data: projects } = useSWR(isCodexAgentEntitled ? `CODEX_AGENT_API_PROJECTS_${workspaceSlug}` : null, () => + projectService.getProjectsLite(workspaceSlug) + ); + const activeAgents = useMemo( + () => (codexAgentsPayload?.agents ?? []).filter((agent) => agent.status !== "revoked"), + [codexAgentsPayload?.agents] + ); + const projectOptions = projects ?? []; + const effectiveSelectedProjectId = selectedProjectId || projectOptions[0]?.id || ""; + + const handleCopy = async (value: string, label: string) => { + await navigator.clipboard.writeText(value); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: `${label} скопирован`, + message: "Секреты не сохраняются в TASKER_AGENT.md; token нужно хранить отдельно в локальном Codex.", + }); + }; + + const handleCreateAgent = async () => { + const displayName = newAgentName.trim(); + if (!displayName || !effectiveSelectedProjectId) return; + + setIsCreatingAgent(true); + try { + const createResponse = await codexAgentService.createAgent(workspaceSlug, { display_name: displayName }); + await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, { + project_id: effectiveSelectedProjectId, + scopes: TASK_AUTHOR_SCOPES, + mode: "voluntary", + }); + const tokenResponse = await codexAgentService.createToken(workspaceSlug, createResponse.agent.id, { + name: `${displayName} local token`, + }); + setTokenPacket(tokenResponse); + await mutateCodexAgents(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Codex agent создан", + message: "Token показан один раз. Скопируйте его в локальную конфигурацию Codex.", + }); + } catch (error: any) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Не удалось создать Codex agent", + message: error?.message ?? error?.error ?? "Проверьте entitlement, Gateway URL/token и выбранный проект.", + }); + } finally { + setIsCreatingAgent(false); + } + }; + + const handleRevokeAgent = async (agentId: string) => { + await codexAgentService.revokeAgent(workspaceSlug, agentId); + await mutateCodexAgents(); + }; return (
@@ -52,7 +144,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti

Доступ к модулю приходит из Launcher entitlement Operational Core → Codex Agent API. Если entitlement - снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный модуль. + снят, этот раздел исчезает из настроек workspace и backend policy больше не возвращает активный + модуль.

@@ -81,6 +174,145 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti description="Пользовательский Codex подключается по MCP endpoint с agent token; token хранится только на стороне Gateway." /> + + {isCodexAgentEntitled && ( +
+
+
+
Агенты workspace
+

+ Tasker вызывает Agent Gateway только через backend proxy. Frontend не получает сервисный Gateway + token. +

+
+
+ setNewAgentName(event.target.value)} + placeholder="Имя агента" + /> + + +
+
+ + {codexAgentsError && ( +
+ Gateway недоступен или не настроен. Проверьте `PLANE_NODEDC_AGENT_GATEWAY_URL` и + `PLANE_NODEDC_AGENT_GATEWAY_TOKEN` в Tasker API runtime. +
+ )} + +
+ {areAgentsLoading ? ( +
Загрузка агентов...
+ ) : activeAgents.length > 0 ? ( + activeAgents.map((agent) => ( +
+
+
{agent.display_name}
+
+ status: {agent.status} · created: {new Date(agent.created_at).toLocaleString()} +
+
+ +
+ )) + ) : ( +
+ Агентов пока нет. Создайте агента, выберите проект и сразу получите token + TASKER_AGENT.md. +
+ )} +
+
+ )} + + {tokenPacket && ( +
+
+
+
Одноразовый token и setup packet
+

+ Token больше не будет доступен после закрытия этого блока. Markdown-инструкция не содержит секрет. +

+
+ +
+
+
+
Agent token
+ + {tokenPacket.token} + + +
+ {tokenPacket.setup?.agents_md && ( +
+
+ TASKER_AGENT.md +
+