This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 6569e37  feat(monitor): monitor opencode, Cursor, Gemini CLI and 
Windsurf
6569e37 is described below

commit 6569e37eb890a60ba06780464e51f3d0e5af7f08
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 01:55:30 2026 +0800

    feat(monitor): monitor opencode, Cursor, Gemini CLI and Windsurf
---
 agenthook/cursor.go           |  143 ++++
 agenthook/gemini.go           |   92 +++
 agenthook/hook.go             |   15 +-
 agenthook/windsurf.go         |   93 +++
 agentmonitor/monitor.go       |   12 +
 agentpatch/claude_code.go     |   70 +-
 agentpatch/cursor.go          |  223 ++++++
 agentpatch/gemini.go          |  208 ++++++
 agentpatch/hook_groups.go     |  179 +++++
 agentpatch/opencode.go        |  145 ++++
 agentpatch/opencode_plugin.js |  182 +++++
 agentpatch/patch.go           |   16 +
 agentpatch/unsupported.go     |   39 -
 agentpatch/windsurf.go        |  198 +++++
 web/src/lib/agents.ts         | 1636 +++++++++++++++++++++--------------------
 15 files changed, 2331 insertions(+), 920 deletions(-)

diff --git a/agenthook/cursor.go b/agenthook/cursor.go
new file mode 100644
index 0000000..60a8574
--- /dev/null
+++ b/agenthook/cursor.go
@@ -0,0 +1,143 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthook
+
+import (
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// CursorEvents are the hook events Gateway installs. Cursor also reports every
+// file read and every sentence the model produces; those say what the agent 
saw
+// and thought rather than what it did, and arrive far too often to record.
+var CursorEvents = []string{
+       "sessionStart", "sessionEnd", "beforeSubmitPrompt", "stop",
+       "preToolUse", "postToolUse", "postToolUseFailure",
+       "beforeShellExecution", "afterShellExecution",
+       "beforeMCPExecution", "afterMCPExecution",
+       "afterFileEdit", "subagentStart", "subagentStop", "preCompact",
+}
+
+// NormalizeCursor maps Cursor's hook event schema to Gateway's agent 
monitoring
+// record. The Cursor CLI reports a subset of these events, so a record arrives
+// for whichever of them the front end in use actually fires.
+func NormalizeCursor(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
+       record := &agentmonitor.Record{
+               Agent:       "cursor",
+               AgentPath:   agentPath,
+               CreatedTime: now.Format(time.RFC3339Nano),
+               SessionKey:  firstString(event, "conversation_id", 
"session_id"),
+               PromptId:    stringValue(event["generation_id"]),
+               ToolUseId:   stringValue(event["tool_use_id"]),
+               ToolName:    stringValue(event["tool_name"]),
+               Model:       stringValue(event["model"]),
+               DurationMs:  int64Value(event["duration_ms"]),
+       }
+
+       switch stringValue(event["hook_event_name"]) {
+       case "sessionStart":
+               record.EventType, record.Action = "session", "start"
+       case "sessionEnd":
+               record.EventType, record.Action = "session", "end"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["reason"]))
+       case "stop":
+               record.EventType, record.Action = "session", "stop"
+               record.Outcome = cursorStopOutcome(stringValue(event["status"]))
+       case "beforeSubmitPrompt":
+               record.EventType, record.Action, record.Outcome = "prompt", 
"submitted", "attempted"
+               record.Title = 
auditutil.SanitizeString(stringValue(event["prompt"]))
+       case "preToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "attempted"
+       case "postToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "success"
+               record.DurationMs = int64Value(event["duration"])
+       case "postToolUseFailure":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "failure"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["error"]))
+       case "beforeShellExecution":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "attempted"
+               record.ToolName, record.Title = "shell", 
auditutil.SanitizeString(stringValue(event["command"]))
+       case "afterShellExecution":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "success"
+               record.ToolName, record.Title = "shell", 
auditutil.SanitizeString(stringValue(event["command"]))
+       case "beforeMCPExecution":
+               record.EventType, record.Action, record.Outcome = "mcp", 
"call", "attempted"
+       case "afterMCPExecution":
+               record.EventType, record.Action, record.Outcome = "mcp", 
"call", "success"
+       case "afterFileEdit":
+               record.EventType, record.Action, record.Outcome = "file", 
"edited", "success"
+               record.Object = 
auditutil.EncodeBoundedJSON(cursorEditPayload(event), auditutil.MaxPayloadBytes)
+               return record
+       case "subagentStart":
+               record.EventType, record.Action = "subagent", "start"
+       case "subagentStop":
+               record.EventType, record.Action, record.Outcome = "subagent", 
"stop", "success"
+       case "preCompact":
+               record.EventType, record.Action, record.Outcome = "compact", 
"before", "attempted"
+       default:
+               return nil
+       }
+
+       record.Object = auditutil.EncodeBoundedJSON(cursorPayload(event, 
record.ToolName), auditutil.MaxPayloadBytes)
+       return record
+}
+
+func cursorStopOutcome(status string) string {
+       switch status {
+       case "completed":
+               return "success"
+       case "":
+               return ""
+       default:
+               return "failure"
+       }
+}
+
+func cursorPayload(event map[string]any, toolName string) map[string]any {
+       payload := make(map[string]any, len(event))
+       for key, value := range event {
+               switch key {
+               case "transcript_path", "tool_output":
+                       continue
+               case "tool_input":
+                       payload[key] = auditutil.SanitizeToolInput(toolName, 
value)
+               default:
+                       payload[key] = auditutil.SanitizeValue(key, value)
+               }
+       }
+       return payload
+}
+
+// cursorEditPayload records that a file changed, not how. The edits carry the
+// file's own text on both sides of the change, which is the file rather than a
+// description of the action.
+func cursorEditPayload(event map[string]any) map[string]any {
+       edits, _ := event["edits"].([]any)
+       return map[string]any{
+               "file_path": auditutil.SanitizeValue("file_path", 
event["file_path"]),
+               "edits":     len(edits),
+       }
+}
+
+func firstString(event map[string]any, keys ...string) string {
+       for _, key := range keys {
+               if value := stringValue(event[key]); value != "" {
+                       return value
+               }
+       }
+       return ""
+}
diff --git a/agenthook/gemini.go b/agenthook/gemini.go
new file mode 100644
index 0000000..57997a0
--- /dev/null
+++ b/agenthook/gemini.go
@@ -0,0 +1,92 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthook
+
+import (
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// GeminiEvents are the hook events Gateway installs. The Gemini CLI also
+// reports the model requests themselves, which carry the whole conversation on
+// every turn and say nothing about what the agent did with it.
+var GeminiEvents = []string{
+       "SessionStart", "SessionEnd", "BeforeAgent", "AfterAgent",
+       "BeforeTool", "AfterTool", "Notification", "PreCompress",
+}
+
+// NormalizeGemini maps the Gemini CLI's hook event schema to Gateway's agent
+// monitoring record. Unknown events are not monitoring events and are ignored.
+func NormalizeGemini(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
+       record := &agentmonitor.Record{
+               Agent:       "gemini-cli",
+               AgentPath:   agentPath,
+               CreatedTime: now.Format(time.RFC3339Nano),
+               SessionKey:  stringValue(event["session_id"]),
+               ToolName:    stringValue(event["tool_name"]),
+       }
+
+       switch stringValue(event["hook_event_name"]) {
+       case "SessionStart":
+               record.EventType, record.Action = "session", "start"
+       case "SessionEnd":
+               record.EventType, record.Action = "session", "end"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["reason"]))
+       case "BeforeAgent":
+               record.EventType, record.Action, record.Outcome = "prompt", 
"submitted", "attempted"
+               record.Title = 
auditutil.SanitizeString(stringValue(event["prompt"]))
+       case "AfterAgent":
+               record.EventType, record.Action, record.Outcome = "session", 
"stop", "success"
+       case "BeforeTool":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "attempted"
+       case "AfterTool":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "success"
+       case "Notification":
+               record.EventType, record.Action = "notification", 
stringValue(event["notification_type"])
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["message"]))
+       case "PreCompress":
+               record.EventType, record.Action, record.Outcome = "compact", 
"before", "attempted"
+       default:
+               return nil
+       }
+
+       if record.Action == "" {
+               record.Action = "observed"
+       }
+       record.Object = auditutil.EncodeBoundedJSON(geminiPayload(event, 
record.ToolName), auditutil.MaxPayloadBytes)
+       return record
+}
+
+// geminiPayload drops what a record cannot carry: the transcript is a path 
into
+// the agent's own storage, and a tool response or a model turn is the content
+// itself rather than a description of the action.
+func geminiPayload(event map[string]any, toolName string) map[string]any {
+       payload := make(map[string]any, len(event))
+       for key, value := range event {
+               switch key {
+               case "transcript_path", "tool_response", "llm_request", 
"llm_response":
+                       continue
+               case "prompt_response":
+                       payload[key+"_length"] = len(stringValue(value))
+               case "tool_input":
+                       payload[key] = auditutil.SanitizeToolInput(toolName, 
value)
+               default:
+                       payload[key] = auditutil.SanitizeValue(key, value)
+               }
+       }
+       return payload
+}
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 3e81664..3174394 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -63,7 +63,8 @@ func Run(args []string, input io.Reader) error {
        if err := flags.Parse(args); err != nil {
                return err
        }
-       if !*managed || *agentID != "claude-code" {
+       normalize, known := normalizers[*agentID]
+       if !*managed || !known {
                return fmt.Errorf("unsupported hook agent %q", *agentID)
        }
 
@@ -73,7 +74,7 @@ func Run(args []string, input io.Reader) error {
        if err := decoder.Decode(&event); err != nil {
                return err
        }
-       record := Normalize(event, *agentPath, time.Now())
+       record := normalize(event, *agentPath, time.Now())
        if record == nil || *recordsURL == "" {
                return nil
        }
@@ -99,6 +100,16 @@ func Run(args []string, input io.Reader) error {
        return response.Body.Close()
 }
 
+// normalizers maps each agent that reports through a command hook to the
+// reader of its own event schema. An agent absent from here has no hook 
Gateway
+// installs, so a process claiming to be one is rejected.
+var normalizers = map[string]func(map[string]any, string, time.Time) 
*agentmonitor.Record{
+       "claude-code": Normalize,
+       "cursor":      NormalizeCursor,
+       "gemini-cli":  NormalizeGemini,
+       "windsurf":    NormalizeWindsurf,
+}
+
 // Normalize maps Claude Code's command-hook event schema to Gateway's agent
 // monitoring record. Unknown events are not monitoring events and are ignored.
 func Normalize(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
diff --git a/agenthook/windsurf.go b/agenthook/windsurf.go
new file mode 100644
index 0000000..f3194d2
--- /dev/null
+++ b/agenthook/windsurf.go
@@ -0,0 +1,93 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthook
+
+import (
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// WindsurfEvents are the hook events Gateway installs. Cascade also reports
+// every file Cascade reads and can write a whole transcript after each turn;
+// both are the content rather than the action, and arrive far too often.
+var WindsurfEvents = []string{
+       "pre_user_prompt", "pre_run_command", "post_run_command",
+       "pre_write_code", "post_write_code",
+       "pre_mcp_tool_use", "post_mcp_tool_use", "post_cascade_response",
+}
+
+// NormalizeWindsurf maps Cascade's hook event schema to Gateway's agent
+// monitoring record. Every event carries its own fields in tool_info.
+func NormalizeWindsurf(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
+       info, _ := event["tool_info"].(map[string]any)
+       if info == nil {
+               info = map[string]any{}
+       }
+
+       record := &agentmonitor.Record{
+               Agent:       "windsurf",
+               AgentPath:   agentPath,
+               CreatedTime: now.Format(time.RFC3339Nano),
+               SessionKey:  stringValue(event["trajectory_id"]),
+               PromptId:    stringValue(event["execution_id"]),
+               Model:       stringValue(event["model_name"]),
+       }
+       payload := map[string]any{}
+
+       switch stringValue(event["agent_action_name"]) {
+       case "pre_user_prompt":
+               record.EventType, record.Action, record.Outcome = "prompt", 
"submitted", "attempted"
+               record.Title = 
auditutil.SanitizeString(stringValue(info["user_prompt"]))
+               payload["user_prompt"] = record.Title
+       case "pre_run_command", "post_run_command":
+               record.EventType, record.Action, record.ToolName = "tool", 
"call", "shell"
+               record.Outcome = 
windsurfOutcome(stringValue(event["agent_action_name"]))
+               record.Title = 
auditutil.SanitizeString(stringValue(info["command_line"]))
+               payload["command_line"], payload["cwd"] = record.Title, 
auditutil.SanitizeValue("cwd", info["cwd"])
+       case "pre_write_code", "post_write_code":
+               record.EventType, record.Action = "file", "edited"
+               record.Outcome = 
windsurfOutcome(stringValue(event["agent_action_name"]))
+               // The edits carry the file's own text on both sides of the 
change,
+               // which is the file rather than a description of the action.
+               edits, _ := info["edits"].([]any)
+               payload["file_path"], payload["edits"] = 
auditutil.SanitizeValue("file_path", info["file_path"]), len(edits)
+       case "pre_mcp_tool_use", "post_mcp_tool_use":
+               record.EventType, record.Action = "mcp", "call"
+               record.Outcome = 
windsurfOutcome(stringValue(event["agent_action_name"]))
+               record.McpServer = stringValue(info["mcp_server_name"])
+               record.McpTool = stringValue(info["mcp_tool_name"])
+               record.ToolName = record.McpTool
+               payload["mcp_tool_arguments"] = 
auditutil.SanitizeToolInput(record.McpTool, info["mcp_tool_arguments"])
+       case "post_cascade_response":
+               record.EventType, record.Action, record.Outcome = "session", 
"stop", "success"
+               payload["response_length"] = len(stringValue(info["response"]))
+       default:
+               return nil
+       }
+
+       record.Object = auditutil.EncodeBoundedJSON(payload, 
auditutil.MaxPayloadBytes)
+       return record
+}
+
+// windsurfOutcome reads the half of the pair an event belongs to: a pre hook
+// reports an action that is about to run, a post hook one that has.
+func windsurfOutcome(event string) string {
+       if len(event) >= 4 && event[:4] == "pre_" {
+               return "attempted"
+       }
+       return "success"
+}
diff --git a/agentmonitor/monitor.go b/agentmonitor/monitor.go
index b4d260c..aa1d6d2 100644
--- a/agentmonitor/monitor.go
+++ b/agentmonitor/monitor.go
@@ -88,10 +88,22 @@ func Stop() {
        openAgentMonitor.stopMonitor()
 }
 
+// MonitorAgentId is the agent one installation's records are reported under.
+// Front ends that share a configuration share the monitoring it feeds: the
+// Codex VS Code integration reports as the CLI, and the opencode desktop app
+// as opencode.
+func MonitorAgentId(agentID string) string {
+       return canonicalAgentID(agentID)
+}
+
 func canonicalAgentID(agentID string) string {
        switch strings.ToLower(strings.TrimSpace(agentID)) {
        case "codex_vscode", "codex-vscode":
                return "codex-cli"
+       case "opencode-desktop":
+               return "opencode"
+       case "cursor-agent":
+               return "cursor"
        default:
                return strings.ToLower(strings.TrimSpace(agentID))
        }
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 9e20585..777a880 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -113,7 +113,7 @@ func (claudeCodePatcher) Status(target Target) (Status, 
error) {
                return Status{Detail: "Claude Code hooks are not installed"}, 
nil
        }
        for _, event := range claudeCodeHookEvents {
-               if !hasClaudeCodeHook(hooks[event]) {
+               if !hasHook(hooks[event], isClaudeCodeHook) {
                        return Status{Detail: "Claude Code hooks need 
refresh"}, nil
                }
        }
@@ -165,7 +165,7 @@ func normalizeClaudeCodeHooks(config map[string]any, 
command string, args []stri
                return fmt.Errorf("hooks must be a JSON object")
        }
        for _, event := range claudeCodeHookEvents {
-               groups, err := withoutClaudeCodeHooks(object[event])
+               groups, err := withoutHooks(object[event], isClaudeCodeHook)
                if err != nil {
                        return fmt.Errorf("hooks.%s: %w", event, err)
                }
@@ -195,10 +195,10 @@ func removeClaudeCodeHooks(config map[string]any) bool {
        }
        changed := false
        for _, event := range claudeCodeHookEvents {
-               if !hasClaudeCodeHook(hooks[event]) {
+               if !hasHook(hooks[event], isClaudeCodeHook) {
                        continue
                }
-               groups, err := withoutClaudeCodeHooks(hooks[event])
+               groups, err := withoutHooks(hooks[event], isClaudeCodeHook)
                if err != nil {
                        continue
                }
@@ -215,68 +215,6 @@ func removeClaudeCodeHooks(config map[string]any) bool {
        return changed
 }
 
-func withoutClaudeCodeHooks(value any) ([]any, error) {
-       if value == nil {
-               return nil, nil
-       }
-       groups, ok := value.([]any)
-       if !ok {
-               return nil, fmt.Errorf("must be a JSON array")
-       }
-       result := make([]any, 0, len(groups))
-       for _, rawGroup := range groups {
-               group, ok := objectAt(rawGroup)
-               if !ok {
-                       result = append(result, rawGroup)
-                       continue
-               }
-               handlers, ok := group["hooks"].([]any)
-               if !ok {
-                       result = append(result, rawGroup)
-                       continue
-               }
-               kept := make([]any, 0, len(handlers))
-               for _, rawHandler := range handlers {
-                       handler, ok := objectAt(rawHandler)
-                       if !ok || !isClaudeCodeHook(handler) {
-                               kept = append(kept, rawHandler)
-                       }
-               }
-               if len(kept) == 0 {
-                       continue
-               }
-               if len(kept) != len(handlers) {
-                       group["hooks"] = kept
-               }
-               result = append(result, group)
-       }
-       return result, nil
-}
-
-func hasClaudeCodeHook(value any) bool {
-       groups, ok := value.([]any)
-       if !ok {
-               return false
-       }
-       for _, rawGroup := range groups {
-               group, ok := objectAt(rawGroup)
-               if !ok {
-                       continue
-               }
-               handlers, ok := group["hooks"].([]any)
-               if !ok {
-                       continue
-               }
-               for _, rawHandler := range handlers {
-                       handler, ok := objectAt(rawHandler)
-                       if ok && isClaudeCodeHook(handler) {
-                               return true
-                       }
-               }
-       }
-       return false
-}
-
 func isClaudeCodeHook(handler map[string]any) bool {
        if handlerType, _ := handler["type"].(string); handlerType != "command" 
{
                return false
diff --git a/agentpatch/cursor.go b/agentpatch/cursor.go
new file mode 100644
index 0000000..f920b21
--- /dev/null
+++ b/agentpatch/cursor.go
@@ -0,0 +1,223 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       "fmt"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agenthook"
+)
+
+const (
+       // cursorHooksVersion is the schema version the file declares.
+       cursorHooksVersion = 1
+       // cursorHookTimeoutMs is what Cursor waits for the hook. Reporting a 
record
+       // is one loopback request, and a hook that hangs must not hold up the 
agent.
+       cursorHookTimeoutMs = 5000
+       // cursorMonitorId is the agent Cursor records are reported under. The 
editor
+       // and the CLI read one ~/.cursor/hooks.json, so they share this file 
and the
+       // monitoring it feeds.
+       cursorMonitorId = "cursor"
+)
+
+type cursorPatcher struct {
+       id string
+}
+
+func init() {
+       register(cursorPatcher{id: "cursor"})
+       register(cursorPatcher{id: "cursor-agent"})
+}
+
+func (p cursorPatcher) AgentId() string { return p.id }
+
+func (cursorPatcher) Supported() bool { return true }
+
+func (p cursorPatcher) Patch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, _, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       command, err := cursorHookCommand(target)
+       if err != nil {
+               return err
+       }
+
+       hooks, err := cursorHooks(config)
+       if err != nil {
+               return err
+       }
+       // The version the file already declares is the one Cursor wrote, and a
+       // later schema is not Gateway's to downgrade.
+       if _, declared := config["version"]; !declared {
+               config["version"] = cursorHooksVersion
+       }
+       for _, event := range agenthook.CursorEvents {
+               handlers, err := withoutFlatHooks(hooks[event], isCursorHook)
+               if err != nil {
+                       return fmt.Errorf("hooks.%s: %w", event, err)
+               }
+               hooks[event] = append(handlers, newCursorHook(command))
+       }
+       return writeJSONConfig(path, config, mode)
+}
+
+func (p cursorPatcher) Unpatch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, exists, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       // The credential is revoked on every unpatch path, including the ones 
that
+       // change no file, so a stale hook can never keep reporting.
+       if exists && removeCursorHooks(config) {
+               if err := writeJSONConfig(path, config, mode); err != nil {
+                       return err
+               }
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p cursorPatcher) Status(target Target) (Status, error) {
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return Status{}, err
+       }
+       config, _, exists, err := readJSONConfig(path)
+       if err != nil {
+               return Status{}, err
+       }
+       if !exists {
+               return Status{Detail: "Cursor hooks are not installed"}, nil
+       }
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return Status{Detail: "Cursor hooks are not installed"}, nil
+       }
+       for _, event := range agenthook.CursorEvents {
+               if !hasFlatHook(hooks[event], isCursorHook) {
+                       return Status{Detail: "Cursor hooks need refresh"}, nil
+               }
+       }
+       return Status{Patched: true, Detail: "Cursor hooks active"}, nil
+}
+
+func (cursorPatcher) PatchNotice(patched bool) (string, string) {
+       if patched {
+               return "Removes Gateway's audit-only Cursor hooks. The editor 
and the CLI share them, so both stop reporting.",
+                       "Restart any Cursor session that is already running."
+       }
+       return "Installs audit-only Cursor hooks. They observe events and never 
block an action; the CLI reports fewer of them than the editor.",
+               "Restart any Cursor session that is already running."
+}
+
+// hooksPath is the user-level hooks file, which both Cursor front ends read.
+func (cursorPatcher) hooksPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".cursor", "hooks.json"), nil
+}
+
+// cursorHookCommand is the whole command line, since Cursor takes a command
+// string rather than a program and its arguments.
+func cursorHookCommand(target Target) (string, error) {
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return "", err
+       }
+       args, err := hookArgs(cursorMonitorId, target)
+       if err != nil {
+               return "", err
+       }
+       return hookCommandLine(executable, args), nil
+}
+
+func cursorHooks(config map[string]any) (map[string]any, error) {
+       value, exists := config["hooks"]
+       if !exists {
+               hooks := map[string]any{}
+               config["hooks"] = hooks
+               return hooks, nil
+       }
+       hooks, ok := objectAt(value)
+       if !ok {
+               return nil, fmt.Errorf("hooks must be a JSON object")
+       }
+       return hooks, nil
+}
+
+// newCursorHook is one entry. failClosed is written even though it is the
+// default: monitoring must never be the reason an action is refused, and the
+// file says so where an operator reads it.
+func newCursorHook(command string) map[string]any {
+       return map[string]any{
+               "command":    command,
+               "type":       "command",
+               "timeout":    cursorHookTimeoutMs,
+               "failClosed": false,
+       }
+}
+
+func removeCursorHooks(config map[string]any) bool {
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return false
+       }
+       changed := false
+       for _, event := range agenthook.CursorEvents {
+               if !hasFlatHook(hooks[event], isCursorHook) {
+                       continue
+               }
+               handlers, err := withoutFlatHooks(hooks[event], isCursorHook)
+               if err != nil {
+                       continue
+               }
+               changed = true
+               if len(handlers) == 0 {
+                       delete(hooks, event)
+               } else {
+                       hooks[event] = handlers
+               }
+       }
+       if changed && len(hooks) == 0 {
+               delete(config, "hooks")
+       }
+       return changed
+}
+
+func isCursorHook(handler map[string]any) bool {
+       command, _ := handler["command"].(string)
+       return strings.Contains(command, agenthook.OwnershipFlag) &&
+               strings.Contains(command, "--agent "+cursorMonitorId) &&
+               strings.Contains(command, "--records-url")
+}
diff --git a/agentpatch/gemini.go b/agentpatch/gemini.go
new file mode 100644
index 0000000..21d58cb
--- /dev/null
+++ b/agentpatch/gemini.go
@@ -0,0 +1,208 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       "fmt"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agenthook"
+)
+
+// geminiHookTimeoutMs is what the Gemini CLI waits for the hook, in
+// milliseconds. Reporting a record is one loopback request, and a hook that
+// hangs must not hold up the session.
+const geminiHookTimeoutMs = 5000
+
+type geminiPatcher struct{}
+
+func init() {
+       register(geminiPatcher{})
+}
+
+func (geminiPatcher) AgentId() string { return "gemini-cli" }
+
+func (geminiPatcher) Supported() bool { return true }
+
+func (p geminiPatcher) Patch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.settingsPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, _, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       command, err := geminiHookCommand(target)
+       if err != nil {
+               return err
+       }
+
+       hooks, err := geminiHooks(config)
+       if err != nil {
+               return err
+       }
+       for _, event := range agenthook.GeminiEvents {
+               groups, err := withoutHooks(hooks[event], isGeminiHook)
+               if err != nil {
+                       return fmt.Errorf("hooks.%s: %w", event, err)
+               }
+               hooks[event] = append(groups, map[string]any{"hooks": 
[]any{newGeminiHook(command)}})
+       }
+       return writeJSONConfig(path, config, mode)
+}
+
+func (p geminiPatcher) Unpatch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.settingsPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, exists, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       // The credential is revoked on every unpatch path, including the ones 
that
+       // change no file, so a stale hook can never keep reporting.
+       if exists && removeGeminiHooks(config) {
+               if err := writeJSONConfig(path, config, mode); err != nil {
+                       return err
+               }
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p geminiPatcher) Status(target Target) (Status, error) {
+       path, err := p.settingsPath(target)
+       if err != nil {
+               return Status{}, err
+       }
+       config, _, exists, err := readJSONConfig(path)
+       if err != nil {
+               return Status{}, err
+       }
+       if !exists {
+               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
+       }
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
+       }
+       for _, event := range agenthook.GeminiEvents {
+               if !hasHook(hooks[event], isGeminiHook) {
+                       return Status{Detail: "Gemini CLI hooks need refresh"}, 
nil
+               }
+       }
+       return Status{Patched: true, Detail: "Gemini CLI hooks active"}, nil
+}
+
+func (geminiPatcher) PatchNotice(patched bool) (string, string) {
+       if patched {
+               return "Removes Gateway's audit-only Gemini CLI hooks.", 
"Restart any Gemini CLI session that is already running."
+       }
+       return "Installs audit-only Gemini CLI hooks. Hooks observe events and 
never block an action.",
+               "Restart any Gemini CLI session that is already running."
+}
+
+func (geminiPatcher) settingsPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".gemini", "settings.json"), nil
+}
+
+// geminiHookCommand is the whole command line, since the Gemini CLI takes a
+// command string rather than a program and its arguments.
+func geminiHookCommand(target Target) (string, error) {
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return "", err
+       }
+       args, err := hookArgs("gemini-cli", target)
+       if err != nil {
+               return "", err
+       }
+       return hookCommandLine(executable, args), nil
+}
+
+func geminiHooks(config map[string]any) (map[string]any, error) {
+       value, exists := config["hooks"]
+       if !exists {
+               hooks := map[string]any{}
+               config["hooks"] = hooks
+               return hooks, nil
+       }
+       hooks, ok := objectAt(value)
+       if !ok {
+               return nil, fmt.Errorf("hooks must be a JSON object")
+       }
+       return hooks, nil
+}
+
+func newGeminiHook(command string) map[string]any {
+       return map[string]any{
+               "type":    "command",
+               "command": command,
+               "name":    gatewayEntryName,
+               "timeout": geminiHookTimeoutMs,
+       }
+}
+
+func removeGeminiHooks(config map[string]any) bool {
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return false
+       }
+       changed := false
+       for _, event := range agenthook.GeminiEvents {
+               if !hasHook(hooks[event], isGeminiHook) {
+                       continue
+               }
+               groups, err := withoutHooks(hooks[event], isGeminiHook)
+               if err != nil {
+                       continue
+               }
+               changed = true
+               if len(groups) == 0 {
+                       delete(hooks, event)
+               } else {
+                       hooks[event] = groups
+               }
+       }
+       if changed && len(hooks) == 0 {
+               delete(config, "hooks")
+       }
+       return changed
+}
+
+// isGeminiHook recognizes Gateway's own handler by the command it runs, which
+// is the only thing a Gemini CLI hook entry carries.
+func isGeminiHook(handler map[string]any) bool {
+       if handlerType, _ := handler["type"].(string); handlerType != "command" 
{
+               return false
+       }
+       command, _ := handler["command"].(string)
+       return strings.Contains(command, agenthook.OwnershipFlag) &&
+               strings.Contains(command, "--agent gemini-cli") &&
+               strings.Contains(command, "--records-url")
+}
diff --git a/agentpatch/hook_groups.go b/agentpatch/hook_groups.go
new file mode 100644
index 0000000..45d3d49
--- /dev/null
+++ b/agentpatch/hook_groups.go
@@ -0,0 +1,179 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       "fmt"
+       "os"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthook"
+)
+
+// Claude Code and the Gemini CLI spell an event the same way: an array of
+// groups, each holding the command handlers that run for it. These walk that
+// shape, so that a patcher only has to say which handler is its own.
+
+// withoutHooks drops the handlers own reports, leaving every other handler and
+// group exactly as the file had them.
+func withoutHooks(value any, own func(map[string]any) bool) ([]any, error) {
+       if value == nil {
+               return nil, nil
+       }
+       groups, ok := value.([]any)
+       if !ok {
+               return nil, fmt.Errorf("must be a JSON array")
+       }
+
+       result := make([]any, 0, len(groups))
+       for _, rawGroup := range groups {
+               group, ok := objectAt(rawGroup)
+               if !ok {
+                       result = append(result, rawGroup)
+                       continue
+               }
+               handlers, ok := group["hooks"].([]any)
+               if !ok {
+                       result = append(result, rawGroup)
+                       continue
+               }
+               kept := make([]any, 0, len(handlers))
+               for _, rawHandler := range handlers {
+                       handler, ok := objectAt(rawHandler)
+                       if !ok || !own(handler) {
+                               kept = append(kept, rawHandler)
+                       }
+               }
+               if len(kept) == 0 {
+                       continue
+               }
+               if len(kept) != len(handlers) {
+                       group["hooks"] = kept
+               }
+               result = append(result, group)
+       }
+       return result, nil
+}
+
+// hasHook reports whether one event already runs own handler.
+func hasHook(value any, own func(map[string]any) bool) bool {
+       groups, ok := value.([]any)
+       if !ok {
+               return false
+       }
+       for _, rawGroup := range groups {
+               group, ok := objectAt(rawGroup)
+               if !ok {
+                       continue
+               }
+               handlers, ok := group["hooks"].([]any)
+               if !ok {
+                       continue
+               }
+               for _, rawHandler := range handlers {
+                       handler, ok := objectAt(rawHandler)
+                       if ok && own(handler) {
+                               return true
+                       }
+               }
+       }
+       return false
+}
+
+// A Cursor or Windsurf event holds its handlers directly, without the groups
+// Claude Code and the Gemini CLI wrap theirs in.
+func withoutFlatHooks(value any, own func(map[string]any) bool) ([]any, error) 
{
+       if value == nil {
+               return nil, nil
+       }
+       handlers, ok := value.([]any)
+       if !ok {
+               return nil, fmt.Errorf("must be a JSON array")
+       }
+       kept := make([]any, 0, len(handlers))
+       for _, rawHandler := range handlers {
+               handler, ok := objectAt(rawHandler)
+               if !ok || !own(handler) {
+                       kept = append(kept, rawHandler)
+               }
+       }
+       return kept, nil
+}
+
+func hasFlatHook(value any, own func(map[string]any) bool) bool {
+       handlers, ok := value.([]any)
+       if !ok {
+               return false
+       }
+       for _, rawHandler := range handlers {
+               if handler, ok := objectAt(rawHandler); ok && own(handler) {
+                       return true
+               }
+       }
+       return false
+}
+
+// hookArgs are the arguments Gateway's own hook subcommand is invoked with.
+func hookArgs(agentId string, target Target) ([]string, error) {
+       url, err := recordsURL()
+       if err != nil {
+               return nil, err
+       }
+       token, err := IssueIngestToken(monitorTarget(target))
+       if err != nil {
+               return nil, err
+       }
+       return []string{
+               agenthook.Subcommand,
+               agenthook.OwnershipFlag,
+               "--agent", agentId,
+               "--records-url", url,
+               "--agent-path", target.Path,
+               "--user", target.Owner,
+               "--ingest-token", token,
+       }, nil
+}
+
+// hookCommandLine renders one command line for an agent whose configuration
+// takes a command string rather than a program and its arguments.
+func hookCommandLine(executable string, args []string) string {
+       parts := make([]string, 0, len(args)+1)
+       for _, part := range append([]string{executable}, args...) {
+               parts = append(parts, quoteArg(part))
+       }
+       return strings.Join(parts, " ")
+}
+
+// quoteArg quotes what a shell would otherwise split or swallow. Gateway's own
+// path is the usual case: it holds spaces on both Windows and macOS. A
+// backslash is left alone, since cmd.exe takes one literally inside quotes and
+// a path that reaches this anywhere else has none.
+func quoteArg(value string) string {
+       if value == "" {
+               return `""`
+       }
+       if !strings.ContainsAny(value, " \t\"'$`&|<>()") {
+               return value
+       }
+       return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
+}
+
+func gatewayExecutable() (string, error) {
+       executable, err := os.Executable()
+       if err != nil {
+               return "", fmt.Errorf("resolve Gateway executable: %w", err)
+       }
+       return executable, nil
+}
diff --git a/agentpatch/opencode.go b/agentpatch/opencode.go
new file mode 100644
index 0000000..cc80059
--- /dev/null
+++ b/agentpatch/opencode.go
@@ -0,0 +1,145 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       _ "embed"
+       "os"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agentmonitor"
+)
+
+// opencodePluginFile is the plugin Gateway owns. opencode loads every module 
in
+// the directory at session start, so installing one is writing this file.
+const opencodePluginFile = gatewayEntryName + ".js"
+
+// opencodeMonitorId is the agent every opencode record is reported under. The
+// CLI and the desktop app read one ~/.config/opencode, so they share this file
+// and the monitoring it feeds; the ingest credential is issued under this id 
so
+// that either front end can report through it.
+const opencodeMonitorId = "opencode"
+
+//go:embed opencode_plugin.js
+var opencodePluginSource string
+
+type opencodePatcher struct {
+       id string
+}
+
+func init() {
+       register(opencodePatcher{id: "opencode"})
+       register(opencodePatcher{id: "opencode-desktop"})
+}
+
+func (p opencodePatcher) AgentId() string { return p.id }
+
+func (opencodePatcher) Supported() bool { return true }
+
+func (p opencodePatcher) Patch(target Target) error {
+       path, err := p.pluginPath(target)
+       if err != nil {
+               return err
+       }
+       plugin, err := renderOpencodePlugin(target)
+       if err != nil {
+               return err
+       }
+       return Apply(target, func(changes *ChangeSet) error {
+               if err := changes.MkdirAll(filepath.Dir(path)); err != nil {
+                       return err
+               }
+               return changes.WriteFile(path, []byte(plugin), 0o644)
+       })
+}
+
+func (p opencodePatcher) Unpatch(target Target) error {
+       if err := Revert(target); err != nil {
+               return err
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p opencodePatcher) Status(target Target) (Status, error) {
+       path, err := p.pluginPath(target)
+       if err != nil {
+               return Status{}, err
+       }
+       data, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return Status{Detail: "opencode plugin is not 
installed"}, nil
+               }
+               return Status{}, err
+       }
+       current, err := recordsURL()
+       if err != nil {
+               return Status{}, err
+       }
+       // A plugin left by an older Gateway, or by one listening on another 
port,
+       // reports nowhere. Re-patching is what rewrites it.
+       if !strings.Contains(string(data), jsonString(current)) {
+               return Status{Detail: "opencode plugin needs refresh"}, nil
+       }
+       if !IsApplied(target) {
+               return Status{Patched: true, Detail: "opencode plugin was 
installed outside Gateway"}, nil
+       }
+       return Status{Patched: true, Detail: "opencode plugin active after 
restart"}, nil
+}
+
+func (opencodePatcher) PatchNotice(patched bool) (string, string) {
+       if patched {
+               return "Removes Gateway's audit-only opencode plugin. The CLI 
and the desktop app share it, so both stop reporting.",
+                       "Restart any opencode session that is already running."
+       }
+       return "Installs an audit-only opencode plugin. It observes sessions, 
tool calls and permission prompts, and changes none of them.",
+               "Restart any opencode session that is already running."
+}
+
+// pluginPath is the module opencode loads at session start. It sits beside the
+// configuration file the MCP and skill listings already read.
+func (opencodePatcher) pluginPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".config", "opencode", "plugin", 
opencodePluginFile), nil
+}
+
+func renderOpencodePlugin(target Target) (string, error) {
+       url, err := recordsURL()
+       if err != nil {
+               return "", err
+       }
+       token, err := IssueIngestToken(monitorTarget(target))
+       if err != nil {
+               return "", err
+       }
+
+       plugin := opencodePluginSource
+       for placeholder, value := range map[string]string{
+               "__CASBIN_GATEWAY_AGENT__":               
jsonString(opencodeMonitorId),
+               "__CASBIN_GATEWAY_RECORDS_URL__":         jsonString(url),
+               "__CASBIN_GATEWAY_AGENT_PATH__":          
jsonString(target.Path),
+               "__CASBIN_GATEWAY_OWNER__":               
jsonString(target.Owner),
+               "__CASBIN_GATEWAY_INGEST_TOKEN__":        jsonString(token),
+               "__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": 
jsonString(agentmonitor.IngestTokenHeader),
+       } {
+               plugin = strings.ReplaceAll(plugin, placeholder, value)
+       }
+       return plugin, nil
+}
diff --git a/agentpatch/opencode_plugin.js b/agentpatch/opencode_plugin.js
new file mode 100644
index 0000000..3b27fb2
--- /dev/null
+++ b/agentpatch/opencode_plugin.js
@@ -0,0 +1,182 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Audit-only opencode plugin. Every hook observes and returns; none of them
+// reads or writes the output object opencode passes in, so no tool call,
+// permission or message is changed by monitoring being on.
+
+const AGENT = __CASBIN_GATEWAY_AGENT__;
+const RECORDS_URL = __CASBIN_GATEWAY_RECORDS_URL__;
+const AGENT_PATH = __CASBIN_GATEWAY_AGENT_PATH__;
+const OWNER = __CASBIN_GATEWAY_OWNER__;
+const INGEST_TOKEN = __CASBIN_GATEWAY_INGEST_TOKEN__;
+const INGEST_TOKEN_HEADER = __CASBIN_GATEWAY_INGEST_TOKEN_HEADER__;
+const POST_TIMEOUT_MS = 3000;
+const MAX_OBJECT_CHARS = 16 * 1024;
+const MAX_TEXT_CHARS = 4 * 1024;
+
+const SENSITIVE_KEY = 
/secret|token|password|passwd|credential|private.?key|api.?key|access.?key|authorization|cookie/i;
+
+// The events worth a record, by the type opencode publishes them under.
+const EVENTS = {
+  "session.created": ["session", "start", ""],
+  "session.deleted": ["session", "end", ""],
+  "session.idle": ["session", "stop", "success"],
+  "session.error": ["session", "stop", "failure"],
+  "session.compacted": ["compact", "after", "success"],
+  "permission.replied": ["permission", "replied", ""],
+  "command.executed": ["command", "executed", "success"],
+  "file.edited": ["file", "edited", "success"],
+};
+
+function sanitize(value, key = "", depth = 0) {
+  if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
+  if (depth > 6) return undefined;
+  if (Array.isArray(value)) return value.slice(0, 50).map((item) => 
sanitize(item, "", depth + 1));
+  if (value && typeof value === "object") {
+    const result = {};
+    for (const [childKey, childValue] of Object.entries(value)) {
+      result[childKey] = sanitize(childValue, childKey, depth + 1);
+    }
+    return result;
+  }
+  if (typeof value === "string" && value.length > MAX_TEXT_CHARS) {
+    return `${value.slice(0, MAX_TEXT_CHARS)}...[truncated]`;
+  }
+  return value;
+}
+
+function objectFor(payload) {
+  try {
+    const value = JSON.stringify(sanitize(payload || {}));
+    if (!value) return "";
+    return value.length > MAX_OBJECT_CHARS ? `${value.slice(0, 
MAX_OBJECT_CHARS)}...[truncated]` : value;
+  } catch {
+    return "";
+  }
+}
+
+function post(record) {
+  try {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
+    const headers = { "Content-Type": "application/json" };
+    if (INGEST_TOKEN) headers[INGEST_TOKEN_HEADER] = INGEST_TOKEN;
+    fetch(RECORDS_URL, {
+      method: "POST",
+      headers,
+      body: JSON.stringify({
+        agent: AGENT,
+        agentPath: AGENT_PATH,
+        user: OWNER,
+        createdTime: new Date().toISOString(),
+        ...record,
+      }),
+      signal: controller.signal,
+    })
+      .catch(() => {})
+      .finally(() => clearTimeout(timer));
+  } catch {
+    // Monitoring is best effort and must never reach the session.
+  }
+}
+
+// promptText joins the text parts of a message. Attachments and file contents
+// are left out: the record says what was asked, not what was pasted in.
+function promptText(parts) {
+  try {
+    return (parts || [])
+      .filter((part) => part && part.type === "text" && typeof part.text === 
"string")
+      .map((part) => part.text)
+      .join("\n")
+      .slice(0, MAX_TEXT_CHARS);
+  } catch {
+    return "";
+  }
+}
+
+export const CasbinGatewayMonitor = async () => {
+  return {
+    event: async ({ event }) => {
+      const mapping = EVENTS[event?.type];
+      if (!mapping) return;
+      const [eventType, action, outcome] = mapping;
+      const properties = event.properties || {};
+      post({
+        eventType,
+        action,
+        outcome,
+        sessionKey: properties.sessionID || properties.info?.id || "",
+        object: objectFor(properties),
+      });
+    },
+
+    "chat.message": async (input, output) => {
+      post({
+        eventType: "prompt",
+        action: "submitted",
+        outcome: "attempted",
+        sessionKey: input?.sessionID || "",
+        promptId: input?.messageID || "",
+        model: input?.model ? 
`${input.model.providerID}/${input.model.modelID}` : "",
+        title: promptText(output?.parts).slice(0, 512),
+        object: objectFor({
+          agent: input?.agent,
+          variant: input?.variant,
+          text: promptText(output?.parts),
+        }),
+      });
+    },
+
+    "tool.execute.before": async (input, output) => {
+      post({
+        eventType: "tool",
+        action: "call",
+        outcome: "attempted",
+        sessionKey: input?.sessionID || "",
+        toolUseId: input?.callID || "",
+        toolName: input?.tool || "",
+        object: objectFor({ args: output?.args }),
+      });
+    },
+
+    "tool.execute.after": async (input, output) => {
+      post({
+        eventType: "tool",
+        action: "call",
+        outcome: "success",
+        sessionKey: input?.sessionID || "",
+        toolUseId: input?.callID || "",
+        toolName: input?.tool || "",
+        title: typeof output?.title === "string" ? output.title.slice(0, 512) 
: "",
+        // The output itself is not recorded: its length is enough to see that 
a
+        // call returned, and a tool result can be megabytes of file content.
+        object: objectFor({ outputLength: (output?.output || "").length }),
+      });
+    },
+
+    "permission.ask": async (input) => {
+      post({
+        eventType: "permission",
+        action: "requested",
+        outcome: "attempted",
+        sessionKey: input?.sessionID || "",
+        toolUseId: input?.callID || "",
+        toolName: input?.type || "",
+        title: typeof input?.title === "string" ? input.title.slice(0, 512) : 
"",
+        object: objectFor({ pattern: input?.pattern, metadata: input?.metadata 
}),
+      });
+    },
+  };
+};
diff --git a/agentpatch/patch.go b/agentpatch/patch.go
index 98fdb90..bc7ede6 100644
--- a/agentpatch/patch.go
+++ b/agentpatch/patch.go
@@ -19,6 +19,8 @@ package agentpatch
 import (
        "errors"
        "fmt"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
 )
 
 // ErrNotSupported reports an agent Gateway can discover but cannot monitor 
yet.
@@ -31,6 +33,14 @@ type Target struct {
        Owner   string `json:"owner"`
 }
 
+// monitorTarget is the installation an ingest credential is issued for. Front
+// ends that share one configuration report under one id, so the credential is
+// issued under that id rather than the one the operator patched from.
+func monitorTarget(target Target) Target {
+       target.AgentId = agentmonitor.MonitorAgentId(target.AgentId)
+       return target
+}
+
 // Status is the monitoring state shown beside an agent installation.
 type Status struct {
        Supported bool   `json:"supported"`
@@ -54,7 +64,13 @@ type noticer interface {
 
 var patchers = map[string]patcher{}
 
+// register panics on a duplicate id: two patchers for one agent means one of
+// them silently never runs, and which one depends on the order the files
+// initialize in.
 func register(value patcher) {
+       if _, exists := patchers[value.AgentId()]; exists {
+               panic("agentpatch: duplicate patcher for agent " + 
value.AgentId())
+       }
        patchers[value.AgentId()] = value
 }
 
diff --git a/agentpatch/unsupported.go b/agentpatch/unsupported.go
deleted file mode 100644
index 6ca284b..0000000
--- a/agentpatch/unsupported.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package agentpatch
-
-func init() {
-       for _, id := range []string{
-               "cursor", "cursor-agent", "windsurf", "opencode", 
"opencode-desktop", "gemini-cli",
-       } {
-               register(unimplemented{id: id})
-       }
-}
-
-type unimplemented struct {
-       id string
-}
-
-func (p unimplemented) AgentId() string { return p.id }
-
-func (unimplemented) Supported() bool { return false }
-
-func (unimplemented) Status(Target) (Status, error) {
-       return Status{Detail: "not implemented yet"}, nil
-}
-
-func (unimplemented) Patch(Target) error { return ErrNotSupported }
-
-func (unimplemented) Unpatch(Target) error { return ErrNotSupported }
diff --git a/agentpatch/windsurf.go b/agentpatch/windsurf.go
new file mode 100644
index 0000000..750ab6a
--- /dev/null
+++ b/agentpatch/windsurf.go
@@ -0,0 +1,198 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       "fmt"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agenthook"
+)
+
+type windsurfPatcher struct{}
+
+func init() {
+       register(windsurfPatcher{})
+}
+
+func (windsurfPatcher) AgentId() string { return "windsurf" }
+
+func (windsurfPatcher) Supported() bool { return true }
+
+func (p windsurfPatcher) Patch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, _, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       entry, err := newWindsurfHook(target)
+       if err != nil {
+               return err
+       }
+
+       hooks, err := windsurfHooks(config)
+       if err != nil {
+               return err
+       }
+       for _, event := range agenthook.WindsurfEvents {
+               handlers, err := withoutFlatHooks(hooks[event], isWindsurfHook)
+               if err != nil {
+                       return fmt.Errorf("hooks.%s: %w", event, err)
+               }
+               hooks[event] = append(handlers, entry)
+       }
+       return writeJSONConfig(path, config, mode)
+}
+
+func (p windsurfPatcher) Unpatch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return err
+       }
+       config, mode, exists, err := readJSONConfig(path)
+       if err != nil {
+               return err
+       }
+       // The credential is revoked on every unpatch path, including the ones 
that
+       // change no file, so a stale hook can never keep reporting.
+       if exists && removeWindsurfHooks(config) {
+               if err := writeJSONConfig(path, config, mode); err != nil {
+                       return err
+               }
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p windsurfPatcher) Status(target Target) (Status, error) {
+       path, err := p.hooksPath(target)
+       if err != nil {
+               return Status{}, err
+       }
+       config, _, exists, err := readJSONConfig(path)
+       if err != nil {
+               return Status{}, err
+       }
+       if !exists {
+               return Status{Detail: "Cascade hooks are not installed"}, nil
+       }
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return Status{Detail: "Cascade hooks are not installed"}, nil
+       }
+       for _, event := range agenthook.WindsurfEvents {
+               if !hasFlatHook(hooks[event], isWindsurfHook) {
+                       return Status{Detail: "Cascade hooks need refresh"}, nil
+               }
+       }
+       return Status{Patched: true, Detail: "Cascade hooks active"}, nil
+}
+
+func (windsurfPatcher) PatchNotice(patched bool) (string, string) {
+       if patched {
+               return "Removes Gateway's audit-only Cascade hooks.", "Restart 
Windsurf to stop running them."
+       }
+       return "Installs audit-only Cascade hooks. They observe events and 
never block an action.",
+               "Restart Windsurf to load them."
+}
+
+// hooksPath is the user-level Cascade hooks file, beside the MCP configuration
+// the skill and server listings already read.
+func (windsurfPatcher) hooksPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".codeium", "windsurf", "hooks.json"), nil
+}
+
+// newWindsurfHook is one entry. Cascade runs command through a shell and
+// powershell through PowerShell, where a quoted path is a string rather than
+// something to run until the call operator says otherwise.
+func newWindsurfHook(target Target) (map[string]any, error) {
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return nil, err
+       }
+       args, err := hookArgs("windsurf", target)
+       if err != nil {
+               return nil, err
+       }
+       command := hookCommandLine(executable, args)
+       return map[string]any{
+               "command":    command,
+               "powershell": "& " + command,
+               // Monitoring is not something the operator asked to watch 
happen.
+               "show_output": false,
+       }, nil
+}
+
+func windsurfHooks(config map[string]any) (map[string]any, error) {
+       value, exists := config["hooks"]
+       if !exists {
+               hooks := map[string]any{}
+               config["hooks"] = hooks
+               return hooks, nil
+       }
+       hooks, ok := objectAt(value)
+       if !ok {
+               return nil, fmt.Errorf("hooks must be a JSON object")
+       }
+       return hooks, nil
+}
+
+func removeWindsurfHooks(config map[string]any) bool {
+       hooks, ok := objectAt(config["hooks"])
+       if !ok {
+               return false
+       }
+       changed := false
+       for _, event := range agenthook.WindsurfEvents {
+               if !hasFlatHook(hooks[event], isWindsurfHook) {
+                       continue
+               }
+               handlers, err := withoutFlatHooks(hooks[event], isWindsurfHook)
+               if err != nil {
+                       continue
+               }
+               changed = true
+               if len(handlers) == 0 {
+                       delete(hooks, event)
+               } else {
+                       hooks[event] = handlers
+               }
+       }
+       if changed && len(hooks) == 0 {
+               delete(config, "hooks")
+       }
+       return changed
+}
+
+func isWindsurfHook(handler map[string]any) bool {
+       command, _ := handler["command"].(string)
+       return strings.Contains(command, agenthook.OwnershipFlag) &&
+               strings.Contains(command, "--agent windsurf") &&
+               strings.Contains(command, "--records-url")
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 84c4d4f..a3d28a7 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -1,813 +1,823 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import i18next from "i18next";
-
-import * as AgentBackend from "@/backend/AgentBackend";
-import * as Setting from "@/Setting";
-import type {BadgeVariant} from "@/components/ui/badge";
-import {providerProtocol, servesResponsesApi} from "@/lib/providers";
-import type {
-  Agent,
-  AgentCatalogEntry,
-  AgentInstallJob,
-  AgentInstance,
-  AgentRuntime,
-  AgentSession,
-  AgentUsage,
-  AgentUsageStat,
-  Provider,
-} from "@/types";
-
-/** How long a started app is given before its process is looked for again. */
-const runtimeSettleMs = 2000;
-
-/** Radix rejects an empty item value, so "the agent's own model" needs one. */
-export const builtinProvider = "-";
-
-/** Routing through the local proxy, which is what makes a switch hot. */
-export const gatewayMode = "gateway";
-export const directMode = "direct";
-
-/**
- * What the agent talks to with nothing bound: the model its own configuration
- * names, or the service it signs in to. Binding nothing puts it back on this.
- */
-export function agentBuiltin(agent: Agent) {
-  return agent.providerConfig?.builtin || i18next.t("agent:Built-in model");
-}
-
-/** The monitor keys the two Codex front ends under one agent id. */
-export function monitorAgentId(agentId: string) {
-  return agentId === "codex_vscode" || agentId === "codex-vscode" ? 
"codex-cli" : agentId;
-}
-
-/**
- * agentKey identifies one installation. The same agent can be installed twice
- * on a host - once per user account, or once per install method - so neither
- * the agent id nor the path is unique on its own.
- */
-export function agentKey(agent: Pick<Agent, "owner" | "path">) {
-  return `${agent.owner}:${agent.path}`;
-}
-
-/**
- * The base URL an agent is pointed at to reach its own provider. One URL 
serves
- * every wire format: an OpenAI client appends /chat/completions to it, Codex
- * appends /responses, and an Anthropic one appends /v1/messages.
- */
-export function agentProxyBaseUrl(agentId: string) {
-  return `${Setting.ServerUrl || 
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
-}
-
-/**
- * A config file that carries the same setting for good, for the agents that
- * have one. Empty when only the environment is known to work.
- */
-export function agentSetupNoteKey(agentId: string) {
-  switch (agentId) {
-  case "claude-code":
-    return "agent:Claude Code config hint";
-  case "claude-desktop":
-    return "agent:Claude Desktop sandbox hint";
-  case "codex-cli":
-    return "agent:Codex CLI config hint";
-  case "opencode":
-  case "opencode-desktop":
-    return "agent:opencode config hint";
-  case "gemini-cli":
-    return "agent:Gemini CLI config hint";
-  default:
-    return "";
-  }
-}
-
-/**
- * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
- * OpenAI Responses API, so it reaches a chat completions provider through
- * Gateway, which translates, or not at all.
- */
-export function agentNeedsResponsesApi(agentId: string) {
-  return agentId.startsWith("codex");
-}
-
-/**
- * Why an agent can only be routed through the gateway, as the key of the line
- * that says so. Empty for an agent that can also be bound to a provider
- * directly.
- */
-export function agentGatewayOnlyKey(agent: Agent, bound: Provider | undefined) 
{
-  if (agentProtocol(agent) === "gemini") {
-    return "agent:Gemini gateway only hint";
-  }
-  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(bound)) {
-    return "agent:Gateway only hint";
-  }
-  return "";
-}
-
-/**
- * The wire format an agent's client speaks, as the server reports it. Empty 
for
- * an agent Gateway does not know, which is then not filtered on.
- */
-export function agentProtocol(agent: Agent) {
-  return agent.providerConfig?.protocol ?? "";
-}
-
-/**
- * Whether an agent can be pointed at a provider serving this wire format.
- * Through the gateway any provider will do: the proxy translates between the
- * APIs. A direct binding writes the provider's own URL into the agent config,
- * so there the two have to speak the same one.
- */
-export function agentSpeaks(agent: Agent, protocol: string) {
-  if ((agent.mode || gatewayMode) !== directMode) {
-    return true;
-  }
-  const spoken = agentProtocol(agent);
-  return spoken === "" || spoken === protocol;
-}
-
-/**
- * Whether this agent, routed the way it is now, can be pointed at this 
provider
- * at all. Through the gateway anything goes, since the proxy translates; a
- * direct binding hands the agent the provider's own URL, and there both the
- * wire format and, for Codex, the Responses API have to line up.
- */
-export function agentCanUse(agent: Agent, provider: Provider) {
-  if ((agent.mode || gatewayMode) !== directMode) {
-    return true;
-  }
-  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
-    return false;
-  }
-  return agentSpeaks(agent, providerProtocol(provider.type));
-}
-
-/**
- * The detail page route for one installation. The path is only spelled out 
when
- * the same agent is installed more than once, which is what makes the id alone
- * ambiguous; otherwise the URL stays short.
- */
-export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
-  const url = `/agents/${encodeURIComponent(agent.agentId)}`;
-  const duplicated = agents.filter(candidate => candidate.agentId === 
agent.agentId).length > 1;
-  return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
-}
-
-/**
- * useAgents owns the local-installation scan and the monitoring toggle, which
- * the dashboard, the list page and the detail page all need.
- */
-export function useAgents(enabled = true) {
-  const [agents, setAgents] = React.useState<Agent[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [error, setError] = React.useState("");
-  const [busyKey, setBusyKey] = React.useState("");
-  // The run state is keyed by installation and loaded on its own, so it can be
-  // refreshed after a start or a stop without scanning the disk again.
-  const [runtime, setRuntime] = React.useState<Record<string, 
AgentRuntime>>({});
-  const [runBusyKey, setRunBusyKey] = React.useState("");
-  // A scan inside a container reads the container's filesystem, not the 
host's,
-  // which is why it can come back empty on a machine full of agents.
-  const [inContainer, setInContainer] = React.useState(false);
-  // A scan is only "done" once the first response has landed, which is what
-  // tells an empty list apart from a list that has not arrived yet.
-  const [scanned, setScanned] = React.useState(false);
-
-  const scan = React.useCallback(
-    (forceRefresh = false) => {
-      if (!enabled) {
-        return;
-      }
-
-      setLoading(true);
-      setError("");
-      AgentBackend.getAgents(forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setAgents(res.data ?? []);
-            setInContainer(res.data2 === true);
-          } else {
-            setError(res.msg || i18next.t("agent:Failed to scan agents"));
-          }
-        })
-        .catch(err => setError(err.message || String(err)))
-        .then(() => {
-          setLoading(false);
-          setScanned(true);
-        });
-    },
-    [enabled],
-  );
-
-  const loadRuntime = React.useCallback((forceRefresh = false) => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentProcesses(forceRefresh)
-      .then(res => {
-        if (res.status === "ok") {
-          const next: Record<string, AgentRuntime> = {};
-          (res.data ?? []).forEach(item => {
-            next[agentKey(item)] = item;
-          });
-          setRuntime(next);
-        }
-      })
-      .catch(() => undefined);
-  }, [enabled]);
-
-  React.useEffect(() => {
-    scan();
-    loadRuntime();
-  }, [scan, loadRuntime]);
-
-  /**
-   * toggleRunning starts the agent or ends every process of it. A desktop app
-   * takes a moment to show up in the process table, so the run state is read
-   * again shortly after the call rather than only once.
-   */
-  const toggleRunning = React.useCallback(
-    (agent: Agent, running: boolean) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-
-      setRunBusyKey(agentKey(agent));
-      (running ? AgentBackend.stopAgent(target) : 
AgentBackend.startAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${agent.name}`,
-            );
-            setTimeout(() => loadRuntime(true), runtimeSettleMs);
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg ||
-                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
-            );
-          }
-          loadRuntime(true);
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setRunBusyKey(""));
-    },
-    [loadRuntime],
-  );
-
-  const togglePatch = React.useCallback(
-    (agent: Agent) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-      const patched = agent.patched;
-
-      setBusyKey(agentKey(agent));
-      (patched ? AgentBackend.unpatchAgent(target) : 
AgentBackend.patchAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            const done = patched
-              ? i18next.t("agent:Monitoring disabled")
-              : i18next.t("agent:Monitoring enabled");
-            const followup = res.data?.followup || agent.followup;
-            Setting.showMessage(
-              "success",
-              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: 
${agent.name}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
update agent patch"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * setRouting saves where an agent's requests go. Only the changed part is
-   * passed in; the rest of the routing is carried over from the agent so a
-   * fallback list is not dropped by a provider change.
-   */
-  const setRouting = React.useCallback(
-    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
-      const next: AgentBackend.AgentRouting = {
-        provider: routing.provider ?? agent.provider,
-        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
-        mode: routing.mode ?? agent.mode ?? gatewayMode,
-      };
-
-      setBusyKey(agentKey(agent));
-      AgentBackend.updateAgentRouting(agent.agentId, next)
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              next.provider === ""
-                ? `${i18next.t("agent:Built-in model restored")}: 
${agentBuiltin(agent)}`
-                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to update agent provider"),
-            );
-            // The routing itself may already be stored: only writing the 
agent's
-            // own configuration file failed, and the page has to show that.
-            scan();
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * activateProvider is the one click the home page is built around: it stores
-   * the routing and, for an agent whose configuration file Gateway knows how 
to
-   * write, writes it, so the agent is on the new provider with nothing left to
-   * press.
-   */
-  const activateProvider = React.useCallback(
-    (agent: Agent, providerId: string) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-      const routing: AgentBackend.AgentRouting = {
-        provider: providerId,
-        fallbacks: [],
-        mode: agent.mode || gatewayMode,
-      };
-      const writes = providerId !== "" && (agent.providerConfig?.supported ?? 
false);
-
-      setBusyKey(agentKey(agent));
-      AgentBackend.updateAgentRouting(agent.agentId, routing)
-        .then(res => {
-          if (res.status !== "ok") {
-            throw new Error(res.msg || i18next.t("agent:Failed to update agent 
provider"));
-          }
-          return writes ? AgentBackend.applyAgentProvider(target) : res;
-        })
-        .then(res => {
-          if (res.status !== "ok") {
-            throw new Error(res.msg || i18next.t("agent:Failed to write the 
agent configuration"));
-          }
-          Setting.showMessage(
-            "success",
-            providerId === ""
-              ? `${i18next.t("agent:Built-in model restored")}: 
${agentBuiltin(agent)}`
-              : `${i18next.t("agent:Provider enabled")}: ${providerId}`,
-          );
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        // The routing may be stored even when the file write failed, so the
-        // scan runs either way and the page shows what actually happened.
-        .then(() => {
-          scan();
-          setBusyKey("");
-        });
-    },
-    [scan],
-  );
-
-  /**
-   * writeProvider writes the bound provider into the agent's own config file, 
or
-   * puts back what the file held before Gateway first wrote it.
-   */
-  const writeProvider = React.useCallback(
-    (agent: Agent, restore = false) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-
-      setBusyKey(agentKey(agent));
-      (restore
-        ? AgentBackend.restoreAgentProvider(target)
-        : AgentBackend.applyAgentProvider(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              restore
-                ? i18next.t("agent:Configuration restored")
-                : i18next.t("agent:Configuration written"),
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to write the agent 
configuration"),
-            );
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  return {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-    setRouting,
-    activateProvider,
-    writeProvider,
-  };
-}
-
-/**
- * useAgentCatalog lists the agents Gateway knows how to work with that this
- * machine has none of. The catalogue is built into the binary, so it is read
- * once and only the scan it is compared against changes.
- */
-export function useAgentCatalog(agents: Agent[], enabled = true) {
-  const [catalog, setCatalog] = React.useState<AgentCatalogEntry[]>([]);
-
-  React.useEffect(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentCatalog()
-      .then(res => setCatalog(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
-  }, [enabled]);
-
-  return React.useMemo(
-    () => catalog.filter(item => !agents.some(agent => agent.agentId === 
item.agentId)),
-    [catalog, agents],
-  );
-}
-
-/** How often a running install is asked about while the manager works. */
-const installPollMs = 1500;
-
-/**
- * useAgentInstall drives the installs and upgrades Gateway runs with the 
host's
- * own package managers. A package manager takes minutes, so the request that
- * starts one returns at once and the job is polled until it ends: the button
- * that started it keeps showing what is happening, and a reload does too.
- *
- * `onFinished` is called after every job that ends, which is when a rescan 
will
- * see the agent that was just installed.
- */
-export function useAgentInstall(enabled = true, onFinished?: () => void) {
-  const [jobs, setJobs] = React.useState<Record<string, AgentInstallJob>>({});
-  const [busyId, setBusyId] = React.useState("");
-  // The jobs this page is waiting on, so the one that finishes is reported
-  // once - and one left running by an earlier page is picked up on load.
-  const watched = React.useRef<Record<string, boolean>>({});
-  const finished = React.useRef(onFinished);
-  finished.current = onFinished;
-
-  const load = React.useCallback(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentInstallJobs()
-      .then(res => {
-        const list = res.status === "ok" ? (res.data ?? []) : [];
-        setJobs(Object.fromEntries(list.map(job => [job.agentId, job])));
-      })
-      .catch(() => undefined);
-  }, [enabled]);
-
-  React.useEffect(() => load(), [load]);
-
-  const running = Object.values(jobs).some(job => job.running);
-
-  React.useEffect(() => {
-    if (!running) {
-      return;
-    }
-
-    const timer = window.setInterval(load, installPollMs);
-    return () => window.clearInterval(timer);
-  }, [running, load]);
-
-  React.useEffect(() => {
-    Object.values(jobs).forEach(job => {
-      if (job.running) {
-        watched.current[job.agentId] = true;
-        return;
-      }
-      if (!watched.current[job.agentId]) {
-        return;
-      }
-      delete watched.current[job.agentId];
-      Setting.showMessage(
-        job.ok ? "success" : "error",
-        job.ok
-          ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" : 
"agent:Agent installed")}: ${job.name}`
-          : `${i18next.t("agent:Failed to install the agent")}: ${job.error || 
job.name}`,
-      );
-      finished.current?.();
-    });
-  }, [jobs]);
-
-  const start = React.useCallback(
-    (agentId: string, call: () => Promise<{status: string; msg?: string; 
data?: AgentInstallJob}>) => {
-      setBusyId(agentId);
-      call()
-        .then(res => {
-          if (res.status === "ok" && res.data) {
-            watched.current[agentId] = true;
-            setJobs(current => ({...current, [agentId]: res.data as 
AgentInstallJob}));
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
install the agent"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyId(""));
-    },
-    [],
-  );
-
-  const install = React.useCallback(
-    (agentId: string) => start(agentId, () => 
AgentBackend.installAgent(agentId)),
-    [start],
-  );
-
-  const upgrade = React.useCallback(
-    (agent: Agent) =>
-      start(agent.agentId, () =>
-        AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path, 
owner: agent.owner}),
-      ),
-    [start],
-  );
-
-  return {jobs, busyId, install, upgrade, reload: load};
-}
-
-/** The run state of one installation, before the first listing has landed. */
-export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) 
{
-  return runtime[agentKey(agent)];
-}
-
-/**
- * useAgentSessions loads the live session summaries. Each summary already
- * carries its agent, record count and last activity, so the totals a page 
shows
- * are folded out of this one request rather than out of the far larger raw
- * record list.
- */
-export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
-  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
-  const [error, setError] = React.useState("");
-
-  const load = React.useCallback(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentSessions(agent)
-      .then(res => {
-        if (res.status === "ok") {
-          setSessions(res.data ?? []);
-          setError("");
-        } else {
-          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
-        }
-      })
-      .catch(err => setError(err.message || String(err)));
-  }, [enabled, agent]);
-
-  React.useEffect(() => {
-    load();
-    if (!enabled || refreshMs <= 0) {
-      return undefined;
-    }
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, [enabled, load, refreshMs]);
-
-  const recordCount = React.useMemo(
-    () => sessions.reduce((total, session) => total + session.recordCount, 0),
-    [sessions],
-  );
-
-  return {sessions, recordCount, error, reload: load};
-}
-
-/**
- * Transcripts are written per tool rather than per installation: every Codex
- * front end shares one directory, so all of them read the same usage. That is
- * also the limit of what the files can tell apart.
- */
-export function usageAgentId(agentId: string) {
-  return agentId.startsWith("codex") ? "codex-cli" : agentId;
-}
-
-/** What one installation spent, out of the totals read off the transcripts. */
-export function usageOf(usage: AgentUsage | undefined, agent: Agent): 
AgentUsageStat | undefined {
-  return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
-}
-
-/** The badge colour every record table paints an outcome with. */
-export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
-  return (
-    {
-      attempted: "info",
-      denied: "warning",
-      failure: "danger",
-      success: "success",
-    } as const
-  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
-}
-
-/**
- * useAgentInstances owns the extra copies of one agent: the ones stored, what
- * each is signed in to, and whether each is running. Each has a state 
directory
- * of its own, so they start, stop and sign in independently of one another.
- */
-export function useAgentInstances(agentId = "", enabled = true) {
-  const [instances, setInstances] = React.useState<AgentInstance[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [busyName, setBusyName] = React.useState("");
-
-  const load = React.useCallback(
-    (forceRefresh = false) => {
-      if (!enabled) {
-        return;
-      }
-
-      setLoading(true);
-      AgentBackend.getAgentInstances(agentId, forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setInstances(res.data ?? []);
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setLoading(false));
-    },
-    [agentId, enabled],
-  );
-
-  React.useEffect(() => {
-    load();
-  }, [load]);
-
-  /**
-   * Adds one copy. The server names and lays it out, so there is nothing to 
ask
-   * first; the name is edited afterwards, once an account has signed in to it.
-   * The agent id stands in for the row that does not exist yet while it runs.
-   */
-  const add = React.useCallback(
-    (agent: Agent) => {
-      setBusyName(agent.agentId);
-      AgentBackend.addAgentInstance({agentId: agent.agentId, path: agent.path, 
owner: agent.owner})
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t("agent:Instance added")}: ${res.data?.instance ?? 
""}`,
-            );
-            load(true);
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
add the instance"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  const rename = React.useCallback(
-    (instance: AgentInstance, displayName: string) => {
-      AgentBackend.renameAgentInstance(instance.name, displayName)
-        .then(res => {
-          if (res.status !== "ok") {
-            Setting.showMessage("error", res.msg);
-          }
-          load();
-        })
-        .catch(err => Setting.showMessage("error", err.message || 
String(err)));
-    },
-    [load],
-  );
-
-  const remove = React.useCallback(
-    (instance: AgentInstance) => {
-      setBusyName(instance.name);
-      AgentBackend.deleteAgentInstance(instance.name)
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage("success", `${i18next.t("agent:Instance 
removed")}: ${instance.instance}`);
-            load(true);
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  /** Starts or stops one copy, leaving whatever else is running alone. */
-  const toggleRunning = React.useCallback(
-    (instance: AgentInstance) => {
-      const running = instance.running;
-
-      setBusyName(instance.name);
-      (running
-        ? AgentBackend.stopAgentInstance(instance.name)
-        : AgentBackend.startAgentInstance(instance.name))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${
-                instance.displayName || instance.instance
-              }`,
-            );
-            // A desktop app takes a moment to show up in the process table, so
-            // the run state is read again shortly after the call.
-            setTimeout(() => load(true), runtimeSettleMs);
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg ||
-                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
-            );
-          }
-          load(true);
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  /**
-   * Takes the URL scheme of the agent for one copy, or gives it back. A copy
-   * started without an account takes it on its own; this is for one that is
-   * already running, or that is signing in again.
-   */
-  const toggleCapture = React.useCallback(
-    (instance: AgentInstance) => {
-      const capture = !instance.capturing;
-
-      setBusyName(instance.name);
-      AgentBackend.captureAgentInstanceLink(instance.name, capture)
-        .then(res => {
-          if (res.status === "ok") {
-            if (capture) {
-              Setting.showMessage("success", i18next.t("agent:Waiting for the 
sign-in link"));
-            }
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-          load();
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyName(""));
-    },
-    [load],
-  );
-
-  return {
-    instances,
-    loading,
-    busyName,
-    reload: load,
-    add,
-    rename,
-    remove,
-    toggleRunning,
-    toggleCapture,
-  };
-}
-
-/** What one hook call returns, as the cards and the tables take it. */
-export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
-
-/** The instances belonging to one installation, out of a listing of them all. 
*/
-export function instancesOf(instances: AgentInstance[], agent: Agent) {
-  return instances.filter(instance => instance.agentId === agent.agentId);
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import type {BadgeVariant} from "@/components/ui/badge";
+import {providerProtocol, servesResponsesApi} from "@/lib/providers";
+import type {
+  Agent,
+  AgentCatalogEntry,
+  AgentInstallJob,
+  AgentInstance,
+  AgentRuntime,
+  AgentSession,
+  AgentUsage,
+  AgentUsageStat,
+  Provider,
+} from "@/types";
+
+/** How long a started app is given before its process is looked for again. */
+const runtimeSettleMs = 2000;
+
+/** Radix rejects an empty item value, so "the agent's own model" needs one. */
+export const builtinProvider = "-";
+
+/** Routing through the local proxy, which is what makes a switch hot. */
+export const gatewayMode = "gateway";
+export const directMode = "direct";
+
+/**
+ * What the agent talks to with nothing bound: the model its own configuration
+ * names, or the service it signs in to. Binding nothing puts it back on this.
+ */
+export function agentBuiltin(agent: Agent) {
+  return agent.providerConfig?.builtin || i18next.t("agent:Built-in model");
+}
+
+/** The monitor keys front ends that share a configuration under one agent id. 
*/
+export function monitorAgentId(agentId: string) {
+  switch (agentId) {
+  case "codex_vscode":
+  case "codex-vscode":
+    return "codex-cli";
+  case "opencode-desktop":
+    return "opencode";
+  case "cursor-agent":
+    return "cursor";
+  default:
+    return agentId;
+  }
+}
+
+/**
+ * agentKey identifies one installation. The same agent can be installed twice
+ * on a host - once per user account, or once per install method - so neither
+ * the agent id nor the path is unique on its own.
+ */
+export function agentKey(agent: Pick<Agent, "owner" | "path">) {
+  return `${agent.owner}:${agent.path}`;
+}
+
+/**
+ * The base URL an agent is pointed at to reach its own provider. One URL 
serves
+ * every wire format: an OpenAI client appends /chat/completions to it, Codex
+ * appends /responses, and an Anthropic one appends /v1/messages.
+ */
+export function agentProxyBaseUrl(agentId: string) {
+  return `${Setting.ServerUrl || 
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
+}
+
+/**
+ * A config file that carries the same setting for good, for the agents that
+ * have one. Empty when only the environment is known to work.
+ */
+export function agentSetupNoteKey(agentId: string) {
+  switch (agentId) {
+  case "claude-code":
+    return "agent:Claude Code config hint";
+  case "claude-desktop":
+    return "agent:Claude Desktop sandbox hint";
+  case "codex-cli":
+    return "agent:Codex CLI config hint";
+  case "opencode":
+  case "opencode-desktop":
+    return "agent:opencode config hint";
+  case "gemini-cli":
+    return "agent:Gemini CLI config hint";
+  default:
+    return "";
+  }
+}
+
+/**
+ * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
+ * OpenAI Responses API, so it reaches a chat completions provider through
+ * Gateway, which translates, or not at all.
+ */
+export function agentNeedsResponsesApi(agentId: string) {
+  return agentId.startsWith("codex");
+}
+
+/**
+ * Why an agent can only be routed through the gateway, as the key of the line
+ * that says so. Empty for an agent that can also be bound to a provider
+ * directly.
+ */
+export function agentGatewayOnlyKey(agent: Agent, bound: Provider | undefined) 
{
+  if (agentProtocol(agent) === "gemini") {
+    return "agent:Gemini gateway only hint";
+  }
+  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(bound)) {
+    return "agent:Gateway only hint";
+  }
+  return "";
+}
+
+/**
+ * The wire format an agent's client speaks, as the server reports it. Empty 
for
+ * an agent Gateway does not know, which is then not filtered on.
+ */
+export function agentProtocol(agent: Agent) {
+  return agent.providerConfig?.protocol ?? "";
+}
+
+/**
+ * Whether an agent can be pointed at a provider serving this wire format.
+ * Through the gateway any provider will do: the proxy translates between the
+ * APIs. A direct binding writes the provider's own URL into the agent config,
+ * so there the two have to speak the same one.
+ */
+export function agentSpeaks(agent: Agent, protocol: string) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  const spoken = agentProtocol(agent);
+  return spoken === "" || spoken === protocol;
+}
+
+/**
+ * Whether this agent, routed the way it is now, can be pointed at this 
provider
+ * at all. Through the gateway anything goes, since the proxy translates; a
+ * direct binding hands the agent the provider's own URL, and there both the
+ * wire format and, for Codex, the Responses API have to line up.
+ */
+export function agentCanUse(agent: Agent, provider: Provider) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
+    return false;
+  }
+  return agentSpeaks(agent, providerProtocol(provider.type));
+}
+
+/**
+ * The detail page route for one installation. The path is only spelled out 
when
+ * the same agent is installed more than once, which is what makes the id alone
+ * ambiguous; otherwise the URL stays short.
+ */
+export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
+  const url = `/agents/${encodeURIComponent(agent.agentId)}`;
+  const duplicated = agents.filter(candidate => candidate.agentId === 
agent.agentId).length > 1;
+  return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
+}
+
+/**
+ * useAgents owns the local-installation scan and the monitoring toggle, which
+ * the dashboard, the list page and the detail page all need.
+ */
+export function useAgents(enabled = true) {
+  const [agents, setAgents] = React.useState<Agent[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [busyKey, setBusyKey] = React.useState("");
+  // The run state is keyed by installation and loaded on its own, so it can be
+  // refreshed after a start or a stop without scanning the disk again.
+  const [runtime, setRuntime] = React.useState<Record<string, 
AgentRuntime>>({});
+  const [runBusyKey, setRunBusyKey] = React.useState("");
+  // A scan inside a container reads the container's filesystem, not the 
host's,
+  // which is why it can come back empty on a machine full of agents.
+  const [inContainer, setInContainer] = React.useState(false);
+  // A scan is only "done" once the first response has landed, which is what
+  // tells an empty list apart from a list that has not arrived yet.
+  const [scanned, setScanned] = React.useState(false);
+
+  const scan = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      setError("");
+      AgentBackend.getAgents(forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setAgents(res.data ?? []);
+            setInContainer(res.data2 === true);
+          } else {
+            setError(res.msg || i18next.t("agent:Failed to scan agents"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          setLoading(false);
+          setScanned(true);
+        });
+    },
+    [enabled],
+  );
+
+  const loadRuntime = React.useCallback((forceRefresh = false) => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentProcesses(forceRefresh)
+      .then(res => {
+        if (res.status === "ok") {
+          const next: Record<string, AgentRuntime> = {};
+          (res.data ?? []).forEach(item => {
+            next[agentKey(item)] = item;
+          });
+          setRuntime(next);
+        }
+      })
+      .catch(() => undefined);
+  }, [enabled]);
+
+  React.useEffect(() => {
+    scan();
+    loadRuntime();
+  }, [scan, loadRuntime]);
+
+  /**
+   * toggleRunning starts the agent or ends every process of it. A desktop app
+   * takes a moment to show up in the process table, so the run state is read
+   * again shortly after the call rather than only once.
+   */
+  const toggleRunning = React.useCallback(
+    (agent: Agent, running: boolean) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+
+      setRunBusyKey(agentKey(agent));
+      (running ? AgentBackend.stopAgent(target) : 
AgentBackend.startAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${agent.name}`,
+            );
+            setTimeout(() => loadRuntime(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
+            );
+          }
+          loadRuntime(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setRunBusyKey(""));
+    },
+    [loadRuntime],
+  );
+
+  const togglePatch = React.useCallback(
+    (agent: Agent) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+      const patched = agent.patched;
+
+      setBusyKey(agentKey(agent));
+      (patched ? AgentBackend.unpatchAgent(target) : 
AgentBackend.patchAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            const done = patched
+              ? i18next.t("agent:Monitoring disabled")
+              : i18next.t("agent:Monitoring enabled");
+            const followup = res.data?.followup || agent.followup;
+            Setting.showMessage(
+              "success",
+              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: 
${agent.name}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
update agent patch"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * setRouting saves where an agent's requests go. Only the changed part is
+   * passed in; the rest of the routing is carried over from the agent so a
+   * fallback list is not dropped by a provider change.
+   */
+  const setRouting = React.useCallback(
+    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
+      const next: AgentBackend.AgentRouting = {
+        provider: routing.provider ?? agent.provider,
+        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
+        mode: routing.mode ?? agent.mode ?? gatewayMode,
+      };
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, next)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              next.provider === ""
+                ? `${i18next.t("agent:Built-in model restored")}: 
${agentBuiltin(agent)}`
+                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to update agent provider"),
+            );
+            // The routing itself may already be stored: only writing the 
agent's
+            // own configuration file failed, and the page has to show that.
+            scan();
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * activateProvider is the one click the home page is built around: it stores
+   * the routing and, for an agent whose configuration file Gateway knows how 
to
+   * write, writes it, so the agent is on the new provider with nothing left to
+   * press.
+   */
+  const activateProvider = React.useCallback(
+    (agent: Agent, providerId: string) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+      const routing: AgentBackend.AgentRouting = {
+        provider: providerId,
+        fallbacks: [],
+        mode: agent.mode || gatewayMode,
+      };
+      const writes = providerId !== "" && (agent.providerConfig?.supported ?? 
false);
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, routing)
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to update agent 
provider"));
+          }
+          return writes ? AgentBackend.applyAgentProvider(target) : res;
+        })
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to write the 
agent configuration"));
+          }
+          Setting.showMessage(
+            "success",
+            providerId === ""
+              ? `${i18next.t("agent:Built-in model restored")}: 
${agentBuiltin(agent)}`
+              : `${i18next.t("agent:Provider enabled")}: ${providerId}`,
+          );
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        // The routing may be stored even when the file write failed, so the
+        // scan runs either way and the page shows what actually happened.
+        .then(() => {
+          scan();
+          setBusyKey("");
+        });
+    },
+    [scan],
+  );
+
+  /**
+   * writeProvider writes the bound provider into the agent's own config file, 
or
+   * puts back what the file held before Gateway first wrote it.
+   */
+  const writeProvider = React.useCallback(
+    (agent: Agent, restore = false) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+
+      setBusyKey(agentKey(agent));
+      (restore
+        ? AgentBackend.restoreAgentProvider(target)
+        : AgentBackend.applyAgentProvider(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              restore
+                ? i18next.t("agent:Configuration restored")
+                : i18next.t("agent:Configuration written"),
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to write the agent 
configuration"),
+            );
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  return {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    setRouting,
+    activateProvider,
+    writeProvider,
+  };
+}
+
+/**
+ * useAgentCatalog lists the agents Gateway knows how to work with that this
+ * machine has none of. The catalogue is built into the binary, so it is read
+ * once and only the scan it is compared against changes.
+ */
+export function useAgentCatalog(agents: Agent[], enabled = true) {
+  const [catalog, setCatalog] = React.useState<AgentCatalogEntry[]>([]);
+
+  React.useEffect(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentCatalog()
+      .then(res => setCatalog(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, [enabled]);
+
+  return React.useMemo(
+    () => catalog.filter(item => !agents.some(agent => agent.agentId === 
item.agentId)),
+    [catalog, agents],
+  );
+}
+
+/** How often a running install is asked about while the manager works. */
+const installPollMs = 1500;
+
+/**
+ * useAgentInstall drives the installs and upgrades Gateway runs with the 
host's
+ * own package managers. A package manager takes minutes, so the request that
+ * starts one returns at once and the job is polled until it ends: the button
+ * that started it keeps showing what is happening, and a reload does too.
+ *
+ * `onFinished` is called after every job that ends, which is when a rescan 
will
+ * see the agent that was just installed.
+ */
+export function useAgentInstall(enabled = true, onFinished?: () => void) {
+  const [jobs, setJobs] = React.useState<Record<string, AgentInstallJob>>({});
+  const [busyId, setBusyId] = React.useState("");
+  // The jobs this page is waiting on, so the one that finishes is reported
+  // once - and one left running by an earlier page is picked up on load.
+  const watched = React.useRef<Record<string, boolean>>({});
+  const finished = React.useRef(onFinished);
+  finished.current = onFinished;
+
+  const load = React.useCallback(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentInstallJobs()
+      .then(res => {
+        const list = res.status === "ok" ? (res.data ?? []) : [];
+        setJobs(Object.fromEntries(list.map(job => [job.agentId, job])));
+      })
+      .catch(() => undefined);
+  }, [enabled]);
+
+  React.useEffect(() => load(), [load]);
+
+  const running = Object.values(jobs).some(job => job.running);
+
+  React.useEffect(() => {
+    if (!running) {
+      return;
+    }
+
+    const timer = window.setInterval(load, installPollMs);
+    return () => window.clearInterval(timer);
+  }, [running, load]);
+
+  React.useEffect(() => {
+    Object.values(jobs).forEach(job => {
+      if (job.running) {
+        watched.current[job.agentId] = true;
+        return;
+      }
+      if (!watched.current[job.agentId]) {
+        return;
+      }
+      delete watched.current[job.agentId];
+      Setting.showMessage(
+        job.ok ? "success" : "error",
+        job.ok
+          ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" : 
"agent:Agent installed")}: ${job.name}`
+          : `${i18next.t("agent:Failed to install the agent")}: ${job.error || 
job.name}`,
+      );
+      finished.current?.();
+    });
+  }, [jobs]);
+
+  const start = React.useCallback(
+    (agentId: string, call: () => Promise<{status: string; msg?: string; 
data?: AgentInstallJob}>) => {
+      setBusyId(agentId);
+      call()
+        .then(res => {
+          if (res.status === "ok" && res.data) {
+            watched.current[agentId] = true;
+            setJobs(current => ({...current, [agentId]: res.data as 
AgentInstallJob}));
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
install the agent"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyId(""));
+    },
+    [],
+  );
+
+  const install = React.useCallback(
+    (agentId: string) => start(agentId, () => 
AgentBackend.installAgent(agentId)),
+    [start],
+  );
+
+  const upgrade = React.useCallback(
+    (agent: Agent) =>
+      start(agent.agentId, () =>
+        AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path, 
owner: agent.owner}),
+      ),
+    [start],
+  );
+
+  return {jobs, busyId, install, upgrade, reload: load};
+}
+
+/** The run state of one installation, before the first listing has landed. */
+export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) 
{
+  return runtime[agentKey(agent)];
+}
+
+/**
+ * useAgentSessions loads the live session summaries. Each summary already
+ * carries its agent, record count and last activity, so the totals a page 
shows
+ * are folded out of this one request rather than out of the far larger raw
+ * record list.
+ */
+export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
+  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
+  const [error, setError] = React.useState("");
+
+  const load = React.useCallback(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentSessions(agent)
+      .then(res => {
+        if (res.status === "ok") {
+          setSessions(res.data ?? []);
+          setError("");
+        } else {
+          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
+        }
+      })
+      .catch(err => setError(err.message || String(err)));
+  }, [enabled, agent]);
+
+  React.useEffect(() => {
+    load();
+    if (!enabled || refreshMs <= 0) {
+      return undefined;
+    }
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, [enabled, load, refreshMs]);
+
+  const recordCount = React.useMemo(
+    () => sessions.reduce((total, session) => total + session.recordCount, 0),
+    [sessions],
+  );
+
+  return {sessions, recordCount, error, reload: load};
+}
+
+/**
+ * Transcripts are written per tool rather than per installation: every Codex
+ * front end shares one directory, so all of them read the same usage. That is
+ * also the limit of what the files can tell apart.
+ */
+export function usageAgentId(agentId: string) {
+  return agentId.startsWith("codex") ? "codex-cli" : agentId;
+}
+
+/** What one installation spent, out of the totals read off the transcripts. */
+export function usageOf(usage: AgentUsage | undefined, agent: Agent): 
AgentUsageStat | undefined {
+  return usage?.agents.find(item => item.name === usageAgentId(agent.agentId));
+}
+
+/** The badge colour every record table paints an outcome with. */
+export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
+  return (
+    {
+      attempted: "info",
+      denied: "warning",
+      failure: "danger",
+      success: "success",
+    } as const
+  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
+}
+
+/**
+ * useAgentInstances owns the extra copies of one agent: the ones stored, what
+ * each is signed in to, and whether each is running. Each has a state 
directory
+ * of its own, so they start, stop and sign in independently of one another.
+ */
+export function useAgentInstances(agentId = "", enabled = true) {
+  const [instances, setInstances] = React.useState<AgentInstance[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [busyName, setBusyName] = React.useState("");
+
+  const load = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      AgentBackend.getAgentInstances(agentId, forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setInstances(res.data ?? []);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setLoading(false));
+    },
+    [agentId, enabled],
+  );
+
+  React.useEffect(() => {
+    load();
+  }, [load]);
+
+  /**
+   * Adds one copy. The server names and lays it out, so there is nothing to 
ask
+   * first; the name is edited afterwards, once an account has signed in to it.
+   * The agent id stands in for the row that does not exist yet while it runs.
+   */
+  const add = React.useCallback(
+    (agent: Agent) => {
+      setBusyName(agent.agentId);
+      AgentBackend.addAgentInstance({agentId: agent.agentId, path: agent.path, 
owner: agent.owner})
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t("agent:Instance added")}: ${res.data?.instance ?? 
""}`,
+            );
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
add the instance"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  const rename = React.useCallback(
+    (instance: AgentInstance, displayName: string) => {
+      AgentBackend.renameAgentInstance(instance.name, displayName)
+        .then(res => {
+          if (res.status !== "ok") {
+            Setting.showMessage("error", res.msg);
+          }
+          load();
+        })
+        .catch(err => Setting.showMessage("error", err.message || 
String(err)));
+    },
+    [load],
+  );
+
+  const remove = React.useCallback(
+    (instance: AgentInstance) => {
+      setBusyName(instance.name);
+      AgentBackend.deleteAgentInstance(instance.name)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage("success", `${i18next.t("agent:Instance 
removed")}: ${instance.instance}`);
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  /** Starts or stops one copy, leaving whatever else is running alone. */
+  const toggleRunning = React.useCallback(
+    (instance: AgentInstance) => {
+      const running = instance.running;
+
+      setBusyName(instance.name);
+      (running
+        ? AgentBackend.stopAgentInstance(instance.name)
+        : AgentBackend.startAgentInstance(instance.name))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${
+                instance.displayName || instance.instance
+              }`,
+            );
+            // A desktop app takes a moment to show up in the process table, so
+            // the run state is read again shortly after the call.
+            setTimeout(() => load(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
+            );
+          }
+          load(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  /**
+   * Takes the URL scheme of the agent for one copy, or gives it back. A copy
+   * started without an account takes it on its own; this is for one that is
+   * already running, or that is signing in again.
+   */
+  const toggleCapture = React.useCallback(
+    (instance: AgentInstance) => {
+      const capture = !instance.capturing;
+
+      setBusyName(instance.name);
+      AgentBackend.captureAgentInstanceLink(instance.name, capture)
+        .then(res => {
+          if (res.status === "ok") {
+            if (capture) {
+              Setting.showMessage("success", i18next.t("agent:Waiting for the 
sign-in link"));
+            }
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+          load();
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  return {
+    instances,
+    loading,
+    busyName,
+    reload: load,
+    add,
+    rename,
+    remove,
+    toggleRunning,
+    toggleCapture,
+  };
+}
+
+/** What one hook call returns, as the cards and the tables take it. */
+export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
+
+/** The instances belonging to one installation, out of a listing of them all. 
*/
+export function instancesOf(instances: AgentInstance[], agent: Agent) {
+  return instances.filter(instance => instance.agentId === agent.agentId);
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to