FEAT - TASKER: support Codex agent labels and profiles

This commit is contained in:
DCCONSTRUCTIONS
2026-05-15 14:25:36 +03:00
parent b0a682b63b
commit 6962642614
3 changed files with 557 additions and 85 deletions
@@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react";
import { Bot, Check, Copy, KeyRound, Route, ShieldCheck } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -40,8 +40,13 @@ const TASK_AUTHOR_SCOPES = [
];
const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const MAX_AGENT_AVATAR_BYTES = 256 * 1024;
const MAX_AGENT_AVATAR_SOURCE_BYTES = 50 * 1024 * 1024;
const AGENT_AVATAR_RENDER_SIZE = 512;
const AGENT_AVATAR_OUTPUT_QUALITY = 0.86;
const OPS_AGENT_FILENAME = "OPS_AGENT.md";
const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent";
const CODEX_TOKEN_ENV_VAR = "NODEDC_OPS_AGENT_TOKEN";
const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp";
const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService();
@@ -123,6 +128,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
[createdSetupCards, persistedSetupCards]
);
const connectionGuideMcpEndpoint = getMcpEndpoint(setupCards.find((card) => card.setup)?.setup);
const connectionGuideConfigSnippet = buildCodexConfigSnippet(connectionGuideMcpEndpoint);
const connectionGuideOpsAgentMd = buildOpsAgentMarkdown(connectionGuideMcpEndpoint);
const handleCopy = async (value: string, label: string) => {
await navigator.clipboard.writeText(value);
@@ -241,8 +249,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
await mutateSetupCards();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Новый token выпущен",
message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.",
title: "Новый токен выпущен",
message: "Скопируйте токен сейчас. После перезахода backend вернет только masked suffix.",
});
} catch (error: any) {
setToast({
@@ -258,6 +266,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const handleSaveAgentName = async (agent: TCodexAgent) => {
const displayName = getAgentDraftName(agentDraftNames, agent).trim();
if (!displayName) return;
if (displayName === agent.display_name) return;
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
@@ -422,7 +431,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? [];
const setup = setupCard?.setup;
return (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
@@ -456,6 +464,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
[agent.id]: event.target.value,
}))
}
onBlur={() => {
if (isAgentDirty && !isUpdatingAgent) void handleSaveAgentName(agent);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
placeholder="Имя агента"
/>
</label>
@@ -483,7 +499,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)}
>
Новый token
Новый токен
</Button>
<Button
variant="secondary"
@@ -498,73 +514,32 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md...
Загрузка токена...
</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
const revealedToken = revealedTokens[token.id];
const tokenValue = revealedToken ?? maskToken(token);
const isTokenRevealed = Boolean(revealedToken);
return (
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]">
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
{tokenValue}
</code>
{isTokenRevealed && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
<div key={token.id} className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Agent token
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
Ops Agent.md
</div>
{setup?.agents_md ? (
<>
<textarea
readOnly
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12"
value={setup.agents_md}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
<div className="relative">
<code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
<span className="truncate">{tokenValue}</span>
</code>
<button
type="button"
aria-label="Скопировать токен"
className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:opacity-90 disabled:bg-white/10 disabled:text-tertiary disabled:opacity-60"
disabled={!revealedToken}
onClick={() => revealedToken && void handleCopy(revealedToken, "Токен")}
>
<Copy className="size-4" />
</button>
</div>
</div>
);
@@ -572,7 +547,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md.
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
</div>
)}
</section>
@@ -580,9 +555,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
})
) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md.
Агентов пока нет. Создайте агента, выберите project и сразу получите токен доступа.
</div>
)}
{activeAgents.length > 0 && (
<section className="nodedc-settings-card px-5 py-5">
<CodexConnectionGuide
configSnippet={connectionGuideConfigSnippet}
mcpEndpoint={connectionGuideMcpEndpoint}
onCopyConfig={() => void handleCopy(connectionGuideConfigSnippet, "config.toml")}
onDownloadAgentsMd={() => handleDownload(connectionGuideOpsAgentMd, OPS_AGENT_FILENAME)}
/>
</section>
)}
</>
)}
</>
@@ -591,6 +577,154 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
);
});
type TCodexConnectionGuideProps = {
configSnippet: string;
mcpEndpoint: string;
onCopyConfig: () => void;
onDownloadAgentsMd: () => void;
};
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
</span>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
<p>Windows: откройте файл через проводник или VS Code.</p>
<code className="mt-2 block break-all text-12 text-primary">
C:\Users\имя-пользователя\.codex\config.toml
</code>
<p className="mt-3">macOS / Linux:</p>
<code className="mt-2 block break-all text-12 text-primary">~/.codex/config.toml</code>
<p className="mt-3">Если файла нет создайте его.</p>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">2. Сохраните токен</div>
<p>
Создайте пользовательскую переменную окружения <code>{CODEX_TOKEN_ENV_VAR}</code>, заменив токен из примера
на уникальный токен конкретного агента.
</p>
<code className="mt-3 block break-all rounded-2xl bg-black/20 px-3 py-3 text-12 text-primary">
{CODEX_TOKEN_ENV_VAR}=ndcag_...
</code>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">3. Добавьте Ops Agent.md</div>
<p>
Скачайте <code>{OPS_AGENT_FILENAME}</code>. Если в проекте уже есть <code>AGENTS.md</code>, добавьте
содержимое Ops Agent.md в начало текущего файла. Если файла правил нет положите Ops Agent.md в корень
проекта.
</p>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button mt-3"
onClick={props.onDownloadAgentsMd}
>
Скачать Ops Agent.md
</Button>
</div>
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">config.toml block</div>
<textarea
readOnly
className="nodedc-settings-input font-mono h-44 w-full resize-y px-3 py-3 text-12"
value={props.configSnippet}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyConfig}>
Скопировать config.toml
</Button>
</div>
</div>
</div>
);
}
function getMcpEndpoint(setup?: TCodexAgentSetupPacket): string {
return setup?.mcp_server?.url ?? DEFAULT_OPS_AGENT_MCP_ENDPOINT;
}
function buildCodexConfigSnippet(endpoint: string): string {
return `[mcp_servers.${CODEX_MCP_SERVER_NAME}]
url = "${endpoint}"
bearer_token_env_var = "${CODEX_TOKEN_ENV_VAR}"
enabled = true
required = true
startup_timeout_sec = 20
tool_timeout_sec = 60`;
}
function buildOpsAgentMarkdown(endpoint: string): string {
return `# NODE.DC Ops Agent Rules
MCP endpoint: ${endpoint}
## Startup
- Call \`tasker_get_agent_instructions\` before creating or changing Tasker cards.
- Call \`tasker_list_projects\` and \`tasker_get_project_context\` before writing into a project.
- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.
## Write Safety
- Every write tool call must include a unique \`idempotency_key\`.
- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.
- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.
- Only assign existing project members returned by project context.
- If a needed label is missing, call \`tasker_ensure_labels\` first and then use returned ids with \`tasker_set_issue_labels\`.
## Card Writing
- Keep card titles concise and operational.
- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.
- Every structured text block must put its visible heading into the block \`title\` field.
- Do not put headings inside block body. Wrong: body starts with \`## Текущая архитектура\`. Correct: \`title: "Текущая архитектура"\`, body contains only content.
- Put short verifiable work items into checker blocks with explicit \`title\` fields.
- After code work, update the related card with factual files touched and validation performed.
## Labels
- Before assigning a new marker/label, check project context labels.
- If the label does not exist, call \`tasker_ensure_labels\` with the granted \`project_id\`.
- Use only label ids returned by project context or \`tasker_ensure_labels\` when calling \`tasker_set_issue_labels\`.
## Effective Grants
- Grants are bound to the local agent token.
- Load effective workspace/project grants through \`tasker_get_agent_instructions\` and \`tasker_list_projects\` after connecting.
- Do not assume access to projects, labels, states, or members that were not returned by NODE.DC MCP tools.
## Available Tools
- \`tasker_get_agent_instructions\`: Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.
- \`tasker_list_projects\`: List Tasker projects granted to the current agent.
- \`tasker_get_project_context\`: Return states, labels, members, and card-writing context for one granted project.
- \`tasker_search_issues\`: Search work items inside one granted Tasker project.
- \`tasker_create_issue\`: Create a Tasker card with optional NODE.DC structured text/checker blocks.
- \`tasker_update_issue\`: Patch allowed issue fields without delete, archive, or project transfer.
- \`tasker_update_structured_blocks\`: Replace NODE.DC structured text/checker blocks in an issue detail layout.
- \`tasker_move_issue\`: Move an issue to an existing state in the same granted project.
- \`tasker_append_comment\`: Append a comment to a granted issue.
- \`tasker_ensure_labels\`: Create missing labels in a granted project and return label ids.
- \`tasker_set_issue_labels\`: Replace issue labels with existing labels from the granted project.
- \`tasker_assign_issue\`: Replace issue assignees with existing members of the granted project.
`;
}
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name;
}
@@ -608,7 +742,7 @@ function maskToken(token: TCodexAgentToken): string {
function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl);
return getFileURL(avatarUrl) ?? null;
}
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
@@ -702,15 +836,73 @@ function readAvatarDataUrl(file: File): Promise<string> {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
}
if (file.size > MAX_AGENT_AVATAR_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ."));
if (file.size > MAX_AGENT_AVATAR_SOURCE_BYTES) {
return Promise.reject(new Error("Исходный файл аватара должен быть не больше 50 МБ."));
}
return resizeAvatarFile(file);
}
async function resizeAvatarFile(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadImageElement(objectUrl);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("Не удалось определить размер изображения.");
}
const scale = Math.min(1, AGENT_AVATAR_RENDER_SIZE / Math.max(sourceWidth, sourceHeight));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Не удалось подготовить аватар.");
}
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlob(canvas, "image/webp", AGENT_AVATAR_OUTPUT_QUALITY);
return blobToDataUrl(blob);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось открыть изображение аватара.")), { once: true });
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Не удалось сжать аватар."));
},
type,
quality
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара."));
reader.readAsDataURL(file);
reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
reader.addEventListener("error", () => reject(new Error("Не удалось прочитать файл аватара.")), { once: true });
reader.readAsDataURL(blob);
});
}