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 3e103d2  feat(agent): show the run status and start or stop an agent
3e103d2 is described below

commit 3e103d2e1c5d4dbad983b020529bb42bccc26410
Author: Yang Luo <[email protected]>
AuthorDate: Sat Aug 22 23:42:11 2026 +0800

    feat(agent): show the run status and start or stop an agent
---
 agent/fingerprint.go                   |   5 +-
 agent/fingerprints/claude-desktop.json |   1 +
 agent/fingerprints/codex.json          |   1 +
 agent/fingerprints/cursor.json         |   1 +
 agent/fingerprints/windsurf.json       |   1 +
 agent/launch.go                        | 107 +++++++++++++++++
 agentprocess/launch_darwin.go          |  48 ++++++++
 agentprocess/launch_linux.go           |  47 ++++++++
 agentprocess/launch_unix.go            |  61 ++++++++++
 agentprocess/launch_windows.go         |  50 ++++++++
 agentprocess/list_darwin.go            |  84 ++++++++++++++
 agentprocess/list_linux.go             |  72 ++++++++++++
 agentprocess/list_windows.go           | 119 +++++++++++++++++++
 agentprocess/process.go                | 206 +++++++++++++++++++++++++++++++++
 controllers/agent.go                   |  31 +++--
 controllers/agent_process.go           | 115 ++++++++++++++++++
 routers/router.go                      |   3 +
 web/src/backend/AgentBackend.ts        |  21 +++-
 web/src/components/AgentRunControl.tsx |  95 +++++++++++++++
 web/src/lib/agents.ts                  |  72 +++++++++++-
 web/src/locales/en/data.json           |  13 +++
 web/src/locales/zh/data.json           |  13 +++
 web/src/pages/AgentDetailPage.tsx      |  74 +++++++++++-
 web/src/pages/AgentsPage.tsx           | 109 ++++++++++++-----
 web/src/types.ts                       |  14 +++
 25 files changed, 1313 insertions(+), 50 deletions(-)

diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 0735ce3..a3ffd7e 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -21,7 +21,10 @@ type Fingerprint struct {
        ID          string `json:"id"`
        DisplayName string `json:"displayName"`
 
-       ExecName            string              `json:"execName,omitempty"`
+       ExecName string `json:"execName,omitempty"`
+       // Desktop marks a windowed app, which is launched without a console.
+       Desktop bool `json:"desktop,omitempty"`
+
        StateDir            string              `json:"stateDir,omitempty"`
        NpmPackage          string              `json:"npmPackage,omitempty"`
        ExtraUnixNpmDirs    []string            
`json:"extraUnixNpmDirs,omitempty"`
diff --git a/agent/fingerprints/claude-desktop.json 
b/agent/fingerprints/claude-desktop.json
index 5f95584..bfa68c6 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -2,6 +2,7 @@
   "id": "claude-desktop",
   "displayName": "Claude Desktop",
   "execName": "claude",
+  "desktop": true,
   "msixFamily": "Claude_pzs8sxrjxfjjc",
   "desktopInstallerDir": "AnthropicClaude"
 }
diff --git a/agent/fingerprints/codex.json b/agent/fingerprints/codex.json
index 6dc0e6e..c19294d 100644
--- a/agent/fingerprints/codex.json
+++ b/agent/fingerprints/codex.json
@@ -2,5 +2,6 @@
   "id": "codex",
   "displayName": "ChatGPT Desktop (Codex)",
   "execName": "ChatGPT",
+  "desktop": true,
   "msixFamily": "OpenAI.Codex_2p2nqsd0c76g0"
 }
diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json
index 6f4bbad..a6c9208 100644
--- a/agent/fingerprints/cursor.json
+++ b/agent/fingerprints/cursor.json
@@ -2,6 +2,7 @@
   "id": "cursor",
   "displayName": "Cursor",
   "execName": "Cursor",
+  "desktop": true,
   "windowsProgramDirs": ["cursor"],
   "homebrewCasks": ["cursor"]
 }
diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json
index c18800f..14cff1e 100644
--- a/agent/fingerprints/windsurf.json
+++ b/agent/fingerprints/windsurf.json
@@ -2,6 +2,7 @@
   "id": "windsurf",
   "displayName": "Windsurf",
   "execName": "Windsurf",
+  "desktop": true,
   "windowsProgramDirs": ["Windsurf"],
   "homebrewCasks": ["windsurf"]
 }
diff --git a/agent/launch.go b/agent/launch.go
new file mode 100644
index 0000000..236663d
--- /dev/null
+++ b/agent/launch.go
@@ -0,0 +1,107 @@
+// 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 agent
+
+import (
+       "os"
+       "path/filepath"
+       "runtime"
+       "strings"
+)
+
+// Launch is how one installation is started.
+type Launch struct {
+       // Executable is the file to run, empty when none was resolved.
+       Executable string
+       // Desktop marks a windowed app, which needs no console of its own.
+       Desktop bool
+}
+
+// LaunchOf resolves what to run for one installation.
+func LaunchOf(installation Installation) Launch {
+       launch := Launch{}
+       execName := ""
+       for i := range fingerprints {
+               if fingerprints[i].ID == installation.AgentId {
+                       execName = fingerprints[i].ExecName
+                       launch.Desktop = fingerprints[i].Desktop
+                       break
+               }
+       }
+       launch.Executable = executableOf(installation.Path, execName)
+       return launch
+}
+
+// executableOf resolves the launcher of an installation. A package manager
+// records the package directory rather than a program, so the shim it 
installed
+// beside the tree is what runs the agent.
+func executableOf(path, execName string) string {
+       if isFile(path) {
+               return path
+       }
+       if execName == "" {
+               return ""
+       }
+
+       root := nodeModulesRoot(path)
+       if root == "" {
+               return ""
+       }
+       for _, candidate := range npmShims(root, execName) {
+               if isFile(candidate) {
+                       return candidate
+               }
+       }
+       return ""
+}
+
+func isFile(path string) bool {
+       info, err := os.Stat(path)
+       return err == nil && info.Mode().IsRegular()
+}
+
+// nodeModulesRoot is the directory holding the node_modules tree path sits in.
+func nodeModulesRoot(path string) string {
+       for dir := filepath.Clean(path); ; {
+               parent := filepath.Dir(dir)
+               if parent == dir {
+                       return ""
+               }
+               if strings.EqualFold(filepath.Base(dir), "node_modules") {
+                       return parent
+               }
+               dir = parent
+       }
+}
+
+// npmShims are the launcher layouts npm writes: beside the tree on Windows,
+// under the prefix's bin/ elsewhere.
+func npmShims(root, execName string) []string {
+       local := filepath.Join(root, "node_modules", ".bin", execName)
+       if runtime.GOOS == "windows" {
+               return []string{
+                       filepath.Join(root, execName+".cmd"),
+                       filepath.Join(root, execName+".exe"),
+                       local + ".cmd",
+               }
+       }
+
+       prefix := root
+       // A Unix global root is <prefix>/lib/node_modules.
+       if filepath.Base(root) == "lib" {
+               prefix = filepath.Dir(root)
+       }
+       return []string{filepath.Join(prefix, "bin", execName), local}
+}
diff --git a/agentprocess/launch_darwin.go b/agentprocess/launch_darwin.go
new file mode 100644
index 0000000..300b738
--- /dev/null
+++ b/agentprocess/launch_darwin.go
@@ -0,0 +1,48 @@
+// 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.
+
+//go:build darwin
+
+package agentprocess
+
+import (
+       "path/filepath"
+       "strings"
+)
+
+func start(target Target) error {
+       if target.Desktop {
+               if bundle := appBundle(target.Executable); bundle != "" {
+                       return spawn("open", "-a", bundle)
+               }
+               return spawn(target.Executable)
+       }
+       return spawn("open", "-a", "Terminal", target.Executable)
+}
+
+// appBundle is the .app an executable lives in, empty outside a bundle. 
Opening
+// the bundle rather than the binary inside it is what registers the app with 
the
+// window server.
+func appBundle(executable string) string {
+       for dir := filepath.Dir(executable); ; {
+               if strings.HasSuffix(dir, ".app") {
+                       return dir
+               }
+               parent := filepath.Dir(dir)
+               if parent == dir {
+                       return ""
+               }
+               dir = parent
+       }
+}
diff --git a/agentprocess/launch_linux.go b/agentprocess/launch_linux.go
new file mode 100644
index 0000000..442beb3
--- /dev/null
+++ b/agentprocess/launch_linux.go
@@ -0,0 +1,47 @@
+// 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.
+
+//go:build linux
+
+package agentprocess
+
+import (
+       "errors"
+       "os/exec"
+)
+
+// terminals are tried in order, each with the flag that ends its own options.
+var terminals = [][]string{
+       {"x-terminal-emulator", "-e"},
+       {"gnome-terminal", "--"},
+       {"konsole", "-e"},
+       {"xfce4-terminal", "-e"},
+       {"alacritty", "-e"},
+       {"xterm", "-e"},
+}
+
+func start(target Target) error {
+       if target.Desktop {
+               return spawn(target.Executable)
+       }
+
+       for _, terminal := range terminals {
+               path, err := exec.LookPath(terminal[0])
+               if err != nil {
+                       continue
+               }
+               return spawn(path, append(terminal[1:], target.Executable)...)
+       }
+       return errors.New("no terminal emulator was found to run this agent in")
+}
diff --git a/agentprocess/launch_unix.go b/agentprocess/launch_unix.go
new file mode 100644
index 0000000..8913ecd
--- /dev/null
+++ b/agentprocess/launch_unix.go
@@ -0,0 +1,61 @@
+// 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.
+
+//go:build !windows
+
+package agentprocess
+
+import (
+       "os"
+       "os/exec"
+       "syscall"
+       "time"
+)
+
+const stopGrace = 2 * time.Second
+
+// spawn runs a launcher in its own session, so the agent outlives Gateway.
+func spawn(name string, args ...string) error {
+       cmd := exec.Command(name, args...)
+       cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+       if home, err := os.UserHomeDir(); err == nil {
+               cmd.Dir = home
+       }
+       if err := cmd.Start(); err != nil {
+               return err
+       }
+       go func() { _ = cmd.Wait() }()
+       return nil
+}
+
+// stop asks first and insists afterwards, so an agent gets to write out its
+// session before it is killed.
+func stop(pid int) error {
+       process, err := os.FindProcess(pid)
+       if err != nil {
+               return err
+       }
+       if err := process.Signal(syscall.SIGTERM); err != nil {
+               return err
+       }
+
+       deadline := time.Now().Add(stopGrace)
+       for time.Now().Before(deadline) {
+               time.Sleep(100 * time.Millisecond)
+               if process.Signal(syscall.Signal(0)) != nil {
+                       return nil
+               }
+       }
+       return process.Signal(syscall.SIGKILL)
+}
diff --git a/agentprocess/launch_windows.go b/agentprocess/launch_windows.go
new file mode 100644
index 0000000..003f0d4
--- /dev/null
+++ b/agentprocess/launch_windows.go
@@ -0,0 +1,50 @@
+// 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.
+
+//go:build windows
+
+package agentprocess
+
+import (
+       "os"
+       "os/exec"
+       "strconv"
+       "syscall"
+)
+
+// start hands the launch to "cmd /c start", which detaches the app from 
Gateway
+// and gives a CLI the console window it needs. Its quoting rules are its own, 
so
+// the command line is written literally rather than assembled by os/exec.
+func start(target Target) error {
+       quoted := `"` + target.Executable + `"`
+       line := `cmd.exe /c start "" ` + quoted
+       if !target.Desktop {
+               line = `cmd.exe /c start "" cmd.exe /k ` + quoted
+       }
+
+       cmd := exec.Command("cmd.exe")
+       cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: line}
+       if home, err := os.UserHomeDir(); err == nil {
+               cmd.Dir = home
+       }
+       return cmd.Run()
+}
+
+// stop takes the whole tree: a CLI runs under the console window that started
+// it, and killing the window alone leaves the agent behind.
+func stop(pid int) error {
+       cmd := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid))
+       cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+       return cmd.Run()
+}
diff --git a/agentprocess/list_darwin.go b/agentprocess/list_darwin.go
new file mode 100644
index 0000000..c0fe273
--- /dev/null
+++ b/agentprocess/list_darwin.go
@@ -0,0 +1,84 @@
+// 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.
+
+//go:build darwin
+
+package agentprocess
+
+import (
+       "context"
+       "os/exec"
+       "strconv"
+       "strings"
+)
+
+const psPath = "/bin/ps"
+
+// list joins two ps listings: "comm" is the executable's full path on macOS,
+// while "command" carries the arguments an interpreted agent is named in.
+func list(ctx context.Context, withCommands bool) []Process {
+       paths := psImages(ctx)
+       if len(paths) == 0 {
+               return nil
+       }
+
+       commands := map[int]string{}
+       if withCommands {
+               commands = psCommands(ctx)
+       }
+       result := make([]Process, 0, len(paths))
+       for pid, path := range paths {
+               result = append(result, Process{Pid: pid, Path: path, Command: 
commands[pid]})
+       }
+       return result
+}
+
+func psImages(ctx context.Context) map[int]string {
+       paths := map[int]string{}
+       for _, line := range psLines(ctx, "pid=,comm=") {
+               fields := strings.Fields(line)
+               if len(fields) < 2 {
+                       continue
+               }
+               pid, err := strconv.Atoi(fields[0])
+               if err != nil || pid <= 0 {
+                       continue
+               }
+               // A path may hold spaces, so the image is everything after the 
pid.
+               paths[pid] = strings.Join(fields[1:], " ")
+       }
+       return paths
+}
+
+func psCommands(ctx context.Context) map[int]string {
+       commands := map[int]string{}
+       for _, line := range psLines(ctx, "pid=,command=") {
+               fields := strings.SplitN(strings.TrimSpace(line), " ", 2)
+               if len(fields) < 2 {
+                       continue
+               }
+               if pid, err := strconv.Atoi(fields[0]); err == nil && pid > 0 {
+                       commands[pid] = fields[1]
+               }
+       }
+       return commands
+}
+
+func psLines(ctx context.Context, format string) []string {
+       output, err := exec.CommandContext(ctx, psPath, "-axww", "-o", 
format).Output()
+       if err != nil {
+               return nil
+       }
+       return strings.Split(string(output), "\n")
+}
diff --git a/agentprocess/list_linux.go b/agentprocess/list_linux.go
new file mode 100644
index 0000000..4108fa5
--- /dev/null
+++ b/agentprocess/list_linux.go
@@ -0,0 +1,72 @@
+// 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.
+
+//go:build linux
+
+package agentprocess
+
+import (
+       "context"
+       "fmt"
+       "os"
+       "strconv"
+       "strings"
+)
+
+// maxCmdlineSize limits reads from a process command line.
+const maxCmdlineSize = 64 * 1024
+
+func list(ctx context.Context, withCommands bool) []Process {
+       entries, err := os.ReadDir("/proc")
+       if err != nil {
+               return nil
+       }
+
+       var result []Process
+       for _, entry := range entries {
+               if ctx.Err() != nil {
+                       return result
+               }
+               pid, err := strconv.Atoi(entry.Name())
+               if err != nil || pid <= 0 {
+                       continue
+               }
+               path, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
+               command := ""
+               if withCommands {
+                       command = processCommand(pid)
+               }
+               if path == "" && command == "" {
+                       continue
+               }
+               result = append(result, Process{Pid: pid, Path: path, Command: 
command})
+       }
+       return result
+}
+
+// processCommand joins the NUL-separated arguments procfs stores.
+func processCommand(pid int) string {
+       file, err := os.Open(fmt.Sprintf("/proc/%d/cmdline", pid))
+       if err != nil {
+               return ""
+       }
+       defer file.Close()
+
+       data := make([]byte, maxCmdlineSize)
+       read, err := file.Read(data)
+       if err != nil || read <= 0 {
+               return ""
+       }
+       return strings.TrimSpace(strings.ReplaceAll(string(data[:read]), 
"\x00", " "))
+}
diff --git a/agentprocess/list_windows.go b/agentprocess/list_windows.go
new file mode 100644
index 0000000..a8d6976
--- /dev/null
+++ b/agentprocess/list_windows.go
@@ -0,0 +1,119 @@
+// 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.
+
+//go:build windows
+
+package agentprocess
+
+import (
+       "context"
+       "encoding/json"
+       "os/exec"
+       "strings"
+       "syscall"
+       "unsafe"
+
+       "golang.org/x/sys/windows"
+)
+
+// commandScript reads what the process was started with, which is the only way
+// an agent running under an interpreter names itself. WMI answers this in 
about
+// a second, so it is asked for only when an image path cannot settle it.
+const commandScript = `Get-CimInstance Win32_Process | ForEach-Object { 
[PSCustomObject]@{ pid = $_.ProcessId; path = $_.ExecutablePath; command = 
$_.CommandLine } } | ConvertTo-Json -Compress`
+
+func list(ctx context.Context, withCommands bool) []Process {
+       if withCommands {
+               if result := listWithCommands(ctx); len(result) > 0 {
+                       return result
+               }
+       }
+       return listImages()
+}
+
+// listImages walks the process table for image paths alone, which the OS
+// answers directly.
+func listImages() []Process {
+       snapshot, err := 
windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
+       if err != nil {
+               return nil
+       }
+       defer windows.CloseHandle(snapshot)
+
+       var entry windows.ProcessEntry32
+       entry.Size = uint32(unsafe.Sizeof(entry))
+       if windows.Process32First(snapshot, &entry) != nil {
+               return nil
+       }
+
+       var result []Process
+       for {
+               if entry.ProcessID > 0 {
+                       if path := imagePath(entry.ProcessID); path != "" {
+                               result = append(result, Process{Pid: 
int(entry.ProcessID), Path: path})
+                       }
+               }
+               if windows.Process32Next(snapshot, &entry) != nil {
+                       return result
+               }
+       }
+}
+
+// imagePath is empty for a process this one may not open, such as one owned by
+// another account when Gateway is not elevated.
+func imagePath(pid uint32) string {
+       handle, err := 
windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+       if err != nil {
+               return ""
+       }
+       defer windows.CloseHandle(handle)
+
+       buffer := make([]uint16, windows.MAX_LONG_PATH)
+       size := uint32(len(buffer))
+       if windows.QueryFullProcessImageName(handle, 0, &buffer[0], &size) != 
nil {
+               return ""
+       }
+       return windows.UTF16ToString(buffer[:size])
+}
+
+func listWithCommands(ctx context.Context) []Process {
+       cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", 
"-NonInteractive", "-Command", commandScript)
+       cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+       output, err := cmd.Output()
+       if err != nil {
+               return nil
+       }
+
+       body := strings.TrimSpace(string(output))
+       // ConvertTo-Json writes a bare object when only one process came back.
+       if strings.HasPrefix(body, "{") {
+               body = "[" + body + "]"
+       }
+       var rows []struct {
+               Pid     int    `json:"pid"`
+               Path    string `json:"path"`
+               Command string `json:"command"`
+       }
+       if json.Unmarshal([]byte(body), &rows) != nil {
+               return nil
+       }
+
+       result := make([]Process, 0, len(rows))
+       for _, row := range rows {
+               if row.Pid <= 0 {
+                       continue
+               }
+               result = append(result, Process{Pid: row.Pid, Path: row.Path, 
Command: row.Command})
+       }
+       return result
+}
diff --git a/agentprocess/process.go b/agentprocess/process.go
new file mode 100644
index 0000000..2f51f67
--- /dev/null
+++ b/agentprocess/process.go
@@ -0,0 +1,206 @@
+// 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 agentprocess reports and controls the live processes of a discovered
+// agent installation.
+package agentprocess
+
+import (
+       "context"
+       "errors"
+       "os"
+       "path/filepath"
+       "runtime"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       listCacheTTL = 3 * time.Second
+       listTimeout  = 10 * time.Second
+)
+
+// Target identifies one installation and says how it is run.
+type Target struct {
+       AgentId string
+       Path    string
+       Owner   string
+       // Executable is the file to launch, empty when none was resolved.
+       Executable string
+       // Desktop marks a windowed app, which is started without a console.
+       Desktop bool
+}
+
+// Status is the run state shown beside an agent installation.
+type Status struct {
+       Running  bool   `json:"running"`
+       Pids     []int  `json:"pids"`
+       CanStart bool   `json:"canStart"`
+       Detail   string `json:"detail,omitempty"`
+}
+
+// Process is one live process on the host. The account it runs as is left out
+// on purpose: an installation found under one account is routinely started by
+// another, so the owner would rule out processes that do belong to it.
+type Process struct {
+       Pid     int
+       Path    string
+       Command string
+}
+
+var processCache = struct {
+       sync.Mutex
+       result       []Process
+       withCommands bool
+       updatedAt    time.Time
+}{}
+
+// StatusOf reports the processes belonging to one installation.
+func StatusOf(target Target) Status {
+       return statusOf(target, needsCommands(target))
+}
+
+// Refresh drops the cached listing, so the next status is read from the host.
+// Whatever was just started or stopped is only visible after that.
+func Refresh() {
+       invalidate()
+}
+
+func statusOf(target Target, withCommands bool) Status {
+       status := Status{Pids: []int{}, CanStart: target.Executable != ""}
+       if !status.CanStart {
+               status.Detail = "no launcher was found for this installation"
+       }
+       for _, process := range snapshot(withCommands) {
+               if matches(target, process) {
+                       status.Pids = append(status.Pids, process.Pid)
+               }
+       }
+       status.Running = len(status.Pids) > 0
+       return status
+}
+
+// Start launches the agent: a desktop app opens on its own, a CLI opens in a
+// console window, since that is the only way it is usable.
+func Start(target Target) error {
+       if target.Executable == "" {
+               return errors.New("no launcher was found for this installation")
+       }
+
+       err := start(target)
+       invalidate()
+       return err
+}
+
+// Stop ends every process of the installation. The console window a CLI was
+// started in owns it, and only a command line names the agent inside that
+// window, so stopping always reads them however the status was reported.
+func Stop(target Target) error {
+       pids := statusOf(target, true).Pids
+       if len(pids) == 0 {
+               return nil
+       }
+
+       var failures []string
+       for _, pid := range pids {
+               if err := stop(pid); err != nil {
+                       failures = append(failures, strconv.Itoa(pid)+": 
"+err.Error())
+               }
+       }
+       invalidate()
+
+       // A process that exited between the listing and the signal is not a 
failure
+       // worth reporting, so only a still-running one is.
+       if len(failures) > 0 && statusOf(target, true).Running {
+               return errors.New(strings.Join(failures, "; "))
+       }
+       return nil
+}
+
+// needsCommands reports whether the installation can only be recognised by a
+// command line: a package manager records the package directory rather than a
+// program, and what runs is the interpreter.
+func needsCommands(target Target) bool {
+       return !samePath(target.Executable, target.Path)
+}
+
+// snapshot lists the host's processes, reusing a recent listing because one 
page
+// load asks for the status of every installation. Reading command lines costs
+// far more than reading image paths on some hosts, so it is only done for the
+// installations that cannot be recognised without them.
+func snapshot(withCommands bool) []Process {
+       processCache.Lock()
+       defer processCache.Unlock()
+
+       fresh := time.Since(processCache.updatedAt) < listCacheTTL
+       if fresh && (processCache.withCommands || !withCommands) {
+               return processCache.result
+       }
+
+       ctx, cancel := context.WithTimeout(context.Background(), listTimeout)
+       defer cancel()
+       processCache.result = list(ctx, withCommands)
+       processCache.withCommands = withCommands
+       processCache.updatedAt = time.Now()
+       return processCache.result
+}
+
+func invalidate() {
+       processCache.Lock()
+       processCache.updatedAt = time.Time{}
+       processCache.Unlock()
+}
+
+// matches recognises an installation among the running processes by its files:
+// an agent installed by a package manager runs through an interpreter, so the
+// process image is node rather than the agent, and only the command line names
+// the installation.
+func matches(target Target, process Process) bool {
+       if process.Pid == os.Getpid() {
+               return false
+       }
+       for _, candidate := range []string{target.Executable, target.Path} {
+               if candidate == "" {
+                       continue
+               }
+               if samePath(process.Path, candidate) || 
containsPath(process.Command, candidate) {
+                       return true
+               }
+       }
+       return false
+}
+
+func samePath(left, right string) bool {
+       if left == "" || right == "" {
+               return false
+       }
+       left, right = filepath.Clean(left), filepath.Clean(right)
+       if runtime.GOOS == "windows" {
+               return strings.EqualFold(left, right)
+       }
+       return left == right
+}
+
+func containsPath(command, path string) bool {
+       if command == "" || path == "" {
+               return false
+       }
+       path = filepath.Clean(path)
+       if runtime.GOOS == "windows" {
+               return strings.Contains(strings.ToLower(command), 
strings.ToLower(path))
+       }
+       return strings.Contains(command, path)
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 86bf157..58a6a94 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -253,28 +253,37 @@ func (c *ApiController) directLoopbackClient() (net.IP, 
bool) {
        return ip, true
 }
 
-// readAgentPatchTarget resolves the request body against the installations 
that
-// were actually discovered. Patching writes into the owner's home directory, 
so
-// an unverified body would let a caller name any account on the host.
-func (c *ApiController) readAgentPatchTarget() (agentpatch.Target, bool) {
-       var target agentpatch.Target
-       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &target); err != nil {
+// readAgentInstallation resolves the request body against the installations 
that
+// were actually discovered. Patching writes into the owner's home directory 
and
+// starting runs a program, so an unverified body would let a caller name any
+// account, and any file, on the host.
+func (c *ApiController) readAgentInstallation() (agent.Installation, bool) {
+       var requested agentpatch.Target
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &requested); err != 
nil {
                c.ResponseError(err.Error())
-               return target, false
+               return agent.Installation{}, false
        }
 
        installations, err := agent.Scan(false)
        if err != nil {
                c.ResponseError(err.Error())
-               return target, false
+               return agent.Installation{}, false
        }
        for _, installation := range installations {
-               if candidate := targetOf(installation); 
matchesTarget(candidate, target) {
-                       return candidate, true
+               if matchesTarget(targetOf(installation), requested) {
+                       return installation, true
                }
        }
        c.ResponseError("no discovered agent installation matches this target")
-       return target, false
+       return agent.Installation{}, false
+}
+
+func (c *ApiController) readAgentPatchTarget() (agentpatch.Target, bool) {
+       installation, ok := c.readAgentInstallation()
+       if !ok {
+               return agentpatch.Target{}, false
+       }
+       return targetOf(installation), true
 }
 
 func matchesTarget(discovered, requested agentpatch.Target) bool {
diff --git a/controllers/agent_process.go b/controllers/agent_process.go
new file mode 100644
index 0000000..43e8d15
--- /dev/null
+++ b/controllers/agent_process.go
@@ -0,0 +1,115 @@
+// 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 controllers
+
+import (
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentprocess"
+)
+
+// agentRuntime is one installation's run state, keyed the way the list keys an
+// installation: the same agent can be installed once per account.
+type agentRuntime struct {
+       AgentId string `json:"agentId"`
+       Path    string `json:"path"`
+       Owner   string `json:"owner"`
+       // Desktop tells the UI what a start would open: the app itself, or a 
console
+       // window for a CLI.
+       Desktop bool `json:"desktop"`
+       agentprocess.Status
+}
+
+// GetAgentProcesses reports which discovered installations are running now. It
+// is separate from the agent listing because reading the process table is 
worth
+// repeating on its own, without scanning the disk again.
+func (c *ApiController) GetAgentProcesses() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installations, err := agent.Scan(false)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       // A page asking again right after a start or a stop wants what the 
host says
+       // now, not what the last caller was told.
+       if c.GetString("refresh") == "true" {
+               agentprocess.Refresh()
+       }
+
+       result := make([]*agentRuntime, 0, len(installations))
+       for _, installation := range installations {
+               result = append(result, runtimeOf(installation))
+       }
+       c.ResponseOk(result)
+}
+
+// StartAgent runs one installation: a desktop app opens on its own, a CLI 
opens
+// in a console window on the host's desktop.
+func (c *ApiController) StartAgent() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installation, ok := c.readAgentInstallation()
+       if !ok {
+               return
+       }
+       if err := agentprocess.Start(processTarget(installation)); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(runtimeOf(installation))
+}
+
+// StopAgent ends every process of one installation.
+func (c *ApiController) StopAgent() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installation, ok := c.readAgentInstallation()
+       if !ok {
+               return
+       }
+       if err := agentprocess.Stop(processTarget(installation)); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(runtimeOf(installation))
+}
+
+func runtimeOf(installation agent.Installation) *agentRuntime {
+       target := processTarget(installation)
+       return &agentRuntime{
+               AgentId: installation.AgentId,
+               Path:    installation.Path,
+               Owner:   installation.Owner,
+               Desktop: target.Desktop,
+               Status:  agentprocess.StatusOf(target),
+       }
+}
+
+func processTarget(installation agent.Installation) agentprocess.Target {
+       launch := agent.LaunchOf(installation)
+       return agentprocess.Target{
+               AgentId:    installation.AgentId,
+               Path:       installation.Path,
+               Owner:      installation.Owner,
+               Executable: launch.Executable,
+               Desktop:    launch.Desktop,
+       }
+}
diff --git a/routers/router.go b/routers/router.go
index 3647056..dd5bd2a 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -69,6 +69,9 @@ func initAPI() {
        beego.Router("/api/patch-agent", &controllers.ApiController{}, 
"POST:PatchAgent")
        beego.Router("/api/unpatch-agent", &controllers.ApiController{}, 
"POST:UnpatchAgent")
        beego.Router("/api/update-agent-routing", &controllers.ApiController{}, 
"POST:UpdateAgentRouting")
+       beego.Router("/api/get-agent-processes", &controllers.ApiController{}, 
"GET:GetAgentProcesses")
+       beego.Router("/api/start-agent", &controllers.ApiController{}, 
"POST:StartAgent")
+       beego.Router("/api/stop-agent", &controllers.ApiController{}, 
"POST:StopAgent")
        beego.Router("/api/get-agent-records", &controllers.ApiController{}, 
"GET:GetAgentRecords")
        beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, 
"GET:GetAgentSessions")
        beego.Router("/api/add-agent-record", &controllers.ApiController{}, 
"POST:AddAgentRecord")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 44a4d49..0a509d5 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -13,7 +13,14 @@
 // limitations under the License.
 
 import {query, request} from "@/backend/request";
-import type {Agent, AgentProviderConfig, AgentProviderFile, AgentRecord, 
AgentSession} from "@/types";
+import type {
+  Agent,
+  AgentProviderConfig,
+  AgentProviderFile,
+  AgentRecord,
+  AgentRuntime,
+  AgentSession,
+} from "@/types";
 
 export interface PatchTarget {
   agentId: string;
@@ -34,6 +41,18 @@ export function unpatchAgent(target: PatchTarget) {
   return request<{followup?: string}>("/api/unpatch-agent", "POST", target);
 }
 
+export function getAgentProcesses(forceRefresh = false) {
+  return request<AgentRuntime[]>(`/api/get-agent-processes${forceRefresh ? 
"?refresh=true" : ""}`);
+}
+
+export function startAgent(target: PatchTarget) {
+  return request<AgentRuntime>("/api/start-agent", "POST", target);
+}
+
+export function stopAgent(target: PatchTarget) {
+  return request<AgentRuntime>("/api/stop-agent", "POST", target);
+}
+
 export interface AgentRouting {
   provider: string;
   fallbacks: string[];
diff --git a/web/src/components/AgentRunControl.tsx 
b/web/src/components/AgentRunControl.tsx
new file mode 100644
index 0000000..6734a13
--- /dev/null
+++ b/web/src/components/AgentRunControl.tsx
@@ -0,0 +1,95 @@
+// 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 {Play, Square} from "lucide-react";
+import i18next from "i18next";
+
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {Agent, AgentRuntime} from "@/types";
+
+/** Whether the installation has live processes, with its pids on hover. */
+export function RunBadge({status}: {status?: AgentRuntime}) {
+  if (!status) {
+    return <span className="text-muted-foreground">-</span>;
+  }
+  if (!status.running) {
+    return (
+      <SimpleTooltip title={status.detail}>
+        <span>
+          <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
+        </span>
+      </SimpleTooltip>
+    );
+  }
+  return (
+    <SimpleTooltip title={`${i18next.t("agent:Processes")}: 
${status.pids.join(", ")}`}>
+      <span>
+        <Badge variant="success">{i18next.t("agent:Running")}</Badge>
+      </span>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The start/stop control. Starting is harmless enough to happen on the click,
+ * while stopping ends work in progress and is confirmed first.
+ */
+export function RunButton({
+  agent,
+  status,
+  busy,
+  onToggle,
+}: {
+  agent: Agent;
+  status?: AgentRuntime;
+  busy: boolean;
+  onToggle: (agent: Agent, running: boolean) => void;
+}) {
+  if (status?.running) {
+    return (
+      <ConfirmDialog
+        title={`${i18next.t("agent:Stop")} ${agent.name}?`}
+        description={i18next.t("agent:Stop hint")}
+        confirmText={i18next.t("agent:Stop")}
+        onConfirm={() => onToggle(agent, true)}
+      >
+        <Button size="sm" variant="outline" loading={busy}>
+          <Square />
+          {i18next.t("agent:Stop")}
+        </Button>
+      </ConfirmDialog>
+    );
+  }
+
+  const startable = status?.canStart === true;
+  return (
+    <SimpleTooltip title={startable ? "" : status?.detail}>
+      <span>
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={!startable}
+          loading={busy}
+          onClick={() => onToggle(agent, false)}
+        >
+          <Play />
+          {i18next.t("agent:Start")}
+        </Button>
+      </span>
+    </SimpleTooltip>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index d88e59b..a7c9fbf 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -18,7 +18,10 @@ import i18next from "i18next";
 import * as AgentBackend from "@/backend/AgentBackend";
 import * as Setting from "@/Setting";
 import type {BadgeVariant} from "@/components/ui/badge";
-import type {Agent, AgentSession} from "@/types";
+import type {Agent, AgentRuntime, AgentSession} from "@/types";
+
+/** How long a started app is given before its process is looked for again. */
+const runtimeSettleMs = 2000;
 
 /** Routing through the local proxy, which is what makes a switch hot. */
 export const gatewayMode = "gateway";
@@ -76,6 +79,10 @@ export function useAgents(enabled = true) {
   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);
@@ -109,9 +116,61 @@ export function useAgents(enabled = 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();
-  }, [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) => {
@@ -222,13 +281,22 @@ export function useAgents(enabled = true) {
     busyKey,
     scanned,
     inContainer,
+    runtime,
+    runBusyKey,
     scan,
+    loadRuntime,
+    toggleRunning,
     togglePatch,
     setRouting,
     writeProvider,
   };
 }
 
+/** 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)];
+}
+
 /** What one agent has been up to, derived from its monitoring sessions. */
 export interface AgentActivity {
   sessionCount: number;
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 992e618..15a6868 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -26,6 +26,8 @@
     "Agent configuration": "Agent configuration",
     "Agent installation not found": "Agent installation not found",
     "Agent path": "Agent path",
+    "Agent started": "Agent started",
+    "Agent stopped": "Agent stopped",
     "Agents": "Agents",
     "Agents on this machine": "Agents on this machine",
     "All agents": "All agents",
@@ -53,6 +55,8 @@
     "Failed to get agent records": "Failed to get agent records",
     "Failed to get agent sessions": "Failed to get agent sessions",
     "Failed to scan agents": "Failed to scan agents",
+    "Failed to start the agent": "Failed to start the agent",
+    "Failed to stop the agent": "Failed to stop the agent",
     "Failed to update agent patch": "Failed to update agent patch",
     "Failed to update agent provider": "Failed to update agent provider",
     "Failed to write the agent configuration": "Failed to write the agent 
configuration",
@@ -89,6 +93,7 @@
     "Payload": "Payload",
     "Preview": "Preview",
     "Preview hint": "What Gateway would write, with the keys masked. 
Everything else in these files is left as it is.",
+    "Processes": "Processes",
     "Prompt ID": "Prompt ID",
     "Provider": "Provider",
     "Provider cleared": "Provider cleared",
@@ -102,11 +107,19 @@
     "Restore the configuration of {agent}?": "Restore the configuration of 
{agent}?",
     "Rewrite configuration": "Rewrite configuration",
     "Route through Gateway": "Route through Gateway",
+    "Run Status": "Run Status",
+    "Running": "Running",
     "Running in a container": "Gateway is running in a container",
     "Running in a container detail": "Agents are found by reading the home 
directories of the machine Gateway runs on, and in a container that is the 
container's own filesystem rather than the host's. Install Gateway on the host 
to see the agents installed there. Providers and the WAF proxy work either 
way.",
     "Scan": "Rescan",
     "Session": "Session",
     "Session title": "Session title",
+    "Start": "Start",
+    "Start app hint": "Opens the app on the machine Gateway runs on.",
+    "Start console hint": "Opens a console window running the CLI on the 
machine Gateway runs on.",
+    "Stop": "Stop",
+    "Stop hint": "Ends every process of this installation, losing whatever it 
is working on.",
+    "Stopped": "Stopped",
     "Target / Model": "Target / Model",
     "Time": "Time",
     "Token hint": "Gateway authenticates upstream with the provider's own key, 
so this token is only there because the client insists on one - any value 
works.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f168e4a..533f699 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -26,6 +26,8 @@
     "Agent configuration": "Agent 配置文件",
     "Agent installation not found": "没有找到这个智能体的安装",
     "Agent path": "Agent 路径",
+    "Agent started": "已启动",
+    "Agent stopped": "已停止",
     "Agents": "智能体",
     "Agents on this machine": "本机的智能体",
     "All agents": "全部 Agent",
@@ -53,6 +55,8 @@
     "Failed to get agent records": "获取 Agent 记录失败",
     "Failed to get agent sessions": "获取 Agent 会话失败",
     "Failed to scan agents": "探测 Agent 失败",
+    "Failed to start the agent": "启动 Agent 失败",
+    "Failed to stop the agent": "停止 Agent 失败",
     "Failed to update agent patch": "更新 Agent Patch 失败",
     "Failed to update agent provider": "更新 Agent 供应商失败",
     "Failed to write the agent configuration": "写入 Agent 配置失败",
@@ -89,6 +93,7 @@
     "Payload": "负载",
     "Preview": "预览",
     "Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
+    "Processes": "进程",
     "Prompt ID": "Prompt ID",
     "Provider": "供应商",
     "Provider cleared": "已清除供应商",
@@ -102,11 +107,19 @@
     "Restore the configuration of {agent}?": "还原 {agent} 的配置?",
     "Rewrite configuration": "重新写入配置",
     "Route through Gateway": "经由 Gateway 路由",
+    "Run Status": "运行状态",
+    "Running": "运行中",
     "Running in a container": "Gateway 正跑在容器里",
     "Running in a container detail": "Agent 是靠读取 Gateway 
所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。供应商和 WAF 
反向代理在容器里照常可用。",
     "Scan": "重新扫描",
     "Session": "会话",
     "Session title": "会话标题",
+    "Start": "启动",
+    "Start app hint": "在运行 Gateway 的机器上打开该应用。",
+    "Start console hint": "在运行 Gateway 的机器上打开一个控制台窗口来运行该命令行。",
+    "Stop": "停止",
+    "Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
+    "Stopped": "未运行",
     "Target / Model": "目标 / 模型",
     "Time": "时间",
     "Token hint": "网关是用供应商自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index 050ba8b..dbc0db5 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -23,6 +23,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
+import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {ProviderCard} from "@/components/ProviderCard";
 import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
@@ -38,6 +39,7 @@ import {
   agentKey,
   getOutcomeVariant,
   monitorAgentId,
+  runtimeOf,
   useAgents,
   useAgentSessions,
 } from "@/lib/agents";
@@ -46,6 +48,7 @@ import type {
   Account,
   Agent,
   AgentRecord,
+  AgentRuntime,
   AgentSession,
   Provider,
   ProviderHealth,
@@ -121,6 +124,44 @@ function MonitoringCard({
   );
 }
 
+/** The run state of the installation, with the control that changes it. */
+function RuntimeCard({
+  agent,
+  status,
+  busy,
+  onToggle,
+}: {
+  agent: Agent;
+  status?: AgentRuntime;
+  busy: boolean;
+  onToggle: (agent: Agent, running: boolean) => void;
+}) {
+  return (
+    <Card>
+      <CardHeader className="p-4 pb-2">
+        <CardTitle className="text-base">{i18next.t("agent:Run 
Status")}</CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-3 p-4 pt-0">
+        <div>
+          <RunBadge status={status} />
+        </div>
+
+        {status?.running ? (
+          <p className="text-sm text-muted-foreground">
+            {`${i18next.t("agent:Processes")}: ${status.pids.join(", ")}`}
+          </p>
+        ) : (
+          <p className="text-sm text-muted-foreground">
+            {i18next.t(status?.desktop ? "agent:Start app hint" : "agent:Start 
console hint")}
+          </p>
+        )}
+
+        <RunButton agent={agent} status={status} busy={busy} 
onToggle={onToggle} />
+      </CardContent>
+    </Card>
+  );
+}
+
 /** What this agent has spent through the proxy, and on which provider. */
 function UsageCard({stats}: {stats: LlmRecordStats | null}) {
   return (
@@ -176,8 +217,20 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
   const params = useParams();
   const [searchParams] = useSearchParams();
   const isAdmin = Setting.isAdminUser(account);
-  const {agents, error, busyKey, scanned, scan, togglePatch, setRouting, 
writeProvider} =
-    useAgents(isAdmin);
+  const {
+    agents,
+    error,
+    busyKey,
+    scanned,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    setRouting,
+    writeProvider,
+  } = useAgents(isAdmin);
   const [tab, setTab] = React.useState<Tab>("Agent Sessions");
   const [records, setRecords] = React.useState<AgentRecord[]>([]);
   const [recordError, setRecordError] = React.useState("");
@@ -370,7 +423,15 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
         />
         <h1 className="text-xl font-semibold tracking-tight">{agent.name}</h1>
         <Badge variant="secondary">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
-        <Button variant="outline" size="sm" className="ml-auto" onClick={() => 
scan(true)}>
+        <Button
+          variant="outline"
+          size="sm"
+          className="ml-auto"
+          onClick={() => {
+            scan(true);
+            loadRuntime();
+          }}
+        >
           <RefreshCw />
           {i18next.t("agent:Scan")}
         </Button>
@@ -384,6 +445,13 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
       )}
 
       <div className="grid gap-4 lg:grid-cols-2">
+        <RuntimeCard
+          agent={agent}
+          status={runtimeOf(runtime, agent)}
+          busy={runBusyKey === agentKey(agent)}
+          onToggle={toggleRunning}
+        />
+
         <MonitoringCard
           agent={agent}
           busy={busyKey === agentKey(agent)}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 96d7f48..a58f18f 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -18,6 +18,7 @@ import i18next from "i18next";
 
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
+import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
@@ -26,12 +27,31 @@ import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {SimpleTooltip} from "@/components/ui/tooltip";
-import {agentDetailPath, agentKey, directMode, monitorAgentId, useAgents} from 
"@/lib/agents";
+import {
+  agentDetailPath,
+  agentKey,
+  directMode,
+  monitorAgentId,
+  runtimeOf,
+  useAgents,
+} from "@/lib/agents";
 import type {Account, Agent} from "@/types";
 
 export default function AgentsPage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
-  const {agents, loading, error, busyKey, inContainer, scan, togglePatch} = 
useAgents(isAdmin);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+  } = useAgents(isAdmin);
 
   if (!isAdmin) {
     return <UnauthorizedResult />;
@@ -49,6 +69,11 @@ export default function AgentsPage({account}: {account: 
Account}) {
         </Link>
       ),
     },
+    {
+      title: i18next.t("agent:Run Status"),
+      key: "running",
+      render: (_value, record) => <RunBadge status={runtimeOf(runtime, 
record)} />,
+    },
     {
       title: i18next.t("agent:Version"),
       key: "version",
@@ -136,38 +161,50 @@ export default function AgentsPage({account}: {account: 
Account}) {
     {
       title: i18next.t("general:Action"),
       key: "action",
-      render: (_value, record) => {
-        if (!record.supported) {
-          return (
-            <Button size="sm" variant="outline" disabled>
-              {i18next.t("agent:Patch")}
-            </Button>
-          );
-        }
-
-        const action = i18next.t(`agent:${record.patched ? "Unpatch" : 
"Patch"}`);
-        const note = [record.notice, record.followup].filter(Boolean).join(" 
");
-        return (
-          <ConfirmDialog
-            title={`${action} ${record.name}?`}
-            description={note || undefined}
-            confirmText={action}
-            variant={record.patched ? "destructive" : "default"}
-            onConfirm={() => togglePatch(record)}
-          >
-            <Button
-              size="sm"
-              variant={record.patched ? "outline" : "default"}
-              loading={busyKey === agentKey(record)}
-            >
-              {action}
-            </Button>
-          </ConfirmDialog>
-        );
-      },
+      render: (_value, record) => (
+        <div className="flex items-center gap-2">
+          <RunButton
+            agent={record}
+            status={runtimeOf(runtime, record)}
+            busy={runBusyKey === agentKey(record)}
+            onToggle={toggleRunning}
+          />
+          {patchButton(record)}
+        </div>
+      ),
     },
   ];
 
+  function patchButton(record: Agent) {
+    if (!record.supported) {
+      return (
+        <Button size="sm" variant="outline" disabled>
+          {i18next.t("agent:Patch")}
+        </Button>
+      );
+    }
+
+    const action = i18next.t(`agent:${record.patched ? "Unpatch" : "Patch"}`);
+    const note = [record.notice, record.followup].filter(Boolean).join(" ");
+    return (
+      <ConfirmDialog
+        title={`${action} ${record.name}?`}
+        description={note || undefined}
+        confirmText={action}
+        variant={record.patched ? "destructive" : "default"}
+        onConfirm={() => togglePatch(record)}
+      >
+        <Button
+          size="sm"
+          variant={record.patched ? "outline" : "default"}
+          loading={busyKey === agentKey(record)}
+        >
+          {action}
+        </Button>
+      </ConfirmDialog>
+    );
+  }
+
   return (
     <PageContainer>
       <PageHeader title={i18next.t("agent:Agents")} 
description={account.hostname} />
@@ -188,7 +225,15 @@ export default function AgentsPage({account}: {account: 
Account}) {
           inContainer ? "agent:Running in a container detail" : "agent:No 
supported agents found",
         )}
         toolbar={
-          <Button variant="outline" size="sm" onClick={() => scan(true)} 
loading={loading}>
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => {
+              scan(true);
+              loadRuntime();
+            }}
+            loading={loading}
+          >
             <RefreshCw />
             {i18next.t("agent:Scan")}
           </Button>
diff --git a/web/src/types.ts b/web/src/types.ts
index eccc158..651dec8 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -205,6 +205,20 @@ export interface Agent {
   providerConfig: AgentProviderConfig;
 }
 
+/** The live processes of one agent installation, keyed by owner and path. */
+export interface AgentRuntime {
+  agentId: string;
+  path: string;
+  owner: string;
+  running: boolean;
+  pids: number[];
+  /** True for a windowed app: a CLI is started in a console window instead. */
+  desktop: boolean;
+  /** False when no launcher was resolved, so the agent cannot be started 
here. */
+  canStart: boolean;
+  detail?: string;
+}
+
 /** What the proxy has seen of one provider since Gateway started. */
 export interface ProviderHealth {
   provider: string;


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

Reply via email to