You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/04 04:11:50 UTC
(casbin-gateway) branch master updated (aa1d6ab -> 45d2061)
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
from aa1d6ab refactor(nav): reorder the sidebar rail
new 896d8b8 feat(sessions): read Cursor's chats from its state database
new 45d2061 fix(hermes): write the model config to the home Hermes reads
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
README.md | 2 +-
README_zh.md | 2 +-
agent/scan_windows.go | 8 +--
agenthistory/cursor.go | 144 +++++++++++++++++++++++++++++++++++++++++++++
agenthistory/history.go | 1 +
agentpatch/hermes_agent.go | 31 +---------
agentprovider/hermes.go | 8 +--
internal/hermes/home.go | 65 ++++++++++++++++++++
8 files changed, 218 insertions(+), 43 deletions(-)
create mode 100644 agenthistory/cursor.go
create mode 100644 internal/hermes/home.go
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 02/02: fix(hermes): write the model config to the home Hermes reads
Posted by hs...@apache.org.
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
commit 45d20615ec9effd57bfbd18f7bbd43ca38617d83
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 12:11:42 2026 +0800
fix(hermes): write the model config to the home Hermes reads
---
agent/scan_windows.go | 8 +-----
agentpatch/hermes_agent.go | 31 +---------------------
agentprovider/hermes.go | 8 +++---
internal/hermes/home.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 71 insertions(+), 41 deletions(-)
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
index e1df2cf..9210cb9 100644
--- a/agent/scan_windows.go
+++ b/agent/scan_windows.go
@@ -162,13 +162,7 @@ func localAppData(home homeDir) string { return appDataDir(home, "Local", "LOC
func roamingAppData(home homeDir) string { return appDataDir(home, "Roaming", "APPDATA") }
func scanHermesWindows(home homeDir) []Installation {
- hermesHome := filepath.Join(localAppData(home), "hermes")
- if isCurrentHome(home.path) {
- if configured := os.Getenv("HERMES_HOME"); configured != "" {
- hermesHome = configured
- }
- }
- root := filepath.Join(hermesHome, hermes.ProjectDir)
+ root := filepath.Join(hermes.Home(home.path), hermes.ProjectDir)
launcher := filepath.Join(root, "venv", "Scripts", hermes.ExecName+".exe")
return hermesInstallation(launcher, home.owner, root)
}
diff --git a/agentpatch/hermes_agent.go b/agentpatch/hermes_agent.go
index 167fce1..b97d6de 100644
--- a/agentpatch/hermes_agent.go
+++ b/agentpatch/hermes_agent.go
@@ -251,18 +251,7 @@ func (hermesPatcher) layoutOf(target Target) (hermesLayout, error) {
if err != nil {
return hermesLayout{}, err
}
- hermesHome := filepath.Join(home, ".hermes")
- if runtime.GOOS == "windows" {
- hermesHome = filepath.Join(home, "AppData", "Local", "hermes")
- if isProcessHome(home) {
- if local := os.Getenv("LOCALAPPDATA"); local != "" {
- hermesHome = filepath.Join(local, "hermes")
- }
- }
- }
- if configured := os.Getenv("HERMES_HOME"); configured != "" && isProcessHome(home) {
- hermesHome = configured
- }
+ hermesHome := hermes.Home(home)
return hermesLayout{
home: hermesHome,
configPath: filepath.Join(hermesHome, "config.yaml"),
@@ -321,21 +310,3 @@ func readHermesObserverConfig(pluginDir string) (hermesObserverConfig, bool, err
return config, config.Owner == "casbin-gateway" &&
config.SchemaVersion == hermesConfigSchema, nil
}
-
-func isProcessHome(home string) bool {
- current, err := os.UserHomeDir()
- if err != nil {
- return false
- }
- left, right := filepath.Clean(home), filepath.Clean(current)
- if resolved, err := filepath.EvalSymlinks(left); err == nil {
- left = resolved
- }
- if resolved, err := filepath.EvalSymlinks(right); err == nil {
- right = resolved
- }
- if runtime.GOOS == "windows" {
- return strings.EqualFold(left, right)
- }
- return left == right
-}
diff --git a/agentprovider/hermes.go b/agentprovider/hermes.go
index e33783b..6eefaf4 100644
--- a/agentprovider/hermes.go
+++ b/agentprovider/hermes.go
@@ -18,7 +18,6 @@ import (
"errors"
"fmt"
"os"
- "path/filepath"
"github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/internal/hermes"
@@ -280,12 +279,13 @@ func (hermesWriter) save(path string, document *yamledit.Document) error {
return changes.commit()
}
-// configPath is the file Hermes reads its settings from, in the home directory
-// its own launcher keeps them in.
+// configPath is the file Hermes reads its settings from, in the Hermes home of
+// the installation's owner: %LOCALAPPDATA%\hermes on Windows, ~/.hermes
+// elsewhere.
func (hermesWriter) configPath(target Target) (string, error) {
home, err := agenthome.Resolve(target.Owner)
if err != nil {
return "", err
}
- return filepath.Join(home, ".hermes", "config.yaml"), nil
+ return hermes.ConfigPath(home), nil
}
diff --git a/internal/hermes/home.go b/internal/hermes/home.go
new file mode 100644
index 0000000..5d61b91
--- /dev/null
+++ b/internal/hermes/home.go
@@ -0,0 +1,65 @@
+// 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 hermes
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+// Home is the directory Hermes keeps config.yaml, its plugins and its
+// credentials in, for the user whose home directory is userHome. Windows
+// installs live under %LOCALAPPDATA%\hermes, every other platform under
+// ~/.hermes. HERMES_HOME and a relocated LOCALAPPDATA come from this process's
+// environment, so they only speak for the account Gateway runs as.
+func Home(userHome string) string {
+ current := isProcessHome(userHome)
+ home := filepath.Join(userHome, ".hermes")
+ if runtime.GOOS == "windows" {
+ home = filepath.Join(userHome, "AppData", "Local", "hermes")
+ if local := os.Getenv("LOCALAPPDATA"); local != "" && current {
+ home = filepath.Join(local, "hermes")
+ }
+ }
+ if configured := os.Getenv("HERMES_HOME"); configured != "" && current {
+ home = configured
+ }
+ return home
+}
+
+// ConfigPath is the config.yaml Hermes reads its settings from.
+func ConfigPath(userHome string) string {
+ return filepath.Join(Home(userHome), "config.yaml")
+}
+
+func isProcessHome(home string) bool {
+ current, err := os.UserHomeDir()
+ if err != nil {
+ return false
+ }
+ left, right := filepath.Clean(home), filepath.Clean(current)
+ if resolved, err := filepath.EvalSymlinks(left); err == nil {
+ left = resolved
+ }
+ if resolved, err := filepath.EvalSymlinks(right); err == nil {
+ right = resolved
+ }
+ if runtime.GOOS == "windows" {
+ return strings.EqualFold(left, right)
+ }
+ return left == right
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 01/02: feat(sessions): read Cursor's chats from its state database
Posted by hs...@apache.org.
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
commit 896d8b88398babf43eb5de3d0451c3deb845cb57
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 12:05:29 2026 +0800
feat(sessions): read Cursor's chats from its state database
---
README.md | 2 +-
README_zh.md | 2 +-
agenthistory/cursor.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++
agenthistory/history.go | 1 +
4 files changed, 147 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index de12238..52872ac 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,7 @@
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew |
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT Desktop (Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — |
| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ | npm |
-| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | — | brew |
+| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew |
| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm |
diff --git a/README_zh.md b/README_zh.md
index b8e845a..ff47ae6 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -92,7 +92,7 @@
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew |
| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT 桌面版(Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — |
| <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ | npm |
-| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | — | brew |
+| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew |
| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew |
| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm |
diff --git a/agenthistory/cursor.go b/agenthistory/cursor.go
new file mode 100644
index 0000000..4062a9b
--- /dev/null
+++ b/agenthistory/cursor.go
@@ -0,0 +1,144 @@
+// 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 agenthistory
+
+import (
+ "database/sql"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+
+ _ "modernc.org/sqlite" // read another agent's session database
+)
+
+const cursorAgent = "cursor"
+
+// scanCursor reads the chats Cursor keeps in the editor's own state database.
+// A chat is a "composer" there: one row of metadata, and one row per message
+// under a key of its own.
+func scanCursor(home string) []Session {
+ path := cursorStatePath(home)
+ if path == "" {
+ return nil
+ }
+
+ db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+ if err != nil {
+ return nil
+ }
+ defer db.Close()
+
+ // The rows are keyed rather than columned, so the ordering and the filter
+ // read the JSON: there are a few hundred chats beside the tens of thousands
+ // of message rows the key prefix keeps this off. Empty chats are dropped
+ // here rather than after reading, so the limit counts sessions somebody had
+ // rather than boxes they opened.
+ rows, err := db.Query(`SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'
+ AND json_array_length(json_extract(value, '$.fullConversationHeadersOnly')) > 0
+ ORDER BY COALESCE(json_extract(value, '$.lastUpdatedAt'), json_extract(value, '$.createdAt')) DESC
+ LIMIT ?`, maxTranscripts)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ sessions := []Session{}
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return sessions
+ }
+ if session, ok := parseCursorComposer(key, value, path); ok {
+ sessions = append(sessions, session)
+ }
+ }
+ return sessions
+}
+
+// cursorComposer is one chat as the editor stores it. The messages themselves
+// are rows of their own; the headers here are what says how many there are and
+// who sent each.
+type cursorComposer struct {
+ ComposerId string `json:"composerId"`
+ Name string `json:"name"`
+ CreatedAt int64 `json:"createdAt"`
+ LastUpdatedAt int64 `json:"lastUpdatedAt"`
+ Headers []struct {
+ BubbleId string `json:"bubbleId"`
+ } `json:"fullConversationHeadersOnly"`
+}
+
+func parseCursorComposer(key, value, path string) (Session, bool) {
+ var composer cursorComposer
+ if err := json.Unmarshal([]byte(value), &composer); err != nil {
+ return Session{}, false
+ }
+ // A composer with no messages is a chat box that was opened and left, of
+ // which the editor keeps a great many.
+ if len(composer.Headers) == 0 {
+ return Session{}, false
+ }
+
+ id := composer.ComposerId
+ if id == "" {
+ id = key[len("composerData:"):]
+ }
+ session := Session{
+ Agent: cursorAgent,
+ SessionKey: id,
+ Title: trimTitle(composer.Name),
+ RecordCount: len(composer.Headers),
+ FirstTime: cursorTime(composer.CreatedAt),
+ LastTime: cursorTime(composer.LastUpdatedAt),
+ Path: path,
+ Historical: true,
+ }
+ if session.LastTime == "" {
+ session.LastTime = session.FirstTime
+ }
+ if session.FirstTime == "" {
+ session.FirstTime = session.LastTime
+ }
+ // Cursor bills its own subscription and records no token counts to read:
+ // the message rows carry the fields, always at zero. A session with no
+ // usage is listed for what it was, without inventing an amount for it.
+ return session, session.SessionKey != ""
+}
+
+// cursorStatePath is the editor's global state database, in the application
+// directory each platform keeps it under.
+func cursorStatePath(home string) string {
+ candidates := [][]string{
+ {"AppData", "Roaming", "Cursor", "User", "globalStorage", "state.vscdb"},
+ {"Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"},
+ {".config", "Cursor", "User", "globalStorage", "state.vscdb"},
+ }
+ for _, parts := range candidates {
+ path := filepath.Join(append([]string{home}, parts...)...)
+ if info, err := os.Stat(path); err == nil && !info.IsDir() {
+ return path
+ }
+ }
+ return ""
+}
+
+// cursorTime reads the epoch milliseconds the editor stores.
+func cursorTime(value int64) string {
+ if value <= 0 {
+ return ""
+ }
+ return time.UnixMilli(value).Local().Format(time.RFC3339)
+}
diff --git a/agenthistory/history.go b/agenthistory/history.go
index e0c30e9..942a978 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -146,6 +146,7 @@ func scan(home string) []Session {
// compressed one its own reader has to open.
sessions = append(sessions, scanOpencode(home)...)
sessions = append(sessions, scanDsh(home)...)
+ sessions = append(sessions, scanCursor(home)...)
sort.SliceStable(sessions, func(left, right int) bool {
return sessions[left].LastTime > sessions[right].LastTime
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org