@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.
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +138 -4
- package/crow_skills/autoupdate.py +17 -4
- package/crow_skills/broadcast.py +138 -20
- package/crow_skills/cli.py +79 -24
- package/crow_skills/guide.py +10 -3
- package/crow_skills/hook.py +32 -14
- package/crow_skills/machine.py +143 -11
- package/crow_skills/projection.py +69 -27
- package/crow_skills/resources.py +5 -3
- package/crow_skills/storage.py +96 -11
- package/crow_skills/ui.py +1 -1
- package/docs/cli.md +35 -11
- package/package.json +1 -1
- package/release.json +16 -16
package/crow_skills/__init__.py
CHANGED
package/crow_skills/agents.py
CHANGED
|
@@ -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": (".
|
|
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":
|
|
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 =
|
|
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
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
package/crow_skills/broadcast.py
CHANGED
|
@@ -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
|
|
553
|
-
``/dev/pts/3``) or None
|
|
554
|
-
non-POSIX system, so the caller may retry later)
|
|
555
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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:
|