diff --git a/docs/plugins.md b/docs/plugins.md index b293416..ce50384 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,255 +1,256 @@ # Plugins Reference (`@shoggoth/plugins`) -This document is a source-level reference for the `@shoggoth/plugins` package. Plugins are loadable extension packages that hook into the [daemon](daemon.md) lifecycle via a central hook registry. +The Shoggoth plugin system is built on [`hooks-plugin`](https://www.npmjs.com/package/hooks-plugin), providing typed lifecycle hooks that plugins tap into to extend daemon behavior. Plugins can observe events, transform data via waterfall hooks, and register entirely new messaging platforms. --- ## Overview -Plugins are loadable extension packages (local directories or npm packages) that register hook handlers into a central `HookRegistry`, allowing code to run at defined lifecycle points (e.g. daemon startup/shutdown). +Plugins are loadable extension packages (local directories or npm packages) that register hook handlers into a `ShoggothPluginSystem`. The system defines 14 typed hooks spanning daemon lifecycle, platform lifecycle, messaging, session, and health. Key characteristics: -- Each plugin is a directory containing a `shoggoth.json` manifest. -- The manifest declares which hooks the plugin handles and points to the handler modules. -- Hook handlers are plain functions (sync or async) that are `default`-exported from their module. -- Plugins are loaded at daemon startup and their hooks are executed sequentially in registration (FIFO) order. +- Plugin metadata lives in `package.json` under a `shoggothPlugin` property bag — no separate manifest file. +- The entrypoint exports a plugin object or a factory function that returns one. +- Hooks are strongly typed — each hook has a defined context type. +- Waterfall hooks (`daemon.configure`, `message.outbound`) allow plugins to transform data in a pipeline. +- Plugins fire in registration order (FIFO). The system can be locked after startup to prevent late registration. - Loading failures are audited but do not abort the loading of other plugins. --- -## Plugin Manifest (`shoggoth.json`) +## How to Create a Plugin -Every plugin directory must contain a `shoggoth.json` file at its root. The manifest is validated with a strict Zod schema: +### 1. Set up `package.json` ```json { - "name": "my-plugin", + "name": "shoggoth-plugin-example", "version": "1.0.0", - "hooks": { - "daemon.startup": "./hooks/startup.js", - "daemon.shutdown": "./hooks/shutdown.js" + "shoggothPlugin": { + "kind": "general", + "entrypoint": "./src/plugin.ts" } } ``` -### Manifest Fields - -| Field | Type | Required | Description | -|-----------|-------------------------------|----------|-------------| -| `name` | `string` | Yes | Plugin name (non-empty). | -| `version` | `string` | Yes | Plugin version (non-empty). | -| `hooks` | `Record` | No | Map of hook names to relative file paths. Each file must default-export a function. | - -The schema is **strict** — unknown fields cause validation failure. - ---- - -## Supported Hook Names (v1) - -| Hook Name | When It Fires | -|--------------------|---------------| -| `daemon.startup` | When the Shoggoth daemon starts up. | -| `daemon.shutdown` | When the Shoggoth daemon shuts down. | - -The `HookName` type is the union of these string literals. - ---- - -## Plugin Loading - -Plugins are loaded by `loadPluginFromDirectory()`: - -1. Read and parse `shoggoth.json` from the plugin's root directory. -2. Validate the manifest against the strict Zod schema. -3. For each entry in `hooks`: - - Resolve the relative path to an absolute file URL. - - Dynamically `import()` the module. - - Verify the module's `default` export is a function. - - Register the function as a handler in the `HookRegistry`. -4. Return `LoadedPluginMeta` (`name`, `version`, `rootDir`). - -If the default export is not a function, loading throws an error. - -```typescript -interface LoadedPluginMeta { - readonly name: string; - readonly version: string; - readonly rootDir: string; +The `shoggothPlugin` property bag fields: + +| Field | Type | Required | Description | +|---|---|---|---| +| `kind` | `"messaging-platform" \| "observability" \| "general"` | No | Defaults to `"general"`. `messaging-platform` plugins must implement required platform hooks. | +| `entrypoint` | `string` | Yes | Module that exports the plugin factory or plugin object. | + +### 2. Write the plugin entrypoint + +Export a factory function (or a plugin object directly): + +```ts +import type { Plugin } from "hooks-plugin"; +import type { ShoggothHooks } from "@shoggoth/plugins"; + +export default function createMyPlugin(): Plugin { + return { + name: "my-plugin", + hooks: { + "daemon.startup": async (ctx) => { + console.log("Plugin initialized with config:", ctx.config); + }, + "daemon.shutdown": async (ctx) => { + console.log("Shutting down:", ctx.reason); + }, + }, + }; } ``` ---- - -## Plugin Resolution (Config-Driven) - -`loadAllPluginsFromConfig()` iterates over `config.plugins` entries. Each entry can specify a plugin by: +### 3. Reference in config -- **Local path** (`entry.path`): Resolved relative to `config.configDirectory`. Absolute paths are used as-is. -- **npm package** (`entry.package`): Resolved via `createRequire()` from a reference file, locating the package's `package.json` and using its parent directory as the plugin root. +By local path (resolved relative to config directory): -### Resolution Helpers - -| Function | Purpose | -|----------|---------| -| `resolveLocalPluginPath(entry, configDir)` | Resolves a local path entry relative to the config directory. | -| `resolveNpmPluginRoot(entry)` | Resolves an npm package entry to its root directory via `createRequire()`. | - -### Audit Events - -Each plugin load attempt is audited: - -```typescript -interface PluginAuditEvent { - readonly action: "plugin.load" | "plugin.unload"; - readonly resource: string; // entry.id ?? entry.path ?? entry.package ?? "unknown" - readonly outcome: "success" | "failure"; - readonly detail?: string; // Error message on failure +```json +{ + "plugins": [ + { "path": "./plugins/shoggoth-plugin-example" } + ] } ``` -Loading failures are caught and audited — they do **not** abort the loading of subsequent plugins. +By npm package name: -Successfully loaded plugins are returned as `LoadedPluginRef[]`: - -```typescript -interface LoadedPluginRef { - readonly resource: string; - readonly manifestName: string; +```json +{ + "plugins": [ + { "package": "shoggoth-plugin-example" } + ] } ``` --- -## Hook Registry +## Hook Catalog -The `HookRegistry` is the central dispatch mechanism for plugin hooks. +### Daemon Lifecycle -```typescript -type HookName = "daemon.startup" | "daemon.shutdown"; -type HookHandler = (ctx?: unknown) => void | Promise; -``` +| Hook | Type | Context | Description | +|---|---|---|---| +| `daemon.configure` | `SyncWaterfallHook` | `DaemonConfigureCtx` | After config load, before subsystems start. Plugins can inspect/transform config. | +| `daemon.startup` | `AsyncHook` | `DaemonStartupCtx` | After DB and core subsystems init. Plugins perform async setup. | +| `daemon.ready` | `AsyncHook` | `DaemonReadyCtx` | All plugins started, platforms connected. System is live. | +| `daemon.shutdown` | `AsyncHook` | `DaemonShutdownCtx` | Graceful shutdown. Plugins release resources. | -### API +### Platform Lifecycle -| Method | Signature | Description | -|--------|-----------|-------------| -| `register` | `register(name: HookName, handler: HookHandler)` | Append a handler for the given hook. Multiple handlers per hook are supported. | -| `run` | `run(name: HookName, ctx?: unknown)` | Execute all handlers for a hook **sequentially** in registration order, awaiting each. An optional context object is passed to every handler. | -| `clear` | `clear(name: HookName)` | Remove all handlers for a specific hook (e.g. during plugin unload). | -| `reset` | `reset()` | Remove all handlers for all hooks. | +| Hook | Type | Context | Description | +|---|---|---|---| +| `platform.register` | `SyncHook` | `PlatformRegisterCtx` | Platforms register URN policy, capabilities, and runtime. | +| `platform.start` | `AsyncHook` | `PlatformStartCtx` | Platforms connect to external services (gateway, API, webhook). | +| `platform.stop` | `AsyncHook` | `PlatformStopCtx` | Platforms disconnect gracefully. | -### Execution Model +### Messaging -- Handlers are executed in FIFO order (the order they were registered). -- Each handler is `await`ed before the next runs — there is no parallel execution. -- An optional context object passed to `run()` is forwarded to every handler. +| Hook | Type | Context | Description | +|---|---|---|---| +| `message.inbound` | `AsyncHook` | `MessageInboundCtx` | Normalized inbound message ready for dispatch. | +| `message.outbound` | `AsyncWaterfallHook` | `MessageOutboundCtx` | Outbound message about to be delivered. Plugins can transform content. | +| `message.reaction` | `AsyncHook` | `MessageReactionCtx` | Reaction event received from a platform. | ---- +### Session -## Package Exports +| Hook | Type | Context | Description | +|---|---|---|---| +| `session.turn.before` | `AsyncHook` | `SessionTurnBeforeCtx` | Before a model turn executes. | +| `session.turn.after` | `AsyncHook` | `SessionTurnAfterCtx` | After a model turn completes (success or failure). | +| `session.segment.change` | `SyncHook` | `SessionSegmentChangeCtx` | Session context segment changes (new/reset). | -The public API exported from `@shoggoth/plugins`: +### Health -```typescript -// Plugin loading (config-driven) -loadAllPluginsFromConfig, resolveLocalPluginPath, resolveNpmPluginRoot -type LoadedPluginRef, PluginAuditEvent, PluginAuditOutcome +| Hook | Type | Context | Description | +|---|---|---|---| +| `health.register` | `SyncHook` | `HealthRegisterCtx` | Plugins register health probes during startup. | -// Hook system -HookRegistry -type HookHandler, HookName +### Hook Types Explained -// Single-plugin loader -loadPluginFromDirectory -type LoadedPluginMeta - -// Plugin manifest -parseShoggothPluginManifest, shoggothPluginManifestSchema -type ShoggothPluginManifest -``` +- `SyncHook` — synchronous, fire-and-forget. Handlers run in order, no return value. +- `AsyncHook` — async sequential. Each handler is awaited before the next runs. +- `SyncWaterfallHook` — synchronous pipeline. Each handler receives the context and returns a (possibly modified) version for the next handler. +- `AsyncWaterfallHook` — async pipeline. Same as waterfall but handlers can be async. --- -## Quick-Start Examples - -### Writing a Plugin - -1. Create a directory with a `shoggoth.json`: +## Platform Plugin Guide + +To add a new messaging platform (e.g. Slack, Telegram), create a plugin with `kind: "messaging-platform"` and implement the four required hooks. + +### Required Hooks + +| Hook | Purpose | +|---|---| +| `platform.register` | Register URN policy and platform capabilities | +| `platform.start` | Connect to the external service, wire message handlers | +| `platform.stop` | Disconnect and clean up resources | +| `health.register` | Register a health probe for the platform | + +### Example + +```ts +import { defineMessagingPlatformPlugin } from "@shoggoth/plugins"; + +export default function createSlackPlugin() { + let client: SlackClient | undefined; + + return defineMessagingPlatformPlugin({ + name: "platform-slack", + version: "0.1.0", + hooks: { + "platform.register"(ctx) { + ctx.registerPlatform({ + id: "slack", + urnPattern: /^slack:/, + // ... capabilities + }); + }, + + async "platform.start"(ctx) { + client = new SlackClient(ctx.env.SLACK_TOKEN); + await client.connect(); + ctx.registerDrain("slack-disconnect", () => client?.disconnect()); + }, + + async "platform.stop"(ctx) { + await client?.disconnect(); + client = undefined; + }, + + "health.register"(ctx) { + ctx.registerProbe({ + name: "slack", + check: async () => ({ + status: client?.connected ? "pass" : "fail", + }), + }); + }, + }, + }); +} +``` +**package.json:** ```json { - "name": "my-startup-plugin", + "name": "@shoggoth/platform-slack", "version": "0.1.0", - "hooks": { - "daemon.startup": "./on-startup.js" + "shoggothPlugin": { + "kind": "messaging-platform", + "entrypoint": "./src/plugin.ts" } } ``` -2. Create the hook handler file (`on-startup.js`): - -```javascript -export default async function onStartup(ctx) { - console.log("Shoggoth daemon is starting up!"); -} -``` +`defineMessagingPlatformPlugin` validates that all four required hooks are present at registration time and throws if any are missing. -### Referencing in Config +### `PlatformStartCtx` Dependencies -Reference a local plugin by path: +The `platform.start` context provides shared daemon dependencies via `ctx.deps`: -```json -{ - "plugins": [ - { "path": "./plugins/my-startup-plugin" } - ] -} -``` +| Dependency | Type | Description | +|---|---|---| +| `hitlStack` | `HitlPendingStack` | Shared HITL pending stack | +| `policyEngine` | `PolicyEngine` | Policy engine access | +| `hitlConfigRef` | `HitlConfigRef` | HITL configuration reference | +| `hitlAutoApproveGate` | `HitlAutoApproveGate?` | Optional HITL auto-approve gate | -Or reference an npm-published plugin by package name: +Additional setters on the context: `setSubagentRuntimeExtension`, `setMessageToolContext`, `setPlatformAdapter`. -```json -{ - "plugins": [ - { "package": "shoggoth-plugin-example" } - ] -} -``` +--- -### Multi-Hook Plugin +## Error Handling -A plugin can register handlers for multiple lifecycle hooks: +The `ShoggothPluginSystem` provides centralized error handling via `listenError`: -```json -{ - "name": "lifecycle-logger", - "version": "1.0.0", - "hooks": { - "daemon.startup": "./hooks/startup.js", - "daemon.shutdown": "./hooks/shutdown.js" - } -} +```ts +system.listenError((event) => { + logger.error("Plugin hook error", { + hookName: event.name, + hookType: event.type, + pluginTag: event.tag, + error: String(event.error), + }); +}); ``` -```javascript -// hooks/startup.js -export default async function onStartup(ctx) { - console.log("[lifecycle-logger] daemon started"); -} -``` +Error behavior by hook: -```javascript -// hooks/shutdown.js -export default async function onShutdown(ctx) { - console.log("[lifecycle-logger] daemon shutting down"); -} -``` +| Hook Phase | Behavior | +|---|---| +| `platform.start` | Fatal for that platform, daemon continues without it | +| `daemon.startup` | Non-fatal — logged and audited | +| `daemon.shutdown` / `platform.stop` | Logged, does not block shutdown | --- ## See Also -- [Daemon](daemon.md) — loads plugins at startup and runs hook handlers -- [Skills](skills.md) — skill discovery and search (previously co-located with plugins) +- [Daemon](daemon.md) — boot sequence and plugin loading +- [Discord Platform](platform-discord.md) — reference platform plugin implementation - [Shared](shared.md) — `ShoggothPluginEntry` config schema diff --git a/package-lock.json b/package-lock.json index b0926fe..52e95a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1001,6 +1001,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/aidly": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/aidly/-/aidly-1.37.0.tgz", + "integrity": "sha512-IMbw5e7KKnacRh9PuuHN/65iFibo8NLtqM9NS1QTn7ySqnCVAcc72/Bz0CHnMvwC6FxLtICZWLVIfPddMr9vwA==", + "license": "MIT", + "dependencies": { + "small-queue": "^1.1.2" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1290,6 +1299,15 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/hooks-plugin": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/hooks-plugin/-/hooks-plugin-1.3.4.tgz", + "integrity": "sha512-2cQFChc7gYjM1A2B7N9fmb3iG+0Ns8Fi9FOEg7P3doH9scDSSJf423V7mxPewc/acshGP1StrQynOgSBM8Pkwg==", + "license": "MIT", + "dependencies": { + "aidly": "^1.36.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1926,6 +1944,12 @@ "simple-concat": "^1.0.0" } }, + "node_modules/small-queue": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/small-queue/-/small-queue-1.1.2.tgz", + "integrity": "sha512-K6zQPh7cRjkzv+upcwrOTbR4kU3neY3KZR2F1nc8Fuez8uJyh4BguOUs4KGKHcuzU1LJmK8yfCkMUZbconLe8Q==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2410,6 +2434,7 @@ "version": "0.1.0", "dependencies": { "@shoggoth/shared": "*", + "hooks-plugin": "^1.3.4", "zod": "^3.24.0" } }, diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 5e4a368..a055a34 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -2,8 +2,6 @@ import { DEFAULT_HITL_CONFIG, loadLayeredConfig, LAYOUT, - parseAgentSessionUrn, - resolvePlatformConfig, VERSION, } from "@shoggoth/shared"; import { routeMcpToolInvocation } from "@shoggoth/mcp-integration"; @@ -65,7 +63,7 @@ import { initLogger, getLogger } from "./logging"; const log = getLogger("shoggoth-daemon"); import { createDelegatingPolicyEngine, createPolicyEngine } from "./policy/engine"; -import { bootstrapPlugins } from "./plugins/bootstrap"; +import { pluginAuditToRow } from "./plugins/bootstrap"; import { bootstrapMainSession } from "./bootstrap-main-session"; import { createDaemonRuntime } from "./runtime"; import { initProcessManager } from "./process-manager-singleton"; @@ -73,26 +71,14 @@ import { setProcessManager } from "@shoggoth/os-exec"; import type { ProcessDeclaration } from "@shoggoth/shared"; import type { ProcessSpec } from "@shoggoth/procman"; import { createToolRunStore } from "./sessions/tool-run-store"; -import { - startDiscordPlatform, - startDaemonDiscordMessaging, - createDiscordInteractionHandler, - type DiscordMessagingRuntime, - handleDiscordHitlReactionAdd, - createHitlDiscordNoticeRegistry, - type HitlDiscordNoticeRegistry, - discordPlatformRegistration, - createDiscordProbe, - resolveDiscordOwnerUserId, -} from "@shoggoth/platform-discord"; -import { registerPlatform as registerMessagingPlatform, executeMessageToolAction } from "@shoggoth/messaging"; +import { registerPlatform as registerMessagingPlatform } from "@shoggoth/messaging"; import { registerPlatform, stopAllPlatforms } from "./platforms/platform-registry"; import { reconcilePersistentSubagents } from "./subagent/reconcile-persistent-subagents"; import { messageToolContextRef, messageToolSliceFromCapabilities, } from "./messaging/message-tool-context-ref"; -import { setSubagentRuntimeExtension } from "./subagent/subagent-extension-ref"; +import { setSubagentRuntimeExtension, subagentRuntimeExtensionRef } from "./subagent/subagent-extension-ref"; import { defaultPlatformAssistantDeps } from "./sessions/assistant-runtime"; import { createPersistingHitlAutoApproveGate } from "./hitl/hitl-auto-approve-persisting"; import { type HitlAutoApproveGate } from "./hitl/hitl-auto-approve"; @@ -129,15 +115,16 @@ import { createSqliteAgentTokenStore } from "./auth/sqlite-agent-tokens"; import { resolveShoggothAgentId, } from "./config/effective-runtime"; -import { subagentRuntimeExtensionRef } from "./subagent/subagent-extension-ref"; import { TimerScheduler } from "./timers/timer-scheduler"; import { setTimerScheduler } from "./sessions/builtin-handlers/timer-handler"; +import { ShoggothPluginSystem, type PlatformDeps } from "@shoggoth/plugins"; +import { fireDaemonHooks } from "./plugins/daemon-hooks"; +import { appendAuditRow } from "./audit/append-audit"; process.umask(0o007); loadDaemonPrompts(); loadDaemonNotices(); setPresentationNoticeResolver(daemonNotice); -registerMessagingPlatform(discordPlatformRegistration); registerContextFinalizer(messageToolFinalizer); registerContextFinalizer(subagentToolStripFinalizer); @@ -172,13 +159,7 @@ if (config.models?.providers && config.models?.failoverChain) { registerOpenAIDefaultsForProviders(config.models.providers, config.models.failoverChain); } -/** Env `DISCORD_BOT_TOKEN` overrides layered `discord.token` (hot-reload picks up config changes). */ -function resolvedDiscordBotToken(): string | undefined { - const fromEnv = process.env.DISCORD_BOT_TOKEN?.trim(); - if (fromEnv) return fromEnv; - const dc = resolvePlatformConfig(configRef.current, "discord"); - return (dc?.token as string | undefined)?.trim() || undefined; -} + const policyRef = { engine: createPolicyEngine(config.policy, config.agents) }; const policyEngine = createDelegatingPolicyEngine(() => policyRef.engine); const hitlRef = { value: { ...DEFAULT_HITL_CONFIG, ...config.hitl } }; @@ -193,7 +174,6 @@ const stateShutdown: { } = { db: undefined, toolRuns: undefined }; let stopEventLoops: () => void = () => {}; -let discordMessaging: DiscordMessagingRuntime | undefined; const rt = createDaemonRuntime({ component: "shoggoth-daemon", @@ -245,12 +225,8 @@ void (async () => { }); } - let hitlDiscordNoticeRegistry: HitlDiscordNoticeRegistry | undefined; let hitlAutoApproveGate: HitlAutoApproveGate | undefined; - const reactionBotUserIdRef = { current: undefined as string | undefined }; - const reactionPassthroughRef: { current: ((ev: import("@shoggoth/platform-discord").DiscordReactionAddEvent) => void) | undefined } = { current: undefined }; if (hitlStack && stateDb) { - hitlDiscordNoticeRegistry = createHitlDiscordNoticeRegistry(); hitlAutoApproveGate = createPersistingHitlAutoApproveGate({ db: stateDb, configDirectory: configRef.current.configDirectory, @@ -298,96 +274,6 @@ void (async () => { stopEventLoops(); }); - try { - const interactionTransportRef: { current: DiscordMessagingRuntime["discordRestTransport"] | undefined } = { current: undefined }; - discordMessaging = await startDaemonDiscordMessaging({ - logger: getLogger("messaging"), - config: configRef.current, - botToken: resolvedDiscordBotToken(), - noticeResolver: daemonNotice, - onInteractionCreate: createDiscordInteractionHandler({ - transport: new Proxy({} as DiscordMessagingRuntime["discordRestTransport"], { - get(_t, prop, receiver) { - if (!interactionTransportRef.current) throw new Error("discord transport not ready"); - return Reflect.get(interactionTransportRef.current, prop, receiver); - }, - }), - get applicationId() { return reactionBotUserIdRef.current ?? ""; }, - logger: getLogger("messaging"), - abortSession: async (sessionId) => requestSessionTurnAbort(sessionId ?? ""), - invokeControlOp: async (op, payload) => { - if (!stateDb) return { ok: false, error: "state database unavailable" }; - const sessions = createSessionStore(stateDb); - const ctx: IntegrationOpsContext = { - config: configRef.current, - stateDb, - acpxStore: undefined, - sessions, - sessionManager: undefined, - acpxSupervisor: undefined, - hitlPending: hitlStack?.pending, - recordIntegrationAudit: () => {}, - }; - const req = { - v: WIRE_VERSION, - id: randomUUID(), - op, - auth: { kind: "operator_token" as const, token: "__internal__" }, - payload, - }; - const principal = { kind: "operator" as const, operatorId: "discord-slash", roles: ["admin"], source: "cli_operator_token" as const }; - const result = await handleIntegrationControlOp(req, principal, ctx); - return { ok: true, result }; - }, - resolveSessionForChannel: (channelId, guildId) => { - try { - const agentsList = (configRef.current.agents as Record)?.list as Record | undefined; - if (!agentsList) return undefined; - for (const agentDef of Object.values(agentsList)) { - if (typeof agentDef !== "object" || agentDef === null) continue; - const discordPlatform = ((agentDef as Record).platforms as Record)?.discord as Record | undefined; - const routesList = discordPlatform?.routes; - if (!Array.isArray(routesList)) continue; - for (const r of routesList) { - if (typeof r !== "object" || r === null) continue; - const route = r as { channelId?: string; sessionId?: string; guildId?: string }; - if (route.channelId !== channelId) continue; - if (route.guildId !== undefined && route.guildId !== guildId) continue; - if (route.guildId === undefined && guildId !== undefined) continue; - return route.sessionId; - } - } - return undefined; - } catch { - return undefined; - } - }, - }), - onMessageReactionAdd: - hitlStack && hitlDiscordNoticeRegistry && hitlAutoApproveGate - ? (ev) => { - const consumed = handleDiscordHitlReactionAdd({ - ev, - pending: hitlStack.pending, - registry: hitlDiscordNoticeRegistry!, - autoApprove: hitlAutoApproveGate!, - ownerUserId: resolveDiscordOwnerUserId(configRef.current), - botUserIdRef: reactionBotUserIdRef, - logger: getLogger("discord-reactions"), - }); - if (!consumed) reactionPassthroughRef.current?.(ev); - } - : (ev) => { reactionPassthroughRef.current?.(ev); }, - reactionBotUserIdRef, - }); - if (discordMessaging) { - interactionTransportRef.current = discordMessaging.discordRestTransport; - rt.shutdown.registerDrain("discord-messaging", () => discordMessaging!.stop()); - } - } catch (e) { - getLogger("messaging").warn("discord messaging failed to start", { err: String(e) }); - } - if (!stateDb) { getLogger("daemon").warn("plugins and event loops skipped (no state database)"); return; @@ -405,16 +291,119 @@ void (async () => { }); } - try { - await bootstrapPlugins({ + // --- Process Manager: init singleton early so MCP stdio spawns go through procman --- + const procman = initProcessManager(); + setProcessManager(procman); + + // --- Turn Queue: init singleton early (needed during hook-triggered turns) --- + const starvationThreshold = config.runtime?.turnQueue?.starvationThreshold ?? 2; + const maxQueueDepth = config.runtime?.turnQueue?.maxDepth ?? 6; + setTurnQueue(new TieredTurnQueue(starvationThreshold, maxQueueDepth)); + + // --- Model Resilience Gate: init singleton early --- + { + const rc = config.runtime?.modelResilience; + const gate = new ModelResilienceGate( + { + maxRetries: rc?.maxRetries, + baseDelayMs: rc?.baseDelayMs, + maxDelayMs: rc?.maxDelayMs, + jitterMs: rc?.jitterMs, + defaultConcurrency: rc?.defaultConcurrency, + }, + rc?.providers, + ); + setResilienceGate(gate); + } + + // Create plugin system and load plugins via standard discovery + const pluginSystem = new ShoggothPluginSystem(); + const resolveFromFile = fileURLToPath(import.meta.url); + { + const { loadAllPluginsFromConfig } = await import("@shoggoth/plugins"); + const loaded = await loadAllPluginsFromConfig({ config, - db, - rt, - resolveFromFile: fileURLToPath(import.meta.url), + system: pluginSystem, + resolveFromFile, + audit: (e) => { + appendAuditRow(db, pluginAuditToRow(e)); + if (e.outcome === "failure") { + getLogger("daemon").error("plugin load failed", { plugin: e.resource, detail: e.detail }); + } + }, }); - } catch (e) { - getLogger("daemon").warn("plugin bootstrap failed", { err: String(e) }); + if (loaded.length > 0) { + getLogger("daemon").info("plugins loaded", { count: loaded.length, plugins: loaded.map(p => p.manifestName) }); + } } + + // Build PlatformDeps - platform-agnostic callbacks the plugins need + const platformsMap = new Map(); + const { PlatformDeliveryRegistry } = await import("@shoggoth/plugins"); + const deliveryRegistry = new PlatformDeliveryRegistry(); + + const platformDeps: PlatformDeps = { + hitlStack, + policyEngine, + hitlConfigRef: hitlRef, + hitlAutoApproveGate, + logger: getLogger("messaging"), + platformAssistantDeps: defaultPlatformAssistantDeps as unknown, + abortSession: async (sessionId) => { await requestSessionTurnAbort(sessionId ?? ""); }, + invokeControlOp: async (op, payload) => { + if (!stateDb) return { ok: false, error: "state database unavailable" }; + const sessions = createSessionStore(stateDb); + const ctx: IntegrationOpsContext = { + config: configRef.current, + stateDb, + acpxStore: undefined, + sessions, + sessionManager: undefined, + acpxSupervisor: undefined, + hitlPending: hitlStack?.pending, + recordIntegrationAudit: () => {}, + }; + const req = { + v: WIRE_VERSION, + id: randomUUID(), + op, + auth: { kind: "operator_token" as const, token: "__internal__" }, + payload, + }; + const principal = { kind: "operator" as const, operatorId: "platform-slash", roles: ["admin"], source: "cli_operator_token" as const }; + const result = await handleIntegrationControlOp(req, principal, ctx); + return { ok: true, result }; + }, + registerPlatform: (platformId, handle) => { + registerPlatform(platformId, handle as any); + platformsMap.set(platformId, handle); + }, + stopAllPlatforms, + reconcilePersistentSubagents: reconcilePersistentSubagents as PlatformDeps["reconcilePersistentSubagents"], + noticeResolver: daemonNotice as (key: string, params?: Record) => string, + }; + + // Fire daemon hooks — plugins handle platform.start, health.register, etc. + const hookResult = await fireDaemonHooks(pluginSystem, { + config, + db, + configRef, + env: process.env, + platforms: platformsMap, + deliveryRegistry, + registerDrain: (name, fn) => rt.shutdown.registerDrain(name, fn), + registerPlatform: (reg) => registerMessagingPlatform(reg), + setPlatformRuntime: (platformId, runtime) => platformsMap.set(platformId, runtime), + registerProbe: (probe) => rt.health.register(probe as any), + deps: platformDeps, + setSubagentRuntimeExtension: (ext) => setSubagentRuntimeExtension(ext as any), + setMessageToolContext: (ctx) => { messageToolContextRef.current = ctx as any; }, + setPlatformAdapter: (adapter) => { platformAdapterRef.current = adapter as any; }, + }); + + // Register plugin shutdown drains + rt.shutdown.registerDrain("plugin-platform-stop", hookResult.drains.platformStop); + rt.shutdown.registerDrain("plugin-daemon-shutdown", hookResult.drains.daemonShutdown); stateShutdown.db = db; stateShutdown.toolRuns = createToolRunStore(db); @@ -441,30 +430,9 @@ void (async () => { rt.shutdown.registerDrain("timer-scheduler", () => { timerScheduler.shutdown(); }); - // --- Process Manager: init singleton, start boot-time processes, register shutdown --- - const procman = initProcessManager(); - setProcessManager(procman); - - // --- Turn Queue: init singleton --- - const starvationThreshold = config.runtime?.turnQueue?.starvationThreshold ?? 2; - const maxQueueDepth = config.runtime?.turnQueue?.maxDepth ?? 6; - setTurnQueue(new TieredTurnQueue(starvationThreshold, maxQueueDepth)); + // --- Process Manager: start boot-time processes, register shutdown --- - // --- Model Resilience Gate: init singleton --- - { - const rc = config.runtime?.modelResilience; - const gate = new ModelResilienceGate( - { - maxRetries: rc?.maxRetries, - baseDelayMs: rc?.baseDelayMs, - maxDelayMs: rc?.maxDelayMs, - jitterMs: rc?.jitterMs, - defaultConcurrency: rc?.defaultConcurrency, - }, - rc?.providers, - ); - setResilienceGate(gate); - } + // (TurnQueue and ModelResilienceGate initialized earlier, before fireDaemonHooks) function processDeclarationToSpec(decl: ProcessDeclaration): ProcessSpec { return { @@ -504,7 +472,7 @@ void (async () => { }); // --- Workflow tool: init server, resume incomplete workflows, register shutdown --- - // Adapters use lazy refs because the Discord platform (and thus sessionManager, + // Adapters use lazy refs because the platform plugin (and thus sessionManager, // runSessionModelTurn, messageToolContextRef) are initialized later in this file. const workflowStateDir = resolve(config.stateDbPath, "..", "workflow-state"); try { @@ -560,16 +528,8 @@ void (async () => { const message = `**Workflow ${status}:** \`${workflowId}\``; getLogger("daemon").debug("workflow notify: delivering to session", { sessionId }); - const parsed = parseAgentSessionUrn(sessionId); - const delivery = (() => { - if (parsed?.platform === "discord") { - const ownerUserId = resolveDiscordOwnerUserId(configRef.current); - if (ownerUserId) { - return { kind: "messaging_surface" as const, userId: ownerUserId }; - } - } - return { kind: "internal" as const }; - })(); + const delivery = deliveryRegistry.resolveOperatorDelivery(sessionId, configRef.current) + ?? { kind: "internal" as const }; getLogger("daemon").debug("workflow notify: resolved delivery", { sessionId, deliveryKind: delivery.kind }); await ext.runSessionModelTurn({ sessionId, @@ -593,8 +553,8 @@ void (async () => { createMessageAdapter: (sessionId: string) => createDaemonMessageAdapter({ getMessageContext: () => messageToolContextRef.current ?? undefined, resolveChannelId: () => { - if (!discordMessaging?.resolveOutboundChannelIdForSession) return undefined; - return discordMessaging.resolveOutboundChannelIdForSession(sessionId); + // This will be resolved after platform starts - the platform adapter handles this + return undefined; }, sessionId, }), @@ -644,14 +604,8 @@ void (async () => { async sendNotification(target: string, message: string): Promise { const ext = subagentRuntimeExtensionRef.current; if (!ext) { getLogger("daemon").warn("workflow task notification: subagent runtime not available"); return; } - const parsed = parseAgentSessionUrn(target); - const delivery = (() => { - if (parsed?.platform === "discord") { - const ownerUserId = resolveDiscordOwnerUserId(configRef.current); - if (ownerUserId) return { kind: "messaging_surface" as const, userId: ownerUserId }; - } - return { kind: "internal" as const }; - })(); + const delivery = deliveryRegistry.resolveOperatorDelivery(target, configRef.current) + ?? { kind: "internal" as const }; try { await ext.runSessionModelTurn({ sessionId: target, @@ -683,130 +637,6 @@ void (async () => { getLogger("daemon").warn("workflow server failed to initialize", { err: String(e) }); } - const dm = discordMessaging; - if (dm && hitlStack) { - const discordPlatform = await startDiscordPlatform({ - db, - config, - configRef, - policyEngine, - hitlConfigRef: hitlRef, - hitlPending: hitlStack, - hitlDiscordNoticeRegistry, - hitlAutoApproveGate, - logger: getLogger("discord"), - discord: dm, - deps: defaultPlatformAssistantDeps, - }); - registerPlatform("discord", discordPlatform); - platformAdapterRef.current = discordPlatform.adapter; - - // Wire reaction passthrough: resolve raw Discord events into the processed format. - const passthroughLogger = getLogger("reaction-passthrough"); - reactionPassthroughRef.current = (ev) => { - const botId = reactionBotUserIdRef.current; - if (botId && ev.userId === botId) return; // ignore self-reactions - const owner = resolveDiscordOwnerUserId(configRef.current)?.trim(); - if (!owner || ev.userId !== owner) return; // operator-only - // Resolve session from channel - const sessionId = dm.resolveOutboundChannelIdForSession - ? (() => { - // Reverse lookup: find session whose outbound channel matches the event channel - for (const r of dm.routes) { - if (r.channelId === ev.channelId) return r.sessionId; - } - return undefined; - })() - : undefined; - if (!sessionId) { - passthroughLogger.debug("reaction.passthrough.no_session", { channelId: ev.channelId }); - return; - } - // Format emoji string - const emojiStr = ev.emoji.id ? `<:${ev.emoji.name ?? "_"}:${ev.emoji.id}>` : (ev.emoji.name ?? ""); - if (!emojiStr) return; - // Fetch message content and check if it's from the bot - void (async () => { - try { - const msg = await dm.discordRestTransport.getMessage(ev.channelId, ev.messageId); - const authorId = (msg.author as Record | undefined)?.id; - if (typeof authorId !== "string" || authorId !== botId) { - passthroughLogger.debug("reaction.passthrough.not_bot_message", { messageId: ev.messageId }); - return; - } - const content = typeof msg.content === "string" ? msg.content : ""; - const timestamp = typeof msg.timestamp === "string" ? new Date(msg.timestamp).getTime() : Date.now(); - await discordPlatform.handleReactionPassthrough({ - sessionId, - messageContent: content, - messageTimestamp: timestamp, - emoji: emojiStr, - userId: ev.userId, - }); - } catch (e) { - passthroughLogger.warn("reaction.passthrough.fetch_failed", { err: String(e), messageId: ev.messageId }); - } - })(); - }; - const subagentExt = { - runSessionModelTurn: discordPlatform.runSessionModelTurn, - subscribeSubagentSession: discordPlatform.subscribeSubagentSession, - registerPlatformThreadBinding: dm.registerPlatformThreadBinding, - announcePersistentSubagentSessionEnded: discordPlatform.announcePersistentSubagentSessionEnded, - }; - setSubagentRuntimeExtension(subagentExt); - messageToolContextRef.current = { - slice: messageToolSliceFromCapabilities(dm.capabilities), - execute: (sessionId, args) => - executeMessageToolAction( - { - capabilities: dm.capabilities, - transport: dm.discordRestTransport, - sessionToChannel: (sid) => dm.resolveOutboundChannelIdForSession?.(sid), - sessionToGuild: (sid) => dm.resolveGuildIdForSession?.(sid), - getSessionWorkspace: (sid) => { - try { - const row = db.prepare("SELECT workspace_path FROM sessions WHERE id = ?").get(sid) as - | { workspace_path: string } - | undefined; - return row?.workspace_path; - } catch { - return undefined; - } - }, - downloadFile: async (url, destPath) => { - const res = await fetch(url); - if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`); - const buf = Buffer.from(await res.arrayBuffer()); - const { writeFile, mkdir } = await import("node:fs/promises"); - const { dirname } = await import("node:path"); - await mkdir(dirname(destPath), { recursive: true }); - await writeFile(destPath, buf); - return buf.byteLength; - }, - }, - sessionId, - args, - ), - }; - const subRecon = reconcilePersistentSubagents({ - db, - config, - ext: subagentExt, - }); - if (subRecon.restored > 0 || subRecon.expiredKilled > 0) { - getLogger("messaging").info("subagent.persisted_reconciled", { - restored: subRecon.restored, - expired_killed: subRecon.expiredKilled, - }); - } - rt.shutdown.registerDrain("platforms", async () => { - await stopAllPlatforms(); - setSubagentRuntimeExtension(undefined); - messageToolContextRef.current = undefined; - }); - } - const heartbeatMs = resolveHeartbeatIntervalMs(configRef.current); const cronMs = resolveCronTickIntervalMs(configRef.current); const batchLimit = resolveHeartbeatBatchSize(configRef.current); @@ -859,7 +689,7 @@ void (async () => { })(); rt.health.register(createSqliteProbe({ getPath: () => config.stateDbPath })); -rt.health.register(createDiscordProbe({ getToken: resolvedDiscordBotToken })); +// Note: Platform health probes are registered by plugins via health.register hook rt.health.register( createModelEndpointProbe({ getBaseUrl: () => resolveModelHealthProbeBaseUrl(configRef.current), @@ -899,7 +729,7 @@ void (async () => { const sqliteFailed = checks.some((c) => c.name === "sqlite" && c.status === "fail"); const modelChecks = checks.filter((c) => c.name === "model"); const allModelsFailed = modelChecks.length > 0 && modelChecks.every((c) => c.status === "fail"); - const anyNonModelFailed = checks.some((c) => (c.name === "embeddings" || c.name === "discord") && c.status === "fail"); + const anyNonModelFailed = checks.some((c) => c.name !== "sqlite" && c.name !== "model" && c.status === "fail"); const someModelFailed = modelChecks.some((c) => c.status === "fail"); const level = sqliteFailed || allModelsFailed ? "error" : anyNonModelFailed || someModelFailed ? "warn" : "info"; @@ -931,4 +761,4 @@ void rt.shutdown.finished.then(() => { process.exit(0); }); -setInterval(() => {}, 86_400_000); +setInterval(() => {}, 86_400_000); \ No newline at end of file diff --git a/packages/daemon/src/plugins/bootstrap.ts b/packages/daemon/src/plugins/bootstrap.ts index 91a5196..c050093 100644 --- a/packages/daemon/src/plugins/bootstrap.ts +++ b/packages/daemon/src/plugins/bootstrap.ts @@ -1,6 +1,6 @@ import type { ShoggothConfig } from "@shoggoth/shared"; import { - HookRegistry, + ShoggothPluginSystem, loadAllPluginsFromConfig, type PluginAuditEvent, } from "@shoggoth/plugins"; @@ -24,7 +24,7 @@ function effectiveConfigAuditPayload(config: ShoggothConfig): string { }); } -function pluginAuditToRow(e: PluginAuditEvent): AppendAuditRowInput { +export function pluginAuditToRow(e: PluginAuditEvent): AppendAuditRowInput { return { source: "system", principalKind: "system", @@ -37,8 +37,8 @@ function pluginAuditToRow(e: PluginAuditEvent): AppendAuditRowInput { } /** - * Loads `shoggoth.json` plugins from config, records audit rows, runs startup hooks, - * registers shutdown hooks + unload audit. + * Loads plugins from config using the new ShoggothPluginSystem, + * records audit rows, fires daemon.startup, registers shutdown hooks. */ export async function bootstrapPlugins(options: { readonly config: ShoggothConfig; @@ -56,17 +56,25 @@ export async function bootstrapPlugins(options: { argsRedactedJson: effectiveConfigAuditPayload(options.config), }); - const registry = new HookRegistry(); + const system = new ShoggothPluginSystem(); const loaded = await loadAllPluginsFromConfig({ config: options.config, - registry, + system, resolveFromFile: options.resolveFromFile, audit: (e) => appendAuditRow(options.db, pluginAuditToRow(e)), }); - await registry.run("daemon.startup"); + + await system.lifecycle["daemon.startup"].emit({ + db: options.db, + config: options.config, + configRef: { current: options.config }, + registerDrain: (name: string, fn: () => void | Promise) => { + options.rt.shutdown.registerDrain(name, fn); + }, + }); options.rt.shutdown.registerDrain("plugin-daemon-shutdown-hooks", async () => { - await registry.run("daemon.shutdown"); + await system.lifecycle["daemon.shutdown"].emit({ reason: "shutdown" }); }); options.rt.shutdown.registerDrain("plugin-unload-audit", async () => { for (const p of loaded) { diff --git a/packages/daemon/src/plugins/daemon-hooks.ts b/packages/daemon/src/plugins/daemon-hooks.ts new file mode 100644 index 0000000..03829ea --- /dev/null +++ b/packages/daemon/src/plugins/daemon-hooks.ts @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------------------- +// daemon-hooks.ts — Orchestrates hook firing in the correct boot sequence +// ------------------------------------------------------------------------------- + +import type { ShoggothConfig } from "@shoggoth/shared"; +import type { ShoggothPluginSystem, PlatformDeps, PlatformDeliveryRegistry } from "@shoggoth/plugins"; +import type { PlatformRegistration, PlatformRuntime } from "@shoggoth/messaging"; +import type { SubagentRuntimeExtension, MessageToolContext, PlatformAdapter } from "@shoggoth/shared"; +import type { HealthProbe } from "@shoggoth/plugins"; + +export interface DaemonHooksContext { + config: ShoggothConfig; + db: unknown; + configRef: { current: ShoggothConfig }; + env: NodeJS.ProcessEnv; + platforms: Map; + deliveryRegistry: PlatformDeliveryRegistry; + registerDrain: (name: string, fn: () => void | Promise) => void; + registerPlatform: (reg: PlatformRegistration) => void; + setPlatformRuntime: (platformId: string, runtime: PlatformRuntime) => void; + registerProbe: (probe: HealthProbe) => void; + deps: PlatformDeps; + setSubagentRuntimeExtension: (ext: SubagentRuntimeExtension | undefined) => void; + setMessageToolContext: (ctx: MessageToolContext) => void; + setPlatformAdapter: (adapter: PlatformAdapter) => void; +} + +export interface DaemonHooksResult { + config: ShoggothConfig; + drains: { + platformStop: () => Promise; + daemonShutdown: () => Promise; + }; +} + +/** + * Fires daemon lifecycle hooks in the correct boot sequence order: + * + * 1. daemon.configure (sync waterfall — returns transformed config) + * 2. platform.register (sync) + * 3. health.register (sync) + * 4. platform.start (async) + * 5. daemon.startup (async) + * 6. Lock plugin system + * 7. daemon.ready (async) + * + * Returns the final config and drain functions for shutdown hooks. + */ +export async function fireDaemonHooks( + system: ShoggothPluginSystem, + ctx: DaemonHooksContext, +): Promise { + // 1. daemon.configure waterfall + const configureResult = system.lifecycle["daemon.configure"].emit({ + config: ctx.config, + }); + const config = configureResult?.config ?? ctx.config; + + // 2. platform.register (sync) + system.lifecycle["platform.register"].emit({ + config, + registerPlatform: ctx.registerPlatform, + setPlatformRuntime: ctx.setPlatformRuntime, + }); + + // 3. health.register (sync) + system.lifecycle["health.register"].emit({ + registerProbe: ctx.registerProbe, + }); + + // 4. platform.start (async) + await system.lifecycle["platform.start"].emit({ + db: ctx.db, + config, + configRef: ctx.configRef, + env: ctx.env, + deps: ctx.deps, + deliveryRegistry: ctx.deliveryRegistry, + registerDrain: ctx.registerDrain, + setSubagentRuntimeExtension: ctx.setSubagentRuntimeExtension, + setMessageToolContext: ctx.setMessageToolContext, + setPlatformAdapter: ctx.setPlatformAdapter, + }); + + // 5. daemon.startup (async) + await system.lifecycle["daemon.startup"].emit({ + db: ctx.db, + config, + configRef: ctx.configRef, + registerDrain: ctx.registerDrain, + }); + + // 6. Lock plugin system + system.lock(); + + // 7. daemon.ready (async) + await system.lifecycle["daemon.ready"].emit({ + config, + platforms: ctx.platforms, + }); + + // Return config + drain functions for shutdown + return { + config, + drains: { + platformStop: async () => { + await system.lifecycle["platform.stop"].emit({ platformId: "*" }); + }, + daemonShutdown: async () => { + await system.lifecycle["daemon.shutdown"].emit({ reason: "shutdown" }); + }, + }, + }; +} diff --git a/packages/daemon/test/plugins/daemon-hooks.test.ts b/packages/daemon/test/plugins/daemon-hooks.test.ts new file mode 100644 index 0000000..e131ba0 --- /dev/null +++ b/packages/daemon/test/plugins/daemon-hooks.test.ts @@ -0,0 +1,262 @@ +import { describe, test, expect, vi } from "vitest"; +import { ShoggothPluginSystem } from "@shoggoth/plugins"; + +// Module under test — does NOT exist yet → tests must fail at import time +import { fireDaemonHooks } from "../../src/plugins/daemon-hooks"; + +// --------------------------------------------------------------------------- +// Helpers: a test plugin that records every hook invocation in order +// --------------------------------------------------------------------------- +function createRecorderPlugin(name: string, callLog: string[]) { + return { + name, + hooks: { + "daemon.configure": (ctx: any) => { + callLog.push(`${name}:daemon.configure`); + return { ...ctx, config: { ...ctx.config, [`${name}_configured`]: true } }; + }, + "platform.register": (_ctx: any) => { + callLog.push(`${name}:platform.register`); + }, + "health.register": (_ctx: any) => { + callLog.push(`${name}:health.register`); + }, + "platform.start": async (_ctx: any) => { + callLog.push(`${name}:platform.start`); + }, + "daemon.startup": async (_ctx: any) => { + callLog.push(`${name}:daemon.startup`); + }, + "daemon.ready": async (_ctx: any) => { + callLog.push(`${name}:daemon.ready`); + }, + "platform.stop": async (_ctx: any) => { + callLog.push(`${name}:platform.stop`); + }, + "daemon.shutdown": async (_ctx: any) => { + callLog.push(`${name}:daemon.shutdown`); + }, + }, + }; +} + +/** Minimal context stubs for fireDaemonHooks */ +function createStubContext(overrides: Record = {}) { + return { + config: { logLevel: "info", plugins: [], ...(overrides.config ?? {}) }, + db: overrides.db ?? ({} as any), + configRef: overrides.configRef ?? { current: { logLevel: "info" } }, + env: overrides.env ?? process.env, + platforms: overrides.platforms ?? new Map(), + registerDrain: overrides.registerDrain ?? vi.fn(), + registerPlatform: overrides.registerPlatform ?? vi.fn(), + setPlatformRuntime: overrides.setPlatformRuntime ?? vi.fn(), + registerProbe: overrides.registerProbe ?? vi.fn(), + deps: overrides.deps ?? { + hitlStack: {}, + policyEngine: {}, + hitlConfigRef: {}, + }, + setSubagentRuntimeExtension: overrides.setSubagentRuntimeExtension ?? vi.fn(), + setMessageToolContext: overrides.setMessageToolContext ?? vi.fn(), + setPlatformAdapter: overrides.setPlatformAdapter ?? vi.fn(), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("fireDaemonHooks", () => { + // 1. daemon.configure waterfall returns (possibly transformed) config + test("daemon.configure waterfall is fired and returns transformed config", async () => { + const system = new ShoggothPluginSystem(); + const callLog: string[] = []; + + system.use({ + name: "config-plugin", + hooks: { + "daemon.configure": (ctx: any) => { + callLog.push("daemon.configure"); + return { ...ctx, config: { ...ctx.config, transformed: true } }; + }, + }, + }); + + const ctx = createStubContext({ config: { original: true } }); + const result = await fireDaemonHooks(system, ctx); + + expect(callLog).toContain("daemon.configure"); + expect(result.config.original).toBe(true); + expect(result.config.transformed).toBe(true); + }); + + // 2. Hooks fire in correct order: + // configure → platform.register → health.register → platform.start → daemon.startup → daemon.ready + test("hooks fire in correct boot sequence order", async () => { + const system = new ShoggothPluginSystem(); + const callLog: string[] = []; + + system.use(createRecorderPlugin("p1", callLog)); + + const ctx = createStubContext(); + await fireDaemonHooks(system, ctx); + + const expectedOrder = [ + "p1:daemon.configure", + "p1:platform.register", + "p1:health.register", + "p1:platform.start", + "p1:daemon.startup", + "p1:daemon.ready", + ]; + + expect(callLog).toEqual(expectedOrder); + }); + + test("order is preserved across multiple plugins", async () => { + const system = new ShoggothPluginSystem(); + const callLog: string[] = []; + + system.use(createRecorderPlugin("alpha", callLog)); + system.use(createRecorderPlugin("beta", callLog)); + + const ctx = createStubContext(); + await fireDaemonHooks(system, ctx); + + // Each hook phase should fire all plugins before moving to the next phase. + // Within a phase, plugins fire in registration order (FIFO). + const configureIdx = callLog.indexOf("alpha:daemon.configure"); + const registerIdx = callLog.indexOf("alpha:platform.register"); + const healthIdx = callLog.indexOf("alpha:health.register"); + const startIdx = callLog.indexOf("alpha:platform.start"); + const startupIdx = callLog.indexOf("alpha:daemon.startup"); + const readyIdx = callLog.indexOf("alpha:daemon.ready"); + + // All configure calls happen before any platform.register calls + const lastConfigure = Math.max( + callLog.indexOf("alpha:daemon.configure"), + callLog.indexOf("beta:daemon.configure"), + ); + const firstRegister = Math.min( + callLog.indexOf("alpha:platform.register"), + callLog.indexOf("beta:platform.register"), + ); + expect(lastConfigure).toBeLessThan(firstRegister); + + // Strict phase ordering + expect(configureIdx).toBeLessThan(registerIdx); + expect(registerIdx).toBeLessThan(healthIdx); + expect(healthIdx).toBeLessThan(startIdx); + expect(startIdx).toBeLessThan(startupIdx); + expect(startupIdx).toBeLessThan(readyIdx); + }); + + // 3. platform.stop and daemon.shutdown are returned as drain functions, not fired immediately + test("platform.stop and daemon.shutdown are returned as drain functions, not fired during boot", async () => { + const system = new ShoggothPluginSystem(); + const callLog: string[] = []; + + system.use(createRecorderPlugin("draintest", callLog)); + + const ctx = createStubContext(); + const result = await fireDaemonHooks(system, ctx); + + // platform.stop and daemon.shutdown should NOT have been called during boot + expect(callLog).not.toContain("draintest:platform.stop"); + expect(callLog).not.toContain("draintest:daemon.shutdown"); + + // They should be returned as callable drain functions + expect(result.drains).toBeDefined(); + expect(typeof result.drains.platformStop).toBe("function"); + expect(typeof result.drains.daemonShutdown).toBe("function"); + + // Calling the drain functions should fire the hooks + await result.drains.platformStop(); + expect(callLog).toContain("draintest:platform.stop"); + + await result.drains.daemonShutdown(); + expect(callLog).toContain("draintest:daemon.shutdown"); + }); + + // 4. The system is locked after daemon.ready fires + test("plugin system is locked after daemon.ready fires", async () => { + const system = new ShoggothPluginSystem(); + const callLog: string[] = []; + + system.use(createRecorderPlugin("locktest", callLog)); + + const ctx = createStubContext(); + await fireDaemonHooks(system, ctx); + + // After fireDaemonHooks completes, the system should be locked. + // Attempting to register a new plugin should throw. + expect(() => { + system.use({ + name: "late-plugin", + hooks: { + "daemon.startup": async () => {}, + }, + }); + }).toThrow(); + }); + + test("system is not locked before daemon.ready fires", async () => { + const system = new ShoggothPluginSystem(); + + // Before calling fireDaemonHooks, registration should work fine + expect(() => { + system.use({ + name: "early-plugin", + hooks: { + "daemon.startup": async () => {}, + }, + }); + }).not.toThrow(); + }); + + // Edge case: no plugins registered — should still complete without error + test("completes successfully with no plugins registered", async () => { + const system = new ShoggothPluginSystem(); + const ctx = createStubContext(); + + const result = await fireDaemonHooks(system, ctx); + + expect(result).toBeDefined(); + expect(result.config).toBeDefined(); + expect(typeof result.drains.platformStop).toBe("function"); + expect(typeof result.drains.daemonShutdown).toBe("function"); + }); + + // daemon.configure waterfall with multiple plugins chains transformations + test("daemon.configure waterfall chains across multiple plugins", async () => { + const system = new ShoggothPluginSystem(); + + system.use({ + name: "plugin-a", + hooks: { + "daemon.configure": (ctx: any) => ({ + ...ctx, + config: { ...ctx.config, a: true }, + }), + }, + }); + + system.use({ + name: "plugin-b", + hooks: { + "daemon.configure": (ctx: any) => ({ + ...ctx, + config: { ...ctx.config, b: true }, + }), + }, + }); + + const ctx = createStubContext({ config: { seed: 1 } }); + const result = await fireDaemonHooks(system, ctx); + + // Both plugins should have contributed to the final config + expect(result.config.seed).toBe(1); + expect(result.config.a).toBe(true); + expect(result.config.b).toBe(true); + }); +}); diff --git a/packages/daemon/test/plugins/plugin-bootstrap.test.ts b/packages/daemon/test/plugins/plugin-bootstrap.test.ts index d23f7a5..34c9fe1 100644 --- a/packages/daemon/test/plugins/plugin-bootstrap.test.ts +++ b/packages/daemon/test/plugins/plugin-bootstrap.test.ts @@ -33,21 +33,26 @@ describe("bootstrapPlugins", () => { mkdirSync(pluginDir); writeFileSync( - join(pluginDir, "shoggoth.json"), + join(pluginDir, "package.json"), JSON.stringify({ name: "tplug", version: "0.1.0", - hooks: { - "daemon.startup": "./s.mjs", - "daemon.shutdown": "./d.mjs", + shoggothPlugin: { + kind: "general", + entrypoint: "./index.mjs", }, }), ); writeFileSync( - join(pluginDir, "s.mjs"), - `export default () => { globalThis.__shoggothPlugTest = (globalThis.__shoggothPlugTest ?? 0) + 1; };`, + join(pluginDir, "index.mjs"), + `export default () => ({ + name: "tplug", + hooks: { + "daemon.startup": () => { globalThis.__shoggothPlugTest = (globalThis.__shoggothPlugTest ?? 0) + 1; }, + "daemon.shutdown": () => {}, + }, + });`, ); - writeFileSync(join(pluginDir, "d.mjs"), `export default () => {};`); const config: ShoggothConfig = { ...defaultConfig(cfgDir), diff --git a/packages/mcp-integration/src/mcp-jsonrpc-transport.ts b/packages/mcp-integration/src/mcp-jsonrpc-transport.ts index 7966749..9c49e33 100644 --- a/packages/mcp-integration/src/mcp-jsonrpc-transport.ts +++ b/packages/mcp-integration/src/mcp-jsonrpc-transport.ts @@ -306,7 +306,7 @@ export async function connectMcpStdioSession(opts: McpStdioConnectOptions): Prom async function connectMcpStdioSessionDirect(opts: McpStdioConnectOptions): Promise { const proc = spawn(opts.command, opts.args ? [...opts.args] : [], { cwd: opts.cwd, - env: opts.env, + env: opts.env ? { ...process.env, ...opts.env } : undefined, stdio: ["pipe", "pipe", "ignore"], }); const out = proc.stdout; diff --git a/packages/platform-discord/package.json b/packages/platform-discord/package.json index 7487920..9574d93 100644 --- a/packages/platform-discord/package.json +++ b/packages/platform-discord/package.json @@ -9,7 +9,12 @@ "types": "./src/index.ts", "import": "./src/index.ts", "default": "./src/index.ts" - } + }, + "./package.json": "./package.json" + }, + "shoggothPlugin": { + "kind": "messaging-platform", + "entrypoint": "./src/plugin.ts" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", @@ -19,6 +24,7 @@ "@shoggoth/daemon": "*", "@shoggoth/messaging": "*", "@shoggoth/models": "*", + "@shoggoth/plugins": "*", "@shoggoth/shared": "*", "zod": "^3.24.0" }, diff --git a/packages/platform-discord/src/index.ts b/packages/platform-discord/src/index.ts index 40e3700..8eb3125 100644 --- a/packages/platform-discord/src/index.ts +++ b/packages/platform-discord/src/index.ts @@ -32,5 +32,8 @@ export * from "./config"; // Step 6: Discord health probe export * from "./probe"; +// Step 8: Discord plugin (MessagingPlatformPlugin) +export { default as createDiscordPlugin } from "./plugin"; + // Step 7: Discord platform adapter (presentation layer integration) export * from "./discord-platform-adapter"; diff --git a/packages/platform-discord/src/plugin.ts b/packages/platform-discord/src/plugin.ts new file mode 100644 index 0000000..3bedca7 --- /dev/null +++ b/packages/platform-discord/src/plugin.ts @@ -0,0 +1,343 @@ +// ------------------------------------------------------------------------------- +// Discord Platform Plugin — implements MessagingPlatformPlugin +// ------------------------------------------------------------------------------- + +import { + defineMessagingPlatformPlugin, + type MessagingPlatformPlugin, + type PlatformStartCtx, + type PlatformDeps, + type PlatformDeliveryResolver, +} from "@shoggoth/plugins"; +import { discordPlatformRegistration } from "./platform-registration"; +import { createDiscordProbe } from "./probe"; +import type { DiscordMessagingRuntime } from "./bootstrap"; +import { createHitlDiscordNoticeRegistry, type HitlDiscordNoticeRegistry } from "./hitl/notice-registry"; +import type { DiscordPlatformHandle } from "./platform"; +import { + startDaemonDiscordMessaging, + startDiscordPlatform, + createDiscordInteractionHandler, + handleDiscordHitlReactionAdd, + resolveDiscordOwnerUserId, +} from "@shoggoth/platform-discord"; +import { executeMessageToolAction } from "@shoggoth/messaging"; +import { resolvePlatformConfig } from "@shoggoth/shared"; + +/** Reaction event shape (matches adapter's DiscordReactionAddEvent). */ +interface ReactionAddEvent { + userId: string; + channelId: string; + messageId: string; + emoji: { name?: string; id?: string }; +} + +/** State held across the plugin's lifecycle. */ +interface DiscordPluginState { + messaging?: DiscordMessagingRuntime; + platform?: DiscordPlatformHandle; + reactionBotUserIdRef: { current: string | undefined }; + reactionPassthroughRef: { current: ((ev: ReactionAddEvent) => void) | undefined }; + getToken: () => string | undefined; +} + +/** Resolve the Discord bot token from env or config. */ +function resolveDiscordBotToken(config: any): string | undefined { + const fromEnv = process.env.DISCORD_BOT_TOKEN?.trim(); + if (fromEnv) return fromEnv; + const dc = resolvePlatformConfig(config, "discord"); + return (dc?.token as string | undefined)?.trim() || undefined; +} + +/** Resolve session ID for a given channel/guild from agent routes config. */ +function resolveSessionForChannel(config: any, channelId: string, guildId?: string): string | undefined { + try { + const agentsList = (config.agents as Record)?.list as Record | undefined; + if (!agentsList) return undefined; + for (const agentDef of Object.values(agentsList)) { + if (typeof agentDef !== "object" || agentDef === null) continue; + const discordPlatform = ((agentDef as Record).platforms as Record)?.discord as Record | undefined; + const routesList = discordPlatform?.routes; + if (!Array.isArray(routesList)) continue; + for (const r of routesList) { + if (typeof r !== "object" || r === null) continue; + const route = r as { channelId?: string; sessionId?: string; guildId?: string }; + if (route.channelId !== channelId) continue; + if (route.guildId !== undefined && route.guildId !== guildId) continue; + if (route.guildId === undefined && guildId !== undefined) continue; + return route.sessionId; + } + } + return undefined; + } catch { + return undefined; + } +} + +/** + * Discord delivery resolver — tells the daemon how to reach the operator + * on Discord-owned sessions. + */ +function createDiscordDeliveryResolver(configRef: { current: any }): PlatformDeliveryResolver { + return { + resolveOperatorDelivery(_sessionId, config) { + const ownerUserId = resolveDiscordOwnerUserId(config ?? configRef.current); + if (ownerUserId) { + return { kind: "messaging_surface", userId: ownerUserId }; + } + return undefined; + }, + resolveSessionForInbound(identifiers, config) { + return resolveSessionForChannel(config ?? configRef.current, identifiers.channelId, identifiers.guildId); + }, + }; +} + +export default function createDiscordPlugin(): MessagingPlatformPlugin { + const state: DiscordPluginState = { + reactionBotUserIdRef: { current: undefined }, + reactionPassthroughRef: { current: undefined }, + getToken: () => undefined, + }; + + return defineMessagingPlatformPlugin({ + name: "platform-discord", + hooks: { + "platform.register"(ctx) { + // Register the Discord platform URN policy + ctx.registerPlatform(discordPlatformRegistration); + }, + + async "platform.start"(ctx) { + const { db, config, configRef, env, deps, deliveryRegistry, registerDrain, setSubagentRuntimeExtension, setMessageToolContext, setPlatformAdapter } = ctx as PlatformStartCtx; + const platformDeps = deps as PlatformDeps; + + // Plugin reads its own bot token from config + state.getToken = () => resolveDiscordBotToken(configRef.current); + + // Register delivery resolver for the "discord" platform segment + deliveryRegistry.register("discord", createDiscordDeliveryResolver(configRef)); + + // Get dependencies from context + const hitlStack = platformDeps.hitlStack; + const hitlAutoApproveGate = platformDeps.hitlAutoApproveGate; + // Plugin owns its own notice registry + const hitlDiscordNoticeRegistry = hitlStack ? createHitlDiscordNoticeRegistry() : undefined; + const logger = platformDeps.logger; + const platformAssistantDeps = platformDeps.platformAssistantDeps; + const abortSession = platformDeps.abortSession; + const invokeControlOp = platformDeps.invokeControlOp; + const registerPlatformFn = platformDeps.registerPlatform; + const stopAllPlatforms = platformDeps.stopAllPlatforms; + const reconcilePersistentSubagents = platformDeps.reconcilePersistentSubagents; + const noticeResolver = platformDeps.noticeResolver; + + // Create interaction transport ref for the interaction handler + const interactionTransportRef: { current: DiscordMessagingRuntime["discordRestTransport"] | undefined } = { current: undefined }; + + // Start Discord messaging (gateway) + const discordMessaging = await startDaemonDiscordMessaging({ + logger, + config: configRef.current, + botToken: state.getToken(), + noticeResolver: noticeResolver as any, + onInteractionCreate: createDiscordInteractionHandler({ + transport: new Proxy({} as DiscordMessagingRuntime["discordRestTransport"], { + get(_t, prop, receiver) { + if (!interactionTransportRef.current) throw new Error("discord transport not ready"); + return Reflect.get(interactionTransportRef.current, prop, receiver); + }, + }), + get applicationId() { return state.reactionBotUserIdRef.current ?? ""; }, + logger, + abortSession: abortSession as any, + invokeControlOp, + resolveSessionForChannel: (channelId, guildId) => + resolveSessionForChannel(configRef.current, channelId, guildId), + }), + onMessageReactionAdd: + hitlStack && hitlDiscordNoticeRegistry && hitlAutoApproveGate + ? (ev: any) => { + const consumed = handleDiscordHitlReactionAdd({ + ev, + pending: hitlStack.pending as any, + registry: hitlDiscordNoticeRegistry, + autoApprove: hitlAutoApproveGate as any, + ownerUserId: resolveDiscordOwnerUserId(configRef.current), + botUserIdRef: state.reactionBotUserIdRef, + logger: (logger.child as any)?.("reactions") ?? logger, + }); + if (!consumed) state.reactionPassthroughRef.current?.(ev); + } + : (ev: any) => { state.reactionPassthroughRef.current?.(ev); }, + reactionBotUserIdRef: state.reactionBotUserIdRef, + }); + + if (discordMessaging) { + state.messaging = discordMessaging; + interactionTransportRef.current = discordMessaging.discordRestTransport; + registerDrain("discord-messaging", () => discordMessaging.stop()); + } + + if (!discordMessaging || !db) { + logger.warn("discord messaging failed to start or no database"); + return; + } + + // Get policy engine from deps + const policyEngine = platformDeps.policyEngine; + + // Start Discord platform (sessions, HITL, MCP, orchestrator) + const discordPlatform = await startDiscordPlatform({ + db: db as any, + config: configRef.current, + configRef, + policyEngine: policyEngine as any, + hitlConfigRef: platformDeps.hitlConfigRef as any, + hitlPending: hitlStack as any, + hitlDiscordNoticeRegistry, + hitlAutoApproveGate: hitlAutoApproveGate as any, + logger, + discord: discordMessaging, + deps: platformAssistantDeps as any, + }); + + state.platform = discordPlatform; + registerPlatformFn("discord", discordPlatform); + setPlatformAdapter(discordPlatform.adapter as any); + + // Wire reaction passthrough + state.reactionPassthroughRef.current = (ev) => { + const botId = state.reactionBotUserIdRef.current; + if (botId && ev.userId === botId) return; + const owner = resolveDiscordOwnerUserId(configRef.current)?.trim(); + if (!owner || ev.userId !== owner) return; + + const sessionId = discordMessaging.resolveOutboundChannelIdForSession + ? (() => { + for (const r of discordMessaging.routes) { + if (r.channelId === ev.channelId) return r.sessionId; + } + return undefined; + })() + : undefined; + + if (!sessionId) { + logger.debug("reaction.passthrough.no_session", { channelId: ev.channelId }); + return; + } + + const emojiStr = ev.emoji.id ? `<:${ev.emoji.name ?? "_"}:${ev.emoji.id}>` : (ev.emoji.name ?? ""); + if (!emojiStr) return; + + void (async () => { + try { + const msg = await discordMessaging.discordRestTransport.getMessage(ev.channelId, ev.messageId); + const authorId = (msg.author as Record | undefined)?.id; + if (typeof authorId !== "string" || authorId !== botId) { + logger.debug("reaction.passthrough.not_bot_message", { messageId: ev.messageId }); + return; + } + const content = typeof msg.content === "string" ? msg.content : ""; + const timestamp = typeof msg.timestamp === "string" ? new Date(msg.timestamp).getTime() : Date.now(); + await discordPlatform.handleReactionPassthrough({ + sessionId, + messageContent: content, + messageTimestamp: timestamp, + emoji: emojiStr, + userId: ev.userId, + }); + } catch (e) { + logger.warn("reaction.passthrough.fetch_failed", { err: String(e), messageId: ev.messageId }); + } + })(); + }; + + // Set subagent runtime extension + const subagentExt = { + runSessionModelTurn: discordPlatform.runSessionModelTurn, + subscribeSubagentSession: discordPlatform.subscribeSubagentSession, + registerPlatformThreadBinding: discordMessaging.registerPlatformThreadBinding, + announcePersistentSubagentSessionEnded: discordPlatform.announcePersistentSubagentSessionEnded, + }; + setSubagentRuntimeExtension(subagentExt as any); + + // Build message tool context from capabilities + const msgCtx = { + slice: discordMessaging.capabilities.extensions as unknown as Record, + execute: (sessionId: string, args: any) => + executeMessageToolAction( + { + capabilities: discordMessaging.capabilities, + transport: discordMessaging.discordRestTransport, + sessionToChannel: (sid) => discordMessaging.resolveOutboundChannelIdForSession?.(sid), + sessionToGuild: (sid) => discordMessaging.resolveGuildIdForSession?.(sid), + getSessionWorkspace: (sid) => { + try { + const row = (db as any).prepare("SELECT workspace_path FROM sessions WHERE id = ?").get(sid) as + | { workspace_path: string } + | undefined; + return row?.workspace_path; + } catch { + return undefined; + } + }, + downloadFile: async (url, destPath) => { + const res = await fetch(url); + if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + const { writeFile, mkdir } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + await mkdir(dirname(destPath), { recursive: true }); + await writeFile(destPath, buf); + return buf.byteLength; + }, + }, + sessionId, + args, + ), + }; + setMessageToolContext(msgCtx); + + // Run persistent subagent reconciliation + const subRecon = reconcilePersistentSubagents({ + db, + config: configRef.current, + ext: subagentExt, + }); + if (subRecon.restored > 0 || subRecon.expiredKilled > 0) { + logger.info("subagent.persisted_reconciled", { + restored: subRecon.restored, + expired_killed: subRecon.expiredKilled, + }); + } + + // Register shutdown drain for platforms + registerDrain("platforms", async () => { + await stopAllPlatforms(); + setSubagentRuntimeExtension(undefined); + }); + }, + + async "platform.stop"(_ctx) { + if (state.platform) { + await state.platform.stop(); + state.platform = undefined; + } + if (state.messaging) { + await state.messaging.stop(); + state.messaging = undefined; + } + state.reactionPassthroughRef.current = undefined; + }, + + "health.register"(ctx) { + ctx.registerProbe( + createDiscordProbe({ + getToken: state.getToken, + }) as any, + ); + }, + }, + }); +} diff --git a/packages/platform-discord/test/plugin.test.ts b/packages/platform-discord/test/plugin.test.ts new file mode 100644 index 0000000..83f34b4 --- /dev/null +++ b/packages/platform-discord/test/plugin.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import createDiscordPlugin from "../src/plugin"; +import { + defineMessagingPlatformPlugin, + REQUIRED_MESSAGING_PLATFORM_HOOKS, +} from "@shoggoth/plugins"; +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("createDiscordPlugin", () => { + it("returns a valid MessagingPlatformPlugin with name and all 4 required hooks", () => { + const plugin = createDiscordPlugin(); + + expect(plugin.name).toBe("platform-discord"); + expect(plugin.hooks).toBeDefined(); + + for (const hook of REQUIRED_MESSAGING_PLATFORM_HOOKS) { + expect(typeof plugin.hooks[hook]).toBe("function"); + } + }); + + it("passes defineMessagingPlatformPlugin validation without throwing", () => { + const plugin = createDiscordPlugin(); + expect(() => defineMessagingPlatformPlugin(plugin)).not.toThrow(); + }); + + it("platform.register hook calls ctx.registerPlatform with discordPlatformRegistration", () => { + const plugin = createDiscordPlugin(); + const registerPlatform = vi.fn(); + const ctx = { + config: {} as any, + registerPlatform, + setPlatformRuntime: vi.fn(), + }; + + plugin.hooks["platform.register"](ctx); + + expect(registerPlatform).toHaveBeenCalledTimes(1); + // The argument should be the discordPlatformRegistration object + const reg = registerPlatform.mock.calls[0][0]; + expect(reg).toBeDefined(); + expect(reg.platformId ?? reg.id ?? reg.name).toBeDefined(); + }); + + it("health.register hook calls ctx.registerProbe", () => { + const plugin = createDiscordPlugin(); + const registerProbe = vi.fn(); + const ctx = { registerProbe }; + + plugin.hooks["health.register"](ctx); + + expect(registerProbe).toHaveBeenCalledTimes(1); + const probe = registerProbe.mock.calls[0][0]; + expect(probe).toBeDefined(); + expect(typeof probe.name).toBe("string"); + expect(typeof probe.check).toBe("function"); + }); + + it("platform.start hook exists and is async", () => { + const plugin = createDiscordPlugin(); + expect(typeof plugin.hooks["platform.start"]).toBe("function"); + // Async functions have AsyncFunction constructor + expect(plugin.hooks["platform.start"].constructor.name).toBe( + "AsyncFunction", + ); + }); + + it("platform.stop hook exists and is async", () => { + const plugin = createDiscordPlugin(); + expect(typeof plugin.hooks["platform.stop"]).toBe("function"); + expect(plugin.hooks["platform.stop"].constructor.name).toBe( + "AsyncFunction", + ); + }); +}); + +describe("platform-discord package.json", () => { + it("has a shoggothPlugin property bag with kind: messaging-platform", () => { + const pkgPath = resolve(__dirname, "..", "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + + expect(pkg.shoggothPlugin).toBeDefined(); + expect(pkg.shoggothPlugin.kind).toBe("messaging-platform"); + }); +}); diff --git a/packages/plugins/README.md b/packages/plugins/README.md new file mode 100644 index 0000000..8c6c25a --- /dev/null +++ b/packages/plugins/README.md @@ -0,0 +1,239 @@ +# @shoggoth/plugins + +Plugin system for Shoggoth, built on [`hooks-plugin`](https://www.npmjs.com/package/hooks-plugin). Provides typed lifecycle hooks, plugin discovery, and a first-class `MessagingPlatformPlugin` interface for adding new messaging platforms. + +## Quick Start + +```ts +import { + ShoggothPluginSystem, + defineMessagingPlatformPlugin, +} from "@shoggoth/plugins"; + +const system = new ShoggothPluginSystem(); + +// Register a plugin +system.use({ + name: "my-plugin", + hooks: { + "daemon.startup": async (ctx) => { + console.log("Plugin started"); + }, + }, +}); + +// Fire hooks +await system.lifecycle["daemon.startup"].emit({ db, config, configRef, registerDrain }); +``` + +## `ShoggothPluginSystem` + +Extends `PluginSystem` from `hooks-plugin` with Shoggoth's 14 typed hooks pre-configured. + +```ts +const system = new ShoggothPluginSystem(); + +// Register plugins +system.use(myPlugin); + +// Fire hooks via system.lifecycle[""].emit(ctx) +// Lock after startup to prevent late registration +system.lock(); + +// Centralized error handling +system.listenError((event) => { + console.error(`Hook ${event.name} failed in plugin ${event.tag}:`, event.error); +}); +``` + +### Config Freeze + +After the `daemon.configure` waterfall, freeze the config to prevent mutation: + +```ts +import { freezeConfig } from "@shoggoth/plugins"; + +config = system.lifecycle["daemon.configure"].emit({ config }).config; +config = freezeConfig(config); +``` + +## `MessagingPlatformPlugin` + +Interface for messaging platform plugins. Requires four hooks: + +| Required Hook | Type | Purpose | +|---|---|---| +| `platform.register` | sync | Register URN policy and capabilities | +| `platform.start` | async | Connect to external service | +| `platform.stop` | async | Disconnect gracefully | +| `health.register` | sync | Register health probes | + +Use `defineMessagingPlatformPlugin` to validate at registration time: + +```ts +import { defineMessagingPlatformPlugin } from "@shoggoth/plugins"; + +export default function createMyPlatformPlugin() { + return defineMessagingPlatformPlugin({ + name: "platform-myservice", + version: "0.1.0", + hooks: { + "platform.register"(ctx) { + ctx.registerPlatform(myRegistration); + }, + async "platform.start"(ctx) { + // Connect to service, wire handlers + }, + async "platform.stop"(ctx) { + // Disconnect, cleanup + }, + "health.register"(ctx) { + ctx.registerProbe({ name: "myservice", check: async () => ({ status: "pass" }) }); + }, + }, + }); +} +``` + +## Hook Taxonomy + +All 14 hooks grouped by lifecycle phase: + +### Daemon Lifecycle + +| Hook | Type | Description | +|---|---|---| +| `daemon.configure` | `SyncWaterfallHook` | After config load. Plugins can inspect/transform config. Returns modified ctx. | +| `daemon.startup` | `AsyncHook` | After DB and core subsystems init. Plugins perform async setup. | +| `daemon.ready` | `AsyncHook` | After all plugins started and platforms connected. System is live. | +| `daemon.shutdown` | `AsyncHook` | Graceful shutdown. Plugins release resources. | + +### Platform Lifecycle + +| Hook | Type | Description | +|---|---|---| +| `platform.register` | `SyncHook` | Platforms register URN policy, capabilities, and runtime. | +| `platform.start` | `AsyncHook` | Platforms connect to external services (gateway, API, webhook). | +| `platform.stop` | `AsyncHook` | Platforms disconnect gracefully. | + +### Messaging + +| Hook | Type | Description | +|---|---|---| +| `message.inbound` | `AsyncHook` | A normalized inbound message is ready for dispatch. | +| `message.outbound` | `AsyncWaterfallHook` | Outbound message about to be delivered. Plugins can transform content. | +| `message.reaction` | `AsyncHook` | A reaction event is received from a platform. | + +### Session + +| Hook | Type | Description | +|---|---|---| +| `session.turn.before` | `AsyncHook` | Before a model turn executes. | +| `session.turn.after` | `AsyncHook` | After a model turn completes (success or failure). | +| `session.segment.change` | `SyncHook` | A session's context segment changes (new/reset). | + +### Health + +| Hook | Type | Description | +|---|---|---| +| `health.register` | `SyncHook` | Plugins register health probes during startup. | + +## Plugin Discovery + +Plugin metadata lives in `package.json` under a `shoggothPlugin` property bag (no separate manifest file): + +```json +{ + "name": "@shoggoth/platform-discord", + "version": "0.1.0", + "shoggothPlugin": { + "kind": "messaging-platform", + "entrypoint": "./src/plugin.ts" + } +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `kind` | `"messaging-platform" \| "observability" \| "general"` | No | Plugin kind. Defaults to `"general"`. `messaging-platform` plugins are validated against `MessagingPlatformPlugin`. | +| `entrypoint` | `string` | Yes | Path to the module that exports the plugin factory or plugin object. | + +`name` and `version` are read from the top-level `package.json` fields. + +The loader (`loadPluginFromDirectory`) reads `package.json`, extracts metadata via `resolvePluginMeta`, imports the entrypoint, calls the exported factory (if it's a function), and passes the resulting `Plugin` to `pluginSystem.use()`. + +## Example Plugin Structure + +``` +my-plugin/ +├── package.json +└── src/ + └── plugin.ts +``` + +**package.json:** +```json +{ + "name": "shoggoth-plugin-logger", + "version": "1.0.0", + "shoggothPlugin": { + "kind": "observability", + "entrypoint": "./src/plugin.ts" + } +} +``` + +**src/plugin.ts:** +```ts +import type { Plugin } from "hooks-plugin"; +import type { ShoggothHooks } from "@shoggoth/plugins"; + +export default function createLoggerPlugin(): Plugin { + return { + name: "logger", + hooks: { + "session.turn.before": async (ctx) => { + console.log(`[turn] session=${ctx.sessionId} content=${ctx.userContent}`); + }, + "session.turn.after": async (ctx) => { + console.log(`[turn] session=${ctx.sessionId} tokens=${ctx.tokenUsage?.completion}`); + }, + }, + }; +} +``` + +**Referencing in config:** +```json +{ + "plugins": [ + { "path": "./plugins/my-plugin" }, + { "package": "shoggoth-plugin-logger" } + ] +} +``` + +## Exports + +```ts +// Plugin system +ShoggothPluginSystem, createShoggothHooks, freezeConfig +type ShoggothHooks, ShoggothHookName + +// Platform plugin interface +defineMessagingPlatformPlugin, REQUIRED_MESSAGING_PLATFORM_HOOKS +type MessagingPlatformPlugin + +// Hook context types +type DaemonConfigureCtx, DaemonStartupCtx, DaemonReadyCtx, DaemonShutdownCtx +type PlatformRegisterCtx, PlatformDeps, PlatformStartCtx, PlatformStopCtx +type MessageInboundCtx, MessageOutboundCtx, MessageReactionCtx +type SessionTurnBeforeCtx, SessionTurnAfterCtx, SessionSegmentChangeCtx +type HealthRegisterCtx, HealthProbe, HealthProbeResult + +// Plugin discovery & loading +loadPluginFromDirectory, loadAllPluginsFromConfig +resolvePluginMeta, parseShoggothPluginBag, shoggothPluginBagSchema +type ShoggothPluginMeta, ShoggothPluginBag +type LoadedPluginMeta, LoadedPluginRef, PluginAuditEvent +``` diff --git a/packages/plugins/package.json b/packages/plugins/package.json index a1020aa..9f82c95 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -16,7 +16,9 @@ "test": "node --import tsx/esm --test --test-isolation=process --test-concurrency=true $(find test -name '*.test.ts' | sort)" }, "dependencies": { + "@shoggoth/messaging": "*", "@shoggoth/shared": "*", + "hooks-plugin": "^1.3.4", "zod": "^3.24.0" } } diff --git a/packages/plugins/src/hook-registry.ts b/packages/plugins/src/hook-registry.ts deleted file mode 100644 index 45a9de8..0000000 --- a/packages/plugins/src/hook-registry.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Declared extension points for v1 (see shoggoth.json hooks). */ -export type HookName = "daemon.startup" | "daemon.shutdown"; - -export type HookHandler = (ctx?: unknown) => void | Promise; - -export class HookRegistry { - private readonly handlers = new Map(); - - register(name: HookName, handler: HookHandler): void { - const list = this.handlers.get(name); - if (list) { - list.push(handler); - } else { - this.handlers.set(name, [handler]); - } - } - - async run(name: HookName, ctx?: unknown): Promise { - for (const h of this.handlers.get(name) ?? []) { - await h(ctx); - } - } - - /** Clear handlers for a hook (e.g. plugin unload). */ - clear(name: HookName): void { - this.handlers.delete(name); - } - - /** Remove every handler registered under a plugin id prefix (v1: per-hook clear only). */ - reset(): void { - this.handlers.clear(); - } -} diff --git a/packages/plugins/src/hook-types.ts b/packages/plugins/src/hook-types.ts new file mode 100644 index 0000000..2c877fe --- /dev/null +++ b/packages/plugins/src/hook-types.ts @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------------- +// Hook Context Types for the Shoggoth Plugin System +// ------------------------------------------------------------------------------- + +import type { + ShoggothConfig, + Logger, + HitlPendingStack, + HitlAutoApproveGate, + HitlConfigRef, + PolicyEngine, + SubagentRuntimeExtension, + MessageToolContext, + PlatformAdapter, +} from "@shoggoth/shared"; +import type { PlatformRegistration, PlatformRuntime, InternalMessage } from "@shoggoth/messaging"; +import type { PlatformDeliveryRegistry } from "./platform-delivery-registry"; + +// ------------------------------------------------------------------------------- +// Daemon Lifecycle +// ------------------------------------------------------------------------------- + +/** Waterfall: plugins can return a modified config. */ +export interface DaemonConfigureCtx { + readonly config: ShoggothConfig; + [key: string]: unknown; +} + +export interface DaemonStartupCtx { + readonly db: unknown; + readonly config: Readonly; + readonly configRef: { readonly current: ShoggothConfig }; + readonly registerDrain: (name: string, fn: () => void | Promise) => void; +} + +export interface DaemonReadyCtx { + readonly config: Readonly; + readonly platforms: ReadonlyMap; +} + +export interface DaemonShutdownCtx { + readonly reason: string; +} + +// ------------------------------------------------------------------------------- +// Platform Lifecycle +// ------------------------------------------------------------------------------- + +export interface PlatformRegisterCtx { + readonly config: Readonly; + readonly registerPlatform: (reg: PlatformRegistration) => void; + readonly setPlatformRuntime: (platformId: string, runtime: PlatformRuntime) => void; +} + +/** + * Dependencies that the daemon creates and passes to platform plugins. + * Platform-agnostic — no Discord/Telegram/etc. specifics leak here. + */ +export interface PlatformDeps { + readonly hitlStack?: HitlPendingStack; + readonly policyEngine: PolicyEngine; + readonly hitlConfigRef: HitlConfigRef; + readonly hitlAutoApproveGate?: HitlAutoApproveGate; + readonly logger: Logger; + /** Default platform assistant dependencies (opaque to the plugin system). */ + readonly platformAssistantDeps: unknown; + readonly abortSession: (sessionId: string) => Promise; + readonly invokeControlOp: (op: string, payload: unknown) => Promise<{ ok: boolean; result?: unknown; error?: string }>; + readonly registerPlatform: (platformId: string, handle: unknown) => void; + readonly stopAllPlatforms: () => Promise; + readonly reconcilePersistentSubagents: (input: { + readonly db: unknown; + readonly config: ShoggothConfig; + readonly ext: unknown; + }) => { restored: number; expiredKilled: number }; + readonly noticeResolver: (key: string, params?: Record) => string; +} + +export interface PlatformStartCtx { + readonly db: unknown; + readonly config: Readonly; + readonly configRef: { readonly current: ShoggothConfig }; + readonly env: NodeJS.ProcessEnv; + readonly deps: PlatformDeps; + readonly deliveryRegistry: PlatformDeliveryRegistry; + readonly registerDrain: (name: string, fn: () => void | Promise) => void; + readonly setSubagentRuntimeExtension: (ext: SubagentRuntimeExtension | undefined) => void; + readonly setMessageToolContext: (ctx: MessageToolContext) => void; + readonly setPlatformAdapter: (adapter: PlatformAdapter) => void; +} + +export interface PlatformStopCtx { + readonly platformId: string; +} + +// ------------------------------------------------------------------------------- +// Messaging +// ------------------------------------------------------------------------------- + +export interface MessageInboundCtx { + readonly message: InternalMessage; + readonly sessionId: string; + readonly platformId: string; +} + +export interface MessageOutboundCtx { + body: string; + readonly sessionId: string; + readonly platformId: string; + readonly replyToMessageId?: string; + [key: string]: unknown; +} + +export interface MessageReactionCtx { + readonly sessionId: string; + readonly platformId: string; + readonly emoji: string; + readonly userId: string; + readonly messageId: string; + readonly channelId: string; +} + +// ------------------------------------------------------------------------------- +// Session +// ------------------------------------------------------------------------------- + +export interface SessionTurnBeforeCtx { + readonly sessionId: string; + readonly userContent: string; + readonly platformId?: string; +} + +export interface SessionTurnAfterCtx { + readonly sessionId: string; + readonly assistantText?: string; + readonly error?: Error; + readonly platformId?: string; + readonly tokenUsage?: { prompt: number; completion: number }; +} + +export interface SessionSegmentChangeCtx { + readonly sessionId: string; + readonly mode: "new" | "reset"; + readonly newSegmentId: string; +} + +// ------------------------------------------------------------------------------- +// Health +// ------------------------------------------------------------------------------- + +export interface HealthRegisterCtx { + readonly registerProbe: (probe: HealthProbe) => void; +} + +export interface HealthProbe { + readonly name: string; + check(): Promise; +} + +export interface HealthProbeResult { + readonly status: "pass" | "fail" | "skipped"; + readonly detail?: string; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 9250ca6..b166ddd 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -6,7 +6,49 @@ export { type PluginAuditEvent, type PluginAuditOutcome, } from "./load-plugins-from-config"; -export { HookRegistry, type HookHandler, type HookName } from "./hook-registry"; export { loadPluginFromDirectory, type LoadedPluginMeta } from "./plugin-loader"; -export { parseShoggothPluginManifest, shoggothPluginManifestSchema } from "./shoggoth-manifest"; -export type { ShoggothPluginManifest } from "./shoggoth-manifest"; +export { + parseShoggothPluginBag, + resolvePluginMeta, + shoggothPluginBagSchema, + type ShoggothPluginBag, + type ShoggothPluginMeta, +} from "./shoggoth-manifest"; + +// hooks-plugin based plugin system +export { + createShoggothHooks, + ShoggothPluginSystem, + freezeConfig, + type ShoggothHooks, + type ShoggothHookName, +} from "./plugin-system"; +export { + defineMessagingPlatformPlugin, + REQUIRED_MESSAGING_PLATFORM_HOOKS, + type MessagingPlatformPlugin, +} from "./messaging-platform-plugin"; +export { + PlatformDeliveryRegistry, + type PlatformDeliveryResolver, + type OperatorDelivery, +} from "./platform-delivery-registry"; +export type { + DaemonConfigureCtx, + DaemonStartupCtx, + DaemonReadyCtx, + DaemonShutdownCtx, + PlatformRegisterCtx, + PlatformDeps, + PlatformStartCtx, + PlatformStopCtx, + MessageInboundCtx, + MessageOutboundCtx, + MessageReactionCtx, + SessionTurnBeforeCtx, + SessionTurnAfterCtx, + SessionSegmentChangeCtx, + HealthRegisterCtx, + HealthProbe, + HealthProbeResult, +} from "./hook-types"; diff --git a/packages/plugins/src/load-plugins-from-config.ts b/packages/plugins/src/load-plugins-from-config.ts index 68eda8f..77d8dad 100644 --- a/packages/plugins/src/load-plugins-from-config.ts +++ b/packages/plugins/src/load-plugins-from-config.ts @@ -1,7 +1,7 @@ import { createRequire } from "node:module"; import { dirname, isAbsolute, resolve } from "node:path"; import type { ShoggothConfig } from "@shoggoth/shared"; -import type { HookRegistry } from "./hook-registry"; +import type { ShoggothPluginSystem } from "./plugin-system"; import { loadPluginFromDirectory } from "./plugin-loader"; export type PluginAuditOutcome = "success" | "failure"; @@ -32,7 +32,7 @@ export function resolveNpmPluginRoot(packageName: string, resolveFromFile: strin /** Loads plugins from config; returns successfully loaded entries (for shutdown unload audit). */ export async function loadAllPluginsFromConfig(options: { readonly config: Pick; - readonly registry: HookRegistry; + readonly system: ShoggothPluginSystem; /** Existing file path passed to `createRequire` for npm resolution. */ readonly resolveFromFile: string; readonly audit?: (e: PluginAuditEvent) => void; @@ -45,7 +45,7 @@ export async function loadAllPluginsFromConfig(options: { entry.path !== undefined ? resolveLocalPluginPath(entry.path, options.config.configDirectory) : resolveNpmPluginRoot(entry.package!, options.resolveFromFile); - const meta = await loadPluginFromDirectory(root, options.registry); + const meta = await loadPluginFromDirectory(root, options.system); loaded.push({ resource: label, manifestName: meta.name }); options.audit?.({ action: "plugin.load", resource: label, outcome: "success" }); } catch (e) { diff --git a/packages/plugins/src/messaging-platform-plugin.ts b/packages/plugins/src/messaging-platform-plugin.ts new file mode 100644 index 0000000..6f31025 --- /dev/null +++ b/packages/plugins/src/messaging-platform-plugin.ts @@ -0,0 +1,51 @@ +// --------------------------------------------------------------------------- +// MessagingPlatformPlugin — interface + validation helper +// --------------------------------------------------------------------------- + +import type { Plugin } from "hooks-plugin"; +import type { ShoggothHooks } from "./plugin-system"; +import type { + PlatformRegisterCtx, + PlatformStartCtx, + PlatformStopCtx, + HealthRegisterCtx, +} from "./hook-types"; + +/** + * The minimum set of hooks a messaging platform plugin must provide. + * Platform plugins may also implement optional hooks (message.*, session.*, etc.). + */ +export interface MessagingPlatformPlugin extends Plugin { + readonly name: string; + readonly version?: string; + hooks: { + "platform.register": (ctx: PlatformRegisterCtx) => void; + "platform.start": (ctx: PlatformStartCtx) => Promise; + "platform.stop": (ctx: PlatformStopCtx) => Promise; + "health.register": (ctx: HealthRegisterCtx) => void; + }; +} + +export const REQUIRED_MESSAGING_PLATFORM_HOOKS = [ + "platform.register", + "platform.start", + "platform.stop", + "health.register", +] as const; + +/** + * Validates and returns a typed MessagingPlatformPlugin. + * Throws if any required hook is missing. + */ +export function defineMessagingPlatformPlugin( + plugin: MessagingPlatformPlugin, +): MessagingPlatformPlugin { + for (const hook of REQUIRED_MESSAGING_PLATFORM_HOOKS) { + if (typeof plugin.hooks[hook] !== "function") { + throw new Error( + `MessagingPlatformPlugin "${plugin.name}" is missing required hook "${hook}"`, + ); + } + } + return plugin; +} diff --git a/packages/plugins/src/platform-delivery-registry.ts b/packages/plugins/src/platform-delivery-registry.ts new file mode 100644 index 0000000..70dcbe6 --- /dev/null +++ b/packages/plugins/src/platform-delivery-registry.ts @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------------- +// Platform Delivery Registry — platform-agnostic operator delivery resolution +// +// Plugins register a resolver for their platform segment (e.g. "discord"). +// The daemon uses this to determine how to reach the operator on a given session's +// platform without knowing platform-specific details. +// ------------------------------------------------------------------------------- + +/** + * Delivery metadata returned by a platform plugin. + * Opaque to the daemon — it just passes this through to the delivery layer. + */ +export type OperatorDelivery = + | { readonly kind: "messaging_surface"; readonly userId: string } + | { readonly kind: "internal" }; + +/** + * A platform plugin implements this to tell the daemon how to reach the operator + * on sessions it owns. + */ +export interface PlatformDeliveryResolver { + /** + * Given a session URN owned by this platform, return delivery metadata + * for reaching the operator. Return undefined if no operator delivery is configured. + */ + resolveOperatorDelivery(sessionId: string, config: any): OperatorDelivery | undefined; + + /** + * Given platform-specific inbound identifiers, resolve to a session ID. + * The shape of `identifiers` is platform-specific and opaque to the daemon. + */ + resolveSessionForInbound?(identifiers: Record, config: any): string | undefined; +} + +/** + * Registry of platform delivery resolvers, keyed by platform segment. + * The daemon holds one instance; plugins register during platform.start. + */ +export class PlatformDeliveryRegistry { + private readonly resolvers = new Map(); + + register(platformSegment: string, resolver: PlatformDeliveryResolver): void { + this.resolvers.set(platformSegment, resolver); + } + + /** + * Resolve operator delivery for a session URN. + * Extracts the platform segment from the URN and delegates to the registered resolver. + */ + resolveOperatorDelivery(sessionId: string, config: any): OperatorDelivery | undefined { + const segment = extractPlatformSegment(sessionId); + if (!segment) return undefined; + const resolver = this.resolvers.get(segment); + return resolver?.resolveOperatorDelivery(sessionId, config); + } + + resolveSessionForInbound(platformSegment: string, identifiers: Record, config: any): string | undefined { + const resolver = this.resolvers.get(platformSegment); + return resolver?.resolveSessionForInbound?.(identifiers, config); + } +} + +/** + * Extract the platform segment from a session URN. + * URN pattern: `::::` + * e.g. `agent:main:discord:channel:123456` + */ +function extractPlatformSegment(sessionId: string): string | undefined { + const parts = sessionId.split(":"); + // agent:main:discord:channel:123 → parts[2] = "discord" + return parts.length >= 3 ? parts[2] : undefined; +} diff --git a/packages/plugins/src/plugin-loader.ts b/packages/plugins/src/plugin-loader.ts index 2c687cb..f4aa710 100644 --- a/packages/plugins/src/plugin-loader.ts +++ b/packages/plugins/src/plugin-loader.ts @@ -1,34 +1,61 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import type { HookHandler, HookName, HookRegistry } from "./hook-registry"; -import { parseShoggothPluginManifest } from "./shoggoth-manifest"; +import type { ShoggothPluginSystem } from "./plugin-system"; +import { resolvePluginMeta } from "./shoggoth-manifest"; +import { defineMessagingPlatformPlugin } from "./messaging-platform-plugin"; export interface LoadedPluginMeta { readonly name: string; readonly version: string; readonly rootDir: string; + readonly kind: string; } +/** + * Load a plugin from a directory. + * Expects a package.json with a `shoggothPlugin` property bag + factory entrypoint. + */ export async function loadPluginFromDirectory( rootDir: string, - registry: HookRegistry, + system: ShoggothPluginSystem, ): Promise { - const manifestPath = join(rootDir, "shoggoth.json"); - const raw = readFileSync(manifestPath, "utf8"); - const manifest = parseShoggothPluginManifest(JSON.parse(raw) as unknown); - if (manifest.hooks) { - for (const [hook, relPath] of Object.entries(manifest.hooks)) { - const url = pathToFileURL(join(rootDir, relPath)).href; - const mod = (await import(url)) as { default?: unknown }; - const fn = mod.default; - if (typeof fn !== "function") { - throw new Error( - `Plugin "${manifest.name}" hook "${hook}" must default-export a function`, - ); - } - registry.register(hook as HookName, fn as HookHandler); - } + const pkgPath = join(rootDir, "package.json"); + + if (!existsSync(pkgPath)) { + throw new Error(`Plugin at "${rootDir}" has no package.json`); + } + + const raw = readFileSync(pkgPath, "utf8"); + const packageJson = JSON.parse(raw) as Record; + + if (!packageJson.shoggothPlugin) { + throw new Error( + `Plugin at "${rootDir}" package.json is missing the "shoggothPlugin" property`, + ); } - return { name: manifest.name, version: manifest.version, rootDir }; + + const meta = resolvePluginMeta(packageJson); + const entrypointUrl = pathToFileURL(join(rootDir, meta.entrypoint)).href; + const mod = (await import(entrypointUrl)) as { default?: unknown }; + let plugin = mod.default; + + // If the default export is a factory function, call it + if (typeof plugin === "function") { + plugin = await plugin(); + } + + // For messaging-platform kind, validate required hooks + if (meta.kind === "messaging-platform") { + plugin = defineMessagingPlatformPlugin(plugin as any); + } + + system.use(plugin as any); + + return { + name: meta.name, + version: meta.version, + rootDir, + kind: meta.kind, + }; } diff --git a/packages/plugins/src/plugin-system.ts b/packages/plugins/src/plugin-system.ts new file mode 100644 index 0000000..ec005ff --- /dev/null +++ b/packages/plugins/src/plugin-system.ts @@ -0,0 +1,82 @@ +// --------------------------------------------------------------------------- +// ShoggothPluginSystem — wraps hooks-plugin's PluginSystem with typed hooks +// --------------------------------------------------------------------------- + +import { + PluginSystem, + SyncHook, + AsyncHook, + SyncWaterfallHook, + AsyncParallelHook, + AsyncWaterfallHook, +} from "hooks-plugin"; + +import type { + DaemonConfigureCtx, + DaemonStartupCtx, + DaemonReadyCtx, + DaemonShutdownCtx, + PlatformRegisterCtx, + PlatformStartCtx, + PlatformStopCtx, + MessageInboundCtx, + MessageOutboundCtx, + MessageReactionCtx, + SessionTurnBeforeCtx, + SessionTurnAfterCtx, + SessionSegmentChangeCtx, + HealthRegisterCtx, +} from "./hook-types"; + +export function createShoggothHooks() { + return { + // Daemon lifecycle + "daemon.configure": new SyncWaterfallHook(), + "daemon.startup": new AsyncHook<[DaemonStartupCtx]>(), + "daemon.ready": new AsyncHook<[DaemonReadyCtx]>(), + "daemon.shutdown": new AsyncHook<[DaemonShutdownCtx]>(), + + // Platform lifecycle + "platform.register": new SyncHook<[PlatformRegisterCtx]>(), + "platform.start": new AsyncHook<[PlatformStartCtx]>(), + "platform.stop": new AsyncHook<[PlatformStopCtx]>(), + + // Messaging + "message.inbound": new AsyncHook<[MessageInboundCtx]>(), + "message.outbound": new AsyncWaterfallHook(), + "message.reaction": new AsyncHook<[MessageReactionCtx]>(), + + // Session + "session.turn.before": new AsyncHook<[SessionTurnBeforeCtx]>(), + "session.turn.after": new AsyncHook<[SessionTurnAfterCtx]>(), + "session.segment.change": new SyncHook<[SessionSegmentChangeCtx]>(), + + // Health + "health.register": new SyncHook<[HealthRegisterCtx]>(), + }; +} + +export type ShoggothHooks = ReturnType; +export type ShoggothHookName = keyof ShoggothHooks; + +export class ShoggothPluginSystem extends PluginSystem { + constructor() { + super(createShoggothHooks()); + } +} + +/** + * Recursively deep-freezes an object using Object.freeze. + * Returns the same reference, now frozen at every level. + */ +export function freezeConfig(obj: T): T { + Object.freeze(obj); + if (obj !== null && typeof obj === "object") { + for (const value of Object.values(obj as Record)) { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + freezeConfig(value); + } + } + } + return obj; +} diff --git a/packages/plugins/src/shoggoth-manifest.ts b/packages/plugins/src/shoggoth-manifest.ts index db3297c..91f310d 100644 --- a/packages/plugins/src/shoggoth-manifest.ts +++ b/packages/plugins/src/shoggoth-manifest.ts @@ -1,17 +1,43 @@ import { z } from "zod"; -const hookNameSchema = z.enum(["daemon.startup", "daemon.shutdown"]); +const pluginKindSchema = z.enum([ + "messaging-platform", + "observability", + "general", +]); -export const shoggothPluginManifestSchema = z +/** Validates the `shoggothPlugin` property bag from package.json. */ +export const shoggothPluginBagSchema = z .object({ - name: z.string().min(1), - version: z.string().min(1), - hooks: z.record(hookNameSchema, z.string().min(1)).optional(), + kind: pluginKindSchema.optional().default("general"), + entrypoint: z.string().min(1), }) .strict(); -export type ShoggothPluginManifest = z.infer; +export type ShoggothPluginBag = z.infer; -export function parseShoggothPluginManifest(data: unknown): ShoggothPluginManifest { - return shoggothPluginManifestSchema.parse(data); +/** Resolved plugin metadata (combined from package.json top-level + shoggothPlugin). */ +export interface ShoggothPluginMeta { + readonly name: string; + readonly version: string; + readonly kind: string; + readonly entrypoint: string; +} + +export function parseShoggothPluginBag(data: unknown): ShoggothPluginBag { + return shoggothPluginBagSchema.parse(data); +} + +/** + * Read a plugin's package.json and extract metadata. + * Throws if `shoggothPlugin` is missing or invalid. + */ +export function resolvePluginMeta(packageJson: Record): ShoggothPluginMeta { + const bag = parseShoggothPluginBag(packageJson.shoggothPlugin); + return { + name: z.string().min(1).parse(packageJson.name), + version: z.string().min(1).parse(packageJson.version), + kind: bag.kind, + entrypoint: bag.entrypoint, + }; } diff --git a/packages/plugins/test/hook-registry.test.ts b/packages/plugins/test/hook-registry.test.ts deleted file mode 100644 index eeb4242..0000000 --- a/packages/plugins/test/hook-registry.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import assert from "node:assert"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, test } from "vitest"; -import { HookRegistry } from "../src/hook-registry"; -import { loadPluginFromDirectory } from "../src/plugin-loader"; - -describe("HookRegistry", () => { - test("runs registered handlers in registration order", async () => { - const r = new HookRegistry(); - const out: number[] = []; - r.register("daemon.startup", async () => { - out.push(1); - }); - r.register("daemon.startup", async () => { - out.push(2); - }); - await r.run("daemon.startup"); - assert.deepStrictEqual(out, [1, 2]); - }); -}); - -describe("loadPluginFromDirectory", () => { - test("loads default export hook from manifest path", async () => { - const root = mkdtempSync(join(tmpdir(), "sh-plug-")); - writeFileSync( - join(root, "shoggoth.json"), - JSON.stringify({ - name: "t", - version: "1.0.0", - hooks: { "daemon.startup": "./hook.mjs" }, - }), - ); - writeFileSync( - join(root, "hook.mjs"), - `let n = 0; -export default async function() { n += 1; globalThis.__shoggothHookN = n; }; -`, - ); - const reg = new HookRegistry(); - await loadPluginFromDirectory(root, reg); - await reg.run("daemon.startup"); - assert.strictEqual((globalThis as { __shoggothHookN?: number }).__shoggothHookN, 1); - delete (globalThis as { __shoggothHookN?: number }).__shoggothHookN; - }); -}); diff --git a/packages/plugins/test/load-plugins-from-config.test.ts b/packages/plugins/test/load-plugins-from-config.test.ts index 957610d..ffccb7c 100644 --- a/packages/plugins/test/load-plugins-from-config.test.ts +++ b/packages/plugins/test/load-plugins-from-config.test.ts @@ -1,40 +1,54 @@ -import assert from "node:assert"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, test } from "vitest"; +import { describe, test, expect } from "vitest"; import { fileURLToPath } from "node:url"; import type { ShoggothConfig } from "@shoggoth/shared"; -import { HookRegistry } from "../src/hook-registry"; +import { ShoggothPluginSystem } from "../src/plugin-system"; import { loadAllPluginsFromConfig, resolveLocalPluginPath } from "../src/load-plugins-from-config"; describe("resolveLocalPluginPath", () => { test("returns absolute paths unchanged", () => { - assert.strictEqual(resolveLocalPluginPath("/abs/here", "/cfg"), "/abs/here"); + expect(resolveLocalPluginPath("/abs/here", "/cfg")).toBe("/abs/here"); }); test("resolves relative to config directory", () => { const r = resolveLocalPluginPath("plugins/x", "/etc/shoggoth"); - assert.ok(r.includes("plugins")); - assert.ok(r.endsWith(join("plugins", "x"))); + expect(r).toContain("plugins"); + expect(r).toMatch(/plugins\/x$/); }); }); describe("loadAllPluginsFromConfig", () => { - test("audits failure for broken manifest and still loads a second plugin", async () => { + test("audits failure for broken package.json and still loads a second plugin", async () => { + // Bad plugin: invalid package.json const bad = mkdtempSync(join(tmpdir(), "sh-bad-plug-")); - writeFileSync(join(bad, "shoggoth.json"), "{ not json"); + writeFileSync(join(bad, "package.json"), "{ not json"); + // Good plugin: valid package.json with shoggothPlugin bag + entrypoint const good = mkdtempSync(join(tmpdir(), "sh-good-plug-")); writeFileSync( - join(good, "shoggoth.json"), + join(good, "package.json"), JSON.stringify({ name: "goodp", version: "1.0.0", - hooks: { "daemon.startup": "./h.mjs" }, + shoggothPlugin: { + entrypoint: "./plugin.mjs", + }, }), ); - writeFileSync(join(good, "h.mjs"), `export default async () => { globalThis.__goodPlug = 7; };`); + writeFileSync( + join(good, "plugin.mjs"), + `export default function() { + return { + name: "goodp", + hooks: { + "daemon.shutdown": async (ctx) => { globalThis.__goodPlug = 7; }, + }, + }; +}; +`, + ); const cfgDir = mkdtempSync(join(tmpdir(), "sh-cfg-")); const config = { @@ -43,23 +57,24 @@ describe("loadAllPluginsFromConfig", () => { } as Pick; const audits: { outcome: string; resource: string }[] = []; - const reg = new HookRegistry(); + const system = new ShoggothPluginSystem(); const loaded = await loadAllPluginsFromConfig({ config, - registry: reg, + system, resolveFromFile: fileURLToPath(import.meta.url), audit: (e) => audits.push({ outcome: e.outcome, resource: e.resource }), }); - assert.deepStrictEqual(loaded, [{ resource: "b", manifestName: "goodp" }]); - assert.strictEqual(audits.length, 2); - assert.strictEqual(audits[0]!.outcome, "failure"); - assert.strictEqual(audits[0]!.resource, "a"); - assert.strictEqual(audits[1]!.outcome, "success"); - assert.strictEqual(audits[1]!.resource, "b"); + expect(loaded).toEqual([{ resource: "b", manifestName: "goodp" }]); + expect(audits).toHaveLength(2); + expect(audits[0]!.outcome).toBe("failure"); + expect(audits[0]!.resource).toBe("a"); + expect(audits[1]!.outcome).toBe("success"); + expect(audits[1]!.resource).toBe("b"); - await reg.run("daemon.startup"); - assert.strictEqual((globalThis as { __goodPlug?: number }).__goodPlug, 7); + // Fire hook through the plugin system to verify the good plugin was registered + await system.lifecycle["daemon.shutdown"].emit({ reason: "test" }); + expect((globalThis as { __goodPlug?: number }).__goodPlug).toBe(7); delete (globalThis as { __goodPlug?: number }).__goodPlug; }); }); diff --git a/packages/plugins/test/plugin-system-integration.test.ts b/packages/plugins/test/plugin-system-integration.test.ts new file mode 100644 index 0000000..f222ac5 --- /dev/null +++ b/packages/plugins/test/plugin-system-integration.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, test, expect } from "vitest"; +import { ShoggothPluginSystem } from "../src/plugin-system"; +import { loadPluginFromDirectory } from "../src/plugin-loader"; + +describe("loadPluginFromDirectory", () => { + test("loads a plugin from package.json with shoggothPlugin bag and fires hook", async () => { + const root = mkdtempSync(join(tmpdir(), "sh-plug-new-")); + + // Write package.json with shoggothPlugin property bag + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "test-plugin", + version: "1.0.0", + shoggothPlugin: { + kind: "general", + entrypoint: "./plugin.mjs", + }, + }), + ); + + // Write entrypoint that exports a factory returning a plugin object with hooks + writeFileSync( + join(root, "plugin.mjs"), + `export default function createPlugin() { + return { + name: "test-plugin", + hooks: { + "daemon.shutdown": async (ctx) => { + globalThis.__testPluginShutdownReason = ctx.reason; + }, + }, + }; +}; +`, + ); + + const system = new ShoggothPluginSystem(); + const meta = await loadPluginFromDirectory(root, system); + + expect(meta.name).toBe("test-plugin"); + expect(meta.version).toBe("1.0.0"); + expect(meta.kind).toBe("general"); + expect(meta.rootDir).toBe(root); + + // Fire the hook through the plugin system and verify it ran + await system.lifecycle["daemon.shutdown"].emit({ reason: "test-shutdown" }); + expect( + (globalThis as { __testPluginShutdownReason?: string }).__testPluginShutdownReason, + ).toBe("test-shutdown"); + delete (globalThis as { __testPluginShutdownReason?: string }).__testPluginShutdownReason; + }); + + test("loads a plugin that directly exports a plugin object (not a factory)", async () => { + const root = mkdtempSync(join(tmpdir(), "sh-plug-direct-")); + + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "direct-plugin", + version: "2.0.0", + shoggothPlugin: { + entrypoint: "./plugin.mjs", + }, + }), + ); + + writeFileSync( + join(root, "plugin.mjs"), + `const plugin = { + name: "direct-plugin", + hooks: { + "daemon.shutdown": async (ctx) => { + globalThis.__directPluginFired = true; + }, + }, +}; +export default plugin; +`, + ); + + const system = new ShoggothPluginSystem(); + const meta = await loadPluginFromDirectory(root, system); + + expect(meta.name).toBe("direct-plugin"); + expect(meta.version).toBe("2.0.0"); + expect(meta.kind).toBe("general"); // default kind + + await system.lifecycle["daemon.shutdown"].emit({ reason: "bye" }); + expect((globalThis as { __directPluginFired?: boolean }).__directPluginFired).toBe(true); + delete (globalThis as { __directPluginFired?: boolean }).__directPluginFired; + }); + + test("throws when package.json is missing shoggothPlugin bag", async () => { + const root = mkdtempSync(join(tmpdir(), "sh-plug-nobag-")); + + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "bad-plugin", + version: "1.0.0", + }), + ); + + const system = new ShoggothPluginSystem(); + await expect(loadPluginFromDirectory(root, system)).rejects.toThrow(); + }); + + test("meta includes kind from shoggothPlugin bag", async () => { + const root = mkdtempSync(join(tmpdir(), "sh-plug-kind-")); + + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "obs-plugin", + version: "0.5.0", + shoggothPlugin: { + kind: "observability", + entrypoint: "./plugin.mjs", + }, + }), + ); + + writeFileSync( + join(root, "plugin.mjs"), + `export default function() { + return { name: "obs-plugin", hooks: {} }; +}; +`, + ); + + const system = new ShoggothPluginSystem(); + const meta = await loadPluginFromDirectory(root, system); + + expect(meta.kind).toBe("observability"); + }); +}); diff --git a/packages/plugins/test/plugin-system.test.ts b/packages/plugins/test/plugin-system.test.ts new file mode 100644 index 0000000..99afee2 --- /dev/null +++ b/packages/plugins/test/plugin-system.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert"; +import { describe, test } from "vitest"; + +// These imports target source files that DO NOT EXIST yet — tests must fail. +import { + ShoggothPluginSystem, + freezeConfig, +} from "../src/plugin-system"; +import { defineMessagingPlatformPlugin } from "../src/messaging-platform-plugin"; +import type { + DaemonConfigureCtx, + DaemonStartupCtx, + PlatformRegisterCtx, + PlatformStartCtx, + PlatformStopCtx, + HealthRegisterCtx, +} from "../src/hook-types"; + +// --------------------------------------------------------------------------- +// 1. ShoggothPluginSystem instantiation — all 14 hooks present +// --------------------------------------------------------------------------- +describe("ShoggothPluginSystem", () => { + const ALL_HOOK_NAMES = [ + "daemon.configure", + "daemon.startup", + "daemon.ready", + "daemon.shutdown", + "platform.register", + "platform.start", + "platform.stop", + "message.inbound", + "message.outbound", + "message.reaction", + "session.turn.before", + "session.turn.after", + "session.segment.change", + "health.register", + ] as const; + + test("can be instantiated and exposes all 14 hooks via lifecycle", () => { + const system = new ShoggothPluginSystem(); + assert.ok(system, "system should be truthy"); + assert.ok(system.lifecycle, "system.lifecycle should be truthy"); + + for (const name of ALL_HOOK_NAMES) { + assert.ok( + system.lifecycle[name], + `hook "${name}" should exist on lifecycle`, + ); + } + // Exactly 14 hooks — no more, no less + const hookKeys = Object.keys(system.lifecycle); + assert.strictEqual(hookKeys.length, 14, "should have exactly 14 hooks"); + }); + + // ------------------------------------------------------------------------- + // 2. daemon.startup async hook — register plugin, fire, verify typed ctx + // ------------------------------------------------------------------------- + test("plugin can register and fire daemon.startup async hook with typed context", async () => { + const system = new ShoggothPluginSystem(); + const received: DaemonStartupCtx[] = []; + + system.use({ + name: "test-startup-plugin", + hooks: { + "daemon.startup": async (ctx: DaemonStartupCtx) => { + received.push(ctx); + }, + }, + }); + + const fakeCtx = { + db: {} as any, + config: { foo: "bar" } as any, + configRef: { current: { foo: "bar" } as any }, + registerDrain: () => {}, + } satisfies DaemonStartupCtx; + + await system.lifecycle["daemon.startup"].emit(fakeCtx); + + assert.strictEqual(received.length, 1, "handler should have been called once"); + assert.strictEqual(received[0].config, fakeCtx.config); + }); + + // ------------------------------------------------------------------------- + // 3. daemon.configure waterfall — passes through and transforms config + // ------------------------------------------------------------------------- + test("daemon.configure waterfall passes through and transforms config", () => { + const system = new ShoggothPluginSystem(); + + system.use({ + name: "config-transform-plugin", + hooks: { + "daemon.configure": (ctx: DaemonConfigureCtx) => { + // Waterfall: return a modified context + return { + ...ctx, + config: { ...ctx.config, injected: true } as any, + }; + }, + }, + }); + + const initial: DaemonConfigureCtx = { + config: { original: true } as any, + }; + + const result = system.lifecycle["daemon.configure"].emit(initial); + assert.ok(result, "waterfall should return a result"); + assert.strictEqual( + (result as any).config.original, + true, + "original config key should be preserved", + ); + assert.strictEqual( + (result as any).config.injected, + true, + "plugin should have injected a key", + ); + }); + + // ------------------------------------------------------------------------- + // 7. SyncHook (platform.register) fires synchronously with typed args + // ------------------------------------------------------------------------- + test("platform.register SyncHook fires synchronously with typed args", () => { + const system = new ShoggothPluginSystem(); + const registrations: string[] = []; + + system.use({ + name: "sync-register-plugin", + hooks: { + "platform.register": (ctx: PlatformRegisterCtx) => { + registrations.push("called"); + // Verify typed ctx shape + assert.ok(typeof ctx.registerPlatform === "function"); + assert.ok(typeof ctx.setPlatformRuntime === "function"); + }, + }, + }); + + system.lifecycle["platform.register"].emit({ + config: {} as any, + registerPlatform: () => {}, + setPlatformRuntime: () => {}, + } satisfies PlatformRegisterCtx); + + // Synchronous — result available immediately, no await + assert.strictEqual(registrations.length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// 4. freezeConfig deep-freezes an object +// --------------------------------------------------------------------------- +describe("freezeConfig", () => { + test("deep-freezes an object so mutation throws in strict mode", () => { + const config = { + a: 1, + nested: { b: 2, deep: { c: 3 } }, + arr: [1, 2, 3], + }; + + const frozen = freezeConfig(config); + + // Top-level frozen + assert.ok(Object.isFrozen(frozen), "top-level should be frozen"); + // Nested frozen + assert.ok(Object.isFrozen(frozen.nested), "nested object should be frozen"); + assert.ok( + Object.isFrozen(frozen.nested.deep), + "deeply nested object should be frozen", + ); + assert.ok(Object.isFrozen(frozen.arr), "array should be frozen"); + + // Mutation should throw in strict mode (ESM is always strict) + assert.throws(() => { + (frozen as any).a = 999; + }, TypeError); + + assert.throws(() => { + (frozen.nested as any).b = 999; + }, TypeError); + + assert.throws(() => { + (frozen.nested.deep as any).c = 999; + }, TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// 5. defineMessagingPlatformPlugin — accepts valid plugin +// --------------------------------------------------------------------------- +describe("defineMessagingPlatformPlugin", () => { + test("accepts a valid plugin with all 4 required hooks", () => { + const plugin = defineMessagingPlatformPlugin({ + name: "test-platform", + hooks: { + "platform.register": (_ctx: PlatformRegisterCtx) => {}, + "platform.start": async (_ctx: PlatformStartCtx) => {}, + "platform.stop": async (_ctx: PlatformStopCtx) => {}, + "health.register": (_ctx: HealthRegisterCtx) => {}, + }, + }); + + assert.ok(plugin, "should return the plugin object"); + assert.strictEqual(plugin.name, "test-platform"); + assert.strictEqual(typeof plugin.hooks["platform.register"], "function"); + assert.strictEqual(typeof plugin.hooks["platform.start"], "function"); + assert.strictEqual(typeof plugin.hooks["platform.stop"], "function"); + assert.strictEqual(typeof plugin.hooks["health.register"], "function"); + }); + + // ------------------------------------------------------------------------- + // 6. defineMessagingPlatformPlugin — throws when required hook missing + // ------------------------------------------------------------------------- + test("throws when a required hook is missing", () => { + assert.throws( + () => { + defineMessagingPlatformPlugin({ + name: "incomplete-platform", + hooks: { + "platform.register": (_ctx: PlatformRegisterCtx) => {}, + "platform.start": async (_ctx: PlatformStartCtx) => {}, + // Missing: platform.stop + // Missing: health.register + }, + } as any); + }, + (err: Error) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes("missing required hook"), + `error message should mention missing hook, got: ${err.message}`, + ); + return true; + }, + ); + + // Also test missing just one hook + assert.throws( + () => { + defineMessagingPlatformPlugin({ + name: "almost-complete-platform", + hooks: { + "platform.register": (_ctx: PlatformRegisterCtx) => {}, + "platform.start": async (_ctx: PlatformStartCtx) => {}, + "platform.stop": async (_ctx: PlatformStopCtx) => {}, + // Missing: health.register + }, + } as any); + }, + (err: Error) => { + assert.ok(err instanceof Error); + assert.ok( + err.message.includes("health.register"), + `error should name the missing hook "health.register", got: ${err.message}`, + ); + return true; + }, + ); + }); +}); diff --git a/packages/plugins/test/shoggoth-manifest.test.ts b/packages/plugins/test/shoggoth-manifest.test.ts index ea99f8f..5cf7c74 100644 --- a/packages/plugins/test/shoggoth-manifest.test.ts +++ b/packages/plugins/test/shoggoth-manifest.test.ts @@ -1,34 +1,110 @@ -import assert from "node:assert"; -import { describe, test } from "vitest"; -import { parseShoggothPluginManifest } from "../src/shoggoth-manifest"; - -describe("parseShoggothPluginManifest", () => { - test("accepts minimal manifest", () => { - const m = parseShoggothPluginManifest({ - name: "demo", - version: "1.0.0", +import { describe, test, expect } from "vitest"; +import { parseShoggothPluginBag, resolvePluginMeta } from "../src/shoggoth-manifest"; + +describe("parseShoggothPluginBag", () => { + test("accepts valid bag with kind and entrypoint", () => { + const bag = parseShoggothPluginBag({ + kind: "messaging-platform", + entrypoint: "./src/plugin.ts", }); - assert.strictEqual(m.name, "demo"); - assert.strictEqual(m.version, "1.0.0"); - assert.strictEqual(m.hooks, undefined); + expect(bag.kind).toBe("messaging-platform"); + expect(bag.entrypoint).toBe("./src/plugin.ts"); }); - test("parses hooks map", () => { - const m = parseShoggothPluginManifest({ - name: "demo", - version: "2.0.0", - hooks: { "daemon.startup": "./start.mjs" }, + test("defaults kind to 'general' when omitted", () => { + const bag = parseShoggothPluginBag({ + entrypoint: "./src/index.ts", }); - assert.strictEqual(m.hooks!["daemon.startup"], "./start.mjs"); + expect(bag.kind).toBe("general"); + expect(bag.entrypoint).toBe("./src/index.ts"); + }); + + test("accepts kind 'observability'", () => { + const bag = parseShoggothPluginBag({ + kind: "observability", + entrypoint: "./src/obs.ts", + }); + expect(bag.kind).toBe("observability"); + }); + + test("throws when entrypoint is missing", () => { + expect(() => parseShoggothPluginBag({ kind: "general" })).toThrow(); }); - test("rejects unknown top-level keys (strict)", () => { - assert.throws(() => - parseShoggothPluginManifest({ - name: "x", - version: "1", + test("throws when entrypoint is empty string", () => { + expect(() => parseShoggothPluginBag({ kind: "general", entrypoint: "" })).toThrow(); + }); + + test("rejects unknown keys (strict)", () => { + expect(() => + parseShoggothPluginBag({ + kind: "general", + entrypoint: "./index.ts", extra: true, - } as never), - ); + }), + ).toThrow(); + }); + + test("throws on null input", () => { + expect(() => parseShoggothPluginBag(null)).toThrow(); + }); + + test("throws on undefined input", () => { + expect(() => parseShoggothPluginBag(undefined)).toThrow(); + }); +}); + +describe("resolvePluginMeta", () => { + test("combines top-level name/version with shoggothPlugin bag", () => { + const meta = resolvePluginMeta({ + name: "@shoggoth/platform-discord", + version: "0.1.0", + shoggothPlugin: { + kind: "messaging-platform", + entrypoint: "./src/plugin.ts", + }, + }); + expect(meta.name).toBe("@shoggoth/platform-discord"); + expect(meta.version).toBe("0.1.0"); + expect(meta.kind).toBe("messaging-platform"); + expect(meta.entrypoint).toBe("./src/plugin.ts"); + }); + + test("defaults kind to 'general' when bag omits it", () => { + const meta = resolvePluginMeta({ + name: "my-plugin", + version: "1.0.0", + shoggothPlugin: { + entrypoint: "./index.ts", + }, + }); + expect(meta.kind).toBe("general"); + }); + + test("throws when shoggothPlugin bag is missing", () => { + expect(() => + resolvePluginMeta({ + name: "my-plugin", + version: "1.0.0", + }), + ).toThrow(); + }); + + test("throws when top-level name is missing", () => { + expect(() => + resolvePluginMeta({ + version: "1.0.0", + shoggothPlugin: { entrypoint: "./index.ts" }, + }), + ).toThrow(); + }); + + test("throws when top-level version is missing", () => { + expect(() => + resolvePluginMeta({ + name: "my-plugin", + shoggothPlugin: { entrypoint: "./index.ts" }, + }), + ).toThrow(); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 82575ba..2dc291c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -146,3 +146,13 @@ export { export { type Logger, type LogLevel, type LogFields, createLogger, initLogger, getLogger, setRootLogger } from "./logging.js"; export { IMAGE_MIME_TYPES, IMAGE_EXTENSION_TO_MIME, IMAGE_MIME_TO_EXTENSION, MAX_IMAGE_BLOCK_BYTES } from "./image"; export { isPrivateIp } from "./network"; +export type { + HitlPendingStore, + HitlPendingStack, + HitlAutoApproveGate, + HitlConfigRef, + PolicyEngine, + SubagentRuntimeExtension, + MessageToolContext, + PlatformAdapter, +} from "./plugin-interfaces"; diff --git a/packages/shared/src/plugin-interfaces.ts b/packages/shared/src/plugin-interfaces.ts new file mode 100644 index 0000000..4c94bee --- /dev/null +++ b/packages/shared/src/plugin-interfaces.ts @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------------- +// Plugin Interface Types — minimal interfaces for cross-package plugin contracts +// +// These define the shapes that platform plugins receive from the daemon via hook +// contexts. They live in @shoggoth/shared so plugins can import real types without +// depending on daemon internals. +// ------------------------------------------------------------------------------- + +import type { ShoggothHitlConfig } from "./schema"; + +/** + * Minimal interface for the HITL pending actions store. + * Plugins use this to queue/resolve HITL approvals. + */ +export interface HitlPendingStore { + approve(id: string, resolverPrincipal: string): boolean; + deny(id: string, resolverPrincipal: string): boolean; + getById(id: string): { id: string; sessionId: string; toolName: string; status: string } | undefined; + listPendingForSession(sessionId: string): readonly { id: string; sessionId: string; toolName: string; status: string }[]; +} + +/** + * HITL pending resolution stack — pending store + resolution hub. + */ +export interface HitlPendingStack { + readonly pending: HitlPendingStore; + readonly waitForHitlResolution: (pendingId: string) => Promise<"approved" | "denied">; +} + +/** + * HITL auto-approve gate — tracks per-session/agent tool approvals. + */ +export interface HitlAutoApproveGate { + enableSessionTool(sessionId: string, toolName: string): void; + enableAgentTool(agentId: string, toolName: string): void; + shouldAutoApprove(sessionId: string, toolName: string): boolean; + clearAutoApproveMemory?(input: { readonly agents: "all" | readonly string[] }): void; +} + +/** + * Mutable ref to the current HITL config. + */ +export interface HitlConfigRef { + value: ShoggothHitlConfig; +} + +/** + * Policy engine — evaluates whether a principal can perform an action. + */ +export interface PolicyEngine { + check(input: { + readonly principal: { readonly kind: string; readonly sessionId?: string }; + readonly action: string; + readonly resource: string; + }): { allow: true } | { allow: false; reason: string }; + readonly config: unknown; +} + +/** + * Subagent runtime extension — platform provides session turn execution. + */ +export interface SubagentRuntimeExtension { + runSessionModelTurn(input: { + sessionId: string; + userContent: string; + userMetadata?: Record; + systemContext?: unknown; + delivery?: { kind: string; userId?: string }; + }): Promise; + subscribeSubagentSession?(input: unknown): unknown; + registerPlatformThreadBinding?(input: unknown): unknown; + announcePersistentSubagentSessionEnded?(input: unknown): unknown; +} + +/** + * Message tool context — execute message tool actions on a platform. + */ +export interface MessageToolContext { + readonly slice: Record; + execute(sessionId: string, args: unknown): Promise; +} + +/** + * Platform adapter — abstract send/stream interface for outbound messages. + */ +export interface PlatformAdapter { + sendBody(target: string, body: string): Promise; + startStream?(target: string): unknown; +} diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 272e7ea..cdee752 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -1022,7 +1022,7 @@ export function defaultConfig(configDirectory: string): ShoggothConfig { hitl: DEFAULT_HITL_CONFIG, memory: DEFAULT_MEMORY_CONFIG, skills: DEFAULT_SKILLS_CONFIG, - plugins: [], + plugins: [{ package: "@shoggoth/platform-discord" }], mcp: { servers: [], poolScope: "global" }, policy: DEFAULT_POLICY_CONFIG, platforms: { discord: { enabled: true } }, diff --git a/plans/2026-04-20_hooks-plugin-overhaul/README.md b/plans/done/2026-04-20_hooks-plugin-overhaul/README.md similarity index 100% rename from plans/2026-04-20_hooks-plugin-overhaul/README.md rename to plans/done/2026-04-20_hooks-plugin-overhaul/README.md diff --git a/plans/2026-04-20_hooks-plugin-overhaul/architecture.svg b/plans/done/2026-04-20_hooks-plugin-overhaul/architecture.svg similarity index 100% rename from plans/2026-04-20_hooks-plugin-overhaul/architecture.svg rename to plans/done/2026-04-20_hooks-plugin-overhaul/architecture.svg diff --git a/plans/2026-04-20_hooks-plugin-overhaul/discord-hook-points.md b/plans/done/2026-04-20_hooks-plugin-overhaul/discord-hook-points.md similarity index 100% rename from plans/2026-04-20_hooks-plugin-overhaul/discord-hook-points.md rename to plans/done/2026-04-20_hooks-plugin-overhaul/discord-hook-points.md diff --git a/plans/2026-04-20_hooks-plugin-overhaul/spec.md b/plans/done/2026-04-20_hooks-plugin-overhaul/spec.md similarity index 100% rename from plans/2026-04-20_hooks-plugin-overhaul/spec.md rename to plans/done/2026-04-20_hooks-plugin-overhaul/spec.md