diff --git a/backend/internal/proto/event.pb.go b/backend/internal/proto/event.pb.go index 06caa26..fa30119 100644 --- a/backend/internal/proto/event.pb.go +++ b/backend/internal/proto/event.pb.go @@ -622,9 +622,15 @@ type PairedEvent struct { // *PairedEvent_Llm // *PairedEvent_Tool Data isPairedEvent_Data `protobuf_oneof:"data"` + // Per-connection routing id (mirrors SessionLogin.connection_id). When set, + // the server routes this connection's verdict on connection_id instead of + // session_id, so concurrent connections sharing one session_id (parallel + // Claude Code tool-call hooks) do not evict each other. + ConnectionId string `protobuf:"bytes,12,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` // Escape hatch for framework metadata not modelled as first-class fields // (e.g. LangGraph checkpoint_ns, arbitrary tags). MetadataJson []byte `protobuf:"bytes,20,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"` + Source string `protobuf:"bytes,21,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -747,6 +753,13 @@ func (x *PairedEvent) GetTool() *ToolPairData { return nil } +func (x *PairedEvent) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + func (x *PairedEvent) GetMetadataJson() []byte { if x != nil { return x.MetadataJson @@ -754,6 +767,13 @@ func (x *PairedEvent) GetMetadataJson() []byte { return nil } +func (x *PairedEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + type isPairedEvent_Data interface { isPairedEvent_Data() } @@ -996,6 +1016,15 @@ type SessionLogin struct { LlmStack *LLMStack `protobuf:"bytes,2,opt,name=llm_stack,json=llmStack,proto3" json:"llm_stack,omitempty"` // Wire schema version the client is speaking. Server rejects unknown values. SchemaVersion uint32 `protobuf:"varint,4,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + // SDK / integration that produced this session (e.g. "claude-code"); the + // server branches HITL behavior on it. Empty for legacy SDKs. + Source string `protobuf:"bytes,5,opt,name=source,proto3" json:"source,omitempty"` + // Optional per-connection routing id, unique per live WebSocket connection. + // When set, the server routes this connection's verdicts on connection_id + // instead of session_id (and keys the single-owner guard on it), so multiple + // concurrent connections sharing one session_id (parallel Claude Code + // tool-call hooks) no longer evict each other. + ConnectionId string `protobuf:"bytes,6,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1051,6 +1080,20 @@ func (x *SessionLogin) GetSchemaVersion() uint32 { return 0 } +func (x *SessionLogin) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *SessionLogin) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + // ClientFrame is the top-level envelope for all client WebSocket messages. type ClientFrame struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1567,7 +1610,7 @@ const file_event_proto_rawDesc = "" + "\ftool_call_id\x18\x02 \x01(\tR\n" + "toolCallId\x12\x14\n" + "\x05input\x18\x03 \x01(\tR\x05input\x12\x16\n" + - "\x06output\x18\x04 \x01(\tR\x06output\"\x8c\x04\n" + + "\x06output\x18\x04 \x01(\tR\x06output\"\xc9\x04\n" + "\vPairedEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12#\n" + "\rinvocation_id\x18\x02 \x01(\tR\finvocationId\x12\x1d\n" + @@ -1582,7 +1625,9 @@ const file_event_proto_rawDesc = "" + "\x03llm\x18\n" + " \x01(\v2\x1f.adrian.core_api.v1.LlmPairDataH\x00R\x03llm\x126\n" + "\x04tool\x18\v \x01(\v2 .adrian.core_api.v1.ToolPairDataH\x00R\x04tool\x12#\n" + - "\rmetadata_json\x18\x14 \x01(\fR\fmetadataJsonB\x06\n" + + "\rconnection_id\x18\f \x01(\tR\fconnectionId\x12#\n" + + "\rmetadata_json\x18\x14 \x01(\fR\fmetadataJson\x12\x16\n" + + "\x06source\x18\x15 \x01(\tR\x06sourceB\x06\n" + "\x04data\"K\n" + "\x10PairedEventBatch\x127\n" + "\x06events\x18\x01 \x03(\v2\x1f.adrian.core_api.v1.PairedEventR\x06events\"Y\n" + @@ -1594,12 +1639,14 @@ const file_event_proto_rawDesc = "" + "\aservers\x18\x01 \x03(\v2\x1d.adrian.core_api.v1.McpServerR\aservers\"<\n" + "\bLLMStack\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" + - "\x05model\x18\x02 \x01(\tR\x05model\"\xa1\x01\n" + + "\x05model\x18\x02 \x01(\tR\x05model\"\xde\x01\n" + "\fSessionLogin\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x129\n" + "\tllm_stack\x18\x02 \x01(\v2\x1c.adrian.core_api.v1.LLMStackR\bllmStack\x12%\n" + - "\x0eschema_version\x18\x04 \x01(\rR\rschemaVersionJ\x04\b\x03\x10\x04R\n" + + "\x0eschema_version\x18\x04 \x01(\rR\rschemaVersion\x12\x16\n" + + "\x06source\x18\x05 \x01(\tR\x06source\x12#\n" + + "\rconnection_id\x18\x06 \x01(\tR\fconnectionIdJ\x04\b\x03\x10\x04R\n" + "block_mode\"\xf1\x01\n" + "\vClientFrame\x128\n" + "\x05login\x18\x01 \x01(\v2 .adrian.core_api.v1.SessionLoginH\x00R\x05login\x12I\n" + diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index a40706b..ccbef77 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -99,7 +99,7 @@ func serve(ctx context.Context, conn *websocket.Conn, sess *session, st *store.S // verdicts and HITL resolutions land here, drained by the writer // goroutine. The LoginAck is written directly inside handleLogin // (single goroutine, pre-register, no concurrency to serialise). - hubCh, deregister, err := hub.Register(sess.sessionID, sess.routeOwner()) + hubCh, deregister, err := hub.Register(sess.routeKey(), sess.routeOwner()) if err != nil { if errors.Is(err, ErrSessionOwnerConflict) { slog.WarnContext(ctx, "ws.session_owner_conflict", @@ -210,10 +210,12 @@ func handleLogin(ctx context.Context, conn *websocket.Conn, sess *session, st *s } sess.sessionID = login.SessionId + sess.connectionID = login.GetConnectionId() if login.LlmStack != nil { sess.llmProvider = login.LlmStack.Provider sess.llmModel = login.LlmStack.Model } + sess.source = login.GetSource() sess.loggedIn = true pol, err := st.GetPolicy(ctx) @@ -369,20 +371,26 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H // notified. No SDK-side action is expected in alert mode. return nil case pb.Mode_MODE_HITL: - if inScope && isActionable(ev) { - // Queue for human review and hold the verdict. The reviews - // REST handler resumes the SDK with a HitlResponse-bearing - // Verdict on approve/reject via the same hub channel. + // Claude Code gates HITL client-side: its PreToolUse hook blocks and + // shows a terminal approval prompt, so CC is its own wait point. + // Forward every in-scope verdict to it and never queue a dashboard + // review no operator would action. This is source-gated and + // independent of isActionable, which only models the LangChain wait + // point. Other SDKs are held for review only when they have a + // server-visible wait point (isActionable); anything else forwards so + // the SDK isn't left waiting on a resolution that will never come. + if inScope && sess.source != "claude-code" && isActionable(ev) { + // Queue for human review and hold the verdict. The reviews REST + // handler resumes the SDK with a HitlResponse-bearing Verdict on + // approve/reject via the same hub channel. if err := st.InsertHitlQueue(ctx, ev.EventId, verdictID, sess.sessionID, madCode); err != nil { slog.ErrorContext(ctx, "hitl.insert_failed", "error", err, "event_id", ev.EventId) } return nil } - // Out of scope OR non-actionable, forward so the SDK isn't - // left waiting on a resolution that will never come, and the - // operator isn't asked to review something approving cannot - // influence. + // Fall through: forward (Claude Code inline HITL, a non-actionable + // event, or an out-of-scope code). case pb.Mode_MODE_BLOCK: // Forward every verdict; SDK is the policy enforcement point. default: @@ -402,7 +410,7 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H }, }, } - if !hub.Publish(sess.sessionID, out) { + if !hub.Publish(sess.routeKey(), out) { slog.WarnContext(ctx, "ws.publish_dropped", "event_id", ev.EventId, "session_id", sess.sessionID) } diff --git a/backend/internal/ws/session.go b/backend/internal/ws/session.go index cb251a3..9412e4e 100644 --- a/backend/internal/ws/session.go +++ b/backend/internal/ws/session.go @@ -10,11 +10,15 @@ import ( // session is per-connection state. Created at WS upgrade time, populated // from the LoginAck round-trip, consumed by the read loop. type session struct { - apiKey *store.APIKey - sessionID string - llmProvider string - llmModel string - loggedIn bool + apiKey *store.APIKey + sessionID string + connectionID string + llmProvider string + llmModel string + // source is the SDK / integration identifier from SessionLogin.source + // (e.g. "claude-code"). Drives CC-native HITL handling in dispatchVerdict. + source string + loggedIn bool } // agentProfileID returns the bound agent_profile_id (or nil if the @@ -38,3 +42,14 @@ func (s *session) routeOwner() string { } return "api_key:" + s.apiKey.ID } + +// routeKey is the Hub key for this connection's verdict subscription: the +// per-connection connection_id when set, else the session_id. Keying on +// connection_id lets concurrent connections that share one session_id +// (parallel Claude Code tool-call hooks) coexist instead of evicting. +func (s *session) routeKey() string { + if s.connectionID != "" { + return s.connectionID + } + return s.sessionID +} diff --git a/backend/proto/event.proto b/backend/proto/event.proto index fe1ad33..ca9026b 100644 --- a/backend/proto/event.proto +++ b/backend/proto/event.proto @@ -114,9 +114,16 @@ message PairedEvent { ToolPairData tool = 11; } + // Per-connection routing id (mirrors SessionLogin.connection_id). When set, + // the server routes this connection's verdict on connection_id instead of + // session_id, so concurrent connections sharing one session_id (parallel + // Claude Code tool-call hooks) do not evict each other. + string connection_id = 12; + // Escape hatch for framework metadata not modelled as first-class fields // (e.g. LangGraph checkpoint_ns, arbitrary tags). bytes metadata_json = 20; + string source = 21; } // PairedEventBatch wraps multiple paired events for batched WebSocket frames. @@ -169,6 +176,15 @@ message SessionLogin { reserved "block_mode"; // Wire schema version the client is speaking. Server rejects unknown values. uint32 schema_version = 4; + // SDK / integration that produced this session (e.g. "claude-code"); the + // server branches HITL behavior on it. Empty for legacy SDKs. + string source = 5; + // Optional per-connection routing id, unique per live WebSocket connection. + // When set, the server routes this connection's verdicts on connection_id + // instead of session_id (and keys the single-owner guard on it), so multiple + // concurrent connections sharing one session_id (parallel Claude Code + // tool-call hooks) no longer evict each other. + string connection_id = 6; } // ClientFrame is the top-level envelope for all client WebSocket messages. diff --git a/frontend/app/(dashboard)/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx index 9f32b4d..bf0aa95 100644 --- a/frontend/app/(dashboard)/settings/page.tsx +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -96,6 +96,7 @@ function PolicyTab() {
@@ -168,12 +172,35 @@ function PolicyTab() { ) } -function SelectField({ label, value, options, onChange }: { - label: string; value: string; options: string[][]; onChange: (v: string) => void +function InfoTooltip({ text }: { text: string }) { + return ( + + + ! + + + {text} + + + ) +} + +function SelectField({ label, value, options, onChange, hint }: { + label: string; value: string; options: string[][]; onChange: (v: string) => void; hint?: string }) { return (
- +