package session import ( "encoding/json" "fmt" "os" "os/user" "path/filepath" "time" ) // Lease is the on-disk format of .ctask/session.json. It identifies the ctask // process holding this workspace open and records a heartbeat timestamp so // that crashed sessions can be detected and cleaned up by a later session. type Lease struct { SessionID string `json:"session_id"` PID int `json:"pid"` Hostname string `json:"hostname"` Username string `json:"username"` Agent string `json:"agent"` Mode string `json:"mode"` StartedAt time.Time `json:"started_at"` LastHeartbeatAt time.Time `json:"last_heartbeat_at"` Terminal string `json:"terminal"` } // LeasePath returns the absolute path of the lease file for the given workspace. func LeasePath(wsDir string) string { return filepath.Join(wsDir, ".ctask", "session.json") } // WriteLease marshals a lease to JSON and writes it to path. // The .ctask/ directory is created if needed. func WriteLease(path string, l *Lease) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(l, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } // ReadLease reads and parses a lease file. A missing file returns the // stdlib os.ErrNotExist (via os.ReadFile), which callers should detect // with errors.Is. func ReadLease(path string) (*Lease, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var l Lease if err := json.Unmarshal(data, &l); err != nil { return nil, fmt.Errorf("parsing lease: %w", err) } return &l, nil } // currentUsername returns the current OS username, falling back to "unknown" // on error. Never panics. func currentUsername() string { u, err := user.Current() if err != nil || u == nil { return "unknown" } if u.Username == "" { return "unknown" } return u.Username } // currentHostname returns the current hostname, falling back to "unknown". func currentHostname() string { h, err := os.Hostname() if err != nil || h == "" { return "unknown" } return h } // currentTerminal is a best-effort terminal identifier based on common env vars. // Returns "unknown" if none are set. func currentTerminal() string { for _, k := range []string{"TERM_PROGRAM", "WT_SESSION", "TERM"} { if v := os.Getenv(k); v != "" { return v } } return "unknown" } // NewSessionID returns a session identifier in the format // "--" using the given time in UTC. func NewSessionID(hostname string, pid int, t time.Time) string { return fmt.Sprintf("%s-%d-%s", hostname, pid, t.UTC().Format("20060102150405")) } // NewLease constructs a fresh lease for the current process. startedAt is used // for both StartedAt and LastHeartbeatAt. func NewLease(startedAt time.Time, agent, mode string) *Lease { hostname := currentHostname() pid := os.Getpid() return &Lease{ SessionID: NewSessionID(hostname, pid, startedAt), PID: pid, Hostname: hostname, Username: currentUsername(), Agent: agent, Mode: mode, StartedAt: startedAt.UTC().Truncate(time.Second), LastHeartbeatAt: startedAt.UTC().Truncate(time.Second), Terminal: currentTerminal(), } }