sessions: Droid's (Factory CLI) sessions are listed, with what they spent and droid --resume <id> (Discord, 谟竭: "magpie中会话功能中不显示droid 这个agent") — the Sessions view read no droid sessions at all. droid keeps each under ~/.factory/sessions (or $FACTORY_HOME_OVERRIDE/.factory), a folder per cwd (older ones in sessions/ itself): <id>.jsonl starts with a session_start (id, title, cwd/lastCwd, parent + forkedAtMessageId for a fork, callingSessionId for a subagent or /btw), then message events with an ISO timestamp and Anthropic-style content; <id>.settings.json holds the running tokenUsage (inputTokens without the cache, outputTokens with thinking) for its model. The title is the first typed prompt (tagged reminders skipped), else droid's; prompts, replies and tool calls count; a subagent's spend counts in the session that called it, a fork skips the lines it copied; custom:<id> models are priced as the model their customModels entry asks for (magpie's own custom:magpie/<ref> → <ref>), else the id without droid's -[x]-N tail. The total isn't split by turn, so it counts on the day of the last message. Format read from droid 0.231.0's own code. TestDroid fails on the old code (0 sessions) and passes; go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried against a real droid session (no Factory account on this machine).
This commit is contained in:
1 parent
154c7e8d96
commit
b84b203256
3 files changed
+376
-5
No files matched your search
@@ -0,0 +1,256 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// Factory's Droid keeps a session under its sessions folder
|
||||
// (~/.factory/sessions, or $FACTORY_HOME_OVERRIDE/.factory/sessions), in a
|
||||
// folder per working directory (older ones right in it): <id>.jsonl, the
|
||||
// transcript, and <id>.settings.json beside it. The transcript's first line
|
||||
// is a session_start event — the id, droid's title for it, the cwd, the
|
||||
// session it was forked from (parent, with forkedAtMessageId the last
|
||||
// message it copied) and, for a subagent's or a /btw side question's, the
|
||||
// session that called it (callingSessionId). Each line after is an event,
|
||||
// a "message" one carrying its time (ISO) and the message, role and content
|
||||
// blocks as Anthropic's. What the session spent is in its settings, one
|
||||
// running total of its own (tokenUsage: input without the cache, output
|
||||
// with the thinking in it) for the model it is on (model); a fork starts at
|
||||
// none, and a subagent's counts in the session that called it. The total
|
||||
// isn't split by turn, so it counts on the day of the last message.
|
||||
// `droid --resume <id>` picks one up again.
|
||||
|
||||
// FactoryDir is Droid's folder: $FACTORY_HOME_OVERRIDE/.factory, else
|
||||
// ~/.factory.
|
||||
func FactoryDir() string {
|
||||
home := strings.TrimSpace(os.Getenv("FACTORY_HOME_OVERRIDE"))
|
||||
if home == "" {
|
||||
home, _ = os.UserHomeDir()
|
||||
} else {
|
||||
home = expandHome(home)
|
||||
}
|
||||
return filepath.Join(home, ".factory")
|
||||
}
|
||||
|
||||
type droidStart struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cwd string `json:"cwd"`
|
||||
LastCwd string `json:"lastCwd"`
|
||||
Parent string `json:"parent"`
|
||||
Calling string `json:"callingSessionId"`
|
||||
Forked string `json:"forkedAtMessageId"`
|
||||
}
|
||||
|
||||
// readDroidStart is a transcript's session_start line.
|
||||
func readDroidStart(path string) (droidStart, bool) {
|
||||
var s droidStart
|
||||
ok := false
|
||||
scanAt(path, 0, nil, func(b []byte, _, _ int64) bool {
|
||||
ok = json.Unmarshal(b, &s) == nil && s.Type == "session_start"
|
||||
return false
|
||||
})
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func droidFiles() []file {
|
||||
root := filepath.Join(FactoryDir(), "sessions")
|
||||
a, _ := filepath.Glob(filepath.Join(root, "*.jsonl"))
|
||||
b, _ := filepath.Glob(filepath.Join(root, "*", "*.jsonl"))
|
||||
var out []file
|
||||
for _, p := range append(a, b...) {
|
||||
id := strings.TrimSuffix(filepath.Base(p), ".jsonl")
|
||||
if !safeID.MatchString(id) {
|
||||
continue
|
||||
}
|
||||
f := file{agent: "droid", key: "droid:" + id, path: p, main: true}
|
||||
if !stat(&f) {
|
||||
continue
|
||||
}
|
||||
// what it spent and on which model are in its settings
|
||||
m := file{path: strings.TrimSuffix(p, ".jsonl") + ".settings.json"}
|
||||
if stat(&m) {
|
||||
f.manifest = m.path
|
||||
f.size += m.size
|
||||
if m.mod.After(f.mod) {
|
||||
f.mod = m.mod
|
||||
}
|
||||
}
|
||||
if s, ok := readDroidStart(p); ok && s.Calling != "" && s.Calling != id && safeID.MatchString(s.Calling) {
|
||||
f.key, f.main = "droid:"+s.Calling, false
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type droidUsage struct {
|
||||
Input int `json:"inputTokens"`
|
||||
Output int `json:"outputTokens"`
|
||||
CacheWrite int `json:"cacheCreationTokens"`
|
||||
CacheRead int `json:"cacheReadTokens"`
|
||||
}
|
||||
|
||||
type droidSettings struct {
|
||||
Model string `json:"model"`
|
||||
Usage *droidUsage `json:"tokenUsage"`
|
||||
}
|
||||
|
||||
type droidLine struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
// droidText is a message's text: its content as a string, or its text
|
||||
// blocks.
|
||||
func droidText(raw json.RawMessage) string {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
json.Unmarshal(raw, &blocks)
|
||||
var parts []string
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" && strings.TrimSpace(b.Text) != "" {
|
||||
parts = append(parts, b.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// droidTools are a message's tool calls, by name.
|
||||
func droidTools(raw json.RawMessage) []string {
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal(raw, &blocks)
|
||||
var out []string
|
||||
for _, b := range blocks {
|
||||
if b.Type == "tool_use" && b.Name != "" {
|
||||
out = append(out, b.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseDroid reads a session's transcript whole, with its settings.
|
||||
func parseDroid(f file) *state {
|
||||
s := &state{Size: f.size, Mod: f.mod.UnixNano()}
|
||||
// a fork's lines up to upto are copies of its source's
|
||||
upto, copied := "", false
|
||||
first := true
|
||||
scan(f.path, 0, func(b []byte) {
|
||||
var l droidLine
|
||||
if json.Unmarshal(b, &l) != nil {
|
||||
return
|
||||
}
|
||||
if first {
|
||||
first = false
|
||||
var st droidStart
|
||||
if l.Type == "session_start" && json.Unmarshal(b, &st) == nil {
|
||||
s.ID, s.Cwd, s.Named = st.ID, st.Cwd, title(st.Title)
|
||||
if st.LastCwd != "" {
|
||||
s.Cwd = st.LastCwd
|
||||
}
|
||||
upto, copied = st.Forked, st.Forked != ""
|
||||
}
|
||||
return
|
||||
}
|
||||
if l.Type != "message" {
|
||||
return
|
||||
}
|
||||
if copied {
|
||||
if l.ID == upto {
|
||||
copied = false
|
||||
}
|
||||
return
|
||||
}
|
||||
at := tsAt(b, false)
|
||||
s.saw(at, f.main)
|
||||
switch l.Message.Role {
|
||||
case "user":
|
||||
text := droidText(l.Message.Content)
|
||||
if strings.TrimSpace(text) == "" || !f.main {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(text), "<") {
|
||||
if s.First == "" {
|
||||
s.First = untagged(text)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.day(dateOf(at)).Prompts++
|
||||
if s.Title == "" {
|
||||
s.Title = title(text)
|
||||
}
|
||||
case "assistant":
|
||||
if f.main {
|
||||
s.day(dateOf(at)).Replies++
|
||||
}
|
||||
for _, name := range droidTools(l.Message.Content) {
|
||||
s.tool(at, name, "")
|
||||
}
|
||||
}
|
||||
})
|
||||
if f.manifest != "" {
|
||||
var st droidSettings
|
||||
if b, err := os.ReadFile(f.manifest); err == nil && json.Unmarshal(b, &st) == nil && st.Usage != nil {
|
||||
when := s.Last
|
||||
if when.IsZero() {
|
||||
when = time.Unix(0, s.Mod)
|
||||
}
|
||||
u := st.Usage
|
||||
s.use(dateOf(when), droidModelName(st.Model), Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite})
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// droidSlot is the tail droid gives a custom model's id made of its name
|
||||
// ("custom:Kimi-K2-[Groq]-0").
|
||||
var droidSlot = regexp.MustCompile(`(-\[[^\]]*\])?-\d+$`)
|
||||
|
||||
// droidModelName is a session's model as it is priced: a custom one
|
||||
// ("custom:<id>") is the model its entry in droid's settings asks for —
|
||||
// magpie's own are "<provider>/<model>" — else its id without droid's
|
||||
// additions; Factory's own are as they are.
|
||||
func droidModelName(m string) string {
|
||||
if m == "" {
|
||||
return "droid"
|
||||
}
|
||||
if !strings.HasPrefix(m, "custom:") {
|
||||
return m
|
||||
}
|
||||
if raw, err := os.ReadFile(filepath.Join(FactoryDir(), "settings.json")); err == nil {
|
||||
of := ""
|
||||
gjson.GetBytes(jsonc.ToJSONInPlace(raw), "customModels").ForEach(func(_, v gjson.Result) bool {
|
||||
if v.Get("id").String() == m {
|
||||
of = v.Get("model").String()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if of != "" {
|
||||
return of
|
||||
}
|
||||
}
|
||||
return droidSlot.ReplaceAllString(strings.TrimPrefix(m, "custom:"), "")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
droidMain = "d0000000-0000-4000-8000-00000000000a"
|
||||
droidSub = "d0000000-0000-4000-8000-00000000000b"
|
||||
droidFork = "d0000000-0000-4000-8000-00000000000c"
|
||||
droidOld = "d0000000-0000-4000-8000-00000000000d"
|
||||
)
|
||||
|
||||
// putDroid writes Droid's sessions under home/.factory as droid keeps
|
||||
// them: a folder per cwd, an older one right in sessions/, each
|
||||
// transcript's settings beside it.
|
||||
func putDroid(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(home, ".factory")
|
||||
write := func(rel string, lines ...string) {
|
||||
p := filepath.Join(root, rel)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// magpie's own custom model, as the agent writes it
|
||||
write("settings.json", `{"customModels":[{"model":"anthropic/claude-sonnet-4-5","id":"custom:magpie/anthropic/claude-sonnet-4-5","provider":"anthropic"}]}`)
|
||||
start := `{"type":"session_start","id":"` + droidMain + `","title":"Login bug","owner":"me","version":2,"cwd":"/work/droid"}`
|
||||
m1 := `{"type":"message","id":"m1","timestamp":"2026-09-27T08:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"<system-reminder>house rules</system-reminder>"}]}}`
|
||||
m2 := `{"type":"message","id":"m2","timestamp":"2026-09-27T08:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"Fix the login bug"}]}}`
|
||||
m3 := `{"type":"message","id":"m3","timestamp":"2026-09-27T08:01:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading"},{"type":"tool_use","id":"t1","name":"Read","input":{}}]}}`
|
||||
m4 := `{"type":"message","id":"m4","timestamp":"2026-09-27T08:02:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}`
|
||||
write("sessions/-work-droid/"+droidMain+".jsonl", start, m1, m2, m3, m4)
|
||||
write("sessions/-work-droid/"+droidMain+".settings.json", `{"model":"custom:magpie/anthropic/claude-sonnet-4-5","tokenUsage":{"inputTokens":100,"outputTokens":50,"cacheCreationTokens":10,"cacheReadTokens":1000,"thinkingTokens":20}}`)
|
||||
// a subagent it called: its spend is the session's
|
||||
write("sessions/-work-droid/"+droidSub+".jsonl",
|
||||
`{"type":"session_start","id":"`+droidSub+`","title":"Explore","owner":"me","cwd":"/work/droid","callingSessionId":"`+droidMain+`"}`,
|
||||
`{"type":"message","id":"s1","timestamp":"2026-09-27T08:01:10.000Z","message":{"role":"user","content":"Look around"}}`)
|
||||
write("sessions/-work-droid/"+droidSub+".settings.json", `{"model":"claude-haiku-4-5","tokenUsage":{"inputTokens":5,"outputTokens":2,"cacheCreationTokens":0,"cacheReadTokens":0,"thinkingTokens":0}}`)
|
||||
// a fork: the lines it copied are its source's
|
||||
write("sessions/-work-droid/"+droidFork+".jsonl",
|
||||
`{"type":"session_start","id":"`+droidFork+`","title":"Login bug","owner":"me","cwd":"/work/droid","parent":"`+droidMain+`","forkedAtMessageId":"m4"}`,
|
||||
m1, m2, m3, m4,
|
||||
`{"type":"message","id":"f1","timestamp":"2026-09-27T09:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"Try another way"}]}}`,
|
||||
`{"type":"message","id":"f2","timestamp":"2026-09-27T09:00:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Sure"}]}}`)
|
||||
write("sessions/-work-droid/"+droidFork+".settings.json", `{"model":"custom:Kimi-K2-[Groq]-0","tokenUsage":{"inputTokens":7,"outputTokens":3,"cacheCreationTokens":0,"cacheReadTokens":0,"thinkingTokens":0}}`)
|
||||
// an older one, in sessions/ itself, with no settings
|
||||
write("sessions/"+droidOld+".jsonl",
|
||||
`{"type":"session_start","id":"`+droidOld+`","title":"New Session","owner":"me"}`,
|
||||
`{"type":"message","id":"o1","timestamp":"2026-09-20T10:00:00.000Z","message":{"role":"user","content":"hi there"}}`)
|
||||
}
|
||||
|
||||
func TestDroid(t *testing.T) {
|
||||
inZone(t, 0)
|
||||
home := setupAgents(t)
|
||||
t.Setenv("FACTORY_HOME_OVERRIDE", "")
|
||||
putDroid(t, home)
|
||||
Reset()
|
||||
ss := List(0)
|
||||
if n := count(ss, "droid"); n != 3 {
|
||||
t.Fatalf("want 3 Droid sessions (the subagent in its session), got %d", n)
|
||||
}
|
||||
d := find(t, ss, "droid", droidMain)
|
||||
if d.Cwd != "/work/droid" || d.Title != "Fix the login bug" {
|
||||
t.Fatalf("droid: %+v", d)
|
||||
}
|
||||
// magpie's custom model by the model it asks for, the subagent's in
|
||||
if m := model(d, "anthropic/claude-sonnet-4-5"); m.Tokens != (Tokens{100, 50, 1000, 10}) {
|
||||
t.Fatalf("sonnet: %+v", d.Models)
|
||||
}
|
||||
if m := model(d, "claude-haiku-4-5"); m.Tokens != (Tokens{5, 2, 0, 0}) {
|
||||
t.Fatalf("haiku, the subagent's in: %+v", d.Models)
|
||||
}
|
||||
if !d.Start.Equal(at("2026-09-27T08:00:00Z")) || !d.Last.Equal(at("2026-09-27T08:02:00Z")) {
|
||||
t.Fatalf("droid times %s %s", d.Start, d.Last)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if want := "cd '/work/droid' && droid --resume " + droidMain; d.Resume != want {
|
||||
t.Fatalf("resume %q", d.Resume)
|
||||
}
|
||||
}
|
||||
// the fork: what it copied is its source's; a custom model of the
|
||||
// user's own by its name
|
||||
f := find(t, ss, "droid", droidFork)
|
||||
if f.Title != "Try another way" || !f.Start.Equal(at("2026-09-27T09:00:00Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) {
|
||||
t.Fatalf("fork: %+v", f)
|
||||
}
|
||||
if m := model(f, "Kimi-K2"); m.Tokens != (Tokens{7, 3, 0, 0}) {
|
||||
t.Fatalf("fork models: %+v", f.Models)
|
||||
}
|
||||
if o := find(t, ss, "droid", droidOld); o.Title != "hi there" || !o.Tokens.zero() {
|
||||
t.Fatalf("old: %+v", o)
|
||||
}
|
||||
|
||||
// $FACTORY_HOME_OVERRIDE
|
||||
moved := filepath.Join(filepath.Dir(home), "factory-home")
|
||||
os.MkdirAll(moved, 0o755)
|
||||
if err := os.Rename(filepath.Join(home, ".factory"), filepath.Join(moved, ".factory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("FACTORY_HOME_OVERRIDE", moved)
|
||||
Reset()
|
||||
find(t, List(0), "droid", droidMain)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// files — Claude Code's projects/*/<id>.jsonl (and Qoder's, the same kind),
|
||||
// Codex's rollout files, OpenCode's database (or its older JSON files) and
|
||||
// ZCode's, Pi's session files and omp's, DeepSeek Harness's, Cline's, Grok
|
||||
// Build's, WorkBuddy's and Cursor CLI's chat stores — with the tokens each spent, what that cost at the
|
||||
// Build's, WorkBuddy's, Droid's and Cursor CLI's chat stores — with the tokens each spent, what that cost at the
|
||||
// effective price, and the command that resumes it. It only ever reads the
|
||||
// agents' folders.
|
||||
//
|
||||
@@ -70,7 +70,7 @@ type Model struct {
|
||||
// Session is one agent session.
|
||||
type Session struct {
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, cursor, hermes
|
||||
Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, droid, cursor, hermes
|
||||
ID string `json:"id"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title"` // the first prompt, else the agent's own title
|
||||
@@ -291,7 +291,8 @@ type file struct {
|
||||
sid string
|
||||
oc ocStore
|
||||
// Cline: the session's manifest, beside its messages; Grok Build: its
|
||||
// summary.json, beside its updates
|
||||
// summary.json, beside its updates; Droid: its settings, beside its
|
||||
// transcript
|
||||
manifest string
|
||||
hermes *hermesDB
|
||||
readOnly bool
|
||||
@@ -365,7 +366,7 @@ func allFiles() []file {
|
||||
var out []file
|
||||
for _, fs := range [][]file{callFiles(), openCodeFiles(), piFiles(),
|
||||
zcodeFiles(), dshFiles(), clineFiles(), ccFiles("qoder", QoderDir("qoder")), ccFiles("qoder-cn", QoderDir("qoder-cn")),
|
||||
grokFiles(), workbuddyFiles(), ompFiles(), cursorFiles(), hermesFiles()} {
|
||||
grokFiles(), workbuddyFiles(), droidFiles(), ompFiles(), cursorFiles(), hermesFiles()} {
|
||||
out = append(out, fs...)
|
||||
}
|
||||
return out
|
||||
@@ -386,6 +387,7 @@ func Dirs() []string {
|
||||
{GrokDir(), filepath.Join(GrokDir(), "sessions")},
|
||||
{WorkBuddyDir(), filepath.Join(WorkBuddyDir(), "projects")},
|
||||
{OmpDir(), filepath.Join(OmpDir(), "sessions")},
|
||||
{FactoryDir(), filepath.Join(FactoryDir(), "sessions")},
|
||||
{CursorDir(), filepath.Join(CursorDir(), "chats")},
|
||||
} {
|
||||
if _, err := os.Stat(d.sessions); err == nil {
|
||||
@@ -854,7 +856,7 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) {
|
||||
if first == "" {
|
||||
first = st.First
|
||||
}
|
||||
if (s.Agent == "codex" || s.Agent == "pi" || s.Agent == "omp" || s.Agent == "dsh" || s.Agent == "grok") && st.ID != "" && f.path == fs[0].path {
|
||||
if (s.Agent == "codex" || s.Agent == "pi" || s.Agent == "omp" || s.Agent == "dsh" || s.Agent == "grok" || s.Agent == "droid") && st.ID != "" && f.path == fs[0].path {
|
||||
s.ID = st.ID
|
||||
}
|
||||
}
|
||||
@@ -921,6 +923,8 @@ func parse(f file, old *state) *state {
|
||||
return parseCline(f)
|
||||
case "grok":
|
||||
return parseGrok(f)
|
||||
case "droid":
|
||||
return parseDroid(f)
|
||||
case "cursor":
|
||||
return parseCursor(f)
|
||||
}
|
||||
@@ -1203,6 +1207,8 @@ func ResumeCommand(agent, id, cwd string) string {
|
||||
run = "grok --resume " + id
|
||||
case "omp":
|
||||
run = "omp --resume " + id
|
||||
case "droid":
|
||||
run = "droid --resume " + id
|
||||
case "cursor":
|
||||
// its chats are looked up by the folder it runs in
|
||||
if cwd == "" {
|
||||
|
||||
Reference in new issue
Block a user