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").
Problem statement
Third-party plugins that need to react to
sessions.patchRPCs (UI inline-button events, /command-style mutations from any channel) currently have to patch the gateway'sapplySessionsPatchToStorehandler atsrc/gateway/sessions-patch.ts:89to add new branches. This is brittle:src/gateway/protocol/schema/sessions.tsSmarter-Claw is the proof case: we maintain
installer/patches/core/sessions-patch-handler-plan-mode.diff(500 LOC) andinstaller/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
extensionfield to thesessions.patchpayload + a plugin-SDK callback for handling it.Schema (additive)
Plugin SDK seam (additive)
Handler dispatch (gateway-side, ~20 LOC)
Backward compatibility
extensionfield unaffectedWhat this saves Smarter-Claw
installer/patches/core/sessions-patch-handler-plan-mode.diff(500 LOC)installer/patches/core/sessions-patch-schema-plan-mode.diff(~70 LOC)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
extension: { plugin: "test-plugin", action: "noop", payload: {x:1} }→ plugin handler receives raw payload → returns ok with entryPatch → next entry contains patchCross-link
Smarter-Claw architecture impact:
electricsheephq/Smarter-Claw#44,#45,#46. Once this RFC lands + Smarter-Claw migrates, the entiresessions-patch-handler-plan-mode.diffretires.This is one of several RFCs Smarter-Claw is filing to push the plugin/host seam upstream. Sister RFC: #71260 (mergeSessionEntryWithPolicy "merge-plugin-metadata").