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 c3c1825  feat(agent): find project skills and flag the ones with 
updates
c3c1825 is described below

commit c3c1825551448cc6b3609c41eac9566ef819693d
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 20:50:32 2026 +0800

    feat(agent): find project skills and flag the ones with updates
---
 agentconfig/agentconfig.go                        | 151 ++++++++--
 agentconfig/layout.go                             | 107 +++++++-
 agentconfig/projects.go                           |  94 +++++++
 agentconfig/skill.go                              | 283 ++++++++++++++-----
 agentconfig/trash.go                              | 319 ++++++++++++++++++++++
 agentconfig/update.go                             | 272 ++++++++++++++++++
 controllers/agent_config.go                       |  90 ++++++
 routers/router.go                                 |   5 +
 web/src/backend/AgentConfigBackend.ts             |  30 ++
 web/src/components/agent-config/detail-dialog.tsx |  33 ++-
 web/src/components/agent-config/trash-dialog.tsx  | 226 +++++++++++++++
 web/src/lib/agent-configs.ts                      | 138 +++++++++-
 web/src/locales/en/data.json                      |  59 +++-
 web/src/locales/zh/data.json                      |  59 +++-
 web/src/pages/AgentConfigsPage.tsx                | 224 ++++++++++++---
 web/src/types.ts                                  |  58 ++++
 16 files changed, 1998 insertions(+), 150 deletions(-)

diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
index 74d05e7..ec09bc2 100644
--- a/agentconfig/agentconfig.go
+++ b/agentconfig/agentconfig.go
@@ -59,9 +59,29 @@ type Item struct {
        Files int   `json:"files,omitempty"`
        Bytes int64 `json:"bytes,omitempty"`
 
+       // Scope tells a skill the operator wrote from one a plugin ships or 
one a
+       // project checkout carries, and Origin names that plugin or project.
+       Scope  string `json:"scope,omitempty"`
+       Origin string `json:"origin,omitempty"`
+       // Project is the checkout a project-scope skill belongs to.
+       Project string `json:"project,omitempty"`
+
+       // Digest identifies the content, and Modified is the newest file 
behind it.
+       // Two agents holding the same name with different digests hold 
different
+       // versions of it, and the newer Modified is the newer one.
+       Digest   string `json:"digest,omitempty"`
+       Modified int64  `json:"modified,omitempty"`
+
+       // Update is where a skill came from and whether that source still 
holds the
+       // same content, which is what tells an out-of-date copy from a current 
one.
+       Update *SkillUpdate `json:"update,omitempty"`
+
        // Managed marks an entry Gateway wrote itself, which is not the 
operator's
        // to migrate and is removed by turning monitoring off instead.
        Managed bool `json:"managed,omitempty"`
+       // ReadOnly explains why Gateway will not delete this item. Empty for 
the
+       // items it may.
+       ReadOnly string `json:"readOnly,omitempty"`
 }
 
 // Inventory is everything Gateway can read for one installation. A location it
@@ -74,8 +94,11 @@ type Inventory struct {
        // Two agents can be compared and copied between exactly when they 
share it.
        Home string `json:"home,omitempty"`
 
-       SkillsDir string `json:"skillsDir,omitempty"`
-       McpFile   string `json:"mcpFile,omitempty"`
+       // SkillsDir is where Gateway writes a skill copied into this agent, and
+       // SkillsDirs every directory the listing was read from, plugins 
included.
+       SkillsDir  string   `json:"skillsDir,omitempty"`
+       SkillsDirs []string `json:"skillsDirs,omitempty"`
+       McpFile    string   `json:"mcpFile,omitempty"`
 
        SkillsSupported bool `json:"skillsSupported"`
        McpSupported    bool `json:"mcpSupported"`
@@ -136,11 +159,11 @@ func Read(agentId string, owner string) *Inventory {
 
        if found.skills != nil {
                inventory.SkillsDir = found.skills.dir(home)
-               skills, err := readSkills(agentId, owner, inventory.SkillsDir)
-               if err != nil {
-                       inventory.Errors = append(inventory.Errors, err.Error())
-               }
-               inventory.Skills = skills
+               scan := readSkills(agentId, owner, found.skills, home)
+               attachSkillUpdates(scan.items, home)
+               inventory.Skills = scan.items
+               inventory.SkillsDirs = scan.dirs
+               inventory.Errors = append(inventory.Errors, scan.problems...)
        }
 
        if found.mcp != nil {
@@ -161,7 +184,11 @@ func ReadDetail(agentId string, owner string, kind string, 
name string) (*Detail
                return nil, err
        }
        if kind == KindSkill {
-               return skillDetail(agentId, owner, found.skills.dir(home), name)
+               item, err := findItem(agentId, owner, kind, name)
+               if err != nil {
+                       return nil, err
+               }
+               return skillDetail(item)
        }
 
        file := found.mcp.path(home)
@@ -176,19 +203,54 @@ func ReadDetail(agentId string, owner string, kind 
string, name string) (*Detail
        return mcpDetail(agentId, owner, file, name, entry)
 }
 
-// Delete removes one item from the agent's own configuration.
+// Delete takes one item out of the agent's own configuration. Nothing is 
erased
+// here: the item is moved into Gateway's trash first, so a delete made by
+// mistake can be restored.
 func Delete(agentId string, owner string, kind string, name string) error {
        found, home, err := resolve(agentId, owner, kind)
        if err != nil {
                return err
        }
+       item, err := findItem(agentId, owner, kind, name)
+       if err != nil {
+               return err
+       }
+       if item.ReadOnly != "" {
+               return fmt.Errorf("%s: %s", name, item.ReadOnly)
+       }
+
        if kind == KindSkill {
-               return deleteSkill(found.skills.dir(home), name)
+               return trashSkill(home, item)
        }
        if found.mcp.readOnly != "" {
                return fmt.Errorf("%s: %s", agentId, found.mcp.readOnly)
        }
-       return found.mcp.store.remove(found.mcp.path(home), name)
+
+       file := found.mcp.path(home)
+       entries, err := found.mcp.store.read(file)
+       if err != nil {
+               return err
+       }
+       if err := trashMcp(home, item, entries[name]); err != nil {
+               return err
+       }
+       return found.mcp.store.remove(file, name)
+}
+
+// findItem locates one item in what the agent actually has. Skills are found 
by
+// scanning rather than by joining the name onto a directory: a skill can come
+// from a plugin or from a group inside the skills directory, and the path it
+// was found at is the only one an edit may touch.
+func findItem(agentId string, owner string, kind string, name string) (*Item, 
error) {
+       if name == "" {
+               return nil, errors.New("the name is empty")
+       }
+       for _, item := range itemsOf(Read(agentId, owner), kind) {
+               if item.Name == name {
+                       return item, nil
+               }
+       }
+       return nil, fmt.Errorf("%s: no %s named %q", agentId, kind, name)
 }
 
 // CopyRequest is one migration: some of a source agent's items, into one or
@@ -232,22 +294,26 @@ func Plan(request CopyRequest) ([]*PlanItem, error) {
 func plan(request CopyRequest, sources map[string]*source) []*PlanItem {
        planned := []*PlanItem{}
        for _, agentId := range request.To {
-               existing, err := targetNames(agentId, request.Owner, 
request.Kind)
+               existing, err := targetItems(agentId, request.Owner, 
request.Kind)
                for _, name := range request.Names {
                        item := &PlanItem{AgentId: agentId, Name: name}
+                       from := sources[name]
+                       target := existing[targetName(request.Kind, name)]
                        switch {
                        case err != nil:
                                item.Action, item.Reason = ActionSkip, 
err.Error()
-                       case sources[name] == nil:
+                       case from == nil:
                                item.Action, item.Reason = ActionSkip, "not 
found in the source agent"
-                       case sources[name].item.Managed:
+                       case from.item.Managed:
                                item.Action, item.Reason = ActionSkip, "written 
by Gateway, not migrated"
-                       case !existing[name]:
+                       case target == nil:
                                item.Action = ActionCreate
+                       case sameContent(from.item, target):
+                               item.Action, item.Reason = ActionSkip, "already 
up to date"
                        case request.Overwrite:
-                               item.Action = ActionOverwrite
+                               item.Action, item.Reason = ActionOverwrite, 
replaces(from.item, target)
                        default:
-                               item.Action, item.Reason = ActionSkip, "already 
exists"
+                               item.Action, item.Reason = ActionSkip, "a 
different version is already there"
                        }
                        planned = append(planned, item)
                }
@@ -255,6 +321,38 @@ func plan(request CopyRequest, sources map[string]*source) 
[]*PlanItem {
        return planned
 }
 
+// sameContent reports two copies of one item as the same version. A digest is
+// computed from the content, so equal digests mean the target already has what
+// the copy would write.
+func sameContent(from *Item, target *Item) bool {
+       return from.Digest != "" && from.Digest == target.Digest
+}
+
+// replaces says which way round the two versions are, so an overwrite that
+// would move an agent backwards is visible before it is applied.
+func replaces(from *Item, target *Item) string {
+       if from.Modified == 0 || target.Modified == 0 {
+               return "replaces a different version"
+       }
+       if target.Modified > from.Modified {
+               return "replaces a newer version"
+       }
+       return "replaces an older version"
+}
+
+// targetName is the name an item takes at the target. A skill from a plugin or
+// from a group inside the skills directory is qualified, and it lands in the
+// target's skills directory under the last part of that name.
+func targetName(kind string, name string) string {
+       if kind != KindSkill {
+               return name
+       }
+       if index := strings.LastIndexAny(name, ":/"); index >= 0 {
+               return name[index+1:]
+       }
+       return name
+}
+
 // Copy writes the selected items into every target agent. One item's failure 
is
 // recorded against that item and the rest still run, so a partly finished
 // migration is reported item by item instead of hidden behind the first error.
@@ -287,7 +385,12 @@ func writeItem(request CopyRequest, from *source, agentId 
string) (string, error
        }
 
        if request.Kind == KindSkill {
-               return copySkill(from.item.Path, found.skills.dir(home), 
from.item.Name)
+               path, err := copySkill(from.item.Path, found.skills.dir(home), 
targetName(request.Kind, from.item.Name))
+               if err != nil {
+                       return "", err
+               }
+               recordSkillOrigin(home, path, request.From, from.item.Name, 
from.item.Path)
+               return path, nil
        }
        if found.mcp.readOnly != "" {
                return "", errors.New(found.mcp.readOnly)
@@ -339,9 +442,9 @@ func readSources(request CopyRequest) (map[string]*source, 
error) {
        return sources, nil
 }
 
-// targetNames is what the target already has, so planning can tell a new name
-// from one that would be replaced.
-func targetNames(agentId string, owner string, kind string) (map[string]bool, 
error) {
+// targetItems is what the target already has, so planning can tell a new item
+// from one that would be replaced, and an identical copy from an older one.
+func targetItems(agentId string, owner string, kind string) (map[string]*Item, 
error) {
        if _, _, err := resolve(agentId, owner, kind); err != nil {
                return nil, err
        }
@@ -351,11 +454,11 @@ func targetNames(agentId string, owner string, kind 
string) (map[string]bool, er
                return nil, errors.New(strings.Join(inventory.Errors, "; "))
        }
 
-       names := map[string]bool{}
+       items := map[string]*Item{}
        for _, item := range itemsOf(inventory, kind) {
-               names[item.Name] = true
+               items[targetName(kind, item.Name)] = item
        }
-       return names, nil
+       return items, nil
 }
 
 func itemsOf(inventory *Inventory, kind string) []*Item {
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 3ae1091..9bfbb04 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -36,15 +36,76 @@ type layout struct {
        mcp    *mcpLayout
 }
 
-// skillLayout is a directory of skill folders, each with a SKILL.md. Every
-// agent Gateway supports uses that same layout, which is what makes copying a
-// skill between two of them a plain directory copy.
+// Where a skill came from. A plugin's skills are read like any other but are
+// not Gateway's to delete: the plugin owns them. A project's skills belong to 
a
+// checkout the agent has been run in, not to the account.
+const (
+       ScopeUser    = "user"
+       ScopePlugin  = "plugin"
+       ScopeProject = "project"
+)
+
+// skillLayout is every place one agent finds skills. Each is a directory of
+// skill folders with a SKILL.md, which is what makes copying a skill between
+// two agents a plain directory copy.
 type skillLayout struct {
+       sources []skillSource
+}
+
+// skillSource is one such place: the agent's own skills directory, a plugin
+// tree that is searched for the skills folders the plugins ship, or the same
+// directory inside every project the agent has been run in.
+type skillSource struct {
        segments []string
+       scope    string
+       scan     bool
+       // projects lists the checkouts to look in instead of the home 
directory.
+       // A project keeps its skills beside its other agent configuration, and 
the
+       // agent reads them there, so a listing without them is short.
+       projects func(home string) []string
+}
+
+func (s skillSource) dir(home string) string {
+       return filepath.Join(append([]string{home}, s.segments...)...)
+}
+
+// roots is every directory this source contributes: one under the home
+// directory, or one under each project the agent has worked in.
+func (s skillSource) roots(home string) []string {
+       if s.projects == nil {
+               return []string{s.dir(home)}
+       }
+
+       roots := []string{}
+       for _, project := range s.projects(home) {
+               roots = append(roots, filepath.Join(append([]string{project}, 
s.segments...)...))
+       }
+       return roots
 }
 
+// dir is where Gateway writes a skill it copies into this agent: the agent's
+// own skills directory, never a plugin's.
 func (l *skillLayout) dir(home string) string {
-       return filepath.Join(append([]string{home}, l.segments...)...)
+       for _, source := range l.sources {
+               if source.scope == ScopeUser && !source.scan && source.projects 
== nil {
+                       return source.dir(home)
+               }
+       }
+       return ""
+}
+
+func userSkills(segments ...string) skillSource {
+       return skillSource{segments: segments, scope: ScopeUser}
+}
+
+func pluginSkills(segments ...string) skillSource {
+       return skillSource{segments: segments, scope: ScopePlugin, scan: true}
+}
+
+// projectSkills reads the same folder inside every project the agent lists in
+// its own configuration, which is where a skill checked into a repository 
sits.
+func projectSkills(projects func(home string) []string, segments ...string) 
skillSource {
+       return skillSource{segments: segments, scope: ScopeProject, projects: 
projects}
 }
 
 // mcpLayout is one config file holding named MCP server entries.
@@ -74,8 +135,12 @@ type mcpStore interface {
 // Cursor and its CLI share one ~/.cursor, so those ids share a layout.
 var layouts = map[string]layout{
        "claude-code": {
-               skills: &skillLayout{segments: []string{".claude", "skills"}},
-               mcp:    &mcpLayout{file: under(".claude.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+               skills: &skillLayout{sources: []skillSource{
+                       userSkills(".claude", "skills"),
+                       pluginSkills(".claude", "plugins"),
+                       projectSkills(jsonProjects(".claude.json"), ".claude", 
"skills"),
+               }},
+               mcp: &mcpLayout{file: under(".claude.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
        },
        "claude-desktop": {
                mcp: &mcpLayout{file: claudeDesktopConfig, store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
@@ -93,7 +158,11 @@ var layouts = map[string]layout{
                },
        },
        "openclaw": {
-               skills: &skillLayout{segments: []string{".openclaw", "skills"}},
+               skills: &skillLayout{sources: []skillSource{
+                       userSkills(".openclaw", "skills"),
+                       pluginSkills(".openclaw", "plugins"),
+                       projectSkills(jsonProjects(".openclaw", 
"openclaw.json"), ".openclaw", "skills"),
+               }},
                mcp: &mcpLayout{
                        file: under(".openclaw", "openclaw.json"),
                        // OpenClaw has spelled this three ways across 
versions, and reads
@@ -104,13 +173,19 @@ var layouts = map[string]layout{
 }
 
 var codexLayout = layout{
-       skills: &skillLayout{segments: []string{".codex", "skills"}},
-       mcp:    &mcpLayout{file: under(".codex", "config.toml"), store: 
&tomlStore{table: "mcp_servers"}},
+       skills: &skillLayout{sources: []skillSource{
+               userSkills(".codex", "skills"),
+               pluginSkills(".codex", "plugins"),
+       }},
+       mcp: &mcpLayout{file: under(".codex", "config.toml"), store: 
&tomlStore{table: "mcp_servers"}},
 }
 
 var cursorLayout = layout{
-       skills: &skillLayout{segments: []string{".cursor", "skills-cursor"}},
-       mcp:    &mcpLayout{file: under(".cursor", "mcp.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+       skills: &skillLayout{sources: []skillSource{
+               userSkills(".cursor", "skills-cursor"),
+               pluginSkills(".cursor", "plugins"),
+       }},
+       mcp: &mcpLayout{file: under(".cursor", "mcp.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
 }
 
 func layoutOf(agentId string) (layout, bool) {
@@ -180,8 +255,14 @@ func Configured(agentId string, owner string) bool {
                return false
        }
 
-       if found.skills != nil && exists(found.skills.dir(home)) {
-               return true
+       if found.skills != nil {
+               for _, source := range found.skills.sources {
+                       for _, root := range source.roots(home) {
+                               if exists(root) {
+                                       return true
+                               }
+                       }
+               }
        }
        return found.mcp != nil && exists(found.mcp.path(home))
 }
diff --git a/agentconfig/projects.go b/agentconfig/projects.go
new file mode 100644
index 0000000..deddf0d
--- /dev/null
+++ b/agentconfig/projects.go
@@ -0,0 +1,94 @@
+// 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 agentconfig
+
+import (
+       "encoding/json"
+       "os"
+       "path/filepath"
+       "runtime"
+       "sort"
+       "strings"
+)
+
+// maxProjects bounds how many checkouts one listing looks into. An agent
+// remembers every directory it has ever been started in, and that list only
+// grows.
+const maxProjects = 500
+
+// jsonProjects lists the directories an agent has worked in, from the projects
+// object it keeps in its own JSON configuration. A directory that is no longer
+// there is dropped: the agent remembers projects that have since been moved or
+// deleted, and Gateway lists what is on disk.
+func jsonProjects(segments ...string) func(string) []string {
+       return func(home string) []string {
+               raw, err := os.ReadFile(filepath.Join(append([]string{home}, 
segments...)...))
+               if err != nil {
+                       return nil
+               }
+
+               document := struct {
+                       Projects map[string]json.RawMessage `json:"projects"`
+               }{}
+               if err := json.Unmarshal(raw, &document); err != nil {
+                       return nil
+               }
+
+               projects := make([]string, 0, len(document.Projects))
+               for path := range document.Projects {
+                       if path == "" || path == home || !isDir(path) {
+                               continue
+                       }
+                       projects = append(projects, filepath.Clean(path))
+               }
+               sort.Strings(projects)
+               return dedupe(projects, maxProjects)
+       }
+}
+
+// comparablePath is what two spellings of one directory share, so a path can 
be
+// used as a map key. Windows and macOS do not distinguish the case of a path,
+// and a trailing separator never makes a different directory.
+func comparablePath(path string) string {
+       cleaned := filepath.Clean(path)
+       if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+               return strings.ToLower(cleaned)
+       }
+       return cleaned
+}
+
+func isDir(path string) bool {
+       info, err := os.Stat(path)
+       return err == nil && info.IsDir()
+}
+
+// dedupe keeps the first of each path and stops at limit. Two projects can
+// differ only in case or in a trailing separator and still be one directory.
+func dedupe(paths []string, limit int) []string {
+       kept := []string{}
+       seen := map[string]bool{}
+       for _, path := range paths {
+               key := comparablePath(path)
+               if seen[key] {
+                       continue
+               }
+               seen[key] = true
+               kept = append(kept, path)
+               if len(kept) >= limit {
+                       break
+               }
+       }
+       return kept
+}
diff --git a/agentconfig/skill.go b/agentconfig/skill.go
index cb7d228..512ee19 100644
--- a/agentconfig/skill.go
+++ b/agentconfig/skill.go
@@ -15,6 +15,8 @@
 package agentconfig
 
 import (
+       "crypto/sha256"
+       "encoding/hex"
        "fmt"
        "io"
        "io/fs"
@@ -33,17 +35,99 @@ const (
        maxSkillFileBytes = 256 * 1024
        // maxSkillFiles bounds the file list shown beside a skill's manifest.
        maxSkillFiles = 200
+       // maxDigestFileBytes bounds what the digest reads; a larger file is
+       // identified by its size and modification time instead of its content.
+       maxDigestFileBytes = 1 << 20
+       // A plugin tree is somebody else's checkout, so the search for the 
skills
+       // folders in it is bounded in both depth and result count.
+       maxPluginDepth    = 6
+       maxPluginSkillDir = 200
 )
 
-// readSkills lists the skill folders in dir. A directory that does not exist 
is
-// an agent with no skills yet, not an error.
-func readSkills(agentId string, owner string, dir string) ([]*Item, error) {
-       entries, err := os.ReadDir(dir)
+// pluginNoise is what a plugin checkout carries besides its own files. The
+// agents' own plugin cache is not on this list: an installed plugin lives 
under
+// it, and skipping it hid every skill of an agent that caches its plugins that
+// way.
+var pluginNoise = map[string]bool{
+       "node_modules": true, "dist": true, "build": true,
+       "vendor": true, "__pycache__": true, ".venv": true, "venv": true,
+}
+
+// skillDir is one directory of skill folders, and what the skills read from it
+// are called.
+type skillDir struct {
+       path  string
+       scope string
+       // owner is the plugin shipping these skills, empty for the agent's own
+       // skills directory. It qualifies their names the way the agents do.
+       owner string
+}
+
+// skillScan is one agent's skills and the directories they were read from.
+type skillScan struct {
+       items    []*Item
+       dirs     []string
+       problems []string
+}
+
+// readSkills lists the skills of one agent, across every place it keeps them. 
A
+// directory that does not exist is an agent with no skills there, not an 
error.
+func readSkills(agentId string, owner string, found *skillLayout, home string) 
skillScan {
+       scan := skillScan{items: []*Item{}, dirs: []string{}, problems: 
[]string{}}
+       seen := map[string]bool{}
+
+       for _, source := range found.sources {
+               for _, root := range source.roots(home) {
+                       dirs := []skillDir{{path: root, scope: source.scope}}
+                       switch {
+                       case source.scan:
+                               dirs = pluginSkillDirs(root, source.scope)
+                       case source.scope == ScopeProject:
+                               // A project's skills are named after the 
checkout they are in,
+                               // so two projects can hold a skill of the same 
name and the
+                               // listing still says which is which.
+                               if !exists(root) {
+                                       continue
+                               }
+                               dirs[0].owner = 
filepath.Base(filepath.Dir(filepath.Dir(root)))
+                               scan.dirs = append(scan.dirs, root)
+                       default:
+                               scan.dirs = append(scan.dirs, root)
+                       }
+
+                       for _, dir := range dirs {
+                               items, err := readSkillDir(agentId, owner, dir)
+                               if err != nil {
+                                       scan.problems = append(scan.problems, 
err.Error())
+                                       continue
+                               }
+                               if source.scan && len(items) > 0 {
+                                       scan.dirs = append(scan.dirs, dir.path)
+                               }
+                               for _, item := range items {
+                                       if seen[item.Name] {
+                                               continue
+                                       }
+                                       seen[item.Name] = true
+                                       scan.items = append(scan.items, item)
+                               }
+                       }
+               }
+       }
+       sortItems(scan.items)
+       return scan
+}
+
+// readSkillDir reads one directory of skill folders. A folder without a
+// manifest is looked into one level further: a skills directory may group its
+// skills, and a grouped skill is a skill like any other.
+func readSkillDir(agentId string, owner string, dir skillDir) ([]*Item, error) 
{
+       entries, err := os.ReadDir(dir.path)
        if os.IsNotExist(err) {
-               return []*Item{}, nil
+               return nil, nil
        }
        if err != nil {
-               return []*Item{}, err
+               return nil, err
        }
 
        items := []*Item{}
@@ -53,40 +137,101 @@ func readSkills(agentId string, owner string, dir string) 
([]*Item, error) {
                if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
                        continue
                }
-               path := filepath.Join(dir, entry.Name())
-               manifest, ok := manifestPath(path)
-               if !ok {
+
+               path := filepath.Join(dir.path, entry.Name())
+               if _, ok := manifestPath(path); ok {
+                       items = append(items, newSkillItem(agentId, owner, dir, 
entry.Name(), path))
                        continue
                }
 
-               item := &Item{
-                       AgentId: agentId,
-                       Owner:   owner,
-                       Kind:    KindSkill,
-                       Name:    entry.Name(),
-                       Path:    path,
+               nested, err := os.ReadDir(path)
+               if err != nil {
+                       continue
                }
+               for _, child := range nested {
+                       if !child.IsDir() || strings.HasPrefix(child.Name(), 
".") {
+                               continue
+                       }
+                       childPath := filepath.Join(path, child.Name())
+                       if _, ok := manifestPath(childPath); ok {
+                               items = append(items, newSkillItem(agentId, 
owner, dir, entry.Name()+"/"+child.Name(), childPath))
+                       }
+               }
+       }
+       return items, nil
+}
+
+func newSkillItem(agentId string, owner string, dir skillDir, name string, 
path string) *Item {
+       item := &Item{
+               AgentId: agentId,
+               Owner:   owner,
+               Kind:    KindSkill,
+               Name:    name,
+               Path:    path,
+               Scope:   dir.scope,
+               Origin:  dir.owner,
+       }
+       if dir.owner != "" {
+               item.Name = dir.owner + ":" + name
+       }
+       if dir.scope == ScopePlugin {
+               item.ReadOnly = "this skill belongs to a plugin; uninstall the 
plugin to remove it"
+       }
+       if dir.scope == ScopeProject {
+               item.Project = filepath.Dir(filepath.Dir(dir.path))
+       }
+
+       if manifest, ok := manifestPath(path); ok {
                if raw, err := os.ReadFile(manifest); err == nil {
                        _, item.Description = parseFrontMatter(string(raw))
                }
-               item.Files, item.Bytes = measure(path)
-               items = append(items, item)
        }
-       sortItems(items)
-       return items, nil
+       stat := measure(path)
+       item.Files, item.Bytes, item.Digest, item.Modified = stat.files, 
stat.bytes, stat.digest, stat.modified
+       return item
 }
 
-// skillDetail loads one skill's manifest and the names of the files shipped
-// with it.
-func skillDetail(agentId string, owner string, dir string, name string) 
(*Detail, error) {
-       if err := checkName(name); err != nil {
-               return nil, err
+// pluginSkillDirs finds the skills folders inside a plugin tree. Agents read
+// the skills their plugins ship, so those are on the machine exactly like the
+// hand-written ones and a listing that leaves them out is wrong.
+func pluginSkillDirs(root string, scope string) []skillDir {
+       dirs := []skillDir{}
+       filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) 
error {
+               if err != nil || !entry.IsDir() || path == root {
+                       return nil
+               }
+               if strings.HasPrefix(entry.Name(), ".") || 
pluginNoise[strings.ToLower(entry.Name())] {
+                       return fs.SkipDir
+               }
+               if len(dirs) >= maxPluginSkillDir {
+                       return filepath.SkipAll
+               }
+               if strings.EqualFold(entry.Name(), "skills") {
+                       dirs = append(dirs, skillDir{path: path, scope: scope, 
owner: filepath.Base(filepath.Dir(path))})
+                       return fs.SkipDir
+               }
+               if depthUnder(root, path) >= maxPluginDepth {
+                       return fs.SkipDir
+               }
+               return nil
+       })
+       return dirs
+}
+
+func depthUnder(root string, path string) int {
+       relative, err := filepath.Rel(root, path)
+       if err != nil {
+               return maxPluginDepth
        }
+       return len(strings.Split(filepath.ToSlash(relative), "/"))
+}
 
-       path := filepath.Join(dir, name)
-       manifest, ok := manifestPath(path)
+// skillDetail loads one skill's manifest and the names of the files shipped
+// with it.
+func skillDetail(item *Item) (*Detail, error) {
+       manifest, ok := manifestPath(item.Path)
        if !ok {
-               return nil, fmt.Errorf("no %s in %s", skillFile, path)
+               return nil, fmt.Errorf("no %s in %s", skillFile, item.Path)
        }
 
        raw, err := os.ReadFile(manifest)
@@ -97,34 +242,7 @@ func skillDetail(agentId string, owner string, dir string, 
name string) (*Detail
        if len(raw) > maxSkillFileBytes {
                content = string(raw[:maxSkillFileBytes]) + "\n\n... truncated 
by Gateway ..."
        }
-
-       _, description := parseFrontMatter(content)
-       files, bytes := measure(path)
-       item := &Item{
-               AgentId:     agentId,
-               Owner:       owner,
-               Kind:        KindSkill,
-               Name:        name,
-               Description: description,
-               Path:        path,
-               Files:       files,
-               Bytes:       bytes,
-       }
-       return &Detail{Item: item, Content: content, Files: listFiles(path)}, 
nil
-}
-
-// deleteSkill removes one skill folder. It refuses a directory without a
-// manifest, so a mistyped name cannot delete something that is not a skill.
-func deleteSkill(dir string, name string) error {
-       if err := checkName(name); err != nil {
-               return err
-       }
-
-       path := filepath.Join(dir, name)
-       if _, ok := manifestPath(path); !ok {
-               return fmt.Errorf("%s is not a skill folder", path)
-       }
-       return os.RemoveAll(path)
+       return &Detail{Item: item, Content: content, Files: 
listFiles(item.Path)}, nil
 }
 
 // copySkill copies one skill folder into another agent's skills directory,
@@ -187,20 +305,59 @@ func manifestPath(dir string) (string, bool) {
        return "", false
 }
 
-func measure(dir string) (int, int64) {
-       files := 0
-       var bytes int64
-       filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) 
error {
+// treeStat summarizes a skill folder. digest identifies its content and
+// modified is its newest file, which is what lets two agents' copies of one
+// skill be told apart and put in order.
+type treeStat struct {
+       files    int
+       bytes    int64
+       digest   string
+       modified int64
+}
+
+func measure(dir string) treeStat {
+       stat := treeStat{}
+       hash := sha256.New()
+       // WalkDir visits in lexical order, so one folder digests the same 
wherever
+       // it is read from.
+       filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) 
error {
                if err != nil || entry.IsDir() {
                        return nil
                }
-               files++
-               if info, err := entry.Info(); err == nil {
-                       bytes += info.Size()
+               info, err := entry.Info()
+               if err != nil {
+                       return nil
+               }
+               stat.files++
+               stat.bytes += info.Size()
+               if modified := info.ModTime().Unix(); modified > stat.modified {
+                       stat.modified = modified
+               }
+
+               relative, err := filepath.Rel(dir, path)
+               if err != nil {
+                       return nil
+               }
+               fmt.Fprintf(hash, "%s\x00%d\x00", filepath.ToSlash(relative), 
info.Size())
+               if !entry.Type().IsRegular() || info.Size() > 
maxDigestFileBytes {
+                       fmt.Fprintf(hash, "%d\x00", info.ModTime().UnixNano())
+                       return nil
                }
+
+               file, err := os.Open(path)
+               if err != nil {
+                       return nil
+               }
+               io.Copy(hash, file)
+               file.Close()
                return nil
        })
-       return files, bytes
+       stat.digest = shortDigest(hash.Sum(nil))
+       return stat
+}
+
+func shortDigest(sum []byte) string {
+       return hex.EncodeToString(sum)[:16]
 }
 
 // listFiles names what a skill ships besides its manifest, in slash form so 
the
diff --git a/agentconfig/trash.go b/agentconfig/trash.go
new file mode 100644
index 0000000..2f2e75e
--- /dev/null
+++ b/agentconfig/trash.go
@@ -0,0 +1,319 @@
+// 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 agentconfig
+
+import (
+       "encoding/json"
+       "fmt"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+       "time"
+)
+
+const (
+       // A deleted item waits under the same home as the agent it came from, 
so a
+       // skill folder is moved there rather than copied.
+       trashRoot   = ".casbin-gateway"
+       trashFolder = "trash"
+       // trashRecord names the metadata of one deleted item, and trashPayload 
the
+       // skill folder beside it.
+       trashRecord  = "gateway-trash.json"
+       trashPayload = "payload"
+       // trashKeepDays is how long a deleted item can be restored. After that 
a
+       // listing drops it, so the trash cannot grow without bound.
+       trashKeepDays = 30
+)
+
+// TrashEntry is one deleted skill or MCP server, kept so a delete can be taken
+// back.
+type TrashEntry struct {
+       Id          string `json:"id"`
+       AgentId     string `json:"agentId"`
+       Owner       string `json:"owner"`
+       Kind        string `json:"kind"`
+       Name        string `json:"name"`
+       Description string `json:"description,omitempty"`
+       // Path is where the item was, and where a restore puts it back.
+       Path      string `json:"path"`
+       Files     int    `json:"files,omitempty"`
+       Bytes     int64  `json:"bytes,omitempty"`
+       DeletedAt int64  `json:"deletedAt"`
+
+       // Entry is the MCP server as it was written, credentials included. It 
is
+       // stripped before a listing leaves this package.
+       Entry map[string]any `json:"entry,omitempty"`
+}
+
+// trashSkill moves a skill folder out of the agent's reach instead of deleting
+// it, so an accidental delete is one restore away.
+func trashSkill(home string, item *Item) error {
+       entry := newTrashEntry(item)
+       dir, err := makeTrashDir(home, entry.Id)
+       if err != nil {
+               return err
+       }
+
+       if err := os.Rename(item.Path, filepath.Join(dir, trashPayload)); err 
!= nil {
+               os.RemoveAll(dir)
+               return err
+       }
+       if err := writeTrashRecord(dir, entry); err != nil {
+               // The folder is already out of the agent's skills directory, 
so put it
+               // back rather than leave it in a trash entry nothing can find.
+               os.Rename(filepath.Join(dir, trashPayload), item.Path)
+               os.RemoveAll(dir)
+               return err
+       }
+
+       // Nothing is at that path now, so the next skill written there is not 
the
+       // one this record was about.
+       forgetSkillOrigin(home, item.Path)
+       return nil
+}
+
+// trashMcp records one MCP server before it is removed from its config file.
+func trashMcp(home string, item *Item, entry map[string]any) error {
+       record := newTrashEntry(item)
+       record.Entry = entry
+       dir, err := makeTrashDir(home, record.Id)
+       if err != nil {
+               return err
+       }
+       if err := writeTrashRecord(dir, record); err != nil {
+               os.RemoveAll(dir)
+               return err
+       }
+       return nil
+}
+
+// ListTrash returns what can still be restored, newest first, and drops what
+// has waited longer than trashKeepDays.
+func ListTrash(owner string) ([]*TrashEntry, error) {
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+
+       root := trashDir(home)
+       dirs, err := os.ReadDir(root)
+       if os.IsNotExist(err) {
+               return []*TrashEntry{}, nil
+       }
+       if err != nil {
+               return nil, err
+       }
+
+       entries := []*TrashEntry{}
+       deadline := time.Now().AddDate(0, 0, -trashKeepDays).Unix()
+       for _, dir := range dirs {
+               if !dir.IsDir() {
+                       continue
+               }
+               entry, err := readTrashRecord(filepath.Join(root, dir.Name()))
+               if err != nil {
+                       continue
+               }
+               if entry.DeletedAt < deadline {
+                       os.RemoveAll(filepath.Join(root, dir.Name()))
+                       continue
+               }
+               // The stored MCP entry carries the credentials of the server 
it was;
+               // what the page needs is that it existed.
+               entry.Entry = nil
+               entry.Owner = owner
+               entries = append(entries, entry)
+       }
+
+       sort.Slice(entries, func(i, j int) bool { return entries[i].DeletedAt > 
entries[j].DeletedAt })
+       return entries, nil
+}
+
+// RestoreTrash puts one deleted item back where it came from. It refuses when
+// something is there again, rather than overwriting whatever replaced it;
+// replace says to go ahead, and what is there is moved into the trash first so
+// that nothing is lost either way.
+func RestoreTrash(owner string, id string, replace bool) (*TrashEntry, error) {
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+       if err := checkName(id); err != nil {
+               return nil, err
+       }
+
+       dir := filepath.Join(trashDir(home), id)
+       entry, err := readTrashRecord(dir)
+       if err != nil {
+               return nil, err
+       }
+
+       if entry.Kind == KindSkill {
+               err = restoreSkill(home, dir, entry, replace)
+       } else {
+               err = restoreMcp(owner, entry, replace)
+       }
+       if err != nil {
+               return nil, err
+       }
+
+       os.RemoveAll(dir)
+       entry.Entry = nil
+       return entry, nil
+}
+
+func restoreSkill(home string, dir string, entry *TrashEntry, replace bool) 
error {
+       if exists(entry.Path) {
+               if !replace {
+                       return fmt.Errorf("%s already exists again; restore it 
as a replacement, or move it aside first", entry.Path)
+               }
+               if err := trashSkill(home, occupant(entry)); err != nil {
+                       return err
+               }
+       }
+       if err := os.MkdirAll(filepath.Dir(entry.Path), 0o755); err != nil {
+               return err
+       }
+       return os.Rename(filepath.Join(dir, trashPayload), entry.Path)
+}
+
+// occupant is whatever took a deleted item's place, described well enough to 
be
+// moved into the trash in its turn.
+func occupant(entry *TrashEntry) *Item {
+       item := &Item{
+               AgentId: entry.AgentId,
+               Owner:   entry.Owner,
+               Kind:    entry.Kind,
+               Name:    entry.Name,
+               Path:    entry.Path,
+       }
+       if entry.Kind == KindSkill {
+               stat := measure(entry.Path)
+               item.Files, item.Bytes = stat.files, stat.bytes
+       }
+       return item
+}
+
+func restoreMcp(owner string, entry *TrashEntry, replace bool) error {
+       found, home, err := resolve(entry.AgentId, owner, KindMcp)
+       if err != nil {
+               return err
+       }
+       if found.mcp.readOnly != "" {
+               return fmt.Errorf("%s: %s", entry.AgentId, found.mcp.readOnly)
+       }
+
+       file := found.mcp.path(home)
+       existing, err := found.mcp.store.read(file)
+       if err != nil {
+               return err
+       }
+       if taken, ok := existing[entry.Name]; ok {
+               if !replace {
+                       return fmt.Errorf("%s already has an MCP server named 
%q again; restore it as a replacement, or rename that one first", 
entry.AgentId, entry.Name)
+               }
+               if err := trashMcp(home, occupant(entry), taken); err != nil {
+                       return err
+               }
+       }
+       return found.mcp.store.write(file, entry.Name, entry.Entry)
+}
+
+// PurgeTrash deletes one entry for good, or all of them when id is empty.
+func PurgeTrash(owner string, id string) error {
+       home, err := homeOf(owner)
+       if err != nil {
+               return err
+       }
+       if id == "" {
+               return os.RemoveAll(trashDir(home))
+       }
+       if err := checkName(id); err != nil {
+               return err
+       }
+       return os.RemoveAll(filepath.Join(trashDir(home), id))
+}
+
+func newTrashEntry(item *Item) *TrashEntry {
+       return &TrashEntry{
+               Id:          fmt.Sprintf("%d-%s", time.Now().UnixNano(), 
slug(item.Name)),
+               AgentId:     item.AgentId,
+               Owner:       item.Owner,
+               Kind:        item.Kind,
+               Name:        item.Name,
+               Description: item.Description,
+               Path:        item.Path,
+               Files:       item.Files,
+               Bytes:       item.Bytes,
+               DeletedAt:   time.Now().Unix(),
+       }
+}
+
+func trashDir(home string) string {
+       return filepath.Join(home, trashRoot, trashFolder)
+}
+
+func makeTrashDir(home string, id string) (string, error) {
+       dir := filepath.Join(trashDir(home), id)
+       if err := os.MkdirAll(dir, 0o700); err != nil {
+               return "", err
+       }
+       return dir, nil
+}
+
+func writeTrashRecord(dir string, entry *TrashEntry) error {
+       raw, err := json.MarshalIndent(entry, "", "  ")
+       if err != nil {
+               return err
+       }
+       return writeFile(filepath.Join(dir, trashRecord), raw, 
defaultConfigMode)
+}
+
+func readTrashRecord(dir string) (*TrashEntry, error) {
+       raw, err := os.ReadFile(filepath.Join(dir, trashRecord))
+       if err != nil {
+               return nil, err
+       }
+       entry := &TrashEntry{}
+       if err := json.Unmarshal(raw, entry); err != nil {
+               return nil, err
+       }
+       if entry.Id == "" || entry.Path == "" {
+               return nil, fmt.Errorf("%s is not a Gateway trash entry", dir)
+       }
+       return entry, nil
+}
+
+// slug keeps a deleted item recognizable in the trash directory without 
letting
+// its name decide a path.
+func slug(name string) string {
+       kept := strings.Map(func(letter rune) rune {
+               switch {
+               case letter >= 'a' && letter <= 'z', letter >= 'A' && letter <= 
'Z',
+                       letter >= '0' && letter <= '9', letter == '-', letter 
== '_':
+                       return letter
+               default:
+                       return '-'
+               }
+       }, name)
+       if len(kept) > 40 {
+               kept = kept[:40]
+       }
+       if kept == "" {
+               return "item"
+       }
+       return kept
+}
diff --git a/agentconfig/update.go b/agentconfig/update.go
new file mode 100644
index 0000000..20a06cb
--- /dev/null
+++ b/agentconfig/update.go
@@ -0,0 +1,272 @@
+// 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 agentconfig
+
+import (
+       "encoding/json"
+       "fmt"
+       "os"
+       "path/filepath"
+       "sync"
+       "time"
+)
+
+// originFile records where the skills Gateway copied came from, under the same
+// home as the trash. A skill folder carries no provenance of its own, so
+// without this a copy and a hand-written skill look alike a day later.
+const originFile = "skill-origins.json"
+
+// What a skill's content says about the copy it came from.
+const (
+       // UpdateCurrent is a skill holding exactly what its source holds.
+       UpdateCurrent = "current"
+       // UpdateAvailable is a source that has moved on while this copy has 
not.
+       UpdateAvailable = "available"
+       // UpdateModified is a copy edited here, with the source unchanged.
+       UpdateModified = "modified"
+       // UpdateDiverged is both of the above at once: updating would overwrite
+       // edits made here.
+       UpdateDiverged = "diverged"
+       // UpdateUnknown is a source that is no longer on this machine.
+       UpdateUnknown = "unknown"
+)
+
+// SkillUpdate is where one skill came from and whether that source still holds
+// the same thing. It answers the question a folder of files cannot: is what is
+// installed here the current version of it.
+type SkillUpdate struct {
+       State string `json:"state"`
+       // Source is the folder this skill was copied from, and SourceAgentId 
the
+       // agent that folder belongs to.
+       Source        string `json:"source,omitempty"`
+       SourceAgentId string `json:"sourceAgentId,omitempty"`
+       SourceName    string `json:"sourceName,omitempty"`
+       // Inferred marks a source Gateway matched by name rather than one it
+       // recorded itself when copying.
+       Inferred bool `json:"inferred,omitempty"`
+
+       SourceDigest   string `json:"sourceDigest,omitempty"`
+       SourceModified int64  `json:"sourceModified,omitempty"`
+       CopiedAt       int64  `json:"copiedAt,omitempty"`
+}
+
+// skillOrigin is one recorded copy: what was copied, from where, and the two
+// digests at the time. Both are needed to tell a source that has moved on from
+// a copy that was edited here.
+type skillOrigin struct {
+       AgentId string `json:"agentId"`
+       Name    string `json:"name"`
+       Path    string `json:"path"`
+       // Digest is what the source held when it was copied, and Written what
+       // landed here.
+       Digest   string `json:"digest,omitempty"`
+       Written  string `json:"written,omitempty"`
+       CopiedAt int64  `json:"copiedAt"`
+}
+
+// originLock serializes the read-modify-write of the origin file. Two copies
+// running at once would otherwise each write back what they read.
+var originLock sync.Mutex
+
+// attachSkillUpdates tells each skill how it stands against the copy it came
+// from. A plugin's skills are left alone: the plugin updates those, not 
Gateway.
+func attachSkillUpdates(items []*Item, home string) {
+       origins := readSkillOrigins(home)
+       for _, item := range items {
+               if item.Scope == ScopePlugin {
+                       continue
+               }
+               if origin, ok := origins[comparablePath(item.Path)]; ok {
+                       item.Update = compareToOrigin(item, origin)
+               }
+       }
+       inferSkillUpdates(items)
+}
+
+// compareToOrigin measures the source again and reads the two digests against
+// the ones recorded when the copy was made.
+func compareToOrigin(item *Item, origin *skillOrigin) *SkillUpdate {
+       update := &SkillUpdate{
+               State:         UpdateUnknown,
+               Source:        origin.Path,
+               SourceAgentId: origin.AgentId,
+               SourceName:    origin.Name,
+               SourceDigest:  origin.Digest,
+               CopiedAt:      origin.CopiedAt,
+       }
+       if _, ok := manifestPath(origin.Path); !ok {
+               return update
+       }
+
+       stat := measure(origin.Path)
+       update.SourceDigest, update.SourceModified = stat.digest, stat.modified
+
+       sourceChanged := stat.digest != origin.Digest
+       editedHere := origin.Written != "" && item.Digest != origin.Written
+       switch {
+       case stat.digest == item.Digest, !sourceChanged && !editedHere:
+               update.State = UpdateCurrent
+       case sourceChanged && editedHere:
+               update.State = UpdateDiverged
+       case sourceChanged:
+               update.State = UpdateAvailable
+       default:
+               update.State = UpdateModified
+       }
+       return update
+}
+
+// inferSkillUpdates pairs a skill the operator keeps with the plugin copy of
+// the same name on this machine. A skill installed by hand from a plugin
+// carries no record of it, and the plugin's copy is the published one, so the
+// two digests still answer whether this one is current.
+func inferSkillUpdates(items []*Item) {
+       published := map[string]*Item{}
+       for _, item := range items {
+               if item.Scope == ScopePlugin {
+                       published[targetName(KindSkill, item.Name)] = item
+               }
+       }
+       if len(published) == 0 {
+               return
+       }
+
+       for _, item := range items {
+               source := published[targetName(KindSkill, item.Name)]
+               if item.Update != nil || item.Scope == ScopePlugin || source == 
nil || source.Digest == "" {
+                       continue
+               }
+               update := &SkillUpdate{
+                       State:          UpdateCurrent,
+                       Source:         source.Path,
+                       SourceAgentId:  source.AgentId,
+                       SourceName:     source.Name,
+                       SourceDigest:   source.Digest,
+                       SourceModified: source.Modified,
+                       Inferred:       true,
+               }
+               if source.Digest != item.Digest {
+                       // Without a record of when this copy was made, the 
newer of the two
+                       // is all that separates an update from a local edit.
+                       if source.Modified <= item.Modified {
+                               continue
+                       }
+                       update.State = UpdateAvailable
+               }
+               item.Update = update
+       }
+}
+
+// UpdateSkill replaces one skill with the current content of the source it was
+// copied from. The old content goes to the trash first, so an update that 
turns
+// out to be unwanted is one restore away like a delete.
+func UpdateSkill(agentId string, owner string, name string) (*Item, error) {
+       _, home, err := resolve(agentId, owner, KindSkill)
+       if err != nil {
+               return nil, err
+       }
+       item, err := findItem(agentId, owner, KindSkill, name)
+       if err != nil {
+               return nil, err
+       }
+
+       if item.ReadOnly != "" {
+               return nil, fmt.Errorf("%s: %s", name, item.ReadOnly)
+       }
+       if item.Update == nil || item.Update.Source == "" {
+               return nil, fmt.Errorf("%s: Gateway does not know where this 
skill came from", name)
+       }
+       if item.Update.State == UpdateCurrent {
+               return nil, fmt.Errorf("%s is already the version its source 
holds", name)
+       }
+       if _, ok := manifestPath(item.Update.Source); !ok {
+               return nil, fmt.Errorf("%s is no longer a skill folder", 
item.Update.Source)
+       }
+
+       if err := trashSkill(home, item); err != nil {
+               return nil, err
+       }
+       path, err := copySkill(item.Update.Source, filepath.Dir(item.Path), 
filepath.Base(item.Path))
+       if err != nil {
+               return nil, fmt.Errorf("%s; the previous version is in the 
recycle bin", err)
+       }
+
+       recordSkillOrigin(home, path, item.Update.SourceAgentId, 
item.Update.SourceName, item.Update.Source)
+       updated, err := findItem(agentId, owner, KindSkill, name)
+       if err != nil {
+               return item, nil
+       }
+       return updated, nil
+}
+
+// recordSkillOrigin remembers that target was copied from source, so a later
+// listing can say whether the source has moved on since. A record that cannot
+// be written costs the update badge, not the copy, so nothing is reported.
+func recordSkillOrigin(home string, target string, sourceAgentId string, 
sourceName string, source string) {
+       originLock.Lock()
+       defer originLock.Unlock()
+
+       origins := readSkillOrigins(home)
+       origins[comparablePath(target)] = &skillOrigin{
+               AgentId:  sourceAgentId,
+               Name:     sourceName,
+               Path:     source,
+               Digest:   measure(source).digest,
+               Written:  measure(target).digest,
+               CopiedAt: time.Now().Unix(),
+       }
+
+       // A skill that has been deleted since would keep its record forever, 
and
+       // the next skill of that path would inherit it.
+       for path, origin := range origins {
+               if !exists(origin.Path) && !exists(path) {
+                       delete(origins, path)
+               }
+       }
+       writeSkillOrigins(home, origins)
+}
+
+// forgetSkillOrigin drops the record of one path, for a skill that has left 
it.
+func forgetSkillOrigin(home string, target string) {
+       originLock.Lock()
+       defer originLock.Unlock()
+
+       origins := readSkillOrigins(home)
+       if _, ok := origins[comparablePath(target)]; !ok {
+               return
+       }
+       delete(origins, comparablePath(target))
+       writeSkillOrigins(home, origins)
+}
+
+func readSkillOrigins(home string) map[string]*skillOrigin {
+       origins := map[string]*skillOrigin{}
+       raw, err := os.ReadFile(filepath.Join(home, trashRoot, originFile))
+       if err != nil {
+               return origins
+       }
+       if err := json.Unmarshal(raw, &origins); err != nil {
+               return map[string]*skillOrigin{}
+       }
+       return origins
+}
+
+func writeSkillOrigins(home string, origins map[string]*skillOrigin) {
+       raw, err := json.MarshalIndent(origins, "", "  ")
+       if err != nil {
+               return
+       }
+       writeFile(filepath.Join(home, trashRoot, originFile), raw, 
defaultConfigMode)
+}
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
index 58d1757..a7c0563 100644
--- a/controllers/agent_config.go
+++ b/controllers/agent_config.go
@@ -157,6 +157,96 @@ func (c *ApiController) DeleteAgentConfigItem() {
        c.ResponseOk(form.Name)
 }
 
+// GetAgentConfigTrash lists the skills and MCP servers deleting has removed 
and
+// that can still be put back.
+func (c *ApiController) GetAgentConfigTrash() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       entries, err := agentconfig.ListTrash(c.GetString("owner"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(entries)
+}
+
+// RestoreAgentConfigItem puts one deleted item back where it came from. It
+// refuses when something is there again unless replace says otherwise, and 
that
+// replaced item goes to the recycle bin in its turn.
+func (c *ApiController) RestoreAgentConfigItem() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               Owner   string `json:"owner"`
+               Id      string `json:"id"`
+               Replace bool   `json:"replace"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       entry, err := agentconfig.RestoreTrash(form.Owner, form.Id, 
form.Replace)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(entry)
+}
+
+// PurgeAgentConfigTrash deletes one trashed item for good, or all of them when
+// no id is given.
+func (c *ApiController) PurgeAgentConfigTrash() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               Owner string `json:"owner"`
+               Id    string `json:"id"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       if err := agentconfig.PurgeTrash(form.Owner, form.Id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(form.Id)
+}
+
+// UpdateAgentConfigSkill replaces one skill with the current content of the
+// source it was copied from, after putting the version it replaces in the
+// recycle bin.
+func (c *ApiController) UpdateAgentConfigSkill() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId string `json:"agentId"`
+               Owner   string `json:"owner"`
+               Name    string `json:"name"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       item, err := agentconfig.UpdateSkill(form.AgentId, form.Owner, 
form.Name)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(item)
+}
+
 // AddAgentConfigMcp writes one new MCP server into the agents it names, so a
 // server can be set up from Gateway instead of by hand in each agent's file.
 func (c *ApiController) AddAgentConfigMcp() {
diff --git a/routers/router.go b/routers/router.go
index 843603a..4ca6fe7 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -39,6 +39,7 @@ func initAPI() {
        beego.Router("/api/update-account", &controllers.ApiController{}, 
"POST:UpdateAccount")
        beego.Router("/api/get-providers", &controllers.ApiController{}, 
"GET:GetProviders")
        beego.Router("/api/get-gateway-status", &controllers.ApiController{}, 
"GET:GetGatewayStatus")
+       beego.Router("/api/update-system-settings", 
&controllers.ApiController{}, "POST:UpdateSystemSettings")
 
        beego.Router("/api/get-global-nodes", &controllers.ApiController{}, 
"GET:GetGlobalNodes")
        beego.Router("/api/get-nodes", &controllers.ApiController{}, 
"GET:GetNodes")
@@ -73,6 +74,10 @@ func initAPI() {
        beego.Router("/api/get-agent-configs", &controllers.ApiController{}, 
"GET:GetAgentConfigs")
        beego.Router("/api/get-agent-config-item", 
&controllers.ApiController{}, "GET:GetAgentConfigItem")
        beego.Router("/api/delete-agent-config-item", 
&controllers.ApiController{}, "POST:DeleteAgentConfigItem")
+       beego.Router("/api/get-agent-config-trash", 
&controllers.ApiController{}, "GET:GetAgentConfigTrash")
+       beego.Router("/api/restore-agent-config-item", 
&controllers.ApiController{}, "POST:RestoreAgentConfigItem")
+       beego.Router("/api/purge-agent-config-trash", 
&controllers.ApiController{}, "POST:PurgeAgentConfigTrash")
+       beego.Router("/api/update-agent-config-skill", 
&controllers.ApiController{}, "POST:UpdateAgentConfigSkill")
        beego.Router("/api/add-agent-config-mcp", &controllers.ApiController{}, 
"POST:AddAgentConfigMcp")
        beego.Router("/api/plan-agent-config-copy", 
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
        beego.Router("/api/plan-agent-provider", &controllers.ApiController{}, 
"POST:PlanAgentProvider")
diff --git a/web/src/backend/AgentConfigBackend.ts 
b/web/src/backend/AgentConfigBackend.ts
index 340ac75..6111c66 100644
--- a/web/src/backend/AgentConfigBackend.ts
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -16,8 +16,10 @@ import {query, request} from "@/backend/request";
 import type {
   AgentConfigDetail,
   AgentConfigInventory,
+  AgentConfigItem,
   AgentConfigKind,
   AgentConfigPlanItem,
+  AgentConfigTrashEntry,
   McpTransport,
 } from "@/types";
 
@@ -67,6 +69,34 @@ export function deleteAgentConfigItem(agentId: string, 
owner: string, kind: Agen
   });
 }
 
+/** What deleting removed and can still be put back. */
+export function getAgentConfigTrash(owner: string) {
+  return 
request<AgentConfigTrashEntry[]>(`/api/get-agent-config-trash${query({owner: 
owner})}`);
+}
+
+/** Puts one deleted item back. `replace` recycles whatever took its place 
first. */
+export function restoreAgentConfigItem(owner: string, id: string, replace = 
false) {
+  return request<AgentConfigTrashEntry>("/api/restore-agent-config-item", 
"POST", {
+    owner: owner,
+    id: id,
+    replace: replace,
+  });
+}
+
+/** Replaces one skill with the current content of the source it was copied 
from. */
+export function updateAgentConfigSkill(agentId: string, owner: string, name: 
string) {
+  return request<AgentConfigItem>("/api/update-agent-config-skill", "POST", {
+    agentId: agentId,
+    owner: owner,
+    name: name,
+  });
+}
+
+/** Deletes one trashed item for good, or all of them when no id is given. */
+export function purgeAgentConfigTrash(owner: string, id = "") {
+  return request("/api/purge-agent-config-trash", "POST", {owner: owner, id: 
id});
+}
+
 /** What a copy would do, asked for before anything is written. */
 export function planAgentConfigCopy(body: CopyRequest) {
   return request<AgentConfigPlanItem[]>("/api/plan-agent-config-copy", "POST", 
body);
diff --git a/web/src/components/agent-config/detail-dialog.tsx 
b/web/src/components/agent-config/detail-dialog.tsx
index 1ca8423..9442bdc 100644
--- a/web/src/components/agent-config/detail-dialog.tsx
+++ b/web/src/components/agent-config/detail-dialog.tsx
@@ -27,7 +27,8 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
-import {endpointOf} from "@/lib/agent-configs";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {endpointOf, formatModified, updateBadge} from "@/lib/agent-configs";
 import type {AgentConfigDetail, AgentConfigItem} from "@/types";
 
 /** The item whose definition is on screen, and the agent it was read from. */
@@ -52,6 +53,7 @@ export function DetailDialog({
   const [loading, setLoading] = React.useState(false);
 
   const item = target?.item;
+  const version = item ? updateBadge(item) : null;
 
   React.useEffect(() => {
     if (!item) {
@@ -100,6 +102,35 @@ export function DetailDialog({
             <CodeText copyable>{item?.path}</CodeText>
           </div>
 
+          {item?.digest ? (
+            <div className="flex flex-wrap items-center gap-2 text-xs">
+              <span 
className="text-muted-foreground">{i18next.t("agentConfig:Version")}</span>
+              <SimpleTooltip title={i18next.t("agentConfig:Version detail")}>
+                <Badge variant="outline" className="font-mono font-normal">
+                  {item.digest}
+                </Badge>
+              </SimpleTooltip>
+              {item.modified ? (
+                <span className="text-muted-foreground">
+                  {`${i18next.t("agentConfig:Changed")} 
${formatModified(item.modified)}`}
+                </span>
+              ) : null}
+            </div>
+          ) : null}
+
+          {version ? (
+            <div className="flex flex-wrap items-center gap-2 text-xs">
+              <span 
className="text-muted-foreground">{i18next.t("agentConfig:Source")}</span>
+              <Badge variant={version.variant}>{version.label}</Badge>
+              <CodeText copyable>{item?.update?.source}</CodeText>
+              {item?.update?.copiedAt ? (
+                <span className="text-muted-foreground">
+                  {`${i18next.t("agentConfig:Copied")} 
${formatModified(item.update.copiedAt)}`}
+                </span>
+              ) : null}
+            </div>
+          ) : null}
+
           {detail?.files?.length ? (
             <div className="flex flex-wrap items-center gap-1.5 text-xs">
               <span 
className="text-muted-foreground">{i18next.t("agentConfig:Bundled 
files")}</span>
diff --git a/web/src/components/agent-config/trash-dialog.tsx 
b/web/src/components/agent-config/trash-dialog.tsx
new file mode 100644
index 0000000..5907fa5
--- /dev/null
+++ b/web/src/components/agent-config/trash-dialog.tsx
@@ -0,0 +1,226 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {RotateCcw, Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {formatBytes, formatModified} from "@/lib/agent-configs";
+import type {AgentConfigTrashEntry} from "@/types";
+
+/**
+ * What deleting removed, and the way back. A delete on this page moves the 
item
+ * here instead of erasing it, so a mistyped click is undone by one restore.
+ */
+export function TrashDialog({
+  open,
+  onOpenChange,
+  owner,
+  agentNames,
+  onRestored,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  owner: string;
+  agentNames: Map<string, string>;
+  onRestored: () => void;
+}) {
+  const [entries, setEntries] = React.useState<AgentConfigTrashEntry[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [busy, setBusy] = React.useState("");
+  const [error, setError] = React.useState("");
+  // The entries whose place has been taken since they were deleted. Restoring
+  // one of those is offered a second time, as a replacement.
+  const [blocked, setBlocked] = React.useState<string[]>([]);
+
+  const load = React.useCallback(() => {
+    setLoading(true);
+    setError("");
+    setBlocked([]);
+    AgentConfigBackend.getAgentConfigTrash(owner)
+      .then(res => {
+        if (res.status === "ok") {
+          setEntries(res.data ?? []);
+        } else {
+          setError(res.msg || i18next.t("agentConfig:Failed to read the 
recycle bin"));
+        }
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => setLoading(false));
+  }, [owner]);
+
+  React.useEffect(() => {
+    if (open) {
+      load();
+    }
+  }, [open, load]);
+
+  const restore = (entry: AgentConfigTrashEntry, replace = false) => {
+    setBusy(entry.id);
+    return AgentConfigBackend.restoreAgentConfigItem(owner, entry.id, replace)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", 
`${i18next.t("agentConfig:Restored")}: ${entry.name}`);
+          load();
+          onRestored();
+        } else {
+          // Something took its place while it was in here, and the server says
+          // so rather than overwriting it. The operator reads what stands in 
the
+          // way and is offered the restore again, as a replacement.
+          if (res.msg?.includes("already")) {
+            setBlocked(previous => [...previous, entry.id]);
+          }
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to restore"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setBusy(""));
+  };
+
+  const purge = (id: string) => {
+    setBusy(id || "all");
+    return AgentConfigBackend.purgeAgentConfigTrash(owner, id)
+      .then(res => {
+        if (res.status === "ok") {
+          load();
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to delete"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setBusy(""));
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[85vh] gap-3 sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agentConfig:Recycle bin")}</DialogTitle>
+          <DialogDescription>{i18next.t("agentConfig:Recycle bin 
hint")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="flex flex-col gap-2 overflow-y-auto">
+          {error ? <MessageAlert description={error} /> : null}
+          {loading ? <Loading /> : null}
+
+          {!loading && entries.length === 0 ? (
+            <EmptyState
+              icon={Trash2}
+              title={i18next.t("agentConfig:Nothing deleted")}
+              description={i18next.t("agentConfig:Nothing deleted detail")}
+            />
+          ) : null}
+
+          {entries.map(entry => (
+            <div key={entry.id} className="flex items-center justify-between 
gap-3 rounded-md border px-3 py-2">
+              <div className="min-w-0">
+                <div className="flex items-center gap-1.5">
+                  <span className="truncate text-sm 
font-medium">{entry.name}</span>
+                  <Badge variant="muted">{agentNames.get(entry.agentId) ?? 
entry.agentId}</Badge>
+                  <Badge variant="outline">
+                    {i18next.t(entry.kind === "skill" ? "agentConfig:Skill" : 
"agentConfig:MCP")}
+                  </Badge>
+                </div>
+                <p className="text-muted-foreground truncate text-xs">
+                  {formatModified(entry.deletedAt)}
+                  {entry.bytes ? ` · ${formatBytes(entry.bytes)}` : ""}
+                  {` · ${entry.path}`}
+                </p>
+              </div>
+
+              <div className="flex shrink-0 items-center gap-1">
+                {blocked.includes(entry.id) ? (
+                  <ConfirmDialog
+                    title={i18next.t("agentConfig:Restore as a replacement?")}
+                    description={i18next.t("agentConfig:Restore as a 
replacement description")}
+                    onConfirm={() => restore(entry, true)}
+                    disabled={busy === entry.id}
+                  >
+                    <Button variant="outline" size="sm" disabled={busy === 
entry.id}>
+                      <RotateCcw className="size-4" />
+                      {i18next.t("agentConfig:Restore as a replacement")}
+                    </Button>
+                  </ConfirmDialog>
+                ) : (
+                  <SimpleTooltip title={i18next.t("agentConfig:Restore")}>
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      disabled={busy === entry.id}
+                      onClick={() => restore(entry)}
+                    >
+                      <RotateCcw className="size-4" />
+                      {i18next.t("agentConfig:Restore")}
+                    </Button>
+                  </SimpleTooltip>
+                )}
+                <ConfirmDialog
+                  title={i18next.t("agentConfig:Delete for good?")}
+                  description={i18next.t("agentConfig:Delete for good 
description")}
+                  onConfirm={() => purge(entry.id)}
+                  disabled={busy === entry.id}
+                >
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="text-destructive"
+                    aria-label={i18next.t("agentConfig:Delete for good?")}
+                    disabled={busy === entry.id}
+                  >
+                    <Trash2 className="size-4" />
+                  </Button>
+                </ConfirmDialog>
+              </div>
+            </div>
+          ))}
+        </div>
+
+        <DialogFooter>
+          {entries.length > 0 ? (
+            <ConfirmDialog
+              title={i18next.t("agentConfig:Empty the recycle bin?")}
+              description={i18next.t("agentConfig:Delete for good 
description")}
+              onConfirm={() => purge("")}
+              disabled={busy === "all"}
+            >
+              <Button variant="outline" className="text-destructive" 
disabled={busy === "all"}>
+                {i18next.t("agentConfig:Empty the recycle bin")}
+              </Button>
+            </ConfirmDialog>
+          ) : null}
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {i18next.t("general:Close")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
index 2ae59b9..7197864 100644
--- a/web/src/lib/agent-configs.ts
+++ b/web/src/lib/agent-configs.ts
@@ -16,7 +16,13 @@ import * as React from "react";
 import i18next from "i18next";
 
 import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
-import type {AgentConfigInventory, AgentConfigItem, AgentConfigKind} from 
"@/types";
+import type {
+  AgentConfigInventory,
+  AgentConfigItem,
+  AgentConfigKind,
+  AgentConfigUpdateState,
+} from "@/types";
+import type {BadgeVariant} from "@/components/ui/badge";
 
 /** Skills and MCP servers belong to an account, so an id alone is not unique. 
*/
 export function inventoryKey(inventory: Pick<AgentConfigInventory, "agentId" | 
"owner">) {
@@ -50,17 +56,133 @@ export function blockedReason(inventory: 
AgentConfigInventory, kind: AgentConfig
   return "";
 }
 
-/** Which agents already have an item of each name, keyed by name. */
-export function presenceOf(inventories: AgentConfigInventory[], kind: 
AgentConfigKind) {
-  const presence = new Map<string, Set<string>>();
+/**
+ * The name two agents' copies of one item share. A skill from a plugin or from
+ * a group inside the skills directory carries that in its name, but it is the
+ * same skill once copied, so comparisons key on the last part.
+ */
+export function sharedName(name: string) {
+  const cut = Math.max(name.lastIndexOf(":"), name.lastIndexOf("/"));
+  return cut < 0 ? name : name.slice(cut + 1);
+}
+
+/** The item of each name every agent holds, keyed by shared name then agent. 
*/
+export function copiesOf(inventories: AgentConfigInventory[], kind: 
AgentConfigKind) {
+  const copies = new Map<string, Map<string, AgentConfigItem>>();
   inventories.forEach(inventory => {
     itemsOf(inventory, kind).forEach(item => {
-      const holders = presence.get(item.name) ?? new Set<string>();
-      holders.add(inventoryKey(inventory));
-      presence.set(item.name, holders);
+      const holders = copies.get(sharedName(item.name)) ?? new Map<string, 
AgentConfigItem>();
+      if (!holders.has(inventoryKey(inventory))) {
+        holders.set(inventoryKey(inventory), item);
+      }
+      copies.set(sharedName(item.name), holders);
     });
   });
-  return presence;
+  return copies;
+}
+
+/** How another agent's copy of one item relates to the one on screen. */
+export type VersionState = "same" | "newer" | "older" | "differs";
+
+export function compareVersions(item: AgentConfigItem, other: 
AgentConfigItem): VersionState {
+  if (item.digest && item.digest === other.digest) {
+    return "same";
+  }
+  if (item.modified && other.modified && item.modified !== other.modified) {
+    return other.modified > item.modified ? "newer" : "older";
+  }
+  return "differs";
+}
+
+/** The agents carrying a newer version of this item than the source does. */
+export function newerHolders(
+  item: AgentConfigItem,
+  copies: Map<string, Map<string, AgentConfigItem>>,
+  peers: AgentConfigInventory[],
+) {
+  const holders = copies.get(sharedName(item.name));
+  if (!holders) {
+    return [];
+  }
+  return peers
+    .filter(peer => {
+      const other = holders.get(inventoryKey(peer));
+      return Boolean(other) && compareVersions(item, other as AgentConfigItem) 
=== "newer";
+    })
+    .map(peer => peer.name);
+}
+
+/**
+ * What a skill's own version says, as a badge. A skill is a folder of files
+ * with no version in it, so this is the answer to the question the folder
+ * cannot answer: is this still what its source holds. Skills with no known
+ * source get nothing, because nothing is known about them.
+ */
+export function updateBadge(item: AgentConfigItem): {
+  label: string;
+  title: string;
+  variant: BadgeVariant;
+} | null {
+  const update = item.update;
+  if (!update || item.kind !== "skill") {
+    return null;
+  }
+
+  const source = update.sourceName || update.source || "";
+  const where = update.inferred
+    ? i18next.t("agentConfig:Matched by name")
+    : i18next.t("agentConfig:Copied by Gateway");
+  const from = `${i18next.t("agentConfig:Source")}: ${source} (${where})`;
+
+  const states: Record<AgentConfigUpdateState, {label: string; title: string; 
variant: BadgeVariant} | null> = {
+    current: {
+      label: i18next.t("agentConfig:Up to date"),
+      title: `${i18next.t("agentConfig:Up to date detail")} ${from}`,
+      variant: "success",
+    },
+    available: {
+      label: i18next.t("agentConfig:Update available"),
+      title: `${i18next.t("agentConfig:Update available detail")} ${from}`,
+      variant: "warning",
+    },
+    modified: {
+      label: i18next.t("agentConfig:Edited here"),
+      title: `${i18next.t("agentConfig:Edited here detail")} ${from}`,
+      variant: "info",
+    },
+    diverged: {
+      label: i18next.t("agentConfig:Update available"),
+      title: `${i18next.t("agentConfig:Diverged detail")} ${from}`,
+      variant: "warning",
+    },
+    unknown: null,
+  };
+  return states[update.state] ?? null;
+}
+
+/** Whether Gateway can pull this skill's source over it. */
+export function canUpdate(item: AgentConfigItem) {
+  return (
+    item.kind === "skill" &&
+    !item.readOnly &&
+    Boolean(item.update?.source) &&
+    (item.update?.state === "available" || item.update?.state === "diverged")
+  );
+}
+
+/** Where a skill outside the agent's own skills directory comes from. */
+export function originTitle(item: AgentConfigItem) {
+  if (item.scope === "project") {
+    return i18next.t("agentConfig:From a project detail").replace("{project}", 
item.project || item.origin || "");
+  }
+  return i18next.t("agentConfig:From a plugin detail").replace("{plugin}", 
item.origin || "");
+}
+
+export function formatModified(modified: number | undefined) {
+  if (!modified) {
+    return "";
+  }
+  return new Date(modified * 1000).toLocaleString();
 }
 
 /**
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index a39319d..f2bb7af 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -138,10 +138,13 @@
     "Added to {done}, {failed} failed": "Added to {done} agents, {failed} 
failed",
     "Arguments": "Arguments",
     "Bundled files": "Bundled files",
+    "Changed": "Changed",
     "Command": "Command",
     "Command hint": "The executable the agent spawns, e.g. npx or uvx.",
     "Contents": "Contents",
+    "Copied": "Copied",
     "Copied 1 item": "Copied 1 item",
+    "Copied by Gateway": "copied by Gateway",
     "Copied {done} items": "Copied {done} items",
     "Copied {done}, {failed} failed": "Copied {done} items, {failed} failed",
     "Copy 1 MCP server": "Copy 1 MCP server",
@@ -154,9 +157,18 @@
     "Copy {count} to other agents": "Copy {count} to other agents",
     "Credentials are masked here": "Credentials are masked in this view. A 
copy carries the real values.",
     "Delete description": "This removes it from the agent's own configuration, 
here:",
+    "Delete for good description": "This erases it from the recycle bin, and 
it cannot be restored afterwards.",
+    "Delete for good?": "Delete for good?",
     "Delete this MCP server?": "Delete this MCP server?",
     "Delete this skill?": "Delete this skill?",
+    "Delete undo hint": "It goes to Gateway's recycle bin first, so this can 
be undone.",
     "Deleted": "Deleted",
+    "Different version here": "A different version is here",
+    "Diverged detail": "The source has changed and so has this copy. Updating 
replaces what was changed here, and puts it in the recycle bin.",
+    "Edited here": "Edited here",
+    "Edited here detail": "This copy differs from the source it came from, and 
that source has not changed since.",
+    "Empty the recycle bin": "Empty the recycle bin",
+    "Empty the recycle bin?": "Empty the recycle bin?",
     "Endpoint": "Endpoint",
     "Environment": "Environment variables",
     "Failed": "Failed",
@@ -165,16 +177,25 @@
     "Failed to delete": "Failed to delete",
     "Failed to plan this copy": "Failed to work out what this copy would do",
     "Failed to read agent configuration": "Failed to read the agents' 
configuration",
+    "Failed to read the recycle bin": "Failed to read the recycle bin",
     "Failed to read this item": "Failed to read this item",
+    "Failed to restore": "Failed to restore",
+    "Failed to update": "Failed to update",
     "From": "From",
+    "From a plugin detail": "Shipped by the {plugin} plugin. Uninstall the 
plugin to remove it.",
+    "From a project detail": "Kept in the project at {project}, not in the 
agent's own skills directory.",
     "Gateway does not know where this agent keeps these": "Gateway does not 
know where this agent keeps these",
     "HTTP endpoint": "HTTP endpoint",
+    "Headers": "Headers",
     "Local command": "Local command (stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP servers",
     "Managed by Gateway": "Gateway",
     "Managed by Gateway detail": "Registered by Gateway's agent monitoring. 
Turn monitoring off on the Agents page to remove it.",
+    "Matched by name": "matched by name",
+    "Moved to the recycle bin": "Moved to the recycle bin",
     "New": "New",
+    "Newer version here": "A newer version is here",
     "No MCP servers yet": "This agent has no MCP servers yet",
     "No agents found": "No agents found on this machine",
     "No agents found detail": "Gateway reads the configuration of the agents 
installed where it runs. Inside a container it sees the container's own 
filesystem, not the host's.",
@@ -182,22 +203,49 @@
     "No skills yet": "This agent has no skills yet",
     "Not installed": "config only",
     "Not installed detail": "Gateway found this configuration on disk but did 
not detect an installation of the agent.",
+    "Nothing deleted": "The recycle bin is empty",
+    "Nothing deleted detail": "Skills and MCP servers deleted on this page 
wait here for 30 days before they are erased.",
+    "Older version here": "An older version is here",
+    "Out of date": "Out of date",
+    "Out of date detail": "{agents} hold a newer version of this.",
     "Page description": "The skills and MCP servers each agent on this machine 
keeps in its own configuration files. Compare them side by side, and copy them 
from one agent to another.",
     "Pairs hint": "One KEY=VALUE per line.",
     "Pick the agents to copy into": "pick the agents to copy into",
     "Read from": "Read from",
+    "Recycle bin": "Recycle bin",
+    "Recycle bin hint": "What deleting removed, kept for 30 days. Restoring 
puts it back where it was.",
     "Replace items that already exist": "Replace items that already exist",
     "Replaced": "Replaced",
+    "Restore": "Restore",
+    "Restore as a replacement": "Restore as a replacement",
+    "Restore as a replacement description": "Something is at that path again. 
It is moved to the recycle bin in its turn, and this item takes its place.",
+    "Restore as a replacement?": "Restore as a replacement?",
+    "Restored": "Restored",
+    "Same version": "The same version",
     "Search by name": "Search by name",
     "Select all": "Select all",
     "Shared with": "The same files are read by {agents}.",
+    "Skill": "Skill",
     "Skills": "Skills",
     "Skills & MCP": "Skills & MCP",
     "Skipped": "Skipped",
+    "Source": "Source",
     "This file is read-only for Gateway": "Gateway does not write this agent's 
configuration file",
     "Transport": "Transport",
     "URL": "URL",
+    "Up to date": "Up to date",
+    "Up to date detail": "This holds exactly what its source holds.",
+    "Update available": "Update available",
+    "Update available detail": "The source this was copied from has changed 
since.",
+    "Update description": "This replaces the skill with the current content 
of:",
+    "Update from source": "Update from source",
+    "Update this skill?": "Update this skill?",
+    "Update undo hint": "The version it replaces goes to the recycle bin, so 
this can be undone.",
+    "Updated": "Updated",
+    "Version": "Version",
+    "Version detail": "A digest of the skill's files. Two agents holding the 
same digest hold the same version.",
     "View": "View",
+    "and {count} more folders": "and {count} more folders",
     "{files} files": "{files} files"
   },
   "cert": {
@@ -360,7 +408,7 @@
     "Any outcome": "Any outcome",
     "Attempts": "Attempts",
     "Bodies are not stored": "Request bodies are not stored",
-    "Bodies are not stored detail": "llmRecordMode is \"metadata\", so the 
prompts, tool schemas and messages behind these rows were never kept. Set it to 
\"full\" in conf/app.conf and restart Gateway to inspect them.",
+    "Bodies are not stored detail": "llmRecordMode is \"metadata\", so the 
prompts, tool schemas and messages behind these rows were never kept. Store 
them from now on to inspect them.",
     "Body is not JSON": "The stored body is not valid JSON, so it is shown as 
it was captured.",
     "Body not stored": "The request body was not stored: llmRecordMode is 
\"metadata\".",
     "Body too large to store": "The request body was larger than the capture 
limit and was not stored.",
@@ -405,8 +453,11 @@
     "Output tokens": "Output tokens",
     "Parameters": "Parameters",
     "Raw": "Raw",
+    "Record metadata": "Record metadata",
+    "Record metadata and bodies": "Record metadata and bodies",
     "Recording is off": "Recording is off",
-    "Recording is off detail": "Set llmRecordMode to \"metadata\" or \"full\" 
in conf/app.conf and restart Gateway. Prompts can carry sensitive data, so 
nothing is retained until you ask for it.",
+    "Recording is off detail": "Nothing is recorded until you ask for it, 
because prompts can carry sensitive data. Metadata is who called which model 
with which outcome; bodies are the prompts themselves.",
+    "Recording is on": "Recording is on",
     "Records were dropped": "{count} records were dropped because the write 
queue was full. Raise llmRecordQueueCapacity if this keeps happening.",
     "Redacted values": "Redacted values",
     "Request": "Request",
@@ -509,7 +560,9 @@
     "Site": "Site",
     "Status": "Status",
     "The reverse proxy is not enabled": "The reverse proxy is not enabled",
-    "The sites below will not be proxied. Set gatewayEnabled = true in 
conf/app.conf and restart Casbin Gateway to enable it.": "The sites below will 
not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin 
Gateway to enable it."
+    "The reverse proxy is now running": "The reverse proxy is now running",
+    "The sites below will not be proxied until it is turned on.": "The sites 
below will not be proxied until it is turned on.",
+    "Turn on the reverse proxy": "Turn on the reverse proxy"
   },
   "usage": {
     "All": "All",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 161fd2a..3443e31 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -138,10 +138,13 @@
     "Added to {done}, {failed} failed": "已添加到 {done} 个 Agent,{failed} 个失败",
     "Arguments": "参数",
     "Bundled files": "附带文件",
+    "Changed": "修改于",
     "Command": "命令",
     "Command hint": "Agent 要启动的可执行文件,例如 npx 或 uvx。",
     "Contents": "内容",
+    "Copied": "复制于",
     "Copied 1 item": "已复制 1 项",
+    "Copied by Gateway": "由网关复制",
     "Copied {done} items": "已复制 {done} 项",
     "Copied {done}, {failed} failed": "已复制 {done} 项,{failed} 项失败",
     "Copy 1 MCP server": "复制 1 个 MCP 服务器",
@@ -154,9 +157,18 @@
     "Copy {count} to other agents": "把 {count} 项复制到其他 Agent",
     "Credentials are masked here": "此视图中的凭据已被遮蔽。复制时携带的是真实值。",
     "Delete description": "这会把它从该 Agent 自己的配置中删除,位置:",
+    "Delete for good description": "将从回收站中彻底删除,之后无法恢复。",
+    "Delete for good?": "彻底删除?",
     "Delete this MCP server?": "删除这个 MCP 服务器?",
     "Delete this skill?": "删除这个技能?",
+    "Delete undo hint": "会先移入网关的回收站,可以随时撤销。",
     "Deleted": "已删除",
+    "Different version here": "这里是另一个版本",
+    "Diverged detail": "来源已更新,本地也改过。更新会覆盖本地修改,被覆盖的版本会放进回收站。",
+    "Edited here": "本地已修改",
+    "Edited here detail": "这份副本与来源不一致,而来源自复制以来没有变化。",
+    "Empty the recycle bin": "清空回收站",
+    "Empty the recycle bin?": "清空回收站?",
     "Endpoint": "端点",
     "Environment": "环境变量",
     "Failed": "失败",
@@ -165,16 +177,25 @@
     "Failed to delete": "删除失败",
     "Failed to plan this copy": "无法预览这次复制的结果",
     "Failed to read agent configuration": "读取 Agent 配置失败",
+    "Failed to read the recycle bin": "读取回收站失败",
     "Failed to read this item": "读取该项失败",
+    "Failed to restore": "恢复失败",
+    "Failed to update": "更新失败",
     "From": "从",
+    "From a plugin detail": "由插件 {plugin} 提供,卸载该插件才能移除。",
+    "From a project detail": "保存在项目 {project} 中,不在智能体自己的 skills 目录里。",
     "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 
把这些配置放在哪里",
     "HTTP endpoint": "HTTP 端点",
+    "Headers": "请求头",
     "Local command": "本地命令(stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP 服务器",
     "Managed by Gateway": "Gateway",
     "Managed by Gateway detail": "由 Gateway 的 Agent 监控注册。要移除它,请在 Agents 
页面关闭监控。",
+    "Matched by name": "按名称匹配",
+    "Moved to the recycle bin": "已移入回收站",
     "New": "新增",
+    "Newer version here": "这里是更新的版本",
     "No MCP servers yet": "该 Agent 还没有 MCP 服务器",
     "No agents found": "本机没有找到 Agent",
     "No agents found detail": "Gateway 只读取它自己所在机器上安装的 Agent 
的配置。在容器里运行时,它看到的是容器自己的文件系统,而不是宿主机的。",
@@ -182,22 +203,49 @@
     "No skills yet": "该 Agent 还没有技能",
     "Not installed": "仅配置",
     "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
+    "Nothing deleted": "回收站是空的",
+    "Nothing deleted detail": "在本页删除的技能和 MCP 服务会在这里保留 30 天,然后才真正删除。",
+    "Older version here": "这里是更旧的版本",
+    "Out of date": "有更新",
+    "Out of date detail": "{agents} 上的版本比这里新。",
     "Page description": "本机每个 Agent 自己配置文件里的技能和 MCP 服务器。可以并排比较,并从一个 Agent 
复制到另一个。",
     "Pairs hint": "每行一个 KEY=VALUE。",
     "Pick the agents to copy into": "选择要复制到的 Agent",
     "Read from": "读取自",
+    "Recycle bin": "回收站",
+    "Recycle bin hint": "删除的内容会在这里保留 30 天,恢复后回到原来的位置。",
     "Replace items that already exist": "覆盖已存在的同名项",
     "Replaced": "覆盖",
+    "Restore": "恢复",
+    "Restore as a replacement": "覆盖恢复",
+    "Restore as a replacement description": "原路径上已经有别的内容。它会被移入回收站,由这一项取代。",
+    "Restore as a replacement?": "覆盖恢复?",
+    "Restored": "已恢复",
+    "Same version": "版本相同",
     "Search by name": "按名称搜索",
     "Select all": "全选",
     "Shared with": "{agents} 读取的是同一批文件。",
+    "Skill": "技能",
     "Skills": "技能",
     "Skills & MCP": "技能与 MCP",
     "Skipped": "跳过",
+    "Source": "来源",
     "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
     "Transport": "连接方式",
     "URL": "URL",
+    "Up to date": "已是最新",
+    "Up to date detail": "内容与来源完全一致。",
+    "Update available": "有新版本",
+    "Update available detail": "复制来源在此之后已经更新。",
+    "Update description": "将用下面这个位置的当前内容替换该技能:",
+    "Update from source": "从来源更新",
+    "Update this skill?": "更新该技能?",
+    "Update undo hint": "被替换的版本会放进回收站,可以撤销。",
+    "Updated": "已更新",
+    "Version": "版本",
+    "Version detail": "技能文件内容的摘要。两个智能体的摘要相同,说明版本相同。",
     "View": "查看",
+    "and {count} more folders": "另有 {count} 个目录",
     "{files} files": "{files} 个文件"
   },
   "cert": {
@@ -360,7 +408,7 @@
     "Any outcome": "全部结果",
     "Attempts": "尝试次数",
     "Bodies are not stored": "未保存请求体",
-    "Bodies are not stored detail": "llmRecordMode 为 
\"metadata\",因此这些记录背后的提示词、工具 schema 和消息都没有保存。在 conf/app.conf 中改为 \"full\" 并重启 
Gateway 后即可查看。",
+    "Bodies are not stored detail": "llmRecordMode 为 
\"metadata\",因此这些记录背后的提示词、工具 schema 和消息都没有保存。开启保存后即可查看。",
     "Body is not JSON": "保存的请求体不是合法 JSON,这里按捕获时的原样显示。",
     "Body not stored": "未保存请求正文:llmRecordMode 为 \"metadata\"。",
     "Body too large to store": "请求正文超过采集上限,未予保存。",
@@ -405,8 +453,11 @@
     "Output tokens": "输出 Token",
     "Parameters": "参数",
     "Raw": "原始 JSON",
+    "Record metadata": "只记录元数据",
+    "Record metadata and bodies": "记录元数据和正文",
     "Recording is off": "记录功能已关闭",
-    "Recording is off detail": "请在 conf/app.conf 中把 llmRecordMode 设为 
\"metadata\" 或 \"full\" 并重启 Gateway。提示词可能包含敏感数据,因此默认不保留任何内容。",
+    "Recording is off detail": 
"除非你主动开启,否则不会记录任何内容,因为提示词可能包含敏感数据。元数据是谁调用了哪个模型、结果如何;正文则是提示词本身。",
+    "Recording is on": "已开启记录",
     "Records were dropped": "有 {count} 条记录因写入队列已满而被丢弃。如持续出现,请调大 
llmRecordQueueCapacity。",
     "Redacted values": "已脱敏字段数",
     "Request": "请求内容",
@@ -509,7 +560,9 @@
     "Site": "站点",
     "Status": "状态",
     "The reverse proxy is not enabled": "反向代理未启用",
-    "The sites below will not be proxied. Set gatewayEnabled = true in 
conf/app.conf and restart Casbin Gateway to enable it.": "下面的站点不会被代理。请在 
conf/app.conf 中设置 gatewayEnabled = true 并重启 Casbin Gateway 后生效。"
+    "The reverse proxy is now running": "反向代理已启动",
+    "The sites below will not be proxied until it is turned on.": 
"在开启之前,下面的站点不会被代理。",
+    "Turn on the reverse proxy": "开启反向代理"
   },
   "usage": {
     "All": "全部",
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
index 187f778..3128282 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -13,7 +13,18 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Check, Eye, Minus, Package, Plus, RefreshCw, Send, Trash2} from 
"lucide-react";
+import {
+  AlertTriangle,
+  ArrowDownToLine,
+  Check,
+  Eye,
+  Minus,
+  Package,
+  Plus,
+  RefreshCw,
+  Send,
+  Trash2,
+} from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
@@ -22,6 +33,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {AddMcpDialog} from "@/components/agent-config/add-mcp-dialog";
 import {CopyDialog} from "@/components/agent-config/copy-dialog";
 import {DetailDialog, type DetailTarget} from 
"@/components/agent-config/detail-dialog";
+import {TrashDialog} from "@/components/agent-config/trash-dialog";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {EmptyState} from "@/components/shared/empty-state";
@@ -35,15 +47,23 @@ import {SimpleTooltip} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
 import {
   blockedReason,
+  canUpdate,
+  compareVersions,
+  copiesOf,
   counted,
   endpointOf,
   formatBytes,
+  formatModified,
   inventoryKey,
   itemsOf,
   locationOf,
-  presenceOf,
+  newerHolders,
+  originTitle,
+  sharedName,
   supports,
+  updateBadge,
   useAgentConfigs,
+  type VersionState,
 } from "@/lib/agent-configs";
 import type {Account, AgentConfigInventory, AgentConfigItem, AgentConfigKind} 
from "@/types";
 
@@ -94,6 +114,36 @@ function SourcePicker({
   );
 }
 
+/**
+ * What one other agent holds of the item on this row. A tick means the same
+ * content; a warning means the same name with different content, which is the
+ * version question the page exists to answer.
+ */
+function PeerCell({state}: {state: VersionState | "missing"}) {
+  if (state === "missing") {
+    return <Minus className="text-muted-foreground/40 mx-auto size-4" />;
+  }
+  if (state === "same") {
+    return (
+      <SimpleTooltip title={i18next.t("agentConfig:Same version")}>
+        <Check className="text-success mx-auto size-4" />
+      </SimpleTooltip>
+    );
+  }
+
+  const title =
+    state === "newer"
+      ? i18next.t("agentConfig:Newer version here")
+      : state === "older"
+        ? i18next.t("agentConfig:Older version here")
+        : i18next.t("agentConfig:Different version here");
+  return (
+    <SimpleTooltip title={title}>
+      <AlertTriangle className="text-warning mx-auto size-4" />
+    </SimpleTooltip>
+  );
+}
+
 export default function AgentConfigsPage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
   const {inventories, loading, error, scanned, refresh} = useAgentConfigs();
@@ -104,7 +154,9 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   const [detail, setDetail] = React.useState<DetailTarget | null>(null);
   const [copyOpen, setCopyOpen] = React.useState(false);
   const [addOpen, setAddOpen] = React.useState(false);
+  const [trashOpen, setTrashOpen] = React.useState(false);
   const [deleting, setDeleting] = React.useState("");
+  const [updating, setUpdating] = React.useState("");
 
   // The scan replaces every inventory, so the chosen source is re-resolved by
   // key rather than held as an object that would go stale on every refresh.
@@ -131,8 +183,9 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
 
   const peers = source ? inventories.filter(inventory => 
inventoryKey(inventory) !== inventoryKey(source)) : [];
   const items = source ? itemsOf(source, kind) : [];
-  const presence = presenceOf(inventories, kind);
+  const copies = copiesOf(inventories, kind);
   const selectable = items.filter(item => !item.managed);
+  const agentNames = new Map(inventories.map(inventory => [inventory.agentId, 
inventory.name]));
   const allSelected = selectable.length > 0 && selected.length === 
selectable.length;
 
   const toggleAll = () => {
@@ -150,7 +203,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
     return AgentConfigBackend.deleteAgentConfigItem(item.agentId, item.owner, 
item.kind, item.name)
       .then(res => {
         if (res.status === "ok") {
-          Setting.showMessage("success", `${i18next.t("agentConfig:Deleted")}: 
${item.name}`);
+          Setting.showMessage("success", `${i18next.t("agentConfig:Moved to 
the recycle bin")}: ${item.name}`);
           refresh();
         } else {
           Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to delete"));
@@ -160,6 +213,21 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
       .then(() => setDeleting(""));
   };
 
+  const updateItem = (item: AgentConfigItem) => {
+    setUpdating(item.name);
+    return AgentConfigBackend.updateAgentConfigSkill(item.agentId, item.owner, 
item.name)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", `${i18next.t("agentConfig:Updated")}: 
${item.name}`);
+          refresh();
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to update"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setUpdating(""));
+  };
+
   const columns: Column<AgentConfigItem>[] = [
     {
       key: "selected",
@@ -190,21 +258,44 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
       dataIndex: "name",
       title: i18next.t("general:Name"),
       sorter: (a, b) => a.name.localeCompare(b.name),
-      render: (_value, record) => (
-        <div className="min-w-0">
-          <div className="flex items-center gap-1.5">
-            <span className="truncate font-medium">{record.name}</span>
-            {record.managed ? (
-              <SimpleTooltip title={i18next.t("agentConfig:Managed by Gateway 
detail")}>
-                <Badge variant="info">{i18next.t("agentConfig:Managed by 
Gateway")}</Badge>
-              </SimpleTooltip>
+      render: (_value, record) => {
+        const outdated = newerHolders(record, copies, peers);
+        const version = updateBadge(record);
+        return (
+          <div className="min-w-0">
+            <div className="flex flex-wrap items-center gap-1.5">
+              <span className="truncate 
font-medium">{sharedName(record.name)}</span>
+              {record.origin ? (
+                <SimpleTooltip title={originTitle(record)}>
+                  <Badge variant="muted">{record.origin}</Badge>
+                </SimpleTooltip>
+              ) : null}
+              {version ? (
+                <SimpleTooltip title={version.title}>
+                  <Badge variant={version.variant}>{version.label}</Badge>
+                </SimpleTooltip>
+              ) : null}
+              {outdated.length > 0 ? (
+                <SimpleTooltip
+                  title={i18next
+                    .t("agentConfig:Out of date detail")
+                    .replace("{agents}", outdated.join(", "))}
+                >
+                  <Badge variant="warning">{i18next.t("agentConfig:Out of 
date")}</Badge>
+                </SimpleTooltip>
+              ) : null}
+              {record.managed ? (
+                <SimpleTooltip title={i18next.t("agentConfig:Managed by 
Gateway detail")}>
+                  <Badge variant="info">{i18next.t("agentConfig:Managed by 
Gateway")}</Badge>
+                </SimpleTooltip>
+              ) : null}
+            </div>
+            {record.description ? (
+              <p className="text-muted-foreground line-clamp-2 
text-xs">{record.description}</p>
             ) : null}
           </div>
-          {record.description ? (
-            <p className="text-muted-foreground line-clamp-2 
text-xs">{record.description}</p>
-          ) : null}
-        </div>
-      ),
+        );
+      },
     },
     {
       key: "summary",
@@ -215,6 +306,12 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
           <span className="text-muted-foreground text-xs">
             {counted(record.files ?? 0, "agentConfig:1 file", 
"agentConfig:{files} files", "{files}")}
             {record.bytes ? ` · ${formatBytes(record.bytes)}` : ""}
+            {record.modified ? (
+              <>
+                <br />
+                {`${i18next.t("agentConfig:Changed")} 
${formatModified(record.modified)}`}
+              </>
+            ) : null}
           </span>
         ) : (
           <div className="flex min-w-0 items-center gap-1.5">
@@ -237,14 +334,13 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
           </span>
         </SimpleTooltip>
       ),
-      render: (_value, record) =>
-        !supports(peer, kind) ? (
-          <span className="text-muted-foreground/50 text-xs">—</span>
-        ) : presence.get(record.name)?.has(inventoryKey(peer)) ? (
-          <Check className="text-success mx-auto size-4" />
-        ) : (
-          <Minus className="text-muted-foreground/40 mx-auto size-4" />
-        ),
+      render: (_value, record) => {
+        if (!supports(peer, kind)) {
+          return <span className="text-muted-foreground/50 text-xs">—</span>;
+        }
+        const other = 
copies.get(sharedName(record.name))?.get(inventoryKey(peer));
+        return <PeerCell state={other ? compareVersions(record, other) : 
"missing"} />;
+      },
     })),
     {
       key: "actions",
@@ -253,6 +349,32 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
       title: i18next.t("general:Action"),
       render: (_value, record) => (
         <div className="flex justify-end gap-1">
+          {canUpdate(record) ? (
+            <ConfirmDialog
+              title={i18next.t("agentConfig:Update this skill?")}
+              description={
+                <span className="flex flex-col gap-1">
+                  <span>{i18next.t("agentConfig:Update description")}</span>
+                  <code className="text-foreground font-mono text-xs 
break-all">{record.update?.source}</code>
+                  <span>{i18next.t("agentConfig:Update undo hint")}</span>
+                </span>
+              }
+              onConfirm={() => updateItem(record)}
+              disabled={updating === record.name}
+            >
+              <SimpleTooltip title={i18next.t("agentConfig:Update from 
source")}>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  className="text-warning"
+                  aria-label={i18next.t("agentConfig:Update from source")}
+                  disabled={updating === record.name}
+                >
+                  <ArrowDownToLine className="size-4" />
+                </Button>
+              </SimpleTooltip>
+            </ConfirmDialog>
+          ) : null}
           <SimpleTooltip title={i18next.t("agentConfig:View")}>
             <Button
               variant="ghost"
@@ -271,20 +393,23 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
               <span className="flex flex-col gap-1">
                 <span>{i18next.t("agentConfig:Delete description")}</span>
                 <code className="text-foreground font-mono text-xs 
break-all">{record.path}</code>
+                <span>{i18next.t("agentConfig:Delete undo hint")}</span>
               </span>
             }
             onConfirm={() => deleteItem(record)}
-            disabled={record.managed || deleting === record.name}
+            disabled={record.managed || Boolean(record.readOnly) || deleting 
=== record.name}
           >
-            <Button
-              variant="ghost"
-              size="icon-sm"
-              className="text-destructive"
-              aria-label={i18next.t("general:Delete")}
-              disabled={record.managed || deleting === record.name}
-            >
-              <Trash2 className="size-4" />
-            </Button>
+            <SimpleTooltip title={record.readOnly || 
i18next.t("general:Delete")}>
+              <Button
+                variant="ghost"
+                size="icon-sm"
+                className="text-destructive"
+                aria-label={i18next.t("general:Delete")}
+                disabled={record.managed || Boolean(record.readOnly) || 
deleting === record.name}
+              >
+                <Trash2 className="size-4" />
+              </Button>
+            </SimpleTooltip>
           </ConfirmDialog>
         </div>
       ),
@@ -307,6 +432,12 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
                 {i18next.t("agentConfig:Add MCP server")}
               </Button>
             ) : null}
+            {inventories.length > 0 ? (
+              <Button variant="outline" onClick={() => setTrashOpen(true)}>
+                <Trash2 className="size-4" />
+                {i18next.t("agentConfig:Recycle bin")}
+              </Button>
+            ) : null}
             <Button variant="outline" onClick={() => refresh(true)} 
disabled={loading}>
               <RefreshCw className={cn("size-4", loading && "animate-spin")} />
               {i18next.t("general:Refresh")}
@@ -353,6 +484,21 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
               <span className="flex items-center gap-2 text-xs">
                 <span 
className="text-muted-foreground">{i18next.t("agentConfig:Read from")}</span>
                 <CodeText copyable>{location}</CodeText>
+                {kind === "skill" && (source.skillsDirs?.length ?? 0) > 1 ? (
+                  <SimpleTooltip
+                    title={
+                      <span className="flex flex-col gap-0.5">
+                        {source.skillsDirs?.map(dir => <span 
key={dir}>{dir}</span>)}
+                      </span>
+                    }
+                  >
+                    <Badge variant="muted">
+                      {i18next
+                        .t("agentConfig:and {count} more folders")
+                        .replace("{count}", String((source.skillsDirs?.length 
?? 1) - 1))}
+                    </Badge>
+                  </SimpleTooltip>
+                ) : null}
               </span>
             ) : null}
           </div>
@@ -413,6 +559,14 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         />
       ) : null}
 
+      <TrashDialog
+        open={trashOpen}
+        onOpenChange={setTrashOpen}
+        owner={source?.owner ?? ""}
+        agentNames={agentNames}
+        onRestored={refresh}
+      />
+
       <DetailDialog target={detail} onOpenChange={open => !open && 
setDetail(null)} />
     </PageContainer>
   );
diff --git a/web/src/types.ts b/web/src/types.ts
index 2996836..745c45a 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -344,8 +344,13 @@ export interface Application {
   name: string;
 }
 
+/** The settings the web UI can change, so nobody has to edit conf/app.conf. */
 export interface GatewayStatus {
   gatewayEnabled: boolean;
+  /** False while enabled means the ports refused to bind; "gatewayError" says 
why. */
+  gatewayRunning: boolean;
+  gatewayError: string;
+  llmRecordMode: "off" | "metadata" | "full";
 }
 
 /** The two kinds of configuration the Skills & MCP page manages. */
@@ -364,8 +369,44 @@ export interface AgentConfigItem {
   url?: string;
   files?: number;
   bytes?: number;
+  /**
+   * "user" for a skill the operator wrote, "plugin" for one a plugin ships,
+   * "project" for one that belongs to a checkout the agent works in.
+   */
+  scope?: string;
+  /** The plugin or project folder a skill came from. */
+  origin?: string;
+  /** The checkout a project skill belongs to. */
+  project?: string;
+  /** Identifies the content: two agents with different digests hold different 
versions. */
+  digest?: string;
+  /** The newest file behind the item, in seconds. */
+  modified?: number;
+  /** Where a skill came from, and whether that source still holds the same 
content. */
+  update?: AgentConfigSkillUpdate;
   /** Written by Gateway's own agent monitoring, and not the operator's to 
move. */
   managed?: boolean;
+  /** Why Gateway will not delete this item. Empty when it may. */
+  readOnly?: string;
+}
+
+/**
+ * How one skill stands against the copy it came from: the same content, an
+ * update waiting in the source, edits made here, or both at once.
+ */
+export type AgentConfigUpdateState = "current" | "available" | "modified" | 
"diverged" | "unknown";
+
+export interface AgentConfigSkillUpdate {
+  state: AgentConfigUpdateState;
+  /** The folder this skill was copied from, and the agent that folder belongs 
to. */
+  source?: string;
+  sourceAgentId?: string;
+  sourceName?: string;
+  /** True when Gateway matched the source by name instead of recording the 
copy. */
+  inferred?: boolean;
+  sourceDigest?: string;
+  sourceModified?: number;
+  copiedAt?: number;
 }
 
 /** One installation's skills and MCP servers, as they exist in its own files. 
*/
@@ -380,7 +421,9 @@ export interface AgentConfigInventory {
   installed: boolean;
   /** Other agents reading the same files, e.g. Cursor and its CLI. */
   sharedWith?: string[];
+  /** Where a copied skill is written; skillsDirs is everything the listing 
was read from. */
   skillsDir?: string;
+  skillsDirs?: string[];
   mcpFile?: string;
   skillsSupported: boolean;
   mcpSupported: boolean;
@@ -397,6 +440,21 @@ export interface AgentConfigDetail {
   files?: string[];
 }
 
+/** One deleted item, waiting in Gateway's trash until it is restored or 
expires. */
+export interface AgentConfigTrashEntry {
+  id: string;
+  agentId: string;
+  owner: string;
+  kind: AgentConfigKind;
+  name: string;
+  description?: string;
+  /** Where it was, and where restoring puts it back. */
+  path: string;
+  files?: number;
+  bytes?: number;
+  deletedAt: number;
+}
+
 /** How an MCP server is reached: a spawned command, or an HTTP endpoint. */
 export type McpTransport = "stdio" | "http";
 


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

Reply via email to