@andespindola/crow-skills 0.11.1 → 0.12.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/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.11.1** ([mudanças](CHANGELOG.md)): o Codex deixa 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).
5
+ **Versão 0.12.0** ([mudanças](CHANGELOG.md)): a crow-behaviour passa 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.11.1
45
+ npm install -g @andespindola/crow-skills@0.12.0
46
46
  crow-skills mode
47
47
  crow-skills progress
48
48
  ./bin/crow-skills list
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.11.1"
3
+ __version__ = "0.12.0"
@@ -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:
@@ -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 e paralelismo recomendado (somente leitura).",
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
@@ -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
- return module.hint(module.probe(home, processes=False, use_cache=True))
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
- module.fold(snap, event, touched, ppid=ppid)
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