Skip to content

Commit 66d31b2

Browse files
idoubiclaude
andcommitted
feat(plugins): hook system end-to-end — chat.send + per-agent opt-in + Plugins tab
Hook plugins were defined in the protocol but never actually wired — RegisterPluginHooks had zero callers and there was no way for a plugin to push a follow-up message back to the chat it received a hook for. This change makes the whole loop work and adds a UI for operators to control which agents each plugin attaches to. ## Protocol — chat.send New method `chat.send` (plugin → fastclaw notification) that delivers a fresh OutboundMessage to a specific chat. The plugin manager turns it into a bus.OutboundMessage with optional MediaItems (base64-encoded inline) and pushes onto bus.Outbound — same path the agent's own reply takes, so the chatter sees a second bubble. Unlike message.inbound (which spawns another agent turn), chat.send skips the agent entirely. HookFireParams gains Channel + AccountID so plugins receiving a hook event have the full bus routing triple needed to echo back to chat.send. Populated at every hooks.Run callsite in the agent loop from msg.Channel / msg.AccountID. ## Ordering vs the agent's main reply PostTurn hooks fire while the agent loop is still finishing, so a fast plugin could win the bus.Outbound enqueue race and the chatter would see the plugin's follow-up before the agent's actual reply. chat.send dispatch is now async with a small default 50ms delay (tunable via FASTCLAW_PLUGIN_CHAT_SEND_DELAY_MS) — gives the gateway's microsecond-scale bus push a head start. Channel adapters serialize sends per account, so bus enqueue order = user-visible order. ## Wiring + per-agent enable `registerHookPluginsForAgent` (new) attaches a plugin's hooks to ONE agent's HookRegistry. Called from loadUserSpace (owner path) AND EnsureAgent (foreign-attach path) so chatters reaching an agent via channel binding get the same hooks the owner does. Default is OPT-IN: a plugin enabled system-wide only means its process runs and is available to attach. Each agent must individually set `plugins.enabled[<id>] = true` (via the dashboard Plugins tab or directly in the configs table) for hooks to fire on its turns. Rationale: hook plugins can change agent behavior in surprising ways (extra messages, modified prompts, recorded conversation data). Default-deny avoids accidentally affecting agents the operator didn't intend. UserSpace gains a borrowed PluginMgr pointer so EnsureAgent doesn't need to reach back to the gateway for it. ## Dashboard - New `GET /api/plugins/hook` endpoint: read-only metadata listing of hook-type plugins (id / name / description / version). Not admin-gated — agent owners need it to populate the Plugins tab. - handleUpdateAgent: accepts `plugins` map + `pluginsReset` flag; GET returns `plugins` per-agent overlay. Patch semantics so flipping one plugin doesn't clobber overrides for siblings. - New dedicated Plugins tab in agent-settings-dialog (Plug icon), positioned between Skills and Channels — mirrors the Skills page layout (header + grid of cards). Replaces the inline card on the Context page (now removed; Context only carries Multi-bubble + Auto-remember). - HookPlugin TypeScript type + listHookPlugins() client. ## Demo `plugins/post-turn-echo-demo/` — minimal Python plugin (~100 lines) that registers on PostTurn and ships a fixed follow-up text via chat.send. Skeleton for richer plugins (audio replies, translations, CRM sync, etc). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25ca090 commit 66d31b2

15 files changed

Lines changed: 734 additions & 11 deletions

File tree

internal/agent/hooks.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ type HookContext struct {
3737
Workspace string // agent workspace path (for PostTurn)
3838
UserID string // owning user ID for multi-user namespace isolation
3939
ChatID string // used by the plugin hook adapter
40+
// Channel + AccountID complete the bus routing triple. Plugins
41+
// reading these in a hook.fire payload can echo them back to
42+
// chat.send so a follow-up message reaches the same chat that
43+
// just got the agent's reply.
44+
Channel string
45+
AccountID string
4046
// Source mirrors bus.InboundMessage.Source so PostTurn hooks can
4147
// distinguish a real user turn from a cron / heartbeat / sub-agent
4248
// / goal-context turn. Empty means user. Hooks that should only

internal/agent/loop.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,7 +1917,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
19171917
)
19181918

19191919
// Hook: BeforeModelCall
1920-
hcBefore := &HookContext{AgentName: a.name, Point: BeforeModelCall, Messages: messages, ChatID: msg.ChatID, UserID: a.ownerUserID}
1920+
hcBefore := &HookContext{AgentName: a.name, Point: BeforeModelCall, Messages: messages, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID}
19211921
a.hooks.Run(ctx, hcBefore)
19221922

19231923
// PII scrubbing: redact sensitive data before sending to LLM
@@ -1955,7 +1955,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
19551955
resp, err := a.streamChatToResponse(ctx, llmMessages, callTools)
19561956

19571957
// Hook: AfterModelCall
1958-
hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()}
1958+
hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()}
19591959
a.hooks.Run(ctx, hcAfter)
19601960

19611961
if err != nil {
@@ -2057,6 +2057,9 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
20572057
Point: BeforeToolCall,
20582058
ToolName: tc.Function.Name,
20592059
ToolArgs: tc.Function.Arguments,
2060+
Channel: msg.Channel,
2061+
AccountID: msg.AccountID,
2062+
ChatID: msg.ChatID,
20602063
UserID: a.ownerUserID,
20612064
})
20622065
}
@@ -2147,6 +2150,9 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
21472150
ToolName: r.toolName,
21482151
ToolResult: resultContent,
21492152
Error: r.err,
2153+
Channel: msg.Channel,
2154+
AccountID: msg.AccountID,
2155+
ChatID: msg.ChatID,
21502156
UserID: a.ownerUserID,
21512157
GoalSessionKey: a.registry.GoalSessionKey(),
21522158
IsPlanMode: isPlanMode(msg.Params),
@@ -2418,6 +2424,8 @@ func (a *Agent) runPostTurn(ctx context.Context, msg bus.InboundMessage, message
24182424
ToolCallCount: toolCallCount,
24192425
Workspace: a.homePath,
24202426
UserID: a.ownerUserID,
2427+
Channel: msg.Channel,
2428+
AccountID: msg.AccountID,
24212429
ChatID: msg.ChatID,
24222430
Source: msg.Source,
24232431
GoalSessionKey: a.registry.GoalSessionKey(),
@@ -2578,13 +2586,13 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
25782586

25792587
// ReAct loop - use Chat for tool iterations
25802588
for i := 0; i < a.maxToolIterations; i++ {
2581-
hcBefore := &HookContext{AgentName: a.name, Point: BeforeModelCall, Messages: messages, ChatID: msg.ChatID, UserID: a.ownerUserID}
2589+
hcBefore := &HookContext{AgentName: a.name, Point: BeforeModelCall, Messages: messages, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID}
25822590
a.hooks.Run(ctx, hcBefore)
25832591

25842592
dumpLLMRequest(a.name, a.model, messages, toolDefs)
25852593
resp, err := a.provider.Chat(ctx, messages, toolDefs, a.model, a.maxTokens, a.temperature)
25862594

2587-
hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()}
2595+
hcAfter := &HookContext{AgentName: a.name, Point: AfterModelCall, Messages: messages, Response: resp, Error: err, StartTime: hcBefore.StartTime, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey()}
25882596
a.hooks.Run(ctx, hcAfter)
25892597

25902598
if err != nil {
@@ -2721,7 +2729,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
27212729

27222730
// Fire BeforeToolCall hooks
27232731
for _, tc := range resp.ToolCalls {
2724-
a.hooks.Run(ctx, &HookContext{AgentName: a.name, Point: BeforeToolCall, ToolName: tc.Function.Name, ToolArgs: tc.Function.Arguments, UserID: a.ownerUserID})
2732+
a.hooks.Run(ctx, &HookContext{AgentName: a.name, Point: BeforeToolCall, ToolName: tc.Function.Name, ToolArgs: tc.Function.Arguments, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID})
27252733
}
27262734

27272735
// Execute tools concurrently via SDK engine
@@ -2731,7 +2739,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
27312739
for idx, r := range results {
27322740
tc := resp.ToolCalls[idx]
27332741
resultContent, meta := extractToolMeta(r.result)
2734-
a.hooks.Run(ctx, &HookContext{AgentName: a.name, Point: AfterToolCall, ToolName: r.toolName, ToolResult: resultContent, Error: r.err, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey(), IsPlanMode: isPlanMode(msg.Params), Source: msg.Source})
2742+
a.hooks.Run(ctx, &HookContext{AgentName: a.name, Point: AfterToolCall, ToolName: r.toolName, ToolResult: resultContent, Error: r.err, Channel: msg.Channel, AccountID: msg.AccountID, ChatID: msg.ChatID, UserID: a.ownerUserID, GoalSessionKey: a.registry.GoalSessionKey(), IsPlanMode: isPlanMode(msg.Params), Source: msg.Source})
27352743

27362744
if r.err != nil {
27372745
slog.Warn("tool execution error", "agent", a.name, "name", r.toolName, "error", r.err)

internal/gateway/gateway.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ func New(env *config.EnvConfig) (*Gateway, error) {
352352
workspace: ws,
353353
usage: meter,
354354
sandboxPool: systemSandboxPool,
355-
users: newUserSpaceRegistry(mb, st, ws, meter, systemSandboxPool),
355+
users: newUserSpaceRegistry(mb, st, ws, meter, systemSandboxPool, pluginMgr),
356356
chanMgr: chanMgr,
357357
webChan: webChan,
358358
scheduler: scheduler,

internal/gateway/userspace.go

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/fastclaw-ai/fastclaw/internal/agent"
1515
"github.com/fastclaw-ai/fastclaw/internal/bus"
1616
"github.com/fastclaw-ai/fastclaw/internal/config"
17+
"github.com/fastclaw-ai/fastclaw/internal/plugin"
1718
"github.com/fastclaw-ai/fastclaw/internal/provider"
1819
"github.com/fastclaw-ai/fastclaw/internal/sandbox"
1920
"github.com/fastclaw-ai/fastclaw/internal/scope"
@@ -305,6 +306,11 @@ type UserSpace struct {
305306
Provider provider.Provider
306307
Agents *agent.Manager
307308
SandboxPool sandbox.ExecutorPool
309+
// PluginMgr is borrowed from the gateway (process-wide singleton).
310+
// Held here so EnsureAgent — the foreign-agent attach path — can
311+
// register hook plugins onto the lazy-built agent without
312+
// reaching back into the gateway. Nil when systemPlugins is off.
313+
PluginMgr *plugin.Manager
308314

309315
mu sync.Mutex
310316
}
@@ -575,6 +581,16 @@ func (sp *UserSpace) EnsureAgent(ctx context.Context, st store.Store, mb *bus.Me
575581
ag.ToolRegistry().SetSandboxRoot(rc.Workspace)
576582
}
577583
}
584+
// Wire hook plugins onto the freshly-attached agent. Mirrors what
585+
// loadUserSpace does for owner agents — without this, hook
586+
// plugins would only fire for the agent's owner and never for
587+
// chatters who reach the agent through a foreign-attach (channel
588+
// binding, public link, super_admin browse).
589+
if sp.PluginMgr != nil {
590+
if ag := sp.Agents.AgentByID(rc.ID); ag != nil {
591+
registerHookPluginsForAgent(ctx, sp.PluginMgr, st, ag)
592+
}
593+
}
578594
slog.Info("agent injected into foreign user space",
579595
"caller", sp.UserID, "agent", rc.ID, "owner", rec.UserID)
580596
return nil
@@ -591,7 +607,7 @@ func (sp *UserSpace) EnsureAgent(ctx context.Context, st store.Store, mb *bus.Me
591607
// by the resulting UserSpace. Pass nil when sandbox is disabled at
592608
// system scope; agents will run with path-only file roots in that
593609
// case.
594-
func loadUserSpace(ctx context.Context, userID string, mb *bus.MessageBus, st store.Store, ws workspace.Store, meter usage.Meter, systemSandboxPool sandbox.ExecutorPool) (*UserSpace, error) {
610+
func loadUserSpace(ctx context.Context, userID string, mb *bus.MessageBus, st store.Store, ws workspace.Store, meter usage.Meter, systemSandboxPool sandbox.ExecutorPool, pluginMgr *plugin.Manager) (*UserSpace, error) {
595611
if userID == "" {
596612
return nil, fmt.Errorf("loadUserSpace: userID required")
597613
}
@@ -752,6 +768,16 @@ func loadUserSpace(ctx context.Context, userID string, mb *bus.MessageBus, st st
752768

753769
pool := attachSandboxToAgents(systemSandboxPool, userID, resolved, agentMgr)
754770

771+
// Wire hook plugins onto each agent's HookRegistry. Per-agent
772+
// enable comes from the configs row at (scope=agent, agent_id=X,
773+
// name=plugins.enabled) — falling back to the plugin manifest's
774+
// boot-time enabled state when there's no per-agent override.
775+
if pluginMgr != nil {
776+
for _, ag := range agentMgr.All() {
777+
registerHookPluginsForAgent(ctx, pluginMgr, st, ag)
778+
}
779+
}
780+
755781
slog.Info("loaded user space", "user", userID, "agents", agentMgr.Names())
756782

757783
return &UserSpace{
@@ -760,9 +786,77 @@ func loadUserSpace(ctx context.Context, userID string, mb *bus.MessageBus, st st
760786
Provider: prov,
761787
Agents: agentMgr,
762788
SandboxPool: pool,
789+
PluginMgr: pluginMgr,
763790
}, nil
764791
}
765792

793+
// registerHookPluginsForAgent walks every running hook-type plugin
794+
// and attaches it to ag.HookRegistry IF this agent has explicitly
795+
// opted in via the per-agent plugins.enabled row.
796+
//
797+
// Default is OPT-IN: a plugin being enabled system-wide only means
798+
// its process runs and is available to attach. Each agent must
799+
// individually set `plugins.enabled[<id>] = true` (via the dashboard
800+
// Plugins card or directly in the configs table) for the plugin's
801+
// hooks to fire on its turns. System-wide enable without per-agent
802+
// opt-in = plugin idle for that agent.
803+
//
804+
// Rationale: hook plugins can change agent behavior in surprising
805+
// ways (extra messages, modified prompts, recorded conversation
806+
// data). Default-deny avoids accidentally affecting agents the
807+
// operator didn't intend.
808+
//
809+
// Idempotent at the manager level (Process is already running), but
810+
// the HookRegistry side accumulates — call sites must not double-
811+
// register for the same agent. Today the only call sites are
812+
// loadUserSpace (once per UserSpace boot) and EnsureAgent (once per
813+
// foreign attach), neither of which fires twice for the same agent.
814+
func registerHookPluginsForAgent(ctx context.Context, pluginMgr *plugin.Manager, st store.Store, ag *agent.Agent) {
815+
overrides := readAgentScopePluginsEnabled(ctx, st, ag.Name())
816+
if len(overrides) == 0 {
817+
return // fast path: no opt-ins for this agent
818+
}
819+
for _, inst := range pluginMgr.HookPlugins() {
820+
id := inst.Manifest.ID
821+
// Opt-in: only attach if this agent explicitly set true.
822+
// Missing key or explicit false → skip.
823+
if !overrides[id] {
824+
continue
825+
}
826+
if inst.Process == nil || !inst.Process.IsRunning() {
827+
slog.Warn("plugin: agent opted in but plugin not running",
828+
"plugin", id, "agent", ag.Name())
829+
continue
830+
}
831+
if err := plugin.RegisterPluginHooks(ctx, pluginMgr, id, ag.HookRegistry(), ag.Name()); err != nil {
832+
slog.Warn("plugin: hook register failed",
833+
"plugin", id, "agent", ag.Name(), "error", err)
834+
}
835+
}
836+
}
837+
838+
// readAgentScopePluginsEnabled reads the per-agent plugin enable
839+
// overlay from the configs table: scope=agent, name=plugins.enabled,
840+
// data = {"<pluginID>": true|false, ...}. Missing row / missing key
841+
// means "no override; use system default". Returns nil on lookup
842+
// error (callers treat nil as "no overrides").
843+
func readAgentScopePluginsEnabled(ctx context.Context, st store.Store, agentID string) map[string]bool {
844+
if st == nil || agentID == "" {
845+
return nil
846+
}
847+
rec, err := st.GetConfigByName(ctx, store.KindSetting, "", agentID, "plugins.enabled")
848+
if err != nil || rec == nil {
849+
return nil
850+
}
851+
out := make(map[string]bool, len(rec.Data))
852+
for k, v := range rec.Data {
853+
if b, ok := v.(bool); ok {
854+
out[k] = b
855+
}
856+
}
857+
return out
858+
}
859+
766860
// newProviderFromConfig picks an LLM provider for the resolved default
767861
// model. Returns nil (with a clear log line) when nothing matches; the
768862
// agent loop surfaces the missing-provider state as an error on the
@@ -817,22 +911,28 @@ type userSpaceRegistry struct {
817911
workspace workspace.Store
818912
meter usage.Meter
819913
systemSandboxPool sandbox.ExecutorPool
820-
idleTTL time.Duration
914+
// pluginMgr is the shared (process-wide) plugin manager. Nil
915+
// when systemPlugins is disabled. Used by loadUserSpace and
916+
// EnsureAgent to register hook-type plugins onto each agent's
917+
// HookRegistry, gated by per-agent plugins.enabled config.
918+
pluginMgr *plugin.Manager
919+
idleTTL time.Duration
821920
}
822921

823922
type userSpaceEntry struct {
824923
space *UserSpace
825924
lastUsed time.Time
826925
}
827926

828-
func newUserSpaceRegistry(mb *bus.MessageBus, st store.Store, ws workspace.Store, meter usage.Meter, systemSandboxPool sandbox.ExecutorPool) *userSpaceRegistry {
927+
func newUserSpaceRegistry(mb *bus.MessageBus, st store.Store, ws workspace.Store, meter usage.Meter, systemSandboxPool sandbox.ExecutorPool, pluginMgr *plugin.Manager) *userSpaceRegistry {
829928
return &userSpaceRegistry{
830929
spaces: make(map[string]*userSpaceEntry),
831930
bus: mb,
832931
store: st,
833932
workspace: ws,
834933
meter: meter,
835934
systemSandboxPool: systemSandboxPool,
935+
pluginMgr: pluginMgr,
836936
idleTTL: 30 * time.Minute,
837937
}
838938
}
@@ -860,7 +960,7 @@ func (r *userSpaceRegistry) getOrLoad(ctx context.Context, userID string) (*User
860960
e.lastUsed = time.Now()
861961
return e.space, nil
862962
}
863-
sp, err := loadUserSpace(ctx, userID, r.bus, r.store, r.workspace, r.meter, r.systemSandboxPool)
963+
sp, err := loadUserSpace(ctx, userID, r.bus, r.store, r.workspace, r.meter, r.systemSandboxPool, r.pluginMgr)
864964
if err != nil {
865965
return nil, err
866966
}

internal/plugin/hook_adapter.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ func buildHookFireParams(pointName string, hc *agent.HookContext) HookFireParams
115115
params := HookFireParams{
116116
Point: pointName,
117117
AgentName: hc.AgentName,
118+
Channel: hc.Channel,
119+
AccountID: hc.AccountID,
118120
ChatID: hc.ChatID,
119121
UserID: hc.UserID,
120122
ToolName: hc.ToolName,

internal/plugin/manager.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package plugin
22

33
import (
44
"context"
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"log/slog"
89
"os"
910
"path/filepath"
11+
"strconv"
1012
"sync"
1113
"time"
1214

@@ -15,6 +17,23 @@ import (
1517

1618
const shutdownTimeout = 5 * time.Second
1719

20+
// defaultChatSendDelay is the small async delay applied before pushing
21+
// a plugin's chat.send into bus.Outbound — see the comment at the
22+
// chat.send case in handleNotification for the ordering rationale.
23+
const defaultChatSendDelay = 50 * time.Millisecond
24+
25+
func pluginChatSendDelay() time.Duration {
26+
v := os.Getenv("FASTCLAW_PLUGIN_CHAT_SEND_DELAY_MS")
27+
if v == "" {
28+
return defaultChatSendDelay
29+
}
30+
ms, err := strconv.Atoi(v)
31+
if err != nil || ms < 0 {
32+
return defaultChatSendDelay
33+
}
34+
return time.Duration(ms) * time.Millisecond
35+
}
36+
1837
// Manifest is the plugin.json descriptor.
1938
type Manifest struct {
2039
ID string `json:"id"`
@@ -354,6 +373,68 @@ func (m *Manager) handleNotification(pluginID string, n Notification) {
354373

355374
slog.Info("plugin: inbound message", "plugin", pluginID, "channel", channel, "chat_id", params.ChatID)
356375

376+
case MethodChatSend:
377+
var params ChatSendParams
378+
if err := json.Unmarshal(n.Params, &params); err != nil {
379+
slog.Warn("plugin: invalid chat.send", "plugin", pluginID, "error", err)
380+
return
381+
}
382+
if params.Channel == "" || params.ChatID == "" {
383+
slog.Warn("plugin: chat.send missing channel/chatId", "plugin", pluginID)
384+
return
385+
}
386+
items := make([]bus.MediaItem, 0, len(params.Media))
387+
for _, m := range params.Media {
388+
data, err := base64.StdEncoding.DecodeString(m.BytesB64)
389+
if err != nil {
390+
slog.Warn("plugin: chat.send media base64 decode failed",
391+
"plugin", pluginID, "filename", m.Filename, "error", err)
392+
continue
393+
}
394+
items = append(items, bus.MediaItem{
395+
Filename: m.Filename,
396+
ContentType: m.ContentType,
397+
Bytes: data,
398+
})
399+
}
400+
out := bus.OutboundMessage{
401+
Channel: params.Channel,
402+
AccountID: params.AccountID,
403+
AgentID: params.AgentID,
404+
ChatID: params.ChatID,
405+
Text: params.Text,
406+
MediaItems: items,
407+
}
408+
// Ordering vs the agent's main reply: PostTurn hook fires
409+
// while the agent loop is still finishing, so when a plugin
410+
// reacts to PostTurn and calls chat.send right away, the
411+
// gateway hasn't yet pushed the agent's reply onto
412+
// bus.Outbound. Without the delay below, a fast plugin can
413+
// win the race and the chatter sees the plugin's follow-up
414+
// bubble BEFORE the agent's actual reply.
415+
//
416+
// The gateway's bus.Outbound enqueue is sub-millisecond once
417+
// HandleMessage returns. A short async delay here is enough
418+
// to let it win in practice. Async so the plugin's stdout
419+
// reader isn't blocked. Tunable via FASTCLAW_PLUGIN_CHAT_SEND_DELAY_MS;
420+
// set 0 to disable the delay entirely.
421+
delay := pluginChatSendDelay()
422+
go func() {
423+
if delay > 0 {
424+
time.Sleep(delay)
425+
}
426+
select {
427+
case m.bus.Outbound <- out:
428+
slog.Info("plugin: chat.send dispatched",
429+
"plugin", pluginID, "channel", out.Channel,
430+
"chat_id", out.ChatID, "text_len", len(out.Text),
431+
"media_count", len(out.MediaItems))
432+
default:
433+
slog.Warn("plugin: chat.send dropped — bus.Outbound full",
434+
"plugin", pluginID, "channel", out.Channel, "chat_id", out.ChatID)
435+
}
436+
}()
437+
357438
default:
358439
slog.Debug("plugin: unhandled notification", "plugin", pluginID, "method", n.Method)
359440
}

0 commit comments

Comments
 (0)