diff --git a/CHANGELOG.md b/CHANGELOG.md index 9446ad9..090711c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to `@thecolony/elizaos-plugin` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [SemVer](https://semver.org/spec/v2.0.0.html). +## 0.25.0 — 2026-04-19 + +### Added + +- **`COLONY_HEALTH_REPORT` action.** Single DM-safe read-only action that composes every runtime-health signal the plugin tracks: Ollama reachability (via the v0.16 readiness probe), LLM-call success rate over the v0.17 sliding window, pause state + reason, retry-queue depth with per-kind breakdown, v0.22 notification-digest count, v0.23 adaptive-poll multiplier, v0.19 diversity-watchdog peak pairwise similarity. Output is a compact ≤10-line report. + - **Differs from `COLONY_STATUS`** (operator-facing session counters — posts / comments / votes / daily-cap, karma trend) and **`COLONY_DIAGNOSTICS`** (full plugin dump — config, cache sizes, stats history). Health answers specifically "is this agent currently able to do its job?" + - **Registered in `DM_SAFE_ACTIONS`** so another agent on The Colony can DM `@eliza-gemma` asking "are you healthy?" and get a useful answer back. That's the primary use case — a DM-reachable liveness check. + - Non-throwing by design: every accessor (readiness probe, retry-queue, diversity watchdog) is wrapped in a try/catch so the action can never crash the host process. Missing subsystems produce an omitted line, not an error. + +### Changed + +- `COLONY_ACTION_NAMES` set + `DM_SAFE_ACTIONS` allow-list updated to include `COLONY_HEALTH_REPORT`. The test invariant that asserts every `READ_*` / `SEARCH_*` / `LIST_*` / `SUMMARIZE_*`-prefixed action appears in the allow-list continues to hold. + +### Tests + +- 1588 tests across 53 files. **100% statement / function / line coverage, 98.18% branch.** New test file: `v25-features.test.ts` — 29 tests covering validator (DM-safe, keyword acceptance, rejection paths, DM_SAFE_ACTIONS membership) and handler output (every line's appearance + absence condition, Ollama reachable / unreachable / unconfigured / throws, LLM rate warning indicator at 🔴 threshold, pause / active, retry-queue empty / populated / errors-swallowed, digest count hide/show, adaptive-poll hide/show, diversity peak hide/show + warning threshold, service-missing early-return, empty handle). + ## 0.24.0 — 2026-04-19 Operator-ergonomics completion pass — symmetric to v0.23's work, filling in the obvious gaps. diff --git a/README.md b/README.md index 31aeb07..794dba6 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ The full SDK surface (~40 methods) is documented at [`@thecolony/sdk`](https://w ## Tests -1559 tests across 52 files. 100% statement / function / line coverage, ≥98% branch coverage — enforced in CI. Run locally: +1588 tests across 53 files. 100% statement / function / line coverage, ≥98% branch coverage — enforced in CI. Run locally: ```bash npm test # one-shot diff --git a/package.json b/package.json index 11b1e16..28ed7b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thecolony/elizaos-plugin", - "version": "0.24.0", + "version": "0.25.0", "description": "ElizaOS plugin for The Colony (thecolony.cc) — post, reply, DM, vote, react, search, browse, follow, curate, summarize, poll, edit, delete, cooldown, join/leave sub-colonies, update profile, rotate keys, and read the feed on the AI-agent-only social network. Reactive polling with thread context, autonomous posting, thread-aware proactive engagement with intelligent react/comment classifier, self-correction (edit/delete), retry queue for transient write failures, persistent activity log, operator cooldown + curation + targeted commenting + thread summarization + polls, universal self-check with content-policy deny list + opt-in SPAM retry, status/diagnostics actions, rich post types, character-topic filtering, mention trust filter, daily post cap, karma-aware auto-pause, per-path LLM model override, token rotation, outbound activity webhook, structured JSON log option, inbound webhook delivery, SIGTERM shutdown handlers, rate-limit backoff, cold-start filtering, Ollama readiness checks.", "keywords": [ "elizaos-plugins", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index ce0541a..b453e26 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -15,6 +15,7 @@ import ColonyPlugin, { commentOnColonyPostAction, colonyStatusAction, colonyDiagnosticsAction, + colonyHealthReportAction, colonyRecentActivityAction, summarizeColonyThreadAction, editColonyPostAction, @@ -68,6 +69,7 @@ describe("ColonyPlugin", () => { commentOnColonyPostAction, colonyStatusAction, colonyDiagnosticsAction, + colonyHealthReportAction, colonyRecentActivityAction, summarizeColonyThreadAction, editColonyPostAction, diff --git a/src/__tests__/v25-features.test.ts b/src/__tests__/v25-features.test.ts new file mode 100644 index 0000000..295bb2e --- /dev/null +++ b/src/__tests__/v25-features.test.ts @@ -0,0 +1,313 @@ +/** + * v0.25.0 — COLONY_HEALTH_REPORT action suite. + * + * Covers: + * - Validator: DM-safe (not refused from DM origin), accepts health + * keywords, rejects unrelated text, rejects missing service. + * - Handler output lines: Ollama reachability, LLM-call success rate, + * pause state, retry queue depth, notification-digest count, + * adaptive-poll multiplier, diversity watchdog peak. + * - DM_SAFE_ACTIONS invariant: COLONY_HEALTH_REPORT is allow-listed. + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { IAgentRuntime, Memory } from "@elizaos/core"; + +import { colonyHealthReportAction } from "../actions/healthReport.js"; +import { DM_SAFE_ACTIONS } from "../services/origin.js"; +import { + fakeRuntime, + fakeService, + fakeState, + makeCallback, + type FakeService, +} from "./helpers.js"; + +// Mock the readiness helper module. Defaults to "reachable"; individual +// tests override with `mockResolvedValue(false)` to exercise the +// unreachable path. +vi.mock("../utils/readiness.js", () => ({ + isOllamaReachable: vi.fn(async () => true), +})); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +import * as readiness from "../utils/readiness.js"; + +function taggedMessage(text: string, origin?: "dm" | "post_mention"): Memory { + const content: Record = { text }; + if (origin) content.colonyOrigin = origin; + return { content } as unknown as Memory; +} + +describe("COLONY_HEALTH_REPORT — validator", () => { + let service: FakeService; + + beforeEach(() => { + service = fakeService(); + }); + + it("returns false when the colony service is not registered", async () => { + const runtime = fakeRuntime(null); + expect( + await colonyHealthReportAction.validate(runtime, taggedMessage("are you healthy?")), + ).toBe(false); + }); + + it("returns false for empty text", async () => { + const runtime = fakeRuntime(service); + expect( + await colonyHealthReportAction.validate(runtime, taggedMessage(" ")), + ).toBe(false); + }); + + it("accepts common health-check phrasings", async () => { + const runtime = fakeRuntime(service); + for (const phrase of [ + "are you healthy?", + "are you ok?", + "run a diagnostic", + "heartbeat check please", + "health check colony", + ]) { + expect( + await colonyHealthReportAction.validate(runtime, taggedMessage(phrase)), + ).toBe(true); + } + }); + + it("rejects unrelated text", async () => { + const runtime = fakeRuntime(service); + expect( + await colonyHealthReportAction.validate(runtime, taggedMessage("hello there")), + ).toBe(false); + }); + + it("is DM-safe: accepts health-check DMs (not refused by origin guard)", async () => { + const runtime = fakeRuntime(service); + expect( + await colonyHealthReportAction.validate( + runtime, + taggedMessage("are you healthy?", "dm"), + ), + ).toBe(true); + }); + + it("is listed in DM_SAFE_ACTIONS", () => { + expect(DM_SAFE_ACTIONS.has("COLONY_HEALTH_REPORT")).toBe(true); + }); +}); + +describe("COLONY_HEALTH_REPORT — handler output", () => { + let service: FakeService; + + beforeEach(() => { + service = fakeService(); + service.username = "colonist-one"; + vi.mocked(readiness.isOllamaReachable).mockReset(); + vi.mocked(readiness.isOllamaReachable).mockResolvedValue(true); + }); + + async function runHealth(): Promise { + const runtime = fakeRuntime(service); + const cb = makeCallback(); + await colonyHealthReportAction.handler!( + runtime, + taggedMessage("are you healthy?"), + fakeState(), + {}, + cb, + ); + return (cb.mock.calls[0]![0] as { text: string }).text; + } + + it("opens with the agent handle", async () => { + const text = await runHealth(); + expect(text).toContain("Health report for @colonist-one"); + }); + + it("reports Ollama as reachable when the probe returns true", async () => { + vi.mocked(readiness.isOllamaReachable).mockResolvedValue(true); + const text = await runHealth(); + expect(text).toContain("Ollama: reachable"); + }); + + it("reports Ollama as UNREACHABLE when the probe returns false", async () => { + vi.mocked(readiness.isOllamaReachable).mockResolvedValue(false); + const text = await runHealth(); + expect(text).toContain("Ollama: UNREACHABLE"); + }); + + it("tolerates the readiness probe throwing (cloud-provider config)", async () => { + vi.mocked(readiness.isOllamaReachable).mockRejectedValue(new Error("no endpoint")); + const text = await runHealth(); + expect(text).toContain("Ollama: not configured"); + }); + + it("reports no-LLM-activity when the call history is empty in-window", async () => { + service.llmCallHistory = []; + const text = await runHealth(); + expect(text).toContain("LLM calls:"); + expect(text).toContain("no activity"); + }); + + it("reports LLM success rate when calls are in-window", async () => { + const now = Date.now(); + service.llmCallHistory = [ + { ts: now, outcome: "success" }, + { ts: now, outcome: "success" }, + { ts: now, outcome: "success" }, + { ts: now, outcome: "failure" }, + ]; + const text = await runHealth(); + expect(text).toMatch(/LLM calls .*: 3 succeeded, 1 failed/); + }); + + it("adds a warning indicator when failure rate is high", async () => { + const now = Date.now(); + service.llmCallHistory = [ + { ts: now, outcome: "failure" }, + { ts: now, outcome: "failure" }, + { ts: now, outcome: "failure" }, + { ts: now, outcome: "success" }, + ]; + const text = await runHealth(); + expect(text).toContain("🔴"); + }); + + it("surfaces pause state when paused", async () => { + service.isPausedForBackoff = vi.fn(() => true); + service.pausedUntilTs = Date.now() + 10 * 60_000; + service.pauseReason = "llm_health"; + const text = await runHealth(); + expect(text).toContain("⏸️ Paused"); + expect(text).toContain("reason: llm_health"); + }); + + it("reports 'active (not paused)' when not paused", async () => { + service.isPausedForBackoff = vi.fn(() => false); + const text = await runHealth(); + expect(text).toContain("Pause state: active"); + }); + + it("reports empty retry queue when post-client exposes one", async () => { + service.postClient = { + getRetryQueue: () => [], + } as never; + const text = await runHealth(); + expect(text).toContain("Retry queue: empty"); + }); + + it("reports retry queue contents when non-empty", async () => { + service.postClient = { + getRetryQueue: () => [ + { kind: "post" }, + { kind: "post" }, + { kind: "comment" }, + ], + } as never; + const text = await runHealth(); + expect(text).toContain("Retry queue: 3 pending"); + expect(text).toContain("2×post"); + expect(text).toContain("1×comment"); + }); + + it("swallows errors from the retry queue accessor", async () => { + service.postClient = { + getRetryQueue: () => { + throw new Error("queue backend down"); + }, + } as never; + const text = await runHealth(); + // Line should be absent rather than crash — health report is non-throwing. + expect(text).not.toContain("Retry queue:"); + }); + + it("surfaces notification digest count when > 0", async () => { + service.stats!.notificationDigestsEmitted = 5; + const text = await runHealth(); + expect(text).toContain("Notification digests this session: 5"); + }); + + it("omits the digest line when count is 0", async () => { + service.stats!.notificationDigestsEmitted = 0; + const text = await runHealth(); + expect(text).not.toContain("Notification digests"); + }); + + it("surfaces adaptive poll multiplier when enabled", async () => { + service.colonyConfig.adaptivePollEnabled = true; + service.computeLlmHealthMultiplier!.mockReturnValue(2.5); + const text = await runHealth(); + expect(text).toContain("Adaptive poll multiplier: 2.50×"); + expect(text).toContain("slowing polls under LLM stress"); + }); + + it("omits the adaptive-poll line when disabled", async () => { + service.colonyConfig.adaptivePollEnabled = false; + const text = await runHealth(); + expect(text).not.toContain("Adaptive poll"); + }); + + it("surfaces diversity watchdog peak when available", async () => { + service.diversityWatchdog = { + peakPairwiseSimilarity: () => 0.75, + }; + service.colonyConfig.diversityThreshold = 0.8; + const text = await runHealth(); + expect(text).toContain("Output diversity: peak pairwise 0.75"); + expect(text).toContain("threshold 0.80"); + }); + + it("adds a warning on the diversity line when peak is within 90% of threshold", async () => { + service.diversityWatchdog = { + peakPairwiseSimilarity: () => 0.78, // 0.78 >= 0.8 * 0.9 = 0.72 + }; + service.colonyConfig.diversityThreshold = 0.8; + const text = await runHealth(); + expect(text).toContain("⚠️"); + }); + + it("tolerates a null diversity watchdog (not configured)", async () => { + service.diversityWatchdog = null; + const text = await runHealth(); + expect(text).not.toContain("Output diversity"); + }); + + it("swallows errors from the diversity accessor", async () => { + service.diversityWatchdog = { + peakPairwiseSimilarity: () => { + throw new Error("ring corrupted"); + }, + }; + const text = await runHealth(); + expect(text).not.toContain("Output diversity"); + }); + + it("returns early when service is not registered (non-throwing)", async () => { + const runtime = fakeRuntime(null); + const cb = makeCallback(); + await expect( + colonyHealthReportAction.handler!( + runtime, + taggedMessage("are you healthy?"), + fakeState(), + {}, + cb, + ), + ).resolves.toBeUndefined(); + expect(cb).not.toHaveBeenCalled(); + }); + + it("handles a null peak-pairwise-similarity return", async () => { + service.diversityWatchdog = { + peakPairwiseSimilarity: () => null, + }; + const text = await runHealth(); + expect(text).not.toContain("Output diversity"); + }); + + it("returns with an 'unknown' handle when service has no username", async () => { + service.username = undefined; + const text = await runHealth(); + expect(text).toContain("Health report for (unknown)"); + }); +}); diff --git a/src/actions/healthReport.ts b/src/actions/healthReport.ts new file mode 100644 index 0000000..95bdb9d --- /dev/null +++ b/src/actions/healthReport.ts @@ -0,0 +1,193 @@ +import { + type Action, + type ActionExample, + type HandlerCallback, + type IAgentRuntime, + type Memory, + type State, + logger, +} from "@elizaos/core"; +import type { ColonyService } from "../services/colony.service.js"; +import { refuseDmOrigin } from "../services/origin.js"; +import { isOllamaReachable } from "../utils/readiness.js"; + +const HEALTH_KEYWORDS = [ + "health", + "are you ok", + "are you okay", + "are you healthy", + "diagnostic", + "heartbeat", + "check yourself", +]; +const HEALTH_REGEX = /\b(?:health|ok|okay|healthy|diagnostic|heartbeat)\b/i; + +/** + * v0.25.0: single-action health readout composing every readiness / + * state signal the plugin tracks. Differs from `COLONY_STATUS` (which + * is operator-reporting — session counters, daily-cap, karma trend) + * and `COLONY_DIAGNOSTICS` (which is a full-fat plugin dump — config, + * cache sizes, stats). Health is the question "is this agent currently + * able to do its job", answered in ≤10 lines. + * + * DM-safe (read-only): the action appears in `DM_SAFE_ACTIONS` so + * another agent can DM `@eliza-gemma` asking "are you healthy?" and + * get a useful answer back. That's the main use case. + */ +export const colonyHealthReportAction: Action = { + name: "COLONY_HEALTH_REPORT", + similes: [ + "COLONY_HEALTHCHECK", + "ARE_YOU_HEALTHY", + "HEARTBEAT_COLONY", + "COLONY_HEALTH", + ], + description: + "Report the agent's current runtime health: Ollama reachability, LLM-call success rate, karma-backoff pause state, retry-queue depth, notification-router activity, diversity-watchdog score. One compact readout that composes signals from the subsystems the plugin tracks. Use when asked 'are you healthy?' / 'is the agent ok?' / 'what's your current status?'.", + validate: async ( + runtime: IAgentRuntime, + message: Memory, + ): Promise => { + // v0.25.0: DM-safe action — read-only, so NOT refused from DM + // origin. The `refuseDmOrigin` helper respects the allowlist. + if (refuseDmOrigin(message, "COLONY_HEALTH_REPORT")) return false; + const service = runtime.getService("colony"); + if (!service) return false; + const text = String(message.content.text ?? "").toLowerCase(); + if (!text.trim()) return false; + const keywordHit = HEALTH_KEYWORDS.some((kw) => text.includes(kw)); + const regexHit = HEALTH_REGEX.test(text); + return keywordHit && regexHit; + }, + handler: async ( + runtime: IAgentRuntime, + _message: Memory, + _state?: State, + _options?: { [key: string]: unknown }, + callback?: HandlerCallback, + ): Promise => { + const service = runtime.getService("colony") as unknown as ColonyService | null; + if (!service) return; + + const lines: string[] = []; + const handle = service.username ? `@${service.username}` : "(unknown)"; + lines.push(`Health report for ${handle}:`); + + // Ollama reachability (cloud-model configs bypass this). + let ollamaLine = "Ollama: not configured (cloud provider)"; + try { + const reachable = await isOllamaReachable(runtime); + ollamaLine = reachable + ? "Ollama: reachable" + : "Ollama: UNREACHABLE (local inference will fail)"; + } catch { + // readiness helper throws only on config-misuse; treat as unconfigured. + } + lines.push(`- ${ollamaLine}`); + + // LLM-call success rate over the sliding window. + const history = service.llmCallHistory ?? []; + const now = Date.now(); + const windowMs = service.colonyConfig?.llmFailureWindowMs ?? 10 * 60_000; + const recent = history.filter((e) => e.ts > now - windowMs); + if (recent.length === 0) { + lines.push(`- LLM calls: (no activity in last ${Math.round(windowMs / 60_000)}min)`); + } else { + const failed = recent.filter((e) => e.outcome === "failure").length; + const succeeded = recent.length - failed; + const rate = failed / recent.length; + const indicator = + rate === 0 ? "✅" : rate < 0.25 ? "" : rate < 0.5 ? "⚠️ " : "🔴 "; + lines.push( + `- ${indicator}LLM calls (last ${Math.round(windowMs / 60_000)}min): ${succeeded} succeeded, ${failed} failed (${Math.round(rate * 100)}%)`, + ); + } + + // Pause state — karma backoff, LLM-health, operator cooldown, diversity. + if (service.isPausedForBackoff?.()) { + const remainingMin = Math.max( + 1, + Math.round((service.pausedUntilTs - now) / 60_000), + ); + const reason = service.pauseReason ? ` — reason: ${service.pauseReason}` : ""; + lines.push( + `- ⏸️ Paused for ${remainingMin}min${reason}`, + ); + } else { + lines.push(`- Pause state: active (not paused)`); + } + + // Retry queue depth. Reads through the post-client if it's running. + const postClient = service.postClient as unknown as { + getRetryQueue?: () => ReadonlyArray<{ kind?: string }>; + } | null; + if (postClient && typeof postClient.getRetryQueue === "function") { + try { + const queue = postClient.getRetryQueue() ?? []; + if (queue.length === 0) { + lines.push(`- Retry queue: empty`); + } else { + const kinds = new Map(); + for (const item of queue) { + const k = item.kind ?? "unknown"; + kinds.set(k, (kinds.get(k) ?? 0) + 1); + } + const breakdown = Array.from(kinds.entries()) + .map(([k, c]) => `${c}×${k}`) + .join(", "); + lines.push(`- Retry queue: ${queue.length} pending (${breakdown})`); + } + } catch { + // defensive — shouldn't happen, but health-report must never throw + } + } + + // Notification-router digest count (v0.22). + const digests = service.stats?.notificationDigestsEmitted ?? 0; + if (digests > 0) { + lines.push(`- Notification digests this session: ${digests}`); + } + + // Adaptive-poll multiplier (v0.23) — only worth showing when enabled. + if (service.colonyConfig?.adaptivePollEnabled) { + const mul = service.computeLlmHealthMultiplier?.() ?? 1.0; + const suffix = mul > 1.5 ? " (slowing polls under LLM stress)" : ""; + lines.push(`- Adaptive poll multiplier: ${mul.toFixed(2)}×${suffix}`); + } + + // Diversity watchdog peak similarity (v0.19). + const dw = service.diversityWatchdog as unknown as { + peakPairwiseSimilarity?: () => number | null; + } | null; + if (dw && typeof dw.peakPairwiseSimilarity === "function") { + try { + const peak = dw.peakPairwiseSimilarity(); + if (typeof peak === "number") { + const threshold = service.colonyConfig?.diversityThreshold ?? 0.8; + const warning = peak >= threshold * 0.9 ? " ⚠️" : ""; + lines.push( + `- Output diversity: peak pairwise ${peak.toFixed(2)} (threshold ${threshold.toFixed(2)})${warning}`, + ); + } + } catch { + // defensive + } + } + + const text = lines.join("\n"); + logger.info(`COLONY_HEALTH_REPORT: produced ${lines.length}-line report for ${handle}`); + callback?.({ text, action: "COLONY_HEALTH_REPORT" }); + }, + examples: [ + [ + { name: "{{user1}}", content: { text: "Are you healthy?" } }, + { + name: "{{agent}}", + content: { + text: "Health report for @eliza-gemma:\n- Ollama: reachable\n- LLM calls (last 10min): 14 succeeded, 0 failed (0%)\n- Pause state: active (not paused)\n- Retry queue: empty", + action: "COLONY_HEALTH_REPORT", + }, + }, + ], + ] as ActionExample[][], +}; diff --git a/src/index.ts b/src/index.ts index fc1bcd6..ddab83b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { curateColonyFeedAction } from "./actions/curate.js"; import { commentOnColonyPostAction } from "./actions/commentOnPost.js"; import { colonyStatusAction } from "./actions/status.js"; import { colonyDiagnosticsAction } from "./actions/diagnostics.js"; +import { colonyHealthReportAction } from "./actions/healthReport.js"; import { colonyRecentActivityAction } from "./actions/recentActivity.js"; import { summarizeColonyThreadAction } from "./actions/summarizeThread.js"; import { editColonyPostAction } from "./actions/editPost.js"; @@ -62,6 +63,7 @@ export const ColonyPlugin: Plugin = { commentOnColonyPostAction, colonyStatusAction, colonyDiagnosticsAction, + colonyHealthReportAction, colonyRecentActivityAction, summarizeColonyThreadAction, editColonyPostAction, @@ -117,6 +119,7 @@ export { curateColonyFeedAction } from "./actions/curate.js"; export { commentOnColonyPostAction } from "./actions/commentOnPost.js"; export { colonyStatusAction } from "./actions/status.js"; export { colonyDiagnosticsAction } from "./actions/diagnostics.js"; +export { colonyHealthReportAction } from "./actions/healthReport.js"; export { colonyRecentActivityAction } from "./actions/recentActivity.js"; export { summarizeColonyThreadAction } from "./actions/summarizeThread.js"; export { editColonyPostAction } from "./actions/editPost.js"; diff --git a/src/services/action-names.ts b/src/services/action-names.ts index 1a6b2a0..ac7c162 100644 --- a/src/services/action-names.ts +++ b/src/services/action-names.ts @@ -30,6 +30,7 @@ export const COLONY_ACTION_NAMES: ReadonlySet = new Set([ "COMMENT_ON_COLONY_POST", "COLONY_STATUS", "COLONY_DIAGNOSTICS", + "COLONY_HEALTH_REPORT", "COLONY_RECENT_ACTIVITY", "SUMMARIZE_COLONY_THREAD", "EDIT_COLONY_POST", diff --git a/src/services/origin.ts b/src/services/origin.ts index b10843c..a8cd905 100644 --- a/src/services/origin.ts +++ b/src/services/origin.ts @@ -92,6 +92,7 @@ export const DM_SAFE_ACTIONS: ReadonlySet = new Set([ // Plugin-state inspection "COLONY_STATUS", "COLONY_DIAGNOSTICS", + "COLONY_HEALTH_REPORT", "COLONY_RECENT_ACTIVITY", "LIST_WATCHED_COLONY_POSTS", "COLONY_PENDING_APPROVALS",