@andespindola/crow-skills 0.11.1 → 0.12.1
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/README.md +2 -2
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/broadcast.py +242 -2
- package/crow_skills/cli.py +56 -2
- package/crow_skills/hook.py +60 -3
- package/crow_skills/resources.py +877 -25
- package/docs/cli.md +44 -3
- package/package.json +1 -1
- package/release.json +20 -15
- package/skills/crow-behaviour/SKILL.md +2 -1
- package/skills/crow-behaviour/references/broadcast.md +2 -0
- package/skills/crow-behaviour/references/processes.md +42 -0
- package/skills/crow-behaviour/references/progress-ledger.md +4 -0
- package/skills/crow-behaviour/references/resources.md +1 -1
- package/skills/crow-behaviour/scripts/crow_ledger.py +619 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Skills com escopo explícito, conhecimento especializado preservado e fluxos comuns reutilizáveis.
|
|
4
4
|
|
|
5
|
-
**Versão 0.
|
|
5
|
+
**Versão 0.12.1** ([mudanças](CHANGELOG.md)): correção pequena na proteção de processos registrados e um teste instável. A 0.12.0 fez a crow-behaviour passar a encerrar a árvore inteira de todo processo que a execução inicia (com timeout, registro no ledger e verificação no fim) e `crow-skills resources --orphans` encontra e, com `--kill`, encerra só o que ficou órfão de ferramentas de agente. A 0.11.1 fez o Codex deixar de mostrar em dobro as skills que o Uniflow já projeta em `~/.codex/skills`. A 0.11.0 trouxe `crow-skills setup --yes`, que configura de uma vez todos os CLIs de agente da máquina, e `crow-skills upgrade --yes`, que atualiza tudo numa só passada; os hooks que o crow-skills grava já saem habilitados (no Codex, aprovados pelo próprio `codex app-server`, só os do crow-skills; `--no-trust` desliga) e `status`/`doctor` mostram se cada CLI vai rodá-los; a ajuda ficou curta e organizada por tarefa. A 0.10.1 passou a tratar swap e CPU como eixos de pressão no controle de recursos (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostrou a CPU% dos CLIs de agente, trouxe `crow-skills resources --exec-prefix` para rodar jobs pesados em prioridade baixa e as regras da crow-behaviour sob pressão (reduzir já, um job pesado por vez, não repetir verificação, validação ampla no CI); os hooks do Codex vão para o `config.toml` quando o `hooks.json` é symlink. A 0.10.0 trouxe o crow-broadcast, que compartilha, de forma passiva, o que cada sessão e cada CLI (Claude Code, Codex, Gemini CLI, Antigravity, Devin, inclusive dentro do Uniflow) está fazendo nesta máquina: repositório, clone, branch, execução crow em andamento e arquivos em edição; o prompt recebe um resumo só quando outra sessão viva está no mesmo repositório, editar um arquivo que outra sessão tocou ou trocar de branch num clone em uso gera aviso (ou bloqueio, se configurado), e `crow-skills broadcast claim` reserva áreas; a crow-behaviour consulta isso antes de planejar e reserva as áreas que delega ([crow-broadcast](docs/broadcast.md)). A 0.9.0 trouxe o controle de memória da máquina: `crow-skills resources` mede memória, pressão e os CLIs de agente em execução e recomenda quantos agentes, jobs pesados e workers rodar em paralelo; a crow-behaviour consulta essa recomendação antes de cada fan-out (sob `warn` reduz, sob `critical` segue sequencial), limita workers de builds e testes, reutiliza um dev server por projeto e encerra o que abriu, e 15 especialistas que sobem processos pesados ganharam a regra correspondente; o hook de prompt e as status lines só avisam quando a máquina está sob pressão. A 0.8.0 trouxe `crow-skills mode on|off`, que liga e desliga a crow-behaviour em todas as sessões e CLIs (Claude Code, Codex, Gemini CLI, Antigravity, Devin) a partir do próximo prompt, com `/crow on|off` dentro da sessão e o indicador `▸▸ crow on` na status line; a crow-behaviour mostra o progresso como a lista de agentes do host (`● main`, `○ dono atividade`); `crow-skills events` publica um barramento de eventos dos hooks para terminais; `crow-skills autoupdate on` atualiza o CLI e as skills gerenciadas todo dia e no login. A 0.7.1 trouxe as correções da revalidação no Codex às skills especialistas; a 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
|
|
6
6
|
|
|
7
7
|
**Reconstrução em andamento.** A primeira versão com 32 contratos genéricos não atende à preservação de capacidades solicitada. Não use esse número nem o mapeamento por categoria como prova de substituição das originais. A revisão passa a abranger cada definição e seus recursos, modos e dependências, sem número fixo de skills ou limite artificial de linhas. Veja [critérios e progresso](docs/reconstruction.md).
|
|
8
8
|
|
|
@@ -42,7 +42,7 @@ Exemplos:
|
|
|
42
42
|
O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente. No [Uniflow](docs/cli.md#uniflow), `--agents uniflow --global` grava as skills no store dele (`~/.config/uniflow/skills`), sem tocar no resto da configuração. Em `--global`, instalar a crow-behaviour também grava o guia aditivo do agente (claude, codex, gemini, devin) para usar as skills; `--no-guide` desliga. As integrações de interface (status lines do Claude Code, do Codex e do Antigravity, hooks do modo crow em cada CLI, comando `/crow`, segmento tmux) seguem o mesmo fluxo, preservando a configuração existente; `--no-integrations` desliga. O [barramento de eventos](docs/events.md) e a atualização automática (`crow-skills autoupdate on`) estão no [CLI](docs/cli.md).
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
|
-
npm install -g @andespindola/crow-skills@0.
|
|
45
|
+
npm install -g @andespindola/crow-skills@0.12.1
|
|
46
46
|
crow-skills mode
|
|
47
47
|
crow-skills progress
|
|
48
48
|
./bin/crow-skills list
|
package/crow_skills/__init__.py
CHANGED
package/crow_skills/broadcast.py
CHANGED
|
@@ -26,6 +26,10 @@ Text written by other agents (ledger titles, unit titles, claim notes) is data:
|
|
|
26
26
|
control and format characters are removed, length is capped, quotes are
|
|
27
27
|
neutralised and the block says it is not an instruction.
|
|
28
28
|
|
|
29
|
+
Session processes (crow-procs/1): on Stop and SessionEnd the hook records the
|
|
30
|
+
live descendants of the session's CLI (pid, start, redacted command) so an
|
|
31
|
+
orphan check can attribute leftovers to a dead session.
|
|
32
|
+
|
|
29
33
|
Single machine only: state lives under ~/.local/state/crow-skills; nothing is
|
|
30
34
|
sent anywhere. Config: ~/.config/crow-skills/broadcast.json (edited by the user).
|
|
31
35
|
"""
|
|
@@ -457,10 +461,18 @@ def pid_alive(pid):
|
|
|
457
461
|
return None # exists but not ours, or unknown: fall back to the heartbeat
|
|
458
462
|
|
|
459
463
|
|
|
460
|
-
def fold(snap, event, touched=(), now=None, ppid=None):
|
|
461
|
-
"""Update the session snapshot's presence object (in place) for one event.
|
|
464
|
+
def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None):
|
|
465
|
+
"""Update the session snapshot's presence object (in place) for one event.
|
|
466
|
+
|
|
467
|
+
cli_pid: the agent CLI resolved from a process table (Stop/SessionEnd); once known and alive it
|
|
468
|
+
is the presence pid, so a hook started through a transient ``sh -c`` does not replace it."""
|
|
462
469
|
now = now or time.time()
|
|
463
470
|
presence = snap.get("presence") if isinstance(snap.get("presence"), dict) else {}
|
|
471
|
+
if cli_pid:
|
|
472
|
+
presence["cliPid"] = cli_pid
|
|
473
|
+
known = presence.get("cliPid")
|
|
474
|
+
if known and pid_alive(known) is not False:
|
|
475
|
+
ppid = known
|
|
464
476
|
presence["schema"] = PRESENCE_SCHEMA
|
|
465
477
|
cwd = snap.get("cwd")
|
|
466
478
|
repo = presence.get("repo") if isinstance(presence.get("repo"), dict) else None
|
|
@@ -764,6 +776,234 @@ def prune_claims(home, now=None):
|
|
|
764
776
|
write_json(path, {"schema": CLAIMS_SCHEMA, "claims": kept})
|
|
765
777
|
|
|
766
778
|
|
|
779
|
+
# --- session processes (crow-procs/1) ---------------------------------------------------------
|
|
780
|
+
#
|
|
781
|
+
# On Stop and SessionEnd the hook records the live descendants of the session's CLI (pid, start
|
|
782
|
+
# time and a redacted command: executable plus package/script name, never arguments), so a later
|
|
783
|
+
# orphan check can attribute a leftover process to a session that is now dead even when it was
|
|
784
|
+
# not reparented to init/launchd (a subreaper such as tmux or systemd --user adopted it).
|
|
785
|
+
|
|
786
|
+
PROCS_SCHEMA = "crow-procs/1"
|
|
787
|
+
PROCS_TTL = 14 * 24 * 3600
|
|
788
|
+
MAX_PROCS = 200
|
|
789
|
+
KEY = re.compile(r"[0-9a-f]{20}")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def procs_dir(home):
|
|
793
|
+
return state_dir(home) / "procs"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _proc_entry(item):
|
|
797
|
+
if not isinstance(item, dict):
|
|
798
|
+
return None
|
|
799
|
+
pid, start, display = item.get("pid"), item.get("start"), item.get("display")
|
|
800
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or not isinstance(start, (int, float)) \
|
|
801
|
+
or isinstance(start, bool) or not isinstance(display, str):
|
|
802
|
+
return None
|
|
803
|
+
entry = {"pid": pid, "start": int(start), "display": clean(display, 80)}
|
|
804
|
+
for field in ("ppid", "pgid"):
|
|
805
|
+
if isinstance(item.get(field), int) and not isinstance(item.get(field), bool):
|
|
806
|
+
entry[field] = item[field]
|
|
807
|
+
return entry
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def record_processes(home, key, host, cli_pid, cli_start, processes, now=None):
|
|
811
|
+
"""Write the session's descendants (only when the set changed); returns True when written."""
|
|
812
|
+
if not isinstance(key, str) or not KEY.fullmatch(key) or not isinstance(cli_pid, int) or cli_pid <= 1:
|
|
813
|
+
return False
|
|
814
|
+
now = now or time.time()
|
|
815
|
+
entries = [e for e in (_proc_entry(p) for p in processes or []) if e][:MAX_PROCS]
|
|
816
|
+
path = procs_dir(home) / (key + ".json")
|
|
817
|
+
old = read_json(path, 256 * 1024)
|
|
818
|
+
same = isinstance(old, dict) and old.get("cliPid") == cli_pid and \
|
|
819
|
+
sorted((p.get("pid"), p.get("start")) for p in old.get("processes") or [] if isinstance(p, dict)) == \
|
|
820
|
+
sorted((e["pid"], e["start"]) for e in entries)
|
|
821
|
+
if same:
|
|
822
|
+
return False
|
|
823
|
+
write_json(path, {"schema": PROCS_SCHEMA, "key": key, "host": clean(host, 20), "cliPid": cli_pid,
|
|
824
|
+
"cliStart": int(cli_start) if isinstance(cli_start, (int, float)) else None,
|
|
825
|
+
"recordedAt": now_iso(now), "processes": entries})
|
|
826
|
+
return True
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def process_records(home, now=None):
|
|
830
|
+
"""Recorded descendants of every session (validated), newest files only (PROCS_TTL)."""
|
|
831
|
+
now = now or time.time()
|
|
832
|
+
directory = procs_dir(home)
|
|
833
|
+
result = []
|
|
834
|
+
try:
|
|
835
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
836
|
+
return result
|
|
837
|
+
entries = list(os.scandir(directory))
|
|
838
|
+
except OSError:
|
|
839
|
+
return result
|
|
840
|
+
for entry in entries:
|
|
841
|
+
if not entry.name.endswith(".json") or not KEY.fullmatch(entry.name[:-5]):
|
|
842
|
+
continue
|
|
843
|
+
try:
|
|
844
|
+
if now - entry.stat(follow_symlinks=False).st_mtime > PROCS_TTL:
|
|
845
|
+
continue
|
|
846
|
+
except OSError:
|
|
847
|
+
continue
|
|
848
|
+
data = read_json(entry.path, 256 * 1024)
|
|
849
|
+
if not isinstance(data, dict) or data.get("schema") != PROCS_SCHEMA or data.get("key") != entry.name[:-5]:
|
|
850
|
+
continue
|
|
851
|
+
cli = data.get("cliPid")
|
|
852
|
+
if not isinstance(cli, int) or isinstance(cli, bool):
|
|
853
|
+
continue
|
|
854
|
+
result.append({"key": data["key"], "host": data.get("host"), "cliPid": cli,
|
|
855
|
+
"cliStart": data.get("cliStart") if isinstance(data.get("cliStart"), (int, float)) else None,
|
|
856
|
+
"processes": [e for e in (_proc_entry(p) for p in data.get("processes") or []) if e]})
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
LEDGER_FRESH = 2 * 3600 # a running ledger touched this recently is live (crow-skills progress uses the same window)
|
|
861
|
+
MAX_LEDGER_BYTES = 1024 * 1024
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
REGISTRATION_SCHEMA = "crow-registration/1"
|
|
865
|
+
REGISTRATION_TTL = 14 * 24 * 3600
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def registrations_dir(home):
|
|
869
|
+
"""Where crow_ledger.py ``proc add`` writes a cwd-independent pointer to each registration, so
|
|
870
|
+
an orphan check from any directory or repository sees every live run's processes."""
|
|
871
|
+
return Path(home) / ".local" / "state" / "crow-skills" / "registrations"
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _number(value):
|
|
875
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _ledger_entry(item, cli_pid=None, cli_start=None, fresh=True):
|
|
879
|
+
"""A protectable registration: {pid, start, cliPid?, cliStart?, fresh}, or None."""
|
|
880
|
+
if not isinstance(item, dict) or item.get("status", "running") not in ("running", "survived") \
|
|
881
|
+
or not isinstance(item.get("pid"), int) or isinstance(item.get("pid"), bool) or not _number(item.get("start")):
|
|
882
|
+
return None
|
|
883
|
+
entry = {"pid": item["pid"], "start": item["start"], "fresh": fresh}
|
|
884
|
+
cli, cli_at = item.get("cliPid", cli_pid), item.get("cliStart", cli_start)
|
|
885
|
+
if isinstance(cli, int) and not isinstance(cli, bool) and cli > 1:
|
|
886
|
+
entry["cliPid"] = cli
|
|
887
|
+
if _number(cli_at):
|
|
888
|
+
entry["cliStart"] = cli_at
|
|
889
|
+
return entry
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def ledger_processes(home, cwds=(), live_keys=(), now=None, env=None):
|
|
893
|
+
"""Processes registered as running (``crow_ledger.py proc add``) by running crow-behaviour
|
|
894
|
+
runs: [{pid, start, cliPid?, cliStart?, fresh}]. Sources: the registration pointers under the
|
|
895
|
+
user state directory (any repository), $CROW_LEDGER, <dir>/.crow/runs from each given
|
|
896
|
+
directory up to the root, and ~/.crow/runs. The caller protects an entry while its agent CLI
|
|
897
|
+
(cliPid, same start) is alive; without a known CLI, while ``fresh`` (the ledger was updated
|
|
898
|
+
within LEDGER_FRESH or its host session is live). Stale or corrupt files are ignored."""
|
|
899
|
+
now = now or time.time()
|
|
900
|
+
env = os.environ if env is None else env
|
|
901
|
+
files, dirs = [], []
|
|
902
|
+
if env.get("CROW_LEDGER"):
|
|
903
|
+
files.append(Path(env["CROW_LEDGER"]).expanduser())
|
|
904
|
+
try:
|
|
905
|
+
directory = registrations_dir(home)
|
|
906
|
+
if not directory.is_symlink() and directory.is_dir():
|
|
907
|
+
for item in os.scandir(directory):
|
|
908
|
+
if item.name.endswith(".json"):
|
|
909
|
+
data = read_json(item.path, 64 * 1024)
|
|
910
|
+
if isinstance(data, dict) and data.get("schema") == REGISTRATION_SCHEMA and isinstance(data.get("ledger"), str):
|
|
911
|
+
files.append(Path(data["ledger"]))
|
|
912
|
+
except OSError:
|
|
913
|
+
pass
|
|
914
|
+
for cwd in cwds:
|
|
915
|
+
if not cwd:
|
|
916
|
+
continue
|
|
917
|
+
current = Path(cwd)
|
|
918
|
+
for directory in (current, *current.parents):
|
|
919
|
+
dirs.append(directory / ".crow" / "runs")
|
|
920
|
+
dirs.append(Path(home) / ".crow" / "runs")
|
|
921
|
+
seen = set()
|
|
922
|
+
for directory in dirs:
|
|
923
|
+
if str(directory) in seen:
|
|
924
|
+
continue
|
|
925
|
+
seen.add(str(directory))
|
|
926
|
+
try:
|
|
927
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
928
|
+
continue
|
|
929
|
+
files += [Path(e.path) for e in os.scandir(directory) if e.name.endswith(".json")]
|
|
930
|
+
except OSError:
|
|
931
|
+
continue
|
|
932
|
+
result, read, keys = [], set(), set()
|
|
933
|
+
for path in files:
|
|
934
|
+
if str(path) in read:
|
|
935
|
+
continue
|
|
936
|
+
read.add(str(path))
|
|
937
|
+
try:
|
|
938
|
+
mtime = path.stat().st_mtime
|
|
939
|
+
except OSError:
|
|
940
|
+
continue
|
|
941
|
+
data = read_json(path, MAX_LEDGER_BYTES)
|
|
942
|
+
if not isinstance(data, dict) or data.get("schema") != "crow-run/1" or data.get("status") != "running":
|
|
943
|
+
continue
|
|
944
|
+
session = data.get("session") if isinstance(data.get("session"), dict) else {}
|
|
945
|
+
key = session_key(session.get("host"), session.get("id"), None) if session.get("id") else None
|
|
946
|
+
fresh = now - mtime <= LEDGER_FRESH or key in live_keys
|
|
947
|
+
for item in data.get("processes") or []:
|
|
948
|
+
entry = _ledger_entry(item, fresh=fresh)
|
|
949
|
+
if entry and (entry["pid"], entry["start"]) not in keys:
|
|
950
|
+
keys.add((entry["pid"], entry["start"]))
|
|
951
|
+
result.append(entry)
|
|
952
|
+
return result # a pointer whose ledger is gone, unreadable or finished protects nothing
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def prune_registrations(home, now=None):
|
|
956
|
+
"""Drop pointers whose ledger is gone or finished, or older than REGISTRATION_TTL; quiet on failure."""
|
|
957
|
+
now = now or time.time()
|
|
958
|
+
directory = registrations_dir(home)
|
|
959
|
+
try:
|
|
960
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
961
|
+
return
|
|
962
|
+
for item in os.scandir(directory):
|
|
963
|
+
if not item.name.endswith(".json"):
|
|
964
|
+
continue
|
|
965
|
+
data = read_json(item.path, 64 * 1024)
|
|
966
|
+
ledger = read_json(data["ledger"], MAX_LEDGER_BYTES) if isinstance(data, dict) and isinstance(data.get("ledger"), str) else None
|
|
967
|
+
if not isinstance(ledger, dict) or ledger.get("status") != "running" or \
|
|
968
|
+
now - item.stat(follow_symlinks=False).st_mtime > REGISTRATION_TTL:
|
|
969
|
+
os.unlink(item.path)
|
|
970
|
+
except OSError:
|
|
971
|
+
pass
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def process_context(home, cfg=None, now=None, cwd=None):
|
|
975
|
+
"""What an orphan check needs from crow-broadcast: live session keys, the CLI pids of live
|
|
976
|
+
sessions (their trees are protected), the processes recorded per session, and the processes
|
|
977
|
+
live crow-behaviour runs registered (also protected)."""
|
|
978
|
+
sessions = live_sessions(home, cfg, now)
|
|
979
|
+
pids = set()
|
|
980
|
+
for snap in sessions:
|
|
981
|
+
presence = presence_of(snap)
|
|
982
|
+
for field in ("pid", "cliPid"):
|
|
983
|
+
pid = presence.get(field)
|
|
984
|
+
if isinstance(pid, int) and not isinstance(pid, bool) and pid > 1:
|
|
985
|
+
pids.add(pid)
|
|
986
|
+
keys = {s["key"] for s in sessions}
|
|
987
|
+
cwds = [cwd or os.getcwd()] + [s.get("cwd") for s in sessions if isinstance(s.get("cwd"), str)]
|
|
988
|
+
return {"liveKeys": keys, "livePids": pids, "records": process_records(home, now),
|
|
989
|
+
"ledgerProcesses": ledger_processes(home, cwds, keys, now)}
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def prune_processes(home, now=None):
|
|
993
|
+
"""Drop records older than PROCS_TTL and stale registration pointers (called on SessionStart)."""
|
|
994
|
+
now = now or time.time()
|
|
995
|
+
prune_registrations(home, now)
|
|
996
|
+
directory = procs_dir(home)
|
|
997
|
+
try:
|
|
998
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
999
|
+
return
|
|
1000
|
+
for entry in os.scandir(directory):
|
|
1001
|
+
if entry.name.endswith(".json") and now - entry.stat(follow_symlinks=False).st_mtime > PROCS_TTL:
|
|
1002
|
+
os.unlink(entry.path)
|
|
1003
|
+
except OSError:
|
|
1004
|
+
pass
|
|
1005
|
+
|
|
1006
|
+
|
|
767
1007
|
def git_branch(root):
|
|
768
1008
|
dot = Path(root) / ".git"
|
|
769
1009
|
try:
|
package/crow_skills/cli.py
CHANGED
|
@@ -71,8 +71,9 @@ COMMANDS = {
|
|
|
71
71
|
["crow-skills events", "crow-skills events tail --limit 20", "crow-skills events tail --follow"]),
|
|
72
72
|
"broadcast": ("Sessões vivas dos CLIs, arquivos em edição, conflitos e reservas.",
|
|
73
73
|
["crow-skills broadcast", "crow-skills broadcast claim src/api --ttl 30m", "crow-skills broadcast release --all"]),
|
|
74
|
-
"resources": ("Memória, pressão, agentes em execução
|
|
75
|
-
["crow-skills resources", "crow-skills resources --exec-prefix"
|
|
74
|
+
"resources": ("Memória, pressão, agentes em execução, paralelismo recomendado e processos órfãos de ferramentas de agente.",
|
|
75
|
+
["crow-skills resources", "crow-skills resources --exec-prefix", "crow-skills resources --orphans",
|
|
76
|
+
"crow-skills resources --orphans --kill --dry-run", "crow-skills resources --orphans --kill --yes"]),
|
|
76
77
|
"autoupdate": ("Agenda a atualização diária e no login (o mesmo fluxo de upgrade) ou roda agora.",
|
|
77
78
|
["crow-skills autoupdate on --yes", "crow-skills autoupdate status", "crow-skills autoupdate run --yes"]),
|
|
78
79
|
"rollback": ("Desfaz uma transação concluída (skills e o guia ligado a ela).", ["crow-skills rollback --transaction <id> --yes"]),
|
|
@@ -199,6 +200,11 @@ def parser():
|
|
|
199
200
|
child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
|
|
200
201
|
child.add_argument("--exec-prefix", action="store_true",
|
|
201
202
|
help="Só imprimir o prefixo que roda um job pesado em prioridade baixa neste sistema (taskpolicy/nice/ionice/start /low)")
|
|
203
|
+
child.add_argument("--orphans", action="store_true",
|
|
204
|
+
help="Listar processos órfãos de ferramentas de agente (árvore, idade, RSS); somente leitura sem --kill")
|
|
205
|
+
child.add_argument("--kill", action="store_true",
|
|
206
|
+
help="Com --orphans: encerrar as árvores órfãs (TERM, carência, KILL só se preciso); exige --yes ou confirmação")
|
|
207
|
+
child.add_argument("--older-than", help="Com --orphans: idade mínima da raiz (ex.: 30m, 2h, 1d; padrão 30m ou o do resources.json)")
|
|
202
208
|
child = command_parser(sub, "autoupdate", [common])
|
|
203
209
|
child.add_argument("action", choices=("on", "off", "status", "run"))
|
|
204
210
|
child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
|
|
@@ -572,6 +578,52 @@ def public(view):
|
|
|
572
578
|
return {k: v for k, v in view.items() if not k.startswith("_")}
|
|
573
579
|
|
|
574
580
|
|
|
581
|
+
def orphans_command(args, home, interactive):
|
|
582
|
+
"""resources --orphans [--kill]: read-only listing; a kill needs --yes or a TTY confirmation."""
|
|
583
|
+
if not args.orphans:
|
|
584
|
+
raise CrowError("usage", "--kill e --older-than só valem com --orphans.")
|
|
585
|
+
older = None
|
|
586
|
+
if args.older_than is not None:
|
|
587
|
+
try:
|
|
588
|
+
older = resources.parse_duration(args.older_than)
|
|
589
|
+
except ValueError as exc:
|
|
590
|
+
raise CrowError("usage", str(exc)) from None
|
|
591
|
+
try:
|
|
592
|
+
context = broadcast.process_context(home)
|
|
593
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
594
|
+
context = None # without crow-broadcast state only reparented roots count; live CLIs still protect their trees
|
|
595
|
+
doc = resources.orphan_scan(home, context=context, older_than=older)
|
|
596
|
+
if older is None and doc["available"]:
|
|
597
|
+
resources.write_orphan_cache(home, doc)
|
|
598
|
+
dry = getattr(args, "dry_run", False)
|
|
599
|
+
if args.kill and doc["trees"]:
|
|
600
|
+
entries = [e for tree in doc["trees"] for e in tree["_entries"]]
|
|
601
|
+
if dry:
|
|
602
|
+
doc["dryRun"] = True
|
|
603
|
+
doc["wouldKill"] = [{"pid": p["pid"], "command": p["command"]} for tree in doc["trees"] for p in tree["processes"]]
|
|
604
|
+
else:
|
|
605
|
+
if not getattr(args, "yes", False):
|
|
606
|
+
if not interactive:
|
|
607
|
+
raise CrowError("confirmation_required", "Use --dry-run para ver o que seria encerrado ou --yes para encerrar as árvores órfãs.")
|
|
608
|
+
print(resources.render_orphans(resources.public_orphans(doc)), file=sys.stderr)
|
|
609
|
+
if prompt("Encerrar estas árvores de processos? [s/N]").lower() not in ("s", "sim", "y", "yes"):
|
|
610
|
+
raise CrowError("cancelled", "Operação cancelada; nenhum processo sinalizado.")
|
|
611
|
+
if not resources.signals_allowed():
|
|
612
|
+
raise CrowError("usage", "CROW_PS_FIXTURE definido: uma tabela de teste nunca é sinalizada.")
|
|
613
|
+
doc["killed"] = resources.kill_trees(entries, grace=doc["settings"]["graceSeconds"])
|
|
614
|
+
doc["survivors"] = sum(1 for item in doc["killed"] if item["result"] == "survived")
|
|
615
|
+
after = resources.orphan_scan(home, context=context, older_than=older) # verification, and a fresh count
|
|
616
|
+
doc["remaining"] = after["count"]
|
|
617
|
+
if older is None and after["available"]:
|
|
618
|
+
resources.write_orphan_cache(home, after)
|
|
619
|
+
elif args.kill and dry:
|
|
620
|
+
doc["dryRun"] = True
|
|
621
|
+
doc["wouldKill"] = []
|
|
622
|
+
data = resources.public_orphans(doc)
|
|
623
|
+
data["text"] = resources.render_orphans(data)
|
|
624
|
+
return data
|
|
625
|
+
|
|
626
|
+
|
|
575
627
|
def broadcast_command(args, home):
|
|
576
628
|
action = args.action
|
|
577
629
|
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
@@ -871,6 +923,8 @@ def run(args):
|
|
|
871
923
|
if args.command == "resources":
|
|
872
924
|
if args.exec_prefix:
|
|
873
925
|
return resources.exec_prefix_data()
|
|
926
|
+
if args.orphans or args.kill or args.older_than is not None:
|
|
927
|
+
return orphans_command(args, home, interactive)
|
|
874
928
|
data = resources.probe(home, processes=not args.no_processes)
|
|
875
929
|
data["text"] = resources.render(data)
|
|
876
930
|
return data
|
package/crow_skills/hook.py
CHANGED
|
@@ -27,6 +27,12 @@ live sessions in the same repository, only when there are any; ``--check``
|
|
|
27
27
|
live session on the same file or clone without writing anything.
|
|
28
28
|
|
|
29
29
|
crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
|
|
30
|
+
|
|
31
|
+
On Stop and SessionEnd the hook also records the live descendants of the
|
|
32
|
+
session's CLI (pid, start time, executable plus package/script name; never
|
|
33
|
+
arguments) in the crow-broadcast state, and at most every 10 minutes refreshes
|
|
34
|
+
the orphan count (crow-skills resources --orphans) that the memory line cites
|
|
35
|
+
under pressure: one ps call on those events, none on prompts.
|
|
30
36
|
"""
|
|
31
37
|
from datetime import datetime, timezone
|
|
32
38
|
import hashlib
|
|
@@ -236,11 +242,48 @@ def resource_hint(home):
|
|
|
236
242
|
if module is None:
|
|
237
243
|
return None
|
|
238
244
|
try:
|
|
239
|
-
|
|
245
|
+
text = module.hint(module.probe(home, processes=False, use_cache=True))
|
|
246
|
+
if text and hasattr(module, "orphan_hint"):
|
|
247
|
+
text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
|
|
248
|
+
return text
|
|
240
249
|
except Exception: # noqa: BLE001
|
|
241
250
|
return None
|
|
242
251
|
|
|
243
252
|
|
|
253
|
+
STOP_EVENTS = ("Stop", "AfterAgent", "PostInvocation", "SessionEnd")
|
|
254
|
+
ORPHAN_REFRESH = 600 # seconds: a Stop event rescans orphans at most this often (it already has the table)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def record_processes(home, host, event, ppid=None):
|
|
258
|
+
"""Stop/SessionEnd: record the CLI's live descendants in crow-broadcast state (pid, start,
|
|
259
|
+
redacted command), so a later orphan check can attribute leftovers to a dead session; and
|
|
260
|
+
refresh the orphan count the pressure hint reads. One ps call; never raises."""
|
|
261
|
+
module, probe = broadcast_module(), resources_module()
|
|
262
|
+
if module is None or probe is None or not hasattr(probe, "read_table") or not hasattr(module, "record_processes"):
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
cfg = module.config(home)
|
|
266
|
+
if not cfg["enabled"]:
|
|
267
|
+
return None
|
|
268
|
+
rows = probe.read_table()
|
|
269
|
+
if not rows:
|
|
270
|
+
return None
|
|
271
|
+
now = time.time()
|
|
272
|
+
cli = probe.hook_cli_pid(rows, os.getppid() if ppid is None else ppid)
|
|
273
|
+
row = next((r for r in rows if r["pid"] == cli), None)
|
|
274
|
+
if row is None or cli <= 1:
|
|
275
|
+
return None
|
|
276
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
277
|
+
module.record_processes(home, key, host, cli, probe.start_of(row, now),
|
|
278
|
+
probe.session_descendants(rows, cli, now=now), now)
|
|
279
|
+
if probe.read_orphan_cache(home, max_age=ORPHAN_REFRESH) is None:
|
|
280
|
+
doc = probe.orphan_scan(home, rows=rows, context=module.process_context(home, cfg, now, event.get("cwd")), now=now)
|
|
281
|
+
probe.write_orphan_cache(home, doc)
|
|
282
|
+
return cli
|
|
283
|
+
except Exception: # noqa: BLE001 - housekeeping never breaks the event
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
244
287
|
# --- event bus ----------------------------------------------------------------------------
|
|
245
288
|
|
|
246
289
|
def append_event(home, event):
|
|
@@ -373,9 +416,12 @@ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
|
|
|
373
416
|
if extra:
|
|
374
417
|
snap.update(extra)
|
|
375
418
|
if presence:
|
|
376
|
-
module, touched, ppid = presence
|
|
419
|
+
module, touched, ppid = presence[:3]
|
|
377
420
|
try:
|
|
378
|
-
|
|
421
|
+
if len(presence) > 3: # (…, cli pid resolved from the process table on Stop/SessionEnd)
|
|
422
|
+
module.fold(snap, event, touched, ppid=ppid, cli_pid=presence[3])
|
|
423
|
+
else:
|
|
424
|
+
module.fold(snap, event, touched, ppid=ppid)
|
|
379
425
|
except Exception: # noqa: BLE001 - presence is best effort
|
|
380
426
|
pass
|
|
381
427
|
write_json(path, snap)
|
|
@@ -696,6 +742,15 @@ def handle(host, raw, payload, home, env=None, check=False):
|
|
|
696
742
|
except Exception: # noqa: BLE001 - the broadcast never breaks the event
|
|
697
743
|
module, presence = None, None
|
|
698
744
|
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
|
|
745
|
+
if raw in STOP_EVENTS:
|
|
746
|
+
cli = record_processes(home, host, event)
|
|
747
|
+
if cli and snap is not None:
|
|
748
|
+
bmod = broadcast_module()
|
|
749
|
+
if bmod is not None:
|
|
750
|
+
try: # the resolved CLI pid becomes the presence pid (a hook's sh -c parent is transient)
|
|
751
|
+
update_session(home, dict(stamped, event="other"), presence=(bmod, [], None, cli))
|
|
752
|
+
except Exception: # noqa: BLE001
|
|
753
|
+
pass
|
|
699
754
|
if module is not None and host in SYNC_TOOL_HOSTS:
|
|
700
755
|
if raw in TOOL_START_EVENTS and (touched or tree or checked):
|
|
701
756
|
return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
|
|
@@ -848,6 +903,8 @@ def run_main(args):
|
|
|
848
903
|
if module is not None:
|
|
849
904
|
try:
|
|
850
905
|
module.prune_claims(home)
|
|
906
|
+
if hasattr(module, "prune_processes"):
|
|
907
|
+
module.prune_processes(home)
|
|
851
908
|
except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
|
|
852
909
|
pass
|
|
853
910
|
except Exception: # noqa: BLE001 - a hook must never break the agent
|