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 e7599f8  feat(agents): list the installation itself as instance 1
e7599f8 is described below

commit e7599f8b7455b0f8e5dd149bc1bb3efe7cba4dd9
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 9 20:45:05 2026 +0800

    feat(agents): list the installation itself as instance 1
---
 agent/instance.go                     |  35 +++++++++
 controllers/agent_instance.go         | 136 +++++++++++++++++++++++++++++++---
 web/src/components/AgentInstances.tsx |  49 ++++++++----
 web/src/lib/agents.ts                 |   9 ++-
 web/src/locales/en/data.json          |   4 +-
 web/src/locales/zh/data.json          |   4 +-
 web/src/types.ts                      |   3 +
 7 files changed, 211 insertions(+), 29 deletions(-)

diff --git a/agent/instance.go b/agent/instance.go
index 8cbba0a..9ca2788 100644
--- a/agent/instance.go
+++ b/agent/instance.go
@@ -90,6 +90,41 @@ func InstanceDir(agentId string, name string) (string, 
error) {
        return filepath.Join(home, instancesDir, agentId, name), nil
 }
 
+// DefaultInstanceDir is the state directory the first instance of an agent 
runs
+// on: the one it uses when it is started with no instance of its own. An agent
+// isolated by an environment variable keeps it under the home directory, the
+// way its own default does; one isolated by a flag is a desktop app, whose
+// default is wherever it puts its user data, known only for the apps Gateway
+// reads an account from. Empty where Gateway does not know it.
+func DefaultInstanceDir(agentId string, owner string) string {
+       rules := isolationOf(agentId)
+       if !rules.supported() {
+               return ""
+       }
+       home, err := agenthome.Resolve(owner)
+       if err != nil {
+               return ""
+       }
+       if rules.env != "" {
+               if dir := stateDirOf(agentId); dir != "" {
+                       return filepath.Join(home, "."+dir)
+               }
+               return ""
+       }
+       return accountStateDir(accountKind(agentId), home)
+}
+
+// stateDirOf is the directory, named without its leading dot, that an agent
+// keeps its state in under a home.
+func stateDirOf(agentId string) string {
+       for i := range fingerprints {
+               if fingerprints[i].ID == agentId {
+                       return fingerprints[i].StateDir
+               }
+       }
+       return ""
+}
+
 // CheckInstanceName keeps an instance name to what is safe as one path segment
 // and readable as a label.
 func CheckInstanceName(name string) error {
diff --git a/controllers/agent_instance.go b/controllers/agent_instance.go
index b0ad090..09f4edb 100644
--- a/controllers/agent_instance.go
+++ b/controllers/agent_instance.go
@@ -19,6 +19,7 @@ import (
        "errors"
        "fmt"
        "strconv"
+       "strings"
        "time"
 
        "github.com/apache/casbin-gateway/agent"
@@ -33,6 +34,11 @@ import (
 // keeps asking cannot fill the disk with empty profiles.
 const maxInstances = 50
 
+// defaultInstanceName is what the installation's own copy is listed as. It 
runs
+// on the state directory the agent uses by itself, so no row is stored for it
+// and the names Gateway hands out start at the second.
+const defaultInstanceName = "1"
+
 // linkCaptureTtl is how long a copy waiting to be signed in holds the URL 
scheme
 // of its agent: long enough for a sign-in in a browser, short enough that a 
link
 // arriving later still opens whichever copy the agent registered itself for.
@@ -46,6 +52,9 @@ type agentInstanceView struct {
        // Desktop tells the UI what a start would open: the app itself, or a 
console
        // window for a CLI.
        Desktop bool `json:"desktop"`
+       // Default marks the installation's own copy, which is listed beside the
+       // extra ones but is not stored, renamed or removed.
+       Default bool `json:"default"`
        // CanCapture is whether Gateway can route this agent's own links here 
at
        // all, and Capturing whether the next one will open this copy.
        CanCapture bool `json:"canCapture"`
@@ -61,7 +70,8 @@ func (c *ApiController) GetAgentInstances() {
                return
        }
 
-       instances, err := object.GetAgentInstances(c.Input().Get("agent"))
+       agentId := c.Input().Get("agent")
+       instances, err := object.GetAgentInstances(agentId)
        if err != nil {
                c.ResponseError(err.Error())
                return
@@ -70,7 +80,12 @@ func (c *ApiController) GetAgentInstances() {
                agentprocess.Refresh()
        }
 
-       result := make([]*agentInstanceView, 0, len(instances))
+       // The installation itself is the first copy, so it is listed first, 
ahead of
+       // the ones stored beside it.
+       result := make([]*agentInstanceView, 0, len(instances)+1)
+       for _, installation := range instancedInstallations(agentId) {
+               result = append(result, 
instanceView(defaultInstance(installation)))
+       }
        for _, instance := range instances {
                result = append(result, instanceView(instance))
        }
@@ -120,6 +135,10 @@ func (c *ApiController) AddAgentInstance() {
                c.ResponseError(err.Error())
                return
        }
+       if form.Instance == defaultInstanceName {
+               c.ResponseError(fmt.Sprintf("%s is the installation's own copy 
and is listed on its own", defaultInstanceName))
+               return
+       }
 
        dataDir, err := agent.InstanceDir(installation.AgentId, form.Instance)
        if err != nil {
@@ -159,6 +178,10 @@ func (c *ApiController) UpdateAgentInstance() {
        if !ok {
                return
        }
+       if isDefaultInstance(instance) {
+               c.ResponseError("the first instance is the installation itself 
and is named after it")
+               return
+       }
        var form struct {
                DisplayName string `json:"displayName"`
        }
@@ -207,6 +230,10 @@ func (c *ApiController) DeleteAgentInstance() {
        if !ok {
                return
        }
+       if isDefaultInstance(instance) {
+               c.ResponseError("the first instance is the installation itself 
and cannot be removed")
+               return
+       }
        if err := object.DeleteAgentInstance(instance.Name); err != nil {
                c.ResponseError(err.Error())
                return
@@ -230,9 +257,12 @@ func (c *ApiController) StartAgentInstance() {
                c.ResponseError(err.Error())
                return
        }
-       if err := agent.PrepareInstance(installation, instance.DataDir); err != 
nil {
-               c.ResponseError(err.Error())
-               return
+       // The first copy runs on the state the agent laid out itself.
+       if !isDefaultInstance(instance) {
+               if err := agent.PrepareInstance(installation, 
instance.DataDir); err != nil {
+                       c.ResponseError(err.Error())
+                       return
+               }
        }
 
        target, err := instanceTarget(installation, instance)
@@ -249,7 +279,8 @@ func (c *ApiController) StartAgentInstance() {
        // directory. A copy with no account in it is one that is about to sign 
in.
        // The start is not worth failing over it: the copy runs either way, 
and the
        // pages show whether the link was captured.
-       if agentlink.Supported() && agent.AccountOfInstance(instance.AgentId, 
instance.DataDir) == nil {
+       if !isDefaultInstance(instance) && agentlink.Supported() &&
+               agent.AccountOfInstance(instance.AgentId, instance.DataDir) == 
nil {
                if err := captureLink(instance, target); err != nil {
                        beego.Error("the sign-in link of", instance.Name, 
"cannot be routed to it:", err)
                }
@@ -270,6 +301,10 @@ func (c *ApiController) CaptureAgentInstanceLink() {
        if !ok {
                return
        }
+       if isDefaultInstance(instance) {
+               c.ResponseError("the agent's own links already open the first 
instance")
+               return
+       }
        var form struct {
                Capture bool `json:"capture"`
        }
@@ -369,20 +404,90 @@ func (c *ApiController) readAgentInstance() 
(*object.AgentInstance, bool) {
                return nil, false
        }
        if instance == nil {
-               c.ResponseError("no agent instance is stored under this name")
-               return nil, false
+               // The first copy is stored nowhere, so it is rebuilt from the
+               // installation it runs.
+               if instance = defaultInstanceNamed(form.Name); instance == nil {
+                       c.ResponseError("no agent instance is stored under this 
name")
+                       return nil, false
+               }
        }
        return instance, true
 }
 
+// isDefaultInstance reports whether one instance is the installation's own 
copy
+// rather than a stored one. The name is Gateway's to hand out and starts at 
the
+// second, so no stored instance answers to it.
+func isDefaultInstance(instance *object.AgentInstance) bool {
+       return instance != nil && instance.Instance == defaultInstanceName
+}
+
+// defaultInstance is the installation's own copy as the lists show it: the
+// state directory the agent uses when nothing hands it another one, under the
+// name every call addresses it by.
+func defaultInstance(installation agent.Installation) *object.AgentInstance {
+       return &object.AgentInstance{
+               Owner:    object.AgentOwner,
+               Name:     object.AgentInstanceName(installation.AgentId, 
defaultInstanceName),
+               AgentId:  installation.AgentId,
+               Instance: defaultInstanceName,
+               DataDir:  agent.DefaultInstanceDir(installation.AgentId, 
installation.Owner),
+               Path:     installation.Path,
+               HostUser: installation.Owner,
+       }
+}
+
+// defaultInstanceNamed resolves the name of a first copy against the
+// installations found here, nil for a name that is not one.
+func defaultInstanceNamed(name string) *object.AgentInstance {
+       agentId, instance, found := strings.Cut(name, "/")
+       if !found || instance != defaultInstanceName {
+               return nil
+       }
+       installations := instancedInstallations(agentId)
+       if len(installations) == 0 {
+               return nil
+       }
+       return defaultInstance(installations[0])
+}
+
+// instancedInstallations are the installations whose own copy is listed as the
+// first instance: one per agent Gateway can run more than one copy of, since
+// the extra copies are listed per agent too.
+func instancedInstallations(agentId string) []agent.Installation {
+       installations, err := agent.Scan(false)
+       if err != nil {
+               return nil
+       }
+
+       listed := map[string]bool{}
+       result := []agent.Installation{}
+       for _, installation := range installations {
+               if agentId != "" && installation.AgentId != agentId {
+                       continue
+               }
+               if !agent.SupportsInstances(installation.AgentId) || 
listed[installation.AgentId] {
+                       continue
+               }
+               listed[installation.AgentId] = true
+               result = append(result, installation)
+       }
+       return result
+}
+
 func instanceView(instance *object.AgentInstance) *agentInstanceView {
+       isDefault := isDefaultInstance(instance)
        view := &agentInstanceView{
                AgentInstance: instance,
-               Account:       agent.AccountOfInstance(instance.AgentId, 
instance.DataDir),
+               Default:       isDefault,
                Status:        agentprocess.Status{Pids: []int{}},
        }
+       if !isDefault {
+               view.Account = agent.AccountOfInstance(instance.AgentId, 
instance.DataDir)
+       }
 
-       if scheme := agent.LinkSchemeOf(instance.AgentId); scheme != "" && 
agentlink.Supported() {
+       // The agent's own links open the first copy already, so there is 
nothing to
+       // route for it.
+       if scheme := agent.LinkSchemeOf(instance.AgentId); scheme != "" && 
agentlink.Supported() && !isDefault {
                view.CanCapture = true
                claim, pending := agentlink.Pending(scheme)
                view.Capturing = pending && claim.Instance == instance.Name
@@ -390,6 +495,11 @@ func instanceView(instance *object.AgentInstance) 
*agentInstanceView {
 
        installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
        if err == nil {
+               // The first copy is signed in wherever the installation is, 
which the
+               // scan has already read.
+               if isDefault {
+                       view.Account = installation.Account
+               }
                var target agentprocess.Target
                if target, err = instanceTarget(installation, instance); err == 
nil {
                        view.Desktop = target.Desktop
@@ -402,6 +512,12 @@ func instanceView(instance *object.AgentInstance) 
*agentInstanceView {
 }
 
 func instanceTarget(installation agent.Installation, instance 
*object.AgentInstance) (agentprocess.Target, error) {
+       // The first copy is the installation run as it stands, which is also 
what
+       // counts its processes apart from the other copies'.
+       if isDefaultInstance(instance) {
+               return processTarget(installation), nil
+       }
+
        launch, err := agent.InstanceLaunchOf(installation, instance.DataDir)
        if err != nil {
                return agentprocess.Target{}, err
diff --git a/web/src/components/AgentInstances.tsx 
b/web/src/components/AgentInstances.tsx
index 87ea9a2..17b2d35 100644
--- a/web/src/components/AgentInstances.tsx
+++ b/web/src/components/AgentInstances.tsx
@@ -81,6 +81,21 @@ function AddInstanceButton({
   );
 }
 
+/**
+ * The name of the first copy, which is the installation itself: it is named
+ * after the installation wherever it is listed, so there is nothing to edit.
+ */
+function DefaultInstanceName({instance}: {instance: AgentInstance}) {
+  return (
+    <SimpleTooltip title={i18next.t("agent:Default instance hint")}>
+      <span className="inline-flex items-center gap-1.5 px-1 font-medium">
+        {instance.instance}
+        <Badge variant="muted">{i18next.t("agent:Default instance")}</Badge>
+      </span>
+    </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
@@ -209,7 +224,12 @@ export function AgentInstances({agent, enabled = true}: 
{agent: Agent; enabled?:
       title: i18next.t("general:Name"),
       key: "instance",
       dataIndex: "instance",
-      render: (_value, record) => <InstanceName instance={record} 
onRename={controls.rename} />,
+      render: (_value, record) =>
+        record.default ? (
+          <DefaultInstanceName instance={record} />
+        ) : (
+          <InstanceName instance={record} onRename={controls.rename} />
+        ),
     },
     {
       title: i18next.t("agent:Account"),
@@ -254,7 +274,8 @@ export function AgentInstances({agent, enabled = true}: 
{agent: Agent; enabled?:
       key: "dataDir",
       dataIndex: "dataDir",
       ellipsis: true,
-      render: (value: string) => <CodeText copyable>{value}</CodeText>,
+      render: (value: string) =>
+        value ? <CodeText copyable>{value}</CodeText> : <span 
className="text-muted-foreground">-</span>,
     },
     {
       title: i18next.t("general:Action"),
@@ -271,17 +292,19 @@ export function AgentInstances({agent, enabled = true}: 
{agent: Agent; enabled?:
             busy={controls.busyName === record.name}
             onToggle={controls.toggleCapture}
           />
-          <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>
+          {record.default ? null : (
+            <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>
       ),
     },
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index a723901..7066593 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -846,9 +846,10 @@ export function getOutcomeVariant(outcome: string | 
undefined): BadgeVariant {
 }
 
 /**
- * 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.
+ * useAgentInstances owns the copies of one agent: the installation's own, the
+ * ones stored beside it, 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[]>([]);
@@ -1016,7 +1017,7 @@ export function useAgentInstances(agentId = "", enabled = 
true) {
 /** 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. 
*/
+/** The copies of one installation, the first included, 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 ed64372..ed8af3b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -95,6 +95,8 @@
     "Copy path": "Copy path",
     "Create a directory": "Create a directory",
     "Create or overwrite a file": "Create or overwrite a file",
+    "Default instance": "Default instance",
+    "Default instance hint": "The agent's own copy, on the state directory it 
uses when nothing hands it another one. Gateway lists it beside the extra 
copies, but neither names nor removes it.",
     "Delete a file": "Delete a file",
     "Detail": "Detail",
     "Details": "Details",
@@ -167,7 +169,7 @@
     "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.",
+    "Instances hint": "Every copy of this agent: the installation itself, and 
the extra ones added here. Each has a state directory of its own, so each is 
signed in to its own account and they run at the same time.",
     "Interactive hint": "This opens a window on the machine and waits for an 
answer there.",
     "Keep a task list": "Keep a task list",
     "Kimi Code config hint": "Kimi Code keeps the endpoint in 
~/.kimi-code/config.toml: a [providers.casbin-gateway] table with type 
\"openai\", base_url and api_key, and one [models.*] alias per model, picked by 
default_model at the root of the file. It reads no credential from the 
environment, and KIMI_CODE_HOME moves the whole directory.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 674936b..2b4f21f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -95,6 +95,8 @@
     "Copy path": "复制路径",
     "Create a directory": "新建目录",
     "Create or overwrite a file": "新建或覆盖文件",
+    "Default instance": "默认实例",
+    "Default instance hint": 
"Agent自身的副本,运行在它默认使用的状态目录上。Gateway只把它和其他副本一起列出,不会重命名或移除它。",
     "Delete a file": "删除文件",
     "Detail": "详情",
     "Details": "详情",
@@ -167,7 +169,7 @@
     "Instance added": "已添加实例",
     "Instance removed": "已移除实例",
     "Instances": "实例",
-    "Instances hint": "该Agent的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
+    "Instances hint": 
"该Agent的全部副本:安装本身,以及在这里添加的其他副本。每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
     "Interactive hint": "这会在本机弹出窗口,需要在本机上确认。",
     "Keep a task list": "维护任务清单",
     "Kimi Code config hint": "Kimi 
Code的端点写在~/.kimi-code/config.toml里:一个[providers.casbin-gateway]表,含type 
\"openai\"、base_url和api_key,每个模型再写一个[models.*]别名,由文件根部的default_model选中。它不从环境变量读取任何凭据,KIMI_CODE_HOME会把整个目录挪到别处。",
diff --git a/web/src/types.ts b/web/src/types.ts
index adc40e6..cdf4441 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -332,6 +332,9 @@ export interface AgentInstance {
   /** The account this copy is signed in to, absent until someone signs in. */
   account?: AgentAccount;
   desktop: boolean;
+  /** Whether this is the installation's own copy, the first instance, which is
+   *  listed beside the extra ones but is not renamed or removed. */
+  default?: boolean;
   running: boolean;
   pids: number[];
   canStart: boolean;


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

Reply via email to