From 643264c24f540bbf2d605835fbb117cc0bccfbe9 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 3 Oct 2026 23:44:33 +0800 Subject: [PATCH] =?UTF-8?q?sessions:=20the=20Sessions=20page=20and=20local?= =?UTF-8?q?=20usage=20read=20the=20agents=20in=20WSL=20too,=20and=20resume?= =?UTF-8?q?=20one=20there=20through=20wsl.exe=20(lgtm=20on=20Discord:=20ma?= =?UTF-8?q?gpie=20can't=20detect=20the=20session=20lists=20of=20agents=20i?= =?UTF-8?q?nside=20WSL=3F)=20=E2=80=94=20on=20Windows=20magpie=20already?= =?UTF-8?q?=20finds=20the=20WSL=20distros=20an=20agent=20is=20set=20up=20i?= =?UTF-8?q?n=20(internal/agent's=20wslDistros,=20the=20distro=20user's=20h?= =?UTF-8?q?ome=20as=20\\wsl.localhost\\home\),=20but=20the?= =?UTF-8?q?=20session=20reader=20only=20looked=20at=20this=20computer's=20?= =?UTF-8?q?own=20~/.claude,=20~/.codex=20and=20~/.pi.=20Now=20internal/age?= =?UTF-8?q?nt=20hands=20those=20homes=20to=20the=20sessions=20package=20(s?= =?UTF-8?q?essions.WSLHomes,=20set=20in=20init,=20no=20second=20discovery)?= =?UTF-8?q?,=20and=20the=20sessions=20of=20Claude=20Code,=20Codex=20and=20?= =?UTF-8?q?Pi=20in=20each=20distro=20are=20listed=20beside=20this=20comput?= =?UTF-8?q?er's,=20with=20Session.WSL=20naming=20the=20distro:=20a=20"WSL?= =?UTF-8?q?=20"=20badge=20on=20the=20Sessions=20page's=20rows=20an?= =?UTF-8?q?d=20the=20management=20page,=20a=20WSL=20line=20in=20the=20deta?= =?UTF-8?q?ils,=20"=20=C2=B7=20WSL=20"=20in=20the=20API's=20name.?= =?UTF-8?q?=20Resume=20runs=20in=20the=20distro:=20wsl.exe=20-d=20''=20--cd=20''=20-e=20sh=20-lc=20'exec=20${SHELL:-sh}=20-l?= =?UTF-8?q?ic=20''claude=20--resume=20'''=20(the=20user's=20own=20shel?= =?UTF-8?q?l,=20so=20the=20agent's=20PATH=20is=20there;=20no=20double=20qu?= =?UTF-8?q?otes,=20which=20PowerShell=205.1=20mangles).=20Their=20calls=20?= =?UTF-8?q?count=20in=20local=20usage=20(callFiles).=20Reading=20a=20distr?= =?UTF-8?q?o=20over=20\\wsl.localhost=20is=20slow=20and=20opening=20a=20fi?= =?UTF-8?q?le=20in=20a=20stopped=20distro=20starts=20it,=20so=20the=20file?= =?UTF-8?q?s=20are=20listed=20in=20the=20background,=20at=20most=20every?= =?UTF-8?q?=2030=20s,=20the=20listing=20kept=20in=20sessions-wsl.json;=20t?= =?UTF-8?q?he=20page=20waits=20for=20it=20only=20the=20first=20time=20ever?= =?UTF-8?q?=20(up=20to=203=20s);=20a=20stopped=20distro's=20sessions=20sta?= =?UTF-8?q?y=20listed=20as=20last=20read=20and=20nothing=20in=20it=20is=20?= =?UTF-8?q?opened=20(the=20parse=20cache=20and=20call=20shards=20keep=20th?= =?UTF-8?q?em).=20magpie=20doesn't=20delete=20a=20session=20in=20WSL:=20th?= =?UTF-8?q?e=20checkbox=20and=20Delete=20are=20off=20for=20them,=20with=20?= =?UTF-8?q?a=20note=20to=20delete=20them=20there,=20and=20sessions.Delete?= =?UTF-8?q?=20refuses.=20OpenCode=20in=20WSL=20isn't=20read=20(its=20SQLit?= =?UTF-8?q?e=20over=20\\wsl.localhost=20isn't=20reliable).=20zh=20strings?= =?UTF-8?q?=20added.=20TestWSLSessionsListed,=20TestWSLStoppedDistroKept?= =?UTF-8?q?=20and=20TestWSLNoneWithoutHomes=20(internal/sessions)=20and=20?= =?UTF-8?q?TestWSLSessionsOfTheDistro=20(internal/agent,=20FakeWSL:=20a=20?= =?UTF-8?q?Claude=20Code=20session=20in=20a=20fake=20Ubuntu=20is=20listed?= =?UTF-8?q?=20with=20its=20distro=20and=20the=20wsl.exe=20resume=20line)?= =?UTF-8?q?=20fail=20on=20the=20old=20code=20and=20pass.=20go=20vet,=20bui?= =?UTF-8?q?lds=20(darwin,=20GOOS=3Dlinux,=20GOOS=3Dwindows)=20and=20go=20t?= =?UTF-8?q?est=20-tags=20nogui=20-count=3D1=20./...=20under=20a=20temp=20H?= =?UTF-8?q?OME=20pass.=20Not=20tried=20on=20a=20real=20WSL=20with=20agent?= =?UTF-8?q?=20sessions:=20the=20Windows=20test=20box's=20distros=20have=20?= =?UTF-8?q?no=20Claude=20Code,=20Codex=20or=20Pi=20sessions;=20the=20resum?= =?UTF-8?q?e=20line=20wasn't=20run=20in=20PowerShell,=20and=20the=20pages?= =?UTF-8?q?=20weren't=20opened=20in=20a=20browser.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agents.go | 3 + internal/agent/wsl.go | 18 ++ internal/agent/wsl_sessions_test.go | 51 +++++ internal/gui/assets/app.css | 1 + internal/gui/assets/app.js | 10 + internal/gui/assets/i18n.js | 2 + internal/gui/assets/sessions.js | 16 +- internal/gui/sessions.go | 11 ++ internal/sessions/call_cache.go | 7 + internal/sessions/calls.go | 1 + internal/sessions/manage.go | 9 +- internal/sessions/pi.go | 10 +- internal/sessions/sessions.go | 35 +++- internal/sessions/wsl.go | 291 ++++++++++++++++++++++++++++ internal/sessions/wsl_test.go | 140 +++++++++++++ 15 files changed, 589 insertions(+), 16 deletions(-) create mode 100644 internal/agent/wsl_sessions_test.go create mode 100644 internal/sessions/wsl.go create mode 100644 internal/sessions/wsl_test.go diff --git a/internal/agent/agents.go b/internal/agent/agents.go index edbf3f4e..65e3d98a 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -15,6 +15,7 @@ import ( "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/sessions" "github.com/yetone/magpie/internal/usage" ) @@ -27,6 +28,8 @@ func init() { } return out } + // the Sessions page reads the sessions of the agents in WSL distros + sessions.WSLHomes = wslHomes } // others are clients that reach the gateway without being agents magpie diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index 7da24348..4ed6a30f 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -20,6 +20,7 @@ import ( "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/proc" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/sessions" ) // An agent installed in a WSL distro reads its config there, not in @@ -529,6 +530,23 @@ func wslAgentsOf(ds []distro) []*Agent { return out } +// wslHomes are the distros agents were found in, each user's home as magpie +// opens it, for internal/sessions to read their sessions in; none off +// Windows. A stopped distro's is its home as last probed. +func wslHomes() []sessions.WSLHome { + if !wslOn { + return nil + } + var out []sessions.WSLHome + for _, d := range wslDistros() { + if d.Root == "" || d.Home == "" { + continue + } + out = append(out, sessions.WSLHome{Distro: d.Name, Home: d.local(d.Home), Running: d.Running}) + } + return out +} + // Only running distros are probed — asking one anything starts it — once // each in a process, and again only after a probe that failed. Those an // agent of wslKinds was found in are kept in wsl.json beside magpie's settings (not the diff --git a/internal/agent/wsl_sessions_test.go b/internal/agent/wsl_sessions_test.go new file mode 100644 index 00000000..1622e086 --- /dev/null +++ b/internal/agent/wsl_sessions_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/sessions" +) + +// The Sessions page reads the sessions of Claude Code in a WSL distro magpie +// found it in (lgtm on Discord: magpie can't see the session lists of agents +// inside WSL), at the distro user's home as the agent is set up there, and +// resumes one through wsl.exe in the distro. +func TestWSLSessionsOfTheDistro(t *testing.T) { + syncHome(t) + root := t.TempDir() + home := filepath.Join(root, "home", "me") + id := "33333333-2222-3333-4444-555555555555" + project := filepath.Join(home, ".claude", "projects", "-home-me-app") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + lines := `{"type":"user","message":{"role":"user","content":"Tidy the README"},"uuid":"u1","timestamp":"2026-09-20T10:00:01.000Z","cwd":"/home/me/app","sessionId":"` + id + `"} +{"parentUuid":"u1","message":{"model":"claude-opus-5-5","id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"Done."}],"usage":{"input_tokens":10,"output_tokens":5}},"requestId":"req_1","type":"assistant","uuid":"a1","timestamp":"2026-09-20T10:00:05.000Z","cwd":"/home/me/app","sessionId":"` + id + `"} +` + if err := os.WriteFile(filepath.Join(project, id+".jsonl"), []byte(lines), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(FakeWSL(map[string]string{"Ubuntu": "home:/home/me\ndir:.claude\n"}, map[string]string{"Ubuntu": root})) + sessions.Reset() + t.Cleanup(sessions.Reset) + + var got *sessions.Session + for _, s := range sessions.List(0) { + if s.ID == id { + got = &s + } + } + if got == nil { + t.Fatal("the distro's Claude Code session isn't listed") + } + if got.WSL != "Ubuntu" || got.Agent != "claude" || got.Cwd != "/home/me/app" || got.Title != "Tidy the README" || got.Output != 5 { + t.Fatalf("%+v", *got) + } + if !strings.HasPrefix(got.Resume, "wsl.exe -d 'Ubuntu' --cd '/home/me/app' ") || !strings.Contains(got.Resume, "''claude --resume "+id+"''") { + t.Fatalf("resume %q", got.Resume) + } + noOwnClaude(t) +} diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 5410ffeb..8966c484 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1608,6 +1608,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .badge.free { background: var(--green-soft); color: var(--green); } /* a model's context, when it is a large one: grey, as it is a fact, not a warning */ .badge.ctx { background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); letter-spacing: .02em; } +.badge.wsl { background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); letter-spacing: .02em; text-transform: none; } /* a model's credit rate (0.5×): grey, a fact to pick by; a discount's price before it struck through */ .badge.rate { background: var(--pill); color: var(--muted); letter-spacing: .02em; text-transform: none; font-variant-numeric: tabular-nums; display: inline-flex; gap: 3px; } .badge.rate s { font-weight: 500; opacity: .75; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 9f03dcb6..22bf4759 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -12710,6 +12710,14 @@ function sessBars(box, title, items, cur, choose, name) { box.append(body); } +// wslBadge marks a session an agent ran in a WSL distro, read through +// \\wsl.localhost; its resume command runs it there through wsl.exe +function wslBadge(s) { + const b = el("span", "badge wsl", "WSL " + s.wsl); + b.title = t("In WSL {distro}: resumed there through wsl.exe", { distro: s.wsl }); + return b; +} + function sessionItem(s) { const key = sessKey(s); const item = el("div", "sess-item" + (sessOpen.has(key) ? " open" : "")); @@ -12722,6 +12730,7 @@ function sessionItem(s) { const via = s.via?.length ? "→ " + [s.via[0].model, s.via[0].effort].filter(Boolean).join(" · ") + (s.via.length > 1 ? " +" + (s.via.length - 1) : "") : ""; const sub = el("div", "sub", [s.cwd ? baseName(s.cwd) : "", s.models.slice(0, 2).map((m) => m.model).join(", ") + (s.models.length > 2 ? " +" + (s.models.length - 2) : "") + (via ? " " + via : ""), ago(s.last)].filter(Boolean).join(" · ")); sub.title = [s.cwd, ...(s.via || []).map(viaText)].filter(Boolean).join("\n"); + if (s.wsl) sub.prepend(wslBadge(s), " "); who.append(sub); r.append(who); const num = el("div", "num"); @@ -12776,6 +12785,7 @@ function sessionDetail(s) { d.append(l); }; line(t("Time"), stamp(s.start) + " – " + stamp(s.last)); + if (s.wsl) line("WSL", s.wsl); if (s.cwd) line(t("Folder"), s.cwd); line(t("Session ID"), s.id, copyBtn(s.id, t("Session id"))); if (s.resume) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 8b051d94..a16934eb 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1381,6 +1381,8 @@ const I18N = { "magpie can list {agent}'s sessions, but cannot resume or delete them.": "magpie 可以列出 {agent} 的会话,但不能继续或删除。", "These {agent} sessions are read only; magpie can list them, but cannot resume or delete them.": "这些 {agent} 会话为只读;magpie 可以列出,但不能继续或删除。", "Some {agent} sessions are read only and cannot be deleted.": "部分 {agent} 会话为只读,无法删除。", + "Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL.": "WSL 中的会话可以恢复,但不能在 magpie 里删除:请在 WSL 中删除。", + "In WSL {distro}: resumed there through wsl.exe": "在 WSL {distro} 中:通过 wsl.exe 在那里恢复", "Select every session shown": "选中所有显示的会话", "Select": "选择", "Delete": "删除", diff --git a/internal/gui/assets/sessions.js b/internal/gui/assets/sessions.js index d1ccf05d..ebf27cba 100644 --- a/internal/gui/assets/sessions.js +++ b/internal/gui/assets/sessions.js @@ -175,6 +175,9 @@ : "magpie can list {agent}'s sessions, but cannot resume or delete them."; box.append(el("p", "usage-note sm-note", t(key, { agent: a.name }))); } + if (a?.deletable && data.sessions.some((s) => s.wsl)) { + box.append(el("p", "usage-note sm-note", t("Sessions in WSL can be resumed, but not deleted from magpie: delete them in WSL."))); + } box.append(selectBar(), el("div", "sm-tree")); queueMicrotask(redrawList); return box; @@ -185,7 +188,7 @@ const bar = el("div", "row-head sm-bar"); const a = current(); if (!a?.deletable) { bar.hidden = true; return bar; } - const list = shown().filter((s) => !s.read_only); + const list = shown().filter((s) => !s.read_only && !s.wsl); const all = el("input", "sm-check"); all.type = "checkbox"; all.checked = list.length > 0 && list.every((s) => picked.has(s.id)); @@ -245,7 +248,7 @@ const open = !!q || opened.has(cwd); const g = el("div", "list sm-group"); const r = el("div", "row sm-folder"); - const writable = items.filter((s) => !s.read_only); + const writable = items.filter((s) => !s.read_only && !s.wsl); // the folder's box picks every session of it shown, folded or not, // for the bar's Delete; the bar still counts sessions (#527) if (current()?.deletable && writable.length) { @@ -288,7 +291,7 @@ const wrap = el("div", "sess-item sm-item" + (detail === s.id ? " open" : "")); const r = el("div", "row sess sm-sess"); r.dataset.id = s.id; - if (a?.deletable && !s.read_only) { + if (a?.deletable && !s.read_only && !s.wsl) { const c = el("input", "sm-check"); c.type = "checkbox"; c.checked = picked.has(s.id); @@ -299,7 +302,9 @@ } const who = el("div", "who"); who.append(el("div", "name", s.title || t("(no prompt)"))); - who.append(el("div", "sub", [ago(s.last), s.messages ? t(s.messages === 1 ? "{n} message" : "{n} messages", { n: s.messages }) : "", fmtBytes(s.size), s.id.slice(0, 8)].filter(Boolean).join(" · "))); + const sub = el("div", "sub", [ago(s.last), s.messages ? t(s.messages === 1 ? "{n} message" : "{n} messages", { n: s.messages }) : "", fmtBytes(s.size), s.id.slice(0, 8)].filter(Boolean).join(" · ")); + if (s.wsl) sub.prepend(wslBadge(s), " "); + who.append(sub); r.append(who); if (s.resume) { const res = el("button", "sess-resume", t("Resume")); @@ -326,7 +331,7 @@ r.append(term); } } - if (a?.deletable && !s.read_only) { + if (a?.deletable && !s.read_only && !s.wsl) { const del = el("button", "copy sm-del"); del.type = "button"; del.title = t("Delete"); @@ -354,6 +359,7 @@ d.append(l); }; line(t("Time"), stamp(s.start || s.last) + " – " + stamp(s.last)); + if (s.wsl) line("WSL", s.wsl); if (s.cwd) line(t("Folder"), s.cwd); line(t("Session ID"), s.id, copyBtn(s.id, t("Session id"))); if (s.resume) line(t("Resume"), el("code", "", s.resume), copyBtn(s.resume, t("Resume command"))); diff --git a/internal/gui/sessions.go b/internal/gui/sessions.go index 108eaaf3..b42f3d9a 100644 --- a/internal/gui/sessions.go +++ b/internal/gui/sessions.go @@ -63,6 +63,7 @@ func sessionRoutes(mux *http.ServeMux, w Windows) { if a := agents[s.Agent]; a != nil { j.Name, j.Icon = a.Name, a.Icon } + j.Name = wslName(j.Name, s) j.Path = tilde(j.Path) out.Sessions = append(out.Sessions, j) } @@ -121,6 +122,7 @@ func sessionRoutes(mux *http.ServeMux, w Windows) { j.Name, j.Icon = a.Name, a.Icon } } + j.Name = wslName(j.Name, s) since := s.Start if since.IsZero() { since = s.Last @@ -154,6 +156,15 @@ func sessionRoutes(mux *http.ServeMux, w Windows) { }) } +// wslName is an agent's name for a session it ran in a WSL distro, as the +// agent there is named (Claude Code · WSL Ubuntu); its own name otherwise. +func wslName(name string, s sessions.Session) string { + if s.WSL == "" { + return name + } + return name + " · WSL " + s.WSL +} + // statsFor is sessions.StatsFor for a range, kept: the page asks for the // stats and the overview one after the other, and for the same range again // each time it is shown. What was read is answered at once; when it is more diff --git a/internal/sessions/call_cache.go b/internal/sessions/call_cache.go index 47823d01..b8bc9c18 100644 --- a/internal/sessions/call_cache.go +++ b/internal/sessions/call_cache.go @@ -151,6 +151,13 @@ func readCalls(f file) *callFile { callsMu.Unlock() return old } + if f.cold || wslCold(f.path) { + // in a stopped WSL distro: opening it would start it + if old != nil { + return old + } + return &callFile{Path: f.path, Agent: f.agent} + } if f.agent == "opencode" { // rows, not lines: a session that changed is read again whole st, ok := readOpenCodeCalls(f) diff --git a/internal/sessions/calls.go b/internal/sessions/calls.go index 55155fe1..a819d300 100644 --- a/internal/sessions/calls.go +++ b/internal/sessions/calls.go @@ -197,6 +197,7 @@ func callFiles() []file { } } add(codexFiles()) + add(wslFiles("claude", "codex")) return out } diff --git a/internal/sessions/manage.go b/internal/sessions/manage.go index 4076bd4d..f4ba60e4 100644 --- a/internal/sessions/manage.go +++ b/internal/sessions/manage.go @@ -116,9 +116,11 @@ func ListAgent(agent string) []Managed { for _, fs := range groups { s, _ := assemble(fs, price) if s.Resume == "" && !s.ReadOnly { - s.Resume = ResumeCommand(s.Agent, s.ID, s.Cwd) + s.Resume = resumeCommand(s.WSL, s.Agent, s.ID, s.Cwd) } - m := Managed{Session: s, Files: len(fs), Deletable: Deletable(agent) && !s.ReadOnly} + // a WSL distro's are listed and resumed, not deleted: magpie moves + // no files out of a distro + m := Managed{Session: s, Files: len(fs), Deletable: Deletable(agent) && !s.ReadOnly && s.WSL == ""} for _, f := range fs { m.Size += f.size if st := cache[f.path]; st != nil && f.main { @@ -206,6 +208,9 @@ func Delete(agent, id string) (Trashed, error) { if len(fs) == 0 { return Trashed{}, errors.New("no such session") } + if s.WSL != "" { + return Trashed{}, fmt.Errorf("magpie doesn't delete sessions in WSL %s: delete it there", s.WSL) + } paths := sessionPaths(agent, id, fs) now := time.Now() for _, p := range paths { diff --git a/internal/sessions/pi.go b/internal/sessions/pi.go index adbb7c77..73af746a 100644 --- a/internal/sessions/pi.go +++ b/internal/sessions/pi.go @@ -65,9 +65,13 @@ func expandHome(p string) string { return p } -func piFiles() []file { - paths, _ := filepath.Glob(filepath.Join(PiDir(), "sessions", "*", "*.jsonl")) - if d := piSessionDir(); d != "" { +func piFiles() []file { return piFilesIn(PiDir(), piSessionDir()) } + +// piFilesIn are the sessions in a Pi folder, and in the folder it was told +// to keep them in ("" for none). +func piFilesIn(dir, sessionDir string) []file { + paths, _ := filepath.Glob(filepath.Join(dir, "sessions", "*", "*.jsonl")) + if d := sessionDir; d != "" { more, _ := filepath.Glob(filepath.Join(d, "*.jsonl")) paths = append(paths, more...) } diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index e0b83be4..c41da331 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -82,6 +82,9 @@ type Session struct { Unpriced int `json:"unpriced"` // models that spent tokens but have no known price Resume string `json:"resume"` // the command that picks the session up again Path string `json:"path"` // its (main) file + // WSL is the WSL distro the session ran in, its files read through + // \\wsl.localhost (see wsl.go); "" for this computer's own + WSL string `json:"wsl,omitempty"` } // PriceOf is the effective price of a model as a session names it, at the @@ -299,6 +302,11 @@ type file struct { alma *almaStore rev string readOnly bool + // wsl: the WSL distro whose home it is in; cold: that distro isn't + // running, and the file, as last listed, is not to be opened (that + // would start the distro) + wsl string + cold bool } // ClaudeDir is Claude Code's folder: $CLAUDE_CONFIG_DIR, else ~/.claude. @@ -368,7 +376,7 @@ func ccFiles(agent, dir string) []file { 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")), + wslFiles("pi"), zcodeFiles(), dshFiles(), clineFiles(), ccFiles("qoder", QoderDir("qoder")), ccFiles("qoder-cn", QoderDir("qoder-cn")), grokFiles(), workbuddyFiles(), droidFiles(), ompFiles(), cursorFiles(), hermesFiles(), almaFiles()} { out = append(out, fs...) } @@ -399,6 +407,7 @@ func Dirs() []string { } } out = append(out, HermesDirs()...) + out = append(out, wslDirs()...) return out } @@ -409,10 +418,13 @@ var rolloutName = regexp.MustCompile(`^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{ // zstSuffix ends a rollout the Codex app has compressed. const zstSuffix = ".zst" -func codexFiles() []file { +func codexFiles() []file { return codexFilesIn(CodexDir()) } + +// codexFilesIn are the rollouts in a Codex folder. +func codexFilesIn(dir string) []file { var out []file for _, folder := range []string{"sessions", "archived_sessions"} { - root := filepath.Join(CodexDir(), folder) + root := filepath.Join(dir, folder) filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil @@ -605,6 +617,9 @@ func writeCache(c *save) { func refresh(want, all []file) { var todo []file for _, f := range want { + if f.cold { + continue // in a stopped WSL distro: what was read of it stands + } if s := cache[f.path]; s == nil || s.Size != f.size || s.Mod != f.mod.UnixNano() || (f.agent == "hermes" && (f.hermes == nil || f.hermes.revision == "" || s.DBRevision != f.hermes.revision)) || (f.agent == "alma" && s.DBRevision != f.rev) { todo = append(todo, f) } @@ -769,6 +784,7 @@ func Reset() { directoryCache.entries = map[string]directoryEntry{} directoryCache.Unlock() resetCalls() + wslReset() mu.Lock() defer mu.Unlock() saved() @@ -836,7 +852,7 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { } return filepath.Base(fs[i].path) < filepath.Base(fs[j].path) }) - s := Session{Agent: fs[0].agent, Path: fs[0].path, Models: []Model{}} + s := Session{Agent: fs[0].agent, Path: fs[0].path, Models: []Model{}, WSL: fs[0].wsl} for _, f := range fs { s.ReadOnly = s.ReadOnly || f.readOnly } @@ -910,7 +926,7 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { return s, false // nothing was said in it } if !s.ReadOnly { - s.Resume = ResumeCommand(s.Agent, s.ID, s.Cwd) + s.Resume = resumeCommand(s.WSL, s.Agent, s.ID, s.Cwd) } return s, true } @@ -1190,7 +1206,11 @@ var safeID = regexp.MustCompile(`^[0-9A-Za-z_-]+$`) // ResumeCommand is the shell line that picks a session up again in its // folder, or "" for an id that isn't plain. -func ResumeCommand(agent, id, cwd string) string { +func ResumeCommand(agent, id, cwd string) string { return resumeCommand("", agent, id, cwd) } + +// resumeCommand is ResumeCommand for a session in a WSL distro too, when +// distro is set: see wslResume. +func resumeCommand(distro, agent, id, cwd string) string { if !safeID.MatchString(id) { return "" } @@ -1225,6 +1245,9 @@ func ResumeCommand(agent, id, cwd string) string { default: return "" } + if distro != "" { + return wslResume(distro, cwd, run) + } if cwd == "" { return run } diff --git a/internal/sessions/wsl.go b/internal/sessions/wsl.go new file mode 100644 index 00000000..cda70167 --- /dev/null +++ b/internal/sessions/wsl.go @@ -0,0 +1,291 @@ +package sessions + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Sessions in WSL. On Windows an agent installed in a WSL distro keeps its +// sessions in the distro's home, which magpie opens through +// \\wsl.localhost\ — the distros internal/agent's wsl.go already +// finds and probes for the agents it sets up (WSLHomes, which that package +// sets; this one can't import it). Claude Code's, Codex's and Pi's session +// files there are read as this computer's are, each session marked with its +// distro (Session.WSL) and resumed through wsl.exe (wslResume). +// +// Reading over \\wsl.localhost is slow, and looking at a stopped distro's +// files starts it. So the distros' files are listed behind, at most every +// wslRelist, and the listing is kept on disk (sessions-wsl.json beside the +// parses): a listing reads what was last found at once, a stopped +// distro's files stay listed as they were found and are never opened +// (file.cold), and their parses, kept by path as every file's are, stand. + +// WSLHome is a WSL distro magpie found agents in: its name, its user's home +// as magpie opens it (\\wsl.localhost\Ubuntu\home\me), and whether it runs. +type WSLHome struct { + Distro string + Home string + Running bool +} + +// WSLHomes are the WSL distros to read sessions in; nil (none) unless +// internal/agent sets it, which it does where there is WSL. +var WSLHomes func() []WSLHome + +// wslAgents are the agents whose sessions are read in a distro, and the +// folder under its home each keeps them in (the default: an agent's own +// variables there can't be read from Windows). +var wslAgents = []struct{ agent, dir string }{ + {"claude", ".claude"}, + {"codex", ".codex"}, + {"pi", filepath.Join(".pi", "agent")}, +} + +// wslScan lists the session files under a distro's home. +func wslScan(home string) []file { + var out []file + for _, a := range wslAgents { + dir := filepath.Join(home, a.dir) + switch a.agent { + case "claude": + out = append(out, ccFiles("claude", dir)...) + case "codex": + out = append(out, codexFilesIn(dir)...) + case "pi": + out = append(out, piFilesIn(dir, "")...) + } + } + return out +} + +// wslEntry is a file of a distro's listing, as kept on disk. +type wslEntry struct { + Agent string `json:"agent"` + Key string `json:"key"` + Path string `json:"path"` + Main bool `json:"main,omitempty"` + Size int64 `json:"size"` + Mod time.Time `json:"mod"` +} + +// wslListing is a distro's files as last listed. +type wslListing struct { + Home string `json:"home"` + Files []wslEntry `json:"files"` + Running bool `json:"-"` // as the last listing found it; false until one has +} + +var wslSess struct { + sync.Mutex + gen uint64 // Reset's: a listing started before one is dropped + loaded bool + lists map[string]*wslListing // by distro + at time.Time // when the last listing started + done chan struct{} // the listing under way, nil when none + kept []byte // what sessions-wsl.json holds +} + +var ( + // wslRelist is how often the distros' files are listed again + wslRelist = 30 * time.Second + // wslFirstWait is how long a read waits for the very first listing, + // when nothing was kept of one; after it the read goes on without + wslFirstWait = 3 * time.Second +) + +func wslListPath() string { return filepath.Join(filepath.Dir(CachePath()), "sessions-wsl.json") } + +// wslFiles are the session files of the given agents in the WSL distros, +// as last listed; a listing is started behind when the last is older than +// wslRelist. +func wslFiles(agents ...string) []file { + if WSLHomes == nil { + return nil + } + wslSess.Lock() + if !wslSess.loaded { + wslSess.loaded = true + wslSess.lists = map[string]*wslListing{} + if b, err := os.ReadFile(wslListPath()); err == nil && json.Unmarshal(b, &wslSess.lists) == nil { + wslSess.kept = b + } + for n, l := range wslSess.lists { + if l == nil { + delete(wslSess.lists, n) + } + } + } + if wslSess.done == nil && time.Since(wslSess.at) > wslRelist { + done := make(chan struct{}) + wslSess.done, wslSess.at = done, time.Now() + go wslList(wslSess.gen, done) + } + wait := wslSess.done + if len(wslSess.lists) > 0 { + wait = nil // what was found before is read now; the new listing comes next time + } + wslSess.Unlock() + if wait != nil { + select { + case <-wait: + case <-time.After(wslFirstWait): + } + } + want := map[string]bool{} + for _, a := range agents { + want[a] = true + } + wslSess.Lock() + defer wslSess.Unlock() + names := make([]string, 0, len(wslSess.lists)) + for n := range wslSess.lists { + names = append(names, n) + } + sort.Strings(names) + var out []file + for _, n := range names { + l := wslSess.lists[n] + for _, e := range l.Files { + if want[e.Agent] { + out = append(out, file{agent: e.Agent, key: e.Key, path: e.Path, main: e.Main, size: e.Size, mod: e.Mod, wsl: n, cold: !l.Running}) + } + } + } + return out +} + +// wslList lists the distros' files: a running one's afresh, a stopped +// one's as they were. With no distros said at all (wsl.exe didn't answer) +// every listing is kept, cold. +func wslList(gen uint64, done chan struct{}) { + defer close(done) + homes := WSLHomes() + wslSess.Lock() + old := wslSess.lists + wslSess.Unlock() + lists := map[string]*wslListing{} + for _, h := range homes { + if h.Distro == "" || h.Home == "" { + continue + } + if !h.Running { + if o := old[h.Distro]; o != nil && o.Home == h.Home { + c := *o + c.Running = false + lists[h.Distro] = &c + } + continue + } + l := &wslListing{Home: h.Home, Running: true, Files: []wslEntry{}} + for _, f := range wslScan(h.Home) { + l.Files = append(l.Files, wslEntry{Agent: f.agent, Key: f.key, Path: f.path, Main: f.main, Size: f.size, Mod: f.mod}) + } + lists[h.Distro] = l + } + if len(homes) == 0 { + for n, o := range old { + c := *o + c.Running = false + lists[n] = &c + } + } + b, _ := json.Marshal(lists) + wslSess.Lock() + defer wslSess.Unlock() + if gen != wslSess.gen { + return + } + wslSess.lists = lists + if wslSess.done == done { + wslSess.done = nil + } + if b != nil && !bytes.Equal(b, wslSess.kept) { + p := wslListPath() + if os.MkdirAll(filepath.Dir(p), 0o755) == nil { + tmp := p + ".tmp" + if os.WriteFile(tmp, b, 0o644) == nil && os.Rename(tmp, p) == nil { + wslSess.kept = b + } else { + os.Remove(tmp) + } + } + } +} + +// wslCold reports whether path is in a WSL distro's home that isn't +// running (or not yet known to run): such a file is not opened. +func wslCold(path string) bool { + if WSLHomes == nil { + return false + } + wslSess.Lock() + defer wslSess.Unlock() + for _, l := range wslSess.lists { + if !l.Running && under(path, l.Home) { + return true + } + } + return false +} + +// under reports whether path is dir or in it, its case aside (Windows'). +func under(path, dir string) bool { + if len(path) < len(dir) || !strings.EqualFold(path[:len(dir)], dir) { + return false + } + return len(path) == len(dir) || os.IsPathSeparator(path[len(dir)]) || os.IsPathSeparator(dir[len(dir)-1]) +} + +// wslDirs are the agents' folders in the distros sessions were found in. +func wslDirs() []string { + if WSLHomes == nil { + return nil + } + wslSess.Lock() + defer wslSess.Unlock() + var out []string + seen := map[string]bool{} + for _, l := range wslSess.lists { + for _, e := range l.Files { + for _, a := range wslAgents { + if d := filepath.Join(l.Home, a.dir); a.agent == e.Agent && !seen[d] { + seen[d] = true + out = append(out, d) + } + } + } + } + sort.Strings(out) + return out +} + +// wslReset forgets the listings, in memory. +func wslReset() { + wslSess.Lock() + defer wslSess.Unlock() + wslSess.gen++ + wslSess.loaded, wslSess.lists, wslSess.at, wslSess.done, wslSess.kept = false, nil, time.Time{}, nil, nil +} + +// wslResume is the PowerShell line that resumes a session in a WSL distro +// (run is the agent's own command): wsl.exe starts the user's own shell +// there as a login and interactive one, so the agent is on the PATH it has +// in a terminal there (~/.local/bin, nvm's), in the session's folder. +// Nothing in it is in double quotes, which Windows PowerShell 5.1 passes +// to wsl.exe mangled. +// +// wsl.exe -d 'Ubuntu' --cd '/home/me/app' -e sh -lc 'exec ${SHELL:-sh} -lic ''claude --resume ''' +func wslResume(distro, cwd, run string) string { + ps := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + line := "wsl.exe -d " + ps(distro) + if strings.HasPrefix(cwd, "/") { + line += " --cd " + ps(cwd) + } + return line + " -e sh -lc " + ps("exec ${SHELL:-sh} -lic '"+run+"'") +} diff --git a/internal/sessions/wsl_test.go b/internal/sessions/wsl_test.go new file mode 100644 index 00000000..2ed1a219 --- /dev/null +++ b/internal/sessions/wsl_test.go @@ -0,0 +1,140 @@ +package sessions + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// wslSettle waits for the listing of the WSL distros under way, if any. +func wslSettle() { + wslSess.Lock() + done := wslSess.done + wslSess.Unlock() + if done != nil { + <-done + } +} + +// wslDistro is a distro's home in a temp dir with Claude Code's, Codex's +// and Pi's sessions in it, and this computer's own folders empty; WSLHomes +// says it runs while *running is true. +func wslDistro(t *testing.T) (home string, running *bool) { + setup(t) + dir := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(dir, "own-claude")) + t.Setenv("CODEX_HOME", filepath.Join(dir, "own-codex")) + home = filepath.Join(dir, "Ubuntu", "home", "me") + copyTree(t, "testdata/claude", filepath.Join(home, ".claude")) + copyTree(t, "testdata/codex", filepath.Join(home, ".codex")) + copyTree(t, "testdata/pi", filepath.Join(home, ".pi", "agent")) + on := true + WSLHomes = func() []WSLHome { return []WSLHome{{Distro: "Ubuntu", Home: home, Running: on}} } + t.Cleanup(func() { wslSettle(); WSLHomes = nil; Reset() }) + Reset() + return home, &on +} + +// The sessions of Claude Code, Codex and Pi in a WSL distro are listed with +// the distro, resumed there through wsl.exe; their calls count in usage. +func TestWSLSessionsListed(t *testing.T) { + home, _ := wslDistro(t) + ss := List(0) + got := map[string]Session{} + for _, s := range ss { + if s.WSL != "Ubuntu" || !strings.HasPrefix(s.Path, home) { + t.Fatalf("a session not from the distro: %+v", s) + } + got[s.Agent] = s + } + for _, a := range []string{"claude", "codex", "pi"} { + if _, ok := got[a]; !ok { + t.Fatalf("no %s session from WSL in %+v", a, ss) + } + } + cc := got["claude"] + if cc.ID != "11111111-2222-3333-4444-555555555555" || cc.Cwd != "/work/app" || cc.Tokens.zero() { + t.Fatalf("claude: %+v", cc) + } + want := `wsl.exe -d 'Ubuntu' --cd '/work/app' -e sh -lc 'exec ${SHELL:-sh} -lic ''claude --resume 11111111-2222-3333-4444-555555555555'''` + if cc.Resume != want { + t.Fatalf("resume\n got %s\nwant %s", cc.Resume, want) + } + if r := got["codex"].Resume; !strings.HasPrefix(r, "wsl.exe -d 'Ubuntu' ") || !strings.Contains(r, "''codex resume ") { + t.Fatalf("codex resume %q", r) + } + var calls int + for _, c := range Calls(time.Time{}) { + if c.Agent == "claude" || c.Agent == "codex" { + calls++ + } + } + if calls == 0 { + t.Fatal("the distro's calls aren't counted") + } + dirs := strings.Join(Dirs(), "\n") + if !strings.Contains(dirs, filepath.Join(home, ".claude")) || !strings.Contains(dirs, filepath.Join(home, ".codex")) { + t.Fatalf("dirs %s", dirs) + } + + // listed and resumed, never deleted from Windows + for _, m := range ListAgent("claude") { + if m.Deletable { + t.Fatalf("a WSL session deletable: %+v", m) + } + } + if _, err := Delete("claude", cc.ID); err == nil || !strings.Contains(err.Error(), "WSL Ubuntu") { + t.Fatalf("delete: %v", err) + } + if _, err := os.Stat(cc.Path); err != nil { + t.Fatal(err) + } +} + +// A stopped distro's sessions are listed as last read, and nothing in it is +// opened: that would start it. +func TestWSLStoppedDistroKept(t *testing.T) { + home, running := wslDistro(t) + if n := len(List(0)); n != 4 { + t.Fatalf("want the 4 sessions, got %d", n) + } + wslSettle() + Saved() + // magpie starts again; the distro has stopped, and its files can't be + // read (a read would start it): here they are gone + *running = false + Reset() + if err := os.RemoveAll(home); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + ss := List(0) + if len(ss) != 4 { + t.Fatalf("pass %d: want the 4 sessions as last read, got %+v", i, ss) + } + for _, s := range ss { + if s.WSL != "Ubuntu" || s.Tokens.zero() || s.Resume == "" { + t.Fatalf("pass %d: %+v", i, s) + } + } + wslSettle() + } +} + +// Off WSL (WSLHomes nil, as on macOS and Linux) nothing changes. +func TestWSLNoneWithoutHomes(t *testing.T) { + setup(t) + if WSLHomes != nil { + t.Fatal("WSLHomes set in this package's tests") + } + for _, s := range List(0) { + if s.WSL != "" { + t.Fatalf("%+v", s) + } + } + if r := ResumeCommand("claude", "abc", ""); r != "claude --resume abc" { + t.Fatal(r) + } +}