@andespindola/crow-skills 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.16.0"
@@ -4,18 +4,60 @@ import os
4
4
  import shutil
5
5
  from .source import CrowError
6
6
 
7
+ # Every destination is the CLI's OWN skills directory. There is no shared folder: the
8
+ # ~/.agents/skills store that codex used until 0.14.0 made crow-skills act on a directory
9
+ # instead of on an agent, which showed up as a phantom "codex,shared" agent and let Codex
10
+ # list the same skill twice (its own dir plus the shared one). LEGACY_SHARED stays named
11
+ # only so an install made there can be found and migrated away.
12
+ LEGACY_SHARED = ".agents/skills"
7
13
  AGENTS = {
8
- "codex": (".agents/skills", ".agents/skills", "codex"),
14
+ "codex": (".codex/skills", ".codex/skills", "codex"),
9
15
  "claude": (".claude/skills", ".claude/skills", "claude"),
10
16
  "gemini": (".gemini/skills", ".gemini/skills", "gemini"),
11
17
  "devin": (".config/devin/skills", ".devin/skills", "devin"),
12
- "shared": (".agents/skills", ".agents/skills", None),
13
18
  # Uniflow keeps one store and links it into each CLI's user skills dir; it has
14
19
  # no project scope. The store sits beside config.json (see uniflow_store).
15
20
  "uniflow": (".config/uniflow/skills", None, "uniflow"),
16
21
  }
17
22
 
18
23
 
24
+ def codex_home(home):
25
+ """$CODEX_HOME for the real profile (as Codex resolves it); an isolated --home uses <home>/.codex.
26
+
27
+ A RELATIVE $CODEX_HOME is ignored: os.path.abspath would resolve it against this process's cwd,
28
+ which is not Codex's, so the destination would depend on where the command was run and could
29
+ land inside the user's repository. Only an absolute (or ~-anchored) value is honoured."""
30
+ home = Path(home)
31
+ raw = os.environ.get("CODEX_HOME") if real_home(home) else None
32
+ if raw:
33
+ expanded = Path(raw).expanduser()
34
+ if expanded.is_absolute():
35
+ return Path(os.path.abspath(expanded))
36
+ return Path(os.path.abspath(home / ".codex"))
37
+
38
+
39
+ def codex_skills_dir(home):
40
+ return Path(physical(codex_home(home) / "skills"))
41
+
42
+
43
+ def legacy_shared_dir(home):
44
+ """Where codex installs landed until 0.14.0; read only, to migrate what is still there."""
45
+ return Path(physical(Path(home) / LEGACY_SHARED))
46
+
47
+
48
+ def physical(path):
49
+ """The physical path behind a skills directory, following symlinks on the components that exist.
50
+
51
+ A CLI directory kept in dotfiles (~/.codex -> /dotfiles/codex) is the normal case, and the CLI
52
+ itself follows the link. storage.checked() refuses to WRITE through a symlink, which is the
53
+ control that keeps a write from landing somewhere other than where it reads: resolving here, at
54
+ destination resolution, honours the dotfiles layout while leaving that control intact, because
55
+ what reaches checked() is already physical. os.path.realpath resolves the existing components
56
+ and leaves the rest, so a skills dir that does not exist yet still resolves correctly.
57
+ """
58
+ return os.path.realpath(os.path.abspath(Path(path).expanduser()))
59
+
60
+
19
61
  def real_home(home):
20
62
  """Environment overrides describe the real profile; an isolated --home ignores them."""
21
63
  return home == Path.home().resolve()
@@ -57,7 +99,7 @@ def destinations(names, home, project=None, custom=None):
57
99
  if custom:
58
100
  if names:
59
101
  raise CrowError("usage", "--dest e --agents não podem ser combinados.")
60
- return [{"path": str(Path(custom).expanduser().absolute()), "agents": ["custom"]}]
102
+ return [{"path": physical(custom), "agents": ["custom"]}]
61
103
  if not names:
62
104
  raise CrowError("usage", "Selecione --agents; não há instalação implícita em todos os agentes.")
63
105
  selected = names.split(",") if isinstance(names, str) else names
@@ -74,15 +116,107 @@ def destinations(names, home, project=None, custom=None):
74
116
  # Respect explicit XDG config, but isolate it when --home selects another profile.
75
117
  if name == "devin" and not project and real_home(home) and os.getenv("XDG_CONFIG_HOME"):
76
118
  path = Path(os.environ["XDG_CONFIG_HOME"]).expanduser() / "devin/skills"
119
+ # Codex resolves its own home through $CODEX_HOME; the destination must follow it, or an
120
+ # install would write beside a profile Codex never reads.
121
+ if name == "codex" and not project:
122
+ path = codex_skills_dir(home)
77
123
  if name == "uniflow":
78
124
  path = uniflow_store(home)
79
- key = str(path.absolute())
125
+ key = physical(path)
80
126
  result.setdefault(key, {"path": key, "agents": []})
81
127
  if name not in result[key]["agents"]:
82
128
  result[key]["agents"].append(name)
83
129
  return list(result.values())
84
130
 
85
131
 
132
+ def plausible_skills_dir(path, agents_):
133
+ """O caminho resolvido parece o diretório de skills DESTE CLI?
134
+
135
+ Regra positiva, por decisão do dono. "Termina em skills" sozinho não basta: o arranjo de
136
+ dotfiles mais comum é `~/.claude/skills -> /dotfiles/claude`, em que o LINK se chama skills e o
137
+ ALVO se chama como o agente — e recusá-lo quebraria exatamente o caso que a resolução existe
138
+ para atender. Então o último trecho pode ser "skills" OU o nome de um dos agentes selecionados
139
+ (ou o nome do diretório de config dele, para o devin). `~/.ssh`, o `.git` de outro repositório
140
+ e o diretório de transações do crow-skills continuam fora.
141
+ """
142
+ # Um trecho sensível no caminho vence o nome: `~/.ssh/skills` passava só por terminar em
143
+ # "skills" e o instalador criava ali seu diretório de staging, ao lado das chaves. Isto não é
144
+ # uma lista de negação geral (que o dono recusou): são três áreas que nunca são diretório de
145
+ # skills de um CLI.
146
+ parts = {x.lower() for x in Path(path).parts}
147
+ if parts & {".ssh", ".git", ".gnupg"}:
148
+ return False
149
+ base = os.path.basename(path).lower()
150
+ if base == "skills":
151
+ return True
152
+ names = set()
153
+ for agent in agents_:
154
+ names.add(agent.lower())
155
+ user_path = (AGENTS.get(agent) or (None,))[0]
156
+ if user_path:
157
+ parts = [x for x in Path(user_path).parts if x not in (".", "")]
158
+ if len(parts) >= 2:
159
+ names.add(parts[-2].lstrip(".").lower()) # .config/devin/skills -> devin
160
+ names.add(parts[0].lstrip(".").lower()) # .claude/skills -> claude
161
+ return base in names
162
+
163
+
164
+ def refuse_implausible_destination(targets, home):
165
+ """Recusa um destino de CLI que resolve para um lugar que não é diretório de skills.
166
+
167
+ Resolver symlink (0.16.0) tirou a garantia implícita de que a seleção de um CLI só escrevia
168
+ embaixo do diretório daquele CLI: um ~/.claude/skills apontando para ~/.ssh, para o .git de
169
+ outro repositório ou para o próprio diretório de transações do crow-skills passava a receber
170
+ pacotes. Regra POSITIVA, por decisão do dono: o caminho resolvido precisa terminar em "skills"
171
+ (o layout de dotfiles normal, /dotfiles/claude/skills, passa) ou ser o store do Uniflow pelo
172
+ agente certo. Uma lista de negação seria mais fácil de furar com um caminho não previsto.
173
+ """
174
+ store = physical(uniflow_store(home))
175
+ for target in targets:
176
+ path = physical(target["path"])
177
+ agents_ = target.get("agents") or []
178
+ if "custom" in agents_:
179
+ continue # --dest é escolha explícita e consciente do usuário
180
+ if path == store and agents_ == ["uniflow"]:
181
+ continue
182
+ if plausible_skills_dir(path, agents_):
183
+ continue
184
+ # Uma área protegida tem razão PRÓPRIA e melhor: dizer "não parece diretório de skills" ali
185
+ # esconderia o motivo verdadeiro e mandaria o usuário para um --dest que a proteção recusa
186
+ # de qualquer forma — beco sem saída.
187
+ from .guide import shielded
188
+ try:
189
+ if shielded(Path(path), home):
190
+ raise CrowError("protected_path", "O destino de " + ",".join(agents_) + " resolve para " + path +
191
+ ", que é área protegida (prompt compartilhado ou configuração do Uniflow).")
192
+ except CrowError:
193
+ raise
194
+ except Exception: # noqa: BLE001 - se não der para avaliar, vale a razão genérica
195
+ pass
196
+ raise CrowError("implausible_destination", "O destino de " + ",".join(agents_) + " resolve para " +
197
+ path + ", que não parece um diretório de skills deste CLI (esperado um caminho cujo "
198
+ "último trecho seja \"skills\" ou o nome do agente). Se é mesmo onde você quer "
199
+ "instalar, use --dest explicitamente.")
200
+
201
+
202
+ def refuse_store_as_cli_dir(targets, home):
203
+ """Recusa um destino de CLI que resolve para o store do Uniflow.
204
+
205
+ Resolver symlink (0.16.0) tornou isso alcançável pela seleção de um CLI: um
206
+ ~/.claude/skills -> ~/.config/uniflow/skills gravaria um pacote dentro do store, que vale para
207
+ TODOS os CLIs que o Uniflow projeta, não só o selecionado. Só vale na MUTAÇÃO: `destinations()`
208
+ é chamado por vários caminhos de leitura (status, doctor, avisos do uniflow) que inspecionam o
209
+ destino de outro agente, e levantar lá derrubaria um comando que nem ia gravar naquele lugar.
210
+ """
211
+ store = physical(uniflow_store(home))
212
+ for target in targets:
213
+ others = [a for a in target.get("agents") or [] if a != "uniflow"]
214
+ if others and physical(target["path"]) == store:
215
+ raise CrowError("protected_path", "O destino de " + ",".join(others) + " resolve para o store do "
216
+ "Uniflow (" + store + "), que vale para todos os CLIs que ele projeta, não só para "
217
+ + ",".join(others) + ". Use --agents uniflow para gravar no store.")
218
+
219
+
86
220
  def inventory(home):
87
221
  rows = []
88
222
  for name, (_, project, binary) in AGENTS.items():
@@ -29,7 +29,7 @@ import subprocess
29
29
  import sys
30
30
 
31
31
  from . import __version__
32
- from .agents import destinations
32
+ from .agents import destinations, legacy_shared_dir
33
33
  from .guide import protect, read_regular, sha, write_atomic
34
34
  from .source import CrowError, Source, same_source
35
35
  from .storage import atomic_json, checked, private_dir, read_json
@@ -334,8 +334,9 @@ def reinstall_behaviour(home, entry, dry_run, trust):
334
334
 
335
335
 
336
336
  def codex_duplicates(home, store, entry, names, dry_run):
337
- """Codex sees Uniflow's links in ~/.codex/skills: those names are not updated natively, and intact managed
338
- native copies of them are removed through uninstall (backup, transaction). Returns the names to update."""
337
+ """Codex sees Uniflow's links in its own dir: those names are not updated there, and intact managed copies
338
+ left in the legacy shared folder are removed through uninstall against THAT folder (backup, transaction).
339
+ Returns the names to update."""
339
340
  from . import projection
340
341
  from .cli import parser, run as cli_run
341
342
  seen = projection.projected("codex", home, store)
@@ -347,7 +348,8 @@ def codex_duplicates(home, store, entry, names, dry_run):
347
348
  entry["kept"] += [{"name": n, "status": "duplicate_modified"} for n in dupes["modified"]]
348
349
  entry["kept"] += [{"name": n, "status": "duplicate_shared"} for n in dupes["shared"]]
349
350
  if dupes["remove"]:
350
- argv = ["uninstall", *dupes["remove"], "--agents", "codex", "--global", "--json", "--home", str(home),
351
+ # --dest na pasta legada: em ~/.codex/skills esses nomes são links do Uniflow e o uninstall recusaria.
352
+ argv = ["uninstall", *dupes["remove"], "--dest", str(legacy_shared_dir(home)), "--json", "--home", str(home),
351
353
  "--dry-run" if dry_run else "--yes", "--no-integrations"]
352
354
  try:
353
355
  data = cli_run(parser().parse_args(argv))
@@ -411,6 +413,11 @@ def skills_update(home, trust=True, dry_run=False):
411
413
  entry["error"] = {"code": exc.code, "message": str(exc)}
412
414
  except (OSError, ValueError, KeyError, TypeError):
413
415
  entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
416
+ if agent == "codex" and not entry.get("error"):
417
+ # Ramo mais provável de quem vinha da 0.14.0: no diretório do Codex só há links do
418
+ # Uniflow, e TODAS as cópias do codex estão na pasta compartilhada. Sem migrar aqui,
419
+ # uma máquina que só roda autoupdate nunca sai daquela pasta.
420
+ machine.migrate_legacy(None, home, store, entry, dry_run, planned=projected)
414
421
  results.append(entry)
415
422
  continue
416
423
  records = store.state(target["path"])["installed"]
@@ -455,6 +462,12 @@ def skills_update(home, trust=True, dry_run=False):
455
462
  entry["error"] = {"code": exc.code, "message": str(exc)}
456
463
  except (OSError, ValueError, KeyError, TypeError):
457
464
  entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
465
+ if agent == "codex" and not entry.get("error"):
466
+ # Mesma migração do setup, e pelo mesmo motivo: sem isto, uma máquina que só roda
467
+ # autoupdate/upgrade mantém a pasta compartilhada antiga para sempre. Depois da
468
+ # atualização, nunca antes.
469
+ from . import machine
470
+ machine.migrate_legacy(None, home, store, entry, dry_run)
458
471
  results.append(entry)
459
472
  return results
460
473
 
@@ -548,30 +548,69 @@ TTY_TIMEOUT = 1.0
548
548
  TTY_TRIES = 2
549
549
 
550
550
 
551
+ def parse_elapsed(text):
552
+ """Segundos de `ps -o etime=` ([[dd-]hh:]mm:ss), ou None quando não dá para ler."""
553
+ if not isinstance(text, str) or not text.strip():
554
+ return None
555
+ body = text.strip()
556
+ days = 0
557
+ if "-" in body:
558
+ head, _, body = body.partition("-")
559
+ try:
560
+ days = int(head)
561
+ except ValueError:
562
+ return None
563
+ pieces = body.split(":")
564
+ if not 1 <= len(pieces) <= 3:
565
+ return None
566
+ try:
567
+ values = [int(x) for x in pieces]
568
+ except ValueError:
569
+ return None
570
+ while len(values) < 3:
571
+ values.insert(0, 0)
572
+ return days * 86400 + values[0] * 3600 + values[1] * 60 + values[2]
573
+
574
+
551
575
  def tty_lookup(pid, runner=None):
552
- """(tty, answered): the controlling terminal of pid as a device path (``/dev/ttys012``,
553
- ``/dev/pts/3``) or None, and whether ``ps`` actually answered (False on error, timeout or a
554
- non-POSIX system, so the caller may retry later). One ``ps -o tty= -p PID`` limited to
555
- TTY_TIMEOUT; never raises."""
576
+ """(tty, answered, elapsed): the controlling terminal of pid as a device path
577
+ (``/dev/ttys012``, ``/dev/pts/3``) or None; whether ``ps`` actually answered (False on error,
578
+ timeout or a non-POSIX system, so the caller may retry later); and how many seconds the process
579
+ has been running, or None.
580
+
581
+ ONE ``ps -o tty=,etime= -p PID`` limited to TTY_TIMEOUT, never raises. The elapsed time rides
582
+ along in the ps that already happens once per stable pid: recording when the CLI started is
583
+ what lets a reader tell "this pid is still the same process" from "the number was recycled",
584
+ and it must cost nothing on the hook's path."""
556
585
  if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or os.name != "posix":
557
- return None, False
586
+ return None, False, None
558
587
  try:
559
588
  if runner is None:
560
589
  import subprocess
561
- done = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)], stdin=subprocess.DEVNULL, capture_output=True,
562
- text=True, timeout=TTY_TIMEOUT)
590
+ done = subprocess.run(["ps", "-o", "tty=,etime=", "-p", str(pid)], stdin=subprocess.DEVNULL,
591
+ capture_output=True, text=True, timeout=TTY_TIMEOUT)
563
592
  if done.returncode != 0:
564
- return None, done.returncode == 1 # 1: no such process (a definite answer)
593
+ return None, done.returncode == 1, None # 1: no such process (a definite answer)
565
594
  out = done.stdout
566
595
  else:
567
596
  out = runner(pid)
568
597
  except Exception: # noqa: BLE001 - a missing tty is never an error
569
- return None, False
570
- name = (out or "").strip().splitlines()[0].strip() if (out or "").strip() else ""
598
+ return None, False, None
599
+ line = (out or "").strip().splitlines()[0].strip() if (out or "").strip() else ""
600
+ # "ttys012 01:23:45": o tty é a primeira coluna; o resto, quando existe, é o etime. Um runner
601
+ # de teste que devolva só o tty continua funcionando — o start fica None.
602
+ # Só separa a segunda coluna quando ela REALMENTE parece um etime; caso contrário a linha
603
+ # inteira é o nome do tty, como antes. Dividir por espaço sem checar aceitaria "tty s1" como
604
+ # o tty "tty", afrouxando a validação do nome.
605
+ parts = line.split()
606
+ name, elapsed = line, None
607
+ if len(parts) == 2 and parse_elapsed(parts[1]) is not None:
608
+ name, elapsed = parts[0], parts[1]
571
609
  base = name[len("/dev/"):] if name.startswith("/dev/") else name
610
+ seconds = parse_elapsed(elapsed)
572
611
  if not base or base.strip("?-") == "" or not re.fullmatch(r"[A-Za-z0-9._-][A-Za-z0-9/._-]{0,63}", base) or ".." in base:
573
- return None, True
574
- return "/dev/" + base, True
612
+ return None, True, seconds
613
+ return "/dev/" + base, True, seconds
575
614
 
576
615
 
577
616
  def tty_of(pid, runner=None):
@@ -610,12 +649,17 @@ def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=
610
649
  stable = isinstance(pid, int) and (pid == presence.get("cliPid") or presence.get("pidStable"))
611
650
  if stable and presence.get("ttyPid") != pid: # one short ps per stable pid (retried if ps did not answer)
612
651
  presence.pop("tty", None)
613
- tty, answered = tty_lookup(pid, tty_runner)
652
+ tty, answered, elapsed = tty_lookup(pid, tty_runner)
614
653
  tries = presence.get("ttyTries") if isinstance(presence.get("ttyTries"), list) and presence["ttyTries"][:1] == [pid] else [pid, 0]
615
654
  tries = [pid, int(tries[1]) + 1 if isinstance(tries[1], int) else 1]
616
655
  presence["ttyTries"] = tries
617
656
  if answered or tries[1] >= TTY_TRIES: # a ps that failed or timed out is retried once, never per event
618
657
  presence["ttyPid"] = pid
658
+ if isinstance(elapsed, int):
659
+ # Identidade do processo, de graça: o MESMO ps do tty traz o tempo decorrido. Guardar o
660
+ # início permite distinguir "o pid continua vivo" de "o número foi reciclado por outro
661
+ # processo" sem nenhuma chamada a mais no caminho do hook.
662
+ presence["cliStart"] = int(now - elapsed)
619
663
  if tty:
620
664
  presence["tty"] = tty
621
665
  if stable:
@@ -673,19 +717,93 @@ def presence_of(snap):
673
717
  return value if isinstance(value, dict) else {}
674
718
 
675
719
 
676
- def liveness(snap, age, cfg, now=None):
677
- """'live' or None: ended sessions and stale ones (no heartbeat, CLI pid not alive) drop out."""
720
+ CLI_REUSE_SECONDS = 60 # mesma tolerância de resources.py: start desta distância é o mesmo CLI
721
+
722
+
723
+ def process_identity(table, pid):
724
+ """(existe, é zumbi, start) de um pid numa tabela já lida, ou (None, None, None) sem tabela.
725
+
726
+ A tabela vem de quem JÁ leu os processos (crow-skills broadcast status, doctor, varredura de
727
+ órfãos). O caminho do hook não passa tabela e por isso não paga nada: lá vale a checagem barata
728
+ de `pid_alive`. Assim a identidade forte existe onde é de graça, e o contrato do hook
729
+ ("retorne rápido, nunca bloqueie") fica intacto.
730
+ """
731
+ # Tabela VAZIA não é "nenhum processo existe": é `ps` que falhou, deu timeout ou não existe
732
+ # neste sistema. Tratá-la como resposta apagava todas as sessões vivas de uma vez. Sem
733
+ # evidência, vale a checagem barata.
734
+ if not isinstance(table, dict) or not table or not isinstance(pid, int) or isinstance(pid, bool):
735
+ return None, None, None
736
+ row = table.get(pid)
737
+ if row is None:
738
+ return False, None, None
739
+ return True, bool(row.get("zombie")), row.get("start")
740
+
741
+
742
+ def process_table(runner=None):
743
+ """{pid: {"start": epoch, "zombie": bool}} num único `ps`, para quem vai avaliar várias sessões.
744
+
745
+ Um ps para a máquina inteira, não um por sessão. Quem não precisa de identidade forte (o hook)
746
+ simplesmente não chama isto."""
747
+ if os.name != "posix":
748
+ return {}
749
+ try:
750
+ if runner is None:
751
+ import subprocess
752
+ done = subprocess.run(["ps", "-Ao", "pid=,etime=,stat="], stdin=subprocess.DEVNULL,
753
+ capture_output=True, text=True, timeout=TTY_TIMEOUT * 3)
754
+ if done.returncode != 0:
755
+ return {}
756
+ out = done.stdout
757
+ else:
758
+ out = runner()
759
+ except Exception: # noqa: BLE001 - sem tabela, a checagem barata continua valendo
760
+ return {}
761
+ moment = time.time()
762
+ table = {}
763
+ for line in (out or "").splitlines():
764
+ parts = line.split()
765
+ if len(parts) < 3:
766
+ continue
767
+ try:
768
+ pid = int(parts[0])
769
+ except ValueError:
770
+ continue
771
+ seconds = parse_elapsed(parts[1])
772
+ table[pid] = {"start": int(moment - seconds) if isinstance(seconds, int) else None,
773
+ "zombie": parts[2][:1] in ("Z", "X")}
774
+ return table
775
+
776
+
777
+ def liveness(snap, age, cfg, now=None, table=None):
778
+ """'live' or None: ended sessions, stale ones and ones whose CLI process is gone drop out.
779
+
780
+ A session whose pid is STABLE (the resolved CLI, not a transient hook parent) and whose process
781
+ no longer exists is dead now, not in stale_minutes: a CLI that crashed used to keep claiming
782
+ `working` for half an hour, inflating the count and making every other session avoid its clone.
783
+ pid_alive returning None (it could not tell) is not treated as dead, and a session without a
784
+ stable pid still falls back to the heartbeat window.
785
+ """
678
786
  if snap.get("state") == "ended":
679
787
  return None
788
+ presence = presence_of(snap)
789
+ if presence.get("pidStable") and pid_alive(presence.get("pid")) is False:
790
+ return None
791
+ if presence.get("pidStable"):
792
+ exists, is_zombie, start = process_identity(table, presence.get("pid"))
793
+ if exists is False or is_zombie:
794
+ return None # o processo sumiu, ou é zumbi: a sessão acabou, sem esperar a janela
795
+ recorded = presence.get("cliStart")
796
+ if exists and isinstance(start, (int, float)) and isinstance(recorded, (int, float)) \
797
+ and abs(start - recorded) > CLI_REUSE_SECONDS:
798
+ return None # o número do pid foi reciclado por outro processo; esta sessão morreu
680
799
  if age <= cfg["stale_minutes"] * 60:
681
800
  return "live"
682
- presence = presence_of(snap)
683
801
  if presence.get("pidStable") and age <= cfg["pid_alive_hours"] * 3600 and pid_alive(presence.get("pid")):
684
802
  return "live"
685
803
  return None
686
804
 
687
805
 
688
- def live_sessions(home, cfg=None, now=None):
806
+ def live_sessions(home, cfg=None, now=None, table=None):
689
807
  """crow-session/1 snapshots of live sessions of this user, newest first, each with ageSeconds and repo."""
690
808
  cfg = cfg or config(home)
691
809
  now = now or time.time()
@@ -709,7 +827,7 @@ def live_sessions(home, cfg=None, now=None):
709
827
  snap = read_json(entry.path)
710
828
  if not isinstance(snap, dict) or snap.get("schema") != SESSION_SCHEMA:
711
829
  continue
712
- if liveness(snap, age, cfg, now) is None:
830
+ if liveness(snap, age, cfg, now, table=table) is None:
713
831
  continue
714
832
  snap["ageSeconds"] = max(0, int(age))
715
833
  presence = presence_of(snap)
@@ -1250,11 +1368,11 @@ def relations(me, other, now, cfg):
1250
1368
  return rel
1251
1369
 
1252
1370
 
1253
- def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None):
1371
+ def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None, table=None):
1254
1372
  """crow-broadcast/1: every live session with its relation to me, plus active claims."""
1255
1373
  cfg = cfg or config(home)
1256
1374
  now = now or time.time()
1257
- live = live_sessions(home, cfg, now)
1375
+ live = live_sessions(home, cfg, now, table=table)
1258
1376
  if me is None:
1259
1377
  me = next((s for s in live if s.get("key") == me_key), None) if me_key else None
1260
1378
  if me is None and cwd: