Skip to content

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

@100yenadmin

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions