Skip to content

RFC: sessions.patch plugin extension hook \u2014 let plugins handle their own patch payloads #71426

Description

@100yenadmin

Problem statement

Third-party plugins that need to react to sessions.patch RPCs (UI inline-button events, /command-style mutations from any channel) currently have to patch the gateway's applySessionsPatchToStore handler at src/gateway/sessions-patch.ts:89 to add new branches. This is brittle:

  • Every host version bump requires re-baselining the patch
  • The patch grows monotonically as the plugin adds features (Smarter-Claw's plan-mode patch is now ~500 lines)
  • Multiple plugins patching the same handler will conflict
  • Schema changes require a parallel patch to src/gateway/protocol/schema/sessions.ts

Smarter-Claw is the proof case: we maintain installer/patches/core/sessions-patch-handler-plan-mode.diff (500 LOC) and installer/patches/core/sessions-patch-schema-plan-mode.diff (~70 LOC) just to wire the UI's Approve/Reject/Edit/Auto/Answer button payloads through to plugin state. A clean SDK seam would let us delete both patches.

Proposed change

Add an extension field to the sessions.patch payload + a plugin-SDK callback for handling it.

Schema (additive)

// src/gateway/protocol/schema/sessions.ts
export const sessionsPatchPayload = Type.Object({
  key: NonEmptyString,
  // ...existing fields...
  extension: Type.Optional(
    Type.Object(
      {
        plugin: NonEmptyString,                          // "smarter-claw"
        action: NonEmptyString,                          // "approve" / "reject" / "answer"
        payload: Type.Unknown(),                         // plugin-validated
      },
      { additionalProperties: false },
    ),
  ),
});

Plugin SDK seam (additive)

// src/plugin-sdk/sessions-patch.ts (NEW or extend existing)
export type SessionsPatchExtensionHandler = (input: {
  key: string;                                   // sessionKey
  agentId: string;
  entry: SessionEntry;                           // current entry (post any non-extension patch fields)
  payload: unknown;                              // raw plugin payload
}) => Promise<
  | { ok: true; entryPatch?: Partial<SessionEntry> }
  | { ok: false; error: string }
>;

// Plugin manifest declares the extension handler:
export const plugin = definePluginEntry({
  // ...existing...
  sessionsPatchExtensions: {
    "approve": myApproveHandler,
    "reject": myRejectHandler,
    "answer": myAnswerHandler,
  },
});

Handler dispatch (gateway-side, ~20 LOC)

// src/gateway/sessions-patch.ts
if (patch.extension) {
  const plugin = pluginRegistry.get(patch.extension.plugin);
  if (!plugin?.sessionsPatchExtensions?.[patch.extension.action]) {
    return invalid(`unknown extension: ${patch.extension.plugin}.${patch.extension.action}`);
  }
  const result = await plugin.sessionsPatchExtensions[patch.extension.action]({
    key, agentId, entry: next, payload: patch.extension.payload,
  });
  if (!result.ok) return invalid(result.error);
  if (result.entryPatch) Object.assign(next, result.entryPatch);
}

Backward compatibility

  • Fully additive — clients without extension field unaffected
  • Existing plugins that patch the handler directly continue to work
  • New plugins use the SDK seam instead

What this saves Smarter-Claw

  • Delete installer/patches/core/sessions-patch-handler-plan-mode.diff (500 LOC)
  • Delete installer/patches/core/sessions-patch-schema-plan-mode.diff (~70 LOC)
  • Move ~570 LOC from a brittle host patch into typed plugin code
  • Eliminate version-bump fragility (no more SHA pinning per host upgrade for these patches)

Generalizes to any plugin that wants to react to UI button events / channel-routed mutations.

Vocabulary alignment

The payload: Type.Unknown() shape lets each plugin own its own payload schema (validated inside the handler). This avoids leaking plugin-specific vocabulary like "approve"/"reject"/"answer" into the gateway protocol — keeps core extension-agnostic per the AGENTS.md architecture rule.

Tests proposed

  • Round-trip: client sends extension: { plugin: "test-plugin", action: "noop", payload: {x:1} } → plugin handler receives raw payload → returns ok with entryPatch → next entry contains patch
  • Unknown plugin → 400 with clear error
  • Unknown action for known plugin → 400
  • Plugin handler throws → 500 surfaced cleanly (not silent)
  • Plugin returns ok=false → mutation rejected, error surfaced

Cross-link

Smarter-Claw architecture impact: electricsheephq/Smarter-Claw#44, #45, #46. Once this RFC lands + Smarter-Claw migrates, the entire sessions-patch-handler-plan-mode.diff retires.

This is one of several RFCs Smarter-Claw is filing to push the plugin/host seam upstream. Sister RFC: #71260 (mergeSessionEntryWithPolicy "merge-plugin-metadata").

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions