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 b9da217  feat(agent): run multiple instances, each signed in to its 
own account
b9da217 is described below

commit b9da21787b8497cafc4d0353c58c5d49b2c26342
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 16:48:57 2026 +0800

    feat(agent): run multiple instances, each signed in to its own account
---
 agent/account.go                       |  52 +++--
 agent/fingerprint.go                   |   9 +
 agent/fingerprints/claude-code.json    |   1 +
 agent/fingerprints/claude-desktop.json |   1 +
 agent/fingerprints/codex-cli.json      |   1 +
 agent/fingerprints/cursor.json         |   1 +
 agent/fingerprints/windsurf.json       |   1 +
 agent/instance.go                      | 182 +++++++++++++++++
 agentprocess/list_darwin.go            |  38 +++-
 agentprocess/list_linux.go             |  29 ++-
 agentprocess/list_windows.go           |   9 +-
 agentprocess/process.go                |  65 +++++-
 controllers/agent.go                   |  15 +-
 controllers/agent_instance.go          | 358 +++++++++++++++++++++++++++++++++
 controllers/agent_process.go           |   1 +
 object/agent_instance.go               | 134 ++++++++++++
 object/ormer.go                        |   7 +
 routers/router.go                      |   6 +
 web/src/backend/AgentBackend.ts        |  37 ++++
 web/src/components/AgentGridCard.tsx   |   7 +
 web/src/components/AgentInstances.tsx  | 314 +++++++++++++++++++++++++++++
 web/src/lib/agents.ts                  | 140 +++++++++++++
 web/src/locales/en/data.json           |  12 ++
 web/src/locales/zh/data.json           |  12 ++
 web/src/pages/AgentDetailPage.tsx      |   3 +
 web/src/pages/HomePage.tsx             |   6 +
 web/src/types.ts                       |  27 +++
 27 files changed, 1424 insertions(+), 44 deletions(-)

diff --git a/agent/account.go b/agent/account.go
index 7ceb7a6..a977c2c 100644
--- a/agent/account.go
+++ b/agent/account.go
@@ -56,10 +56,14 @@ func fillAccounts(installations []Installation, homes 
[]homeDir) {
                if home == "" {
                        continue
                }
-               key := kind + "\x00" + home
+               dir := accountStateDir(kind, home)
+               if dir == "" {
+                       continue
+               }
+               key := kind + "\x00" + dir
                account, ok := cache[key]
                if !ok {
-                       account = readAccount(kind, home)
+                       account = readAccountIn(kind, dir)
                        cache[key] = account
                }
                installations[i].Account = account
@@ -81,14 +85,36 @@ func accountKind(agentId string) string {
        }
 }
 
-func readAccount(kind, home string) *Account {
+// AccountOfInstance reads the account one extra copy of an agent is signed in
+// to. An instance keeps the state its agent keeps under a home directory, only
+// somewhere else, so the readers are given that directory directly.
+func AccountOfInstance(agentId string, dataDir string) *Account {
+       return readAccountIn(accountKind(agentId), dataDir)
+}
+
+// accountStateDir is where one agent keeps the state its account is read from,
+// under a home directory. An instance is given a private copy of it.
+func accountStateDir(kind, home string) string {
        switch kind {
        case "codex":
-               return readCodexAccount(home)
+               return filepath.Join(home, ".codex")
        case "claude-code":
-               return readClaudeCodeAccount(home)
+               return home
        case "claude-desktop":
-               return readClaudeDesktopAccount(home)
+               return claudeDesktopDataDir(home)
+       default:
+               return ""
+       }
+}
+
+func readAccountIn(kind, dir string) *Account {
+       switch kind {
+       case "codex":
+               return readCodexAccount(dir)
+       case "claude-code":
+               return readClaudeCodeAccount(dir)
+       case "claude-desktop":
+               return readClaudeDesktopAccount(dir)
        default:
                return nil
        }
@@ -96,8 +122,8 @@ func readAccount(kind, home string) *Account {
 
 // readCodexAccount decodes the profile carried in the JWT that Codex and the
 // ChatGPT desktop app store after a ChatGPT sign-in.
-func readCodexAccount(home string) *Account {
-       data := readCapped(filepath.Join(home, ".codex", "auth.json"))
+func readCodexAccount(dir string) *Account {
+       data := readCapped(filepath.Join(dir, "auth.json"))
        if data == nil {
                return nil
        }
@@ -148,8 +174,8 @@ func fillString(target *string, value any) {
 
 // readClaudeCodeAccount reads the oauth account Claude Code records in its
 // top-level config file.
-func readClaudeCodeAccount(home string) *Account {
-       data := readCapped(filepath.Join(home, ".claude.json"))
+func readClaudeCodeAccount(dir string) *Account {
+       data := readCapped(filepath.Join(dir, ".claude.json"))
        if data == nil {
                return nil
        }
@@ -180,11 +206,7 @@ func readClaudeCodeAccount(home string) *Account {
 // readClaudeDesktopAccount pulls the profile the desktop app leaves in its
 // IndexedDB store. The store is a Chromium format with no stable reader while
 // the app holds it open, so the fields are lifted out by their markers.
-func readClaudeDesktopAccount(home string) *Account {
-       dir := claudeDesktopDataDir(home)
-       if dir == "" {
-               return nil
-       }
+func readClaudeDesktopAccount(dir string) *Account {
        account := &Account{}
        scanClaudeIndexedDB(filepath.Join(dir, "IndexedDB"), account)
        if account.Email == "" && account.Name == "" {
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 31e71cd..74854b8 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -34,6 +34,15 @@ type Fingerprint struct {
        // where 127.0.0.1 is the sandbox rather than this host.
        Sandboxed bool `json:"sandboxed,omitempty"`
 
+       // InstanceArg is the flag a private state directory is passed on the 
command
+       // line with, for an app that takes one - Chromium's --user-data-dir.
+       // InstanceEnv is the variable that names it for an agent that reads its
+       // state directory from the environment. Either one lets Gateway run a 
second
+       // copy of the agent signed in to a different account; neither means it
+       // cannot.
+       InstanceArg string `json:"instanceArg,omitempty"`
+       InstanceEnv string `json:"instanceEnv,omitempty"`
+
        StateDir            string              `json:"stateDir,omitempty"`
        NpmPackage          string              `json:"npmPackage,omitempty"`
        ExtraUnixNpmDirs    []string            
`json:"extraUnixNpmDirs,omitempty"`
diff --git a/agent/fingerprints/claude-code.json 
b/agent/fingerprints/claude-code.json
index ccb192c..f73bf89 100644
--- a/agent/fingerprints/claude-code.json
+++ b/agent/fingerprints/claude-code.json
@@ -3,6 +3,7 @@
   "displayName": "Claude Code",
   "installUrl": "https://docs.claude.com/en/docs/claude-code/setup";,
   "execName": "claude",
+  "instanceEnv": "CLAUDE_CONFIG_DIR",
   "stateDir": "claude",
   "npmPackage": "@anthropic-ai/claude-code",
   "wingetPackage": "Anthropic.ClaudeCode",
diff --git a/agent/fingerprints/claude-desktop.json 
b/agent/fingerprints/claude-desktop.json
index 327fc2b..2d69483 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -5,6 +5,7 @@
   "execName": "claude",
   "desktop": true,
   "sandboxed": true,
+  "instanceArg": "--user-data-dir",
   "msixFamily": "Claude_pzs8sxrjxfjjc",
   "desktopInstallerDir": "AnthropicClaude"
 }
diff --git a/agent/fingerprints/codex-cli.json 
b/agent/fingerprints/codex-cli.json
index 78c60fd..09f5616 100644
--- a/agent/fingerprints/codex-cli.json
+++ b/agent/fingerprints/codex-cli.json
@@ -3,6 +3,7 @@
   "displayName": "Codex CLI",
   "installUrl": "https://developers.openai.com/codex/cli/";,
   "execName": "codex",
+  "instanceEnv": "CODEX_HOME",
   "stateDir": "codex",
   "npmPackage": "@openai/codex",
   "homebrewCasks": ["codex", "codex@latest"],
diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json
index 792c115..fa674ff 100644
--- a/agent/fingerprints/cursor.json
+++ b/agent/fingerprints/cursor.json
@@ -4,6 +4,7 @@
   "installUrl": "https://cursor.com/download";,
   "execName": "Cursor",
   "desktop": true,
+  "instanceArg": "--user-data-dir",
   "windowsProgramDirs": ["cursor"],
   "homebrewCasks": ["cursor"]
 }
diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json
index b4f8313..d438466 100644
--- a/agent/fingerprints/windsurf.json
+++ b/agent/fingerprints/windsurf.json
@@ -4,6 +4,7 @@
   "installUrl": "https://windsurf.com/download";,
   "execName": "Windsurf",
   "desktop": true,
+  "instanceArg": "--user-data-dir",
   "windowsProgramDirs": ["Windsurf"],
   "homebrewCasks": ["windsurf"]
 }
diff --git a/agent/instance.go b/agent/instance.go
new file mode 100644
index 0000000..1d3711a
--- /dev/null
+++ b/agent/instance.go
@@ -0,0 +1,182 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "errors"
+       "fmt"
+       "os"
+       "path/filepath"
+       "runtime"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// instancesDir is the directory, under a home, holding the private state of
+// every extra copy of an agent. One place for all of them is what lets an
+// installation's own status tell its processes from theirs.
+var instancesDir = filepath.Join(".casbin-gateway", "agent-instances")
+
+// isolation is how one agent is given a state directory of its own.
+type isolation struct {
+       arg string
+       env string
+}
+
+// isolationOf reports how a second copy of this agent is kept apart from the
+// first. The zero value means Gateway knows no way, so the agent runs once.
+func isolationOf(agentId string) isolation {
+       for i := range fingerprints {
+               if fingerprints[i].ID == agentId {
+                       return isolation{arg: fingerprints[i].InstanceArg, env: 
fingerprints[i].InstanceEnv}
+               }
+       }
+       return isolation{}
+}
+
+func (i isolation) supported() bool {
+       return i.arg != "" || i.env != ""
+}
+
+// SupportsInstances reports whether Gateway can run more than one copy of this
+// agent at a time, each signed in to its own account.
+func SupportsInstances(agentId string) bool {
+       return isolationOf(agentId).supported()
+}
+
+// InstancesRoot is where every instance of every agent keeps its state: the
+// home of the account Gateway runs as, which is also the account an instance 
is
+// started under. An installation owned by another account - a machine-wide one
+// is owned by the system - is still run by this one.
+func InstancesRoot() (string, error) {
+       return agenthome.Resolve("")
+}
+
+// InstanceDir is the state directory of one named instance. The name is what
+// the caller chose, so it is checked here rather than trusted into a path.
+func InstanceDir(agentId string, name string) (string, error) {
+       if err := CheckInstanceName(name); err != nil {
+               return "", err
+       }
+       home, err := InstancesRoot()
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, instancesDir, agentId, name), nil
+}
+
+// CheckInstanceName keeps an instance name to what is safe as one path segment
+// and readable as a label.
+func CheckInstanceName(name string) error {
+       if name == "" {
+               return errors.New("the instance name is empty")
+       }
+       if len(name) > 64 {
+               return errors.New("the instance name is longer than 64 
characters")
+       }
+       for _, r := range name {
+               switch {
+               case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r 
<= '9':
+               case r == '-', r == '_':
+               default:
+                       return errors.New("an instance name may hold only 
letters, digits, - and _")
+               }
+       }
+       return nil
+}
+
+// InstanceLaunchOf resolves what to run for one instance of an installation,
+// without touching the disk. An agent that takes its state directory on the
+// command line is simply given the flag; one that reads it from the 
environment
+// is started through a launcher script inside that directory, because the
+// console a CLI opens in would not otherwise carry the variable - and nothing
+// on the command line would then say which instance a process belongs to.
+func InstanceLaunchOf(installation Installation, dataDir string) (Launch, 
error) {
+       launch := LaunchOf(installation)
+       if launch.Executable == "" {
+               return Launch{}, errors.New("no launcher was found for this 
installation")
+       }
+
+       rules := isolationOf(installation.AgentId)
+       switch {
+       case rules.arg != "":
+               launch.Args = append(append([]string{}, launch.Args...), 
rules.arg+"="+dataDir)
+               return launch, nil
+       case rules.env != "":
+               launch.Executable, launch.Args = instanceScriptPath(dataDir), 
nil
+               return launch, nil
+       }
+       return Launch{}, fmt.Errorf("gateway cannot run a second copy of %s", 
installation.AgentId)
+}
+
+// PrepareInstance creates the state directory of one instance and, for an 
agent
+// isolated by an environment variable, the launcher script that sets it. The
+// script is rewritten on every start, so an upgraded agent is never launched
+// through a stale path.
+//
+// An installation with no launcher - one found by the state directory it left
+// behind - still gets its directory: what cannot be started is reported by the
+// start, not by the instance being registered.
+func PrepareInstance(installation Installation, dataDir string) error {
+       if err := os.MkdirAll(dataDir, 0o700); err != nil {
+               return err
+       }
+       rules := isolationOf(installation.AgentId)
+       launch := LaunchOf(installation)
+       if rules.env == "" || launch.Executable == "" {
+               return nil
+       }
+       return writeInstanceScript(dataDir, rules.env, launch)
+}
+
+func instanceScriptPath(dataDir string) string {
+       if runtime.GOOS == "windows" {
+               return filepath.Join(dataDir, "launch.cmd")
+       }
+       return filepath.Join(dataDir, "launch.sh")
+}
+
+// writeInstanceScript writes the launcher of one instance beside its state.
+func writeInstanceScript(dataDir string, variable string, launch Launch) error 
{
+       lines := []string{}
+       if runtime.GOOS == "windows" {
+               lines = append(lines, "@echo off", `set 
"`+variable+"="+dataDir+`"`, windowsCommand(launch), "")
+               return os.WriteFile(instanceScriptPath(dataDir), 
[]byte(strings.Join(lines, "\r\n")), 0o700)
+       }
+
+       lines = append(lines, "#!/bin/sh", "export 
"+variable+"="+shellQuote(dataDir), "exec "+shellCommand(launch), "")
+       return os.WriteFile(instanceScriptPath(dataDir), 
[]byte(strings.Join(lines, "\n")), 0o700)
+}
+
+func windowsCommand(launch Launch) string {
+       command := `"` + launch.Executable + `"`
+       for _, argument := range launch.Args {
+               command += ` "` + argument + `"`
+       }
+       return command
+}
+
+func shellCommand(launch Launch) string {
+       command := shellQuote(launch.Executable)
+       for _, argument := range launch.Args {
+               command += " " + shellQuote(argument)
+       }
+       return command
+}
+
+func shellQuote(value string) string {
+       return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
+}
diff --git a/agentprocess/list_darwin.go b/agentprocess/list_darwin.go
index c0fe273..610063c 100644
--- a/agentprocess/list_darwin.go
+++ b/agentprocess/list_darwin.go
@@ -33,13 +33,13 @@ func list(ctx context.Context, withCommands bool) []Process 
{
                return nil
        }
 
-       commands := map[int]string{}
+       commands, parents := map[int]string{}, map[int]int{}
        if withCommands {
-               commands = psCommands(ctx)
+               commands, parents = psCommands(ctx)
        }
        result := make([]Process, 0, len(paths))
        for pid, path := range paths {
-               result = append(result, Process{Pid: pid, Path: path, Command: 
commands[pid]})
+               result = append(result, Process{Pid: pid, Parent: parents[pid], 
Path: path, Command: commands[pid]})
        }
        return result
 }
@@ -61,18 +61,34 @@ func psImages(ctx context.Context) map[int]string {
        return paths
 }
 
-func psCommands(ctx context.Context) map[int]string {
-       commands := map[int]string{}
-       for _, line := range psLines(ctx, "pid=,command=") {
-               fields := strings.SplitN(strings.TrimSpace(line), " ", 2)
-               if len(fields) < 2 {
+func psCommands(ctx context.Context) (map[int]string, map[int]int) {
+       commands, parents := map[int]string{}, map[int]int{}
+       for _, line := range psLines(ctx, "pid=,ppid=,command=") {
+               // ps pads each number into a column of its own, so the two are 
cut off
+               // rather than split on a single space.
+               pidText, rest := cutField(line)
+               parentText, command := cutField(rest)
+               pid, err := strconv.Atoi(pidText)
+               if err != nil || pid <= 0 || command == "" {
                        continue
                }
-               if pid, err := strconv.Atoi(fields[0]); err == nil && pid > 0 {
-                       commands[pid] = fields[1]
+               commands[pid] = command
+               if parent, err := strconv.Atoi(parentText); err == nil {
+                       parents[pid] = parent
                }
        }
-       return commands
+       return commands, parents
+}
+
+// cutField takes the first whitespace-separated token off a line and returns 
it
+// with whatever follows the spaces after it.
+func cutField(line string) (string, string) {
+       line = strings.TrimLeft(line, " \t")
+       end := strings.IndexAny(line, " \t")
+       if end < 0 {
+               return line, ""
+       }
+       return line[:end], strings.TrimLeft(line[end:], " \t")
 }
 
 func psLines(ctx context.Context, format string) []string {
diff --git a/agentprocess/list_linux.go b/agentprocess/list_linux.go
index 4108fa5..6e8cddd 100644
--- a/agentprocess/list_linux.go
+++ b/agentprocess/list_linux.go
@@ -43,18 +43,41 @@ func list(ctx context.Context, withCommands bool) []Process 
{
                        continue
                }
                path, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
-               command := ""
+               command, parent := "", 0
                if withCommands {
-                       command = processCommand(pid)
+                       command, parent = processCommand(pid), 
processParent(pid)
                }
                if path == "" && command == "" {
                        continue
                }
-               result = append(result, Process{Pid: pid, Path: path, Command: 
command})
+               result = append(result, Process{Pid: pid, Parent: parent, Path: 
path, Command: command})
        }
        return result
 }
 
+// processParent reads the ppid procfs keeps as the fourth field of stat. The
+// executable name before it may hold spaces and brackets, so the fields are 
read
+// from after the closing one.
+func processParent(pid int) int {
+       data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
+       if err != nil {
+               return 0
+       }
+       end := strings.LastIndexByte(string(data), ')')
+       if end < 0 {
+               return 0
+       }
+       fields := strings.Fields(string(data)[end+1:])
+       if len(fields) < 2 {
+               return 0
+       }
+       parent, err := strconv.Atoi(fields[1])
+       if err != nil {
+               return 0
+       }
+       return parent
+}
+
 // processCommand joins the NUL-separated arguments procfs stores.
 func processCommand(pid int) string {
        file, err := os.Open(fmt.Sprintf("/proc/%d/cmdline", pid))
diff --git a/agentprocess/list_windows.go b/agentprocess/list_windows.go
index a8d6976..a0a1265 100644
--- a/agentprocess/list_windows.go
+++ b/agentprocess/list_windows.go
@@ -30,7 +30,7 @@ import (
 // commandScript reads what the process was started with, which is the only way
 // an agent running under an interpreter names itself. WMI answers this in 
about
 // a second, so it is asked for only when an image path cannot settle it.
-const commandScript = `Get-CimInstance Win32_Process | ForEach-Object { 
[PSCustomObject]@{ pid = $_.ProcessId; path = $_.ExecutablePath; command = 
$_.CommandLine } } | ConvertTo-Json -Compress`
+const commandScript = `Get-CimInstance Win32_Process | ForEach-Object { 
[PSCustomObject]@{ pid = $_.ProcessId; parent = $_.ParentProcessId; path = 
$_.ExecutablePath; command = $_.CommandLine } } | ConvertTo-Json -Compress`
 
 func list(ctx context.Context, withCommands bool) []Process {
        if withCommands {
@@ -60,7 +60,9 @@ func listImages() []Process {
        for {
                if entry.ProcessID > 0 {
                        if path := imagePath(entry.ProcessID); path != "" {
-                               result = append(result, Process{Pid: 
int(entry.ProcessID), Path: path})
+                               result = append(result, Process{
+                                       Pid: int(entry.ProcessID), Parent: 
int(entry.ParentProcessID), Path: path,
+                               })
                        }
                }
                if windows.Process32Next(snapshot, &entry) != nil {
@@ -101,6 +103,7 @@ func listWithCommands(ctx context.Context) []Process {
        }
        var rows []struct {
                Pid     int    `json:"pid"`
+               Parent  int    `json:"parent"`
                Path    string `json:"path"`
                Command string `json:"command"`
        }
@@ -113,7 +116,7 @@ func listWithCommands(ctx context.Context) []Process {
                if row.Pid <= 0 {
                        continue
                }
-               result = append(result, Process{Pid: row.Pid, Path: row.Path, 
Command: row.Command})
+               result = append(result, Process{Pid: row.Pid, Parent: 
row.Parent, Path: row.Path, Command: row.Command})
        }
        return result
 }
diff --git a/agentprocess/process.go b/agentprocess/process.go
index 91ea537..d7083cc 100644
--- a/agentprocess/process.go
+++ b/agentprocess/process.go
@@ -31,6 +31,9 @@ import (
 const (
        listCacheTTL = 3 * time.Second
        listTimeout  = 10 * time.Second
+       // maxAncestorDepth caps the walk up a process tree looking for the 
launcher
+       // an instance was started through.
+       maxAncestorDepth = 8
 )
 
 // Target identifies one installation and says how it is run.
@@ -44,6 +47,12 @@ type Target struct {
        Args []string
        // Desktop marks a windowed app, which is started without a console.
        Desktop bool
+       // Marker is the state directory one instance was started with, which 
its
+       // processes name on their command lines. Empty for the installation 
itself.
+       Marker string
+       // Exclude are the markers of the installation's instances, so its own 
status
+       // does not count the processes belonging to them.
+       Exclude []string
 }
 
 // Status is the run state shown beside an agent installation.
@@ -58,7 +67,10 @@ type Status struct {
 // on purpose: an installation found under one account is routinely started by
 // another, so the owner would rule out processes that do belong to it.
 type Process struct {
-       Pid     int
+       Pid int
+       // Parent is the process that started this one, 0 when it is not known. 
It is
+       // only read along with the command lines, which is when it is of any 
use.
+       Parent  int
        Path    string
        Command string
 }
@@ -86,8 +98,11 @@ func statusOf(target Target, withCommands bool) Status {
        if !status.CanStart {
                status.Detail = "no launcher was found for this installation"
        }
-       for _, process := range snapshot(withCommands) {
-               if matches(target, process) {
+
+       processes := snapshot(withCommands)
+       byPid := indexByPid(processes, target)
+       for _, process := range processes {
+               if matches(target, process, byPid) {
                        status.Pids = append(status.Pids, process.Pid)
                }
        }
@@ -95,6 +110,19 @@ func statusOf(target Target, withCommands bool) Status {
        return status
 }
 
+// indexByPid is only built for a target that reads instance markers: it is 
what
+// lets a process be recognised by the launcher that started it.
+func indexByPid(processes []Process, target Target) map[int]Process {
+       if target.Marker == "" && len(target.Exclude) == 0 {
+               return nil
+       }
+       index := make(map[int]Process, len(processes))
+       for _, process := range processes {
+               index[process.Pid] = process
+       }
+       return index
+}
+
 // Start launches the agent: a desktop app opens on its own, a CLI opens in a
 // console window, since that is the only way it is usable.
 func Start(target Target) error {
@@ -136,6 +164,9 @@ func Stop(target Target) error {
 // command line: a package manager records the package directory rather than a
 // program, and what runs is the interpreter.
 func needsCommands(target Target) bool {
+       if target.Marker != "" || len(target.Exclude) > 0 {
+               return true
+       }
        return !samePath(target.Executable, target.Path)
 }
 
@@ -170,10 +201,18 @@ func invalidate() {
 // an agent installed by a package manager runs through an interpreter, so the
 // process image is node rather than the agent, and only the command line names
 // the installation.
-func matches(target Target, process Process) bool {
+func matches(target Target, process Process, byPid map[int]Process) bool {
        if process.Pid == os.Getpid() {
                return false
        }
+       if target.Marker != "" && !carriesMarker(process, target.Marker, byPid) 
{
+               return false
+       }
+       for _, marker := range target.Exclude {
+               if carriesMarker(process, marker, byPid) {
+                       return false
+               }
+       }
        for _, candidate := range []string{target.Executable, target.Path} {
                if candidate == "" {
                        continue
@@ -188,6 +227,24 @@ func matches(target Target, process Process) bool {
        return false
 }
 
+// carriesMarker reports whether a process, or the process that started it, 
names
+// one instance's state directory on its command line. A CLI is started 
through a
+// launcher script that names the directory and the agent itself is its child,
+// so only the ancestry tells one instance from another.
+func carriesMarker(process Process, marker string, byPid map[int]Process) bool 
{
+       for depth := 0; depth < maxAncestorDepth; depth++ {
+               if containsPath(process.Command, marker) {
+                       return true
+               }
+               parent, ok := byPid[process.Parent]
+               if !ok || parent.Pid == process.Pid {
+                       return false
+               }
+               process = parent
+       }
+       return false
+}
+
 // isRelocatedCopy recognises the copy a self-updating desktop app runs: the
 // installed file stays a stub beside one directory per version, and what 
starts
 // is the same program inside the current one.
diff --git a/controllers/agent.go b/controllers/agent.go
index 0b7f42d..cfd9e9c 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -53,6 +53,9 @@ type discoveredAgent struct {
        // Upgrade is the command that would update this installation in place,
        // through the package manager that installed it.
        Upgrade agentinstall.Plan `json:"upgrade"`
+       // SupportsInstances says whether this agent can be run more than once 
at a
+       // time, each copy signed in to an account of its own.
+       SupportsInstances bool `json:"supportsInstances"`
 }
 
 // GetAgents scans known installation locations and returns the AI agents
@@ -89,6 +92,8 @@ func (c *ApiController) GetAgents() {
                        Mode:           object.ModeGateway,
                        ProviderConfig: 
agentprovider.StatusOf(providerTarget(target)),
                        Upgrade:        
agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod),
+
+                       SupportsInstances: 
agent.SupportsInstances(installation.AgentId),
                }
                baseUrl, ok := baseUrls[installation.AgentId]
                if !ok {
@@ -422,18 +427,12 @@ func (c *ApiController) readAgentInstallation() 
(agent.Installation, bool) {
                return agent.Installation{}, false
        }
 
-       installations, err := agent.Scan(false)
+       installation, err := findInstallation(requested.AgentId, 
requested.Path, requested.Owner)
        if err != nil {
                c.ResponseError(err.Error())
                return agent.Installation{}, false
        }
-       for _, installation := range installations {
-               if matchesTarget(targetOf(installation), requested) {
-                       return installation, true
-               }
-       }
-       c.ResponseError("no discovered agent installation matches this target")
-       return agent.Installation{}, false
+       return installation, true
 }
 
 func (c *ApiController) readAgentPatchTarget() (agentpatch.Target, bool) {
diff --git a/controllers/agent_instance.go b/controllers/agent_instance.go
new file mode 100644
index 0000000..7270970
--- /dev/null
+++ b/controllers/agent_instance.go
@@ -0,0 +1,358 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "strconv"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentpatch"
+       "github.com/apache/casbin-gateway/agentprocess"
+       "github.com/apache/casbin-gateway/object"
+)
+
+// maxInstances caps the names Gateway will hand out on its own, so a page that
+// keeps asking cannot fill the disk with empty profiles.
+const maxInstances = 50
+
+// agentInstanceView is one instance as the pages show it: what was stored, the
+// account its own state is signed in to, and whether it is running right now.
+type agentInstanceView struct {
+       *object.AgentInstance
+       Account *agent.Account `json:"account,omitempty"`
+       // Desktop tells the UI what a start would open: the app itself, or a 
console
+       // window for a CLI.
+       Desktop bool `json:"desktop"`
+       agentprocess.Status
+}
+
+// GetAgentInstances lists the extra copies of one agent, or of every agent 
when
+// no agent is named. Each is signed in on its own, so each reports its own
+// account and its own run state.
+func (c *ApiController) GetAgentInstances() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instances, err := object.GetAgentInstances(c.Input().Get("agent"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if c.GetString("refresh") == "true" {
+               agentprocess.Refresh()
+       }
+
+       result := make([]*agentInstanceView, 0, len(instances))
+       for _, instance := range instances {
+               result = append(result, instanceView(instance))
+       }
+       c.ResponseOk(result)
+}
+
+// AddAgentInstance registers one more copy of an installed agent and lays out
+// the state directory that keeps it apart from the others. Nothing is signed 
in
+// yet: the instance is started, and the sign-in happens in the agent itself.
+//
+// The name is optional, and the pages leave it out: adding a copy is one 
click,
+// and what to call it is answered later, once there is an account in it.
+func (c *ApiController) AddAgentInstance() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId     string `json:"agentId"`
+               Path        string `json:"path"`
+               Owner       string `json:"owner"`
+               Instance    string `json:"instance"`
+               DisplayName string `json:"displayName"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       installation, err := findInstallation(form.AgentId, form.Path, 
form.Owner)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if !agent.SupportsInstances(installation.AgentId) {
+               c.ResponseError(fmt.Sprintf("gateway cannot run a second copy 
of %s", installation.AgentId))
+               return
+       }
+
+       if form.Instance == "" {
+               if form.Instance, err = nextInstanceName(installation.AgentId); 
err != nil {
+                       c.ResponseError(err.Error())
+                       return
+               }
+       }
+       if err := agent.CheckInstanceName(form.Instance); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       dataDir, err := agent.InstanceDir(installation.AgentId, form.Instance)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       instance := &object.AgentInstance{
+               AgentId:     installation.AgentId,
+               Instance:    form.Instance,
+               DisplayName: form.DisplayName,
+               DataDir:     dataDir,
+               Path:        installation.Path,
+               HostUser:    installation.Owner,
+       }
+       if err := object.AddAgentInstance(instance); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       // The row is what reserves the name, so it goes in first; an instance 
whose
+       // state could not be laid out is no instance at all, and is taken back.
+       if err := agent.PrepareInstance(installation, dataDir); err != nil {
+               _ = object.DeleteAgentInstance(instance.Name)
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(instanceView(instance))
+}
+
+// UpdateAgentInstance renames one instance in the lists. Only the label is
+// editable: the name the state directory was laid out under stays put.
+func (c *ApiController) UpdateAgentInstance() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instance, ok := c.readAgentInstance()
+       if !ok {
+               return
+       }
+       var form struct {
+               DisplayName string `json:"displayName"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := object.SetAgentInstanceDisplayName(instance.Name, 
form.DisplayName); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       instance.DisplayName = form.DisplayName
+       c.ResponseOk(instanceView(instance))
+}
+
+// nextInstanceName is what a copy is called when the caller names none. The
+// installation itself is the first copy, so the numbering starts at the 
second.
+func nextInstanceName(agentId string) (string, error) {
+       instances, err := object.GetAgentInstances(agentId)
+       if err != nil {
+               return "", err
+       }
+
+       taken := make(map[string]bool, len(instances))
+       for _, instance := range instances {
+               taken[instance.Instance] = true
+       }
+       for number := 2; number <= maxInstances; number++ {
+               if name := strconv.Itoa(number); !taken[name] {
+                       return name, nil
+               }
+       }
+       return "", errors.New("this agent already has as many instances as 
Gateway will name")
+}
+
+// DeleteAgentInstance forgets one instance. Its state directory stays on disk:
+// it holds a signed-in account and that account's work, which is the 
operator's
+// to remove.
+func (c *ApiController) DeleteAgentInstance() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instance, ok := c.readAgentInstance()
+       if !ok {
+               return
+       }
+       if err := object.DeleteAgentInstance(instance.Name); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(instance.DataDir)
+}
+
+// StartAgentInstance runs one instance against its own state directory, beside
+// whichever other instances are already running.
+func (c *ApiController) StartAgentInstance() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instance, ok := c.readAgentInstance()
+       if !ok {
+               return
+       }
+       installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := agent.PrepareInstance(installation, instance.DataDir); err != 
nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       target, err := instanceTarget(installation, instance)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := agentprocess.Start(target); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(instanceView(instance))
+}
+
+// StopAgentInstance ends the processes of one instance, leaving the others as
+// they are.
+func (c *ApiController) StopAgentInstance() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instance, ok := c.readAgentInstance()
+       if !ok {
+               return
+       }
+       installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       target, err := instanceTarget(installation, instance)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := agentprocess.Stop(target); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(instanceView(instance))
+}
+
+// readAgentInstance resolves the request body against the stored instances, so
+// that a request names one Gateway created rather than a directory of its own.
+func (c *ApiController) readAgentInstance() (*object.AgentInstance, bool) {
+       var form struct {
+               Name string `json:"name"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return nil, false
+       }
+
+       instance, err := object.GetAgentInstance(form.Name)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return nil, false
+       }
+       if instance == nil {
+               c.ResponseError("no agent instance is stored under this name")
+               return nil, false
+       }
+       return instance, true
+}
+
+func instanceView(instance *object.AgentInstance) *agentInstanceView {
+       view := &agentInstanceView{
+               AgentInstance: instance,
+               Account:       agent.AccountOfInstance(instance.AgentId, 
instance.DataDir),
+               Status:        agentprocess.Status{Pids: []int{}},
+       }
+
+       installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
+       if err == nil {
+               var target agentprocess.Target
+               if target, err = instanceTarget(installation, instance); err == 
nil {
+                       view.Desktop = target.Desktop
+                       view.Status = agentprocess.StatusOf(target)
+                       return view
+               }
+       }
+       view.Status.Detail = err.Error()
+       return view
+}
+
+func instanceTarget(installation agent.Installation, instance 
*object.AgentInstance) (agentprocess.Target, error) {
+       launch, err := agent.InstanceLaunchOf(installation, instance.DataDir)
+       if err != nil {
+               return agentprocess.Target{}, err
+       }
+       return agentprocess.Target{
+               AgentId:    installation.AgentId,
+               Path:       installation.Path,
+               Owner:      installation.Owner,
+               Executable: launch.Executable,
+               Args:       launch.Args,
+               Desktop:    launch.Desktop,
+               Marker:     instance.DataDir,
+       }, nil
+}
+
+// findInstallation resolves an agent id, path and owner against the
+// installations a scan actually found. Starting one runs a program, so an
+// unverified path would let a caller name any file on the host.
+func findInstallation(agentId string, path string, owner string) 
(agent.Installation, error) {
+       installations, err := agent.Scan(false)
+       if err != nil {
+               return agent.Installation{}, err
+       }
+       requested := agentpatch.Target{AgentId: agentId, Path: path, Owner: 
owner}
+       for _, installation := range installations {
+               if matchesTarget(targetOf(installation), requested) {
+                       return installation, nil
+               }
+       }
+       return agent.Installation{}, errors.New("no discovered agent 
installation matches this target")
+}
+
+// instanceMarkers are the state directories of one agent's instances, so an
+// installation's own run state does not count their processes as its own.
+func instanceMarkers(agentId string) []string {
+       if !agent.SupportsInstances(agentId) {
+               return nil
+       }
+       instances, err := object.GetAgentInstances(agentId)
+       if err != nil || len(instances) == 0 {
+               return nil
+       }
+
+       markers := make([]string, 0, len(instances))
+       for _, instance := range instances {
+               markers = append(markers, instance.DataDir)
+       }
+       return markers
+}
diff --git a/controllers/agent_process.go b/controllers/agent_process.go
index 322a7c2..4847928 100644
--- a/controllers/agent_process.go
+++ b/controllers/agent_process.go
@@ -112,5 +112,6 @@ func processTarget(installation agent.Installation) 
agentprocess.Target {
                Executable: launch.Executable,
                Args:       launch.Args,
                Desktop:    launch.Desktop,
+               Exclude:    instanceMarkers(installation.AgentId),
        }
 }
diff --git a/object/agent_instance.go b/object/agent_instance.go
new file mode 100644
index 0000000..f28b00f
--- /dev/null
+++ b/object/agent_instance.go
@@ -0,0 +1,134 @@
+// 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 object
+
+import (
+       "errors"
+       "fmt"
+
+       "github.com/apache/casbin-gateway/util"
+       "github.com/xorm-io/core"
+)
+
+// AgentInstance is one extra copy of an agent, kept apart from the others by a
+// state directory of its own so that each can be signed in to a different
+// account and used at the same time.
+//
+// The installation is shared: an instance is a second set of state, not a
+// second copy of the program.
+type AgentInstance struct {
+       Owner       string `xorm:"varchar(100) notnull pk" json:"owner"`
+       Name        string `xorm:"varchar(300) notnull pk" json:"name"`
+       CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+       UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+       AgentId string `xorm:"varchar(100) index" json:"agentId"`
+       // Instance is the name within the agent, which is also the last 
segment of
+       // the state directory.
+       Instance    string `xorm:"varchar(100)" json:"instance"`
+       DisplayName string `xorm:"varchar(200)" json:"displayName"`
+       // DataDir is the private state directory this instance is started with.
+       DataDir string `xorm:"varchar(500)" json:"dataDir"`
+       // Path and HostUser are the installation the instance runs, as a scan
+       // reported it.
+       Path     string `xorm:"varchar(500)" json:"path"`
+       HostUser string `xorm:"varchar(200)" json:"hostUser"`
+}
+
+func (instance *AgentInstance) GetId() string {
+       return fmt.Sprintf("%s/%s", instance.Owner, instance.Name)
+}
+
+// AgentInstanceName is the stored name of one instance: the agent it belongs 
to
+// and the name chosen for it, since the same name may be used under two 
agents.
+func AgentInstanceName(agentId string, instance string) string {
+       return agentId + "/" + instance
+}
+
+// GetAgentInstances returns the instances of one agent, or of every agent when
+// agentId is empty, oldest first so the list does not reshuffle.
+func GetAgentInstances(agentId string) ([]*AgentInstance, error) {
+       instances := []*AgentInstance{}
+       session := ormer.Engine.Where("owner = ?", AgentOwner)
+       if agentId != "" {
+               session = session.And("agent_id = ?", agentId)
+       }
+       if err := session.Asc("created_time").Find(&instances); err != nil {
+               return nil, err
+       }
+       return instances, nil
+}
+
+func GetAgentInstance(name string) (*AgentInstance, error) {
+       instance := &AgentInstance{Owner: AgentOwner, Name: name}
+       existed, err := ormer.Engine.Get(instance)
+       if err != nil {
+               return nil, err
+       }
+       if !existed {
+               return nil, nil
+       }
+       return instance, nil
+}
+
+// AddAgentInstance stores one instance. The name is what the state directory 
is
+// built from, so a second row under it would hand two instances the same 
state.
+func AddAgentInstance(instance *AgentInstance) error {
+       if instance.AgentId == "" || instance.Instance == "" {
+               return errors.New("the agent and the instance name are 
required")
+       }
+
+       instance.Owner = AgentOwner
+       instance.Name = AgentInstanceName(instance.AgentId, instance.Instance)
+       stored, err := GetAgentInstance(instance.Name)
+       if err != nil {
+               return err
+       }
+       if stored != nil {
+               return fmt.Errorf("this agent already has an instance named 
%s", instance.Instance)
+       }
+
+       instance.CreatedTime = util.GetCurrentTime()
+       instance.UpdatedTime = instance.CreatedTime
+       _, err = ormer.Engine.Insert(instance)
+       return err
+}
+
+// DeleteAgentInstance forgets one instance. The state directory is left where 
it
+// is: it holds a signed-in account and whatever that account did, which is not
+// Gateway's to throw away.
+func DeleteAgentInstance(name string) error {
+       _, err := ormer.Engine.Delete(&AgentInstance{Owner: AgentOwner, Name: 
name})
+       return err
+}
+
+// SetAgentInstanceDisplayName renames one instance in the lists. Only the 
label
+// moves: the name is what the state directory was laid out under, and renaming
+// that would leave the account behind.
+func SetAgentInstanceDisplayName(name string, displayName string) error {
+       stored, err := GetAgentInstance(name)
+       if err != nil {
+               return err
+       }
+       if stored == nil {
+               return fmt.Errorf("no agent instance is stored under this name: 
%s", name)
+       }
+
+       // Cols() is what writes an empty label: xorm skips zero values 
otherwise.
+       _, err = ormer.Engine.ID(core.PK{AgentOwner, name}).
+               Cols("display_name", "updated_time").
+               Update(&AgentInstance{DisplayName: displayName, UpdatedTime: 
util.GetCurrentTime()})
+       return err
+}
diff --git a/object/ormer.go b/object/ormer.go
index 473d99e..ca956fe 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -269,6 +269,13 @@ func (a *Ormer) createTable() {
                panic(err)
        }
 
+       // The extra copies of an agent, each with a state directory of its own 
so
+       // that two accounts can be signed in and used at the same time.
+       err = a.Engine.Sync2(new(AgentInstance))
+       if err != nil {
+               panic(err)
+       }
+
        err = a.Engine.Sync2(new(LlmRecord))
        if err != nil {
                panic(err)
diff --git a/routers/router.go b/routers/router.go
index b0a451e..b198f23 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -55,6 +55,12 @@ func initAPI() {
        beego.Router("/api/get-agent-processes", &controllers.ApiController{}, 
"GET:GetAgentProcesses")
        beego.Router("/api/start-agent", &controllers.ApiController{}, 
"POST:StartAgent")
        beego.Router("/api/stop-agent", &controllers.ApiController{}, 
"POST:StopAgent")
+       beego.Router("/api/get-agent-instances", &controllers.ApiController{}, 
"GET:GetAgentInstances")
+       beego.Router("/api/add-agent-instance", &controllers.ApiController{}, 
"POST:AddAgentInstance")
+       beego.Router("/api/update-agent-instance", 
&controllers.ApiController{}, "POST:UpdateAgentInstance")
+       beego.Router("/api/delete-agent-instance", 
&controllers.ApiController{}, "POST:DeleteAgentInstance")
+       beego.Router("/api/start-agent-instance", &controllers.ApiController{}, 
"POST:StartAgentInstance")
+       beego.Router("/api/stop-agent-instance", &controllers.ApiController{}, 
"POST:StopAgentInstance")
        beego.Router("/api/get-agent-records", &controllers.ApiController{}, 
"GET:GetAgentRecords")
        beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, 
"GET:GetAgentSessions")
        beego.Router("/api/get-agent-session", &controllers.ApiController{}, 
"GET:GetAgentSession")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index db1d342..2547d5d 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -17,6 +17,7 @@ import type {
   Agent,
   AgentCatalogEntry,
   AgentInstallJob,
+  AgentInstance,
   AgentProviderConfig,
   AgentProviderFile,
   AgentRecord,
@@ -77,6 +78,42 @@ export function stopAgent(target: PatchTarget) {
   return request<AgentRuntime>("/api/stop-agent", "POST", target);
 }
 
+/** The extra copies of one agent, each with its own account and run state. */
+export function getAgentInstances(agent = "", forceRefresh = false) {
+  return request<AgentInstance[]>(
+    `/api/get-agent-instances${query({agent: agent, refresh: forceRefresh ? 
"true" : ""})}`,
+  );
+}
+
+/**
+ * Registers one more copy of an installed agent and lays out its state. The
+ * server names it, so adding one is a single click; the name is edited after.
+ */
+export function addAgentInstance(target: PatchTarget) {
+  return request<AgentInstance>("/api/add-agent-instance", "POST", target);
+}
+
+/** Renames one instance in the lists; its state directory stays where it is. 
*/
+export function renameAgentInstance(name: string, displayName: string) {
+  return request<AgentInstance>("/api/update-agent-instance", "POST", {
+    name: name,
+    displayName: displayName,
+  });
+}
+
+/** Forgets one instance; its state directory is left on disk. */
+export function deleteAgentInstance(name: string) {
+  return request<string>("/api/delete-agent-instance", "POST", {name: name});
+}
+
+export function startAgentInstance(name: string) {
+  return request<AgentInstance>("/api/start-agent-instance", "POST", {name: 
name});
+}
+
+export function stopAgentInstance(name: string) {
+  return request<AgentInstance>("/api/stop-agent-instance", "POST", {name: 
name});
+}
+
 export interface AgentRouting {
   provider: string;
   fallbacks: string[];
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index 5018795..31d1657 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -18,6 +18,7 @@ import {Bot, ChevronRight, UserRound} from "lucide-react";
 import i18next from "i18next";
 
 import {AgentIcon} from "@/components/AgentIcon";
+import {AgentCardInstances} from "@/components/AgentInstances";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {QuotaBadge} from "@/components/ProviderQuota";
@@ -42,6 +43,7 @@ import {
   directMode,
   monitorAgentId,
   type AgentActivity,
+  type AgentInstanceControls,
 } from "@/lib/agents";
 import {providerIdOf, providerProtocol} from "@/lib/providers";
 import {formatCost, formatTokens} from "@/lib/usage";
@@ -157,6 +159,7 @@ export function AgentGridCard({
   usage,
   activity,
   status,
+  instances,
   recording,
   busy,
   runBusy,
@@ -178,6 +181,8 @@ export function AgentGridCard({
   usage?: AgentUsageStat;
   activity?: AgentActivity;
   status?: AgentRuntime;
+  /** The extra copies of this agent, for the agents that can run more than 
one. */
+  instances?: AgentInstanceControls;
   /** False while llmRecordMode is off, when a zero would be a lie. */
   recording: boolean;
   busy: boolean;
@@ -344,6 +349,8 @@ export function AgentGridCard({
 
         <PlanUsage quota={quota} />
 
+        {instances ? <AgentCardInstances agent={agent} controls={instances} /> 
: null}
+
         <div className="mt-auto flex flex-wrap items-center gap-3 border-t 
pt-3">
           <RunButton agent={agent} status={status} busy={runBusy} 
onToggle={onToggleRunning} />
 
diff --git a/web/src/components/AgentInstances.tsx 
b/web/src/components/AgentInstances.tsx
new file mode 100644
index 0000000..757bf7c
--- /dev/null
+++ b/web/src/components/AgentInstances.tsx
@@ -0,0 +1,314 @@
+// 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 {Play, Plus, RefreshCw, Square, Trash2, UserPlus, Users} from 
"lucide-react";
+import i18next from "i18next";
+
+import {accountLabel} from "@/components/AgentGridCard";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeText} from "@/components/shared/misc";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {instancesOf, useAgentInstances, type AgentInstanceControls} from 
"@/lib/agents";
+import {cn} from "@/lib/utils";
+import type {Agent, AgentInstance} from "@/types";
+
+/** The label of one instance: whoever is signed in to it, or what it was 
named. */
+function instanceLabel(instance: AgentInstance) {
+  const signedIn = instance.account ? accountLabel(instance.account) : "";
+  return signedIn || instance.displayName || instance.instance;
+}
+
+/** Adds one more copy, which the server names and lays out on its own. */
+function AddInstanceButton({
+  agent,
+  controls,
+  compact = false,
+}: {
+  agent: Agent;
+  controls: AgentInstanceControls;
+  compact?: boolean;
+}) {
+  const label = i18next.t("agent:Add instance");
+  const busy = controls.busyName === agent.agentId;
+
+  if (!compact) {
+    return (
+      <Button size="sm" loading={busy} onClick={() => controls.add(agent)}>
+        <Plus />
+        {label}
+      </Button>
+    );
+  }
+  return (
+    <SimpleTooltip title={label}>
+      <Button
+        size="icon"
+        variant="ghost"
+        className="size-6"
+        loading={busy}
+        aria-label={label}
+        onClick={() => controls.add(agent)}
+      >
+        <Plus />
+      </Button>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The name of one instance, editable where it is listed. A copy is added in 
one
+ * click and numbered by the server, so this is where it is given a name worth
+ * reading - once there is an account in it to name it after.
+ */
+function InstanceName({
+  instance,
+  onRename,
+}: {
+  instance: AgentInstance;
+  onRename: (instance: AgentInstance, displayName: string) => void;
+}) {
+  const [value, setValue] = React.useState(instance.displayName);
+
+  // A reload brings the stored name back, which is what a rejected rename has
+  // to fall back to.
+  React.useEffect(() => setValue(instance.displayName), 
[instance.displayName]);
+
+  return (
+    <Input
+      value={value}
+      placeholder={instance.instance}
+      aria-label={i18next.t("general:Name")}
+      className="hover:border-input h-7 border-transparent px-1 font-medium 
shadow-none"
+      onChange={event => setValue(event.target.value)}
+      onBlur={() => {
+        const named = value.trim();
+        if (named !== instance.displayName) {
+          onRename(instance, named);
+        }
+      }}
+      onKeyDown={event => {
+        if (event.key === "Enter") {
+          event.currentTarget.blur();
+        } else if (event.key === "Escape") {
+          setValue(instance.displayName);
+        }
+      }}
+    />
+  );
+}
+
+/** Starts or stops one copy, as the icon that says which it would do. */
+function InstanceRunButton({
+  instance,
+  busy,
+  onToggle,
+}: {
+  instance: AgentInstance;
+  busy: boolean;
+  onToggle: (instance: AgentInstance) => void;
+}) {
+  const label = i18next.t(instance.running ? "agent:Stop" : "agent:Start");
+  return (
+    <SimpleTooltip title={instance.running || instance.canStart ? label : 
instance.detail}>
+      <span>
+        <Button
+          size="icon"
+          variant="ghost"
+          className="size-6"
+          disabled={!instance.running && !instance.canStart}
+          loading={busy}
+          aria-label={label}
+          onClick={() => onToggle(instance)}
+        >
+          {instance.running ? <Square /> : <Play />}
+        </Button>
+      </span>
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * The extra copies of one agent. Each is started against a state directory of
+ * its own, so each signs in separately and the two run side by side without
+ * seeing each other's sessions.
+ */
+export function AgentInstances({agent, enabled = true}: {agent: Agent; 
enabled?: boolean}) {
+  const controls = useAgentInstances(agent.agentId, enabled && 
agent.supportsInstances === true);
+
+  if (!agent.supportsInstances) {
+    return null;
+  }
+
+  const columns: Column<AgentInstance>[] = [
+    {
+      title: i18next.t("general:Name"),
+      key: "instance",
+      dataIndex: "instance",
+      render: (_value, record) => <InstanceName instance={record} 
onRename={controls.rename} />,
+    },
+    {
+      title: i18next.t("agent:Account"),
+      key: "account",
+      render: (_value, record) =>
+        record.account && accountLabel(record.account) ? (
+          <div className="flex flex-col">
+            <span>{accountLabel(record.account)}</span>
+            {record.account.email && record.account.email !== 
accountLabel(record.account) ? (
+              <code className="text-muted-foreground 
text-xs">{record.account.email}</code>
+            ) : null}
+          </div>
+        ) : (
+          <SimpleTooltip title={i18next.t("agent:Not signed in hint")}>
+            <span className="text-muted-foreground inline-flex items-center 
gap-1">
+              <UserPlus className="size-3.5" />
+              {i18next.t("agent:Not signed in")}
+            </span>
+          </SimpleTooltip>
+        ),
+    },
+    {
+      title: i18next.t("agent:Run Status"),
+      key: "running",
+      render: (_value, record) =>
+        record.running ? (
+          <SimpleTooltip title={`${i18next.t("agent:Processes")}: 
${record.pids.join(", ")}`}>
+            <span>
+              <Badge variant="success">{i18next.t("agent:Running")}</Badge>
+            </span>
+          </SimpleTooltip>
+        ) : (
+          <SimpleTooltip title={record.detail}>
+            <span>
+              <Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
+            </span>
+          </SimpleTooltip>
+        ),
+    },
+    {
+      title: i18next.t("agent:State directory"),
+      key: "dataDir",
+      dataIndex: "dataDir",
+      ellipsis: true,
+      render: (value: string) => <CodeText copyable>{value}</CodeText>,
+    },
+    {
+      title: i18next.t("general:Action"),
+      key: "action",
+      render: (_value, record) => (
+        <div className="flex items-center gap-2">
+          <InstanceRunButton
+            instance={record}
+            busy={controls.busyName === record.name}
+            onToggle={controls.toggleRunning}
+          />
+          <ConfirmDialog
+            title={`${i18next.t("agent:Remove instance")}: 
${instanceLabel(record)}?`}
+            description={i18next.t("agent:Remove instance hint")}
+            confirmText={i18next.t("agent:Remove instance")}
+            variant="destructive"
+            onConfirm={() => controls.remove(record)}
+          >
+            <Button size="icon" variant="ghost" className="size-6">
+              <Trash2 />
+            </Button>
+          </ConfirmDialog>
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <DataTable
+      title={i18next.t("agent:Instances")}
+      description={i18next.t("agent:Instances hint")}
+      columns={columns}
+      dataSource={controls.instances}
+      rowKey={instance => instance.name}
+      loading={controls.loading}
+      pageSize={0}
+      emptyText={i18next.t("agent:No instances yet")}
+      toolbar={
+        <div className="flex items-center gap-2">
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => controls.reload(true)}
+            loading={controls.loading}
+          >
+            <RefreshCw />
+            {i18next.t("agent:Scan")}
+          </Button>
+          <AddInstanceButton agent={agent} controls={controls} />
+        </div>
+      }
+    />
+  );
+}
+
+/**
+ * The instances of one agent as a grid card shows them: an icon for the 
section,
+ * one line per copy with the account it is signed in to, and the control that
+ * starts or stops it. The card has no room for words about any of it, so the
+ * names live in the tooltips and the renaming on the detail page.
+ */
+export function AgentCardInstances({
+  agent,
+  controls,
+}: {
+  agent: Agent;
+  controls: AgentInstanceControls;
+}) {
+  if (!agent.supportsInstances) {
+    return null;
+  }
+
+  const instances = instancesOf(controls.instances, agent);
+  return (
+    <div className="space-y-1.5">
+      <div className="flex items-center justify-between gap-2">
+        <SimpleTooltip title={i18next.t("agent:Instances")}>
+          <span className="text-muted-foreground flex items-center gap-1 
text-xs">
+            <Users className="size-3.5" />
+            {instances.length > 0 ? instances.length : null}
+          </span>
+        </SimpleTooltip>
+        <AddInstanceButton agent={agent} controls={controls} compact />
+      </div>
+
+      {instances.map(instance => (
+        <div key={instance.name} className="flex items-center gap-2">
+          <span
+            className={cn(
+              "size-1.5 shrink-0 rounded-full",
+              instance.running ? "bg-success" : "bg-muted-foreground/40",
+            )}
+          />
+          <SimpleTooltip title={instance.account ? instance.account.email : 
instance.dataDir}>
+            <span className="min-w-0 flex-1 truncate 
text-xs">{instanceLabel(instance)}</span>
+          </SimpleTooltip>
+          <InstanceRunButton
+            instance={instance}
+            busy={controls.busyName === instance.name}
+            onToggle={controls.toggleRunning}
+          />
+        </div>
+      ))}
+    </div>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index b1d8318..a1d53b9 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -23,6 +23,7 @@ import type {
   Agent,
   AgentCatalogEntry,
   AgentInstallJob,
+  AgentInstance,
   AgentRuntime,
   AgentSession,
   AgentUsage,
@@ -662,3 +663,142 @@ export function getOutcomeVariant(outcome: string | 
undefined): BadgeVariant {
     } as const
   )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
 }
+
+/**
+ * useAgentInstances owns the extra copies of one agent: the ones stored, what
+ * each is signed in to, and whether each is running. Each has a state 
directory
+ * of its own, so they start, stop and sign in independently of one another.
+ */
+export function useAgentInstances(agentId = "", enabled = true) {
+  const [instances, setInstances] = React.useState<AgentInstance[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [busyName, setBusyName] = React.useState("");
+
+  const load = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      AgentBackend.getAgentInstances(agentId, forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setInstances(res.data ?? []);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setLoading(false));
+    },
+    [agentId, enabled],
+  );
+
+  React.useEffect(() => {
+    load();
+  }, [load]);
+
+  /**
+   * Adds one copy. The server names and lays it out, so there is nothing to 
ask
+   * first; the name is edited afterwards, once an account has signed in to it.
+   * The agent id stands in for the row that does not exist yet while it runs.
+   */
+  const add = React.useCallback(
+    (agent: Agent) => {
+      setBusyName(agent.agentId);
+      AgentBackend.addAgentInstance({agentId: agent.agentId, path: agent.path, 
owner: agent.owner})
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t("agent:Instance added")}: ${res.data?.instance ?? 
""}`,
+            );
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
add the instance"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  const rename = React.useCallback(
+    (instance: AgentInstance, displayName: string) => {
+      AgentBackend.renameAgentInstance(instance.name, displayName)
+        .then(res => {
+          if (res.status !== "ok") {
+            Setting.showMessage("error", res.msg);
+          }
+          load();
+        })
+        .catch(err => Setting.showMessage("error", err.message || 
String(err)));
+    },
+    [load],
+  );
+
+  const remove = React.useCallback(
+    (instance: AgentInstance) => {
+      setBusyName(instance.name);
+      AgentBackend.deleteAgentInstance(instance.name)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage("success", `${i18next.t("agent:Instance 
removed")}: ${instance.instance}`);
+            load(true);
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  /** Starts or stops one copy, leaving whatever else is running alone. */
+  const toggleRunning = React.useCallback(
+    (instance: AgentInstance) => {
+      const running = instance.running;
+
+      setBusyName(instance.name);
+      (running
+        ? AgentBackend.stopAgentInstance(instance.name)
+        : AgentBackend.startAgentInstance(instance.name))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${
+                instance.displayName || instance.instance
+              }`,
+            );
+            // A desktop app takes a moment to show up in the process table, so
+            // the run state is read again shortly after the call.
+            setTimeout(() => load(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
+            );
+          }
+          load(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  return {instances, loading, busyName, reload: load, add, rename, remove, 
toggleRunning};
+}
+
+/** What one hook call returns, as the cards and the tables take it. */
+export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
+
+/** The instances belonging to one installation, out of a listing of them all. 
*/
+export function instancesOf(instances: AgentInstance[], agent: Agent) {
+  return instances.filter(instance => instance.agentId === agent.agentId);
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 0cf0594..a2ed3e1 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -19,6 +19,7 @@
   },
   "agent": {
     "Account": "Account",
+    "Add instance": "Add instance",
     "Advanced view": "Advanced view",
     "Advanced view hint": "Every installation found, with its processes, path, 
owner and install method.",
     "Agent": "Agent",
@@ -60,6 +61,7 @@
     "Empty message": "Empty message",
     "Enable": "Enable",
     "Event": "Event",
+    "Failed to add the instance": "Failed to add the instance",
     "Failed to get agent records": "Failed to get agent records",
     "Failed to get agent sessions": "Failed to get agent sessions",
     "Failed to install the agent": "Failed to install the agent",
@@ -87,6 +89,10 @@
     "Install page": "The vendor's own install page",
     "Installation": "Installation",
     "Installing": "Installing",
+    "Instance added": "Instance added",
+    "Instance removed": "Instance removed",
+    "Instances": "Instances",
+    "Instances hint": "Extra copies of this agent, each with a state directory 
of its own, so each is signed in to its own account and they run at the same 
time.",
     "Last activity": "Last activity",
     "Last {count} records": "Last {count} records",
     "MCP target": "MCP target",
@@ -98,6 +104,7 @@
     "No agent records yet - patch an agent to start collecting them": "No 
activity yet - turn on monitoring for an agent to start collecting it",
     "No agent sessions yet": "No agent sessions yet",
     "No agent sessions yet - patch an agent to start collecting them": "No 
sessions yet - turn on monitoring for an agent to start collecting them",
+    "No instances yet": "No instances yet - add one to sign in with a second 
account",
     "No other provider speaks this API": "No other provider speaks this API",
     "No supported agents found": "No supported agents found",
     "No usage yet": "No requests have been relayed for this agent yet",
@@ -105,6 +112,8 @@
     "Not installed hint": "Agents Gateway works with that were not found on 
this machine. Install one with a package manager this host already has, or open 
its vendor's page.",
     "Not patched": "Not monitored",
     "Not recorded": "not recorded",
+    "Not signed in": "Not signed in",
+    "Not signed in hint": "Start this instance and sign in; the account shows 
up here afterwards.",
     "Not supported": "Not supported yet",
     "Not written": "Not written",
     "Patch": "Turn on monitoring",
@@ -123,6 +132,8 @@
     "Read from the agent's own logs": "Read from the agent's own logs",
     "Records": "Records",
     "Records are kept in memory only and are lost when Gateway restarts": 
"Records are kept in memory only and are lost when Gateway restarts",
+    "Remove instance": "Remove instance",
+    "Remove instance hint": "Gateway forgets this instance. Its state 
directory, and the account signed in to it, are left on disk.",
     "Reported from": "Reported from",
     "Restore configuration": "Restore configuration",
     "Restore confirm hint": "Puts back the provider settings this agent had 
before Gateway first wrote them, and leaves every other setting untouched.",
@@ -141,6 +152,7 @@
     "Start": "Start",
     "Start app hint": "Opens the app on the machine Gateway runs on.",
     "Start console hint": "Opens a console window running the CLI on the 
machine Gateway runs on.",
+    "State directory": "State directory",
     "Still calling Gateway": "This agent is set to its own model, but its 
configuration still points at Gateway, so every request fails. Put the 
configuration back.",
     "Stop": "Stop",
     "Stop hint": "Ends every process of this installation, losing whatever it 
is working on.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0ebdc1f..3a8e543 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -19,6 +19,7 @@
   },
   "agent": {
     "Account": "账户",
+    "Add instance": "添加实例",
     "Advanced view": "高级视图",
     "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
     "Agent": "智能体",
@@ -60,6 +61,7 @@
     "Empty message": "空消息",
     "Enable": "启用",
     "Event": "事件",
+    "Failed to add the instance": "添加实例失败",
     "Failed to get agent records": "获取 Agent 记录失败",
     "Failed to get agent sessions": "获取 Agent 会话失败",
     "Failed to install the agent": "安装失败",
@@ -87,6 +89,10 @@
     "Install page": "厂商的安装页面",
     "Installation": "安装信息",
     "Installing": "安装中",
+    "Instance added": "已添加实例",
+    "Instance removed": "已移除实例",
+    "Instances": "实例",
+    "Instances hint": "该 Agent 的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
     "Last activity": "最后活动",
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
@@ -98,6 +104,7 @@
     "No agent records yet - patch an agent to start collecting them": 
"还没有活动记录,开启某个智能体的监控后才会开始收集",
     "No agent sessions yet": "还没有会话",
     "No agent sessions yet - patch an agent to start collecting them": 
"还没有会话,开启某个智能体的监控后才会开始收集",
+    "No instances yet": "暂无实例 - 添加一个即可用第二个账号登录",
     "No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
     "No supported agents found": "没有发现本机安装的智能体",
     "No usage yet": "该 Agent 还没有经过网关的请求",
@@ -105,6 +112,8 @@
     "Not installed hint": "本机未找到、但 Gateway 支持的代理。可直接用本机已有的包管理器安装,或打开厂商页面。",
     "Not patched": "未开启",
     "Not recorded": "未保留",
+    "Not signed in": "未登录",
+    "Not signed in hint": "启动该实例并登录,之后账号会显示在这里。",
     "Not supported": "暂不支持",
     "Not written": "未写入",
     "Patch": "开启监控",
@@ -123,6 +132,8 @@
     "Read from the agent's own logs": "读自 agent 自己的日志",
     "Records": "记录数",
     "Records are kept in memory only and are lost when Gateway restarts": 
"记录仅保存在内存中,Gateway 重启后会丢失",
+    "Remove instance": "移除实例",
+    "Remove instance hint": "Gateway 将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
     "Reported from": "上报来源",
     "Restore configuration": "还原配置",
     "Restore confirm hint": "把该 Agent 在 Gateway 首次写入之前的供应商配置还原回去,其他配置保持不变。",
@@ -141,6 +152,7 @@
     "Start": "启动",
     "Start app hint": "在运行 Gateway 的机器上打开该应用。",
     "Start console hint": "在运行 Gateway 的机器上打开一个控制台窗口来运行该命令行。",
+    "State directory": "状态目录",
     "Still calling Gateway": "该 Agent 
已设为使用自带模型,但它的配置仍指向网关,所以每个请求都会失败。请恢复它的配置。",
     "Stop": "停止",
     "Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index 749ab59..19f3260 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -23,6 +23,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
+import {AgentInstances} from "@/components/AgentInstances";
 import {accountLabel} from "@/components/AgentGridCard";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {InstallOutput, ToolUpgradeConfirmDialog} from 
"@/components/ToolUpgradeConfirmDialog";
@@ -536,6 +537,8 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
         </Card>
       </div>
 
+      <AgentInstances agent={agent} enabled={isAdmin} />
+
       {!watching ? (
         <Alert variant="info">
           <AlertDescription>
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index a2e36e5..d185dce 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -35,6 +35,7 @@ import {
   agentKey,
   runtimeOf,
   useAgentInstall,
+  useAgentInstances,
   useAgents,
   useAgentSessions,
   usageOf,
@@ -84,6 +85,9 @@ export default function HomePage({account}: {account: 
Account}) {
   } = useAgents(isAdmin);
   // Installs the agents this machine is missing, listed under the cards.
   const installer = useAgentInstall(isAdmin, () => scan(true));
+  // Every agent's extra copies in one listing, so a page of cards costs one
+  // request rather than one per card.
+  const instances = useAgentInstances("", isAdmin);
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
@@ -190,6 +194,7 @@ export default function HomePage({account}: {account: 
Account}) {
     scan(true);
     loadRuntime(true);
     loadProviders();
+    instances.reload(true);
   };
 
   const rescan = (
@@ -283,6 +288,7 @@ export default function HomePage({account}: {account: 
Account}) {
               usage={usageOf(usage, agent)}
               activity={activityOf(activity, agent)}
               status={runtimeOf(runtime, agent)}
+              instances={instances}
               recording={recording}
               busy={busyKey === agentKey(agent)}
               runBusy={runBusyKey === agentKey(agent)}
diff --git a/web/src/types.ts b/web/src/types.ts
index 3d111ce..04f330b 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -160,6 +160,33 @@ export interface Agent {
   proxyBaseUrl?: string;
   /** The command that would update this installation where it stands. */
   upgrade?: AgentInstallPlan;
+  /** Whether a second copy of this agent can run beside the first. */
+  supportsInstances?: boolean;
+}
+
+/**
+ * One extra copy of an agent, kept apart from the others by a state directory
+ * of its own, so that each is signed in to a different account and the two can
+ * work at the same time.
+ */
+export interface AgentInstance {
+  /** The stored id, "<agentId>/<instance>", which every call names it by. */
+  name: string;
+  agentId: string;
+  instance: string;
+  displayName: string;
+  /** The private state directory this copy is started with. */
+  dataDir: string;
+  path: string;
+  hostUser: string;
+  createdTime: string;
+  /** The account this copy is signed in to, absent until someone signs in. */
+  account?: AgentAccount;
+  desktop: boolean;
+  running: boolean;
+  pids: number[];
+  canStart: boolean;
+  detail?: string;
 }
 
 /**


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

Reply via email to