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]