@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.
@@ -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 load_config(home):
123
- """Effective thresholds and the problems found in the user's file (never raises)."""
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
- errors.append("resources.json não é um arquivo regular; padrões usados.")
129
- elif path.is_file():
130
- if path.stat().st_size > MAX_CONFIG:
131
- errors.append("resources.json maior que 64 KiB; padrões usados.")
132
- else:
133
- data = json.loads(path.read_text(encoding="utf-8"))
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
- errors.append("resources.json ilegível ou JSON inválido; padrões usados.")
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):