sessions: the Sessions page and local usage read the agents in WSL too, and resume one there through wsl.exe (lgtm on Discord: magpie can't detect the session lists of agents inside WSL?) — on Windows magpie already finds the WSL distros an agent is set up in (internal/agent's wslDistros, the distro user's home as \\wsl.localhost\<distro>\home\<user>), but the session reader only looked at this computer's own ~/.claude, ~/.codex and ~/.pi. Now internal/agent hands those homes to the sessions package (sessions.WSLHomes, set in init, no second discovery), and the sessions of Claude Code, Codex and Pi in each distro are listed beside this computer's, with Session.WSL naming the distro: a "WSL <distro>" badge on the Sessions page's rows and the management page, a WSL line in the details, " · WSL <distro>" in the API's name. Resume runs in the distro: wsl.exe -d '<distro>' --cd '<cwd>' -e sh -lc 'exec ${SHELL:-sh} -lic ''claude --resume <id>''' (the user's own shell, so the agent's PATH is there; no double quotes, which PowerShell 5.1 mangles). Their calls count in local usage (callFiles). Reading a distro over \\wsl.localhost is slow and opening a file in a stopped distro starts it, so the files are listed in the background, at most every 30 s, the listing kept in sessions-wsl.json; the page waits for it only the first time ever (up to 3 s); a stopped distro's sessions stay listed as last read and nothing in it is opened (the parse cache and call shards keep them). magpie doesn't delete a session in WSL: the checkbox and Delete are off for them, with a note to delete them there, and sessions.Delete refuses. OpenCode in WSL isn't read (its SQLite over \\wsl.localhost isn't reliable). zh strings added. TestWSLSessionsListed, TestWSLStoppedDistroKept and TestWSLNoneWithoutHomes (internal/sessions) and TestWSLSessionsOfTheDistro (internal/agent, FakeWSL: a Claude Code session in a fake Ubuntu is listed with its distro and the wsl.exe resume line) fail on the old code and pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried on a real WSL with agent sessions: the Windows test box's distros have no Claude Code, Codex or Pi sessions; the resume line wasn't run in PowerShell, and the pages weren't opened in a browser.
This commit is contained in:
1 parent
0ae7330242
commit
643264c24f
15 files changed
+589
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "删除",
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -197,6 +197,7 @@ func callFiles() []file {
|
||||
}
|
||||
}
|
||||
add(codexFiles())
|
||||
add(wslFiles("claude", "codex"))
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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\<distro> — 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 <id>'''
|
||||
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+"'")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user