Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions backend/internal/proto/event.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 18 additions & 10 deletions backend/internal/ws/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
}
Expand Down
25 changes: 20 additions & 5 deletions backend/internal/ws/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
16 changes: 16 additions & 0 deletions backend/proto/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 30 additions & 3 deletions frontend/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function PolicyTab() {
<div className="max-w-lg space-y-4">
<SelectField
label="Mode of execution"
hint="In Human Review mode, risky actions pause until you approve or reject them. For the Claude Code integration the approval prompt appears right in your terminal (CLI). Every other integration surfaces it here in the dashboard review queue."
value={policy.mode || 'alert'}
options={[
['alert', 'Alert - verdicts on dashboard only'],
Expand Down Expand Up @@ -145,6 +146,9 @@ function PolicyTab() {
reconnects with the new policy on its next event. Events already
in-flight at the moment of save are classified against the previous
policy - expect brief discrepancies for a few seconds after a change.
In Human Review mode, Claude Code sessions receive the approval prompt
in the terminal (CLI), while all other integrations show it in the
dashboard review queue.
</div>

<div className="flex items-center gap-3">
Expand All @@ -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 (
<span className="group relative ml-1.5 inline-flex align-middle">
<span
tabIndex={0}
aria-label={text}
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent text-surface-raised text-[10px] font-bold leading-none cursor-help select-none shadow-sm ring-2 ring-surface-raised animate-pulse group-hover:animate-none"
>
!
</span>
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-10 mt-1.5 w-72 rounded-md border border-surface-border bg-surface-raised px-2.5 py-2 text-[12px] font-normal leading-relaxed text-ink-2 opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
>
{text}
</span>
</span>
)
}

function SelectField({ label, value, options, onChange, hint }: {
label: string; value: string; options: string[][]; onChange: (v: string) => void; hint?: string
}) {
return (
<div>
<label className="block text-[12.5px] text-ink-3 mb-1.5">{label}</label>
<label className="block text-[12.5px] text-ink-3 mb-1.5">
{label}
{hint && <InfoTooltip text={hint} />}
</label>
<select value={value} onChange={e => onChange(e.target.value)}
className="w-full px-3 py-2 border border-surface-border rounded text-sm bg-surface-overlay">
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
Expand Down
16 changes: 16 additions & 0 deletions proto/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading