@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 +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 +876 -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/crow_skills/resources.py
CHANGED
|
@@ -31,6 +31,10 @@ JSON raw reading (optionally with an "agents" object); the cache is then bypasse
|
|
|
31
31
|
|
|
32
32
|
The probe never changes a host limit. Thresholds come from DEFAULTS, merged
|
|
33
33
|
with ~/.config/crow-skills/resources.json when it exists.
|
|
34
|
+
|
|
35
|
+
Orphan processes (crow-orphans/1, ``crow-skills resources --orphans``) read a
|
|
36
|
+
fuller table (``ps -o pid=,ppid=,pgid=,uid=,rss=,etime=,stat=,args=``) only
|
|
37
|
+
when asked, or from the Stop/SessionEnd hooks; see the section below.
|
|
34
38
|
"""
|
|
35
39
|
from datetime import datetime, timezone
|
|
36
40
|
import json
|
|
@@ -119,35 +123,41 @@ def now_iso():
|
|
|
119
123
|
|
|
120
124
|
# --- configuration -----------------------------------------------------------------------
|
|
121
125
|
|
|
122
|
-
def
|
|
123
|
-
"""
|
|
126
|
+
def config_file(home):
|
|
127
|
+
"""(dict or None, errors) from the user's resources.json; never raises."""
|
|
124
128
|
path = config_path(home)
|
|
125
|
-
effective, errors, loaded = dict(DEFAULTS), [], False
|
|
126
129
|
try:
|
|
127
130
|
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if not isinstance(data, dict):
|
|
135
|
-
errors.append("resources.json não é um objeto JSON; padrões usados.")
|
|
136
|
-
else:
|
|
137
|
-
loaded = True
|
|
138
|
-
for key, value in data.items():
|
|
139
|
-
if key in ("$schema", "schema", "comment"):
|
|
140
|
-
continue
|
|
141
|
-
if key not in DEFAULTS:
|
|
142
|
-
errors.append("chave desconhecida ignorada: " + str(key)[:40])
|
|
143
|
-
continue
|
|
144
|
-
low, high = LIMITS[key]
|
|
145
|
-
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
|
146
|
-
errors.append(key + " inválido (esperado número entre " + str(low) + " e " + str(high) + "); padrão mantido.")
|
|
147
|
-
continue
|
|
148
|
-
effective[key] = int(value) if key in INTEGER_KEYS else float(value)
|
|
131
|
+
return None, ["resources.json não é um arquivo regular; padrões usados."]
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return None, []
|
|
134
|
+
if path.stat().st_size > MAX_CONFIG:
|
|
135
|
+
return None, ["resources.json maior que 64 KiB; padrões usados."]
|
|
136
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
149
137
|
except (OSError, ValueError, UnicodeDecodeError):
|
|
150
|
-
|
|
138
|
+
return None, ["resources.json ilegível ou JSON inválido; padrões usados."]
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
return None, ["resources.json não é um objeto JSON; padrões usados."]
|
|
141
|
+
return data, []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_config(home):
|
|
145
|
+
"""Effective thresholds and the problems found in the user's file (never raises)."""
|
|
146
|
+
path = config_path(home)
|
|
147
|
+
effective = dict(DEFAULTS)
|
|
148
|
+
data, errors = config_file(home)
|
|
149
|
+
loaded = data is not None
|
|
150
|
+
for key, value in (data or {}).items():
|
|
151
|
+
if key in ("$schema", "schema", "comment", "orphans"): # orphans: orphan_config()
|
|
152
|
+
continue
|
|
153
|
+
if key not in DEFAULTS:
|
|
154
|
+
errors.append("chave desconhecida ignorada: " + str(key)[:40])
|
|
155
|
+
continue
|
|
156
|
+
low, high = LIMITS[key]
|
|
157
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
|
158
|
+
errors.append(key + " inválido (esperado número entre " + str(low) + " e " + str(high) + "); padrão mantido.")
|
|
159
|
+
continue
|
|
160
|
+
effective[key] = int(value) if key in INTEGER_KEYS else float(value)
|
|
151
161
|
for warn_key, critical_key, below in PAIRS:
|
|
152
162
|
if (effective[critical_key] > effective[warn_key]) if below else (effective[critical_key] < effective[warn_key]):
|
|
153
163
|
errors.append(critical_key + (" maior que " if below else " menor que ") + warn_key + "; os dois padrões foram restaurados.")
|
|
@@ -794,6 +804,847 @@ def probe(home, processes=True, use_cache=False, platform=None):
|
|
|
794
804
|
return build(reading, cfg, meta, agents, cached, age)
|
|
795
805
|
|
|
796
806
|
|
|
807
|
+
# --- orphan processes (crow-orphans/1) ------------------------------------------------------
|
|
808
|
+
#
|
|
809
|
+
# A process left behind by agent tooling: a test runner, a stdio MCP server wrapper (npm exec /
|
|
810
|
+
# npx), a browser driver or a build that outlived the session that started it. Agent CLIs start
|
|
811
|
+
# each MCP server in its own process group, so killing the child's group leaks grandchildren,
|
|
812
|
+
# which the kernel reparents to init/launchd (ppid 1). Detection therefore follows the process
|
|
813
|
+
# TREE (a root and every descendant, whatever their groups), and a kill snapshots that tree
|
|
814
|
+
# before the first signal: once the parent dies the ppid links that find the grandchildren are
|
|
815
|
+
# gone.
|
|
816
|
+
#
|
|
817
|
+
# Orphan root: a process of this user, older than the threshold, that is either reparented to
|
|
818
|
+
# init/launchd (ppid 1) or was recorded by the hooks as a descendant of a session that is now dead
|
|
819
|
+
# (same pid, same start time and same redacted command: a reused pid never matches), AND whose
|
|
820
|
+
# command matches an agent-tooling signature (or is a shell/env/timeout wrapper of a descendant
|
|
821
|
+
# that does). Never: pid 1, this process and its ancestors, processes of other users, processes
|
|
822
|
+
# of live sessions (crow-broadcast presence), allowlisted ones (anywhere in the tree), or, on
|
|
823
|
+
# Linux, processes of a systemd service. Command lines are read only to match signatures and to
|
|
824
|
+
# recheck identity before a signal; they are never printed or stored: the output carries the
|
|
825
|
+
# executable and the package/script name, with anything token-like masked.
|
|
826
|
+
|
|
827
|
+
ORPHAN_SCHEMA = "crow-orphans/1"
|
|
828
|
+
ORPHAN_CACHE_SCHEMA = "crow-orphans-cache/1"
|
|
829
|
+
ORPHAN_DEFAULTS = {"olderThanMinutes": 30, "graceSeconds": 5}
|
|
830
|
+
ORPHAN_LIMITS = {"olderThanMinutes": (0, 30 * 24 * 60), "graceSeconds": (0.1, 120)}
|
|
831
|
+
ORPHAN_CACHE_SECONDS = 30 * 60 # the prompt hint trusts an orphan scan this recent
|
|
832
|
+
START_TOLERANCE = 3 # seconds between two estimates of a process start time (etime has 1 s resolution)
|
|
833
|
+
# Records written by a hook and read by a later scan: two clocks, ps latency and possible clock steps.
|
|
834
|
+
# A mismatch only ever means "not an orphan" for a process, and "alive" for a session's CLI.
|
|
835
|
+
RECORD_TOLERANCE = 10
|
|
836
|
+
CLI_REUSE_SECONDS = 60 # a recorded CLI pid alive with a start this close is the same, live CLI
|
|
837
|
+
PS_COLUMNS = "pid=,ppid=,pgid=,uid=,rss=,etime=,stat=,args="
|
|
838
|
+
# Name -> regex searched (case-insensitive) in the full command line. Config can add, replace or
|
|
839
|
+
# disable (null) any of them.
|
|
840
|
+
SIGNATURES = {
|
|
841
|
+
"npm-exec": r"(^|/)(npm|pnpm|yarn|bun)(\.cmd)?\s+(exec|x|dlx)(\s|$)|(^|/)(npx|pnpx|bunx|uvx)(\s|$)|npx-cli\.js",
|
|
842
|
+
"mcp-server": r"mcp[-_]?server|server[-_][a-z0-9_-]*mcp|[@/_-]mcp([-_/\s@]|$)",
|
|
843
|
+
"node-test": r"(^|/)(node|nodejs|bun|deno|tsx|ts-node)\s(.*[\s/._-])?(test|tests|spec|specs)([/._-]|\s|$)|(^|/)node\s(.*\s)?--test(\s|$)",
|
|
844
|
+
"test-runner": r"(^|/)(jest|vitest|mocha|ava|karma|cypress|pytest|py\.test|tox|nox)(\s|$)|\s-m\s+(pytest|unittest)(\s|$)"
|
|
845
|
+
r"|(^|/)go\s+test(\s|$)|(^|/)playwright\s+test(\s|$)",
|
|
846
|
+
"browser-driver": r"(^|/)(chromedriver|geckodriver|msedgedriver)(\s|$)|ms-playwright|chrome-headless-shell|puppeteer"
|
|
847
|
+
r"|(^|/)chrom(e|ium)[^/\s]*\s(.*\s)?--headless",
|
|
848
|
+
"rust-build": r"(^|/)(cargo|rustc)(\s|$)",
|
|
849
|
+
"dev-server": r"(^|/)(vite|nodemon|webpack-dev-server|parcel)(\s|$)|(^|/)(next|nuxt|astro|webpack)\s+(dev|serve|start)(\s|$)"
|
|
850
|
+
r"|next-server|(^|/)tsc\s(.*\s)?(-w|--watch)(\s|$)|(^|/)esbuild\s(.*\s)?--watch",
|
|
851
|
+
}
|
|
852
|
+
# A root with this executable is an orphan only through a descendant that matches a signature.
|
|
853
|
+
WRAPPERS = ("sh", "bash", "zsh", "dash", "ksh", "fish", "env", "timeout", "gtimeout", "nohup", "time", "xargs")
|
|
854
|
+
# Generic long-running daemons people start by hand (VPN, keep-awake, agents, local services):
|
|
855
|
+
# protected even when they match a signature or sit in a matching tree. Personal additions go
|
|
856
|
+
# to resources.json ("orphans": {"allowlist": [...]}), never here.
|
|
857
|
+
ALLOWLIST = ("wg-quick", "wireguard-go", "openvpn", "tailscaled", "caffeinate", "ssh-agent", "gpg-agent", "ollama",
|
|
858
|
+
"colima", "limactl", "dockerd", "containerd", "syncthing", "postgres", "redis-server", "mongod", "mysqld")
|
|
859
|
+
SHELLS = ("sh", "bash", "zsh", "dash", "ksh", "fish")
|
|
860
|
+
NPM_LIKE = ("npm", "pnpm", "yarn", "bun")
|
|
861
|
+
PACKAGE_RUNNERS = ("npx", "pnpx", "bunx", "uvx", "pipx")
|
|
862
|
+
INTERPRETER = re.compile(r"(node|nodejs|bun|deno|tsx|ts-node|python[0-9.]*|ruby|perl|php)", re.IGNORECASE)
|
|
863
|
+
SUBCOMMANDS = ("test", "run", "build", "check", "dev", "serve", "start", "watch", "exec", "x", "dlx", "bench", "clippy", "install")
|
|
864
|
+
TOKENISH = re.compile(r"(?i)token|secret|passw|apikey|api[_-]key|bearer|credential|authorization|private[_-]?key"
|
|
865
|
+
r"|(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA[A-Z0-9]|AIza)|^sk-"
|
|
866
|
+
r"|^(?=.*[0-9])(?=.*[A-Za-z])[A-Za-z0-9+/_=.-]{32,}$|^(?=.*[0-9])[0-9a-fA-F]{20,}$")
|
|
867
|
+
SAFE_WORD = re.compile(r"[^A-Za-z0-9@._/:+=-]")
|
|
868
|
+
# What a shown word may look like: a path/script/module name, or an npm-style package spec
|
|
869
|
+
# (@scope/name@version). URLs, user:password@host, NAME=value and anything else are masked.
|
|
870
|
+
SHOWN_WORD = re.compile(r"(@[A-Za-z0-9._-]+/)?[A-Za-z0-9._/+-]+(@[A-Za-z0-9._^~+-]+)?")
|
|
871
|
+
RUN_SCRIPT = re.compile(r"[A-Za-z0-9._-]{1,30}(:[A-Za-z0-9._-]{1,30}){0,3}") # npm run test:unit (only there)
|
|
872
|
+
ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
|
|
873
|
+
# Options whose next word is a value, not the package/script (uvx --from git+https://..., node -r x).
|
|
874
|
+
VALUE_OPTIONS = ("--from", "--spec", "-p", "--with", "--registry", "--cache", "--prefix", "-r", "--require", "--import",
|
|
875
|
+
"--loader", "--experimental-loader", "-w", "--workspace", "--userconfig", "-C", "--cwd", "--dir", "--python")
|
|
876
|
+
INLINE_OPTIONS = ("-c", "--call", "-e", "--eval") # everything after them is inline code or a command line: never shown
|
|
877
|
+
ENV_VALUE_OPTIONS = ("-u", "--unset", "-C", "--chdir", "-S", "--split-string") # env's own options with a value
|
|
878
|
+
# What an interpreter's script looks like: a path, or a file with a script extension. Other operands
|
|
879
|
+
# (option values such as 127.0.0.1:9229 or lib) are never shown as the script.
|
|
880
|
+
SCRIPT_LIKE = re.compile(r"\S*/\S*|[^/\s]+\.(m?[jt]sx?|c[jt]s|py|rb|pl|php|sh|bash|zsh|lua)", re.IGNORECASE)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def parse_etime(text):
|
|
884
|
+
"""ps etime ([[dd-]hh:]mm:ss) -> seconds, or None."""
|
|
885
|
+
match = re.fullmatch(r"\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*", text or "")
|
|
886
|
+
if not match:
|
|
887
|
+
return None
|
|
888
|
+
days, hours, minutes, seconds = (int(x) if x else 0 for x in match.groups())
|
|
889
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def parse_table(text):
|
|
893
|
+
"""``ps -o pid=,ppid=,pgid=,uid=,rss=,etime=,stat=,args=`` (macOS or procps) -> rows:
|
|
894
|
+
{pid, ppid, pgid, uid, rssKb, age (s), stat, command}. The command stays in memory only."""
|
|
895
|
+
rows = []
|
|
896
|
+
for line in (text or "").splitlines():
|
|
897
|
+
parts = line.split(None, 7)
|
|
898
|
+
if len(parts) < 7 or not all(p.isdigit() for p in parts[:5]):
|
|
899
|
+
continue
|
|
900
|
+
age = parse_etime(parts[5])
|
|
901
|
+
if age is None:
|
|
902
|
+
continue
|
|
903
|
+
rows.append({"pid": int(parts[0]), "ppid": int(parts[1]), "pgid": int(parts[2]), "uid": int(parts[3]),
|
|
904
|
+
"rssKb": int(parts[4]), "age": age, "stat": parts[6], "command": parts[7].strip() if len(parts) > 7 else ""})
|
|
905
|
+
return rows
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def ps_argv(platform=None):
|
|
909
|
+
platform = platform or sys.platform
|
|
910
|
+
if platform.startswith("linux"):
|
|
911
|
+
return ["ps", "-e", "-ww", "-o", PS_COLUMNS]
|
|
912
|
+
# BSD/macOS: never -e there (it prints the environment).
|
|
913
|
+
return ["ps", "-axww", "-o", PS_COLUMNS]
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def table_is_fixture():
|
|
917
|
+
return bool(os.environ.get("CROW_PS_FIXTURE"))
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def signals_allowed():
|
|
921
|
+
"""A kill only ever runs against the real process table (never a test fixture)."""
|
|
922
|
+
return not table_is_fixture()
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def orphan_note(config_file_path):
|
|
926
|
+
"""Shown with every non-empty listing: a hand-started daemon may match a signature."""
|
|
927
|
+
return ("Atenção: uma árvore listada pode ser um daemon que o usuário iniciou à mão (fora do launchd/systemd), não uma "
|
|
928
|
+
"sobra de agente; nada é encerrado sem --kill. Para excluí-la, adicione o pacote ou script à allowlist em " +
|
|
929
|
+
str(config_file_path) + ', por exemplo {"orphans": {"allowlist": ["@scope/meu-servidor-mcp", "re:meu-daemon\\\\.js"]}}.')
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def read_table(platform=None):
|
|
933
|
+
"""Rows of every process (one ps call), or None when ps is unavailable. Tests (and only
|
|
934
|
+
tests) pin the table with CROW_PS_FIXTURE, the path of a ps output in the same format."""
|
|
935
|
+
fixture = os.environ.get("CROW_PS_FIXTURE")
|
|
936
|
+
if fixture:
|
|
937
|
+
try:
|
|
938
|
+
return parse_table(Path(fixture).read_text(encoding="utf-8"))
|
|
939
|
+
except OSError:
|
|
940
|
+
return None
|
|
941
|
+
platform = platform or sys.platform
|
|
942
|
+
if not (platform == "darwin" or platform.startswith("linux") or "bsd" in platform):
|
|
943
|
+
return None
|
|
944
|
+
text = _run(ps_argv(platform), timeout=5.0)
|
|
945
|
+
return parse_table(text) if text else None
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def zombie(row):
|
|
949
|
+
return row["stat"][:1] in ("Z", "X")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def start_of(row, now):
|
|
953
|
+
return now - row["age"]
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _basename(word):
|
|
957
|
+
return os.path.basename(word.rstrip("/")) or word
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _mask(word, limit=60, shape=SHOWN_WORD):
|
|
961
|
+
raw = word.strip("'\"")
|
|
962
|
+
word = SAFE_WORD.sub("", raw)
|
|
963
|
+
if not word:
|
|
964
|
+
return ""
|
|
965
|
+
if word != raw or "://" in word or "=" in word or not shape.fullmatch(word) or TOKENISH.search(word):
|
|
966
|
+
return "***"
|
|
967
|
+
return word[:limit]
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _script(words):
|
|
971
|
+
"""First operand that looks like a script (skipping option values)."""
|
|
972
|
+
skip = False
|
|
973
|
+
for word in words:
|
|
974
|
+
if skip:
|
|
975
|
+
skip = False
|
|
976
|
+
continue
|
|
977
|
+
if word in INLINE_OPTIONS:
|
|
978
|
+
return None
|
|
979
|
+
if word in VALUE_OPTIONS:
|
|
980
|
+
skip = True
|
|
981
|
+
continue
|
|
982
|
+
if word == "--" or word.startswith("-"):
|
|
983
|
+
continue
|
|
984
|
+
if SCRIPT_LIKE.fullmatch(word.strip("'\"")):
|
|
985
|
+
return word
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _operand(words):
|
|
990
|
+
"""First non-option word (``--package=X`` and ``--package X`` give X; values of VALUE_OPTIONS are skipped)."""
|
|
991
|
+
skip = False
|
|
992
|
+
for index, word in enumerate(words):
|
|
993
|
+
if skip:
|
|
994
|
+
skip = False
|
|
995
|
+
continue
|
|
996
|
+
if word.startswith("--package="):
|
|
997
|
+
return word.split("=", 1)[1]
|
|
998
|
+
if word == "--package" and index + 1 < len(words):
|
|
999
|
+
return words[index + 1]
|
|
1000
|
+
if word in INLINE_OPTIONS:
|
|
1001
|
+
return None
|
|
1002
|
+
if word in VALUE_OPTIONS:
|
|
1003
|
+
skip = True
|
|
1004
|
+
continue
|
|
1005
|
+
if word == "--" or word.startswith("-"):
|
|
1006
|
+
continue
|
|
1007
|
+
return word
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def redact(command):
|
|
1012
|
+
"""Executable + package/script name only, anything token-like masked: what may be shown."""
|
|
1013
|
+
words = (command or "").split()
|
|
1014
|
+
while words and ASSIGNMENT.match(words[0].strip("'\"")):
|
|
1015
|
+
words = words[1:] # NAME=value prefixes are never shown
|
|
1016
|
+
if not words:
|
|
1017
|
+
return "?"
|
|
1018
|
+
exe = _mask(_basename(words[0]), 40) or "?"
|
|
1019
|
+
rest = words[1:]
|
|
1020
|
+
parts = [exe]
|
|
1021
|
+
if exe in SHELLS and "-c" in rest:
|
|
1022
|
+
inner = " ".join(rest[rest.index("-c") + 1:]).strip("'\"")
|
|
1023
|
+
inner = re.sub(r"^\s*exec\s+", "", inner)
|
|
1024
|
+
return (exe + " -c " + redact(inner))[:80]
|
|
1025
|
+
if exe == "env":
|
|
1026
|
+
while rest and (rest[0].startswith("-") or ASSIGNMENT.match(rest[0])):
|
|
1027
|
+
rest = rest[2:] if rest[0] in ENV_VALUE_OPTIONS else rest[1:]
|
|
1028
|
+
return ("env " + redact(" ".join(rest)))[:80] if rest else "env"
|
|
1029
|
+
if exe in NPM_LIKE and rest and rest[0] in SUBCOMMANDS:
|
|
1030
|
+
parts.append(rest[0])
|
|
1031
|
+
package = _operand(rest[1:])
|
|
1032
|
+
if package and rest[0] in ("exec", "x", "dlx"):
|
|
1033
|
+
parts.append(_mask(package))
|
|
1034
|
+
elif package and rest[0] in ("run", "run-script"):
|
|
1035
|
+
parts.append(_mask(package, shape=RUN_SCRIPT))
|
|
1036
|
+
elif exe in SHELLS:
|
|
1037
|
+
script = _script(rest)
|
|
1038
|
+
if script:
|
|
1039
|
+
parts.append(_mask(_basename(script)))
|
|
1040
|
+
elif exe in PACKAGE_RUNNERS:
|
|
1041
|
+
package = _operand(rest)
|
|
1042
|
+
if package:
|
|
1043
|
+
parts.append(_mask(package))
|
|
1044
|
+
elif INTERPRETER.fullmatch(exe):
|
|
1045
|
+
if "-c" in rest or "-e" in rest or "--eval" in rest:
|
|
1046
|
+
parts.append("-c" if "-c" in rest else "-e") # inline code is never shown
|
|
1047
|
+
elif "-m" in rest and rest.index("-m") + 1 < len(rest):
|
|
1048
|
+
parts += ["-m", _mask(rest[rest.index("-m") + 1])]
|
|
1049
|
+
else:
|
|
1050
|
+
script = _script(rest)
|
|
1051
|
+
if script:
|
|
1052
|
+
name = _mask(_basename(script))
|
|
1053
|
+
parts.append(name)
|
|
1054
|
+
if name in ("npx-cli.js", "npm-cli.js"):
|
|
1055
|
+
package = _operand(rest[rest.index(script) + 1:])
|
|
1056
|
+
if package:
|
|
1057
|
+
parts.append(_mask(package))
|
|
1058
|
+
else:
|
|
1059
|
+
operand = _operand(rest)
|
|
1060
|
+
if operand in SUBCOMMANDS:
|
|
1061
|
+
parts.append(operand)
|
|
1062
|
+
return " ".join(p for p in parts if p)[:80]
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _allowed(command, allow):
|
|
1066
|
+
words = (command or "").split()
|
|
1067
|
+
names = {_basename(w.strip("'\"")) for w in words}
|
|
1068
|
+
for entry in allow:
|
|
1069
|
+
if isinstance(entry, re.Pattern):
|
|
1070
|
+
if entry.search(command or ""):
|
|
1071
|
+
return True
|
|
1072
|
+
elif entry in names or any(w == entry or w.startswith(entry + "@") for w in words):
|
|
1073
|
+
return True
|
|
1074
|
+
return False
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def orphan_config(home):
|
|
1078
|
+
"""Effective orphan settings ("orphans" in resources.json) and the problems found (never raises)."""
|
|
1079
|
+
data, errors = config_file(home)
|
|
1080
|
+
signatures, wrappers, allow = dict(SIGNATURES), list(WRAPPERS), list(ALLOWLIST)
|
|
1081
|
+
cfg = dict(ORPHAN_DEFAULTS)
|
|
1082
|
+
section = (data or {}).get("orphans")
|
|
1083
|
+
if section is not None and not isinstance(section, dict):
|
|
1084
|
+
errors.append("orphans deve ser um objeto; padrões usados.")
|
|
1085
|
+
section = None
|
|
1086
|
+
for key, value in (section or {}).items():
|
|
1087
|
+
if key in ORPHAN_LIMITS:
|
|
1088
|
+
low, high = ORPHAN_LIMITS[key]
|
|
1089
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
|
1090
|
+
errors.append("orphans." + key + " inválido (esperado número entre " + str(low) + " e " + str(high) + "); padrão mantido.")
|
|
1091
|
+
else:
|
|
1092
|
+
cfg[key] = float(value)
|
|
1093
|
+
elif key == "allowlist" and isinstance(value, list) and len(value) <= 200:
|
|
1094
|
+
for item in value:
|
|
1095
|
+
if isinstance(item, str) and 0 < len(item) <= 200:
|
|
1096
|
+
allow.append(item)
|
|
1097
|
+
else:
|
|
1098
|
+
errors.append("orphans.allowlist: item inválido ignorado.")
|
|
1099
|
+
elif key == "wrappers" and isinstance(value, list) and len(value) <= 50:
|
|
1100
|
+
for item in value:
|
|
1101
|
+
if isinstance(item, str) and re.fullmatch(r"[A-Za-z0-9._+-]{1,40}", item):
|
|
1102
|
+
wrappers.append(item)
|
|
1103
|
+
else:
|
|
1104
|
+
errors.append("orphans.wrappers: item inválido ignorado.")
|
|
1105
|
+
elif key == "signatures" and isinstance(value, dict) and len(value) <= 100:
|
|
1106
|
+
for name, pattern in value.items():
|
|
1107
|
+
if pattern is None:
|
|
1108
|
+
signatures.pop(name, None)
|
|
1109
|
+
elif isinstance(pattern, str) and 0 < len(pattern) <= 500 and re.fullmatch(r"[A-Za-z0-9._-]{1,40}", str(name)):
|
|
1110
|
+
signatures[name] = pattern
|
|
1111
|
+
else:
|
|
1112
|
+
errors.append("orphans.signatures." + str(name)[:40] + " inválida; ignorada.")
|
|
1113
|
+
elif key not in ("comment",):
|
|
1114
|
+
errors.append("orphans." + str(key)[:40] + ": chave desconhecida ou valor inválido; ignorada.")
|
|
1115
|
+
compiled = []
|
|
1116
|
+
for name, pattern in signatures.items():
|
|
1117
|
+
try:
|
|
1118
|
+
compiled.append((name, re.compile(pattern, re.IGNORECASE)))
|
|
1119
|
+
except re.error:
|
|
1120
|
+
errors.append("orphans.signatures." + name[:40] + ": regex inválida; ignorada.")
|
|
1121
|
+
matchers = []
|
|
1122
|
+
for item in allow:
|
|
1123
|
+
if item.startswith("re:"):
|
|
1124
|
+
try:
|
|
1125
|
+
matchers.append(re.compile(item[3:], re.IGNORECASE))
|
|
1126
|
+
except re.error:
|
|
1127
|
+
errors.append("orphans.allowlist: regex inválida ignorada.")
|
|
1128
|
+
else:
|
|
1129
|
+
matchers.append(item)
|
|
1130
|
+
cfg.update(signatures=compiled, wrappers=tuple(wrappers), allowlist=matchers,
|
|
1131
|
+
public={"olderThanMinutes": cfg["olderThanMinutes"], "graceSeconds": cfg["graceSeconds"],
|
|
1132
|
+
"signatures": sorted(signatures), "wrappers": list(wrappers), "allowlist": list(allow)})
|
|
1133
|
+
return cfg, {"path": str(config_path(home)), "errors": errors}
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def signature(command, cfg):
|
|
1137
|
+
for name, pattern in cfg["signatures"]:
|
|
1138
|
+
if pattern.search(command or ""):
|
|
1139
|
+
return name
|
|
1140
|
+
return None
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def children_of(rows):
|
|
1144
|
+
kids = {}
|
|
1145
|
+
for row in rows:
|
|
1146
|
+
kids.setdefault(row["ppid"], []).append(row["pid"])
|
|
1147
|
+
return kids
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def tree_pids(root, kids):
|
|
1151
|
+
"""root and every descendant (parents before children)."""
|
|
1152
|
+
order, seen = [root], {root}
|
|
1153
|
+
for pid in order:
|
|
1154
|
+
for child in kids.get(pid, ()):
|
|
1155
|
+
if child not in seen:
|
|
1156
|
+
seen.add(child)
|
|
1157
|
+
order.append(child)
|
|
1158
|
+
return order
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def ancestry(pid, rows):
|
|
1162
|
+
"""pid and its ancestors present in rows (stops at pid 1 or a loop)."""
|
|
1163
|
+
parent = {r["pid"]: r["ppid"] for r in rows}
|
|
1164
|
+
chain, current = [], pid
|
|
1165
|
+
while current and current not in chain:
|
|
1166
|
+
chain.append(current)
|
|
1167
|
+
if current <= 1:
|
|
1168
|
+
break
|
|
1169
|
+
current = parent.get(current)
|
|
1170
|
+
return chain
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def systemd_service(pid):
|
|
1174
|
+
"""Linux: True when the process belongs to a systemd service (its own lifecycle manager), and
|
|
1175
|
+
also when its cgroup cannot be read (fail closed: an unknown process is never an orphan)."""
|
|
1176
|
+
text = _read("/proc/" + str(int(pid)) + "/cgroup", 4096) if sys.platform.startswith("linux") else None
|
|
1177
|
+
if not text:
|
|
1178
|
+
return True
|
|
1179
|
+
for line in text.splitlines():
|
|
1180
|
+
if line.startswith("0::") and line.rstrip().endswith(".service"):
|
|
1181
|
+
return True
|
|
1182
|
+
return False
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def parse_launchctl(text):
|
|
1186
|
+
"""``launchctl list`` (PID, status, label per line) -> pids of running launchd jobs."""
|
|
1187
|
+
pids = set()
|
|
1188
|
+
for line in (text or "").splitlines():
|
|
1189
|
+
first = line.split("\t", 1)[0].strip()
|
|
1190
|
+
if first.isdigit() and int(first) > 1:
|
|
1191
|
+
pids.add(int(first))
|
|
1192
|
+
return pids
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
class ServiceChecker:
|
|
1196
|
+
"""pid -> True when a service manager owns the process: a launchd job of this user (macOS,
|
|
1197
|
+
one ``launchctl list`` call, made only when there is a candidate) or a systemd service
|
|
1198
|
+
(Linux). A daemon the user runs through launchd/systemd has ppid 1 by design and is never
|
|
1199
|
+
an orphan, whatever its command line. Fails closed: when the manager cannot be asked (error,
|
|
1200
|
+
timeout under pressure, unreadable cgroup) every candidate counts as managed and
|
|
1201
|
+
``unverified`` says why, so a scan never claims a clean machine it could not check."""
|
|
1202
|
+
|
|
1203
|
+
def __init__(self, platform=None, runner=None):
|
|
1204
|
+
self.platform = platform or sys.platform
|
|
1205
|
+
self.runner = runner
|
|
1206
|
+
self.unverified = None
|
|
1207
|
+
self._pids = False
|
|
1208
|
+
|
|
1209
|
+
def __call__(self, pid):
|
|
1210
|
+
if self.platform.startswith("linux"):
|
|
1211
|
+
text = _read("/proc/" + str(int(pid)) + "/cgroup", 4096)
|
|
1212
|
+
if not text:
|
|
1213
|
+
self.unverified = "cgroup do systemd ilegível"
|
|
1214
|
+
return True
|
|
1215
|
+
return any(line.startswith("0::") and line.rstrip().endswith(".service") for line in text.splitlines())
|
|
1216
|
+
if self.platform != "darwin":
|
|
1217
|
+
return False
|
|
1218
|
+
if self._pids is False:
|
|
1219
|
+
self._pids = parse_launchctl((self.runner or _run)(["launchctl", "list"], timeout=3.0)) or None
|
|
1220
|
+
if self._pids is None:
|
|
1221
|
+
self.unverified = "launchctl indisponível"
|
|
1222
|
+
return self._pids is None or pid in self._pids
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def service_checker(platform=None, runner=None):
|
|
1226
|
+
return ServiceChecker(platform, runner)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
# Agent CLIs are sessions, never tooling leftovers: a tree that holds one (a headless `claude -p`,
|
|
1230
|
+
# `codex exec` started with nohup) is never an orphan, whatever its arguments match.
|
|
1231
|
+
AGENT_PATHS = re.compile(r"@anthropic-ai/claude-code|@openai/codex|@google/gemini-cli|/claude-code/|/codex-cli/", re.IGNORECASE)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def agent_cli(command):
|
|
1235
|
+
words = (command or "").split()
|
|
1236
|
+
while words and ASSIGNMENT.match(words[0]):
|
|
1237
|
+
words = words[1:]
|
|
1238
|
+
if not words:
|
|
1239
|
+
return False
|
|
1240
|
+
exe = _basename(words[0]).lower()
|
|
1241
|
+
if exe in AGENT_NAMES or AGENT_PATHS.search(command or ""):
|
|
1242
|
+
return True
|
|
1243
|
+
if INTERPRETER.fullmatch(exe):
|
|
1244
|
+
script = _script(words[1:])
|
|
1245
|
+
if script and re.sub(r"\.(m?js|cjs)$", "", _basename(script).lower()) in AGENT_NAMES:
|
|
1246
|
+
return True
|
|
1247
|
+
return False
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def find_orphans(rows, cfg, uid, now=None, older_than=None, protected=(), records=(), self_pids=(), managed=None):
|
|
1251
|
+
"""Orphan trees in a process table (pure apart from ``managed``, a pid -> bool callback).
|
|
1252
|
+
|
|
1253
|
+
protected: pids of live sessions (their descendants are protected too). records: processes
|
|
1254
|
+
the hooks attributed to dead sessions, [{pid, start, display, session}]. Returns trees sorted
|
|
1255
|
+
by RSS, each with private ``_entries`` (full commands, for a kill) that callers never print."""
|
|
1256
|
+
now = time.time() if now is None else now
|
|
1257
|
+
older_than = cfg["olderThanMinutes"] * 60 if older_than is None else older_than
|
|
1258
|
+
alive = [r for r in rows if not zombie(r)]
|
|
1259
|
+
by_pid = {r["pid"]: r for r in alive}
|
|
1260
|
+
kids = children_of(alive)
|
|
1261
|
+
shielded = set()
|
|
1262
|
+
for pid in protected:
|
|
1263
|
+
if pid in by_pid:
|
|
1264
|
+
shielded.update(tree_pids(pid, kids))
|
|
1265
|
+
self_pids = set(self_pids)
|
|
1266
|
+
attributed = {}
|
|
1267
|
+
for record in records:
|
|
1268
|
+
row = by_pid.get(record.get("pid"))
|
|
1269
|
+
if row and redact(row["command"]) == record.get("display") and isinstance(record.get("start"), (int, float)) \
|
|
1270
|
+
and abs(start_of(row, now) - record["start"]) <= RECORD_TOLERANCE:
|
|
1271
|
+
attributed[row["pid"]] = record
|
|
1272
|
+
candidates = {}
|
|
1273
|
+
for row in alive:
|
|
1274
|
+
pid = row["pid"]
|
|
1275
|
+
if pid <= 1 or row["uid"] != uid or pid in self_pids or pid in shielded:
|
|
1276
|
+
continue
|
|
1277
|
+
if pid in attributed:
|
|
1278
|
+
cli = attributed[pid].get("cliPid")
|
|
1279
|
+
if cli and cli in ancestry(pid, alive)[1:]:
|
|
1280
|
+
continue # still under the recorded CLI: that session is not dead
|
|
1281
|
+
reason = "dead-session"
|
|
1282
|
+
else:
|
|
1283
|
+
parent = by_pid.get(row["ppid"])
|
|
1284
|
+
subreaper = parent is not None and parent["uid"] == uid and \
|
|
1285
|
+
_basename((parent["command"].split() or ["?"])[0]) == "systemd" # systemd --user adopts orphans
|
|
1286
|
+
reason = "reparented" if row["ppid"] == 1 or subreaper else None
|
|
1287
|
+
if reason:
|
|
1288
|
+
candidates[pid] = reason
|
|
1289
|
+
trees = []
|
|
1290
|
+
for pid, reason in candidates.items():
|
|
1291
|
+
root = by_pid[pid]
|
|
1292
|
+
if root["age"] < older_than:
|
|
1293
|
+
continue
|
|
1294
|
+
members = [by_pid[p] for p in tree_pids(pid, kids) if p in by_pid]
|
|
1295
|
+
if any(m["uid"] != uid or m["pid"] in self_pids or m["pid"] in shielded or _allowed(m["command"], cfg["allowlist"])
|
|
1296
|
+
or agent_cli(m["command"]) for m in members):
|
|
1297
|
+
continue
|
|
1298
|
+
name = signature(root["command"], cfg)
|
|
1299
|
+
if name is None:
|
|
1300
|
+
below = next((signature(m["command"], cfg) for m in members[1:] if signature(m["command"], cfg)), None)
|
|
1301
|
+
exe = _basename((root["command"].split() or ["?"])[0])
|
|
1302
|
+
if below and (reason == "dead-session" or exe in cfg["wrappers"]):
|
|
1303
|
+
name = below
|
|
1304
|
+
if name is None:
|
|
1305
|
+
continue
|
|
1306
|
+
if managed is not None and managed(pid):
|
|
1307
|
+
continue
|
|
1308
|
+
depth = {pid: 0}
|
|
1309
|
+
processes, entries = [], []
|
|
1310
|
+
for m in members:
|
|
1311
|
+
if m["pid"] != pid:
|
|
1312
|
+
depth[m["pid"]] = depth.get(m["ppid"], 0) + 1
|
|
1313
|
+
processes.append({"pid": m["pid"], "ppid": m["ppid"], "pgid": m["pgid"], "depth": depth[m["pid"]],
|
|
1314
|
+
"ageSeconds": m["age"], "rssMb": round(m["rssKb"] / 1024, 1), "command": redact(m["command"])})
|
|
1315
|
+
entries.append({"pid": m["pid"], "pgid": m["pgid"], "uid": m["uid"], "command": m["command"],
|
|
1316
|
+
"start": start_of(m, now)})
|
|
1317
|
+
tree = {"root": pid, "reason": reason, "signature": name, "ageSeconds": root["age"],
|
|
1318
|
+
"rssMb": round(sum(m["rssKb"] for m in members) / 1024, 1),
|
|
1319
|
+
"groups": len({m["pgid"] for m in members}), "processes": processes, "_entries": entries}
|
|
1320
|
+
if reason == "dead-session":
|
|
1321
|
+
tree["session"] = str(attributed[pid].get("session") or "")[:20]
|
|
1322
|
+
trees.append(tree)
|
|
1323
|
+
inside = {p["pid"] for t in trees for p in t["processes"][1:]}
|
|
1324
|
+
trees = [t for t in trees if t["root"] not in inside] # a qualifying root inside another orphan tree
|
|
1325
|
+
return sorted(trees, key=lambda t: (-t["rssMb"], t["root"]))
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def public_orphans(doc):
|
|
1329
|
+
"""The document without private keys (full commands)."""
|
|
1330
|
+
if isinstance(doc, dict):
|
|
1331
|
+
return {k: public_orphans(v) for k, v in doc.items() if not str(k).startswith("_")}
|
|
1332
|
+
if isinstance(doc, list):
|
|
1333
|
+
return [public_orphans(v) for v in doc]
|
|
1334
|
+
return doc
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def orphan_scan(home, rows=None, context=None, older_than=None, now=None, uid=None, managed=None, self_pid=None):
|
|
1338
|
+
"""crow-orphans/1 for this machine. context: {"livePids", "liveKeys", "records",
|
|
1339
|
+
"ledgerProcesses"} from crow-broadcast (process_context), None without it (then only
|
|
1340
|
+
reparented roots count). Protected (with their trees): CLIs of live sessions, the processes
|
|
1341
|
+
recorded for live sessions, and processes registered as running by live crow-behaviour runs."""
|
|
1342
|
+
cfg, meta = orphan_config(home)
|
|
1343
|
+
now = time.time() if now is None else now
|
|
1344
|
+
rows = read_table() if rows is None else rows
|
|
1345
|
+
doc = {"schema": ORPHAN_SCHEMA, "generatedAt": now_iso(), "available": rows is not None,
|
|
1346
|
+
"olderThanSeconds": int(cfg["olderThanMinutes"] * 60 if older_than is None else older_than),
|
|
1347
|
+
"trees": [], "count": 0, "processes": 0, "rssMb": 0.0, "settings": cfg["public"], "config": meta}
|
|
1348
|
+
if rows is None:
|
|
1349
|
+
return doc
|
|
1350
|
+
uid = os.getuid() if uid is None and hasattr(os, "getuid") else uid
|
|
1351
|
+
context = context or {}
|
|
1352
|
+
by_pid = {r["pid"]: r for r in rows}
|
|
1353
|
+
records = []
|
|
1354
|
+
protected = set(context.get("livePids") or ())
|
|
1355
|
+
for record in context.get("records") or []:
|
|
1356
|
+
cli = by_pid.get(record.get("cliPid"))
|
|
1357
|
+
cli_alive = cli is not None and not zombie(cli) and (not isinstance(record.get("cliStart"), (int, float))
|
|
1358
|
+
or abs(start_of(cli, now) - record["cliStart"]) <= CLI_REUSE_SECONDS)
|
|
1359
|
+
if record.get("key") in (context.get("liveKeys") or ()) or cli_alive:
|
|
1360
|
+
if cli is not None:
|
|
1361
|
+
protected.add(cli["pid"]) # the recorded CLI (not the hook's shell) shields its whole tree
|
|
1362
|
+
for item in record.get("processes") or []: # and so do its recorded processes, even reparented
|
|
1363
|
+
row = by_pid.get(item.get("pid")) if isinstance(item, dict) else None
|
|
1364
|
+
if row and isinstance(item.get("start"), (int, float)) and abs(start_of(row, now) - item["start"]) <= RECORD_TOLERANCE:
|
|
1365
|
+
protected.add(row["pid"])
|
|
1366
|
+
continue # the session is alive: its processes are not orphans
|
|
1367
|
+
for item in record.get("processes") or []:
|
|
1368
|
+
if isinstance(item, dict):
|
|
1369
|
+
records.append(dict(item, session=record.get("key"), cliPid=record.get("cliPid")))
|
|
1370
|
+
for item in context.get("ledgerProcesses") or []: # registered by a running crow-behaviour run
|
|
1371
|
+
row = by_pid.get(item.get("pid")) if isinstance(item, dict) else None
|
|
1372
|
+
if not row or not isinstance(item.get("start"), (int, float)) or abs(start_of(row, now) - item["start"]) > RECORD_TOLERANCE:
|
|
1373
|
+
continue
|
|
1374
|
+
if isinstance(item.get("cliPid"), int):
|
|
1375
|
+
# Protected exactly while the agent CLI that registered it lives (same pid, same start).
|
|
1376
|
+
cli = by_pid.get(item["cliPid"])
|
|
1377
|
+
if cli is None or zombie(cli) or (isinstance(item.get("cliStart"), (int, float))
|
|
1378
|
+
and abs(start_of(cli, now) - item["cliStart"]) > CLI_REUSE_SECONDS):
|
|
1379
|
+
continue
|
|
1380
|
+
elif not item.get("fresh", True):
|
|
1381
|
+
continue # CLI unknown: only while the ledger is fresh or its session is live
|
|
1382
|
+
protected.add(row["pid"])
|
|
1383
|
+
fixture = table_is_fixture() # tests: fixture pids are not this machine's (no ancestry, no launchctl)
|
|
1384
|
+
self_pids = [] if fixture and self_pid is None else ancestry(os.getpid() if self_pid is None else self_pid, rows)
|
|
1385
|
+
if managed is None:
|
|
1386
|
+
managed = (lambda pid: False) if fixture else service_checker()
|
|
1387
|
+
trees = find_orphans(rows, cfg, uid, now, doc["olderThanSeconds"], protected, records, self_pids, managed)
|
|
1388
|
+
doc.update(trees=trees, count=len(trees), processes=sum(len(t["processes"]) for t in trees),
|
|
1389
|
+
rssMb=round(sum(t["rssMb"] for t in trees), 1), verified=not getattr(managed, "unverified", None))
|
|
1390
|
+
if not doc["verified"]:
|
|
1391
|
+
doc["unverifiedReason"] = managed.unverified
|
|
1392
|
+
if trees:
|
|
1393
|
+
doc["note"] = orphan_note(meta["path"])
|
|
1394
|
+
return doc
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def kill_trees(entries, grace=5.0, rows_reader=None, kill=None, sleep=time.sleep, clock=time.monotonic,
|
|
1398
|
+
interval=0.2, uid=None, self_pid=None):
|
|
1399
|
+
"""Stop process trees from a snapshot taken before the first signal.
|
|
1400
|
+
|
|
1401
|
+
entries: [{pid, pgid, uid, command, start}] (find_orphans ``_entries`` or a ledger snapshot).
|
|
1402
|
+
TERM every process (and each group it leads when every member of that group is in the
|
|
1403
|
+
snapshot), re-snapshot every ``interval`` during ``grace`` to catch new children, recheck
|
|
1404
|
+
each pid's command and start time before any later signal (a reused pid is never signalled),
|
|
1405
|
+
KILL what is still alive after the grace, and verify. Returns [{pid, command, result}] with
|
|
1406
|
+
result terminated, killed, gone (already dead), reused (pid now another process), refused
|
|
1407
|
+
(pid 1, this process or an ancestor, another user), denied or survived."""
|
|
1408
|
+
if kill is None:
|
|
1409
|
+
if table_is_fixture():
|
|
1410
|
+
raise RuntimeError("CROW_PS_FIXTURE: a fixture table is never signalled")
|
|
1411
|
+
kill = os.kill
|
|
1412
|
+
rows_reader = rows_reader or read_table
|
|
1413
|
+
uid = (os.getuid() if hasattr(os, "getuid") else 0) if uid is None else uid
|
|
1414
|
+
self_pid = os.getpid() if self_pid is None else self_pid
|
|
1415
|
+
rows = rows_reader()
|
|
1416
|
+
if rows is None:
|
|
1417
|
+
return [{"pid": e["pid"], "command": redact(e.get("command")), "result": "unverified"} for e in entries]
|
|
1418
|
+
mine = set(ancestry(self_pid, rows)) | {self_pid}
|
|
1419
|
+
forbidden_groups = {r["pgid"] for r in rows if r["pid"] in mine}
|
|
1420
|
+
try:
|
|
1421
|
+
forbidden_groups.add(os.getpgrp())
|
|
1422
|
+
except OSError:
|
|
1423
|
+
pass
|
|
1424
|
+
tracked, results, signalled = {}, {}, {}
|
|
1425
|
+
for entry in entries:
|
|
1426
|
+
pid = entry.get("pid")
|
|
1427
|
+
if not isinstance(pid, int) or pid <= 1 or pid in mine or entry.get("uid", uid) != uid:
|
|
1428
|
+
results[pid] = "refused"
|
|
1429
|
+
else:
|
|
1430
|
+
tracked[pid] = entry
|
|
1431
|
+
|
|
1432
|
+
def identity(pid, by_pid, now):
|
|
1433
|
+
row, entry = by_pid.get(pid), tracked[pid]
|
|
1434
|
+
if row is None or zombie(row):
|
|
1435
|
+
return "dead"
|
|
1436
|
+
if row["command"] != entry["command"] or row["uid"] != uid or abs(start_of(row, now) - entry["start"]) > START_TOLERANCE:
|
|
1437
|
+
return "other"
|
|
1438
|
+
return "same"
|
|
1439
|
+
|
|
1440
|
+
def send(pids, sig, rows):
|
|
1441
|
+
groups = {}
|
|
1442
|
+
for row in rows:
|
|
1443
|
+
groups.setdefault(row["pgid"], set()).add(row["pid"])
|
|
1444
|
+
for pid in pids:
|
|
1445
|
+
entry = tracked[pid]
|
|
1446
|
+
owned = {p for p in tracked if results.get(p) not in ("reused", "refused", "denied")}
|
|
1447
|
+
if entry.get("pgid") == pid and pid not in forbidden_groups and groups.get(pid, set()) <= owned:
|
|
1448
|
+
try:
|
|
1449
|
+
kill(-pid, sig)
|
|
1450
|
+
for member in groups[pid]: # reached by the group signal: may be gone before its own
|
|
1451
|
+
signalled[member] = sig
|
|
1452
|
+
except OSError:
|
|
1453
|
+
pass
|
|
1454
|
+
try:
|
|
1455
|
+
kill(pid, sig)
|
|
1456
|
+
signalled[pid] = sig
|
|
1457
|
+
except ProcessLookupError:
|
|
1458
|
+
signalled.setdefault(pid, sig) # alive at the last snapshot: it ended during this stop
|
|
1459
|
+
except PermissionError:
|
|
1460
|
+
results[pid] = "denied"
|
|
1461
|
+
|
|
1462
|
+
def settle(rows, now):
|
|
1463
|
+
by_pid = {r["pid"]: r for r in rows}
|
|
1464
|
+
alive = []
|
|
1465
|
+
for pid in list(tracked):
|
|
1466
|
+
if pid in results:
|
|
1467
|
+
continue
|
|
1468
|
+
state = identity(pid, by_pid, now)
|
|
1469
|
+
if state == "same":
|
|
1470
|
+
alive.append(pid)
|
|
1471
|
+
elif pid not in signalled:
|
|
1472
|
+
results[pid] = "gone" if state == "dead" else "reused"
|
|
1473
|
+
else:
|
|
1474
|
+
results[pid] = "killed" if signalled[pid] == signal_kill else "terminated"
|
|
1475
|
+
return alive
|
|
1476
|
+
|
|
1477
|
+
import signal as _signal
|
|
1478
|
+
signal_kill = getattr(_signal, "SIGKILL", 9)
|
|
1479
|
+
now = time.time()
|
|
1480
|
+
alive = settle(rows, now)
|
|
1481
|
+
send(alive, _signal.SIGTERM, rows)
|
|
1482
|
+
deadline = clock() + max(0.0, grace)
|
|
1483
|
+
while alive and clock() < deadline:
|
|
1484
|
+
sleep(interval)
|
|
1485
|
+
rows, now = rows_reader(), time.time()
|
|
1486
|
+
if rows is None:
|
|
1487
|
+
break
|
|
1488
|
+
alive = settle(rows, now)
|
|
1489
|
+
fresh = []
|
|
1490
|
+
for row in rows: # children started during the grace (a TERM handler, a respawning wrapper)
|
|
1491
|
+
if row["ppid"] in alive and row["pid"] not in tracked and row["uid"] == uid and row["pid"] not in mine and not zombie(row):
|
|
1492
|
+
tracked[row["pid"]] = {"pid": row["pid"], "pgid": row["pgid"], "uid": row["uid"], "command": row["command"],
|
|
1493
|
+
"start": start_of(row, now)}
|
|
1494
|
+
fresh.append(row["pid"])
|
|
1495
|
+
if fresh:
|
|
1496
|
+
send(fresh, _signal.SIGTERM, rows)
|
|
1497
|
+
alive += fresh
|
|
1498
|
+
rows, now = rows_reader(), time.time()
|
|
1499
|
+
if rows is not None:
|
|
1500
|
+
alive = settle(rows, now)
|
|
1501
|
+
send(alive, signal_kill, rows)
|
|
1502
|
+
for _ in range(20):
|
|
1503
|
+
if not alive:
|
|
1504
|
+
break
|
|
1505
|
+
sleep(min(interval, 0.1))
|
|
1506
|
+
rows, now = rows_reader(), time.time()
|
|
1507
|
+
if rows is None:
|
|
1508
|
+
break
|
|
1509
|
+
alive = settle(rows, now)
|
|
1510
|
+
for pid in tracked:
|
|
1511
|
+
results.setdefault(pid, "survived" if rows is not None else "unverified")
|
|
1512
|
+
commands = {e.get("pid"): e.get("command") for e in entries}
|
|
1513
|
+
commands.update({pid: e["command"] for pid, e in tracked.items()})
|
|
1514
|
+
return [{"pid": pid, "command": redact(commands.get(pid)), "result": result} for pid, result in results.items()]
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def session_descendants(rows, cli_pid, self_pid=None, now=None):
|
|
1518
|
+
"""What the hooks record for a session: descendants of the agent CLI (never the hook's own
|
|
1519
|
+
chain), as {pid, ppid, pgid, start, display}. Commands are redacted before they leave."""
|
|
1520
|
+
now = time.time() if now is None else now
|
|
1521
|
+
self_pid = os.getpid() if self_pid is None else self_pid
|
|
1522
|
+
alive = [r for r in rows if not zombie(r)]
|
|
1523
|
+
by_pid = {r["pid"]: r for r in alive}
|
|
1524
|
+
hook_chain = set(ancestry(self_pid, alive))
|
|
1525
|
+
out = []
|
|
1526
|
+
for pid in tree_pids(cli_pid, children_of(alive))[1:]:
|
|
1527
|
+
if pid in hook_chain:
|
|
1528
|
+
continue
|
|
1529
|
+
row = by_pid[pid]
|
|
1530
|
+
out.append({"pid": pid, "ppid": row["ppid"], "pgid": row["pgid"], "start": round(start_of(row, now)),
|
|
1531
|
+
"display": redact(row["command"])})
|
|
1532
|
+
return out[:200]
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def hook_cli_pid(rows, ppid):
|
|
1536
|
+
"""The agent CLI behind a hook: its parent, or the grandparent when the parent is a shell."""
|
|
1537
|
+
by_pid = {r["pid"]: r for r in rows}
|
|
1538
|
+
row = by_pid.get(ppid)
|
|
1539
|
+
if row and _basename((row["command"].split() or ["?"])[0]).lstrip("-") in SHELLS and row["ppid"] > 1:
|
|
1540
|
+
return row["ppid"]
|
|
1541
|
+
return ppid
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def orphan_cache_path(home):
|
|
1545
|
+
return Path(home) / ".local" / "state" / "crow-skills" / "orphans-cache.json"
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def write_orphan_cache(home, doc):
|
|
1549
|
+
"""Counts only (no pid, no command): what the prompt hint needs. Failure means no cache."""
|
|
1550
|
+
base = Path(os.path.realpath(home))
|
|
1551
|
+
path = orphan_cache_path(base)
|
|
1552
|
+
try:
|
|
1553
|
+
for part in (base / ".local", base / ".local" / "state", path.parent):
|
|
1554
|
+
if part.is_symlink():
|
|
1555
|
+
return
|
|
1556
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1557
|
+
temp = path.parent / ("." + path.name + "." + uuid.uuid4().hex)
|
|
1558
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
1559
|
+
try:
|
|
1560
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1561
|
+
json.dump({"schema": ORPHAN_CACHE_SCHEMA, "at": time.time(), "count": int(doc.get("count") or 0),
|
|
1562
|
+
"processes": int(doc.get("processes") or 0), "rssMb": float(doc.get("rssMb") or 0)}, handle)
|
|
1563
|
+
os.replace(temp, path)
|
|
1564
|
+
finally:
|
|
1565
|
+
if os.path.lexists(temp):
|
|
1566
|
+
os.unlink(temp)
|
|
1567
|
+
except OSError:
|
|
1568
|
+
pass
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def read_orphan_cache(home, max_age=ORPHAN_CACHE_SECONDS, now=None):
|
|
1572
|
+
path = orphan_cache_path(home)
|
|
1573
|
+
try:
|
|
1574
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > 4096:
|
|
1575
|
+
return None
|
|
1576
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1577
|
+
age = (now or time.time()) - float(data.get("at") or 0)
|
|
1578
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
1579
|
+
return None
|
|
1580
|
+
if not isinstance(data, dict) or data.get("schema") != ORPHAN_CACHE_SCHEMA or not 0 <= age <= max_age:
|
|
1581
|
+
return None
|
|
1582
|
+
if not all(isinstance(data.get(k), (int, float)) and not isinstance(data.get(k), bool) for k in ("count", "processes", "rssMb")):
|
|
1583
|
+
return None
|
|
1584
|
+
return data
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def orphan_hint(home):
|
|
1588
|
+
"""Suffix for the pressure hint from the last orphan scan (a file read, never a ps call)."""
|
|
1589
|
+
cache = read_orphan_cache(home)
|
|
1590
|
+
if not cache or cache["count"] <= 0:
|
|
1591
|
+
return ""
|
|
1592
|
+
return (" Órfãos de ferramentas de agente: " + str(int(cache["count"])) + " árvore(s), " + str(int(cache["processes"])) +
|
|
1593
|
+
" processo(s), " + gb(cache["rssMb"]) + " GB; revise com `crow-skills resources --orphans` e limpe só o que for "
|
|
1594
|
+
"órfão (nunca processos do usuário ou de sessões vivas).")
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def duration_text(seconds):
|
|
1598
|
+
seconds = int(seconds or 0)
|
|
1599
|
+
if seconds >= 86400:
|
|
1600
|
+
return str(seconds // 86400) + "d" + (str(seconds % 86400 // 3600) + "h" if seconds % 86400 >= 3600 else "")
|
|
1601
|
+
if seconds >= 3600:
|
|
1602
|
+
return str(seconds // 3600) + "h" + ("%02d" % (seconds % 3600 // 60))
|
|
1603
|
+
return str(seconds // 60) + "min"
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def parse_duration(text):
|
|
1607
|
+
"""'30m', '2h', '1d', '45s' or plain seconds -> seconds (ValueError otherwise)."""
|
|
1608
|
+
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhd]?)\s*", str(text or ""), re.IGNORECASE)
|
|
1609
|
+
if not match:
|
|
1610
|
+
raise ValueError("duração inválida: use 30m, 2h, 1d ou segundos")
|
|
1611
|
+
return int(float(match.group(1)) * {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[match.group(2).lower()])
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
REASONS = {"reparented": "pai morto (reparentado ao init/launchd)", "dead-session": "sessão de agente encerrada"}
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def render_orphans(doc):
|
|
1618
|
+
if not doc.get("available"):
|
|
1619
|
+
return "órfãos: tabela de processos indisponível nesta plataforma."
|
|
1620
|
+
if not doc["trees"] and doc.get("verified") is False:
|
|
1621
|
+
lines = ["órfãos: não verificado: " + str(doc.get("unverifiedReason")) + "; processos reparentados não puderam ser "
|
|
1622
|
+
"separados de serviços do usuário e não foram listados (isso não quer dizer que não há órfãos)."]
|
|
1623
|
+
elif not doc["trees"]:
|
|
1624
|
+
lines = ["órfãos: nenhum processo órfão de ferramentas de agente com mais de " + duration_text(doc["olderThanSeconds"]) + "."]
|
|
1625
|
+
else:
|
|
1626
|
+
lines = ["órfãos de ferramentas de agente: " + str(doc["count"]) + " árvore(s), " + str(doc["processes"]) +
|
|
1627
|
+
" processo(s), " + gb(doc["rssMb"]) + " GB RSS (mais de " + duration_text(doc["olderThanSeconds"]) + ")"]
|
|
1628
|
+
for tree in doc["trees"]:
|
|
1629
|
+
lines.append("- " + tree["signature"] + " · " + REASONS.get(tree["reason"], tree["reason"]) + " · " +
|
|
1630
|
+
duration_text(tree["ageSeconds"]) + " · " + str(round(tree["rssMb"])) + " MB · " +
|
|
1631
|
+
str(len(tree["processes"])) + " processo(s) em " + str(tree["groups"]) + " grupo(s)")
|
|
1632
|
+
for proc in tree["processes"]:
|
|
1633
|
+
lines.append(" " + " " * proc["depth"] + ("└ " if proc["depth"] else "") + str(proc["pid"]) + " " +
|
|
1634
|
+
proc["command"] + " (" + duration_text(proc["ageSeconds"]) + ", " + str(round(proc["rssMb"])) + " MB)")
|
|
1635
|
+
if doc.get("note") and not doc.get("killed"):
|
|
1636
|
+
lines.append(doc["note"])
|
|
1637
|
+
for item in doc.get("killed") or []:
|
|
1638
|
+
lines.append(" " + str(item["pid"]) + " " + item["command"] + ": " + item["result"])
|
|
1639
|
+
if doc.get("remaining") is not None:
|
|
1640
|
+
lines.append("restantes após o encerramento: " + str(doc["remaining"]) + " árvore(s)")
|
|
1641
|
+
if doc.get("dryRun"):
|
|
1642
|
+
lines.append("dry-run: nada foi sinalizado; --kill --yes encerra as árvores listadas.")
|
|
1643
|
+
for error in doc["config"]["errors"]:
|
|
1644
|
+
lines.append("config: " + error)
|
|
1645
|
+
return "\n".join(lines)
|
|
1646
|
+
|
|
1647
|
+
|
|
797
1648
|
# --- presentation ---------------------------------------------------------------------------
|
|
798
1649
|
|
|
799
1650
|
def cpu_level(data):
|