1 import Base 2 import ../json.bend as Json 3 import ../fs.bend as Fs 4 import ../act.bend as Act 5 6 # Is a Claude session running against a slot's session profile? 7 # (switcher._live_session_pids / process_detection.scan_sessions): the records 8 # /sessions/-/sessions/*.json each name a pid; one 9 # counts only when that pid is alive and is still the process that wrote the 10 # record. A record that cannot be read counts as no session (a scan, not a 11 # guard). The check is ps-based, like the reference's on macOS and Linux. 12 13 def ActM(-A: Type) -> Type: 14 Act.of(A) 15 16 def ActM.pure(-A: Type, x: A) -> Act.of(A): 17 Act.pure(A, x) 18 19 def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B): 20 Act.bind(A, B, m, f) 21 22 # ---- the profile directory ------------------------------------------------------------------------- 23 24 def keep_char(+n: U32) -> Bool: 25 (U32.is_ge(n, 48) && U32.is_le(n, 57)) || (U32.is_ge(n, 65) && U32.is_le(n, 90)) || (U32.is_ge(n, 97) && U32.is_le(n, 122)) || U32.is_eq(n, 46) || U32.is_eq(n, 95) || U32.is_eq(n, 45) 26 27 def pick_char(+k: Bool, +c: Char) -> Char: 28 match k: 29 case True{}: 30 c 31 case False{}: 32 '_' 33 34 def slug_char(+c: Char) -> Char: 35 pick_char(keep_char(Char.to_u32(c)), c) 36 37 def slug_list(+cs: List<&2, Char>) -> List<&2, Char>: 38 match cs: 39 case Nil{}: 40 [] 41 case Con{h, t}: 42 slug_char(h) <> slug_list(t) 43 44 # session.slugify_email (ASCII addresses; a non-ASCII character becomes `_`). 45 def slugify(+email: String) -> String: 46 String.from_list(slug_list(String.to_list(email))) 47 48 def dir_of(+root: String, +slot: String, +email: String) -> String: 49 root ++ "/sessions/" ++ slot ++ "-" ++ slugify(email) ++ "/sessions" 50 51 # ---- asking ps ---------------------------------------------------------------------------------------- 52 53 def ps_some(+empty: Bool, +t: String) -> Maybe<&2, String>: 54 match empty: 55 case True{}: 56 None{} 57 case False{}: 58 Some{t} 59 60 def ps_nonempty(+ok: Bool, +t: String) -> Maybe<&2, String>: 61 match ok: 62 case True{}: 63 ps_some(String.is_empty(t), t) 64 case False{}: 65 None{} 66 67 def ps_text(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> Maybe<&2, String>: 68 match r: 69 case Fail{e}: 70 None{} 71 case Done{(st, (out, err))}: 72 ps_nonempty(U32.is_eq(st, 0), String.trim(out)) 73 74 # `ps -o COLS= -p PID` under LC_ALL=C TZ=UTC; None when unknowable. 75 def ps(+cols: String, +pid: U32) -> IO(Maybe<&2, String>): 76 IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, Maybe<&2, String>, Process.run("/usr/bin/env", ["LC_ALL=C", "TZ=UTC", "ps", "-o", cols, "-p", U32.show(pid)], "", 4096, 5000), r => IO.pure(Maybe<&2, String>, ps_text(r))) 77 78 # ---- ps -o lstart= ("Wed Sep 2 20:35:59 2026") as epoch seconds --------------------------------- 79 80 def month_hit(+hit: Bool, +i: U32, +rest: Maybe<&2, U32>) -> Maybe<&2, U32>: 81 match hit: 82 case True{}: 83 Some{i} 84 case False{}: 85 rest 86 87 def month_from(+ms: List<&2, String>, +m: String, +i: U32) -> Maybe<&2, U32>: 88 match ms: 89 case Nil{}: 90 None{} 91 case Con{h, t}: 92 month_hit(String.eq(h, m), i, month_from(t, m, (i + 1 : U32))) 93 94 def month_idx(+m: String) -> Maybe<&2, U32>: 95 month_from(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], m, 1) 96 97 def word_keep(+empty: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>: 98 match empty: 99 case True{}: 100 rest 101 case False{}: 102 h <> rest 103 104 def words(+ws: List<&2, String>) -> List<&2, String>: 105 match ws: 106 case Nil{}: 107 [] 108 case Con{h, t}: 109 word_keep(String.is_empty(h), h, words(t)) 110 111 def sel_u(+c: Bool, +x: U32, +y: U32) -> U32: 112 match c: 113 case True{}: 114 x 115 case False{}: 116 y 117 118 def days5(+a: U32, +b: U32, +doy: U32) -> U32: 119 ((((a + b : U32) + doy : U32) - 719468) : U32) 120 121 def days4(+era: U32, +yoe: U32, +doy: U32) -> U32: 122 days5((era * 146097 : U32), ((((yoe * 365 : U32) + (yoe / 4 : U32) : U32) - (yoe / 100 : U32)) : U32), doy) 123 124 def days3(+era: U32, +yoe: U32, +dm1: U32, +mp: U32) -> U32: 125 days4(era, yoe, ((((153 * mp : U32) + 2 : U32) / 5 : U32) + dm1 : U32)) 126 127 def days2(+yy: U32, +m: U32, +d: U32) -> U32: 128 days3((yy / 400 : U32), (yy % 400 : U32), (d - 1 : U32), sel_u(U32.is_gt(m, 2), (m - 3 : U32), (m + 9 : U32))) 129 130 # days since 1970-01-01 of a civil date (years from 1970 on). 131 def days_civil(+y: U32, +m: U32, +d: U32) -> U32: 132 days2(sel_u(U32.is_le(m, 2), (y - 1 : U32), y), m, d) 133 134 def num(+s: String) -> U32: 135 Maybe.default(&2, U32, U32.read(s), 0) 136 137 def clock_secs(+ps: List<&2, String>) -> Maybe<&2, U32>: 138 match ps: 139 case Con{h, Con{m, Con{s, Nil{}}}}: 140 Some{(((num(h) * 3600 : U32) + (num(m) * 60 : U32) : U32) + num(s) : U32)} 141 case _: 142 None{} 143 144 def lstart_ok(+ok: Bool, +y: U32, +m: U32, +d: U32, +c: U32) -> Maybe<&2, U32>: 145 match ok: 146 case True{}: 147 Some{((days_civil(y, m, d) * 86400 : U32) + c : U32)} 148 case False{}: 149 None{} 150 151 # ---- does the process at the pid still belong to the record? ---------------------------------- 152 153 def lstart_fields(+m: Maybe<&2, U32>, +d: Maybe<&2, U32>, +c: Maybe<&2, U32>, +y: Maybe<&2, U32>) -> Maybe<&2, U32>: 154 match m d c y: 155 case Some{mm} Some{dd} Some{cc} Some{yy}: 156 lstart_ok(U32.is_ge(yy, 1970), yy, mm, dd, cc) 157 case _ _ _ _: 158 None{} 159 160 def lstart_dy(+m: Maybe<&2, U32>, +ws: List<&2, String>) -> Maybe<&2, U32>: 161 match ws: 162 case Con{day, Con{clk, Con{yr, Nil{}}}}: 163 lstart_fields(m, U32.read(day), clock_secs(String.split(clk, ':')), U32.read(yr)) 164 case _: 165 None{} 166 167 def lstart_dmy(+ws: List<&2, String>) -> Maybe<&2, U32>: 168 match ws: 169 case Con{mon, t}: 170 lstart_dy(month_idx(mon), t) 171 case Nil{}: 172 None{} 173 174 def lstart_of(+ws: List<&2, String>) -> Maybe<&2, U32>: 175 match ws: 176 case Con{wd, t}: 177 lstart_dmy(t) 178 case Nil{}: 179 None{} 180 181 def lstart_secs(+text: String) -> Maybe<&2, U32>: 182 lstart_of(words(String.split(text, ' '))) 183 184 def all_digits(+cs: List<&2, Char>) -> Bool: 185 match cs: 186 case Nil{}: 187 True{} 188 case Con{c, t}: 189 Char.is_digit(c) && all_digits(t) 190 191 def is_ticks(+t: String) -> Bool: 192 Bool.not(String.is_empty(t)) && all_digits(String.to_list(t)) 193 194 def nth_hit(+hit: Bool, +h: String, +rest: Maybe<&2, String>) -> Maybe<&2, String>: 195 match hit: 196 case True{}: 197 Some{h} 198 case False{}: 199 rest 200 201 # Field 22 of /proc//stat (counted from the last `)`), the Linux stamp. 202 def nth_word(+ws: List<&2, String>, +n: U32) -> Maybe<&2, String>: 203 match ws: 204 case Nil{}: 205 None{} 206 case Con{h, t}: 207 nth_hit(U32.is_eq(n, 0), h, nth_word(t, (n - 1 : U32))) 208 209 def last_part(+ps: List<&2, String>, +d: String) -> String: 210 match ps: 211 case Nil{}: 212 d 213 case Con{h, t}: 214 last_part(t, h) 215 216 def stat_ticks(+text: String) -> Maybe<&2, String>: 217 nth_word(words(String.split(last_part(String.split(text, ')'), ""), ' ')), 19) 218 219 def ticks_match(+want: String, +m: Maybe<&2, String>) -> Bool: 220 match m: 221 case None{}: 222 True{} 223 case Some{t}: 224 String.eq(t, want) 225 226 def stat_text(r: Fs.Res(String)) -> Maybe<&2, String>: 227 match r: 228 case Fail{e}: 229 None{} 230 case Done{t}: 231 stat_ticks(t) 232 233 def matches_ticks(+pid: U32, +want: String) -> IO(Bool): 234 IO.bind(Fs.Res(String), Bool, Fs.read_file("/proc/" ++ U32.show(pid) ++ "/stat"), r => IO.pure(Bool, ticks_match(want, stat_text(r)))) 235 236 def looks_claude(+m: Maybe<&2, String>) -> Bool: 237 match m: 238 case None{}: 239 True{} 240 case Some{t}: 241 String.contains(String.to_lower(t), "claude") 242 243 def stranger2(+recent: Bool, +pid: U32) -> IO(Bool): 244 match recent: 245 case True{}: 246 IO.pure(Bool, True{}) 247 case False{}: 248 IO.bind(Maybe<&2, String>, Bool, ps("comm=,args=", pid), t => IO.pure(Bool, looks_claude(t))) 249 250 # Only a stranger that started later than the record's claude disqualifies it. 251 def stranger(+recorded: U32, +started: Maybe<&2, U32>, +pid: U32) -> IO(Bool): 252 match started: 253 case None{}: 254 IO.pure(Bool, True{}) 255 case Some{st}: 256 stranger2(U32.is_le(st, (recorded + 120 : U32)), pid) 257 258 def started_of(+m: Maybe<&2, String>) -> Maybe<&2, U32>: 259 match m: 260 case None{}: 261 None{} 262 case Some{t}: 263 lstart_secs(t) 264 265 def by_lstart(+pid: U32, +rec: Maybe<&2, U32>) -> IO(Bool): 266 match rec: 267 case None{}: 268 IO.pure(Bool, True{}) 269 case Some{recorded}: 270 IO.bind(Maybe<&2, String>, Bool, ps("lstart=", pid), t => stranger(recorded, started_of(t), pid)) 271 272 def stamped2(+ticks: Bool, +pid: U32, +stamp: String) -> IO(Bool): 273 match ticks: 274 case True{}: 275 matches_ticks(pid, stamp) 276 case False{}: 277 by_lstart(pid, lstart_secs(stamp)) 278 279 def stamped(+pid: U32, +stamp: String) -> IO(Bool): 280 stamped2(is_ticks(stamp), pid, stamp) 281 282 def with_stamp(+pid: U32, +m: Maybe<&2, String>) -> IO(Bool): 283 match m: 284 case None{}: 285 IO.pure(Bool, True{}) 286 case Some{stamp}: 287 stamped(pid, stamp) 288 289 def alive_then(+up: Bool, +pid: U32, +stamp: Maybe<&2, String>) -> IO(Bool): 290 match up: 291 case True{}: 292 with_stamp(pid, stamp) 293 case False{}: 294 IO.pure(Bool, False{}) 295 296 def pid_live(+pid: U32, +stamp: Maybe<&2, String>) -> IO(Bool): 297 IO.bind(Maybe<&2, String>, Bool, ps("pid=", pid), t => alive_then(Maybe.is_some(&2, String, t), pid, stamp)) 298 299 # ---- one record --------------------------------------------------------------------------------- 300 301 def stamp_of(+j: Json.Json) -> Maybe<&2, String>: 302 Json.as_string(Maybe.default(&2, Json.Json, Json.get("procStart", j), Json.JNull{})) 303 304 def pid_of(+j: Json.Json) -> Maybe<&2, U32>: 305 Json.as_u32(Maybe.default(&2, Json.Json, Json.get("pid", j), Json.JNull{})) 306 307 def pid_live_gt1(+ok: Bool, +p: U32, +stamp: Maybe<&2, String>) -> IO(Bool): 308 match ok: 309 case True{}: 310 pid_live(p, stamp) 311 case False{}: 312 IO.pure(Bool, False{}) 313 314 def record_live2(+pid: Maybe<&2, U32>, +stamp: Maybe<&2, String>) -> IO(Bool): 315 match pid: 316 case None{}: 317 IO.pure(Bool, False{}) 318 case Some{p}: 319 pid_live_gt1(U32.is_gt(p, 1), p, stamp) 320 321 def record_live(r: Result<&2, &2, String, Json.Json>) -> IO(Bool): 322 match r: 323 case Fail{e}: 324 IO.pure(Bool, False{}) 325 case Done{+j}: 326 record_live2(pid_of(j), stamp_of(j)) 327 328 def file_live(r: Fs.Res(String)) -> IO(Bool): 329 match r: 330 case Fail{e}: 331 IO.pure(Bool, False{}) 332 case Done{t}: 333 record_live(Json.parse(t)) 334 335 # ---- the directory --------------------------------------------------------------------------------------- 336 337 def any_live(+dir: String, +names: List<&2, String>) -> IO(Bool): 338 match names: 339 case Nil{}: 340 IO.pure(Bool, False{}) 341 case Con{n, t}: 342 IO.bind(Fs.Res(String), Bool, Fs.read_file(dir ++ "/" ++ n), r => IO.bind(Bool, Bool, file_live(r), hit => IO.bind(Bool, Bool, any_live(dir, t), rest => IO.pure(Bool, hit || rest)))) 343 344 def rest_live(+hit: Bool, +dir: String, +t: List<&2, String>) -> IO(Bool): 345 match hit: 346 case True{}: 347 IO.pure(Bool, True{}) 348 case False{}: 349 any_live(dir, t) 350 351 def json_keep(+ok: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>: 352 match ok: 353 case True{}: 354 h <> rest 355 case False{}: 356 rest 357 358 def json_names(+ns: List<&2, String>) -> List<&2, String>: 359 match ns: 360 case Nil{}: 361 [] 362 case Con{h, t}: 363 json_keep(String.ends_with(h, ".json"), h, json_names(t)) 364 365 def scan(+dir: String, r: Fs.Res(List<&2, String>)) -> IO(Bool): 366 match r: 367 case Fail{e}: 368 IO.pure(Bool, False{}) 369 case Done{ns}: 370 any_live(dir, json_names(ns)) 371 372 # Is a Claude session live against this slot's session profile? 373 def live(+root: String, +slot: String, +email: String) -> IO(Bool): 374 IO.bind(Fs.Res(List<&2, String>), Bool, Fs.list_dir(dir_of(root, slot, email)), r => scan(dir_of(root, slot, email), r))