diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 8593a33c40..89c654db30 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -56,6 +56,36 @@ For a V2 roster, eligibility has three states: an entry stamped `"v2"`, explicit with no `multi_agent_version` field is eligible. A genuine `"v1"` pin is excluded because it states that the model belongs to the other collaboration surface. +## Managed advisory setup + +The dashboard can install an optional `codexcommander-delegation` user skill plus a bounded global +policy block. These artifacts supply a durable **Balanced** or **Orchestrator** mode hook for new +Codex tasks: + +```text +$HOME/.agents/skills/codexcommander-delegation/SKILL.md +$CODEX_HOME/AGENTS.md +``` + +The skill is advisory and deliberately contains no roster or model ids. Before a spawn it reads the +current collaboration tool contract and live injected guidance; CodexCommander remains the live +roster authority. A roster can change independently without making the installed skill stale. + +The bounded `AGENTS.md` block is the durable mode hook. Codex loads it once for a run, so installing, +updating, changing mode, or removing it does not rewrite the instructions of a current task. Start a +new Codex task after any of those actions. User instructions and repository-scoped instructions +remain higher priority and can limit or forbid delegation even in Orchestrator mode. A nonempty +`$CODEX_HOME/AGENTS.override.md` shadows the global managed block; an empty override does not. + +**Remove setup** deletes only the managed `SKILL.md`, removes its directory only when it is empty, +and removes only the `AGENTS.md` bytes bounded by the exact CodexCommander marker pair. Foreign skill +content, ambiguous markers, linked paths, and unexpected sibling files are never force-removed. + +This advisory setup is separate from the collaboration surface and settings below. It does not call +`/api/v2`, change `subagentDeveloperInstructions`, mutate native `[agents]` defaults or Codex config, +inject a roster, rebuild or activate the catalog, restart a worker, or replace the proxy. Use the +corresponding Run Policy and catalog controls for those operations. + ## Delegation model and effort The dashboard's **Sub-agent delegation** controls three related settings: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 2fbbac0421..e76ea37677 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -50,7 +50,8 @@ A dashboard bound to a non-loopback hostname may use the admin token but the browser prompt is enabled only on a trusted HTTPS origin. A plaintext remote page never asks for or sends the bearer. Without trusted HTTPS, use a local or SSH tunnel that presents the dashboard as loopback, then open it through `ccx gui`. Raw admin remains available to headless management API -clients, but catalog Apply is deliberately restricted to a confirmed local dashboard launch. +clients, but catalog Apply and managed delegation install/remove are deliberately restricted to a +confirmed local dashboard launch. On trusted HTTPS, a remote dashboard presents a standard password form so a browser password manager can offer to save and autofill the credential. The dashboard itself still keeps that raw admin token @@ -71,7 +72,7 @@ the browser or password manager's decision. | **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. A query searches Accounts, Free and Paid together while the tabs remain useful for browsing. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | -| **Subagents** | Open the **Agent Command Center** to choose and order the five models advertised to `spawn_agent`, search the current catalog, and configure Run Policy for protocol, V2 delivery, guidance, fallback, and thread limits. Saved entries that are not advertised are reported explicitly. Its status distinguishes saved configuration, the generated on-disk catalog, and the roster loaded by current Codex workers. | +| **Subagents** | Open the **Agent Command Center** to choose and order the five models advertised to `spawn_agent`, search the current catalog, configure Run Policy, and install the optional advisory Codex delegation setup. Saved entries that are not advertised are reported explicitly. Its status distinguishes saved configuration, the generated on-disk catalog, and the roster loaded by current Codex workers. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose **Reliable V1**, **Codex native**, or **Concurrent V2**, and configure the V2 thread limit. The Current behavior card reports context as **Uncapped**, **Limited**, or **Mixed limits**. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Each routed-provider row reports **Auto-discovery on** or **Static catalog only** and links to the owning Provider setting. | | **Client Apps** | Inspect configured and available local clients, apply or remove managed config where supported, review backups, and reach Codex, Claude Code/Desktop, Grok Build, OpenCode and the file-managed clients without treating providers as clients. | | **API Access** | Issue and manage keys that authenticate other apps to the CodexCommander proxy. Provider credentials remain under Providers. | @@ -138,6 +139,40 @@ A new task or fork within the same ChatGPT worker does not reload its model cata ChatGPT first, then start the new task. For advanced automation, `ccx sync --restart-codex` remains available with the same worker-interruption caveat as the dashboard fallback. +## Install the advisory delegation setup + +The **Subagents → Agent Command Center** includes **Codex delegation setup**, an optional way to give +new Codex tasks a durable delegation mode without freezing today's model roster into instructions. +Use it in this order: + +1. Choose **Balanced** or **Orchestrator**. Balanced delegates substantial, bounded parallel work + when it clearly helps while allowing the root to implement. Orchestrator normally delegates + research and implementation and keeps the root focused on coordination and synthesis; it may + still work directly when delegation is unavailable or clearly wasteful. +2. Choose **Preview** and review the exact two managed artifacts: + `$HOME/.agents/skills/codexcommander-delegation/SKILL.md` and the bounded CodexCommander block in + `$CODEX_HOME/AGENTS.md`. +3. Confirm **Install**, **Update**, or **Repair**. An installed setup instead offers **Change mode**. + The dashboard refuses automatic changes when either path is unsafe, a skill at the target is not + CodexCommander-managed, or the `AGENTS.md` marker pair is ambiguous. +4. Start a new Codex task. Codex reads the global block once per run; installing, repairing, + changing mode, or removing it does not reload a current task. +5. To uninstall, choose **Remove**, then confirm the **Remove delegation setup** dialog. This removes + only the managed `SKILL.md` and bounded `AGENTS.md` block. The skill directory is removed only + when empty, so unrelated siblings are preserved. +6. Expand **Manual setup** and copy its server-provided setup only when the local installer is + unavailable. It is a fallback, not an extra automatic installation method. + +The installed skill is advisory. It carries no roster or model ids and tells Codex to inspect the +current collaboration tool contract and live CodexCommander roster before delegating. Those live +contracts remain authoritative, and user or repository instructions can prohibit delegation. A +nonempty `$CODEX_HOME/AGENTS.override.md` shadows the managed global block; the card reports that +state rather than claiming the setup is Ready. + +This setup does not edit `config.toml`, `subagentDeveloperInstructions`, native `[agents]` defaults, +the featured roster, or the model catalog. It neither restarts a Codex worker nor replaces the +CodexCommander proxy. Configure and activate those separate surfaces through their existing controls. + ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, @@ -247,6 +282,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | | `GET /api/logs?tail=50&limit=20&offset=0&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. With `limit`/`offset`, paging walks backward from the newest row (`offset=0` returns the latest page). Response shape: `{ timeZone, total, logs }` where `total` is the filtered row count before pagination. | | `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | +| `GET` / `PUT` / `DELETE /api/codex-delegation` | Read the managed delegation status and canonical previews, or install/change/remove the two advisory artifacts. PUT/DELETE require a confirmed dashboard launch with same-origin CSRF; a raw admin client receives 403. | | `POST /api/stop` | Persist OFF, restore and prove native Codex, and stop an unsupervised proxy. Returns 409 for an installed supervisor, lifecycle contention, or an unsafe native restore; tray/CLI Stop owns the manager-first delegated path. | :::tip diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index a2db6c72fb..c66a675554 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -62,6 +62,34 @@ replay prefixes and inserted before a trailing `compaction_trigger`. V2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with `fork_turns: "none"`. A custom `injectionPrompt` substitutes missing values with an empty string. +## Managed advisory setup + +**Codex delegation setup** in the Agent Command Center is not a `config.json` field. It manages only: + +```text +$HOME/.agents/skills/codexcommander-delegation/SKILL.md +$CODEX_HOME/AGENTS.md +``` + +The user skill proves CodexCommander ownership through `name: codexcommander-delegation` plus +`metadata.managed-by: codexcommander` and `metadata.managed-version: "1"`. The global policy owns only +the whole-line region from `` through +``. There is no hash, manifest, or hidden ownership file for +this setup. + +The skill is advisory and contains no roster ids. It consults the current collaboration tool +contract and live CodexCommander roster, which remain authoritative. The global block records the +selected `balanced` or `orchestrator` mode and is loaded once per Codex run. Start a new task after +install, update, mode change, repair, or removal; current tasks are not reloaded. User and repository +instructions can prohibit delegation. A nonempty `$CODEX_HOME/AGENTS.override.md` shadows the managed +global block, while an empty override does not. + +Uninstall removes only the owned `SKILL.md`, removes its directory only when empty, and removes only +the bounded global block while preserving every other `AGENTS.md` byte. This setup never mutates +`config.toml`, `subagentDeveloperInstructions`, native `[agents]` defaults, roster injection, or the +catalog, and it does not restart workers or replace the proxy. Those remain separate from the +`/api/codex-delegation` resource. + ## Native Codex default sync When enabled, `syncCodexSubagentDefaults` writes marker-owned diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 288273c226..83c202b2d3 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -60,9 +60,11 @@ isolation. Browsers may copy the record into duplicated or opener-created tabs, restored tab; every copy remains bound to the exact origin and CSRF token and is usable only until the fixed server expiry, a proxy restart, or a rejecting `401`. -The raw admin bearer remains valid for ordinary API mutations. Catalog Apply is deliberately stricter: -`POST /api/codex-catalog/apply` accepts only a confirmed GUI session, so scripts use -`ccx sync --restart-codex` instead. +The raw admin bearer remains valid for ordinary API mutations. Catalog Apply and managed delegation +writes are deliberately stricter: `POST /api/codex-catalog/apply` and +`PUT, DELETE /api/codex-delegation` accept only a confirmed GUI session. Scripts use +`ccx sync --restart-codex` for catalog activation; delegation status remains readable and the +dashboard provides a manual copy fallback when its local installer is unavailable. A remote operator browser may authenticate with the raw admin token only over trusted HTTPS; a plaintext remote page never prompts for or sends it. Without trusted HTTPS, use a local or SSH tunnel @@ -96,6 +98,7 @@ route-specific results rather than repeating this table. | `GET, PUT /api/effort-caps` | Read or set global and sub-agent reasoning-effort ceilings | 400 invalid ladder value | | `GET, PUT /api/subagent-models` | Read or order up to five requested `spawn_agent` quick picks; this does not force routing. Responses keep the persisted `chosen` list separate from the effective `advertised` list, report any `excluded` choices, and include additive `activation` evidence for the desired config, on-disk catalog, and running Codex worker | 400 invalid list or more than five models | | `GET, PUT /api/subagent-model-fallback` | Read or set the ordered global fallback chain for spawned child turns and its poll interval | 400 invalid list or poll interval | +| `GET, PUT, DELETE /api/codex-delegation` | Read managed advisory-delegation status, install/update one exact mode, or remove the two managed artifacts. GET accepts normal authenticated principals; PUT/DELETE require a confirmed GUI session with same-origin CSRF and reject a raw admin principal with 403 | 400 invalid PUT body or nonempty DELETE body; 403 confirmed dashboard launch required; 409 conflict/unsafe/concurrent-change refusal; 500 write or partial-write failure; 503 `mutation_busy` (`Retry-After: 1`) | | `GET /api/grok` | Read Grok managed-config status and candidate models | 400 status read failure | | `PUT /api/grok/selection` | Persist the excluded Grok models | 400 invalid or oversized selection | | `POST /api/grok/apply` | Apply persisted Grok configuration through the managed sync | 409 `grok_apply_busy`; 400/500 apply failure | @@ -128,6 +131,167 @@ snapshot. For scripts or the native companion, `ccx sync --restart-codex` remains the compatible advanced fallback. Quitting and reopening Codex Desktop is the reliable manual worker-replacement boundary. +#### Managed Codex delegation setup + +`/api/codex-delegation` is a focused management resource for the advisory user skill and bounded +global `AGENTS.md` block. It is separate from `/api/v2`, `subagentDeveloperInstructions`, native +`[agents]` defaults, roster injection, and the catalog lifecycle. It changes no Codex config, never +restarts a worker, and never replaces the CodexCommander proxy. Current Codex tasks do not reload the +managed block; start a new task after a successful install, update, mode change, repair, or removal. + +`GET` is authenticated and read-only. A raw admin client or a confirmed GUI session can read it: + +```http +GET /api/codex-delegation HTTP/1.1 +Host: localhost:10100 +X-CodexCommander-API-Key: +``` + +The response uses only symbolic paths and fixed public states. This abridged example omits the +canonical packaged preview and manual-copy text; the live `previews` and `copyPrompts` fields contain +generated setup content, never the existing user `AGENTS.md`, an override file, or an inspected +absolute path. + +```http +HTTP/1.1 200 OK +Cache-Control: no-store +Content-Type: application/json + +{ + "schemaVersion": 1, + "state": "current", + "installedMode": "balanced", + "artifacts": { + "skill": { + "state": "current", + "displayPath": "$HOME/.agents/skills/codexcommander-delegation/SKILL.md" + }, + "agentsPolicy": { + "state": "current", + "displayPath": "$CODEX_HOME/AGENTS.md" + } + }, + "override": { "state": "absent" }, + "activation": "effective" +} +``` + +`PUT` accepts exactly one safe public field, `mode`, with the stable value `balanced` or +`orchestrator`. A confirmed GUI session must supply its exact origin claim, browser `Origin`, and +CSRF token. These placeholders illustrate the launch-session exchange; do not substitute a raw +admin token because that principal receives 403 before the body is consumed. + +```http +PUT /api/codex-delegation HTTP/1.1 +Host: localhost:10100 +Origin: http://localhost:10100 +X-CodexCommander-API-Key: +X-CodexCommander-GUI-Origin: http://localhost:10100 +X-CodexCommander-CSRF-Token: +Content-Type: application/json + +{ "mode": "orchestrator" } +``` + +```http +HTTP/1.1 200 OK +Cache-Control: no-store +Content-Type: application/json + +{ + "ok": true, + "changed": true, + "status": { + "schemaVersion": 1, + "state": "current", + "installedMode": "orchestrator", + "artifacts": { + "skill": { + "state": "current", + "displayPath": "$HOME/.agents/skills/codexcommander-delegation/SKILL.md" + }, + "agentsPolicy": { + "state": "current", + "displayPath": "$CODEX_HOME/AGENTS.md" + } + }, + "override": { "state": "absent" }, + "activation": "effective" + } +} +``` + +`DELETE` accepts no request body and uses the same confirmed-session origin and CSRF headers: + +```http +DELETE /api/codex-delegation HTTP/1.1 +Host: localhost:10100 +Origin: http://localhost:10100 +X-CodexCommander-API-Key: +X-CodexCommander-GUI-Origin: http://localhost:10100 +X-CodexCommander-CSRF-Token: +``` + +```http +HTTP/1.1 200 OK +Cache-Control: no-store +Content-Type: application/json + +{ + "ok": true, + "changed": true, + "status": { + "schemaVersion": 1, + "state": "not-installed", + "installedMode": null, + "artifacts": { + "skill": { + "state": "absent", + "displayPath": "$HOME/.agents/skills/codexcommander-delegation/SKILL.md" + }, + "agentsPolicy": { + "state": "absent", + "displayPath": "$CODEX_HOME/AGENTS.md" + } + }, + "override": { "state": "absent" }, + "activation": "effective" + } +} +``` + +The stable status enums are: + +| Field | Values | +| --- | --- | +| `state` | `not-installed`, `current`, `update-available`, `partial`, `conflict`, `unsafe` | +| `artifacts.*.state` | `absent`, `current`, `outdated`, `foreign`, `unsafe` | +| `artifacts.*.reason` | `ownership_conflict`, `unsafe_path` when present | +| `override.state` | `absent`, `empty`, `active`, `unsafe` | +| `activation` | `effective`, `shadowed`, `unknown` | +| `installedMode` | `balanced`, `orchestrator`, or `null` | + +A refused mutation returns `{ "ok": false, "changed": boolean, "reason": enum, "status": ... }`. +Its stable `reason` values are `foreign_skill`, `ambiguous_agents_markers`, `unsafe_path`, +`unreadable`, `invalid_utf8`, `too_large`, `changed_during_mutation`, `mutation_busy`, +`write_failed`, and `partial_write`. `mutation_busy` returns 503 with `Retry-After: 1`; +`write_failed` and `partial_write` return 500; the other mutation refusals return 409. A +`partial_write` response reports `changed: true`, while successful compensation reports +`changed: false` with the original refusal reason. Every response remains `Cache-Control: no-store`. + +The setup uses fixed filesystem targets and exposes no caller-selected path. Installation writes the +skill before the policy block; removal deletes the policy block before the skill. It recognizes the +skill only through its embedded ownership metadata and the policy only through its stable bounded +marker pair. No hash, manifest, or hidden ownership file is created. Linked, nonregular, multi-link, +ambiguous, or concurrently changed targets fail closed; bytes outside the bounded policy block are +preserved. If the second artifact fails, the installer compensates the first when safe and otherwise +reports a partial write. + +A nonempty `$CODEX_HOME/AGENTS.override.md` makes `activation: "shadowed"`; it is reported, never +modified. The skill carries no roster ids and consults the live collaboration contract. It is +advisory, and live tool guidance plus user or repository instructions remain authoritative about +whether delegation is allowed. + ### Combos | Method and path | Purpose | Notable errors | diff --git a/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx b/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx new file mode 100644 index 0000000000..e1198eb496 --- /dev/null +++ b/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; +import { useT, type TKey } from "../../i18n/shared"; +import { useCopyFeedback } from "../use-copy-feedback"; +import type { CodexDelegationSetupController, CodexDelegationStatus } from "../../pages/use-codex-delegation-setup"; + +function statusKey(status: CodexDelegationStatus): TKey { + if (status.state === "current" && status.activation === "effective") return "sub.delegationSetup.statusReady"; + if (status.state === "current" && status.activation === "shadowed") return "sub.delegationSetup.statusShadowed"; + const keys: Record = { + "not-installed": "sub.delegationSetup.statusNotInstalled", current: "sub.delegationSetup.statusInstalled", + "update-available": "sub.delegationSetup.statusUpdate", partial: "sub.delegationSetup.statusPartial", + conflict: "sub.delegationSetup.statusConflict", unsafe: "sub.delegationSetup.statusUnsafe", + }; + return keys[status.state]; +} + +function blockedReason(status: CodexDelegationStatus): TKey { + const reason = status.artifacts.skill.reason ?? status.artifacts.agentsPolicy.reason; + return reason === "ownership_conflict" ? "sub.delegationSetup.reasonConflict" : "sub.delegationSetup.reasonUnsafe"; +} + +function isOwnedArtifact(state: CodexDelegationStatus["artifacts"]["skill"]["state"]): boolean { + return state === "current" || state === "outdated"; +} + +function isRemovable(status: CodexDelegationStatus): boolean { + const artifactStates = [status.artifacts.skill.state, status.artifacts.agentsPolicy.state]; + const safelyManaged = artifactStates.every(state => state === "absent" || isOwnedArtifact(state)); + if (!safelyManaged || !artifactStates.some(isOwnedArtifact)) return false; + return status.state === "current" || status.state === "update-available" || status.state === "partial" || status.state === "conflict"; +} + +export default function CodexDelegationSetupCard({ delegationSetup }: { delegationSetup: CodexDelegationSetupController }) { + const t = useT(); + const { loaded, status, selectedMode, busy, error, setSelectedMode, install, uninstall } = delegationSetup; + const [previewOpen, setPreviewOpen] = useState(false); + const [removeOpen, setRemoveOpen] = useState(false); + const [previewApply, setPreviewApply] = useState(false); + const [success, setSuccess] = useState(false); + const openerRef = useRef(null); + const previewConfirmRef = useRef(null); + const removeTriggerRef = useRef(null); + const copyFeedback = useCopyFeedback(); + const blocked = status?.state === "conflict" || status?.state === "unsafe"; + const canMutate = loaded && !!status && !blocked && !busy; + const installed = status?.state === "current"; + const removable = !!status && isRemovable(status); + const canRemove = removable && !busy; + const primaryKey = status?.state === "update-available" ? "sub.delegationSetup.update" + : status?.state === "partial" ? "sub.delegationSetup.repair" : "sub.delegationSetup.install"; + const prompt = status?.copyPrompts[selectedMode] ?? ""; + const copyOutcome = copyFeedback.outcomeFor(prompt); + + const closePreview = () => { setPreviewOpen(false); setPreviewApply(false); setTimeout(() => openerRef.current?.focus(), 0); }; + const closeRemove = () => { setRemoveOpen(false); setTimeout(() => removeTriggerRef.current?.focus(), 0); }; + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (previewOpen) { event.preventDefault(); closePreview(); } + if (removeOpen) { event.preventDefault(); closeRemove(); } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); + + useEffect(() => { const timer = setTimeout(() => { if (typeof document === "undefined") return; if (previewOpen) (previewApply ? previewConfirmRef.current : document.querySelector(".swi-delegation-dialog button"))?.focus(); if (removeOpen) document.querySelector('[role="alertdialog"] button')?.focus(); }, 0); return () => clearTimeout(timer); }, [previewApply, previewOpen, removeOpen]); + const trap = (event: React.KeyboardEvent) => { if (event.key !== "Tab") return; const buttons = Array.from(event.currentTarget.querySelectorAll("button:not([disabled])")); if (!buttons.length) return; const first = buttons[0]!; const last = buttons.at(-1)!; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; + const openPreview = (event: React.MouseEvent, apply: boolean) => { openerRef.current = event.currentTarget; setPreviewApply(apply); setPreviewOpen(true); }; + const runInstall = async () => { if (await install()) { setSuccess(true); closePreview(); } }; + const runRemove = async () => { if (await uninstall()) { setSuccess(true); closeRemove(); } }; + + if (!loaded) return

{t("sub.delegationSetup.loading")}

; + + return ( +
+
+

{t("sub.delegationSetup.title")}

{t("sub.delegationSetup.subtitle")}

+ {status && {t(statusKey(status))}} +
+ {!status &&

{t("sub.delegationSetup.error")}

} + {status &&
+
+ {t("sub.delegationSetup.modeLegend")} + {(["balanced", "orchestrator"] as const).map(mode => )} +
+

{t("sub.delegationSetup.liveRoster")}

+
    +
  • {t("sub.delegationSetup.skillArtifact")}{status.artifacts.skill.displayPath}
  • +
  • {t("sub.delegationSetup.agentsArtifact")}{status.artifacts.agentsPolicy.displayPath}
  • +
+ {blocked &&

{t(blockedReason(status))}

} + {error &&

{t("sub.delegationSetup.error")}

} +
+ + {!installed && } + {installed && } + {removable && } +
+ {busy &&

{t("sub.delegationSetup.working")}

} + {success &&

{t("sub.delegationSetup.newTask")}

} +
{t("sub.delegationSetup.manual")}

{t("sub.delegationSetup.manualHint")}

+
} + {previewOpen && status &&
event.stopPropagation()}>

{t("sub.delegationSetup.preview")}

{status.previews[selectedMode].skillText}
{status.previews[selectedMode].agentsBlockText}
{previewApply && }
} + {removeOpen &&
event.stopPropagation()}>

{t("sub.delegationSetup.removeTitle")}

{t("sub.delegationSetup.removeConfirm")}

{error &&

{t("sub.delegationSetup.error")}

}
} +
+ ); +} diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index 00d2f203a6..a468aabd80 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -16,6 +16,8 @@ import { formatNamespacedModelId, providerIconSrc } from "../../provider-icons"; import SubagentDelegationSection from "./SubagentDelegationSection"; import type { DelegationPatch, DelegationModelOption } from "../../pages/use-subagent-delegation"; import type { RosterReachability } from "../../pages/subagent-roster-reachability"; +import CodexDelegationSetupCard from "./CodexDelegationSetupCard"; +import type { CodexDelegationSetupController } from "../../pages/use-codex-delegation-setup"; export const FEATURED_MAX = 5; export const LONG_CONTEXT_MIN = 200_000; @@ -68,6 +70,7 @@ export interface SubagentsWorkspaceProps { onSave: (patch: DelegationPatch) => void | Promise; }; runPolicy?: React.ReactNode; + delegationSetup: CodexDelegationSetupController; } function providerFromSelector(selector: string): string { @@ -163,6 +166,7 @@ export default function SubagentsWorkspace({ onSave, delegation, runPolicy, + delegationSetup, }: SubagentsWorkspaceProps) { const t = useT(); const [query, setQuery] = useState(""); @@ -516,6 +520,7 @@ export default function SubagentsWorkspace({ /> )} + ); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a83325f2fc..c529b6c464 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2197,4 +2197,45 @@ export const de: Record = { "integrations.restoredSuccess": "Die ursprüngliche OpenCode-Konfiguration wurde wiederhergestellt.", "integrations.autoEnabled": "Automatische OpenCode-Verbindung aktiviert.", "integrations.autoDisabled": "Automatische OpenCode-Verbindung deaktiviert.", + "sub.delegationSetup.loading": "Delegationseinrichtung wird geladen…", + "sub.delegationSetup.title": "Codex für diese Liste einrichten", + "sub.delegationSetup.subtitle": "Installiert die aktuelle Listenanleitung für neue Codex-Aufgaben.", + "sub.delegationSetup.statusReady": "Bereit", + "sub.delegationSetup.statusInstalled": "Installiert", + "sub.delegationSetup.statusShadowed": "Installiert, aber AGENTS.override.md ist aktiv", + "sub.delegationSetup.statusNotInstalled": "Nicht installiert", + "sub.delegationSetup.statusUpdate": "Update verfügbar", + "sub.delegationSetup.statusPartial": "Reparatur nötig", + "sub.delegationSetup.statusConflict": "Konflikt", + "sub.delegationSetup.statusUnsafe": "Aufmerksamkeit nötig", + "sub.delegationSetup.modeLegend": "Delegationsmodus", + "sub.delegationSetup.mode.balanced": "Ausgewogen", + "sub.delegationSetup.mode.balancedDescription": "Verwendet einen ausgewogenen Delegationsansatz.", + "sub.delegationSetup.mode.orchestrator": "Orchestrator", + "sub.delegationSetup.mode.orchestratorDescription": "Verwendet einen orchestratorgeführten Delegationsansatz.", + "sub.delegationSetup.liveRoster": "Verwendet die Live-Liste; Modell-IDs werden nicht kopiert.", + "sub.delegationSetup.skillArtifact": "Delegations-Skill", + "sub.delegationSetup.agentsArtifact": "Codex-Anweisungen", + "sub.delegationSetup.preview": "Vorschau", + "sub.delegationSetup.install": "Installieren", + "sub.delegationSetup.update": "Aktualisieren", + "sub.delegationSetup.repair": "Reparieren", + "sub.delegationSetup.changeMode": "Modus ändern", + "sub.delegationSetup.remove": "Entfernen", + "sub.delegationSetup.removeTitle": "Delegationseinrichtung entfernen", + "sub.delegationSetup.removeConfirm": "Die verwaltete Delegationseinrichtung entfernen?", + "sub.delegationSetup.manual": "Installer nicht verfügbar? Manuelle Einrichtung zeigen", + "sub.delegationSetup.manualHint": "Kopieren Sie die vom Server bereitgestellte Eingabeaufforderung für den ausgewählten Modus.", + "sub.delegationSetup.copy": "Einrichtung kopieren", + "sub.delegationSetup.copied": "Kopiert", + "sub.delegationSetup.copyUnavailable": "Kopieren nicht verfügbar", + "sub.delegationSetup.newTask": "Starten Sie eine neue Codex-Aufgabe, um diese Einrichtung zu verwenden.", + "sub.delegationSetup.working": "Wird ausgeführt…", + "sub.delegationSetup.reasonConflict": "Diese Einrichtung kann nicht automatisch geändert werden, weil eine vorhandene Datei nicht von CodexCommander verwaltet wird.", + "sub.delegationSetup.reasonUnsafe": "Diese Einrichtung kann nicht automatisch geändert werden, weil ihre Dateien nicht sicher geprüft werden konnten.", + "sub.delegationSetup.error": "Die Anfrage zur Delegationseinrichtung ist fehlgeschlagen. Versuchen Sie es erneut.", + "sub.delegationSetup.retry": "Erneut versuchen", + "sub.delegationSetup.close": "Schließen", + "sub.delegationSetup.cancel": "Abbrechen", + "sub.delegationSetup.confirmChangeMode": "Modus ändern", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ac0d866d1d..ce870a6d22 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2225,6 +2225,47 @@ export const en = { "integrations.restoredSuccess": "OpenCode's original configuration was restored.", "integrations.autoEnabled": "Automatic OpenCode connection enabled.", "integrations.autoDisabled": "Automatic OpenCode connection disabled.", + "sub.delegationSetup.loading": "Loading delegation setup…", + "sub.delegationSetup.title": "Teach Codex to use this roster", + "sub.delegationSetup.subtitle": "Install the current roster guidance for new Codex tasks.", + "sub.delegationSetup.statusReady": "Ready", + "sub.delegationSetup.statusInstalled": "Installed", + "sub.delegationSetup.statusShadowed": "Installed, but AGENTS.override.md is active", + "sub.delegationSetup.statusNotInstalled": "Not installed", + "sub.delegationSetup.statusUpdate": "Update available", + "sub.delegationSetup.statusPartial": "Needs repair", + "sub.delegationSetup.statusConflict": "Conflict", + "sub.delegationSetup.statusUnsafe": "Needs attention", + "sub.delegationSetup.modeLegend": "Delegation mode", + "sub.delegationSetup.mode.balanced": "Balanced", + "sub.delegationSetup.mode.balancedDescription": "Use a balanced delegation approach.", + "sub.delegationSetup.mode.orchestrator": "Orchestrator", + "sub.delegationSetup.mode.orchestratorDescription": "Use an orchestrator-led delegation approach.", + "sub.delegationSetup.liveRoster": "Uses the live roster; model IDs are not copied into this setup.", + "sub.delegationSetup.skillArtifact": "Delegation skill", + "sub.delegationSetup.agentsArtifact": "Codex instructions", + "sub.delegationSetup.preview": "Preview", + "sub.delegationSetup.install": "Install", + "sub.delegationSetup.update": "Update", + "sub.delegationSetup.repair": "Repair", + "sub.delegationSetup.changeMode": "Change mode", + "sub.delegationSetup.remove": "Remove", + "sub.delegationSetup.removeTitle": "Remove delegation setup", + "sub.delegationSetup.removeConfirm": "Remove the managed delegation setup?", + "sub.delegationSetup.manual": "Installer unavailable? Show manual setup", + "sub.delegationSetup.manualHint": "Copy the server-provided setup prompt for the selected mode.", + "sub.delegationSetup.copy": "Copy setup", + "sub.delegationSetup.copied": "Copied", + "sub.delegationSetup.copyUnavailable": "Copy unavailable", + "sub.delegationSetup.newTask": "Start a new Codex task to use this setup.", + "sub.delegationSetup.working": "Working…", + "sub.delegationSetup.reasonConflict": "This setup can’t be changed automatically because an existing file is not managed by CodexCommander.", + "sub.delegationSetup.reasonUnsafe": "This setup can’t be changed automatically because its files could not be safely verified.", + "sub.delegationSetup.error": "The delegation setup request failed. Try again.", + "sub.delegationSetup.retry": "Retry", + "sub.delegationSetup.close": "Close", + "sub.delegationSetup.cancel": "Cancel", + "sub.delegationSetup.confirmChangeMode": "Change mode", } as const; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 727448dfb9..9356415b83 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2217,4 +2217,45 @@ export const ja: Record = { "integrations.restoredSuccess": "OpenCode の元の設定を復元しました。", "integrations.autoEnabled": "OpenCode の自動接続を有効にしました。", "integrations.autoDisabled": "OpenCode の自動接続を無効にしました。", + "sub.delegationSetup.loading": "委任設定を読み込み中…", + "sub.delegationSetup.title": "このロスターを Codex に使わせる", + "sub.delegationSetup.subtitle": "新しい Codex タスクに現在のロスター指示をインストールします。", + "sub.delegationSetup.statusReady": "準備完了", + "sub.delegationSetup.statusInstalled": "インストール済み", + "sub.delegationSetup.statusShadowed": "インストール済みですが、AGENTS.override.md が有効です", + "sub.delegationSetup.statusNotInstalled": "未インストール", + "sub.delegationSetup.statusUpdate": "更新可能", + "sub.delegationSetup.statusPartial": "修復が必要", + "sub.delegationSetup.statusConflict": "競合", + "sub.delegationSetup.statusUnsafe": "注意が必要", + "sub.delegationSetup.modeLegend": "委任モード", + "sub.delegationSetup.mode.balanced": "バランス", + "sub.delegationSetup.mode.balancedDescription": "バランスの取れた委任方法を使用します。", + "sub.delegationSetup.mode.orchestrator": "オーケストレーター", + "sub.delegationSetup.mode.orchestratorDescription": "オーケストレーター主導の委任方法を使用します。", + "sub.delegationSetup.liveRoster": "ライブロスターを使用します。モデル ID はこの設定にコピーされません。", + "sub.delegationSetup.skillArtifact": "委任スキル", + "sub.delegationSetup.agentsArtifact": "Codex 指示", + "sub.delegationSetup.preview": "プレビュー", + "sub.delegationSetup.install": "インストール", + "sub.delegationSetup.update": "更新", + "sub.delegationSetup.repair": "修復", + "sub.delegationSetup.changeMode": "モードを変更", + "sub.delegationSetup.remove": "削除", + "sub.delegationSetup.removeTitle": "委任設定を削除", + "sub.delegationSetup.removeConfirm": "管理された委任設定を削除しますか?", + "sub.delegationSetup.manual": "インストーラーを利用できませんか? 手動設定を表示", + "sub.delegationSetup.manualHint": "選択したモード用にサーバーが提供した設定プロンプトをコピーします。", + "sub.delegationSetup.copy": "設定をコピー", + "sub.delegationSetup.copied": "コピーしました", + "sub.delegationSetup.copyUnavailable": "コピーできません", + "sub.delegationSetup.newTask": "この設定を使用するには、新しい Codex タスクを開始してください。", + "sub.delegationSetup.working": "処理中…", + "sub.delegationSetup.reasonConflict": "既存のファイルが CodexCommander により管理されていないため、この設定を自動的に変更できません。", + "sub.delegationSetup.reasonUnsafe": "ファイルを安全に検証できないため、この設定を自動的に変更できません。", + "sub.delegationSetup.error": "委任設定リクエストに失敗しました。再試行してください。", + "sub.delegationSetup.retry": "再試行", + "sub.delegationSetup.close": "閉じる", + "sub.delegationSetup.cancel": "キャンセル", + "sub.delegationSetup.confirmChangeMode": "モードを変更", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 33bdacc82a..4591b8c098 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2217,5 +2217,46 @@ export const ko: Record = { "integrations.restoredSuccess": "OpenCode의 원래 설정을 복원했습니다.", "integrations.autoEnabled": "OpenCode 자동 연결을 활성화했습니다.", "integrations.autoDisabled": "OpenCode 자동 연결을 비활성화했습니다.", + "sub.delegationSetup.loading": "위임 설정을 불러오는 중…", + "sub.delegationSetup.title": "Codex가 이 로스터를 사용하도록 설정", + "sub.delegationSetup.subtitle": "새 Codex 작업에 현재 로스터 지침을 설치합니다.", + "sub.delegationSetup.statusReady": "준비됨", + "sub.delegationSetup.statusInstalled": "설치됨", + "sub.delegationSetup.statusShadowed": "설치됨, 하지만 AGENTS.override.md가 활성화됨", + "sub.delegationSetup.statusNotInstalled": "설치되지 않음", + "sub.delegationSetup.statusUpdate": "업데이트 가능", + "sub.delegationSetup.statusPartial": "복구 필요", + "sub.delegationSetup.statusConflict": "충돌", + "sub.delegationSetup.statusUnsafe": "주의 필요", + "sub.delegationSetup.modeLegend": "위임 모드", + "sub.delegationSetup.mode.balanced": "균형", + "sub.delegationSetup.mode.balancedDescription": "균형 잡힌 위임 방식을 사용합니다.", + "sub.delegationSetup.mode.orchestrator": "오케스트레이터", + "sub.delegationSetup.mode.orchestratorDescription": "오케스트레이터 중심 위임 방식을 사용합니다.", + "sub.delegationSetup.liveRoster": "실시간 로스터를 사용하며 모델 ID는 복사되지 않습니다.", + "sub.delegationSetup.skillArtifact": "위임 스킬", + "sub.delegationSetup.agentsArtifact": "Codex 지침", + "sub.delegationSetup.preview": "미리보기", + "sub.delegationSetup.install": "설치", + "sub.delegationSetup.update": "업데이트", + "sub.delegationSetup.repair": "복구", + "sub.delegationSetup.changeMode": "모드 변경", + "sub.delegationSetup.remove": "제거", + "sub.delegationSetup.removeTitle": "위임 설정 제거", + "sub.delegationSetup.removeConfirm": "관리되는 위임 설정을 제거할까요?", + "sub.delegationSetup.manual": "설치 프로그램을 사용할 수 없나요? 수동 설정 표시", + "sub.delegationSetup.manualHint": "선택한 모드의 서버 제공 설정 프롬프트를 복사하세요.", + "sub.delegationSetup.copy": "설정 복사", + "sub.delegationSetup.copied": "복사됨", + "sub.delegationSetup.copyUnavailable": "복사 불가", + "sub.delegationSetup.newTask": "이 설정을 사용하려면 새 Codex 작업을 시작하세요.", + "sub.delegationSetup.working": "작업 중…", + "sub.delegationSetup.reasonConflict": "기존 파일이 CodexCommander에서 관리되지 않아 이 설정을 자동으로 변경할 수 없습니다.", + "sub.delegationSetup.reasonUnsafe": "파일을 안전하게 확인할 수 없어 이 설정을 자동으로 변경할 수 없습니다.", + "sub.delegationSetup.error": "위임 설정 요청에 실패했습니다. 다시 시도하세요.", + "sub.delegationSetup.retry": "다시 시도", + "sub.delegationSetup.close": "닫기", + "sub.delegationSetup.cancel": "취소", + "sub.delegationSetup.confirmChangeMode": "모드 변경", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bd91f38395..ae044afab7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2219,4 +2219,45 @@ export const ru: Record = { "integrations.restoredSuccess": "Исходная конфигурация OpenCode восстановлена.", "integrations.autoEnabled": "Автоматическое подключение OpenCode включено.", "integrations.autoDisabled": "Автоматическое подключение OpenCode выключено.", + "sub.delegationSetup.loading": "Загрузка настройки делегирования…", + "sub.delegationSetup.title": "Настройте Codex для этого списка", + "sub.delegationSetup.subtitle": "Устанавливает текущие инструкции списка для новых задач Codex.", + "sub.delegationSetup.statusReady": "Готово", + "sub.delegationSetup.statusInstalled": "Установлено", + "sub.delegationSetup.statusShadowed": "Установлено, но AGENTS.override.md активен", + "sub.delegationSetup.statusNotInstalled": "Не установлено", + "sub.delegationSetup.statusUpdate": "Доступно обновление", + "sub.delegationSetup.statusPartial": "Требуется восстановление", + "sub.delegationSetup.statusConflict": "Конфликт", + "sub.delegationSetup.statusUnsafe": "Требует внимания", + "sub.delegationSetup.modeLegend": "Режим делегирования", + "sub.delegationSetup.mode.balanced": "Сбалансированный", + "sub.delegationSetup.mode.balancedDescription": "Использует сбалансированный подход к делегированию.", + "sub.delegationSetup.mode.orchestrator": "Оркестратор", + "sub.delegationSetup.mode.orchestratorDescription": "Использует подход с ведущим оркестратором.", + "sub.delegationSetup.liveRoster": "Использует актуальный список; идентификаторы моделей не копируются.", + "sub.delegationSetup.skillArtifact": "Навык делегирования", + "sub.delegationSetup.agentsArtifact": "Инструкции Codex", + "sub.delegationSetup.preview": "Просмотр", + "sub.delegationSetup.install": "Установить", + "sub.delegationSetup.update": "Обновить", + "sub.delegationSetup.repair": "Восстановить", + "sub.delegationSetup.changeMode": "Сменить режим", + "sub.delegationSetup.remove": "Удалить", + "sub.delegationSetup.removeTitle": "Удалить настройку делегирования", + "sub.delegationSetup.removeConfirm": "Удалить управляемую настройку делегирования?", + "sub.delegationSetup.manual": "Установщик недоступен? Показать ручную настройку", + "sub.delegationSetup.manualHint": "Скопируйте предоставленную сервером подсказку для выбранного режима.", + "sub.delegationSetup.copy": "Копировать настройку", + "sub.delegationSetup.copied": "Скопировано", + "sub.delegationSetup.copyUnavailable": "Копирование недоступно", + "sub.delegationSetup.newTask": "Начните новую задачу Codex, чтобы использовать эту настройку.", + "sub.delegationSetup.working": "Выполняется…", + "sub.delegationSetup.reasonConflict": "Эту настройку нельзя изменить автоматически, потому что существующий файл не управляется CodexCommander.", + "sub.delegationSetup.reasonUnsafe": "Эту настройку нельзя изменить автоматически, потому что её файлы не удалось безопасно проверить.", + "sub.delegationSetup.error": "Запрос настройки делегирования не выполнен. Повторите попытку.", + "sub.delegationSetup.retry": "Повторить", + "sub.delegationSetup.close": "Закрыть", + "sub.delegationSetup.cancel": "Отмена", + "sub.delegationSetup.confirmChangeMode": "Сменить режим", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cac576b913..b306e68c0a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2217,4 +2217,45 @@ export const zh: Record = { "integrations.restoredSuccess": "已恢复 OpenCode 的原始配置。", "integrations.autoEnabled": "已启用 OpenCode 自动连接。", "integrations.autoDisabled": "已禁用 OpenCode 自动连接。", + "sub.delegationSetup.loading": "正在加载委派设置…", + "sub.delegationSetup.title": "让 Codex 使用此名册", + "sub.delegationSetup.subtitle": "为新的 Codex 任务安装当前名册指导。", + "sub.delegationSetup.statusReady": "就绪", + "sub.delegationSetup.statusInstalled": "已安装", + "sub.delegationSetup.statusShadowed": "已安装,但 AGENTS.override.md 处于活动状态", + "sub.delegationSetup.statusNotInstalled": "未安装", + "sub.delegationSetup.statusUpdate": "有可用更新", + "sub.delegationSetup.statusPartial": "需要修复", + "sub.delegationSetup.statusConflict": "冲突", + "sub.delegationSetup.statusUnsafe": "需要注意", + "sub.delegationSetup.modeLegend": "委派模式", + "sub.delegationSetup.mode.balanced": "平衡", + "sub.delegationSetup.mode.balancedDescription": "使用平衡的委派方式。", + "sub.delegationSetup.mode.orchestrator": "协调器", + "sub.delegationSetup.mode.orchestratorDescription": "使用协调器主导的委派方式。", + "sub.delegationSetup.liveRoster": "使用实时名册;模型 ID 不会复制到此设置中。", + "sub.delegationSetup.skillArtifact": "委派技能", + "sub.delegationSetup.agentsArtifact": "Codex 指令", + "sub.delegationSetup.preview": "预览", + "sub.delegationSetup.install": "安装", + "sub.delegationSetup.update": "更新", + "sub.delegationSetup.repair": "修复", + "sub.delegationSetup.changeMode": "更改模式", + "sub.delegationSetup.remove": "移除", + "sub.delegationSetup.removeTitle": "移除委派设置", + "sub.delegationSetup.removeConfirm": "移除受管理的委派设置?", + "sub.delegationSetup.manual": "安装程序不可用?显示手动设置", + "sub.delegationSetup.manualHint": "复制所选模式的服务器提供设置提示。", + "sub.delegationSetup.copy": "复制设置", + "sub.delegationSetup.copied": "已复制", + "sub.delegationSetup.copyUnavailable": "无法复制", + "sub.delegationSetup.newTask": "开始新的 Codex 任务以使用此设置。", + "sub.delegationSetup.working": "正在处理…", + "sub.delegationSetup.reasonConflict": "现有文件不由 CodexCommander 管理,因此无法自动更改此设置。", + "sub.delegationSetup.reasonUnsafe": "无法安全验证其文件,因此无法自动更改此设置。", + "sub.delegationSetup.error": "委派设置请求失败。请重试。", + "sub.delegationSetup.retry": "重试", + "sub.delegationSetup.close": "关闭", + "sub.delegationSetup.cancel": "取消", + "sub.delegationSetup.confirmChangeMode": "更改模式", }; diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index eb88dd4e9b..2920e30dbf 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -12,6 +12,7 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation } from "./use-subagent-delegation"; import { useSubagentRunPolicy } from "./use-subagent-run-policy"; +import { useCodexDelegationSetup } from "./use-codex-delegation-setup"; import { deriveRosterReachability, type RosterProjections } from "./subagent-roster-reachability"; import SubagentRunPolicySection from "../components/subagents-workspace/SubagentRunPolicySection"; import { setClientResourceData } from "../client-resource"; @@ -251,6 +252,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const busyRef = useRef(busy); const delegation = useSubagentDelegation(apiBase); const runPolicy = useSubagentRunPolicy(apiBase); + const delegationSetup = useCodexDelegationSetup(apiBase); const loadSubagents = useCallback(async (): Promise => { const rosterRequest = fetch(`${apiBase}/api/subagent-models`) @@ -797,6 +799,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { }} /> )} + delegationSetup={delegationSetup} /> {applyDialog && (
{ if (!busy) setApplyDialog(null); }}> diff --git a/gui/src/pages/use-codex-delegation-setup.ts b/gui/src/pages/use-codex-delegation-setup.ts new file mode 100644 index 0000000000..af85aebd99 --- /dev/null +++ b/gui/src/pages/use-codex-delegation-setup.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type CodexDelegationMode = "balanced" | "orchestrator"; +export type CodexDelegationArtifactState = "absent" | "current" | "outdated" | "foreign" | "unsafe"; + +export interface CodexDelegationStatus { + schemaVersion: 1; + state: "not-installed" | "current" | "update-available" | "partial" | "conflict" | "unsafe"; + installedMode: CodexDelegationMode | null; + artifacts: { + skill: { state: CodexDelegationArtifactState; displayPath: string; reason?: string }; + agentsPolicy: { state: CodexDelegationArtifactState; displayPath: string; reason?: string }; + }; + override: { state: "absent" | "empty" | "active" | "unsafe" }; + activation: "effective" | "shadowed" | "unknown"; + previews: Record; + copyPrompts: Record; +} + +type MutationResponse = { ok?: boolean; status?: CodexDelegationStatus; error?: string }; + +export interface CodexDelegationSetupController { + loaded: boolean; + status: CodexDelegationStatus | null; + selectedMode: CodexDelegationMode; + busy: boolean; + error: string | null; + setSelectedMode(mode: CodexDelegationMode): void; + install(): Promise; + uninstall(): Promise; + reload(): Promise; +} + +const modes = ["balanced", "orchestrator"] as const; +const states = ["not-installed", "current", "update-available", "partial", "conflict", "unsafe"] as const; +const artifacts = ["absent", "current", "outdated", "foreign", "unsafe"] as const; +const includes = (items: readonly T[], value: unknown): value is T => typeof value === "string" && items.includes(value as T); +const record = (value: unknown): Record | null => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + +function isStatus(value: unknown): value is CodexDelegationStatus { + const data = record(value); const artifactSet = record(data?.artifacts); const skill = record(artifactSet?.skill); const policy = record(artifactSet?.agentsPolicy); const previews = record(data?.previews); const balanced = record(previews?.balanced); const orchestrator = record(previews?.orchestrator); const prompts = record(data?.copyPrompts); const override = record(data?.override); + return data?.schemaVersion === 1 && includes(states, data.state) && (data.installedMode === null || includes(modes, data.installedMode)) + && !!skill && !!policy && includes(artifacts, skill.state) && includes(artifacts, policy.state) && typeof skill.displayPath === "string" && typeof policy.displayPath === "string" + && !!balanced && !!orchestrator && typeof balanced.skillText === "string" && typeof balanced.agentsBlockText === "string" && typeof orchestrator.skillText === "string" && typeof orchestrator.agentsBlockText === "string" + && !!prompts && typeof prompts.balanced === "string" && typeof prompts.orchestrator === "string" && !!override && includes(["absent", "empty", "active", "unsafe"] as const, override.state) && includes(["effective", "shadowed", "unknown"] as const, data.activation); +} + +async function responseError(response: Response): Promise { + try { + const data = await response.json() as MutationResponse; + return typeof data.error === "string" ? data.error : `status=${response.status}`; + } catch { + return `status=${response.status}`; + } +} + +export function useCodexDelegationSetup(apiBase: string): CodexDelegationSetupController { + const [loaded, setLoaded] = useState(false); + const [status, setStatus] = useState(null); + const [selectedMode, setSelectedMode] = useState("balanced"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const busyRef = useRef(false); + const selectedModeRef = useRef("balanced"); + const selectedModeInitialized = useRef(false); + const requestRef = useRef(null); + const chooseMode = useCallback((mode: CodexDelegationMode) => { + selectedModeRef.current = mode; + setSelectedMode(mode); + }, []); + + const reload = useCallback(async () => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + try { + const response = await fetch(`${apiBase}/api/codex-delegation`, { signal: controller.signal }); + if (!response.ok) throw new Error(await responseError(response)); + const next: unknown = await response.json(); + if (!isStatus(next)) throw new Error("invalid delegation status"); + if (controller.signal.aborted) return; + setStatus(next); + if (!selectedModeInitialized.current) { + selectedModeInitialized.current = true; + const nextMode = next.installedMode ?? "balanced"; + selectedModeRef.current = nextMode; + setSelectedMode(nextMode); + } + setError(null); + } catch (cause) { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (!controller.signal.aborted) setLoaded(true); + } + }, [apiBase]); + + useEffect(() => { + void (async () => { await reload(); })(); + return () => requestRef.current?.abort(); + }, [reload]); + + const mutate = useCallback(async (method: "PUT" | "DELETE"): Promise => { + if (busyRef.current) return false; + busyRef.current = true; + setBusy(true); + setError(null); + try { + const response = await fetch(`${apiBase}/api/codex-delegation`, method === "PUT" ? { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: selectedModeRef.current }), + } : { method }); + const payload = await response.json() as MutationResponse; + if (!response.ok || payload.ok !== true) throw new Error(payload.error ?? `status=${response.status}`); + await reload(); + return true; + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + return false; + } finally { + busyRef.current = false; + setBusy(false); + } + }, [apiBase, reload]); + + return { loaded, status, selectedMode, busy, error, setSelectedMode: chooseMode, install: () => mutate("PUT"), uninstall: () => mutate("DELETE"), reload }; +} diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index 660879f879..50c4f21441 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -336,6 +336,39 @@ margin-top: 1px; } +/* Managed Codex delegation follows the policy card grammar: one clear primary + action, quiet supporting details, and no roster/model data duplicated here. */ +.swi-delegation-setup { grid-column: 1 / -1; } +.swi-delegation-body { display: flex; flex-direction: column; gap: var(--space-3); padding: 0 var(--space-4) var(--space-4); } +.swi-delegation-badge { align-self: flex-start; padding: var(--space-1) var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-pill); color: var(--muted); background: var(--raised); font-size: var(--text-micro); font-weight: var(--weight-medium); } +.swi-delegation-badge--current { color: var(--green); } +.swi-delegation-badge--update-available, .swi-delegation-badge--partial { color: var(--amber); } +.swi-delegation-badge--conflict, .swi-delegation-badge--unsafe { color: var(--red); } +.swi-delegation-modes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); margin: 0; padding: 0; border: 0; } +.swi-delegation-mode { display: flex; gap: var(--space-2); min-height: var(--control-lg); padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; } +.swi-delegation-mode.is-selected { border-color: var(--accent); background: var(--raised); } +.swi-delegation-mode:focus-within { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.swi-delegation-mode span { display: flex; flex-direction: column; gap: var(--space-1); color: var(--text); font-size: var(--text-label); } +.swi-delegation-mode small, .swi-delegation-note, .swi-delegation-working, .swi-delegation-manual p { color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); } +.swi-delegation-note, .swi-delegation-working, .swi-delegation-manual p { margin: 0; } +.swi-delegation-artifacts { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } +.swi-delegation-artifacts li { display: flex; justify-content: space-between; gap: var(--space-3); padding: var(--space-2); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); color: var(--muted); font-size: var(--text-label); } +.swi-delegation-artifacts code { color: var(--text); overflow-wrap: anywhere; text-align: end; } +.swi-delegation-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.swi-delegation-remove { color: var(--red); } +.swi-delegation-blocked, .swi-delegation-error { margin: 0; padding: var(--space-2); border: 1px solid color-mix(in srgb, var(--red) 35%, var(--border)); border-radius: var(--radius-xs); color: var(--red); font-size: var(--text-label); } +.swi-delegation-manual { border-top: 1px solid var(--border-soft); } +.swi-delegation-manual > summary { min-height: var(--control-lg); padding-top: var(--space-2); color: var(--muted); cursor: pointer; font-size: var(--text-label); font-weight: var(--weight-medium); } +.swi-delegation-manual > summary:hover { color: var(--text); } +.swi-delegation-dialog { max-width: min(680px, calc(100vw - var(--space-6))); } +.swi-delegation-dialog pre { max-height: 230px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } + +@media (max-width: 700px) { + .swi-delegation-modes { grid-template-columns: 1fr; } + .swi-delegation-artifacts li { align-items: flex-start; flex-direction: column; } + .swi-delegation-artifacts code { text-align: start; } +} + /* Split notice: one quiet info row between the roster and the footer, mirroring the .swi-card-footer rhythm (icon + text + inline action). */ .swi-roster-note { diff --git a/gui/tests/codex-delegation-setup.test.tsx b/gui/tests/codex-delegation-setup.test.tsx new file mode 100644 index 0000000000..c3375c6dab --- /dev/null +++ b/gui/tests/codex-delegation-setup.test.tsx @@ -0,0 +1,436 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import CodexDelegationSetupCard from "../src/components/subagents-workspace/CodexDelegationSetupCard"; +import { + useCodexDelegationSetup, + type CodexDelegationMode, + type CodexDelegationSetupController, + type CodexDelegationStatus, +} from "../src/pages/use-codex-delegation-setup"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +function makeStatus(state: CodexDelegationStatus["state"] = "not-installed", mode: CodexDelegationMode | null = null): CodexDelegationStatus { + return { + schemaVersion: 1, state, installedMode: mode, + artifacts: { + skill: { state: mode ? "current" : "absent", displayPath: "$HOME/.agents/skills/codexcommander-delegation/SKILL.md" }, + agentsPolicy: { state: mode ? "current" : "absent", displayPath: "$CODEX_HOME/AGENTS.md" }, + }, + override: { state: "absent" }, activation: "effective", + previews: { + balanced: { skillText: "balanced skill from server", agentsBlockText: "balanced policy from server" }, + orchestrator: { skillText: "orchestrator skill from server", agentsBlockText: "orchestrator policy from server" }, + }, + copyPrompts: { balanced: "balanced manual prompt from server", orchestrator: "orchestrator manual prompt from server" }, + }; +} + +function makeArtifactStatus( + state: CodexDelegationStatus["state"], + skill: CodexDelegationStatus["artifacts"]["skill"]["state"], + agentsPolicy: CodexDelegationStatus["artifacts"]["agentsPolicy"]["state"], + mode: CodexDelegationMode | null, +): CodexDelegationStatus { + const value = makeStatus(state, mode); + value.artifacts.skill.state = skill; + value.artifacts.agentsPolicy.state = agentsPolicy; + return value; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.append(container as never); +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); root = null; }); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function flush() { await new Promise(resolve => setTimeout(resolve, 0)); } + +async function render(node: React.ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(container); root.render({node}); await flush(); }); +} + +function findButton(label: string, within: ParentNode = container): HTMLButtonElement | null { + return Array.from(within.querySelectorAll("button")).find(item => item.textContent?.trim() === label) ?? null; +} + +function button(label: string, within: ParentNode = container): HTMLButtonElement { + const found = findButton(label, within); + if (!found) throw new Error(`Missing button: ${label}`); + return found; +} + +function radio(mode: CodexDelegationMode): HTMLInputElement { + const found = container.querySelector(`input[value="${mode}"]`); + if (!found) throw new Error(`Missing radio: ${mode}`); + return found; +} + +function directController(value: CodexDelegationStatus | null, overrides: Partial = {}): CodexDelegationSetupController { + return { + loaded: value !== null, status: value, selectedMode: value?.installedMode ?? "balanced", busy: false, error: null, + setSelectedMode: () => {}, install: async () => true, uninstall: async () => true, reload: async () => {}, ...overrides, + }; +} + +async function mountDirect(value: CodexDelegationStatus | null, overrides: Partial = {}) { + function Harness() { + const [mode, setMode] = useState(overrides.selectedMode ?? value?.installedMode ?? "balanced"); + return ; + } + await render(); +} + +async function mountHook(apiBase = "/hook") { + function Harness() { return ; } + await render(); +} + +async function openApply(label: "Install" | "Update" | "Repair" | "Change mode") { + await act(async () => { button(label).click(); await flush(); }); + return container.querySelector('[role="dialog"]')!; +} + +test("loading never makes a false not-installed claim", async () => { + await mountDirect(null); + expect(container.textContent).toContain("Loading delegation setup"); + expect(container.textContent).not.toContain("Not installed"); +}); + +test("fresh Install requires preview confirmation, sends exact Balanced PUT, and shows the new-task reminder", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + let current = makeStatus(); + globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), init }); + if (init?.method === "PUT") { current = makeStatus("current", "balanced"); return Response.json({ ok: true, status: current }); } + return Response.json(current); + }; + await mountHook("/fresh"); + expect(radio("balanced").checked).toBe(true); + const dialog = await openApply("Install"); + expect(dialog.textContent).toContain("balanced skill from server"); + expect(requests.some(request => request.init?.method === "PUT")).toBe(false); + await act(async () => { button("Install", dialog).click(); await flush(); }); + const put = requests.find(request => request.init?.method === "PUT")!; + expect(put.url).toBe("/fresh/api/codex-delegation"); + expect(put.init?.headers).toEqual({ "Content-Type": "application/json" }); + expect(put.init?.body).toBe('{"mode":"balanced"}'); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Start a new Codex task"); +}); + +test("Orchestrator selection changes server preview and exact Install PUT", async () => { + const requests: RequestInit[] = []; + let current = makeStatus(); + globalThis.fetch = async (_url, init) => { + requests.push(init ?? {}); + if (init?.method === "PUT") { current = makeStatus("current", "orchestrator"); return Response.json({ ok: true, status: current }); } + return Response.json(current); + }; + await mountHook(); + await act(async () => { radio("orchestrator").click(); }); + const dialog = await openApply("Install"); + expect(dialog.textContent).toContain("orchestrator skill from server"); + expect(dialog.textContent).not.toContain("balanced skill from server"); + await act(async () => { button("Install", dialog).click(); await flush(); }); + expect(requests.find(init => init.method === "PUT")?.body).toBe('{"mode":"orchestrator"}'); +}); + +for (const [state, action] of [["update-available", "Update"], ["partial", "Repair"]] as const) { + test(`${action} confirms its preview and sends the installed mode PUT`, async () => { + const requests: RequestInit[] = []; + let current = makeStatus(state, "balanced"); + globalThis.fetch = async (_url, init) => { + requests.push(init ?? {}); + if (init?.method === "PUT") { current = makeStatus("current", "balanced"); return Response.json({ ok: true, status: current }); } + return Response.json(current); + }; + await mountHook(`/${action.toLowerCase()}`); + const dialog = await openApply(action); + expect(requests.some(init => init.method === "PUT")).toBe(false); + await act(async () => { button(action, dialog).click(); await flush(); }); + expect(requests.find(init => init.method === "PUT")?.body).toBe('{"mode":"balanced"}'); + }); +} + +test("installed Change mode confirms truthfully and sends the selected exact PUT", async () => { + const requests: RequestInit[] = []; + let current = makeStatus("current", "balanced"); + globalThis.fetch = async (_url, init) => { + requests.push(init ?? {}); + if (init?.method === "PUT") { current = makeStatus("current", "orchestrator"); return Response.json({ ok: true, status: current }); } + return Response.json(current); + }; + await mountHook("/change"); + expect(container.textContent).toContain("Ready"); + expect(button("Remove")).toBeTruthy(); + await act(async () => { radio("orchestrator").click(); }); + const dialog = await openApply("Change mode"); + expect(dialog.textContent).toContain("orchestrator policy from server"); + expect(requests.some(init => init.method === "PUT")).toBe(false); + await act(async () => { button("Change mode", dialog).click(); await flush(); }); + expect(requests.find(init => init.method === "PUT")?.body).toBe('{"mode":"orchestrator"}'); +}); + +for (const state of ["conflict", "unsafe"] as const) { + test(`${state} refuses automatic mutation and projects its distinct reason`, async () => { + const value = makeStatus(state); + value.artifacts.skill.reason = state === "conflict" ? "ownership_conflict" : "unsafe_path"; + let installs = 0; + await mountDirect(value, { install: async () => { installs++; return true; } }); + expect(button("Install").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain(state === "conflict" ? "existing file is not managed" : "could not be safely verified"); + button("Install").click(); + expect(installs).toBe(0); + }); +} + +test("shadowed current install is truthful and never claims Ready", async () => { + const value = makeStatus("current", "balanced"); value.activation = "shadowed"; value.override.state = "active"; + await mountDirect(value); + expect(container.textContent).toContain("Installed, but AGENTS.override.md is active"); + expect(container.textContent).not.toContain("Ready"); +}); + +test("manual details are collapsed and copy the selected server prompt only after clipboard success", async () => { + const writes: string[] = []; + Object.defineProperty(testWindow.navigator, "clipboard", { configurable: true, value: { writeText: async (text: string) => { writes.push(text); } } }); + await mountDirect(makeStatus()); + const details = container.querySelector("details") as HTMLDetailsElement; + expect(details.open).toBe(false); + await act(async () => { details.querySelector("summary")!.click(); radio("orchestrator").click(); }); + expect(button("Copy setup").textContent).toBe("Copy setup"); + await act(async () => { button("Copy setup").click(); await flush(); }); + expect(writes).toEqual(["orchestrator manual prompt from server"]); + expect(button("Copied")).toBeTruthy(); +}); + +test("manual copy failure gives honest unavailable feedback and never claims copied", async () => { + Object.defineProperty(testWindow.navigator, "clipboard", { configurable: true, value: { writeText: async () => { throw new Error("denied"); } } }); + Object.defineProperty(testWindow.document, "execCommand", { configurable: true, value: () => false }); + await mountDirect(makeStatus()); + const details = container.querySelector("details")!; + await act(async () => { details.querySelector("summary")!.click(); button("Copy setup").click(); await flush(); }); + expect(button("Copy unavailable")).toBeTruthy(); + expect(container.textContent).not.toContain("Copied"); +}); + +test("Remove sends no DELETE before confirm, retains the failed dialog error, then closes and reminds on success", async () => { + const requests: RequestInit[] = []; + let deletes = 0; let current = makeStatus("current", "balanced"); + globalThis.fetch = async (_url, init) => { + requests.push(init ?? {}); + if (init?.method === "DELETE") { + deletes++; + if (deletes === 1) return Response.json({ error: "locked" }, { status: 500 }); + current = makeStatus(); return Response.json({ ok: true, status: current }); + } + return Response.json(current); + }; + await mountHook("/remove"); + await act(async () => { button("Remove").click(); await flush(); }); + let dialog = container.querySelector('[role="alertdialog"]')!; + expect(requests.some(init => init.method === "DELETE")).toBe(false); + await act(async () => { button("Remove", dialog).click(); await flush(); }); + dialog = container.querySelector('[role="alertdialog"]')!; + expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("request failed"); + expect(requests.find(init => init.method === "DELETE")?.body).toBeUndefined(); + await act(async () => { button("Remove", dialog).click(); await flush(); }); + expect(container.querySelector('[role="alertdialog"]')).toBeNull(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Start a new Codex task"); +}); + +for (const [name, initial] of [ + ["current", makeArtifactStatus("current", "current", "current", "balanced")], + ["update available", makeArtifactStatus("update-available", "outdated", "current", "balanced")], + ["partial managed skill", makeArtifactStatus("partial", "current", "absent", null)], + ["partial managed policy", makeArtifactStatus("partial", "absent", "outdated", "balanced")], + ["compatibility collision", makeArtifactStatus("conflict", "current", "outdated", "balanced")], + ["compatibility collision with managed skill", makeArtifactStatus("conflict", "current", "absent", null)], + ["compatibility collision with managed policy", makeArtifactStatus("conflict", "absent", "outdated", "balanced")], +] as const) { + test(`${name} exposes confirmed Remove and sends a bodyless DELETE`, async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + let current = initial; + globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), init }); + if (init?.method === "DELETE") { + current = makeStatus(); + return Response.json({ ok: true, status: current }); + } + return Response.json(current); + }; + await mountHook(`/remove-${name.replaceAll(" ", "-")}`); + const remove = findButton("Remove"); + expect(remove).not.toBeNull(); + await act(async () => { remove!.click(); await flush(); }); + const dialog = container.querySelector('[role="alertdialog"]')!; + expect(dialog).toBeTruthy(); + expect(requests.some(request => request.init?.method === "DELETE")).toBe(false); + await act(async () => { button("Remove", dialog).click(); await flush(); }); + const deletion = requests.find(request => request.init?.method === "DELETE")!; + expect(deletion.url).toBe(`/remove-${name.replaceAll(" ", "-")}/api/codex-delegation`); + expect(deletion.init?.body).toBeUndefined(); + }); +} + +for (const [name, value] of [ + ["aggregate state without managed artifacts", makeArtifactStatus("partial", "absent", "absent", null)], + ["foreign skill", makeArtifactStatus("conflict", "foreign", "current", "balanced")], + ["ambiguous agents markers", makeArtifactStatus("conflict", "current", "foreign", "balanced")], + ["aggregate unsafe", makeArtifactStatus("unsafe", "current", "current", "balanced")], +] as const) { + test(`${name} never exposes Remove`, async () => { + let uninstalls = 0; + await mountDirect(value, { uninstall: async () => { uninstalls++; return true; } }); + expect(Array.from(container.querySelectorAll("button")).some(item => item.textContent?.trim() === "Remove")).toBe(false); + expect(uninstalls).toBe(0); + }); +} + +test("initial GET failure shows Retry and a successful retry restores truthful status", async () => { + let reads = 0; + globalThis.fetch = async () => ++reads === 1 ? Response.json({ error: "offline" }, { status: 503 }) : Response.json(makeStatus("current", "orchestrator")); + await mountHook("/retry-initial"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("request failed"); + await act(async () => { button("Retry").click(); await flush(); }); + expect(reads).toBe(2); + expect(container.textContent).toContain("Ready"); + expect(radio("orchestrator").checked).toBe(true); +}); + +test("retained-status refresh failure keeps truth, exposes Retry, and clears the error after recovery", async () => { + let reads = 0; const current = makeStatus("current", "balanced"); + globalThis.fetch = async (_url, init) => { + if (init?.method === "PUT") return Response.json({ ok: true, status: current }); + reads++; + if (reads === 2) return Response.json({ error: "refresh failed" }, { status: 503 }); + return Response.json(current); + }; + await mountHook("/retry-retained"); + const dialog = await openApply("Change mode"); + await act(async () => { button("Change mode", dialog).click(); await flush(); }); + expect(container.textContent).toContain("Ready"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("request failed"); + await act(async () => { button("Retry").click(); await flush(); }); + expect(reads).toBe(3); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.textContent).toContain("Ready"); +}); + +test("a superseded GET cannot replace the newer status", async () => { + let resolveFirst!: (response: Response) => void; let reads = 0; + globalThis.fetch = async () => { + reads++; + if (reads === 1) return new Promise(resolve => { resolveFirst = resolve; }); + return Response.json(makeStatus("current", "orchestrator")); + }; + function Harness() { + const setup = useCodexDelegationSetup("/race"); + return <>{setup.status?.installedMode ?? "none"}; + } + await render(); + await act(async () => { button("Force reload").click(); await flush(); }); + expect(container.querySelector("[data-state]")?.textContent).toBe("orchestrator"); + await act(async () => { resolveFirst(Response.json(makeStatus("current", "balanced"))); await flush(); }); + expect(container.querySelector("[data-state]")?.textContent).toBe("orchestrator"); +}); + +test("unmount aborts the outstanding GET and suppresses its late result", async () => { + let signal: AbortSignal | undefined; let resolveRead!: (response: Response) => void; + globalThis.fetch = async (_url, init) => { + signal = init?.signal ?? undefined; + return new Promise(resolve => { resolveRead = resolve; }); + }; + function Harness() { const setup = useCodexDelegationSetup("/unmount"); return {setup.loaded ? "loaded" : "pending"}; } + await render(); + await act(async () => { root?.unmount(); root = null; }); + expect(signal?.aborted).toBe(true); + await act(async () => { resolveRead(Response.json(makeStatus("current", "balanced"))); await flush(); }); + expect(container.textContent).toBe(""); +}); + +test("malformed status is rejected as a visible retriable error", async () => { + globalThis.fetch = async () => Response.json({ schemaVersion: 1, state: "current" }); + await mountHook("/malformed"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("request failed"); + expect(button("Retry")).toBeTruthy(); + expect(container.textContent).not.toContain("Ready"); +}); + +test("in-flight mutation disables every automatic control and announces busy state", async () => { + let resolvePut!: (response: Response) => void; const current = makeStatus("current", "balanced"); + globalThis.fetch = async (_url, init) => init?.method === "PUT" + ? new Promise(resolve => { resolvePut = resolve; }) + : Response.json(current); + await mountHook("/busy"); + const dialog = await openApply("Change mode"); + await act(async () => { button("Change mode", dialog).click(); await flush(); }); + expect(container.querySelector("fieldset")?.hasAttribute("disabled")).toBe(true); + expect(button("Preview").disabled).toBe(true); + expect(button("Change mode").disabled).toBe(true); + expect(button("Remove").disabled).toBe(true); + expect(container.querySelector('[aria-live="polite"]')?.textContent).toContain("Working"); + await act(async () => { resolvePut(Response.json({ ok: true, status: current })); await flush(); }); +}); + +test("preview and remove dialogs take the documented safe initial focus", async () => { + await mountDirect(makeStatus("current", "balanced")); + await openApply("Change mode"); + await act(async () => { await flush(); }); + expect(document.activeElement).toBe(button("Change mode", container.querySelector('[role="dialog"]')!)); + await act(async () => { button("Close").click(); await flush(); button("Remove").click(); await flush(); }); + await act(async () => { await flush(); }); + expect(document.activeElement).toBe(button("Cancel", container.querySelector('[role="alertdialog"]')!)); +}); + +test("preview and remove dialogs trap forward and reverse Tab at their boundaries", async () => { + await mountDirect(makeStatus("current", "balanced")); + const preview = await openApply("Change mode"); + const close = button("Close", preview); const confirm = button("Change mode", preview); + confirm.focus(); confirm.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement).toBe(close); + close.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })); + expect(document.activeElement).toBe(confirm); + await act(async () => { close.click(); await flush(); button("Remove").click(); await flush(); }); + const remove = container.querySelector('[role="alertdialog"]')!; + const cancel = button("Cancel", remove); const confirmRemove = button("Remove", remove); + confirmRemove.focus(); confirmRemove.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement).toBe(cancel); +}); + +test("Escape and backdrop close each dialog and restore its actual opener", async () => { + await mountDirect(makeStatus("current", "balanced")); + const previewOpener = button("Preview"); previewOpener.focus(); + await act(async () => { previewOpener.click(); await flush(); }); + await act(async () => { window.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Escape" })); await flush(); }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(previewOpener); + const removeOpener = button("Remove"); removeOpener.focus(); + await act(async () => { removeOpener.click(); await flush(); }); + const backdrop = container.querySelector('[role="alertdialog"]')!.parentElement!; + await act(async () => { backdrop.dispatchEvent(new testWindow.MouseEvent("mousedown", { bubbles: true })); await flush(); }); + expect(container.querySelector('[role="alertdialog"]')).toBeNull(); + expect(document.activeElement).toBe(removeOpener); +}); diff --git a/gui/tests/subagents-classic.test.ts b/gui/tests/subagents-classic.test.ts index 3da8391ce3..16a7228e74 100644 --- a/gui/tests/subagents-classic.test.ts +++ b/gui/tests/subagents-classic.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import ts from "typescript"; /** * Subagents ships one command-center layout (configured roster + library + policy). @@ -61,3 +62,352 @@ test("Subagents workspace assets and i18n keys are present", async () => { expect(src).toContain("sub.workspace."); } }); + +const delegationSetupKeys = [ + "sub.delegationSetup.loading", + "sub.delegationSetup.title", + "sub.delegationSetup.subtitle", + "sub.delegationSetup.statusReady", + "sub.delegationSetup.statusInstalled", + "sub.delegationSetup.statusShadowed", + "sub.delegationSetup.statusNotInstalled", + "sub.delegationSetup.statusUpdate", + "sub.delegationSetup.statusPartial", + "sub.delegationSetup.statusConflict", + "sub.delegationSetup.statusUnsafe", + "sub.delegationSetup.modeLegend", + "sub.delegationSetup.mode.balanced", + "sub.delegationSetup.mode.balancedDescription", + "sub.delegationSetup.mode.orchestrator", + "sub.delegationSetup.mode.orchestratorDescription", + "sub.delegationSetup.liveRoster", + "sub.delegationSetup.skillArtifact", + "sub.delegationSetup.agentsArtifact", + "sub.delegationSetup.preview", + "sub.delegationSetup.install", + "sub.delegationSetup.update", + "sub.delegationSetup.repair", + "sub.delegationSetup.changeMode", + "sub.delegationSetup.remove", + "sub.delegationSetup.removeTitle", + "sub.delegationSetup.removeConfirm", + "sub.delegationSetup.manual", + "sub.delegationSetup.manualHint", + "sub.delegationSetup.copy", + "sub.delegationSetup.copied", + "sub.delegationSetup.copyUnavailable", + "sub.delegationSetup.newTask", + "sub.delegationSetup.working", + "sub.delegationSetup.reasonConflict", + "sub.delegationSetup.reasonUnsafe", + "sub.delegationSetup.error", + "sub.delegationSetup.retry", + "sub.delegationSetup.close", + "sub.delegationSetup.cancel", + "sub.delegationSetup.confirmChangeMode", +] as const; + +function findHardcodedVisibleJsxCopy(src: string): string[] { + const sourceFile = ts.createSourceFile("fixture.tsx", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const violations: string[] = []; + const nonVisibleAttributes = new Set([ + "aria-busy", "aria-checked", "aria-controls", "aria-current", "aria-describedby", "aria-disabled", + "aria-expanded", "aria-hidden", "aria-labelledby", "aria-live", "aria-modal", "aria-pressed", + "aria-selected", "checked", "className", "defaultChecked", "disabled", "href", "htmlFor", "id", "key", + "multiple", "name", "readOnly", "ref", "rel", "required", "role", "selected", "src", "style", "tabIndex", + "target", "type", + ]); + + const attributeName = (attribute: ts.JsxAttribute) => attribute.name.getText(sourceFile); + const isNonVisibleAttribute = (name: string) => ( + nonVisibleAttributes.has(name) || name.startsWith("data-") || /^on[A-Z]/.test(name) + ); + const unwrap = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isSatisfiesExpression(current) + || ts.isNonNullExpression(current) + ) { + current = current.expression; + } + return current; + }; + const isDelegationTranslationKey = (expression: ts.Expression): boolean => { + const current = unwrap(expression); + if (ts.isStringLiteral(current) || ts.isNoSubstitutionTemplateLiteral(current)) { + return current.text.startsWith("sub.delegationSetup."); + } + if (ts.isTemplateExpression(current)) return current.head.text.startsWith("sub.delegationSetup."); + if (ts.isConditionalExpression(current)) { + return isDelegationTranslationKey(current.whenTrue) && isDelegationTranslationKey(current.whenFalse); + } + // TKey-typed variables and helper results keep non-copy control logic out of + // the JSX while the real source still passes TypeScript's translation-key check. + return ts.isIdentifier(current) || ts.isCallExpression(current) || ts.isPropertyAccessExpression(current); + }; + const isDelegationTranslationCall = (expression: ts.CallExpression) => ( + ts.isIdentifier(expression.expression) + && expression.expression.text === "t" + && expression.arguments.length > 0 + && isDelegationTranslationKey(expression.arguments[0]!) + ); + const isDelegationModeTuple = (expression: ts.Expression) => { + const current = unwrap(expression); + return ts.isArrayLiteralExpression(current) + && current.elements.length === 2 + && ts.isStringLiteral(current.elements[0]!) + && current.elements[0].text === "balanced" + && ts.isStringLiteral(current.elements[1]!) + && current.elements[1].text === "orchestrator"; + }; + const recordLiteral = (node: ts.Node, approvedTechnical: boolean) => { + if (approvedTechnical) return; + const text = ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) + ? node.text + : node.getText(sourceFile).trim(); + if (text) violations.push(text); + }; + const hasTechnicalCopyApproval = (opening: ts.JsxOpeningLikeElement) => { + const tag = opening.tagName.getText(sourceFile); + if (tag !== "code" && tag !== "pre") return false; + const marker = opening.attributes.properties.find( + property => ts.isJsxAttribute(property) && attributeName(property) === "data-i18n-technical", + ); + return !!marker && ts.isJsxAttribute(marker) && !!marker.initializer + && ts.isStringLiteral(marker.initializer) && marker.initializer.text === "true"; + }; + + let scanExpression: (expression: ts.Expression, approvedTechnical: boolean) => void; + let scanJsxElement: (element: ts.JsxElement, approvedTechnical: boolean) => void; + let scanJsxFragment: (fragment: ts.JsxFragment, approvedTechnical: boolean) => void; + + const scanFunctionBody = (body: ts.ConciseBody, approvedTechnical: boolean) => { + if (!ts.isBlock(body)) { + scanExpression(body, approvedTechnical); + return; + } + const visitReturns = (node: ts.Node) => { + if (node !== body && ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression) { + scanExpression(node.expression, approvedTechnical); + return; + } + ts.forEachChild(node, visitReturns); + }; + visitReturns(body); + }; + + const scanObjectLiteral = (object: ts.ObjectLiteralExpression, approvedTechnical: boolean) => { + for (const property of object.properties) { + if (ts.isPropertyAssignment(property)) scanExpression(property.initializer, approvedTechnical); + else if (ts.isSpreadAssignment(property)) scanExpression(property.expression, approvedTechnical); + else if (ts.isMethodDeclaration(property) && property.body) scanFunctionBody(property.body, approvedTechnical); + else if (ts.isGetAccessorDeclaration(property) && property.body) scanFunctionBody(property.body, approvedTechnical); + } + }; + + scanExpression = (expression, approvedTechnical) => { + const current = unwrap(expression); + if (ts.isStringLiteral(current) || ts.isNoSubstitutionTemplateLiteral(current)) { + recordLiteral(current, approvedTechnical); + return; + } + if (ts.isTemplateExpression(current) || ts.isTaggedTemplateExpression(current)) { + recordLiteral(current, approvedTechnical); + return; + } + if (ts.isJsxElement(current)) { + scanJsxElement(current, approvedTechnical); + return; + } + if (ts.isJsxSelfClosingElement(current)) { + scanJsxAttributes(current, false); + return; + } + if (ts.isJsxFragment(current)) { + scanJsxFragment(current, approvedTechnical); + return; + } + if (ts.isConditionalExpression(current)) { + scanExpression(current.whenTrue, approvedTechnical); + scanExpression(current.whenFalse, approvedTechnical); + return; + } + if (ts.isBinaryExpression(current)) { + const operator = current.operatorToken.kind; + if (operator === ts.SyntaxKind.AmpersandAmpersandToken || operator === ts.SyntaxKind.CommaToken) { + scanExpression(current.right, approvedTechnical); + } else if ( + operator === ts.SyntaxKind.BarBarToken + || operator === ts.SyntaxKind.QuestionQuestionToken + || operator === ts.SyntaxKind.PlusToken + || operator === ts.SyntaxKind.EqualsToken + || operator === ts.SyntaxKind.PlusEqualsToken + || operator === ts.SyntaxKind.BarBarEqualsToken + || operator === ts.SyntaxKind.AmpersandAmpersandEqualsToken + || operator === ts.SyntaxKind.QuestionQuestionEqualsToken + ) { + scanExpression(current.left, approvedTechnical); + scanExpression(current.right, approvedTechnical); + } + return; + } + if (ts.isArrayLiteralExpression(current)) { + for (const element of current.elements) { + if (ts.isSpreadElement(element)) scanExpression(element.expression, approvedTechnical); + else scanExpression(element, approvedTechnical); + } + return; + } + if (ts.isObjectLiteralExpression(current)) { + scanObjectLiteral(current, approvedTechnical); + return; + } + if (ts.isCallExpression(current)) { + if (isDelegationTranslationCall(current)) return; + const callee = unwrap(current.expression); + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + // These exact machine values select translated mode labels; they are + // not rendered copy. Other literal-bearing call receivers still fail. + if (!isDelegationModeTuple(callee.expression)) scanExpression(callee.expression, approvedTechnical); + } else if (!ts.isIdentifier(callee)) { + scanExpression(callee, approvedTechnical); + } + for (const argument of current.arguments) scanExpression(argument, approvedTechnical); + return; + } + if (ts.isNewExpression(current)) { + for (const argument of current.arguments ?? []) scanExpression(argument, approvedTechnical); + return; + } + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + scanFunctionBody(current.body, approvedTechnical); + return; + } + if (ts.isPropertyAccessExpression(current) || ts.isElementAccessExpression(current)) { + scanExpression(current.expression, approvedTechnical); + return; + } + if (ts.isAwaitExpression(current) || ts.isYieldExpression(current)) { + if (current.expression) scanExpression(current.expression, approvedTechnical); + } + }; + + const scanJsxAttribute = (attribute: ts.JsxAttribute, approvedTechnical: boolean) => { + if (isNonVisibleAttribute(attributeName(attribute)) || !attribute.initializer) return; + if (ts.isStringLiteral(attribute.initializer)) recordLiteral(attribute.initializer, approvedTechnical); + else if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) { + scanExpression(attribute.initializer.expression, approvedTechnical); + } + }; + function scanJsxAttributes(opening: ts.JsxOpeningLikeElement, approvedTechnical: boolean) { + for (const property of opening.attributes.properties) { + if (ts.isJsxAttribute(property)) scanJsxAttribute(property, approvedTechnical); + else scanExpression(property.expression, approvedTechnical); + } + } + const scanJsxChild = (child: ts.JsxChild, approvedTechnical: boolean) => { + if (ts.isJsxText(child)) recordLiteral(child, approvedTechnical); + else if (ts.isJsxExpression(child) && child.expression) scanExpression(child.expression, approvedTechnical); + else if (ts.isJsxElement(child)) scanJsxElement(child, approvedTechnical); + else if (ts.isJsxSelfClosingElement(child)) scanJsxAttributes(child, false); + else if (ts.isJsxFragment(child)) scanJsxFragment(child, approvedTechnical); + }; + scanJsxElement = (element, approvedTechnical) => { + const childTechnicalApproval = approvedTechnical || hasTechnicalCopyApproval(element.openingElement); + scanJsxAttributes(element.openingElement, false); + for (const child of element.children) scanJsxChild(child, childTechnicalApproval); + }; + scanJsxFragment = (fragment, approvedTechnical) => { + for (const child of fragment.children) scanJsxChild(child, approvedTechnical); + }; + + const visitTopLevel = (node: ts.Node) => { + if (ts.isJsxElement(node)) scanJsxElement(node, false); + else if (ts.isJsxSelfClosingElement(node)) scanJsxAttributes(node, false); + else if (ts.isJsxFragment(node)) scanJsxFragment(node, false); + else ts.forEachChild(node, visitTopLevel); + }; + visitTopLevel(sourceFile); + return violations; +} + +test("visible-copy contract rejects direct and recursively wrapped JSX literal mutations", () => { + const fixtures = [ + ["direct JSX text", "const Card = () =>

Direct visible copy

;"], + ["direct string expression", 'const Card = () =>

{"Direct expression copy"}

;'], + ["parenthesized literal", 'const Card = () =>

{("Parenthesized copy")}

;'], + ["asserted literal", 'const Card = () =>

{("Asserted copy" as string)}

;'], + ["conditional literal", 'const Card = () =>

{enabled ? "Conditional copy" : serverCopy}

;'], + ["array-wrapped literal", 'const Card = () =>

{["Array copy"]}

;'], + ["call-wrapped literal", 'const Card = () =>

{renderCopy("Call copy")}

;'], + ] as const; + + const actual = Object.fromEntries(fixtures.map(([name, src]) => [name, findHardcodedVisibleJsxCopy(src)])); + expect(actual).toEqual({ + "direct JSX text": ["Direct visible copy"], + "direct string expression": ["Direct expression copy"], + "parenthesized literal": ["Parenthesized copy"], + "asserted literal": ["Asserted copy"], + "conditional literal": ["Conditional copy"], + "array-wrapped literal": ["Array copy"], + "call-wrapped literal": ["Call copy"], + }); +}); + +test("visible-copy contract rejects unapproved code and pre literal mutations", () => { + const src = 'const Card = () => <>{"Code visible copy"}
{`Pre visible copy`}
;'; + expect(findHardcodedVisibleJsxCopy(src)).toEqual(["Code visible copy", "Pre visible copy"]); +}); + +test("visible-copy contract rejects literals in every literal-bearing visible prop", () => { + const src = `const Card = () => <> + Alt visible copy +