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 (
-
+