From 0167d9643e387cc490fe83ac5822c3f6752d724a Mon Sep 17 00:00:00 2001 From: Beforerr Date: Tue, 14 Jul 2026 14:09:35 +0900 Subject: [PATCH] feat: auto-close sessions when owner exits --- README.md | 3 ++ docs/architecture.md | 6 ++- go.mod | 1 + go.sum | 2 + go/daemon.go | 15 ++++++- go/julia_integration_test.go | 1 - go/main.go | 40 ++++++++++++++--- go/manager.go | 51 +++++++++++++++++++-- go/owner.go | 76 ++++++++++++++++++++++++++++++++ go/owner_test.go | 41 +++++++++++++++++ go/proc_darwin.go | 40 +++++++++++++++++ go/proc_linux.go | 47 ++++++++++++++++++++ go/proc_windows.go | 28 ++++++++++++ go/session.go | 3 ++ skills/repld/SKILL.md | 7 ++- skills/repld/references/julia.md | 3 +- 16 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 go/owner.go create mode 100644 go/owner_test.go create mode 100644 go/proc_darwin.go create mode 100644 go/proc_linux.go diff --git a/README.md b/README.md index 31df29d..47b0020 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ repld stop # shutdown daemon `` is `julia`, `python3`, `R`, `wolframscript`, an absolute/relative interpreter path, etc. repld's own flags (`--session`/`--lang`/`--trace`/`--fresh`) go before ``; after it, every flag forwards verbatim to the interpreter (e.g. Julia's `--project=DIR`, `+1.11` for juliaup) except native eval/print flags (`-e`/`-c`/`-E`). Each call routes to a persistent session keyed by language + project + `--session`/cwd. +A session auto-closes once the agent process that created it exits. +`repld free ` pins a session against auto-closing; `repld --owner-pid 0 ...` opts out at creation. + ## Architecture One Go binary is both the CLI client and the background daemon (auto-started on first use, stopped with `repld stop`). For design details, wire protocol, and key-file map, see [docs/architecture.md](docs/architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md index 0934d77..d027791 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,9 @@ are keyed by `lang` + (`--session` label / project / cwd); the lang prefix keeps same-dir sessions of different languages distinct. Runs until `stop` (or an optional `daemon --idle-timeout SECS`; default 0 = never). +**Owner lease**: the daemon closes a session after its owning process exits. +Ownerless sessions persist until closed; `repld free` removes an existing lease. + **Session** (`go/session.go`): wraps one interpreter subprocess. The adapter supplies the launch argv, the embedded runtime source + its load statement, the per-eval wrapper, and the sentinel statement. Code is hex-encoded and eval'd via @@ -97,7 +100,8 @@ session is a no-op. - `go/adapter.go` — `Adapter` interface: the language-execution seam (launch argv, runtime, eval wrap, sentinel, `EvalFileStmt` for in-session file eval) - `go/daemon.go` — request dispatch, idle watchdog, client-disconnect→interrupt watcher - `go/session.go` — `Session`: subprocess lifecycle, `executeRaw` (sentinel + control protocol), `execute`, interrupt, graceful `kill` -- `go/manager.go` — `SessionManager`: session map, routing/keying, per-session logs +- `go/manager.go` — `SessionManager`: session map, routing/keying, per-session logs, owner-lease reaper +- `go/owner.go` — owner-pid resolution (client) + liveness/pid-reuse check (daemon) - `go/julia/`, `go/python/`, `go/r/`, `go/wolfram/` — per-language `Adapter` + embedded runtime: dials the control socket, evals code and writes the control frame, handles errors ## Adding a language diff --git a/go.mod b/go.mod index 89881ac..b03a024 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 81bd0d0..445dac3 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go/daemon.go b/go/daemon.go index f78a41d..cdc13be 100644 --- a/go/daemon.go +++ b/go/daemon.go @@ -74,6 +74,17 @@ func handleRequest(state *daemonState, req protocolRequest) response { } return response{Output: msg} + case "free": + key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req)) + if kerr != nil { + return errResp(kerr.Error()) + } + msg, err := state.manager.free(key) + if err != nil { + return errResp(err.Error()) + } + return response{Output: msg} + case "stop": state.stopOnce.Do(func() { close(state.stopCh) }) return response{Output: "Daemon stopping."} @@ -198,7 +209,7 @@ func handleStreamingEval(state *daemonState, req protocolRequest, conn net.Conn) if req.Fresh { state.manager.restart(req.Lang, req.Session, req.Cwd, disc) } - sess, err := state.manager.getOrCreate(req.Lang, req.Cwd, req.Session, req.Exe, req.Args) + sess, err := state.manager.getOrCreate(req.Lang, req.Cwd, req.Session, req.Exe, req.Args, req.OwnerPID, req.OwnerStart) if err != nil { emit(streamFrame{Done: true, Error: err.Error()}) return @@ -311,6 +322,8 @@ func serveDaemon(socketPath string, idleTimeout time.Duration) error { } state.lastRequest.Store(time.Now().UnixNano()) + go state.manager.reapLoop(state.stopCh) + // Idle watchdog: closes listener when idle or stop requested go func() { defer ln.Close() diff --git a/go/julia_integration_test.go b/go/julia_integration_test.go index 6d18e33..daf6525 100644 --- a/go/julia_integration_test.go +++ b/go/julia_integration_test.go @@ -393,7 +393,6 @@ func TestKillRunsAtexitHooks(t *testing.T) { require.FileExists(t, marker, "graceful shutdown should run atexit hooks, not SIGKILL") } - func TestJuliaFileEvalArgsAndState(t *testing.T) { if _, err := exec.LookPath(julia.Adapter{}.DefaultExe()); err != nil { t.Skipf("%s not installed", julia.Adapter{}.DefaultExe()) diff --git a/go/main.go b/go/main.go index cdc7ec7..1093a6e 100644 --- a/go/main.go +++ b/go/main.go @@ -66,6 +66,8 @@ type protocolRequest struct { RequireExisting bool `json:"require_existing,omitempty"` File string `json:"file,omitempty"` FileArgs []string `json:"file_args,omitempty"` + OwnerPID int `json:"owner_pid,omitempty"` // session owner; its exit auto-closes the session + OwnerStart int64 `json:"owner_start,omitempty"` // owner start time, guards against pid reuse } type streamFrame struct { @@ -134,7 +136,7 @@ func mustGetwd() string { return cwd } -func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh bool, traceLevel string, args []string) { +func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh bool, traceLevel string, args []string, ownerPID int, ownerStart int64) { if code == "-" { b, err := io.ReadAll(os.Stdin) if err != nil { @@ -155,13 +157,15 @@ func cmdEval(socketPath, lang, code, exe, session string, printResult, fresh boo PrintResult: printResult, Fresh: fresh, RequireExisting: lang == "" && exe == "" && session != "", + OwnerPID: ownerPID, + OwnerStart: ownerStart, } run(socketPath, req, true) } // cmdEvalFile sends an in-session file eval: path abs-ified but not read here — // the interpreter reads it at eval time, so edits between calls take effect. -func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceLevel string, fileArgs, fwd []string) { +func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceLevel string, fileArgs, fwd []string, ownerPID int, ownerStart int64) { abs, err := filepath.Abs(file) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -178,6 +182,8 @@ func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceL Fresh: fresh, File: abs, FileArgs: fileArgs, + OwnerPID: ownerPID, + OwnerStart: ownerStart, }, true) } @@ -205,6 +211,18 @@ func cmdClose(socketPath string, tg subTarget, exe string) { }, false) } +func cmdFree(socketPath string, tg subTarget, exe string) { + run(socketPath, protocolRequest{ + Action: "free", + ID: tg.id, + Lang: tg.lang, + Exe: exe, + Cwd: mustGetwd(), + Session: tg.session, + Args: tg.fwd, + }, false) +} + func cmdTrace(socketPath string, tg subTarget, exe string) { run(socketPath, protocolRequest{ Action: "trace", @@ -236,6 +254,7 @@ repld flags: --session LABEL Named session, reusable without re-specifying the exe --fresh Clear the targeted session before evaluating --trace LEVEL Error traceback level: short, smart, or full (eval default: smart) + --owner-pid N Auto-close the session when process N exits (0 = never; opt out) Commands (trace/interrupt/close locate a session by [exe], --session, or the short id shown by 'sessions'; an id prefix works when unambiguous): @@ -243,6 +262,7 @@ short id shown by 'sessions'; an id prefix works when unambiguous): trace Print the last saved error traceback for the session interrupt Interrupt the in-flight eval (SIGKILL after 3s if unresponsive) close Kill the session's interpreter and discard its state + free Clear the session's owner so it is never auto-closed stop Stop the daemon daemon Run the daemon in the foreground (normally auto-started) --idle-timeout SECS Shut down after idle (default: 0 = never; use 'stop') @@ -251,7 +271,7 @@ short id shown by 'sessions'; an id prefix works when unambiguous): } var subcommands = map[string]bool{ - "sessions": true, "trace": true, "interrupt": true, "close": true, "stop": true, "daemon": true, + "sessions": true, "trace": true, "interrupt": true, "close": true, "free": true, "stop": true, "daemon": true, } type parsed struct { @@ -260,6 +280,7 @@ type parsed struct { lang string session string trace string + ownerPID string fresh bool evalMode string code string @@ -290,6 +311,7 @@ func parseArgs(args []string) parsed { p := parsed{socket: defaultSocket} repld := map[string]*string{ "socket": &p.socket, "lang": &p.lang, "session": &p.session, "trace": &p.trace, + "owner-pid": &p.ownerPID, } evalModeFor := func(name string) string { evalNames, printNames := evalPrintFlags(resolveLang(p)) @@ -461,20 +483,21 @@ func main() { usage(0) } exe := resolveExeStr(p.exe, lang) + ownerPID, ownerStart := resolveOwner(p.ownerPID) switch { case p.evalMode != "": - cmdEval(p.socket, lang, p.code, exe, p.session, p.evalMode == "print", p.fresh, p.trace, p.fwd) + cmdEval(p.socket, lang, p.code, exe, p.session, p.evalMode == "print", p.fresh, p.trace, p.fwd, ownerPID, ownerStart) case p.file != "": - cmdEvalFile(p.socket, lang, p.file, exe, p.session, p.fresh, p.trace, p.fileArgs, p.fwd) + cmdEvalFile(p.socket, lang, p.file, exe, p.session, p.fresh, p.trace, p.fileArgs, p.fwd, ownerPID, ownerStart) case len(p.fwd) > 0: - cmdEval(p.socket, lang, "", exe, p.session, false, p.fresh, p.trace, p.fwd) + cmdEval(p.socket, lang, "", exe, p.session, false, p.fresh, p.trace, p.fwd, ownerPID, ownerStart) default: fi, err := os.Stdin.Stat() if err != nil || fi.Mode()&os.ModeCharDevice != 0 { usage(2) } - cmdEval(p.socket, lang, "-", exe, p.session, false, p.fresh, p.trace, p.fwd) + cmdEval(p.socket, lang, "-", exe, p.session, false, p.fresh, p.trace, p.fwd, ownerPID, ownerStart) } } @@ -497,6 +520,9 @@ func dispatchSubcommand(p parsed) { case "close": tg := parseTarget(p) cmdClose(p.socket, tg, resolveExeStr(tg.exe, tg.lang)) + case "free": + tg := parseTarget(p) + cmdFree(p.socket, tg, resolveExeStr(tg.exe, tg.lang)) case "daemon": fs := flag.NewFlagSet("daemon", flag.ExitOnError) idleTimeout := fs.Float64("idle-timeout", 0, "Shut down after this many idle seconds (0 = never)") diff --git a/go/manager.go b/go/manager.go index 7e8fa4b..e77d145 100644 --- a/go/manager.go +++ b/go/manager.go @@ -146,7 +146,7 @@ func (m *SessionManager) openLogFile(key sessionKey) *os.File { // forwarded args apply only when a session is first created; // a live session for the key is reused as-is. -func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []string) (*Session, error) { +func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []string, ownerPID int, ownerStart int64) (*Session, error) { lc, known := langs[lang] disc := "" if known { @@ -185,6 +185,7 @@ func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []strin m.mu.Lock() sess.id = m.uniqueIDLocked() + sess.ownerPID, sess.ownerStart = ownerPID, ownerStart m.sessions[key] = sess m.mu.Unlock() return sess, nil @@ -227,6 +228,46 @@ func (m *SessionManager) close(key sessionKey) (string, error) { return fmt.Sprintf("Session %s closed.", key), nil } +func (m *SessionManager) free(key sessionKey) (string, error) { + m.mu.Lock() + sess := m.sessions[key] + if sess != nil { + sess.ownerPID, sess.ownerStart = 0, 0 + } + m.mu.Unlock() + if sess == nil { + return "", fmt.Errorf("no session for %s", key) + } + return fmt.Sprintf("Session %s freed; it will not auto-close.", key), nil +} + +func (m *SessionManager) reapDeadOwners() { + m.mu.Lock() + var dead []sessionKey + for key, sess := range m.sessions { + if ownerDead(sess.ownerPID, sess.ownerStart) { + dead = append(dead, key) + } + } + m.mu.Unlock() + for _, key := range dead { + m.close(key) + } +} + +func (m *SessionManager) reapLoop(stop <-chan struct{}) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + m.reapDeadOwners() + } + } +} + func (m *SessionManager) hasLiveSession(lang, session, cwd, disc string) bool { key := m.key(lang, session, cwd, disc) m.mu.Lock() @@ -256,6 +297,7 @@ type sessionInfo struct { Session string `yaml:"session,omitempty"` // named-session label; "" for cwd-keyed sessions Status string `yaml:"status,omitempty"` // "dead" or "busy"; "" when idle and alive Busy float64 `yaml:"busy,omitempty"` // seconds in-flight; set only while busy + Owner int `yaml:"owner,omitempty"` // owner pid whose exit auto-closes the session; 0 = pinned Args []string `yaml:"args,omitempty"` // effective launch args (Julia's implicit --project is made explicit) Log string `yaml:"log,omitempty"` } @@ -276,9 +318,10 @@ func (m *SessionManager) list() []sessionInfo { result := make([]sessionInfo, 0, len(m.sessions)) for key, sess := range m.sessions { info := sessionInfo{ - ID: sess.id, - Lang: sess.lang, - Args: sess.fwd, + ID: sess.id, + Lang: sess.lang, + Args: sess.fwd, + Owner: sess.ownerPID, } if key.label != "" { info.Session = key.label diff --git a/go/owner.go b/go/owner.go new file mode 100644 index 0000000..52785d1 --- /dev/null +++ b/go/owner.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +func ownerDead(pid int, startTime int64) bool { + if pid <= 0 { + return false + } + st, alive := procInfo(pid) + if !alive { + return true + } + return startTime != 0 && st != 0 && st != startTime +} + +func resolveOwner(explicit string) (int, int64) { + if explicit != "" { + return ownerFrom(explicit) + } + if env := os.Getenv("REPLD_OWNER_PID"); env != "" { + return ownerFrom(env) + } + if os.Getenv("CLAUDECODE") != "" { + // The immediate parent is the per-call shell; the harness sits some + // variable number of levels up (Claude Code wraps Bash as a compound + // `zsh -c`, and users add timeout/env/xargs wrappers), so walk the + // ancestry rather than assuming a fixed depth. + if h := harnessPID(os.Getppid(), ppidOf, procIdent); h > 0 { + st, _ := procInfo(h) + return h, st + } + } + return 0, 0 +} + +const maxAncestorWalk = 10 + +func harnessPID(start int, parent func(int) (int, bool), ident func(int) string) int { + pid := start + for depth := 0; depth < maxAncestorWalk && pid > 1; depth++ { + if identifiesHarness(ident(pid)) { + return pid + } + p, ok := parent(pid) + if !ok { + break + } + pid = p + } + return 0 +} + +// Shell argv may mention ~/.claude; match executable basenames only. +func identifiesHarness(ident string) bool { + sep := func(r rune) bool { return r == 0 || r == ' ' || r == '\t' || r == '\n' || r == '\r' } + for _, tok := range strings.FieldsFunc(ident, sep) { + if filepath.Base(tok) == "claude" { + return true + } + } + return false +} + +func ownerFrom(s string) (int, int64) { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n <= 0 { + return 0, 0 + } + st, _ := procInfo(n) + return n, st +} diff --git a/go/owner_test.go b/go/owner_test.go new file mode 100644 index 0000000..0c517a1 --- /dev/null +++ b/go/owner_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOwnerLease(t *testing.T) { + c := exec.Command("sleep", "300") + require.NoError(t, c.Start()) + t.Cleanup(func() { + _ = c.Process.Kill() + _ = c.Wait() + }) + start, alive := procInfo(c.Process.Pid) + require.True(t, alive) + + m := newSessionManager() + defer m.shutdown() + + owned := sessionKey{label: "owned"} + freed := sessionKey{label: "freed"} + for _, key := range []sessionKey{owned, freed} { + sess := newSession("julia", "s", nil, nil) + sess.ownerPID, sess.ownerStart = c.Process.Pid, start + m.sessions[key] = sess + } + + m.reapDeadOwners() + require.Contains(t, m.sessions, owned) + _, err := m.free(freed) + require.NoError(t, err) + + require.NoError(t, c.Process.Kill()) + _ = c.Wait() + m.reapDeadOwners() + require.NotContains(t, m.sessions, owned) + require.Contains(t, m.sessions, freed) +} diff --git a/go/proc_darwin.go b/go/proc_darwin.go new file mode 100644 index 0000000..940abcb --- /dev/null +++ b/go/proc_darwin.go @@ -0,0 +1,40 @@ +package main + +import "golang.org/x/sys/unix" + +func procInfo(pid int) (startTime int64, alive bool) { + kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil || kp == nil { + return 0, false + } + tv := kp.Proc.P_starttime + return int64(tv.Sec)*1e9 + int64(tv.Usec)*1e3, true +} + +func ppidOf(pid int) (int, bool) { + kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil || kp == nil { + return 0, false + } + return int(kp.Eproc.Ppid), true +} + +// procIdent returns the process's comm plus its raw argv buffer (KERN_PROCARGS2, +// readable only for same-user processes). Empty when nothing is readable. +func procIdent(pid int) string { + var b []byte + if kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid); err == nil && kp != nil { + c := kp.Proc.P_comm + for _, ch := range c { + if ch == 0 { + break + } + b = append(b, byte(ch)) + } + b = append(b, 0) + } + if raw, err := unix.SysctlRaw("kern.procargs2", pid); err == nil { + b = append(b, raw...) + } + return string(b) +} diff --git a/go/proc_linux.go b/go/proc_linux.go new file mode 100644 index 0000000..bc36e0a --- /dev/null +++ b/go/proc_linux.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// statFields returns the space-split fields of /proc//stat after the comm +// field. comm is parenthesized and may contain spaces, so split after the last +// ')': fields[0] is the state (field 3), so 1-indexed field N is fields[N-3]. +func statFields(pid int) ([]string, bool) { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return nil, false + } + i := strings.LastIndexByte(string(b), ')') + if i < 0 { + return nil, false + } + return strings.Fields(string(b)[i+1:]), true +} + +func procInfo(pid int) (startTime int64, alive bool) { + f, ok := statFields(pid) + if !ok || len(f) < 20 { + return 0, false + } + st, _ := strconv.ParseInt(f[19], 10, 64) // field 22: starttime in clock ticks + return st, true +} + +func ppidOf(pid int) (int, bool) { + f, ok := statFields(pid) + if !ok || len(f) < 2 { + return 0, false + } + ppid, _ := strconv.Atoi(f[1]) // field 4 + return ppid, true +} + +func procIdent(pid int) string { + comm, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + cmd, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) // NUL-separated argv + return string(comm) + "\x00" + string(cmd) +} diff --git a/go/proc_windows.go b/go/proc_windows.go index cd050ec..caab840 100644 --- a/go/proc_windows.go +++ b/go/proc_windows.go @@ -3,11 +3,16 @@ package main import ( + "errors" "fmt" "os" "syscall" + + "golang.org/x/sys/windows" ) +const stillActive = 259 + func sysProcAttrDetach() *syscall.SysProcAttr { return &syscall.SysProcAttr{CreationFlags: 0x00000008} // DETACHED_PROCESS } @@ -21,3 +26,26 @@ func terminateProc(p *os.Process) error { func interruptProc(_ *os.Process) error { return fmt.Errorf("interrupt signal not supported on windows") } + +func procInfo(pid int) (int64, bool) { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + // Access restrictions must not make us reap a live owner. + return 0, !errors.Is(err, windows.ERROR_INVALID_PARAMETER) + } + defer windows.CloseHandle(h) + + var exitCode uint32 + if err := windows.GetExitCodeProcess(h, &exitCode); err == nil && exitCode != stillActive { + return 0, false + } + var created, exited, kernel, user windows.Filetime + if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil { + return 0, true + } + return created.Nanoseconds(), true +} + +func ppidOf(_ int) (int, bool) { return 0, false } + +func procIdent(_ int) string { return "" } diff --git a/go/session.go b/go/session.go index f46b4ec..7bfd384 100644 --- a/go/session.go +++ b/go/session.go @@ -42,6 +42,9 @@ type Session struct { logFile *os.File startup []startupChunk + ownerPID int + ownerStart int64 + controlAcceptTimeout float64 // seconds to wait for the runtime's control dial-back } diff --git a/skills/repld/SKILL.md b/skills/repld/SKILL.md index 6f85066..0c39f9a 100644 --- a/skills/repld/SKILL.md +++ b/skills/repld/SKILL.md @@ -1,6 +1,6 @@ --- name: repld -description: "Evaluate Julia/Python/R/Wolfram in long-lived background sessions so imports, variables, and project state persist across calls. Use for iterative work — package development, REPL-style experiments, tests, benchmarks — where starting fresh each time would be wasteful." +description: "Evaluate Julia/Python/R/Wolfram in long-lived background sessions so imports, variables, and project state persist across calls. Use for iterative work — package development, REPL-style experiments, tests, benchmarks — where starting fresh wastes tokens and time. Use prudently for reproducible research." --- ## Preferred workflow @@ -32,7 +32,7 @@ repld python3 train.py 50 # 50 → sys.argv[1] - Avoid repeating fixture/setup code in every command. - Repld flags (`--session`, `--fresh`, `--lang`) go before the interpreter. Interpreter flags go after it. - Session routing: `--session LABEL` has highest priority. Otherwise sessions are keyed by language plus adapter-specific environment (for example project flag for Julia, interpreter path for Python/R/Wolfram) plus cwd. -- Avoid using `--fresh` when a live interpreter can safely pick up changed state. +- Avoid using `--fresh` when interpreters can safely pick up changed state. See [julia.md](references/julia.md), [python.md](references/python.md), [r.md](references/r.md), [wolfram.md](references/wolfram.md) for language-specific notes. @@ -41,8 +41,7 @@ See [julia.md](references/julia.md), [python.md](references/python.md), [r.md](r ```bash repld sessions # list active sessions, show IDs repld trace # last saved traceback -repld interrupt -repld close +repld [interrupt | close] repld stop # shut down daemon timeout 30 repld julia -e 'might_hang()' # client death interrupts eval diff --git a/skills/repld/references/julia.md b/skills/repld/references/julia.md index a053795..362e81e 100644 --- a/skills/repld/references/julia.md +++ b/skills/repld/references/julia.md @@ -5,7 +5,8 @@ Without specifying `--session`, distinct projects correspond to different sessions. ```bash -repld julia --project=test -e 'using ImportPackageOnce' +repld julia -e 'include("setup.jl")' +repld julia -e 'using Pkg; Pkg.activate("test"); using ImportTestPackageOnce' repld julia --project=@temp -e 'using Pkg; Pkg.add("Example")' repld julia +1.11 -E 'VERSION' # default project env is "@." repld --fresh julia -t 4 -E 'Threads.nthreads()'