diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs
index 5259271e0..cf61c0c4c 100644
--- a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs
+++ b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ readPublishedCatalogPersonaVersions,
readSharedCatalogPersonaIds,
+ writePublishedCatalogPersonaVersions,
writeSharedCatalogPersonaIds,
} from "./personaCatalogVisibility.ts";
@@ -54,3 +56,49 @@ test("catalog visibility persists persona ids without blocking on storage errors
}),
);
});
+
+test("catalog publication versions read only string revisions", () => {
+ const storage = {
+ getItem: () =>
+ JSON.stringify({
+ "custom:analyst": "2026-07-22T00:00:00.000Z",
+ "custom:invalid": 42,
+ }),
+ };
+
+ assert.deepEqual(readPublishedCatalogPersonaVersions(storage), {
+ "custom:analyst": "2026-07-22T00:00:00.000Z",
+ });
+ assert.deepEqual(readPublishedCatalogPersonaVersions(null), {});
+ assert.deepEqual(
+ readPublishedCatalogPersonaVersions({ getItem: () => "[]" }),
+ {},
+ );
+});
+
+test("catalog publication versions persist without blocking on storage errors", () => {
+ let storedKey = "";
+ let storedValue = "";
+ writePublishedCatalogPersonaVersions(
+ { "custom:analyst": "2026-07-22T00:00:00.000Z" },
+ {
+ setItem: (key, value) => {
+ storedKey = key;
+ storedValue = value;
+ },
+ },
+ );
+
+ assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1");
+ assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}');
+ assert.doesNotThrow(() =>
+ writePublishedCatalogPersonaVersions(
+ { "custom:analyst": "2026-07-22T00:00:00.000Z" },
+ {
+ setItem: () => {
+ throw new Error("unavailable");
+ },
+ },
+ ),
+ );
+});
diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts
index 43b30036e..433df35c3 100644
--- a/desktop/src/features/agents/lib/personaCatalogVisibility.ts
+++ b/desktop/src/features/agents/lib/personaCatalogVisibility.ts
@@ -1,5 +1,9 @@
const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
"buzz-persona-catalog-visibility-v1";
+const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY =
+ "buzz-persona-catalog-published-versions-v1";
+
+export type PublishedCatalogPersonaVersions = Record;
function resolveStorage(
storage: Pick | null | undefined,
@@ -49,3 +53,47 @@ export function writeSharedCatalogPersonaIds(
// Catalog visibility is a convenience setting and should not block sharing.
}
}
+
+export function readPublishedCatalogPersonaVersions(
+ storage?: Pick | null,
+): PublishedCatalogPersonaVersions {
+ const targetStorage = resolveStorage(storage);
+ if (!targetStorage) return {};
+
+ try {
+ const raw = targetStorage.getItem(
+ PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY,
+ );
+ if (!raw) return {};
+
+ const parsed: unknown = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(parsed).filter(
+ (entry): entry is [string, string] => typeof entry[1] === "string",
+ ),
+ );
+ } catch {
+ return {};
+ }
+}
+
+export function writePublishedCatalogPersonaVersions(
+ versions: Readonly,
+ storage?: Pick | null,
+): void {
+ const targetStorage = resolveStorage(storage);
+ if (!targetStorage) return;
+
+ try {
+ targetStorage.setItem(
+ PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY,
+ JSON.stringify(versions),
+ );
+ } catch {
+ // Catalog publication state should not block sharing.
+ }
+}
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
index 049fa8230..81bc3948a 100644
--- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
@@ -35,9 +35,9 @@ import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
+ buildPersonaRuntimeDropdownOptions,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
computeLocalModeGate,
- formatRuntimeOptionLabel,
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
@@ -49,7 +49,6 @@ import {
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
shouldClearKnownModelForSelectionScope,
- sortPersonaRuntimes,
} from "./agentConfigOptions";
import { RequiredFieldLabel } from "./agentConfigControls";
import {
@@ -97,13 +96,20 @@ type AgentDefinitionDialogProps = {
onOpenChange: (open: boolean) => void;
onSubmit: (
input: CreatePersonaInput | UpdatePersonaInput,
+ options: AgentDefinitionSubmitOptions,
) => Promise;
+ /** Offers publishing alongside Save after a shared agent has been edited. */
+ showPublishUpdatesOption?: boolean;
/** Rendered below the form fields in create mode only ("Where to run"). */
createRunSection?: React.ReactNode;
/** Extra create-mode submit gate (e.g. incomplete provider config). */
createSubmitBlocked?: boolean;
};
+export type AgentDefinitionSubmitOptions = {
+ publishCatalogUpdates: boolean;
+};
+
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
@@ -121,6 +127,7 @@ export function AgentDefinitionDialog({
runtimesLoading = false,
onOpenChange,
onSubmit,
+ showPublishUpdatesOption = false,
createRunSection,
createSubmitBlocked = false,
}: AgentDefinitionDialogProps) {
@@ -158,6 +165,9 @@ export function AgentDefinitionDialog({
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
+ const [hasUserChanges, setHasUserChanges] = React.useState(false);
+ const [publishUpdatesChecked, setPublishUpdatesChecked] =
+ React.useState(false);
const {
globalConfig,
inheritedDefaults: {
@@ -213,6 +223,8 @@ export function AgentDefinitionDialog({
// sufficient reason to auto-open.
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
+ setHasUserChanges(false);
+ setPublishUpdatesChecked(false);
isRuntimeAutoSeededRef.current = false;
hasSeededForOpenRef.current = false;
}, [initialValues, open]);
@@ -258,6 +270,8 @@ export function AgentDefinitionDialog({
behaviorSeedRef.current = emptyPersonaBehaviorDraft;
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
+ setHasUserChanges(false);
+ setPublishUpdatesChecked(false);
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
// [initialValues, open] effect resets both when the dialog re-opens.
}
@@ -309,14 +323,20 @@ export function AgentDefinitionDialog({
};
if ("id" in initialValues) {
- await onSubmit({
- id: initialValues.id,
- ...baseInput,
- });
+ await onSubmit(
+ {
+ id: initialValues.id,
+ ...baseInput,
+ },
+ {
+ publishCatalogUpdates:
+ showPublishUpdatesOption && hasUserChanges && publishUpdatesChecked,
+ },
+ );
return;
}
- await onSubmit(baseInput);
+ await onSubmit(baseInput, { publishCatalogUpdates: false });
}
function handleSubmitForm(event: React.FormEvent) {
@@ -342,6 +362,7 @@ export function AgentDefinitionDialog({
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
useRuntimeFileConfigQuery(runtime, { enabled: open });
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
+ setHasUserChanges(true);
setAiConfigurationMode(nextMode);
setIsCustomProviderEditing(false);
setIsCustomModelEditing(false);
@@ -552,41 +573,14 @@ export function AgentDefinitionDialog({
const showCustomProviderInput =
llmProviderFieldVisible && isCustomProviderEditing;
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
- const sortedRuntimes = React.useMemo(
- () => sortPersonaRuntimes(runtimes),
- [runtimes],
- );
- const blankRuntimeOptionLabel = runtimesLoading
- ? "Loading harnesses..."
- : isCreateMode
- ? "Choose a harness"
- : "No preference (use app default)";
- const runtimeDropdownOptions: PersonaDropdownOption[] = [
- ...(!isCreateMode
- ? [
- {
- label: blankRuntimeOptionLabel,
- value: NO_RUNTIME_DROPDOWN_VALUE,
- },
- ]
- : []),
- ...sortedRuntimes.map((candidate) => ({
- disabled: isCreateMode && candidate.availability !== "available",
- label: `${formatRuntimeOptionLabel(candidate)}${
- isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : ""
- }`,
- value: candidate.id,
- })),
- ];
- if (
- runtime.trim().length > 0 &&
- !runtimeDropdownOptions.some((option) => option.value === runtime)
- ) {
- runtimeDropdownOptions.push({
- label: `${runtime.trim()} (current)`,
- value: runtime.trim(),
+ const { blankRuntimeOptionLabel, runtimeDropdownOptions } =
+ buildPersonaRuntimeDropdownOptions({
+ defaultRuntimeId: defaultRuntime?.id,
+ isCreateMode,
+ runtime,
+ runtimes,
+ runtimesLoading,
});
- }
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions
.filter((option) => option.id.trim().length > 0)
@@ -673,6 +667,7 @@ export function AgentDefinitionDialog({
}
function handleRuntimeDropdownChange(nextValue: string) {
+ setHasUserChanges(true);
const nextRuntime =
nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
// The user made an explicit choice — no longer auto-seeded.
@@ -691,6 +686,7 @@ export function AgentDefinitionDialog({
}
function handleProviderDropdownChange(nextValue: string) {
+ setHasUserChanges(true);
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") {
@@ -708,6 +704,7 @@ export function AgentDefinitionDialog({
}
function handleModelDropdownChange(nextValue: string) {
+ setHasUserChanges(true);
applySelection(
selectionOnModelDropdownChange(selection, {
nextValue,
@@ -739,6 +736,9 @@ export function AgentDefinitionDialog({
isAvatarUploadPending={isAvatarUploadPending}
isPending={isPending}
onCancel={() => handleOpenChange(false)}
+ onPublishUpdatesCheckedChange={setPublishUpdatesChecked}
+ publishUpdatesChecked={publishUpdatesChecked}
+ showPublishUpdates={showPublishUpdatesOption && hasUserChanges}
submitBlockReason={displayName.trim() ? submitBlockReason : null}
submitLabel={submitLabel}
/>
@@ -747,15 +747,22 @@ export function AgentDefinitionDialog({
-
+
+
+ {isCatalogVisible && hasCatalogUpdates ? (
+
+ ) : null}
+
)
}
diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx
index 6ae81ff6c..dd1120806 100644
--- a/desktop/src/features/agents/ui/agentConfigOptions.tsx
+++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx
@@ -426,6 +426,57 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
return `${runtime.label}${suffix}`;
}
+export function buildPersonaRuntimeDropdownOptions({
+ defaultRuntimeId,
+ isCreateMode,
+ runtime,
+ runtimes,
+ runtimesLoading,
+}: {
+ defaultRuntimeId?: string;
+ isCreateMode: boolean;
+ runtime: string;
+ runtimes: AcpRuntimeCatalogEntry[];
+ runtimesLoading: boolean;
+}): {
+ blankRuntimeOptionLabel: string;
+ runtimeDropdownOptions: PersonaDropdownOption[];
+} {
+ const blankRuntimeOptionLabel = runtimesLoading
+ ? "Loading harnesses..."
+ : isCreateMode
+ ? "Choose a harness"
+ : "No preference (use app default)";
+ const runtimeDropdownOptions: PersonaDropdownOption[] = [
+ ...(!isCreateMode
+ ? [
+ {
+ label: blankRuntimeOptionLabel,
+ value: NO_RUNTIME_DROPDOWN_VALUE,
+ },
+ ]
+ : []),
+ ...sortPersonaRuntimes(runtimes).map((candidate) => ({
+ disabled: isCreateMode && candidate.availability !== "available",
+ label: `${formatRuntimeOptionLabel(candidate)}${
+ isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : ""
+ }`,
+ value: candidate.id,
+ })),
+ ];
+ const currentRuntime = runtime.trim();
+ if (
+ currentRuntime.length > 0 &&
+ !runtimeDropdownOptions.some((option) => option.value === currentRuntime)
+ ) {
+ runtimeDropdownOptions.push({
+ label: `${currentRuntime} (current)`,
+ value: currentRuntime,
+ });
+ }
+ return { blankRuntimeOptionLabel, runtimeDropdownOptions };
+}
+
function runtimeAvailabilitySortRank(
availability: AcpRuntimeCatalogEntry["availability"],
) {
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 494b4ae1c..217a96a40 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -19,7 +19,9 @@ import {
} from "@/features/agents/hooks";
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
import {
+ readPublishedCatalogPersonaVersions,
readSharedCatalogPersonaIds,
+ writePublishedCatalogPersonaVersions,
writeSharedCatalogPersonaIds,
} from "@/features/agents/lib/personaCatalogVisibility";
import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment";
@@ -94,6 +96,8 @@ export function usePersonaActions() {
const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState<
string[]
>(readSharedCatalogPersonaIds);
+ const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] =
+ React.useState>(readPublishedCatalogPersonaVersions);
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
string | null
>(null);
@@ -137,6 +141,7 @@ export function usePersonaActions() {
intent?: AgentCreateIntent,
backendIntent?: BackendIntent | null,
targetChannel?: Pick | null,
+ options?: { publishCatalogUpdates?: boolean },
): Promise {
if (isPersonaSubmitPending) {
return false;
@@ -146,7 +151,10 @@ export function usePersonaActions() {
setIsPersonaSubmitPending(true);
try {
if ("id" in input) {
- await updatePersonaMutation.mutateAsync(input);
+ const updatedPersona = await updatePersonaMutation.mutateAsync(input);
+ if (options?.publishCatalogUpdates) {
+ publishPersonaCatalogUpdates(updatedPersona);
+ }
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
} else {
const runtime = availableRuntimes.find(
@@ -354,6 +362,16 @@ export function usePersonaActions() {
linkedAgent: ManagedAgent | undefined,
) {
clearFeedback("library");
+ if (
+ sharedCatalogPersonaIdSet.has(persona.id) &&
+ publishedCatalogPersonaVersions[persona.id] === undefined
+ ) {
+ setPublishedCatalogPersonaVersions((current) => {
+ const next = { ...current, [persona.id]: persona.updatedAt };
+ writePublishedCatalogPersonaVersions(next);
+ return next;
+ });
+ }
setPersonaToShare({
persona,
linkedAgentPubkey: linkedAgent?.pubkey ?? null,
@@ -412,6 +430,35 @@ export function usePersonaActions() {
writeSharedCatalogPersonaIds(ids);
return ids;
});
+ setPublishedCatalogPersonaVersions((current) => {
+ const next = { ...current };
+ if (visible) {
+ next[persona.id] = persona.updatedAt;
+ } else {
+ delete next[persona.id];
+ }
+ writePublishedCatalogPersonaVersions(next);
+ return next;
+ });
+ }
+
+ function hasPersonaCatalogUpdates(persona: AgentPersona) {
+ const publishedVersion = publishedCatalogPersonaVersions[persona.id];
+ return (
+ sharedCatalogPersonaIdSet.has(persona.id) &&
+ publishedVersion !== undefined &&
+ publishedVersion !== persona.updatedAt
+ );
+ }
+
+ function publishPersonaCatalogUpdates(persona: AgentPersona) {
+ if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return;
+
+ setPublishedCatalogPersonaVersions((current) => {
+ const next = { ...current, [persona.id]: persona.updatedAt };
+ writePublishedCatalogPersonaVersions(next);
+ return next;
+ });
}
const isPending =
@@ -460,6 +507,8 @@ export function usePersonaActions() {
setPersonaToExportSnapshot,
handleExportSnapshot,
setPersonaCatalogVisibility,
+ hasPersonaCatalogUpdates,
+ publishPersonaCatalogUpdates,
sharedCatalogPersonaIdSet,
clearFeedback,
snapshotImportState,
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index df7713266..b822013db 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -1288,6 +1288,11 @@ test("custom personas share with people and keep export separate", async ({
test("custom personas can be shared to the relay catalog", async ({ page }) => {
const personaId = "custom:catalog-analyst";
await installMockBridge(page, {
+ globalAgentConfig: {
+ env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ },
personas: [
{
id: personaId,
@@ -1319,9 +1324,14 @@ This deliberately long fenced-code example must not establish the minimum width
await page.getByLabel("Open actions for Catalog Analyst").click();
await page.getByRole("menuitem", { name: "Share" }).click();
const catalogToggle = page.getByTestId("persona-share-show-in-catalog");
+ const publishCatalogUpdatesButton = page.getByTestId(
+ "persona-share-publish-catalog-updates",
+ );
await expect(catalogToggle).toHaveAttribute("aria-checked", "false");
+ await expect(publishCatalogUpdatesButton).toHaveCount(0);
await catalogToggle.click();
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
+ await expect(publishCatalogUpdatesButton).toHaveCount(0);
await page
.getByTestId("persona-share-dialog")
.getByRole("button", { name: "Close" })
@@ -1347,9 +1357,65 @@ This deliberately long fenced-code example must not establish the minimum width
).toBeLessThanOrEqual(1);
await page.keyboard.press("Escape");
+ await page.getByLabel("Open actions for Catalog Analyst").click();
+ await page.getByRole("menuitem", { name: "Edit" }).click();
+ const editDialog = page.getByTestId("persona-dialog");
+ const publishUpdatesCheckbox = editDialog.getByTestId(
+ "persona-dialog-publish-updates",
+ );
+ await expect(publishUpdatesCheckbox).toHaveCount(0);
+ await editDialog
+ .getByLabel("Agent instructions")
+ .fill("Review the latest catalog changes.");
+ await expect(publishUpdatesCheckbox).toBeVisible();
+ await expect(publishUpdatesCheckbox).toHaveAttribute(
+ "data-state",
+ "unchecked",
+ );
+ const [saveButtonBox, publishUpdatesCheckboxBox] = await Promise.all([
+ editDialog.getByRole("button", { name: "Save changes" }).boundingBox(),
+ publishUpdatesCheckbox.boundingBox(),
+ ]);
+ expect(publishUpdatesCheckboxBox?.x ?? 0).toBeGreaterThan(
+ (saveButtonBox?.x ?? 0) + (saveButtonBox?.width ?? 0),
+ );
+ await editDialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(editDialog).toHaveCount(0);
+
await page.getByLabel("Open actions for Catalog Analyst").click();
await page.getByRole("menuitem", { name: "Share" }).click();
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
+ await expect(publishCatalogUpdatesButton).toBeVisible();
+ const [catalogToggleBox, publishCatalogUpdatesButtonBox] = await Promise.all([
+ catalogToggle.boundingBox(),
+ publishCatalogUpdatesButton.boundingBox(),
+ ]);
+ expect(publishCatalogUpdatesButtonBox?.x ?? 0).toBeGreaterThan(
+ (catalogToggleBox?.x ?? 0) + (catalogToggleBox?.width ?? 0),
+ );
+ await publishCatalogUpdatesButton.click();
+ await expect(publishCatalogUpdatesButton).toHaveCount(0);
+ await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
+ await page
+ .getByTestId("persona-share-dialog")
+ .getByRole("button", { name: "Close" })
+ .click();
+
+ await page.getByLabel("Open actions for Catalog Analyst").click();
+ await page.getByRole("menuitem", { name: "Edit" }).click();
+ await editDialog
+ .getByLabel("Agent instructions")
+ .fill("Review and publish the latest catalog changes.");
+ await expect(publishUpdatesCheckbox).toBeVisible();
+ await publishUpdatesCheckbox.click();
+ await expect(publishUpdatesCheckbox).toHaveAttribute("data-state", "checked");
+ await editDialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(editDialog).toHaveCount(0);
+
+ await page.getByLabel("Open actions for Catalog Analyst").click();
+ await page.getByRole("menuitem", { name: "Share" }).click();
+ await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
+ await expect(publishCatalogUpdatesButton).toHaveCount(0);
await catalogToggle.click();
await page
.getByTestId("persona-share-dialog")