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 9375b60  feat(agent): install and upgrade agent CLIs from the pages
9375b60 is described below

commit 9375b600238c247425ba77cdae4d97fe8fea914a
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 23:07:11 2026 +0800

    feat(agent): install and upgrade agent CLIs from the pages
---
 agent/agent.go                                  |  37 ++++
 agentinstall/job.go                             | 203 +++++++++++++++++++++
 agentinstall/plan.go                            | 223 ++++++++++++++++++++++++
 agentinstall/run_unix.go                        |  23 +++
 agentinstall/run_windows.go                     |  28 +++
 controllers/agent.go                            |  20 ++-
 controllers/agent_install.go                    |  83 +++++++++
 routers/router.go                               |   3 +
 web/src/backend/AgentBackend.ts                 |  16 ++
 web/src/components/AgentCatalog.tsx             |  72 +++-----
 web/src/components/ToolInstallRow.tsx           | 107 ++++++++++++
 web/src/components/ToolUpgradeConfirmDialog.tsx |  89 ++++++++++
 web/src/lib/agents.ts                           | 102 +++++++++++
 web/src/locales/en/data.json                    |  12 +-
 web/src/locales/zh/data.json                    |  12 +-
 web/src/pages/AgentDetailPage.tsx               |  15 +-
 web/src/pages/AgentsPage.tsx                    |  13 +-
 web/src/pages/HomePage.tsx                      |  14 +-
 web/src/types.ts                                |  38 ++++
 19 files changed, 1059 insertions(+), 51 deletions(-)

diff --git a/agent/agent.go b/agent/agent.go
index 0c4fa99..b5aa980 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -84,3 +84,40 @@ func IsKnownAgentId(id string) bool {
        }
        return false
 }
+
+// Packages are the package-manager ids one agent is published under, which is
+// what installing or upgrading it needs. Empty fields mean the agent is not
+// published there, so that manager cannot install it.
+type Packages struct {
+       Npm          string
+       Winget       string
+       HomebrewCask string
+       System       string
+       InstallUrl   string
+       Desktop      bool
+}
+
+// PackagesOf reads the fingerprints rather than a host scan, so the packages 
of
+// an agent are known while it is not installed - which is when installing it 
is
+// what someone wants.
+func PackagesOf(id string) Packages {
+       for i := range fingerprints {
+               if fingerprints[i].ID != id {
+                       continue
+               }
+               packages := Packages{
+                       Npm:        fingerprints[i].NpmPackage,
+                       Winget:     fingerprints[i].WingetPackage,
+                       System:     fingerprints[i].SystemPackage,
+                       InstallUrl: fingerprints[i].InstallUrl,
+                       Desktop:    fingerprints[i].Desktop,
+               }
+               // The rest of the list are version-pinned aliases of the 
first, which
+               // is the cask an install should ask for.
+               if casks := fingerprints[i].HomebrewCasks; len(casks) > 0 {
+                       packages.HomebrewCask = casks[0]
+               }
+               return packages
+       }
+       return Packages{}
+}
diff --git a/agentinstall/job.go b/agentinstall/job.go
new file mode 100644
index 0000000..9b5c876
--- /dev/null
+++ b/agentinstall/job.go
@@ -0,0 +1,203 @@
+// 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 agentinstall
+
+import (
+       "bytes"
+       "context"
+       "errors"
+       "os"
+       "os/exec"
+       "sort"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/agent"
+)
+
+const (
+       // jobTimeout bounds one install: a package manager that is waiting for
+       // something is never going to get it here, since nothing is attached 
to its
+       // input.
+       jobTimeout = 15 * time.Minute
+       // maxOutput is the tail of the manager's own output kept for the page. 
The
+       // end is what says why an install failed.
+       maxOutput = 16 * 1024
+)
+
+// Job is one install or upgrade, running or finished. The page polls it rather
+// than holding a request open for as long as a package manager takes.
+type Job struct {
+       AgentId string `json:"agentId"`
+       Name    string `json:"name"`
+       Action  string `json:"action"`
+       Manager string `json:"manager"`
+       Command string `json:"command"`
+       Running bool   `json:"running"`
+       // Ok is the outcome of a finished job, false while one runs.
+       Ok bool `json:"ok"`
+       // Output is the tail of what the package manager printed, on both 
streams.
+       Output    string `json:"output"`
+       Error     string `json:"error,omitempty"`
+       StartTime string `json:"startTime"`
+       EndTime   string `json:"endTime,omitempty"`
+}
+
+type job struct {
+       sync.Mutex
+       Job
+       output tail
+}
+
+var jobs = struct {
+       sync.Mutex
+       byAgent map[string]*job
+}{byAgent: map[string]*job{}}
+
+// Start runs a plan in the background and reports the job it started. One 
agent
+// runs one job at a time: a second install of the same package would fight the
+// first over the same directory.
+func Start(plan Plan) (Job, error) {
+       if !plan.Available {
+               return Job{}, errors.New(plan.Detail)
+       }
+
+       jobs.Lock()
+       if running, ok := jobs.byAgent[plan.AgentId]; ok && 
running.snapshot().Running {
+               jobs.Unlock()
+               return Job{}, errors.New("this agent is already being installed 
or upgraded")
+       }
+       started := &job{Job: Job{
+               AgentId:   plan.AgentId,
+               Name:      agent.DisplayNameOf(plan.AgentId),
+               Action:    plan.Action,
+               Manager:   plan.Manager,
+               Command:   plan.Command,
+               Running:   true,
+               StartTime: now(),
+       }}
+       jobs.byAgent[plan.AgentId] = started
+       jobs.Unlock()
+
+       go run(started, plan)
+       return started.snapshot(), nil
+}
+
+// JobOf is the running or last finished job of one agent.
+func JobOf(agentId string) (Job, bool) {
+       jobs.Lock()
+       defer jobs.Unlock()
+
+       found, ok := jobs.byAgent[agentId]
+       if !ok {
+               return Job{}, false
+       }
+       return found.snapshot(), true
+}
+
+// Jobs lists every job of this process, newest first.
+func Jobs() []Job {
+       jobs.Lock()
+       result := make([]Job, 0, len(jobs.byAgent))
+       for _, item := range jobs.byAgent {
+               result = append(result, item.snapshot())
+       }
+       jobs.Unlock()
+
+       sort.SliceStable(result, func(left, right int) bool {
+               return result[left].StartTime > result[right].StartTime
+       })
+       return result
+}
+
+func run(started *job, plan Plan) {
+       ctx, cancel := context.WithTimeout(context.Background(), jobTimeout)
+       defer cancel()
+
+       cmd := exec.CommandContext(ctx, plan.program, plan.args...)
+       cmd.Stdout, cmd.Stderr = &started.output, &started.output
+       // A manager run from the agent's own tree would pick up a project's
+       // configuration; the home directory is the neutral one.
+       if home, err := os.UserHomeDir(); err == nil {
+               cmd.Dir = home
+       }
+       // Nothing is attached to the input, so a manager that asks a question 
would
+       // hang until the timeout. These say to answer none.
+       cmd.Env = append(os.Environ(), "CI=1", "NO_COLOR=1", 
"npm_config_yes=true", "HOMEBREW_NO_AUTO_UPDATE=1")
+       hideWindow(cmd)
+
+       err := cmd.Run()
+       if ctx.Err() != nil {
+               err = errors.New("the install did not finish within " + 
jobTimeout.String())
+       }
+
+       started.Lock()
+       started.Running = false
+       started.Ok = err == nil
+       if err != nil {
+               started.Error = err.Error()
+       }
+       started.EndTime = now()
+       started.Unlock()
+
+       // Whatever the outcome, what is on disk changed: the next listing has 
to be
+       // read from the host rather than from the scan that ran before this.
+       _, _ = agent.Scan(true)
+}
+
+func (j *job) snapshot() Job {
+       j.Lock()
+       defer j.Unlock()
+
+       result := j.Job
+       result.Output = j.output.String()
+       return result
+}
+
+// tail keeps the last maxOutput bytes of a stream, so an install that prints a
+// progress bar for ten minutes cannot grow without bound.
+type tail struct {
+       sync.Mutex
+       buffer bytes.Buffer
+}
+
+func (t *tail) Write(data []byte) (int, error) {
+       t.Lock()
+       defer t.Unlock()
+
+       written := len(data)
+       t.buffer.Write(data)
+       if t.buffer.Len() > maxOutput {
+               kept := t.buffer.Bytes()[t.buffer.Len()-maxOutput:]
+               trimmed := bytes.Buffer{}
+               trimmed.Write(kept)
+               t.buffer = trimmed
+       }
+       return written, nil
+}
+
+func (t *tail) String() string {
+       t.Lock()
+       defer t.Unlock()
+
+       // Carriage returns are how a progress bar overwrites its own line, and 
they
+       // read as blank lines once the output is shown as text.
+       return strings.TrimSpace(strings.ReplaceAll(t.buffer.String(), "\r", 
"\n"))
+}
+
+func now() string {
+       return time.Now().Format(time.RFC3339)
+}
diff --git a/agentinstall/plan.go b/agentinstall/plan.go
new file mode 100644
index 0000000..972ff44
--- /dev/null
+++ b/agentinstall/plan.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 agentinstall installs and upgrades agent CLIs with the package
+// managers the host already has. Everything it runs is built from the
+// fingerprints compiled into Gateway, never from a request.
+package agentinstall
+
+import (
+       "os/exec"
+       "runtime"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/agent"
+)
+
+// The actions a plan describes.
+const (
+       ActionInstall = "install"
+       ActionUpgrade = "upgrade"
+)
+
+// The package managers Gateway can drive. They are also the install methods a
+// scan reports, which is how an upgrade finds the one that owns a tree.
+const (
+       ManagerNpm      = "npm"
+       ManagerWinget   = "winget"
+       ManagerHomebrew = "homebrew"
+)
+
+// Plan is the command that would install or upgrade one agent here, and, when
+// there is none, what is missing.
+type Plan struct {
+       AgentId string `json:"agentId"`
+       Action  string `json:"action"`
+       Manager string `json:"manager,omitempty"`
+       // Command is the command line as it will run, shown before it does.
+       Command   string `json:"command,omitempty"`
+       Available bool   `json:"available"`
+       Detail    string `json:"detail,omitempty"`
+       // InstallUrl is the vendor's own page, which is all that is left for an
+       // agent no package manager here can install.
+       InstallUrl string `json:"installUrl,omitempty"`
+
+       program string
+       args    []string
+}
+
+// InstallPlan picks the manager that can install an agent this host does not
+// have: the one it is published on, whose own program is on PATH.
+func InstallPlan(agentId string) Plan {
+       packages := agent.PackagesOf(agentId)
+       plan := Plan{AgentId: agentId, Action: ActionInstall, InstallUrl: 
packages.InstallUrl}
+
+       for _, manager := range installOrder() {
+               switch manager {
+               case ManagerNpm:
+                       if packages.Npm == "" {
+                               continue
+                       }
+                       if program := lookup("npm"); program != "" {
+                               return fill(plan, ManagerNpm, program, 
"install", "-g", packages.Npm+"@latest")
+                       }
+               case ManagerHomebrew:
+                       if packages.HomebrewCask == "" {
+                               continue
+                       }
+                       if program := lookup("brew"); program != "" {
+                               return fill(plan, ManagerHomebrew, program, 
"install", "--cask", packages.HomebrewCask)
+                       }
+               case ManagerWinget:
+                       if packages.Winget == "" {
+                               continue
+                       }
+                       if program := lookup("winget"); program != "" {
+                               return fill(plan, ManagerWinget, program, 
append([]string{"install", "--id", packages.Winget, "--exact"}, 
wingetFlags...)...)
+                       }
+               }
+       }
+
+       plan.Detail = unavailableDetail(packages)
+       return plan
+}
+
+// UpgradePlan upgrades an installation in place, through the manager that put
+// it there: another one would install a second copy beside it.
+func UpgradePlan(agentId string, installMethod string) Plan {
+       packages := agent.PackagesOf(agentId)
+       plan := Plan{AgentId: agentId, Action: ActionUpgrade, InstallUrl: 
packages.InstallUrl}
+
+       switch installMethod {
+       case ManagerNpm:
+               if packages.Npm == "" {
+                       break
+               }
+               if program := lookup("npm"); program != "" {
+                       return fill(plan, ManagerNpm, program, "install", "-g", 
packages.Npm+"@latest")
+               }
+               plan.Detail = "npm installed this agent but is not on Gateway's 
PATH"
+               return plan
+       case ManagerHomebrew:
+               if packages.HomebrewCask == "" {
+                       break
+               }
+               if program := lookup("brew"); program != "" {
+                       return fill(plan, ManagerHomebrew, program, "upgrade", 
"--cask", packages.HomebrewCask)
+               }
+               plan.Detail = "Homebrew installed this agent but is not on 
Gateway's PATH"
+               return plan
+       case ManagerWinget:
+               if packages.Winget == "" {
+                       break
+               }
+               if program := lookup("winget"); program != "" {
+                       return fill(plan, ManagerWinget, program, 
append([]string{"upgrade", "--id", packages.Winget, "--exact"}, 
wingetFlags...)...)
+               }
+               plan.Detail = "winget installed this agent but is not on 
Gateway's PATH"
+               return plan
+       }
+
+       plan.Detail = "this agent was installed as \"" + installMethod + "\", 
which Gateway cannot upgrade; use its own updater"
+       return plan
+}
+
+// wingetFlags keep an install unattended: winget otherwise stops on a licence
+// prompt no one is there to answer.
+var wingetFlags = []string{
+       "--silent",
+       "--accept-package-agreements",
+       "--accept-source-agreements",
+       "--disable-interactivity",
+}
+
+// installOrder prefers the manager that installs into the user's own account,
+// since Gateway does not run as an administrator: npm with a user prefix on
+// Windows and Linux, Homebrew on macOS, where it owns its own tree.
+func installOrder() []string {
+       switch runtime.GOOS {
+       case "darwin":
+               return []string{ManagerHomebrew, ManagerNpm}
+       case "windows":
+               return []string{ManagerNpm, ManagerWinget}
+       default:
+               return []string{ManagerNpm, ManagerHomebrew}
+       }
+}
+
+func fill(plan Plan, manager string, program string, args ...string) Plan {
+       plan.Manager = manager
+       plan.program = program
+       plan.args = args
+       plan.Command = manager + " " + strings.Join(args, " ")
+       plan.Available = true
+       return plan
+}
+
+// unavailableDetail says which of the two is missing: the package, or the
+// manager that would install it.
+func unavailableDetail(packages agent.Packages) string {
+       var managers []string
+       if packages.Npm != "" {
+               managers = append(managers, "npm")
+       }
+       if packages.HomebrewCask != "" && runtime.GOOS != "windows" {
+               managers = append(managers, "Homebrew")
+       }
+       if packages.Winget != "" && runtime.GOOS == "windows" {
+               managers = append(managers, "winget")
+       }
+       if len(managers) == 0 {
+               if packages.Desktop {
+                       return "this is a desktop app, downloaded from its 
vendor rather than installed by a package manager"
+               }
+               return "no package manager on this platform publishes this 
agent"
+       }
+       return "install " + strings.Join(managers, " or ") + " first, or 
install the agent from its vendor's page"
+}
+
+// lookupTTL bounds how long a PATH search is trusted, so a manager installed
+// after Gateway started is picked up without a restart.
+const lookupTTL = 30 * time.Second
+
+var lookups = struct {
+       sync.Mutex
+       found map[string]lookupResult
+}{found: map[string]lookupResult{}}
+
+type lookupResult struct {
+       program string
+       at      time.Time
+}
+
+// lookup resolves a manager to an absolute program, which is what the job 
runs:
+// a bare name would be looked up again in whatever directory it is started in.
+// Every agent listing asks for the same few managers, and a PATH search walks
+// every directory on it, so the answers are held briefly.
+func lookup(name string) string {
+       lookups.Lock()
+       defer lookups.Unlock()
+
+       if cached, ok := lookups.found[name]; ok && time.Since(cached.at) < 
lookupTTL {
+               return cached.program
+       }
+       program, err := exec.LookPath(name)
+       if err != nil {
+               program = ""
+       }
+       lookups.found[name] = lookupResult{program: program, at: time.Now()}
+       return program
+}
diff --git a/agentinstall/run_unix.go b/agentinstall/run_unix.go
new file mode 100644
index 0000000..32a232f
--- /dev/null
+++ b/agentinstall/run_unix.go
@@ -0,0 +1,23 @@
+// 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 agentinstall
+
+import "os/exec"
+
+// hideWindow has nothing to hide off Windows: a package manager started 
without
+// a terminal opens no window of its own.
+func hideWindow(*exec.Cmd) {}
diff --git a/agentinstall/run_windows.go b/agentinstall/run_windows.go
new file mode 100644
index 0000000..af6bcf0
--- /dev/null
+++ b/agentinstall/run_windows.go
@@ -0,0 +1,28 @@
+// 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 agentinstall
+
+import (
+       "os/exec"
+       "syscall"
+)
+
+// hideWindow keeps the console a package manager would open to itself: the
+// output is read from the pipes and shown on the page instead.
+func hideWindow(cmd *exec.Cmd) {
+       cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 46a1deb..227f748 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -26,6 +26,7 @@ import (
        "github.com/apache/casbin-gateway/agent"
        "github.com/apache/casbin-gateway/agenthistory"
        "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agentinstall"
        "github.com/apache/casbin-gateway/agentmonitor"
        "github.com/apache/casbin-gateway/agentpatch"
        "github.com/apache/casbin-gateway/agentprovider"
@@ -50,6 +51,9 @@ type discoveredAgent struct {
        // page cannot build it from its own address: an agent that runs in a 
sandbox
        // is given this host's network address rather than loopback.
        ProxyBaseUrl string `json:"proxyBaseUrl"`
+       // Upgrade is the command that would update this installation in place,
+       // through the package manager that installed it.
+       Upgrade agentinstall.Plan `json:"upgrade"`
 }
 
 // GetAgents scans known installation locations and returns the AI agents
@@ -85,6 +89,7 @@ func (c *ApiController) GetAgents() {
                        Fallbacks:      []string{},
                        Mode:           object.ModeGateway,
                        ProviderConfig: 
agentprovider.StatusOf(providerTarget(target)),
+                       Upgrade:        
agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod),
                }
                baseUrl, ok := baseUrls[installation.AgentId]
                if !ok {
@@ -114,7 +119,20 @@ func (c *ApiController) GetAgentCatalog() {
                return
        }
 
-       c.ResponseOk(agent.KnownAgents())
+       catalog := agent.KnownAgents()
+       result := make([]*catalogEntry, 0, len(catalog))
+       for _, known := range catalog {
+               result = append(result, &catalogEntry{Known: known, Install: 
agentinstall.InstallPlan(known.AgentId)})
+       }
+       c.ResponseOk(result)
+}
+
+// catalogEntry is one known agent with the command that would install it here,
+// so a machine missing an agent is offered the install rather than only told
+// where to read about it.
+type catalogEntry struct {
+       agent.Known
+       Install agentinstall.Plan `json:"install"`
 }
 
 // UpdateAgentRouting binds one agent to the provider its requests are 
forwarded
diff --git a/controllers/agent_install.go b/controllers/agent_install.go
new file mode 100644
index 0000000..571346d
--- /dev/null
+++ b/controllers/agent_install.go
@@ -0,0 +1,83 @@
+// 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 (
+       "encoding/json"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentinstall"
+)
+
+// InstallAgent installs an agent this machine does not have, with a package
+// manager it does. The command comes from the fingerprint of the requested
+// agent id and nothing else, so a request names an agent, never a command.
+func (c *ApiController) InstallAgent() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId string `json:"agentId"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if !agent.IsKnownAgentId(form.AgentId) {
+               c.ResponseError("unknown agent: " + form.AgentId)
+               return
+       }
+
+       job, err := agentinstall.Start(agentinstall.InstallPlan(form.AgentId))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(job)
+}
+
+// UpgradeAgent updates one discovered installation through the package manager
+// that installed it. The manager runs as the account Gateway runs as, so an
+// installation found in another user's home is updated only where that account
+// shares the same global prefix.
+func (c *ApiController) UpgradeAgent() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installation, ok := c.readAgentInstallation()
+       if !ok {
+               return
+       }
+
+       job, err := 
agentinstall.Start(agentinstall.UpgradePlan(installation.AgentId, 
installation.InstallMethod))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(job)
+}
+
+// GetAgentInstallJobs reports the installs and upgrades of this process, the
+// ones still running included. A package manager takes minutes, so the page
+// polls this instead of waiting on the request that started the job.
+func (c *ApiController) GetAgentInstallJobs() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       c.ResponseOk(agentinstall.Jobs())
+}
diff --git a/routers/router.go b/routers/router.go
index f13bcb5..2a366cd 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -46,6 +46,9 @@ func initAPI() {
 
        beego.Router("/api/get-agents", &controllers.ApiController{}, 
"GET:GetAgents")
        beego.Router("/api/get-agent-catalog", &controllers.ApiController{}, 
"GET:GetAgentCatalog")
+       beego.Router("/api/get-agent-install-jobs", 
&controllers.ApiController{}, "GET:GetAgentInstallJobs")
+       beego.Router("/api/install-agent", &controllers.ApiController{}, 
"POST:InstallAgent")
+       beego.Router("/api/upgrade-agent", &controllers.ApiController{}, 
"POST:UpgradeAgent")
        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")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 7c5f7f3..db1d342 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -16,6 +16,7 @@ import {query, request} from "@/backend/request";
 import type {
   Agent,
   AgentCatalogEntry,
+  AgentInstallJob,
   AgentProviderConfig,
   AgentProviderFile,
   AgentRecord,
@@ -41,6 +42,21 @@ export function getAgentCatalog() {
   return request<AgentCatalogEntry[]>("/api/get-agent-catalog");
 }
 
+/** Installs an agent this machine lacks; the job runs on past this response. 
*/
+export function installAgent(agentId: string) {
+  return request<AgentInstallJob>("/api/install-agent", "POST", {agentId: 
agentId});
+}
+
+/** Updates one installation through the package manager that installed it. */
+export function upgradeAgent(target: PatchTarget) {
+  return request<AgentInstallJob>("/api/upgrade-agent", "POST", target);
+}
+
+/** Every install and upgrade this Gateway process has run, newest first. */
+export function getAgentInstallJobs() {
+  return request<AgentInstallJob[]>("/api/get-agent-install-jobs");
+}
+
 export function patchAgent(target: PatchTarget) {
   return request<{followup?: string}>("/api/patch-agent", "POST", target);
 }
diff --git a/web/src/components/AgentCatalog.tsx 
b/web/src/components/AgentCatalog.tsx
index 32cbd0c..55c418f 100644
--- a/web/src/components/AgentCatalog.tsx
+++ b/web/src/components/AgentCatalog.tsx
@@ -12,20 +12,33 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Bot, Download} from "lucide-react";
 import i18next from "i18next";
 
-import {AgentIcon} from "@/components/AgentIcon";
-import {useAgentCatalog} from "@/lib/agents";
+import {ToolInstallRow} from "@/components/ToolInstallRow";
+import {useAgentCatalog, type useAgentInstall} from "@/lib/agents";
 import type {Agent} from "@/types";
 
 /**
- * The agents Gateway supports that this machine has none of, each linking to
- * the vendor's own install page. Without them a fresh machine says only that
+ * The agents Gateway supports that this machine has none of, each with the
+ * click that installs it here. Without them a fresh machine says only that
  * nothing was found, which is the one thing it cannot act on.
  */
-export function AgentCatalog({agents, enabled = true}: {agents: Agent[]; 
enabled?: boolean}) {
+export function AgentCatalog({
+  agents,
+  enabled = true,
+  installer,
+}: {
+  agents: Agent[];
+  enabled?: boolean;
+  /**
+   * The page's own installer. It is passed in rather than made here: the page
+   * upgrades the agents it already has through the same one, and two of them
+   * would report the same finished job twice.
+   */
+  installer: ReturnType<typeof useAgentInstall>;
+}) {
   const missing = useAgentCatalog(agents, enabled);
+  const {jobs, busyId, install} = installer;
 
   if (missing.length === 0) {
     return null;
@@ -39,44 +52,15 @@ export function AgentCatalog({agents, enabled = true}: 
{agents: Agent[]; enabled
       </div>
 
       <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
2xl:grid-cols-4">
-        {missing.map(item => {
-          const inside = (
-            <>
-              {/* The icons are keyed by vendor name, which every entry has. 
*/}
-              <AgentIcon
-                agent={item.name}
-                size={22}
-                fallback={<Bot className="text-muted-foreground size-5" />}
-              />
-              <span className="min-w-0 truncate text-sm 
font-medium">{item.name}</span>
-              {item.installUrl ? (
-                <span className="text-muted-foreground ml-auto inline-flex 
shrink-0 items-center gap-1 text-xs">
-                  <Download className="size-3.5" />
-                  {i18next.t("agent:Install")}
-                </span>
-              ) : null}
-            </>
-          );
-
-          return item.installUrl ? (
-            <a
-              key={item.agentId}
-              href={item.installUrl}
-              target="_blank"
-              rel="noreferrer"
-              className="hover:border-primary hover:text-primary flex 
items-center gap-2.5 rounded-lg border p-3 transition-colors"
-            >
-              {inside}
-            </a>
-          ) : (
-            <div
-              key={item.agentId}
-              className="text-muted-foreground flex items-center gap-2.5 
rounded-lg border p-3"
-            >
-              {inside}
-            </div>
-          );
-        })}
+        {missing.map(item => (
+          <ToolInstallRow
+            key={item.agentId}
+            entry={item}
+            job={jobs[item.agentId]}
+            busy={busyId === item.agentId}
+            onInstall={install}
+          />
+        ))}
       </div>
     </section>
   );
diff --git a/web/src/components/ToolInstallRow.tsx 
b/web/src/components/ToolInstallRow.tsx
new file mode 100644
index 0000000..5cedb20
--- /dev/null
+++ b/web/src/components/ToolInstallRow.tsx
@@ -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.
+
+import {Bot, Download, ExternalLink} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {InstallOutput} from "@/components/ToolUpgradeConfirmDialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {AgentCatalogEntry, AgentInstallJob} from "@/types";
+
+/**
+ * One agent this machine does not have, with the click that installs it. The
+ * install runs on the host with the package manager that is already there, so
+ * the row shows which one it will use and what that manager is printing; an
+ * agent no manager here publishes keeps the link to its vendor, which is then
+ * all there is.
+ */
+export function ToolInstallRow({
+  entry,
+  job,
+  busy,
+  onInstall,
+}: {
+  entry: AgentCatalogEntry;
+  /** The running or last install of this agent, when there was one. */
+  job?: AgentInstallJob;
+  busy: boolean;
+  onInstall: (agentId: string) => void;
+}) {
+  const plan = entry.install;
+  const running = job?.running === true;
+  const failed = job !== undefined && !job.running && !job.ok;
+
+  return (
+    <div className="space-y-2 rounded-lg border p-3">
+      <div className="flex items-center gap-2.5">
+        {/* The icons are keyed by vendor name, which every entry has. */}
+        <AgentIcon
+          agent={entry.name}
+          size={22}
+          fallback={<Bot className="text-muted-foreground size-5" />}
+        />
+        <span className="min-w-0 flex-1 truncate text-sm 
font-medium">{entry.name}</span>
+
+        {entry.installUrl ? (
+          <SimpleTooltip title={i18next.t("agent:Install page")}>
+            <a
+              href={entry.installUrl}
+              target="_blank"
+              rel="noreferrer"
+              className="text-muted-foreground hover:text-primary shrink-0"
+              aria-label={i18next.t("agent:Install page")}
+            >
+              <ExternalLink className="size-4" />
+            </a>
+          </SimpleTooltip>
+        ) : null}
+
+        <SimpleTooltip
+          title={plan.available ? plan.command : plan.detail}
+        >
+          <span className="shrink-0">
+            <Button
+              size="sm"
+              variant={failed ? "outline" : "default"}
+              disabled={!plan.available || running}
+              loading={busy || running}
+              onClick={() => onInstall(entry.agentId)}
+            >
+              <Download />
+              {i18next.t(
+                running ? "agent:Installing" : failed ? "agent:Retry" : 
"agent:Install",
+              )}
+            </Button>
+          </span>
+        </SimpleTooltip>
+      </div>
+
+      <div className="flex flex-wrap items-center gap-2">
+        {plan.available ? (
+          <Badge variant="muted">{plan.manager}</Badge>
+        ) : (
+          <span className="text-muted-foreground text-xs">{plan.detail}</span>
+        )}
+        {plan.available ? (
+          <code className="text-muted-foreground min-w-0 truncate 
text-xs">{plan.command}</code>
+        ) : null}
+      </div>
+
+      <InstallOutput job={job} />
+    </div>
+  );
+}
diff --git a/web/src/components/ToolUpgradeConfirmDialog.tsx 
b/web/src/components/ToolUpgradeConfirmDialog.tsx
new file mode 100644
index 0000000..36a6b2c
--- /dev/null
+++ b/web/src/components/ToolUpgradeConfirmDialog.tsx
@@ -0,0 +1,89 @@
+// 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 {ArrowUpCircle} from "lucide-react";
+import i18next from "i18next";
+
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {Agent, AgentInstallJob} from "@/types";
+
+/** The tail of what a package manager printed, while it works and after. */
+export function InstallOutput({job}: {job?: AgentInstallJob}) {
+  if (!job || job.output === "") {
+    return null;
+  }
+
+  return (
+    <pre className="bg-muted text-muted-foreground max-h-32 overflow-auto 
rounded p-2 text-xs whitespace-pre-wrap">
+      {job.output}
+    </pre>
+  );
+}
+
+/**
+ * The guard in front of an upgrade. Unlike a provider switch this replaces the
+ * program on disk while the agent may be running, so the command that will run
+ * is shown before it does and the click is confirmed.
+ */
+export function ToolUpgradeConfirmDialog({
+  agent,
+  job,
+  busy,
+  onConfirm,
+}: {
+  agent: Agent;
+  /** The running or last upgrade of this agent, when there was one. */
+  job?: AgentInstallJob;
+  busy: boolean;
+  onConfirm: () => void;
+}) {
+  const plan = agent.upgrade;
+  const running = job?.running === true;
+
+  const button = (
+    <Button size="sm" variant="outline" disabled={!plan?.available || running} 
loading={busy || running}>
+      <ArrowUpCircle />
+      {i18next.t(running ? "agent:Upgrading" : "agent:Upgrade")}
+    </Button>
+  );
+
+  // Nothing to confirm for an agent whose install method Gateway cannot drive:
+  // the button says why instead of opening a dialog with no command in it.
+  if (!plan?.available) {
+    return (
+      <SimpleTooltip title={plan?.detail}>
+        <span>{button}</span>
+      </SimpleTooltip>
+    );
+  }
+
+  return (
+    <ConfirmDialog
+      title={i18next.t("agent:Upgrade {agent}?").replace("{agent}", 
agent.name)}
+      description={
+        <span className="space-y-2">
+          <span className="block">{i18next.t("agent:Upgrade hint")}</span>
+          <code className="bg-muted block rounded p-2 text-xs 
break-all">{plan.command}</code>
+        </span>
+      }
+      confirmText={i18next.t("agent:Upgrade")}
+      variant="default"
+      onConfirm={onConfirm}
+    >
+      {button}
+    </ConfirmDialog>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index f1d7943..b1d8318 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -22,6 +22,7 @@ import {providerProtocol, servesResponsesApi} from 
"@/lib/providers";
 import type {
   Agent,
   AgentCatalogEntry,
+  AgentInstallJob,
   AgentRuntime,
   AgentSession,
   AgentUsage,
@@ -458,6 +459,107 @@ export function useAgentCatalog(agents: Agent[], enabled 
= true) {
   );
 }
 
+/** 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)];
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 59e361c..6e4cd8f 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -25,9 +25,11 @@
     "Agent Sessions": "Sessions",
     "Agent configuration": "Agent configuration",
     "Agent installation not found": "Agent installation not found",
+    "Agent installed": "Agent installed",
     "Agent path": "Agent path",
     "Agent started": "Agent started",
     "Agent stopped": "Agent stopped",
+    "Agent upgraded": "Agent upgraded",
     "Agents": "Agents",
     "Agents on this machine": "Agents on this machine",
     "All agents": "All agents",
@@ -59,6 +61,7 @@
     "Event": "Event",
     "Failed to get agent records": "Failed to get agent records",
     "Failed to get agent sessions": "Failed to get agent sessions",
+    "Failed to install the agent": "Failed to install the agent",
     "Failed to read this transcript": "Failed to read this transcript",
     "Failed to scan agents": "Failed to scan agents",
     "Failed to start the agent": "Failed to start the agent",
@@ -80,7 +83,9 @@
     "Install": "Install",
     "Install Method": "Install Method",
     "Install an AI agent on this machine, then scan again": "Install an AI 
agent on this machine, then scan again",
+    "Install page": "The vendor's own install page",
     "Installation": "Installation",
+    "Installing": "Installing",
     "Last activity": "Last activity",
     "Last {count} records": "Last {count} records",
     "MCP target": "MCP target",
@@ -96,7 +101,7 @@
     "No supported agents found": "No supported agents found",
     "No usage yet": "No requests have been relayed for this agent yet",
     "Not installed": "Not installed",
-    "Not installed hint": "Agents Gateway works with that were not found on 
this machine. Open one to install it from its vendor, then scan again.",
+    "Not installed hint": "Agents Gateway works with that were not found on 
this machine. Install one with a package manager this host already has, or open 
its vendor's page.",
     "Not patched": "Not monitored",
     "Not recorded": "not recorded",
     "Not supported": "Not supported yet",
@@ -121,6 +126,7 @@
     "Restore configuration": "Restore configuration",
     "Restore confirm hint": "Puts back the provider settings this agent had 
before Gateway first wrote them, and leaves every other setting untouched.",
     "Restore the configuration of {agent}?": "Restore the configuration of 
{agent}?",
+    "Retry": "Retry",
     "Rewrite configuration": "Rewrite configuration",
     "Route through Gateway": "Route through Gateway",
     "Run Status": "Run Status",
@@ -153,6 +159,10 @@
     "Turn on monitoring to collect activity": "Turn on monitoring to collect 
activity",
     "Unknown": "Unknown",
     "Unpatch": "Turn off monitoring",
+    "Upgrade": "Upgrade",
+    "Upgrade hint": "Replaces this installation with the latest version, 
through the package manager that installed it. Restart the agent afterwards to 
run the new one.",
+    "Upgrade {agent}?": "Upgrade {agent}?",
+    "Upgrading": "Upgrading",
     "Usage": "Usage",
     "User": "User",
     "Version": "Version",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b64c62f..c747fa0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -25,9 +25,11 @@
     "Agent Sessions": "会话",
     "Agent configuration": "Agent 配置文件",
     "Agent installation not found": "没有找到这个智能体的安装",
+    "Agent installed": "安装完成",
     "Agent path": "Agent 路径",
     "Agent started": "已启动",
     "Agent stopped": "已停止",
+    "Agent upgraded": "升级完成",
     "Agents": "智能体",
     "Agents on this machine": "本机的智能体",
     "All agents": "全部 Agent",
@@ -59,6 +61,7 @@
     "Event": "事件",
     "Failed to get agent records": "获取 Agent 记录失败",
     "Failed to get agent sessions": "获取 Agent 会话失败",
+    "Failed to install the agent": "安装失败",
     "Failed to read this transcript": "读取该会话记录失败",
     "Failed to scan agents": "探测 Agent 失败",
     "Failed to start the agent": "启动 Agent 失败",
@@ -80,7 +83,9 @@
     "Install": "安装",
     "Install Method": "安装方式",
     "Install an AI agent on this machine, then scan again": "在本机安装一个 AI 
智能体后,再扫描一次",
+    "Install page": "厂商的安装页面",
     "Installation": "安装信息",
+    "Installing": "安装中",
     "Last activity": "最后活动",
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
@@ -96,7 +101,7 @@
     "No supported agents found": "没有发现本机安装的智能体",
     "No usage yet": "该 Agent 还没有经过网关的请求",
     "Not installed": "未安装",
-    "Not installed hint": "Gateway 支持、但本机未发现的智能体。点击可打开官方安装页面,安装后重新扫描即可。",
+    "Not installed hint": "本机未找到、但 Gateway 支持的代理。可直接用本机已有的包管理器安装,或打开厂商页面。",
     "Not patched": "未开启",
     "Not recorded": "未保留",
     "Not supported": "暂不支持",
@@ -121,6 +126,7 @@
     "Restore configuration": "还原配置",
     "Restore confirm hint": "把该 Agent 在 Gateway 首次写入之前的供应商配置还原回去,其他配置保持不变。",
     "Restore the configuration of {agent}?": "还原 {agent} 的配置?",
+    "Retry": "重试",
     "Rewrite configuration": "重新写入配置",
     "Route through Gateway": "经由 Gateway 路由",
     "Run Status": "运行状态",
@@ -153,6 +159,10 @@
     "Turn on monitoring to collect activity": "开启监控后才会收集活动",
     "Unknown": "未知",
     "Unpatch": "关闭监控",
+    "Upgrade": "升级",
+    "Upgrade hint": "通过当初安装它的包管理器,将该安装替换为最新版本。升级后需重启代理才能用上新版本。",
+    "Upgrade {agent}?": "升级 {agent}?",
+    "Upgrading": "升级中",
     "Usage": "用量",
     "User": "用户",
     "Version": "版本",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index 229de0c..c5e5eb7 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -24,6 +24,7 @@ import * as LlmRecordBackend from 
"@/backend/LlmRecordBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {InstallOutput, ToolUpgradeConfirmDialog} from 
"@/components/ToolUpgradeConfirmDialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {ProviderCard} from "@/components/ProviderCard";
 import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
@@ -40,6 +41,7 @@ import {
   getOutcomeVariant,
   monitorAgentId,
   runtimeOf,
+  useAgentInstall,
   useAgents,
   useAgentSessions,
 } from "@/lib/agents";
@@ -231,6 +233,8 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
     setRouting,
     writeProvider,
   } = useAgents(isAdmin);
+  // The upgrade rescans when it ends, so the version above is the new one.
+  const installer = useAgentInstall(isAdmin, () => scan(true));
   const [tab, setTab] = React.useState<Tab>("Agent Sessions");
   const [records, setRecords] = React.useState<AgentRecord[]>([]);
   const [recordError, setRecordError] = React.useState("");
@@ -481,7 +485,15 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
               {agent.version || i18next.t("agent:Unknown")}
             </InfoRow>
             <InfoRow label={i18next.t("agent:Install Method")}>
-              {agent.installMethod || "-"}
+              <span className="flex flex-wrap items-center gap-2">
+                {agent.installMethod || "-"}
+                <ToolUpgradeConfirmDialog
+                  agent={agent}
+                  job={installer.jobs[agent.agentId]}
+                  busy={installer.busyId === agent.agentId}
+                  onConfirm={() => installer.upgrade(agent)}
+                />
+              </span>
             </InfoRow>
             <InfoRow label={i18next.t("general:Owner")}>{agent.owner}</InfoRow>
             <InfoRow label={i18next.t("general:Path")}>
@@ -503,6 +515,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
                 </SimpleTooltip>
               </span>
             </InfoRow>
+            <InstallOutput job={installer.jobs[agent.agentId]} />
           </CardContent>
         </Card>
       </div>
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index c8541ab..5285313 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -20,6 +20,7 @@ import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {AgentIcon} from "@/components/AgentIcon";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
@@ -35,6 +36,7 @@ import {
   directMode,
   monitorAgentId,
   runtimeOf,
+  useAgentInstall,
   useAgents,
 } from "@/lib/agents";
 import type {Account, Agent} from "@/types";
@@ -55,6 +57,9 @@ export default function AgentsPage({account}: {account: 
Account}) {
     toggleRunning,
     togglePatch,
   } = useAgents(isAdmin);
+  // One installer for the page: it installs what is missing below the table 
and
+  // upgrades what is in it.
+  const installer = useAgentInstall(isAdmin, () => scan(true));
 
   if (!isAdmin) {
     return <UnauthorizedResult />;
@@ -174,6 +179,12 @@ export default function AgentsPage({account}: {account: 
Account}) {
             busy={runBusyKey === agentKey(record)}
             onToggle={toggleRunning}
           />
+          <ToolUpgradeConfirmDialog
+            agent={record}
+            job={installer.jobs[record.agentId]}
+            busy={installer.busyId === record.agentId}
+            onConfirm={() => installer.upgrade(record)}
+          />
           {patchButton(record)}
         </div>
       ),
@@ -256,7 +267,7 @@ export default function AgentsPage({account}: {account: 
Account}) {
         }
       />
 
-      <AgentCatalog agents={agents} enabled={scanned} />
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
     </PageContainer>
   );
 }
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 32b1706..a2e36e5 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -30,7 +30,15 @@ import {PageContainer, PageHeader} from 
"@/components/shared/page-header";
 import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Card} from "@/components/ui/card";
-import {activityOf, agentKey, runtimeOf, useAgents, useAgentSessions, usageOf} 
from "@/lib/agents";
+import {
+  activityOf,
+  agentKey,
+  runtimeOf,
+  useAgentInstall,
+  useAgents,
+  useAgentSessions,
+  usageOf,
+} from "@/lib/agents";
 import type {
   Account,
   AgentUsage,
@@ -74,6 +82,8 @@ export default function HomePage({account}: {account: 
Account}) {
     togglePatch,
     activateProvider,
   } = useAgents(isAdmin);
+  // Installs the agents this machine is missing, listed under the cards.
+  const installer = useAgentInstall(isAdmin, () => scan(true));
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
@@ -285,7 +295,7 @@ export default function HomePage({account}: {account: 
Account}) {
       )}
 
       {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog agents={agents} enabled={scanned} />
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
     </PageContainer>
   );
 }
diff --git a/web/src/types.ts b/web/src/types.ts
index cd0c887..9097830 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -149,6 +149,42 @@ export interface Agent {
   providerConfig: AgentProviderConfig;
   /** Where this agent reaches its bound provider, as the server resolved it. 
*/
   proxyBaseUrl?: string;
+  /** The command that would update this installation where it stands. */
+  upgrade?: AgentInstallPlan;
+}
+
+/**
+ * What installing or upgrading one agent would run here. The command is built
+ * on the server from what Gateway knows about the agent, so the page shows it
+ * and asks for it by agent, never by command.
+ */
+export interface AgentInstallPlan {
+  agentId: string;
+  /** "install" for an agent this machine lacks, "upgrade" for one it has. */
+  action: string;
+  /** The package manager that would do it: npm, winget or homebrew. */
+  manager?: string;
+  command?: string;
+  available: boolean;
+  /** Why there is no command, when there is none. */
+  detail?: string;
+  installUrl?: string;
+}
+
+/** One install or upgrade, polled while the package manager works. */
+export interface AgentInstallJob {
+  agentId: string;
+  name: string;
+  action: string;
+  manager: string;
+  command: string;
+  running: boolean;
+  ok: boolean;
+  /** The tail of what the package manager printed, on both streams. */
+  output: string;
+  error?: string;
+  startTime: string;
+  endTime?: string;
 }
 
 /**
@@ -161,6 +197,8 @@ export interface AgentCatalogEntry {
   /** The vendor's own install page, opened in a new tab. */
   installUrl?: string;
   desktop?: boolean;
+  /** The command that would install it here, when a package manager can. */
+  install: AgentInstallPlan;
 }
 
 /** The live processes of one agent installation, keyed by owner and path. */


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

Reply via email to