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]