@andespindola/crow-skills 0.7.1 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +6 -5
  2. package/crow_skills/__init__.py +1 -1
  3. package/crow_skills/autoupdate.py +398 -0
  4. package/crow_skills/cli.py +99 -3
  5. package/crow_skills/events.py +133 -0
  6. package/crow_skills/guide.py +7 -1
  7. package/crow_skills/hook.py +616 -0
  8. package/crow_skills/integrations.py +460 -63
  9. package/crow_skills/progress.py +157 -15
  10. package/crow_skills/resources.py +713 -0
  11. package/crow_skills/source.py +40 -0
  12. package/crow_skills/storage.py +5 -4
  13. package/docs/cli.md +144 -17
  14. package/package.json +1 -1
  15. package/release.json +56 -31
  16. package/skills/crow-accessibility/SKILL.md +2 -0
  17. package/skills/crow-api-test/SKILL.md +2 -0
  18. package/skills/crow-backend/SKILL.md +2 -0
  19. package/skills/crow-behaviour/SKILL.md +4 -3
  20. package/skills/crow-behaviour/references/orchestration.md +4 -0
  21. package/skills/crow-behaviour/references/progress-ledger.md +20 -3
  22. package/skills/crow-behaviour/references/resources.md +44 -0
  23. package/skills/crow-behaviour/scripts/crow_ledger.py +120 -4
  24. package/skills/crow-browser/SKILL.md +2 -0
  25. package/skills/crow-debug/SKILL.md +2 -0
  26. package/skills/crow-frontend/SKILL.md +2 -0
  27. package/skills/crow-infra/SKILL.md +2 -0
  28. package/skills/crow-interface/SKILL.md +2 -0
  29. package/skills/crow-media/SKILL.md +2 -0
  30. package/skills/crow-native-mobile/SKILL.md +2 -0
  31. package/skills/crow-performance/SKILL.md +2 -0
  32. package/skills/crow-qa/SKILL.md +2 -0
  33. package/skills/crow-release/SKILL.md +2 -0
  34. package/skills/crow-reverse/SKILL.md +2 -0
  35. package/skills/crow-security-scan/SKILL.md +2 -0
@@ -0,0 +1,713 @@
1
+ """Machine resource probe and parallelism recommendation (crow-resources/1).
2
+
3
+ Self-contained on purpose (standard library only, no package-relative
4
+ imports): the integrations copy this file next to the hook as
5
+ ``crow_resources.py``, so the per-prompt hook and the status line can read
6
+ memory pressure without the CLI package. The CLI uses the same functions for
7
+ ``crow-skills resources`` and the ``resources`` field of ``events snapshot``.
8
+
9
+ What is read, and nothing else:
10
+ - macOS: one ``sysctl`` call (hw.memsize, hw.ncpu, vm.swapusage,
11
+ kern.memorystatus_vm_pressure_level, kern.memorystatus_level); ``vm_stat``
12
+ only when the kernel does not report the available level.
13
+ - Linux: /proc/meminfo (MemAvailable, swap), /proc/pressure/memory (PSI) when
14
+ present, and the cgroup v2 memory limit when the process runs under one.
15
+ - Agent processes (full probe only): pid, parent pid, RSS and the executable
16
+ name, from ``ps -axo pid=,ppid=,rss=,comm=`` (macOS) or /proc/<pid>/stat
17
+ (Linux). Names are matched against a fixed list and only the agent kind,
18
+ counts and RSS are reported: no command line, argument or process name is
19
+ ever printed or stored.
20
+
21
+ Tests (and only tests) pin the reading with CROW_RESOURCES_FIXTURE, the path of a
22
+ JSON raw reading (optionally with an "agents" object); the cache is then bypassed.
23
+
24
+ The probe never changes a host limit. Thresholds come from DEFAULTS, merged
25
+ with ~/.config/crow-skills/resources.json when it exists.
26
+ """
27
+ from datetime import datetime, timezone
28
+ import json
29
+ import math
30
+ import os
31
+ from pathlib import Path
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ import uuid
37
+
38
+ SCHEMA = "crow-resources/1"
39
+ CACHE_SCHEMA = "crow-resources-cache/1"
40
+ LEVELS = ("normal", "warn", "critical")
41
+ MODES = ("parallel", "reduced", "sequential")
42
+ # Executable name (basename, lower case) -> agent kind. Node-based CLIs that keep the
43
+ # process name "node" are not detected: their command line is never read.
44
+ AGENT_NAMES = {"claude": "claude", "codex": "codex", "gemini": "gemini", "agy": "agy", "antigravity": "agy", "devin": "devin"}
45
+
46
+ # Defaults derived from measurements on a 18 GB / 12-core macOS machine (docs/cli.md,
47
+ # "crow-skills resources"): a Claude Code session with its MCP servers and shells used
48
+ # ~0.95 GB RSS as a process tree; a standalone Codex CLI 60-70 MB; the Codex desktop
49
+ # app-server tree ~2.1 GB. perAgentMb takes the heaviest common CLI session, since a
50
+ # fan-out cannot know which kind of worker the host starts. perHeavyJobMb and
51
+ # perWorkerMb are conservative estimates for a build/test suite/dev server and for one
52
+ # test or compiler worker; they were not measured on this machine.
53
+ DEFAULTS = {
54
+ "reservedMb": 2048, # kept free for the OS, the editor and the browser...
55
+ "reservedPercent": 15, # ...but never more than this share of the total (small machines)
56
+ "perAgentMb": 1024, # one more agent/subagent session
57
+ "perHeavyJobMb": 1536, # one build, test suite, dev server, emulator or container
58
+ "perWorkerMb": 512, # one worker inside a heavy job (-j, --maxWorkers, pytest -n)
59
+ "maxParallelAgents": 6, # hard cap, whatever the memory
60
+ "maxParallelHeavyJobs": 4, # hard cap; also limited to cpus // 4
61
+ "warnAvailablePercent": 15, # available/total below this -> warn
62
+ "criticalAvailablePercent": 7, # available/total below this -> critical
63
+ "psiWarnSomeAvg10": 10.0, # Linux PSI "some avg10" (%) at or above this -> warn
64
+ "psiCriticalFullAvg10": 10.0, # Linux PSI "full avg10" (%) at or above this -> critical
65
+ "loadPerCpuReduce": 1.5, # 1-min load / cpus above this halves heavy jobs and workers
66
+ "cacheSeconds": 5, # hooks and status lines reuse a probe this recent
67
+ }
68
+ LIMITS = { # key -> (minimum, maximum)
69
+ "reservedMb": (0, 1 << 22), "reservedPercent": (0, 90), "perAgentMb": (1, 1 << 22), "perHeavyJobMb": (1, 1 << 22), "perWorkerMb": (1, 1 << 22),
70
+ "maxParallelAgents": (1, 256), "maxParallelHeavyJobs": (1, 256),
71
+ "warnAvailablePercent": (0, 100), "criticalAvailablePercent": (0, 100),
72
+ "psiWarnSomeAvg10": (0, 100), "psiCriticalFullAvg10": (0, 100),
73
+ "loadPerCpuReduce": (0.1, 1000), "cacheSeconds": (0, 300),
74
+ }
75
+ INTEGER_KEYS = ("reservedMb", "perAgentMb", "perHeavyJobMb", "perWorkerMb", "maxParallelAgents", "maxParallelHeavyJobs")
76
+ MAX_CONFIG = 64 * 1024
77
+ MB = 1024 * 1024
78
+
79
+
80
+ def config_path(home):
81
+ return Path(home) / ".config" / "crow-skills" / "resources.json"
82
+
83
+
84
+ def cache_path(home):
85
+ return Path(home) / ".local" / "state" / "crow-skills" / "resources-cache.json"
86
+
87
+
88
+ def now_iso():
89
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
90
+
91
+
92
+ # --- configuration -----------------------------------------------------------------------
93
+
94
+ def load_config(home):
95
+ """Effective thresholds and the problems found in the user's file (never raises)."""
96
+ path = config_path(home)
97
+ effective, errors, loaded = dict(DEFAULTS), [], False
98
+ try:
99
+ if path.is_symlink() or (path.exists() and not path.is_file()):
100
+ errors.append("resources.json não é um arquivo regular; padrões usados.")
101
+ elif path.is_file():
102
+ if path.stat().st_size > MAX_CONFIG:
103
+ errors.append("resources.json maior que 64 KiB; padrões usados.")
104
+ else:
105
+ data = json.loads(path.read_text(encoding="utf-8"))
106
+ if not isinstance(data, dict):
107
+ errors.append("resources.json não é um objeto JSON; padrões usados.")
108
+ else:
109
+ loaded = True
110
+ for key, value in data.items():
111
+ if key in ("$schema", "schema", "comment"):
112
+ continue
113
+ if key not in DEFAULTS:
114
+ errors.append("chave desconhecida ignorada: " + str(key)[:40])
115
+ continue
116
+ low, high = LIMITS[key]
117
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
118
+ errors.append(key + " inválido (esperado número entre " + str(low) + " e " + str(high) + "); padrão mantido.")
119
+ continue
120
+ effective[key] = int(value) if key in INTEGER_KEYS else float(value)
121
+ except (OSError, ValueError, UnicodeDecodeError):
122
+ errors.append("resources.json ilegível ou JSON inválido; padrões usados.")
123
+ if effective["criticalAvailablePercent"] > effective["warnAvailablePercent"]:
124
+ errors.append("criticalAvailablePercent maior que warnAvailablePercent; os dois padrões foram restaurados.")
125
+ effective["criticalAvailablePercent"] = DEFAULTS["criticalAvailablePercent"]
126
+ effective["warnAvailablePercent"] = DEFAULTS["warnAvailablePercent"]
127
+ return effective, {"path": str(path), "loaded": loaded, "errors": errors}
128
+
129
+
130
+ # --- parsing (pure, covered by fixtures) ------------------------------------------------
131
+
132
+ def _size_mb(text):
133
+ """'4464.19M' / '6.00G' / '512K' -> MB (float)."""
134
+ match = re.fullmatch(r"\s*([0-9.]+)\s*([KMGT]?)B?\s*", text or "", re.IGNORECASE)
135
+ if not match:
136
+ return None
137
+ value = float(match.group(1))
138
+ return value * {"": 1 / MB, "K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}[match.group(2).upper()]
139
+
140
+
141
+ def parse_sysctl(text):
142
+ """``sysctl name ...`` output (``name: value`` lines) -> dict of raw strings."""
143
+ values = {}
144
+ for line in (text or "").splitlines():
145
+ name, sep, value = line.partition(":")
146
+ if sep and re.fullmatch(r"[a-z0-9_.]+", name.strip()):
147
+ values[name.strip()] = value.strip()
148
+ return values
149
+
150
+
151
+ def parse_swapusage(text):
152
+ """'total = 6144.00M used = 4464.19M free = 1679.81M (encrypted)' -> (total, used) MB."""
153
+ found = dict(re.findall(r"(total|used|free)\s*=\s*([0-9.]+[KMGT]?)", text or ""))
154
+ total, used = _size_mb(found.get("total")), _size_mb(found.get("used"))
155
+ return total, used
156
+
157
+
158
+ def parse_vm_stat(text):
159
+ """vm_stat output -> available MB (free + inactive + speculative + purgeable), or None."""
160
+ page = re.search(r"page size of (\d+) bytes", text or "")
161
+ if not page:
162
+ return None
163
+ counts = {}
164
+ for name, value in re.findall(r"^Pages ([a-z ]+):\s+(\d+)\.", text, re.MULTILINE):
165
+ counts[name.strip()] = int(value)
166
+ if "free" not in counts:
167
+ return None
168
+ pages = sum(counts.get(k, 0) for k in ("free", "inactive", "speculative", "purgeable"))
169
+ return pages * int(page.group(1)) / MB
170
+
171
+
172
+ def macos_reading(sysctl_text, vm_stat_text=None):
173
+ """Raw macOS reading from sysctl (and vm_stat when the kernel level is missing)."""
174
+ values = parse_sysctl(sysctl_text)
175
+ try:
176
+ total = int(values["hw.memsize"]) / MB
177
+ except (KeyError, ValueError):
178
+ return None
179
+ reading = {"platform": "darwin", "source": "sysctl", "totalMb": total, "availableMb": None, "swapTotalMb": None,
180
+ "swapUsedMb": None, "osLevel": None, "psi": None, "cgroupLimitMb": None}
181
+ try:
182
+ level = int(values["kern.memorystatus_level"]) # % of memory the kernel counts as available
183
+ if 0 <= level <= 100:
184
+ reading["availableMb"] = total * level / 100
185
+ except (KeyError, ValueError):
186
+ pass
187
+ if reading["availableMb"] is None and vm_stat_text:
188
+ reading["availableMb"] = parse_vm_stat(vm_stat_text)
189
+ reading["source"] = "sysctl+vm_stat"
190
+ reading["swapTotalMb"], reading["swapUsedMb"] = parse_swapusage(values.get("vm.swapusage", ""))
191
+ # kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical (the level memory_pressure shows).
192
+ reading["osLevel"] = {"1": "normal", "2": "warn", "4": "critical"}.get(values.get("kern.memorystatus_vm_pressure_level"))
193
+ try:
194
+ reading["cpus"] = int(values["hw.ncpu"])
195
+ except (KeyError, ValueError):
196
+ pass
197
+ return reading
198
+
199
+
200
+ def parse_meminfo(text):
201
+ values = {}
202
+ for line in (text or "").splitlines():
203
+ name, sep, rest = line.partition(":")
204
+ parts = rest.split()
205
+ if sep and parts and parts[0].isdigit():
206
+ values[name.strip()] = int(parts[0]) / 1024 # kB -> MB
207
+ return values
208
+
209
+
210
+ def parse_psi(text):
211
+ """/proc/pressure/memory -> {'someAvg10': float, 'fullAvg10': float} (missing lines omitted)."""
212
+ result = {}
213
+ for line in (text or "").splitlines():
214
+ kind, _, rest = line.partition(" ")
215
+ match = re.search(r"avg10=([0-9.]+)", rest)
216
+ if kind in ("some", "full") and match:
217
+ result[kind + "Avg10"] = float(match.group(1))
218
+ return result or None
219
+
220
+
221
+ def _cgroup_int(text):
222
+ value = (text or "").strip()
223
+ if not value or value == "max":
224
+ return None
225
+ try:
226
+ return int(value)
227
+ except ValueError:
228
+ return None
229
+
230
+
231
+ def parse_cpu_max(text):
232
+ """cgroup v2 cpu.max ('200000 100000' or 'max 100000') -> CPUs (ceil), or None."""
233
+ parts = (text or "").split()
234
+ if len(parts) != 2 or parts[0] == "max":
235
+ return None
236
+ try:
237
+ quota, period = int(parts[0]), int(parts[1])
238
+ except ValueError:
239
+ return None
240
+ return max(1, -(-quota // period)) if quota > 0 and period > 0 else None
241
+
242
+
243
+ def linux_reading(meminfo_text, psi_text=None, cgroup=None):
244
+ """cgroup: optional dict with the raw cgroup v2 files of the tightest limit:
245
+ max, current, stat (memory.stat), pressure (memory.pressure) and cpuMax (cpu.max)."""
246
+ info = parse_meminfo(meminfo_text)
247
+ if "MemTotal" not in info:
248
+ return None
249
+ total = info["MemTotal"]
250
+ available = info.get("MemAvailable")
251
+ if available is None: # kernels before 3.14
252
+ available = info.get("MemFree", 0) + info.get("Buffers", 0) + info.get("Cached", 0)
253
+ reading = {"platform": "linux", "source": "proc", "totalMb": total, "availableMb": available,
254
+ "swapTotalMb": info.get("SwapTotal"), "swapUsedMb": None, "osLevel": None,
255
+ "psi": parse_psi(psi_text) if psi_text else None, "cgroupLimitMb": None}
256
+ if info.get("SwapTotal") is not None and info.get("SwapFree") is not None:
257
+ reading["swapUsedMb"] = max(0.0, info["SwapTotal"] - info["SwapFree"])
258
+ cgroup = cgroup or {}
259
+ limit = _cgroup_int(cgroup.get("max"))
260
+ if limit and limit / MB < total:
261
+ # Inside a container: the cgroup limit is the machine this process really has. Usage is the
262
+ # working set (memory.current minus inactive_file, as the kubelet counts it): page cache the
263
+ # kernel can reclaim is not memory in use.
264
+ used = _cgroup_int(cgroup.get("current")) or 0
265
+ stat = dict(re.findall(r"^(\w+) (\d+)$", cgroup.get("stat") or "", re.MULTILINE))
266
+ used = max(0, used - int(stat.get("inactive_file", 0)))
267
+ reading["cgroupLimitMb"] = limit / MB
268
+ reading["totalMb"] = limit / MB
269
+ reading["availableMb"] = max(0.0, min(available, (limit - used) / MB))
270
+ reading["source"] = "proc+cgroup"
271
+ if cgroup.get("pressure"):
272
+ reading["psi"] = parse_psi(cgroup["pressure"]) or reading["psi"]
273
+ cpus = parse_cpu_max(cgroup.get("cpuMax"))
274
+ if cpus:
275
+ reading["cpuQuota"] = cpus
276
+ return reading
277
+
278
+
279
+ def parse_ps(text):
280
+ """``ps -axo pid=,ppid=,rss=,comm=`` -> [(pid, ppid, rss_kb, basename)]."""
281
+ rows = []
282
+ for line in (text or "").splitlines():
283
+ parts = line.split(None, 3)
284
+ if len(parts) < 4 or not (parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit()):
285
+ continue
286
+ rows.append((int(parts[0]), int(parts[1]), int(parts[2]), os.path.basename(parts[3].strip())))
287
+ return rows
288
+
289
+
290
+ def agent_trees(rows):
291
+ """Agent CLI process trees: roots are agent processes with no agent ancestor; each tree's
292
+ RSS includes every descendant (node workers, MCP servers, shells). Only kinds, counts and
293
+ MB are returned."""
294
+ parent = {pid: ppid for pid, ppid, _, _ in rows}
295
+ rss = {pid: kb for pid, _, kb, _ in rows}
296
+ kind = {pid: AGENT_NAMES.get(name.lower()) for pid, _, _, name in rows}
297
+ children = {}
298
+ for pid, ppid, _, _ in rows:
299
+ children.setdefault(ppid, []).append(pid)
300
+
301
+ def has_agent_ancestor(pid):
302
+ seen, current = set(), parent.get(pid)
303
+ while current and current not in seen:
304
+ if kind.get(current):
305
+ return True
306
+ seen.add(current)
307
+ current = parent.get(current)
308
+ return False
309
+
310
+ by_kind, total = {}, 0
311
+ for pid, _, _, _ in rows:
312
+ if not kind.get(pid) or has_agent_ancestor(pid):
313
+ continue
314
+ stack, seen, tree_kb = [pid], set(), 0
315
+ while stack:
316
+ current = stack.pop()
317
+ if current in seen:
318
+ continue
319
+ seen.add(current)
320
+ tree_kb += rss.get(current, 0)
321
+ stack.extend(children.get(current, []))
322
+ entry = by_kind.setdefault(kind[pid], {"count": 0, "rssMb": 0})
323
+ entry["count"] += 1
324
+ entry["rssMb"] += round(tree_kb / 1024)
325
+ total += tree_kb
326
+ return {"count": sum(e["count"] for e in by_kind.values()), "rssMb": round(total / 1024), "byKind": by_kind}
327
+
328
+
329
+ # --- readers (side effects: one subprocess or a few /proc reads) ----------------------------
330
+
331
+ def _run(argv, timeout=2.0):
332
+ try:
333
+ done = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"})
334
+ return done.stdout
335
+ except (OSError, subprocess.SubprocessError, ValueError):
336
+ return ""
337
+
338
+
339
+ def _read(path, limit=256 * 1024):
340
+ try:
341
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
342
+ return handle.read(limit)
343
+ except OSError:
344
+ return None
345
+
346
+
347
+ def cgroup_files():
348
+ """cgroup v2 files of this process: the tightest memory.max walking up to the root (with
349
+ that level's memory.current and memory.stat), the leaf memory.pressure and the tightest
350
+ cpu.max. Empty dict outside cgroup v2."""
351
+ text = _read("/proc/self/cgroup") or ""
352
+ leaf = next((line[3:].strip() for line in text.splitlines() if line.startswith("0::")), None)
353
+ if leaf is None:
354
+ return {}
355
+ root = Path("/sys/fs/cgroup")
356
+ path = Path(os.path.normpath(str(root / leaf.lstrip("/"))))
357
+ if path != root and root not in path.parents:
358
+ return {}
359
+ result, best, best_cpu = {}, None, None
360
+ for directory in (path, *[p for p in path.parents if p == root or root in p.parents]):
361
+ limit = _cgroup_int(_read(directory / "memory.max", 64))
362
+ if limit and (best is None or limit < best):
363
+ best = limit
364
+ result.update(max=str(limit), current=_read(directory / "memory.current", 64),
365
+ stat=_read(directory / "memory.stat", 16 * 1024),
366
+ pressure=_read(directory / "memory.pressure", 512))
367
+ cpu_text = _read(directory / "cpu.max", 64)
368
+ cpus = parse_cpu_max(cpu_text)
369
+ if cpus and (best_cpu is None or cpus < best_cpu):
370
+ best_cpu, result["cpuMax"] = cpus, cpu_text
371
+ if directory == root:
372
+ break
373
+ return result
374
+
375
+
376
+ def read_machine(platform=None):
377
+ """One raw reading of the machine (no processes)."""
378
+ platform = platform or sys.platform
379
+ reading = None
380
+ if platform == "darwin":
381
+ text = _run(["sysctl", "hw.memsize", "hw.ncpu", "vm.swapusage", "kern.memorystatus_vm_pressure_level", "kern.memorystatus_level"])
382
+ values = parse_sysctl(text)
383
+ reading = macos_reading(text, _run(["vm_stat"]) if "kern.memorystatus_level" not in values else None)
384
+ elif platform.startswith("linux"):
385
+ reading = linux_reading(_read("/proc/meminfo"), _read("/proc/pressure/memory"), cgroup_files())
386
+ if reading is None:
387
+ reading = {"platform": platform if platform in ("darwin",) or platform.startswith("linux") else "other",
388
+ "source": "unavailable", "totalMb": None, "availableMb": None, "swapTotalMb": None, "swapUsedMb": None,
389
+ "osLevel": None, "psi": None, "cgroupLimitMb": None}
390
+ if not reading.get("cpus"):
391
+ try:
392
+ reading["cpus"] = len(os.sched_getaffinity(0)) # type: ignore[attr-defined]
393
+ except (AttributeError, OSError):
394
+ reading["cpus"] = os.cpu_count() or 1
395
+ if reading.get("cpuQuota"):
396
+ reading["cpus"] = min(reading["cpus"], reading.pop("cpuQuota"))
397
+ try:
398
+ reading["load"] = [round(x, 2) for x in os.getloadavg()]
399
+ except (AttributeError, OSError):
400
+ reading["load"] = None
401
+ return reading
402
+
403
+
404
+ def read_processes(platform=None):
405
+ platform = platform or sys.platform
406
+ if platform.startswith("linux"):
407
+ rows, page_kb = [], (os.sysconf("SC_PAGE_SIZE") // 1024 if hasattr(os, "sysconf") else 4)
408
+ try:
409
+ pids = [p for p in os.listdir("/proc") if p.isdigit()]
410
+ except OSError:
411
+ return None
412
+ for pid in pids:
413
+ stat = _read("/proc/" + pid + "/stat", 4096)
414
+ statm = _read("/proc/" + pid + "/statm", 512)
415
+ if not stat or not statm or ")" not in stat:
416
+ continue
417
+ name = stat[stat.find("(") + 1:stat.rfind(")")]
418
+ fields = stat[stat.rfind(")") + 2:].split()
419
+ try:
420
+ rows.append((int(pid), int(fields[1]), int(statm.split()[1]) * page_kb, name))
421
+ except (IndexError, ValueError):
422
+ continue
423
+ return agent_trees(rows)
424
+ if platform == "darwin" or "bsd" in platform:
425
+ text = _run(["ps", "-axo", "pid=,ppid=,rss=,comm="], timeout=3.0)
426
+ return agent_trees(parse_ps(text)) if text else None
427
+ return None
428
+
429
+
430
+ # --- assessment ------------------------------------------------------------------------------
431
+
432
+ def worst(*levels):
433
+ order = {"normal": 0, "warn": 1, "critical": 2}
434
+ known = [l for l in levels if l in order]
435
+ return max(known, key=order.get) if known else None
436
+
437
+
438
+ def assess(reading, cfg):
439
+ """Pressure level for a raw reading: the worst of the OS level, PSI and available percent."""
440
+ total, available = reading.get("totalMb"), reading.get("availableMb")
441
+ reasons = []
442
+ if not total or available is None:
443
+ return {"level": "unknown", "os": reading.get("osLevel"), "psi": reading.get("psi"),
444
+ "reasons": ["memória não medida nesta plataforma; recomendação conservadora"]}
445
+ percent = 100.0 * available / total
446
+ by_percent = "critical" if percent < cfg["criticalAvailablePercent"] else "warn" if percent < cfg["warnAvailablePercent"] else "normal"
447
+ if by_percent != "normal":
448
+ reasons.append("disponível " + str(round(percent)) + "% < " + str(round(cfg["warnAvailablePercent"] if by_percent == "warn"
449
+ else cfg["criticalAvailablePercent"])) + "%")
450
+ psi_level = None
451
+ psi = reading.get("psi")
452
+ if psi:
453
+ if psi.get("fullAvg10", 0) >= cfg["psiCriticalFullAvg10"]:
454
+ psi_level = "critical"
455
+ reasons.append("PSI full avg10 " + str(psi["fullAvg10"]))
456
+ elif psi.get("someAvg10", 0) >= cfg["psiWarnSomeAvg10"]:
457
+ psi_level = "warn"
458
+ reasons.append("PSI some avg10 " + str(psi["someAvg10"]))
459
+ else:
460
+ psi_level = "normal"
461
+ if reading.get("osLevel") in ("warn", "critical"):
462
+ reasons.append("pressão do sistema " + reading["osLevel"])
463
+ return {"level": worst(by_percent, psi_level, reading.get("osLevel")), "os": reading.get("osLevel"), "psi": psi,
464
+ "availablePercent": round(percent, 1), "reasons": reasons}
465
+
466
+
467
+ def recommend(reading, pressure, cfg):
468
+ """maxParallelAgents, maxParallelHeavyJobs, maxWorkersPerJob and mode for this reading."""
469
+ cpus = max(1, int(reading.get("cpus") or 1))
470
+ level = pressure["level"]
471
+ reasons = []
472
+ if level == "unknown":
473
+ agents, heavy = min(2, cfg["maxParallelAgents"]), 1
474
+ workers = max(1, cpus // 2)
475
+ return {"mode": "reduced" if agents > 1 else "sequential", "maxParallelAgents": agents, "maxParallelHeavyJobs": heavy,
476
+ "maxWorkersPerJob": workers, "budgetMb": None, "reasons": ["sem medição: assuma reduced"]}
477
+ reserve = min(cfg["reservedMb"], reading["totalMb"] * cfg["reservedPercent"] / 100.0)
478
+ budget = max(0.0, reading["availableMb"] - reserve)
479
+ if level == "critical":
480
+ return {"mode": "sequential", "maxParallelAgents": 1, "maxParallelHeavyJobs": 0, "maxWorkersPerJob": 1,
481
+ "budgetMb": round(budget), "reasons": ["pressão crítica: sequencial, sem novos jobs pesados"] + pressure["reasons"]}
482
+ memory_agents = int(budget // cfg["perAgentMb"])
483
+ agents = max(1, min(memory_agents, cfg["maxParallelAgents"]))
484
+ memory_heavy = int(budget // cfg["perHeavyJobMb"])
485
+ heavy = max(1, min(memory_heavy, max(1, cpus // 4), cfg["maxParallelHeavyJobs"]))
486
+ if memory_agents < cfg["maxParallelAgents"]:
487
+ reasons.append("memória: " + str(round(budget)) + " MB livres além da reserva, " + str(cfg["perAgentMb"]) + " MB por agente")
488
+ if memory_heavy < 1:
489
+ reasons.append("memória abaixo de um job pesado (" + str(cfg["perHeavyJobMb"]) + " MB): no máximo um, sem nada pesado em paralelo")
490
+ if level == "warn":
491
+ agents, heavy = max(1, agents // 2), max(1, heavy // 2)
492
+ reasons += ["pressão warn: paralelismo pela metade"] + pressure["reasons"]
493
+ load = reading.get("load")
494
+ load_high = bool(load) and load[0] / cpus > cfg["loadPerCpuReduce"]
495
+ if load_high:
496
+ heavy = max(1, heavy // 2)
497
+ reasons.append("carga " + str(load[0]) + " em " + str(cpus) + " CPUs")
498
+ workers = min(max(1, cpus // heavy), max(1, int((budget / heavy) // cfg["perWorkerMb"])))
499
+ if level == "warn" or load_high:
500
+ workers = max(1, workers // 2)
501
+ # Sizing by free memory at normal pressure is already in the numbers; "reduced" means the
502
+ # machine is struggling (pressure or load), "sequential" that there is room for one agent only.
503
+ mode = "sequential" if agents <= 1 else "reduced" if level == "warn" or load_high else "parallel"
504
+ return {"mode": mode, "maxParallelAgents": agents, "maxParallelHeavyJobs": heavy, "maxWorkersPerJob": workers,
505
+ "budgetMb": round(budget), "reasons": reasons}
506
+
507
+
508
+ def build(reading, cfg, meta, agents=None, cached=False, age=0):
509
+ pressure = assess(reading, cfg)
510
+ total, available = reading.get("totalMb"), reading.get("availableMb")
511
+ memory = {"totalMb": round(total) if total else None, "availableMb": round(available) if available is not None else None,
512
+ "usedPercent": round(100.0 * (1 - available / total), 1) if total and available is not None else None,
513
+ "swapTotalMb": round(reading["swapTotalMb"]) if reading.get("swapTotalMb") is not None else None,
514
+ "swapUsedMb": round(reading["swapUsedMb"]) if reading.get("swapUsedMb") is not None else None,
515
+ "cgroupLimitMb": round(reading["cgroupLimitMb"]) if reading.get("cgroupLimitMb") else None}
516
+ load = reading.get("load")
517
+ return {"schema": SCHEMA, "generatedAt": now_iso(), "platform": reading.get("platform"), "source": reading.get("source"),
518
+ "cached": cached, "ageSeconds": int(age), "memory": memory, "pressure": pressure,
519
+ "cpu": {"count": reading.get("cpus"), "load1": load[0] if load else None, "load5": load[1] if load else None,
520
+ "load15": load[2] if load else None},
521
+ "agents": agents, "recommendation": recommend(reading, pressure, cfg), "thresholds": cfg, "config": meta}
522
+
523
+
524
+ # --- cache ----------------------------------------------------------------------------------
525
+
526
+ def read_cache(home, max_age, now=None):
527
+ path = cache_path(home)
528
+ try:
529
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
530
+ return None, 0
531
+ data = json.loads(path.read_text(encoding="utf-8"))
532
+ except (OSError, ValueError):
533
+ return None, 0
534
+ if not isinstance(data, dict) or data.get("schema") != CACHE_SCHEMA or not isinstance(data.get("reading"), dict):
535
+ return None, 0
536
+ try:
537
+ age = (now or time.time()) - float(data.get("at") or 0)
538
+ valid = math.isfinite(age) and valid_reading(data["reading"])
539
+ except (TypeError, ValueError):
540
+ return None, 0
541
+ if not valid or age < 0 or age > max_age:
542
+ return None, 0 # a corrupt or old cache is a miss; the next probe rewrites it
543
+ return data["reading"], age
544
+
545
+
546
+ def valid_reading(reading):
547
+ """A raw reading has the numeric fields build() uses (None allowed where unmeasured)."""
548
+ def number(value, allow_none=True):
549
+ return (value is None and allow_none) or (isinstance(value, (int, float)) and not isinstance(value, bool)
550
+ and math.isfinite(value) and value >= 0)
551
+ if not isinstance(reading, dict):
552
+ return False
553
+ if not all(number(reading.get(k)) for k in ("totalMb", "availableMb", "swapTotalMb", "swapUsedMb", "cgroupLimitMb")):
554
+ return False
555
+ if reading.get("totalMb") == 0 or (reading.get("totalMb") is None and reading.get("availableMb") is not None):
556
+ return False # availableMb may be unmeasured (None) while totalMb is known: assess() reports unknown
557
+ if not number(reading.get("cpus"), allow_none=False) or reading.get("osLevel") not in (None, "normal", "warn", "critical"):
558
+ return False
559
+ load, psi = reading.get("load"), reading.get("psi")
560
+ if load is not None and not (isinstance(load, list) and len(load) == 3 and all(number(x, False) for x in load)):
561
+ return False
562
+ if psi is not None and not (isinstance(psi, dict) and all(number(v, False) for v in psi.values())):
563
+ return False
564
+ return True
565
+
566
+
567
+ def write_cache(home, reading):
568
+ """Atomic, private, never through a symlink; a failure only means no cache."""
569
+ # The home itself may be a symlink (/home -> /var/home on Fedora Atomic); only the
570
+ # directories this CLI creates below it must not be.
571
+ base = Path(os.path.realpath(home))
572
+ path = cache_path(base)
573
+ try:
574
+ directory = path.parent
575
+ for part in (base / ".local", base / ".local" / "state", base / ".local" / "state" / "crow-skills"):
576
+ if part.is_symlink():
577
+ return
578
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
579
+ temp = directory / ("." + path.name + "." + uuid.uuid4().hex)
580
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
581
+ try:
582
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
583
+ json.dump({"schema": CACHE_SCHEMA, "at": time.time(), "reading": reading}, handle)
584
+ os.replace(temp, path)
585
+ finally:
586
+ if os.path.lexists(temp):
587
+ os.unlink(temp)
588
+ except OSError:
589
+ pass
590
+
591
+
592
+ def fixture_reading():
593
+ """The pinned reading of CROW_RESOURCES_FIXTURE (tests), or None when unset or invalid:
594
+ a bad fixture falls back to the real machine instead of failing."""
595
+ fixture = os.environ.get("CROW_RESOURCES_FIXTURE")
596
+ if not fixture:
597
+ return None
598
+ try:
599
+ path = Path(fixture)
600
+ if not path.is_file() or path.stat().st_size > 64 * 1024:
601
+ return None
602
+ data = json.loads(path.read_text(encoding="utf-8"))
603
+ except (OSError, ValueError):
604
+ return None
605
+ if not isinstance(data, dict):
606
+ return None
607
+ agents = data.pop("agents", None)
608
+ if not valid_reading(data):
609
+ return None
610
+ if isinstance(agents, dict):
611
+ data["agents"] = agents
612
+ return data
613
+
614
+
615
+ def probe(home, processes=True, use_cache=False, platform=None):
616
+ """The crow-resources/1 document. use_cache: reuse a reading younger than cacheSeconds
617
+ (hooks, status lines, snapshot); processes: also scan agent CLI process trees."""
618
+ cfg, meta = load_config(home)
619
+ reading, age, cached = None, 0, False
620
+ data = fixture_reading()
621
+ if data is not None:
622
+ agents = data.pop("agents", None) if processes else None
623
+ return build(data, cfg, meta, agents, False, 0)
624
+ if use_cache:
625
+ reading, age = read_cache(home, cfg["cacheSeconds"])
626
+ cached = reading is not None
627
+ if reading is None:
628
+ reading = read_machine(platform)
629
+ write_cache(home, reading)
630
+ agents = read_processes(platform) if processes else None
631
+ return build(reading, cfg, meta, agents, cached, age)
632
+
633
+
634
+ # --- presentation ---------------------------------------------------------------------------
635
+
636
+ def alert(data):
637
+ """True when the machine needs attention: pressure warn/critical or a non-parallel recommendation."""
638
+ return data["pressure"]["level"] in ("warn", "critical") or data["recommendation"]["mode"] != "parallel"
639
+
640
+
641
+ def gb(mb):
642
+ return "?" if mb is None else ("%.1f" % (mb / 1024)).replace(".", ",")
643
+
644
+
645
+ def hint(data):
646
+ """One line for a prompt hook's additionalContext, or None when there is nothing to say."""
647
+ if not alert(data):
648
+ return None
649
+ rec, memory = data["recommendation"], data["memory"]
650
+ level = data["pressure"]["level"]
651
+ if level == "unknown":
652
+ head = "[crow-skills] memória: não medida nesta plataforma."
653
+ else:
654
+ head = ("[crow-skills] memória: pressão " + level + ", " + gb(memory["availableMb"]) + " GB livres de " +
655
+ gb(memory["totalMb"]) + " GB.")
656
+ if rec["mode"] == "sequential" and rec["maxParallelHeavyJobs"] == 0:
657
+ body = " Recomendação sequential: um agente por vez, não inicie builds/testes/dev servers novos e encerre os que esta sessão abriu e não usa."
658
+ else:
659
+ body = (" Recomendação " + rec["mode"] + ": até " + str(rec["maxParallelAgents"]) + " agente(s) e " +
660
+ str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s) em paralelo, " + str(rec["maxWorkersPerJob"]) + " worker(s) por job.")
661
+ return head + body + " Detalhe: crow-skills resources --json."
662
+
663
+
664
+ def segment(data, unicode=True):
665
+ """Compact status line/tmux segment, empty when the machine is fine (e.g. 'mem ▲ 85% ×2')."""
666
+ if not alert(data):
667
+ return ""
668
+ used = data["memory"]["usedPercent"]
669
+ level = data["pressure"]["level"]
670
+ # The arrow is for real pressure only; "sequential" at normal pressure shows just the numbers.
671
+ arrow = {"warn": "▲" if unicode else "^", "critical": "▲▲" if unicode else "^^"}.get(level)
672
+ times = "×" if unicode else "x"
673
+ text = "mem " + (arrow + " " if arrow else "") + (str(int(round(used))) + "%" if used is not None else "?")
674
+ return text + " " + times + str(data["recommendation"]["maxParallelAgents"])
675
+
676
+
677
+ def render(data):
678
+ memory, pressure, rec, cpu = data["memory"], data["pressure"], data["recommendation"], data["cpu"]
679
+ lines = ["memória: " + gb(memory["availableMb"]) + " GB livres de " + gb(memory["totalMb"]) + " GB" +
680
+ (" (" + str(int(round(memory["usedPercent"]))) + "% em uso)" if memory["usedPercent"] is not None else "") +
681
+ (" · swap " + gb(memory["swapUsedMb"]) + "/" + gb(memory["swapTotalMb"]) + " GB" if memory["swapTotalMb"] else "") +
682
+ (" · limite cgroup " + gb(memory["cgroupLimitMb"]) + " GB" if memory["cgroupLimitMb"] else ""),
683
+ "pressão: " + pressure["level"] + (" (" + "; ".join(pressure["reasons"]) + ")" if pressure["reasons"] else ""),
684
+ "cpu: " + str(cpu["count"]) + (" · carga " + str(cpu["load1"]) + " " + str(cpu["load5"]) + " " + str(cpu["load15"])
685
+ if cpu["load1"] is not None else "")]
686
+ agents = data.get("agents")
687
+ if agents is not None:
688
+ kinds = ", ".join(k + " " + str(v["count"]) + " (" + gb(v["rssMb"]) + " GB)" for k, v in sorted(agents["byKind"].items()))
689
+ lines.append("agentes: " + str(agents["count"]) + " CLI(s), " + gb(agents["rssMb"]) + " GB RSS" + (" · " + kinds if kinds else ""))
690
+ lines.append("recomendação: " + rec["mode"] + " · até " + str(rec["maxParallelAgents"]) + " agente(s), " +
691
+ str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s), " + str(rec["maxWorkersPerJob"]) + " worker(s) por job")
692
+ for reason in rec["reasons"]:
693
+ lines.append(" - " + reason)
694
+ for error in data["config"]["errors"]:
695
+ lines.append("config: " + error)
696
+ return "\n".join(lines)
697
+
698
+
699
+ def main(argv=None):
700
+ import argparse
701
+ p = argparse.ArgumentParser(prog="crow_resources", description="Memória, pressão e paralelismo recomendado.")
702
+ p.add_argument("--home")
703
+ p.add_argument("--json", action="store_true")
704
+ p.add_argument("--no-processes", action="store_true")
705
+ args = p.parse_args(argv)
706
+ home = Path(args.home).expanduser() if args.home else Path.home()
707
+ data = probe(home, processes=not args.no_processes)
708
+ print(json.dumps(data, ensure_ascii=False) if args.json else render(data))
709
+ return 0
710
+
711
+
712
+ if __name__ == "__main__":
713
+ raise SystemExit(main())