Skip to content

[plugin sdk] sdk seam followups 6 seams stacked on #73384#73526

Closed
100yenadmin wants to merge 64 commits into
openclaw:mainfrom
electricsheephq:feat/plugin-sdk-plan-mode-parity-followups
Closed

[plugin sdk] sdk seam followups 6 seams stacked on #73384#73526
100yenadmin wants to merge 64 commits into
openclaw:mainfrom
electricsheephq:feat/plugin-sdk-plan-mode-parity-followups

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

(production: ~830 LOC, tests: ~880 LOC, ~1690 / -13 across 29 files).

This PR closes the remaining gap between what the consolidated plugin SDK exposes after #73384 and what non-trivial workflow plugins need to run outside the host process without reaching into core internals. It adds six tightly-scoped seams on top of #73384's workflow foundation — every seam is additive, each seam is motivated by concrete private-stack call sites where the SDK has no public surface yet, and the changes are gated by 52 new tests (production: ~830 LOC, tests: ~880 LOC, ~1690 / -13 across 29 files).

This PR is a stacked follow-up to #73384 and should be treated as waiting on that base PR. GitHub currently shows this branch against main, so the visible diff/conflict state includes inherited stack context from #73384. The intended review window is the unique SDK follow-up surface listed below; once #73384 lands, this branch will be rebased or refiled onto fresh main and validated there instead of chasing unrelated main-branch drift while the base PR is still open.

This remains generic plugin SDK work. It does not add Plan Mode product code, prompts, /plan behavior, or product-specific UI. Internally, Plan Mode parity is only the oracle used to prove the host seams are complete; publicly, the SDK surface is for many plugin classes with similar needs — deployment approvers, budget guards, SLA watchers, review assistants, channel-native workflow plugins, scheduled nudges, sub-agent observability, slot-projected session extensions, and channel-helper re-exports.


Relationship to the predecessor PRs

flowchart TD
    RFC["RFC #71731 (open)<br/>plugin host-hook capability requirements"]
    Foundation["#72287 MERGED 2026-04-28<br/>generic host-hook contracts<br/>+7247/-282 LOC<br/>session extension storage, queued injections,<br/>trusted tool policies, control UI descriptors,<br/>lifecycle/event subscriptions, run-context helpers,<br/>scheduler ownership cleanup, Gateway schemas"]
    Workflow["#73384 OPEN/MERGEABLE<br/>workflow seams + fixtures + recipes<br/>+5937/-45 LOC<br/>session actions, outbound attachments,<br/>scheduleSessionTurn, finalize-retry,<br/>advanced workflow contract fixtures,<br/>host-hook examples & recipes docs"]
    ThisPR["THIS PR (stacked on #73384)<br/>SDK seam follow-ups<br/>+1690/-13 LOC<br/>6 seams: host runtime ctx, channel-typed<br/>attachment hints, suppressHostInputWhile,<br/>scheduler taxonomy/tagged cleanup,<br/>session-extension SessionEntry promotion,<br/>Telegram api.ts re-exports"]
    Oracle1["#71676 (closed)<br/>internal parity oracle<br/>(reference only — never merged)"]
    Plugin["FUTURE: complex workflow plugins<br/>approval, scheduling, channel-native UX,<br/>subagent-aware policy, session projections"]

    RFC --> Foundation
    Oracle1 -.parity reference.-> Foundation
    Foundation --> Workflow
    Oracle1 -.parity reference.-> Workflow
    Workflow --> ThisPR
    Oracle1 -.parity reference.-> ThisPR
    ThisPR --> Plugin

    classDef merged fill:#1f7a1f,stroke:#0a3d0a,color:#fff
    classDef open fill:#1f4d7a,stroke:#0a1f3d,color:#fff
    classDef thisPr fill:#7a4d1f,stroke:#3d260a,color:#fff
    classDef ref fill:#555,stroke:#222,color:#fff,stroke-dasharray:4 4
    classDef future fill:#222,stroke:#000,color:#aaa,stroke-dasharray:4 4

    class Foundation merged
    class Workflow,RFC open
    class ThisPR thisPr
    class Oracle1 ref
    class Plugin future
Loading

Cumulative SDK surface after each PR merges

flowchart LR
    subgraph After72287["After #72287 (MERGED)"]
        F1["session extension storage + projection"]
        F2["queued next-turn injections"]
        F3["trusted tool policies"]
        F4["tool metadata"]
        F5["control UI descriptors (basic)"]
        F6["lifecycle/event subscriptions"]
        F7["run-context helpers (plugin-owned only)"]
        F8["scheduler ownership cleanup"]
        F9["Gateway schemas + SDK exports"]
    end
    subgraph After73384["After #73384 (this PR's base)"]
        W1["typed session actions"]
        W2["sendPluginSessionAttachment"]
        W3["schedulePluginSessionTurn (basic)"]
        W4["bounded finalize-retry"]
        W5["queued injection priority"]
        W6["workflow hook config validation"]
        W7["workflow contract fixtures"]
        W8["host-hook examples + recipes docs"]
    end
    subgraph AfterThisPR["After THIS PR (the gap closure)"]
        S1["getHostRunContext (subagent state, parent-run linkage)"]
        S2["derivedPaths on before_tool_call (apply_patch parser)"]
        S3["channel-typed attachment hints (parseMode, ephemeral, threadTs, ...)"]
        S4["suppressHostInputWhile descriptor field"]
        S5["scheduler tag taxonomy + unscheduleSessionTurnsByTag"]
        S6["session-extension SessionEntry slot promotion"]
        S7["Telegram api.ts public re-exports"]
    end

    After72287 --> After73384
    After73384 --> AfterThisPR
Loading

Why the chain matters

Each PR in this chain answers a specific question:

PR Question Answer
#71731 (RFC, open) What host capabilities must a real plugin be able to compose? A list of generic host-hook primitives.
#72287 (merged) How does the host expose those primitives without leaking internals? Generic registration, scoped runtime, validated I/O, lifecycle cleanup.
#73384 (open, this PR's base) What workflow affordances does a plugin need after hooks register? Session actions, attachments, scheduling, finalize-retry, workflow fixtures, recipes.
THIS PR What's still missing for non-trivial workflow plugins to run outside core? Six narrow extensions, all additive, none breaking.
(Future) bundled workflow plugins Can the SDK now host complex approval/scheduling/channel-native plugins without forking core? After this PR: yes.

The six seams — what each one does, why it's needed, what it adds

Every seam below follows the same template:

  1. What — the new SDK surface, in one sentence.
  2. Why — the concrete private-stack call site or plugin archetype that today reaches into core.
  3. How — the implementation approach, in two or three sentences.
  4. Compat — what existing behavior is unchanged.
  5. Files — exactly which files change, separated into NEW vs EXTENDED.

Seam A — Host runtime context (read-only _host.runtime namespace + apply-patch path derivation)

What. Plugins running inside before_tool_call / after_tool_call get a typed, read-only view of the host's run state via a new getHostRunContext<K>(namespace) getter, scoped to the reserved _host.* namespace prefix. The first registered namespace is _host.runtime, returning { openSubagentRunIds, lastSubagentSettledAt, parentRunId }. As a complement, every before_tool_call event now carries a derivedPaths?: string[] field populated by a new host-side per-tool param parser registry (the first parser handles apply_patch).

Why. Subagent-aware policy plugins need to know whether the current run has open subagents and what its parent-run linkage is before deciding whether a mutation gate should fire. Today that requires reaching into agent-events internals (no public surface). Independently, path-policy plugins re-parse every apply_patch envelope from scratch to know which paths the patch will touch — duplicating work the host parser already does milliseconds later when it actually applies the patch.

How. A new host-runtime-namespace.ts module declares the namespace constants (HOST_RUNTIME_NAMESPACE = "_host.runtime", HOST_RUNTIME_NAMESPACE_PREFIX = "_host."), the HostRuntimeRunContext type, and the assertHostRuntimeNamespace narrowing helper. setPluginRunContext rejects writes to any _host.* namespace with a logged warning (no exception — graceful degradation); getPluginRunContext returns undefined symmetrically. getHostRunContext("_host.runtime") materializes a deep snapshot of the host's view of the run from agent-events, so plugins cannot mutate live runtime state. Subagent tracking on AgentRunContext (parentRunId, openSubagentRunIds, lastSubagentSettledAt) follows a first-write-wins rule: a model-switch re-registration of a child cannot overwrite the original parent linkage. The apply_patch derivation lives in a new host-tool-param-parsers.ts registry that runs synchronously in the before_tool_call hot path, with each parser implemented in its own file (apply-patch-paths.ts for the apply-patch one) so the registry stays small and the parsers stay individually testable.

Compat. All additions are optional. Plugins that never call getHostRunContext see no behavior change. The _host.* reservation is a write-side rejection only; existing plugin namespaces are untouched. derivedPaths is an optional field on the existing PluginHookBeforeToolCallEvent; consumers that don't read it see no change.

Files.

  • NEW src/plugins/host-runtime-namespace.ts (52 LOC): namespace constants, HostRuntimeRunContext type, isReservedHostRuntimeNamespace, assertHostRuntimeNamespace.
  • NEW src/plugins/host-runtime-namespace.test.ts (41 LOC): 5 unit tests.
  • NEW src/agents/apply-patch-paths.ts (95 LOC): forgiving extractor for *** Add File:, *** Update File: (+ optional *** Move to:), *** Delete File: markers.
  • NEW src/agents/apply-patch-paths.test.ts (100 LOC): 11 unit tests covering single/multi-file, move-to, malformed, empty, and non-string inputs.
  • NEW src/plugins/host-tool-param-parsers.ts (37 LOC): per-tool parser registry; first entry is apply_patch.
  • NEW src/plugins/host-tool-param-parsers.test.ts (37 LOC): 4 unit tests.
  • EXTENDED src/plugins/host-hook-runtime.ts (+76 LOC): getHostRunContext, setPluginRunContext _host.* rejection, getPluginRunContext symmetric guard, agent-events bridge for the runtime snapshot.
  • EXTENDED src/plugins/hook-types.ts (+16 LOC): derivedPaths?: string[] on PluginHookBeforeToolCallEvent, getHostRunContext on PluginHookToolContext.
  • EXTENDED src/plugins/tool-types.ts (+11 LOC): getHostRunContext on OpenClawPluginToolContext.
  • EXTENDED src/agents/openclaw-tools.plugin-context.ts (+5 LOC): wire getHostRunContext through to plugin tool contexts.
  • EXTENDED src/agents/pi-tools.before-tool-call.ts (+6 LOC): call deriveToolParams(name, params) once per call and stamp derivedPaths onto the event.
  • EXTENDED src/agents/agent-command.ts (+2 LOC) and src/agents/command/types.ts (+2 LOC): parentRunId?: string on AgentCommandOpts for clean subagent-spawn parent declaration.
  • EXTENDED src/infra/agent-events.ts (+60 LOC): parentRunId, openSubagentRunIds, lastSubagentSettledAt on AgentRunContext; first-write-wins parent registration; subagent settle tracking.
sequenceDiagram
    participant Tool as before_tool_call hook
    participant Plugin as Plugin handler
    participant HostCtx as getHostRunContext("_host.runtime")
    participant AgentEvents as agent-events store
    participant ParamParser as deriveToolParams("apply_patch", params)
    participant PatchParser as extractApplyPatchTargetPaths

    Tool->>ParamParser: derive once per call
    ParamParser->>PatchParser: parse envelope (forgiving)
    PatchParser-->>ParamParser: ["src/foo.ts", "src/bar.ts"]
    ParamParser-->>Tool: { derivedPaths: [...] }
    Tool->>Plugin: invoke with event.derivedPaths populated
    Plugin->>HostCtx: read for subagent state
    HostCtx->>AgentEvents: snapshot (deep clone)
    AgentEvents-->>HostCtx: { openSubagentRunIds, lastSubagentSettledAt, parentRunId }
    HostCtx-->>Plugin: read-only HostRuntimeRunContext
    Plugin->>Plugin: decide policy (no core reach-in)
    Plugin-->>Tool: allow / deny / annotate
Loading

Seam B — Channel-typed attachment hints

What. PluginSessionAttachmentParams (added in #73384) grows two optional fields: captionFormat: "plain" | "html" | "markdownv2" (channel-agnostic caption rendering hint) and channelHints: { telegram?, discord?, slack? } (strongly-typed per-channel knobs — Telegram parseMode/disableNotification/forceDocumentMime, Discord ephemeral/suppressEmbeds, Slack unfurlLinks/threadTs). A new resolveAttachmentDelivery helper centralizes precedence (channelHints.<channel>.parseMode > captionFormat) so every channel adapter sees the same resolved view.

Why. Channel-native workflow plugins need per-channel delivery intent: Telegram captions may need HTML or MarkdownV2 rendering plus silent delivery, Slack replies may need threadTs to stay in the originating thread, and Discord prompts may need ephemeral: true. Without typed hints, plugins resort to passing untyped any blobs through metadata and hoping each channel adapter knows how to read them — a contract that is not enforced and is invisible at compile time.

How. The hint object is purely additive on PluginSessionAttachmentParams. sendPluginSessionAttachment (the workflow seam #73384 added) plumbs the resolved silent (Telegram disableNotification) and threadTs (Slack) into the existing sendMessage surface. The remaining hints (parseMode, forceDocumentMime, ephemeral, suppressEmbeds, unfurlLinks) are forward-compatible — channels that don't recognize them ignore them. The PR deliberately does NOT touch MessageSendParams to avoid rippling backward-compat risk through every channel; that's left for adapter-by-adapter follow-up.

Compat. Existing sendPluginSessionAttachment callers see no change. The two new fields are optional. Channel adapters that don't read them ignore them. The precedence rule is documented and tested.

Files.

  • EXTENDED src/plugins/host-hook-workflow.ts (+236 LOC, the bulk of which is this seam): resolveAttachmentDelivery helper, captionFormat + channelHints typing, plumbing through existing sendMessage.
  • EXTENDED src/plugins/types.ts (+13 LOC): public PluginSessionAttachmentParams extension.
  • EXTENDED src/plugins/host-hooks.ts (+104 LOC for seams B + D + F shared helpers and types): includes the channel-hint type definitions.
  • EXTENDED src/plugin-sdk/core.ts (+7 LOC): re-exports.
flowchart LR
    Plugin["plugin code"] -->|"{captionFormat: 'html',<br/>channelHints: {<br/>  telegram: {parseMode: 'HTML',<br/>    disableNotification: true},<br/>  slack: {threadTs: '1234.5678'}<br/>}}"| Workflow["sendPluginSessionAttachment"]
    Workflow --> Resolve["resolveAttachmentDelivery"]
    Resolve --> ResolvedView["resolved view:<br/>{ silent: true,<br/>  threadTs: '1234.5678',<br/>  captionFormat: 'html', ...}"]
    ResolvedView --> Telegram["Telegram adapter<br/>reads: parseMode, silent"]
    ResolvedView --> Slack["Slack adapter<br/>reads: threadTs, unfurlLinks"]
    ResolvedView --> Discord["Discord adapter<br/>reads: ephemeral, suppressEmbeds"]
    ResolvedView --> Other["unknown channel<br/>ignores hints, sends defaults"]
Loading

Seam C — suppressHostInputWhile declarative descriptor field

What. A new optional suppressHostInputWhile field on PluginControlUiDescriptor is a hint to UI clients that the host's default chat input should be hidden while the plugin's control UI (e.g. an approval card) is mounted. The shape is purely declarative.

Why. When an approval plugin mounts a pending approval card, the host's chat input can be the wrong affordance — clicking enter would send a free-form message instead of approving or rejecting the structured action. In the private stack this required reaching into the host UI to hide its input. With this seam, the plugin declares the intent in its descriptor; UI clients honor it independently.

How. A single optional field on the existing descriptor. UI clients (web chat, macOS app, etc.) decide whether to honor it. The host does NOT enforce input gating server-side — server-side gating is already handled by the existing trusted-tool policy + workflow action surface. This seam is strictly about client-side UI affordance.

Compat. Optional field. Descriptors that don't set it work exactly as before.

Files.

  • EXTENDED src/plugins/hook-types.ts (part of the +16 LOC above): suppressHostInputWhile?: SuppressHostInputCondition on PluginControlUiDescriptor.
  • EXTENDED src/plugins/types.ts (part of the +13 LOC above): public re-export.
flowchart TD
    Plugin["plugin registers descriptor"] -->|"{<br/>  kind: 'approval-card',<br/>  suppressHostInputWhile: 'mounted'<br/>}"| Registry
    Registry --> Persist["host stores descriptor"]
    Persist --> UI["UI client (web/mac/CLI) renders descriptor"]
    UI --> Decision{"client honors<br/>suppressHostInputWhile?"}
    Decision -->|"yes"| Hide["hide host chat input<br/>while card is mounted"]
    Decision -->|"no"| Show["show host chat input<br/>(legacy clients work)"]
Loading

Seam D — Scheduler taxonomy + tagged cleanup

What. PluginSessionTurnScheduleParams (added in #73384) grows tag and payloadExtras. tag is auto-prefixed with ${pluginId}: and persisted into the cron job name so two plugins can reuse the same short tag without collision. payloadExtras are JSON-compatible fields merged into the cron agentTurn payload before send. A new OpenClawPluginApi.unscheduleSessionTurnsByTag({ sessionKey, tag }) queries cron, filters by the auto-prefixed name + sessionTarget, and removes matches; returns { removed, failed } counts.

Why. Approval, SLA, and budget-watch plugins schedule follow-up turns: "if the user hasn't responded within 60s, nudge them" or "if this background monitor is still open, re-check it." When the user acts or the condition clears, the plugin needs to cancel outstanding turns deterministically. Without a tag taxonomy, the plugin would have to manage cron job ids itself (and risk leaking them on crash); without unscheduleSessionTurnsByTag, the plugin would have to walk all cron jobs and string-match — both of which require reaching into cron internals.

How. Job names are formatted as plugin:${pluginId}:tag:${tag}:${sessionKey}:${uuid}. The pluginId: prefix is added by the host, never trusted from the plugin, so cross-plugin collisions are impossible. payloadExtras is spread into the existing cron payload; downstream consumers see them alongside kind/message. unscheduleSessionTurnsByTag queries cron.list, filters by the auto-prefixed name + sessionTarget (so cancellation is scoped to the same session), and removes matches.

Compat. Both tag and payloadExtras are optional. Existing schedulePluginSessionTurn callers from #73384 work unchanged. The unscheduleSessionTurnsByTag API is net-new.

Files.

  • EXTENDED src/plugins/host-hook-workflow.ts (part of the +236 LOC above): tag prefixing, payload extras spread, list-and-remove implementation.
  • EXTENDED src/plugins/host-hooks.ts (part of the +104 LOC above): scheduler param + return types.
  • EXTENDED src/plugins/api-builder.ts (+5 LOC): expose unscheduleSessionTurnsByTag on the plugin API.
  • EXTENDED src/plugins/registry.ts (+9 LOC): wire the workflow seam through registry.
  • EXTENDED src/plugin-sdk/core.ts (part of the +7 LOC above): re-export.
  • EXTENDED src/plugin-sdk/plugin-test-api.ts (+1 LOC): test API entry.
sequenceDiagram
    participant Plan as plan-mode plugin
    participant API as OpenClawPluginApi
    participant Cron as cron service
    participant Runner as agent runner

    Plan->>API: schedulePluginSessionTurn({<br/>sessionKey, when, message, tag: 'nudge'})
    API->>Cron: register name=<br/>"plugin:plan-mode:tag:nudge:abc123:uuid-1"
    Cron-->>API: ok
    API-->>Plan: { jobName: '...' }

    Note over Plan: user approves → cancel pending nudges

    Plan->>API: unscheduleSessionTurnsByTag({<br/>sessionKey: 'abc123', tag: 'nudge'})
    API->>Cron: list()
    Cron-->>API: [...all jobs...]
    API->>API: filter name startsWith<br/>"plugin:plan-mode:tag:nudge:abc123:"
    API->>Cron: remove(uuid-1)
    Cron-->>API: ok
    API-->>Plan: { removed: 1, failed: 0 }
Loading

Seam E — Session extension promotion to SessionEntry slot

What. PluginSessionExtensionRegistration grows sessionEntrySlotKey and sessionEntrySlotSchema. After every successful patchPluginSessionExtension, the projector output is mirrored into SessionEntry[<slotKey>] so non-plugin readers (channel renderers, sidebar telemetry, search indexers) can consume the typed slot without reaching into pluginExtensions[pluginId][namespace].

Why. Workflow plugins often persist approval state, titles, pending action ids, summaries, or monitor status in a plugin-owned session extension. Channel renderers, sidebars, or search indexers may need a typed projection of that state without knowing the plugin id and namespace. With slot promotion, the projected data appears at an explicit SessionEntry slot and consumers read a stable typed mirror.

How. The slot is a read-only mirror, not a parallel write surface. Writes still go through patchSessionExtension; the host overwrites the slot on every patch and clears it on unset: true. A misbehaving projector cannot block the primary patch from being persisted — projection failures log and skip the slot update, but the patch lands either way. The slot key + schema are validated at registration time.

Compat. Plugins that don't set sessionEntrySlotKey see no change. Existing SessionEntry consumers see new optional slots they can ignore. The primary pluginExtensions storage path is unchanged.

Files.

  • EXTENDED src/plugins/host-hook-state.ts (+60 LOC): slot key persistence, projector mirror, unset handling, projection-failure isolation.
  • EXTENDED src/plugins/host-hooks.ts (part of the +104 LOC above): registration param types.
  • EXTENDED src/plugins/types.ts (part of the +13 LOC above): public re-exports.
flowchart LR
    PluginRegister["plugin.registerSessionExtension({<br/>  namespace: 'state',<br/>  sessionEntrySlotKey: 'planMode',<br/>  sessionEntrySlotSchema: PlanModeSlotSchema,<br/>  projector: (raw) => ({...projected})<br/>})"]
    PluginRegister --> PatchCall["plugin.patchSessionExtension(<br/>{namespace: 'state', patch: {...}})"]
    PatchCall --> Storage["pluginExtensions[pluginId]['state']<br/>= merged raw value (PRIMARY)"]
    Storage --> Project["run projector(raw)"]
    Project -->|"success"| MirrorOk["SessionEntry['planMode']<br/>= projected (MIRROR)"]
    Project -->|"throws"| MirrorSkip["log + skip slot;<br/>primary store unaffected"]
    MirrorOk --> Reader["channel renderer / sidebar /<br/>search reads typed slot<br/>without knowing plugin internals"]
    MirrorSkip --> Reader
Loading

Seam F — Telegram api.ts public re-exports

What. Promotes Telegram's caption-rendering helpers (markdownToTelegramHtml, escapeTelegramHtml, markdownToTelegramHtmlChunks, splitTelegramHtmlChunks, plus the TelegramFormattedChunk type) from the private extensions/telegram/src/format.ts to the public extensions/telegram/api.ts barrel. escapeTelegramHtml is added as a public alias of the existing private escapeHtml.

Why. Channel-native workflow plugins need to convert Markdown labels, approval titles, and status summaries into Telegram-safe HTML. Today the only way to do this is to import from extensions/telegram/src/format.js — a private path. Public re-exports make the import stable: import { markdownToTelegramHtml } from "@openclaw/telegram/api".

How. Pure re-export through the existing barrel + one rename alias. Zero behavior change in format.ts.

Compat. Strictly additive. format.ts remains intact. The new public surface is a barrel re-export, so existing private imports keep working until the migration is complete.

Files.

  • EXTENDED extensions/telegram/api.ts (+8 LOC): re-exports.
  • EXTENDED extensions/telegram/src/format.ts (+12 LOC): one alias + JSDoc tightening.
  • NEW extensions/telegram/api.test.ts (16 LOC): 2 tests proving the re-exports are wired correctly and produce the same output as the private module.

Detailed end-to-end view: how the seams compose for a real plugin

The cleanest way to see why these six seams are scoped together is to walk through what a channel-native approval/workflow plugin does in a single user turn — and which seams it touches.

sequenceDiagram
    autonumber
    participant User
    participant Host as Host (gateway + runner)
    participant Plugin as workflow plugin
    participant SessionExt as session extension store
    participant Slot as SessionEntry["planMode"] slot
    participant Cron as cron scheduler
    participant Telegram as Telegram channel adapter

    User->>Host: requests a structured workflow action
    Host->>Plugin: before_tool_call(exit_plan_mode)
    Plugin->>Plugin: getHostRunContext("_host.runtime")<br/>Seam A: check parent-run + open subagents
    Plugin->>SessionExt: patchSessionExtension(state, {pendingApprovalId: 'abc'})
    SessionExt->>Slot: mirror to entry.planMode<br/>Seam E: typed slot for renderers
    Plugin->>Plugin: render approval card descriptor<br/>Seam C: suppressHostInputWhile = 'mounted'
    Plugin->>Cron: schedulePluginSessionTurn({tag: 'nudge', when: +60s})<br/>Seam D: tag auto-prefixed
    Plugin->>Telegram: sendPluginSessionAttachment({<br/>  caption: 'Approve plan?',<br/>  captionFormat: 'html',<br/>  channelHints: {telegram: {parseMode: 'HTML', disableNotification: true}}<br/>})<br/>Seam B + Seam F (markdownToTelegramHtml)
    Host-->>User: card rendered, host input suppressed,<br/>nudge scheduled, Telegram caption sent
    Note over User: user approves
    User->>Host: clicks approve in card
    Host->>Plugin: sessionAction('plan.approve')
    Plugin->>Cron: unscheduleSessionTurnsByTag({tag: 'nudge'})<br/>Seam D: cancel deterministically
    Plugin->>SessionExt: patchSessionExtension(state, {approved: true})
    SessionExt->>Slot: mirror update to entry.planMode<br/>Seam E
    Plugin-->>Host: continueAgent (existing #73384 surface)
Loading

Every seam in this PR is touched at least once in this single plan-mode turn. Without any one of them, the plugin would have to reach into core for the equivalent capability.


Out of scope (explicitly deferred)

These items are intentionally left for follow-up PRs rather than bundled in here:

  • UI client wiring for suppressHostInputWhile. The descriptor is declarative; UI clients (web/macOS/CLI) consume it independently. This PR does not touch ui/src/ to keep the diff focused on SDK surface.
  • Channel adapter wiring for the remaining hints (parseMode, forceDocumentMime, ephemeral, suppressEmbeds, unfurlLinks). MessageSendParams is left untouched to avoid rippling backward-compat risk through every channel adapter; the resolved hints are forward-compatible and ready for adapter-by-adapter consumption.
  • Migrating any existing plugin to use these seams. Concrete plugin migrations are follow-up work after the SDK surface lands.
  • SessionEntry slot consumers in renderers. Slot promotion is a write path here; the read paths (channel renderers, sidebar) are not touched in this PR.
  • Subagent gating in before_tool_call itself. This PR exposes the host's view; deciding policy is the plugin's job.

Backward compatibility

Every addition is optional. No existing field shape changed. No existing default behavior changed. No existing public symbol removed.

Surface Change kind
PluginHookToolContext.getHostRunContext NEW optional method
PluginHookBeforeToolCallEvent.derivedPaths NEW optional field
OpenClawPluginToolContext.getHostRunContext NEW optional method
AgentCommandOpts.parentRunId NEW optional field
AgentRunContext.{parentRunId, openSubagentRunIds, lastSubagentSettledAt} NEW fields
PluginSessionAttachmentParams.{captionFormat, channelHints} NEW optional fields
PluginControlUiDescriptor.suppressHostInputWhile NEW optional field
PluginSessionTurnScheduleParams.{tag, payloadExtras} NEW optional fields
OpenClawPluginApi.unscheduleSessionTurnsByTag NEW method
PluginSessionExtensionRegistration.{sessionEntrySlotKey, sessionEntrySlotSchema} NEW optional fields
extensions/telegram/api.ts re-exports NEW public re-exports

The docs/.generated/plugin-sdk-api-baseline.sha256 was regenerated to reflect the new public surface.


Test plan

Comprehensive test coverage stamped against every seam:

Test file Tests Covers
src/plugins/host-runtime-namespace.test.ts (NEW) 5 Seam A — namespace constants, _host.* reservation, narrowing helper
src/agents/apply-patch-paths.test.ts (NEW) 11 Seam A — single/multi-file, *** Move to:, malformed input, empty input, non-string input
src/plugins/host-tool-param-parsers.test.ts (NEW) 4 Seam A — registry dispatch, missing-tool fallback, apply-patch wiring
src/plugins/contracts/host-runtime-reads.contract.test.ts (NEW) 14 Seam A — end-to-end: getHostRunContext, derivedPaths, parent-run linkage, first-write-wins, deep-clone safety
src/plugins/contracts/plan-mode-followups.contract.test.ts (NEW) 16 Seams B + C + D + E end-to-end: channel hints precedence, descriptor wiring, tag auto-prefix, unschedule-by-tag, slot mirror, slot unset, projector failure isolation
extensions/telegram/api.test.ts (NEW) 2 Seam F — re-exports produce same output as private module
TOTAL NEW 52

Plus regression coverage:

  • 57 existing contract tests in src/plugins/contracts/ pass unchanged (host-hooks, advanced-workflow-fixtures, host-hook-workflow, etc.).
  • 10 existing infra tests around agent-events pass unchanged.
  • Wider sweeps: src/agents/agent-command, src/agents/pi-tools.before-tool-call, src/agents/pi-embedded-subscribe.handlers.tools — all green across 74 vitest shards.

Verification commands

  • pnpm build green (full chain through copy-hook-metadata and write-cli-compat)
  • pnpm tsgo:core clean
  • pnpm tsgo:core:test clean
  • pnpm format:check clean on changed files (8 pre-existing format issues remain in unrelated files — not introduced by this PR)
  • pnpm plugin-sdk:api:gen baseline regenerated; docs/.generated/plugin-sdk-api-baseline.sha256 updated; the gitignored JSON files were re-emitted (per repo policy)
  • pnpm test src/plugins/contracts — all green (existing 57 + new 30 = 87)
  • pnpm test src/plugins/host-runtime-namespace + src/plugins/host-tool-param-parsers + src/agents/apply-patch-paths — all green (20 unit tests)
  • pnpm test extensions/telegram/api — all green (2 tests)
  • Wider regression sweeps — green

File-group changelog

NEW (9 files, 879 LOC including tests)

File LOC Purpose
src/plugins/host-runtime-namespace.ts 52 Reserved _host.* namespace constants, HostRuntimeRunContext type
src/plugins/host-runtime-namespace.test.ts 41 Unit tests for namespace constants/helpers
src/agents/apply-patch-paths.ts 95 Forgiving extractor for apply_patch destination paths
src/agents/apply-patch-paths.test.ts 100 Unit tests for the extractor
src/plugins/host-tool-param-parsers.ts 37 Per-tool param parser registry (first entry: apply_patch)
src/plugins/host-tool-param-parsers.test.ts 37 Unit tests for registry dispatch
src/plugins/contracts/host-runtime-reads.contract.test.ts 197 End-to-end contract for Seam A
src/plugins/contracts/plan-mode-followups.contract.test.ts 489 End-to-end contract for Seams B + C + D + E
extensions/telegram/api.test.ts 16 Re-export verification for Seam F

EXTENDED (20 files, 811 LOC delta)

File Δ LOC Seams touched
src/plugins/host-hook-workflow.ts +236 B, D
src/plugins/host-hooks.ts +104 B, D, E
src/plugins/host-hook-runtime.ts +76 A
src/plugins/host-hook-state.ts +60 E
src/infra/agent-events.ts +60 A
src/plugins/hook-types.ts +16 A, C
src/plugins/types.ts +13 B, C, E
src/plugins/tool-types.ts +11 A
src/plugins/registry.ts +9 D
src/plugins/api-builder.ts +5 D
src/agents/openclaw-tools.plugin-context.ts +5 A
src/agents/pi-tools.before-tool-call.ts +6 A
src/agents/agent-command.ts +2 A
src/agents/command/types.ts +2 A
src/agents/pi-embedded-subscribe.handlers.tools.ts +2 A (parent-run threading)
src/plugin-sdk/core.ts +7 B, D, E
src/plugin-sdk/plugin-test-api.ts +1 D (test API)
extensions/telegram/api.ts +8 F
extensions/telegram/src/format.ts +12 F (alias + JSDoc)
docs/.generated/plugin-sdk-api-baseline.sha256 +4/-4 (regenerated, gitignored JSON)

TOTAL: 29 files, +1690 / -13 LOC.


Why this PR exists separately from #73384

#73384 was already large (52 files, +5937 LOC) and explicitly scoped to "workflow seams + fixtures + recipes" — the four affordances a plugin needs to act on hooks (session actions, attachments, scheduling, finalize-retry). It deliberately did not branch into:

Bundling them together would have made #73384 ~7600 LOC and harder to review. Stacking them as a follow-up keeps each PR's diff coherent and reviewable in isolation. This PR does not modify any line that #73384 also modifies. Every change here is either net-new (9 new files) or extends a different region of a file that #73384 also touches — no overlap, no merge conflict on rebase.


Branch + push status

Eva added 30 commits April 28, 2026 14:43
…etadata-key helper

Address Copilot review threads on PR #72383:

1. host-hook-runtime.ts: when a jobId is in preserveJobIds, skip the
   cleanup callback regardless of generation. Previously the gate was
   "skip only if generations match", which meant the OLD cleanup would
   still run after registry replacement and could remove the EXTERNAL
   scheduled resource (e.g. cron.remove) the new live generation now
   relies on. Generation-matched deletion below already protects the
   newer registry record; preserveJobIds is the user-visible contract
   for "do not touch this scheduled job at all".

2. tools-catalog.ts + tools-effective-inventory.ts + plugins/tools.ts:
   hoist PLUGIN_TOOL_METADATA_KEY_SEPARATOR + buildPluginToolMetadataKey
   into the shared plugins/tools module. Both files had identical local
   declarations; consolidating prevents future drift on the keying scheme.
…gisterToolMetadata doc

Address Copilot review threads on PR #72383:

1. schema/plugins.ts: PluginJsonValueSchema used Type.This() inside a
   Type.Union without a recursive scope, which produces an invalid
   schema (unresolved $ref) at AJV compile time. Wrap the union in
   Type.Cyclic({...}, "PluginJsonValue") with explicit Type.Ref.
   Targeted vitest passes 54/54 across src/gateway/protocol.

2. plugins/types.ts: registerToolMetadata docstring claimed it
   applies to "plugin-owned or core tool", but the registry +
   projection logic intentionally scopes to (pluginId, toolName) so
   plugins cannot decorate other plugins' tools or core tools.
   Updated the doc to match.
…cy params + correct CHANGELOG PR ref

Address Copilot review threads on PR #72383:

1. protocol/schema/plugins.ts + server-methods/plugin-host-hooks.ts:
   plugin session-action failures (`{ ok: false, error, code?, details? }`)
   are now returned as a successful RPC with `ok: false` + typed error
   fields, not as transport-level errorShape. Schema now declares
   `error?`, `code?`, `details?` on the result. Distinguishes plugin-
   declared failures from transport-level RPC failures (validation,
   schema mismatch, dispatch error).

2. trusted-tool-policy.ts: `decision.params` is validated as a plain
   object via `isPlainObject` before being applied. A buggy trusted
   policy returning a non-object (string/array/primitive) no longer
   propagates into the tool runtime / `before_tool_call` hooks. Fixes
   the matching pi-tools.before-tool-call.ts:446 concern at the source.

3. CHANGELOG.md: corrected the entry to describe THIS PR's content
   (workflow action, outbound, scheduler, retry host seams + protocol
   surfaces) and reference (#72383) instead of (#72287).
…ionActionResultSchema

Per copilot review on plugins.ts:69 — `plugins.sessionAction` returns plugin-
declared failures as a successful RPC with `{ ok: false, error, code?, details? }`
(see server-methods/plugin-host-hooks.ts:119), but the schema only allowed
`{ ok, result?, continueAgent?, reply? }` with `additionalProperties: false`.
Generated clients (Swift, etc.) would either drop the failure fields or fail to
decode the response. The previous commit (89444ba1) updated the server but the
schema patch was missed; this completes the contract by adding optional
`error`, `code`, `details` to the result so the wire shape and the schema agree.
…ompt hooks present

Per chatgpt-codex P2 review on prepare.ts:331 — eagerly invoking
`loadOpenClawHistoryMessages()` on every CLI run forces avoidable disk read
+ JSON parse latency even when the registered hook set never reads
`messages`. The previous (pre-PR) call site had a `hasHooks(...)` guard;
restore the equivalent gate at the callsite by checking whether the
registered hooks include any of the message-consuming hooks
(`agent_turn_prepare`, `before_prompt_build`, `before_agent_start`) before
loading. `heartbeat_prompt_contribution` does not consume messages and is
intentionally excluded.
@100yenadmin
100yenadmin requested a review from a team as a code owner April 28, 2026 11:28
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: telegram Channel integration: telegram app: macos App: macos app: web-ui App: web-ui gateway Gateway runtime scripts Repository scripts docker Docker and sandbox tooling agents Agent runtime and tooling extensions: qa-lab size: XL labels Apr 28, 2026
@100yenadmin

Copy link
Copy Markdown
Contributor Author

📌 Stacking note (for reviewers)

This PR is stacked on top of #73384 (which is also open and MERGEABLE).

Until #73384 merges, GitHub shows the combined diff against main:

Files Additions Deletions
#73384 (base) 50 +5937 -45
THIS PR (delta on top) 29 +1690 -13
Combined view (current) 75 +7505 -67
After #73384 merges 29 +1690 -13

The 29 files / +1690 LOC delta is the actual review surface for this PR. It contains zero overlap with #73384's commits — every change here is either net-new (9 new files) or extends a different region of a file that #73384 also modifies. There will be no rebase conflicts when #73384 lands.

For an isolated diff of only this PR's changes, see the fork-internal stacked PR at https://github.com/100yenadmin/openclaw-1/pull/19 (same branch, base = #73384 head). That view shows only the 29-file / +1690 delta.

The CONFLICTING mergeable status is expected — it resolves to MERGEABLE automatically the moment #73384 merges, since this branch is literally one commit (ed56347a0c) on top of #73384's current HEAD (16d38b48dd).

Suggested review order:

  1. Read the PR description top-to-bottom (six seams, each with what / why / how / compat / files + a diagram).
  2. Filter the file diff in GitHub to the 9 NEW files first (clean reads — no need to mentally diff against [plugin sdk] Consolidate workflow seams and fixtures #73384's surface):
    • src/plugins/host-runtime-namespace.ts + test
    • src/agents/apply-patch-paths.ts + test
    • src/plugins/host-tool-param-parsers.ts + test
    • src/plugins/contracts/host-runtime-reads.contract.test.ts
    • src/plugins/contracts/plan-mode-followups.contract.test.ts
    • extensions/telegram/api.test.ts
  3. Then the 20 EXTENDED files — each one's per-seam attribution is in the file-group changelog table at the bottom of the PR description.

@greptile-apps

greptile-apps Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds six additive SDK seams (host runtime context, channel-typed attachment hints, suppressHostInputWhile, scheduler tag taxonomy, session-extension slot promotion, and Telegram api re-exports) as a stacked follow-up to #73384. The implementation is well-structured and backward-compatible, with 52 new tests and no existing API removals.

  • P1 — missing reservation check for sessionEntrySlotKey: patchPluginSessionExtension writes entryRecord[slotKey] directly without validating that slotKey doesn't collide with a core SessionEntry field. The PR description says "validated at registration time" but registerSessionExtension in registry.ts has no such check for the new field, leaving a data-corruption path open.
  • P2 — silent name+tag interaction in scheduleSessionTurn: when both name and tag are provided, the explicit name wins and the tag is quietly discarded, making unscheduleSessionTurnsByTag unable to find the job.
  • P2 — 5 s hard cap in waitForTerminalEventHandlers: the replacement of unbounded Promise.allSettled with a 5-second race can silently abandon long-running cleanup handlers with no diagnostic emitted.

Confidence Score: 4/5

Safe to merge after addressing the sessionEntrySlotKey reservation gap; the remaining findings are non-blocking P2 improvements.

A P1 is present: sessionEntrySlotKey can silently overwrite core SessionEntry fields because the registration-time validation described in the PR description was not implemented. This is isolated to bundled plugins and requires a developer mistake to trigger, but the fix is straightforward. No P0 security or crash-inducing issues were found.

src/plugins/host-hook-state.ts (slot key reservation) and src/plugins/host-hook-workflow.ts (name+tag interaction).

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/host-hook-state.ts
Line: 473-503

Comment:
**`sessionEntrySlotKey` can overwrite reserved `SessionEntry` fields**

`patchPluginSessionExtension` writes directly to `entryRecord[slotKey]` without checking whether `slotKey` conflicts with an existing core `SessionEntry` field such as the session identifier, `updatedAt`, or `pluginExtensions`. The PR description states "The slot key + schema are validated at registration time," but the `registerSessionExtension` path in `registry.ts` has no reservation check for the new `sessionEntrySlotKey` field. A plugin that accidentally (or intentionally) names its slot after a reserved field will silently corrupt session data on every patch call.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/host-hook-workflow.ts
Line: 395-401

Comment:
**Explicit `name` silently disables tag-based cleanup**

When a caller provides both `name` and `tag`, the explicit `name` wins and the job is stored under a custom cron name that does NOT match the `plugin:${pluginId}:tag:${tag}:${sessionKey}:` prefix pattern. As a result, `unscheduleSessionTurnsByTag` will never find this job — the `tag` field is silently discarded. Either reject the combination or document that `tag` has no effect when `name` is set.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/host-hook-runtime.ts
Line: 93-113

Comment:
**5-second hard timeout can silently truncate in-flight cleanup**

`waitForTerminalEventHandlers` replaces an unbounded `Promise.allSettled` with a 5-second race. Any plugin event handler that takes longer than `PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS` (5 s) will be abandoned without any log at the call-site. The timeout fires, `clearPluginRunContext` runs, and the in-flight handler continues operating against an already-cleared context. Consider emitting a warning when the timeout wins so operators can identify slow handlers.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plugin-sdk): plan-mode parity follo..." | Re-trigger Greptile

Comment on lines 473 to +503
@@ -452,12 +500,62 @@ export async function patchPluginSessionExtension(params: {
} else {
delete entry.pluginExtensions;
}
if (slotKey) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 sessionEntrySlotKey can overwrite reserved SessionEntry fields

patchPluginSessionExtension writes directly to entryRecord[slotKey] without checking whether slotKey conflicts with an existing core SessionEntry field such as the session identifier, updatedAt, or pluginExtensions. The PR description states "The slot key + schema are validated at registration time," but the registerSessionExtension path in registry.ts has no reservation check for the new sessionEntrySlotKey field. A plugin that accidentally (or intentionally) names its slot after a reserved field will silently corrupt session data on every patch call.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/host-hook-state.ts
Line: 473-503

Comment:
**`sessionEntrySlotKey` can overwrite reserved `SessionEntry` fields**

`patchPluginSessionExtension` writes directly to `entryRecord[slotKey]` without checking whether `slotKey` conflicts with an existing core `SessionEntry` field such as the session identifier, `updatedAt`, or `pluginExtensions`. The PR description states "The slot key + schema are validated at registration time," but the `registerSessionExtension` path in `registry.ts` has no reservation check for the new `sessionEntrySlotKey` field. A plugin that accidentally (or intentionally) names its slot after a reserved field will silently corrupt session data on every patch call.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +395 to +401
}
const schedule = resolveSchedule(params.schedule);
if (!schedule) {
return undefined;
}
const rawDeliveryMode = (params.schedule as { deliveryMode?: unknown }).deliveryMode;
const deliveryMode = resolveSessionTurnDeliveryMode(rawDeliveryMode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Explicit name silently disables tag-based cleanup

When a caller provides both name and tag, the explicit name wins and the job is stored under a custom cron name that does NOT match the plugin:${pluginId}:tag:${tag}:${sessionKey}: prefix pattern. As a result, unscheduleSessionTurnsByTag will never find this job — the tag field is silently discarded. Either reject the combination or document that tag has no effect when name is set.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/host-hook-workflow.ts
Line: 395-401

Comment:
**Explicit `name` silently disables tag-based cleanup**

When a caller provides both `name` and `tag`, the explicit `name` wins and the job is stored under a custom cron name that does NOT match the `plugin:${pluginId}:tag:${tag}:${sessionKey}:` prefix pattern. As a result, `unscheduleSessionTurnsByTag` will never find this job — the `tag` field is silently discarded. Either reject the combination or document that `tag` has no effect when `name` is set.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 93 to +113
}

function waitForTerminalEventHandlers(pendingHandlers: Set<Promise<void>>): Promise<void> {
if (pendingHandlers.size === 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const timeout = setTimeout(resolveOnce, PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS);
timeout.unref?.();

function resolveOnce() {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve();
}

void Promise.allSettled(pendingHandlers).then(resolveOnce);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 5-second hard timeout can silently truncate in-flight cleanup

waitForTerminalEventHandlers replaces an unbounded Promise.allSettled with a 5-second race. Any plugin event handler that takes longer than PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS (5 s) will be abandoned without any log at the call-site. The timeout fires, clearPluginRunContext runs, and the in-flight handler continues operating against an already-cleared context. Consider emitting a warning when the timeout wins so operators can identify slow handlers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/host-hook-runtime.ts
Line: 93-113

Comment:
**5-second hard timeout can silently truncate in-flight cleanup**

`waitForTerminalEventHandlers` replaces an unbounded `Promise.allSettled` with a 5-second race. Any plugin event handler that takes longer than `PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS` (5 s) will be abandoned without any log at the call-site. The timeout fires, `clearPluginRunContext` runs, and the in-flight handler continues operating against an already-cleared context. Consider emitting a warning when the timeout wins so operators can identify slow handlers.

How can I resolve this? If you propose a fix, please make it concise.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the plugin SDK + host-hook workflow surfaces to close parity gaps needed by “plan-mode”-class plugins running out-of-process, adding new workflow APIs (session actions, attachment hints, tagged scheduling cleanup), host runtime read seams, and additional test/contract coverage across core, gateway protocol, and a few extension entrypoints.

Changes:

  • Add host workflow seams: plugin session actions (Gateway-routed), bundled-only session attachments, and tagged session-turn scheduling/unscheduling.
  • Add host runtime read surfaces: reserved _host.* run-context snapshot reads + tool-param derivations (e.g. apply_patch derivedPaths).
  • Expand fixtures/contracts and wire-through exports/codegen (gateway schemas + Swift models), plus a few packaging/testing script updates.

Reviewed changes

Copilot reviewed 75 out of 75 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/vitest-scoped-config.test.ts Updates private plugin-sdk subpath expectations for scoped vitest config.
test/scripts/npm-telegram-live.test.ts Adjusts E2E script assertions for QA channel mounting approach.
test/scripts/lint-suppressions.test.ts Updates lint suppression inventory for new/relocated type-parameter suppressions.
src/security/windows-acl.test.ts Resets modules before dynamic imports to stabilize test isolation.
src/secrets/target-registry.fast-path.test.ts Updates expectation to pass installRuntimeDeps: false.
src/secrets/channel-contract-api.ts Ensures bundled channel artifact loads can skip runtime dep install.
src/secrets/channel-contract-api.fast-path.test.ts Updates expectations to pass installRuntimeDeps: false.
src/plugins/types.ts Expands public plugin API/types (emit events, session actions, attachments, scheduling).
src/plugins/trusted-tool-policy.ts Adds getSessionExtension helper + guards for params rewrites in trusted policies.
src/plugins/tool-types.ts Adds runId and getHostRunContext to plugin tool context typing.
src/plugins/runtime.ts Moves agent-event unsubscribe handle into shared runtime state.
src/plugins/runtime-state.ts Adds agentEventUnsubscribe field to registry runtime state.
src/plugins/registry.ts Wires new workflow APIs (emit events, schedule/unschedule, attachments, session actions) and validates session-action schemas.
src/plugins/registry-types.ts Adds sessionActions registry contribution type/slot.
src/plugins/registry-empty.ts Initializes empty registry sessionActions array.
src/plugins/public-surface-loader.ts Adds installRuntimeDeps?: boolean to bundled public artifact loader.
src/plugins/plugin-lookup-table.test.ts Mocks new config-presence helper listExplicitlyDisabledChannelIdsForConfig.
src/plugins/host-tool-param-parsers.ts Adds host-owned tool param derivation registry (initial: apply_patch paths).
src/plugins/host-tool-param-parsers.test.ts Unit tests for tool param derivation dispatch.
src/plugins/host-runtime-namespace.ts Defines reserved _host.* namespace constants/types + assertions.
src/plugins/host-runtime-namespace.test.ts Unit tests for reserved namespace helpers.
src/plugins/host-hooks.ts Adds/extends host-hook types (UI descriptor fields, session actions, attachments, scheduling, slot promotion metadata).
src/plugins/host-hook-workflow.ts Implements workflow seams: agent-event emit helper, bundled-only attachments, tagged scheduling + unschedule-by-tag, delivery hint resolver.
src/plugins/host-hook-turn-types.ts Adds priority to next-turn injection type.
src/plugins/host-hook-state.ts Adds injection priority ordering + session-extension slot mirroring + sync session extension reads.
src/plugins/host-hook-runtime.ts Adds host run-context snapshot reads (getHostRunContext) + _host.* write protection + bounded terminal handler wait + scheduler cleanup knobs.
src/plugins/host-hook-cleanup.ts Enhances cleanup to avoid double-cleaning scheduler jobs and supports optional cfg resolution.
src/plugins/hooks.ts Adds hook-runner context mapping hook to enrich contexts (e.g., session-extension reads).
src/plugins/hooks.security.test.ts Adds test coverage for memoized session-extension reads in hook context.
src/plugins/hook-types.ts Extends hook types with derivedPaths, retry fields, getHostRunContext, getSessionExtension.
src/plugins/contracts/plan-mode-followups.contract.test.ts Contract coverage for seams B–F (delivery hints, UI descriptor field typing, tags, slot mirroring).
src/plugins/contracts/host-runtime-reads.contract.test.ts Contract coverage for _host.runtime snapshot reads and reserved namespace behavior.
src/plugins/contracts/host-hooks.contract.test.ts Adds broad fixture assertions for new workflow surfaces and bounded terminal cleanup.
src/plugins/contracts/advanced-workflow-fixtures.ts Adds advanced workflow fixtures demonstrating approval/policy/monitor/artifact/retry archetypes.
src/plugins/contracts/advanced-workflow-fixtures.contract.test.ts End-to-end tests for workflow fixtures via gateway + hooks.
src/plugins/captured-registration.ts Captures new workflow APIs in captured registration helper.
src/plugins/bundled-public-surface-runtime-root.ts Allows skipping runtime dependency install when preparing public surface location.
src/plugins/api-builder.ts Extends plugin API builder with new workflow methods + no-op defaults.
src/plugin-sdk/plugin-test-api.ts Updates test plugin API surface for new methods.
src/plugin-sdk/plugin-entry.ts Re-exports new workflow-related types.
src/plugin-sdk/core.ts Re-exports host runtime namespace types/constants.
src/infra/agent-events.ts Adds parent/subagent tracking to AgentRunContext and updates settle/sweep behavior.
src/gateway/server-startup-config.recovery.test.ts Adds mocks for new config path/runtime helpers used during recovery tests.
src/gateway/server-methods/plugin-host-hooks.ts Adds Gateway handler for plugins.sessionAction with schema + result validation.
src/gateway/server-methods-list.ts Registers plugins.sessionAction in base method list.
src/gateway/protocol/schema/types.ts Adds schema type exports for sessionAction params/result.
src/gateway/protocol/schema/protocol-schemas.ts Registers sessionAction schemas in protocol schema map.
src/gateway/protocol/schema/plugins.ts Tightens PluginJsonValue schema and adds sessionAction schemas + UI descriptor fields.
src/gateway/protocol/index.ts Compiles validator for PluginsSessionActionParams.
src/gateway/method-scopes.ts Adds plugins.sessionAction to session-scoped method group.
src/config/sessions/types.ts Adds persisted injection priority field.
src/agents/pi-tools.before-tool-call.ts Stamps derivedPaths onto before_tool_call events + wires getHostRunContext.
src/agents/pi-tools.before-tool-call.integration.e2e.test.ts Updates assertions to tolerate extra context fields.
src/agents/pi-embedded-subscribe.handlers.tools.ts Wires getHostRunContext into after_tool_call hook context.
src/agents/pi-embedded-runner/run.ts Clears finalize-retry budget at run end.
src/agents/openclaw-tools.plugin-context.ts Threads runId and host run-context getter through tool factory inputs.
src/agents/harness/lifecycle-hook-helpers.ts Adds bounded finalize-retry budget keyed by run/idempotency.
src/agents/harness/lifecycle-hook-helpers.test.ts Tests finalize-retry budget keying and clearing.
src/agents/command/types.ts Adds parentRunId to agent command options.
src/agents/cli-runner/prepare.ts Avoids loading history messages when no relevant hooks are registered; clears prompt-build cache.
src/agents/apply-patch-paths.ts Adds forgiving apply_patch destination path extractor.
src/agents/apply-patch-paths.test.ts Unit tests for apply_patch target path extraction.
src/agents/agent-command.ts Registers agent run context with optional parentRunId.
scripts/e2e/npm-telegram-live-docker.sh Changes QA channel mounting to a node_modules symlink instead of package export injection.
extensions/telegram/src/format.ts Exports escapeTelegramHtml (public alias) and keeps internal escape helper.
extensions/telegram/api.ts Re-exports Telegram formatting helpers from the public barrel.
extensions/telegram/api.test.ts Verifies Telegram API barrel re-exports behave correctly.
extensions/qa-lab/src/runtime-api.ts Switches QA channel import to @openclaw/qa-channel/api.js.
extensions/qa-lab/src/lab-server.test.ts Updates mock target for QA channel API import path.
docs/docs.json Adds docs nav entry for host-hook examples.
docs/.i18n/glossary.zh-CN.json Adds translated glossary entries for new docs nav labels.
docs/.generated/plugin-sdk-api-baseline.sha256 Updates SDK API baseline hashes after surface changes.
apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift Updates generated Swift models for UI descriptor fields + sessionAction params/result.
apps/macos/Sources/OpenClawProtocol/GatewayModels.swift Same as shared Swift protocol models (macOS target).
Comments suppressed due to low confidence (1)

src/gateway/protocol/schema/plugins.ts:40

  • If suppressHostInputWhile is intended to be consumed by UI clients via plugins.uiDescriptors, it should be part of the Gateway protocol schema for PluginControlUiDescriptor. Right now the schema only includes renderer/stateNamespace/actionIds/..., so codegen clients won’t see/deserialize suppressHostInputWhile in a typed way. Consider adding a suppressHostInputWhile object schema (matching PluginControlUiSuppressHostInputWhile) to keep the protocol in sync with the plugin SDK types.
export const PluginControlUiDescriptorSchema = Type.Object(
  {
    id: NonEmptyString,
    pluginId: NonEmptyString,
    pluginName: Type.Optional(NonEmptyString),
    surface: Type.Union([
      Type.Literal("session"),
      Type.Literal("tool"),
      Type.Literal("run"),
      Type.Literal("settings"),
    ]),
    label: NonEmptyString,
    description: Type.Optional(Type.String()),
    placement: Type.Optional(Type.String()),
    renderer: Type.Optional(Type.String()),
    stateNamespace: Type.Optional(Type.String()),
    actionIds: Type.Optional(Type.Array(NonEmptyString)),
    schema: Type.Optional(PluginJsonValueSchema),
    requiredScopes: Type.Optional(Type.Array(NonEmptyString)),
  },
  { additionalProperties: false },
);

Comment on lines +20 to +36
const sessionExtensionCache = new Map<string, PluginJsonValue | undefined>();
const policyCtx: PluginHookToolContext = {
...ctx,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Plugin callers type JSON reads by namespace.
getSessionExtension: <T extends PluginJsonValue = PluginJsonValue>(namespace: string) => {
if (sessionExtensionCache.has(namespace)) {
return sessionExtensionCache.get(namespace) as T | undefined;
}
const value = getPluginSessionExtensionSync<T>({
pluginId: registration.pluginId,
sessionKey: ctx.sessionKey,
namespace,
});
sessionExtensionCache.set(namespace, value);
return value;
},
};

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionExtensionCache is created inside the policy loop, so repeated ctx.getSessionExtension(...) reads are only memoized within a single policy’s evaluate() call. If multiple trusted policies for the same plugin read the same namespace, this will re-load/clone session state multiple times per tool call. Consider hoisting the cache outside the for loop (keyed by pluginId+namespace) so the whole trusted-policy chain shares one memoization map per runTrustedToolPolicies invocation.

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +425
// Cron normaliser preserves unknown payload fields via spread; merging
// plugin-supplied extras after the canonical fields lets channels and
// downstream consumers see the combined view without losing the standard
// `kind`/`message` shape.
const payload: Record<string, unknown> = {
...(params.schedule.payloadExtras ?? {}),
kind: "agentTurn",
message,
};

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says payloadExtras are merged after the canonical fields so downstream consumers see the combined view without losing kind/message, but the implementation spreads payloadExtras before setting kind/message. Either update the comment to match the current (safer) behavior (canonical fields override extras), or change the spread order if you truly want extras to win.

Copilot uses AI. Check for mistakes.
Comment thread src/plugins/registry.ts
Comment on lines 1634 to +1707
@@ -1585,6 +1642,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
!controlUiSurfaces.has(surface as PluginControlUiDescriptor["surface"]) ||
description === "" ||
placement === "" ||
renderer === "" ||
stateNamespace === "" ||
actionIds === null ||
requiredScopes === null
) {
pushDiagnostic({
@@ -1642,6 +1702,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
label,
...(description !== undefined ? { description } : {}),
...(placement !== undefined ? { placement } : {}),
...(renderer !== undefined ? { renderer } : {}),
...(stateNamespace !== undefined ? { stateNamespace } : {}),
...(actionIds !== undefined ? { actionIds } : {}),

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PluginControlUiDescriptor now includes suppressHostInputWhile, but the registry’s normalization/validation logic here doesn’t read or persist that field. As a result, plugins can set it at compile time but it will be dropped from the stored descriptor and never reach plugins.uiDescriptors. Add normalization/validation for suppressHostInputWhile and include it in the normalized descriptor payload (or remove the field from the public type if it’s intentionally not supported yet).

Copilot uses AI. Check for mistakes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed56347a0c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +32 to 36
renderer: Type.Optional(Type.String()),
stateNamespace: Type.Optional(Type.String()),
actionIds: Type.Optional(Type.Array(NonEmptyString)),
schema: Type.Optional(PluginJsonValueSchema),
requiredScopes: Type.Optional(Type.Array(NonEmptyString)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add suppressHostInputWhile to protocol descriptor schema

PluginControlUiDescriptor now supports suppressHostInputWhile (src/plugins/host-hooks.ts), but the Gateway wire schema for plugins.uiDescriptors never exposes that field. Because PluginControlUiDescriptorSchema is additionalProperties: false, generated clients (including Swift models) cannot decode/use this hint, so the new UI-input suppression seam is effectively dropped for Gateway consumers even when plugins register it.

Useful? React with 👍 / 👎.

Comment on lines +251 to +255
result = await sendMessage({
to: deliveryContext.to,
content: text,
channel: deliveryContext.channel,
accountId: deliveryContext.accountId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate resolved parseMode in attachment sends

sendPluginSessionAttachment computes resolvedDelivery.parseMode from captionFormat/channelHints, but the value is never forwarded in the sendMessage(...) payload. In practice, plugins can set captionFormat or Telegram channelHints.parseMode and still get default formatting, so the new caption-format hint API is a no-op for delivery behavior.

Useful? React with 👍 / 👎.

@clawsweeper

clawsweeper Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

Keep open. Current main still has the merged generic host-hook foundation but does not contain this PR’s follow-up SDK seams: host runtime reads, derived apply_patch paths, typed session workflow actions, channel-typed attachment delivery, tagged scheduled turns, session-entry slot promotion, or Telegram formatter re-exports. The PR is also intentionally stacked behind open #73384, carries active review comments on public SDK/Gateway behavior, and touches security-sensitive scheduler/outbound/protocol surfaces, so it needs maintainer review rather than cleanup closure.

Maintainer follow-up before merge:

Keep this PR open. Resolve or land #73384 first, then rebase or refile this follow-up onto current main, review the unique follow-up diff, and address the active SDK/Gateway/security-sensitive review comments before any merge or supersession decision.

Best possible solution:

Keep this PR open. Resolve or land #73384 first, then rebase or refile this follow-up onto current main, review the unique follow-up diff, and address the active SDK/Gateway/security-sensitive review comments before any merge or supersession decision.

Acceptance criteria:

  • pnpm test src/plugins/contracts/host-runtime-reads.contract.test.ts src/plugins/contracts/plan-mode-followups.contract.test.ts src/plugins/contracts/advanced-workflow-fixtures.contract.test.ts extensions/telegram/api.test.ts test/scripts/npm-telegram-live.test.ts test/vitest-scoped-config.test.ts
  • pnpm plugin-sdk:api:gen/check
  • Gateway protocol/client generation check used by this repo
  • pnpm check:changed in Testbox after rebasing

What I checked:

  • Current main verified: Checkout is on main at d33c3f7 with no worktree diff; latest commit is perf(catalog): cache manifest built-in model suppression resolver (perf(catalog): cache manifest built-in model suppression resolver #74236). (d33c3f7da651)
  • Plugin API follow-up methods absent: OpenClawPluginApi exposes registerSessionExtension, enqueueNextTurnInjection, trusted policies, control UI descriptors, event subscriptions, run-context helpers, and registerSessionSchedulerJob, but not this PR's registerSessionAction, sendSessionAttachment, scheduleSessionTurn, unscheduleSessionTurnsByTag, emitAgentEvent, or host-run-context methods. (src/plugins/types.ts:2376, d33c3f7da651)
  • Tool hook parity seams absent: PluginHookToolContext and PluginHookBeforeToolCallEvent contain only the existing tool/session/run fields; there is no getHostRunContext, getSessionExtension, or derivedPaths field on current main. (src/plugins/hook-types.ts:393, d33c3f7da651)
  • before_tool_call does not derive apply_patch paths: runBeforeToolCallHook normalizes params and invokes trusted policies/hooks without deriveToolParams or an apply_patch path extractor; the proposed host-tool-param parser files are also absent from current main. (src/agents/pi-tools.before-tool-call.ts:473, d33c3f7da651)
  • Gateway session action surface absent: pluginHostHookHandlers only implements plugins.uiDescriptors, and the plugin protocol schema only describes UI descriptors; plugins.sessionAction and its params/result schemas are not present. (src/gateway/server-methods/plugin-host-hooks.ts:10, d33c3f7da651)
  • Descriptor, session slot, and Telegram export follow-ups absent: PluginSessionExtensionRegistration lacks sessionEntrySlotKey/sessionEntrySlotSchema, PluginControlUiDescriptor lacks suppressHostInputWhile, and extensions/telegram/api.ts does not re-export markdownToTelegramHtml or escapeTelegramHtml from format.ts. (src/plugins/host-hooks.ts:37, d33c3f7da651)

Likely related people:

  • 100yenadmin: Provided PR context identifies this person as the author of merged [plugin sdk] Add generic plugin host-hook contracts #72287, the generic host-hook foundation on current main, and author of open [plugin sdk] Consolidate workflow seams and fixtures #73384, the workflow base this PR is stacked on. That ties them to the feature history beyond merely opening this PR. (role: introduced behavior / adjacent SDK stack author; confidence: high; commits: 68e5f2ce1913, b65894cd1c41, ed56347a0cb3; files: src/plugins/types.ts, src/plugins/host-hooks.ts, src/plugins/host-hook-runtime.ts)
  • Peter Steinberger: The local checkout's available history/blame attributes the current central host-hook files to dc81043, and recent local history shows adjacent gateway/plugin maintenance on current main. (role: recent maintainer / current-main path provenance; confidence: medium; commits: dc810437e767, 146c0a7e1d3d, 8d58ad4c15cd; files: src/plugins/types.ts, src/plugins/host-hooks.ts, src/plugins/host-hook-runtime.ts)
  • Vincent Koc: Recent current-main history in plugin and gateway paths includes fixes and tests around plugin config aliases, malformed channel registrations, gateway dispatch, and external install contract coverage, making this a plausible adjacent routing candidate for SDK/Gateway review. (role: recent adjacent plugin/gateway maintainer; confidence: medium; commits: 412434a450be, 4b99724a9cb8, a3519e362ff1; files: src/plugins, src/plugin-sdk, src/gateway/server-methods)

Remaining risk / open question:

  • This PR is an active public SDK/Gateway protocol expansion, not an obsolete PR. Closing it now would lose work that current main does not implement.
  • The PR diff touches scheduler, outbound attachment delivery, Gateway protocol schemas/generated clients, Docker/E2E packaging scripts, and plugin public surfaces. Those are security/supply-chain-sensitive paths that need normal maintainer review after the base stack is resolved.
  • Provided review comments identify unresolved concrete defects or contract gaps, including possible session-entry field corruption via sessionEntrySlotKey, tag/name scheduler cleanup ambiguity, missing suppressHostInputWhile protocol propagation, attachment parseMode not being forwarded, and remaining private SDK subpath drift.

Codex review notes: model gpt-5.5, reasoning high; reviewed against d33c3f7da651.

@100yenadmin
100yenadmin requested a review from Copilot April 29, 2026 06:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 75 out of 75 changed files in this pull request and generated 3 comments.

Comment thread src/plugins/host-hook-state.ts
Comment thread test/vitest-scoped-config.test.ts
Comment thread extensions/qa-lab/src/runtime-api.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 75 out of 75 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/plugins/registry.ts:1711

  • registerControlUiDescriptor normalizes/persists renderer, stateNamespace, and actionIds, but it currently drops descriptor.suppressHostInputWhile entirely. As a result, plugins can set the field (per the public type) but it will never reach plugins.uiDescriptors consumers. Please either plumb + validate suppressHostInputWhile here (and persist it into the registry descriptor), or remove it from the public descriptor type until the host/UI pipeline supports it end-to-end.

Comment thread src/gateway/protocol/schema/plugins.ts
Comment thread src/plugins/host-hook-workflow.ts
Comment thread extensions/qa-lab/src/runtime-api.ts
Comment thread src/agents/apply-patch-paths.ts
@100yenadmin 100yenadmin changed the title [plugin sdk] plan-mode parity follow-ups: 6 seams stacked on #73384 [plugin sdk] sdk seam followups 6 seams stacked on #73384 Apr 29, 2026

Copy link
Copy Markdown

Stack status update: this PR is intentionally stacked behind #73384 and should wait for that base PR before final merge-readiness work.

GitHub currently shows this branch against main, so the visible diff/conflict state includes inherited #73384 context and can produce noisy review comments outside the unique follow-up surface. I am not going to keep chasing moving-main drift on this branch while #73384 is still open.

I updated the PR description in place, without rewriting it, to clarify:

  • this is generic plugin SDK work for approval, deployment, budget, SLA, review-assistant, and channel-native workflow plugins;
  • Plan Mode parity is only the internal oracle used to prove the seams are sufficient;
  • no Plan Mode product code, prompts, /plan behavior, or product-specific UI belongs here;
  • after [plugin sdk] Consolidate workflow seams and fixtures #73384 lands, this branch should be rebased or refiled onto fresh main, then validated and reviewed against only the unique follow-up diff.

@vincentkoc

vincentkoc commented Apr 29, 2026

Copy link
Copy Markdown
Member

DO NOT DO THIS. STACKING PRS. You had reached a limit, this is bad! You have been warned.
Appreciate the contribution, but no one will look at this.

@vincentkoc vincentkoc closed this Apr 29, 2026
@100yenadmin

100yenadmin commented Apr 29, 2026

Copy link
Copy Markdown
Contributor Author

@vincentkoc I'd like to collaborate and communicate more positively. Sent you an email. I understand you are busy but we've never communicated vs other maintainers or collaborated on issue/fix. For reference, I'm not a 🐕 or a clanker. DON'T DO THAT! BAD! WARN! Are not effective for mentorship or collaboration.

I’d also appreciate keeping feedback actionable and specific where possible; that makes it much easier for me to correct course quickly and understand. This is a drafted follow on to #72287 and #73384. Happy to rebase these followups onto current main and refile as standalone PRs if that would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling app: macos App: macos app: web-ui App: web-ui channel: telegram Channel integration: telegram docker Docker and sandbox tooling docs Improvements or additions to documentation extensions: qa-lab gateway Gateway runtime scripts Repository scripts size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants