@andespindola/crow-skills 0.38.1 → 0.38.2

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.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.38.1"
3
+ __version__ = "0.38.2"
@@ -534,15 +534,46 @@ def _identity(pid):
534
534
  return None
535
535
 
536
536
 
537
+ _NAMES_READER = None
538
+
539
+
540
+ def _served_names():
541
+ """pid -> server name, borrowed from the runtime module.
542
+
543
+ This module deliberately spawns nothing; it reaches the process table the
544
+ same way it reaches process identity, through crow_mcp.py.
545
+ """
546
+ global _NAMES_READER
547
+ try:
548
+ if _NAMES_READER is None:
549
+ spec = importlib.util.spec_from_file_location("crow_mcp_status_names", _runtime())
550
+ module = importlib.util.module_from_spec(spec)
551
+ spec.loader.exec_module(module)
552
+ _NAMES_READER = module.serving_names
553
+ return _NAMES_READER() or {}
554
+ except (OSError, ValueError, ImportError, AttributeError):
555
+ return {}
556
+
557
+
537
558
  def status(home):
538
559
  home = _home(home)
539
560
  result = plan(home)
540
561
  result.update(action="status", configuredManaged=sum(r["managed"] for r in result["servers"]))
541
562
  snapshots = []
542
563
  directory = _state(home) / "instances"
564
+ served = _served_names()
565
+ stale = 0
543
566
  if directory.exists():
544
567
  _safe(directory, home)
545
- for path in sorted(directory.glob("runtime-*.json"))[:200]:
568
+ # Newest first. This used to be sorted(glob)[:200], i.e. by FILENAME —
569
+ # and filenames start with a pid, so the window was arbitrary. Measured
570
+ # on a real machine: 1402 records on disk, the 200 read held 142
571
+ # "stopped" and exactly 2 live pids, while `ps` showed 25 live servers.
572
+ # The observations described mostly dead processes and missed the ones
573
+ # actually running, which is worse than reporting nothing.
574
+ files = sorted(directory.glob("runtime-*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
575
+ stale = max(0, len(files) - 200)
576
+ for path in files[:200]:
546
577
  try:
547
578
  data = json.loads(_read(path, home))
548
579
  # Registry entries are observations, never authority to signal PIDs.
@@ -561,10 +592,28 @@ def status(home):
561
592
  row[live_key] = current is not None
562
593
  row[verified_key] = current is not None and isinstance(recorded, (tuple, list)) and tuple(recorded) == tuple(current)
563
594
  row["source"] = "runtime-reported"
595
+ # Which server this is, when its process is still around.
596
+ name = served.get(data.get("pid"))
597
+ if name:
598
+ row["name"] = name
564
599
  snapshots.append(row)
565
600
  except (OSError, ValueError):
566
601
  continue
567
602
  result["runtimeObservations"] = snapshots
603
+ # Aggregates, so a caller can say "N of M alive" instead of counting
604
+ # configured entries and calling it a measurement.
605
+ alive = sorted({r["name"] for r in snapshots if r.get("name") and r.get("pidAlive")})
606
+ result["aliveManaged"] = len(alive)
607
+ result["aliveNames"] = alive
608
+ result["staleRuntimeRecords"] = stale
609
+ # Warm-up caches are reported, never deleted here: dropping one forces a
610
+ # cold start, which is a behaviour change and the owner's decision.
611
+ try:
612
+ caches = list(directory.glob("metadata-*.json")) if directory.exists() else []
613
+ result["warmupCaches"] = len(caches)
614
+ result["warmupCacheBytes"] = sum(c.stat().st_size for c in caches)
615
+ except OSError:
616
+ pass
568
617
  result["liveSessionsReloaded"] = False
569
618
  return result
570
619
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.38.1",
3
+ "version": "0.38.2",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
package/release.json CHANGED
@@ -28,7 +28,7 @@
28
28
  "crow_skills/__init__.py": {
29
29
  "executable": false,
30
30
  "kind": "file",
31
- "sha256": "5c2d124b8f54ae50df371268045f54ba0e08e8cdb27f2141773bfb80fba661b1"
31
+ "sha256": "7765d89d0c3b7dd8b7d765a61e6a507429a1b2f7710fae47b1d014183fb87e40"
32
32
  },
33
33
  "crow_skills/__main__.py": {
34
34
  "executable": false,
@@ -93,7 +93,7 @@
93
93
  "crow_skills/mcp.py": {
94
94
  "executable": false,
95
95
  "kind": "file",
96
- "sha256": "a7de34a4f31d6b48ce640fca8b2a9086bd780d3d1d2aa953263e1caaa5c717d2"
96
+ "sha256": "cf88b7c1fa188eda74c43e6da17198b7b285cc97706d2003ddf7fbe6d8dd5c99"
97
97
  },
98
98
  "crow_skills/npm_entry.py": {
99
99
  "executable": false,
@@ -148,7 +148,7 @@
148
148
  "package.json": {
149
149
  "executable": false,
150
150
  "kind": "file",
151
- "sha256": "64e8d706972b11b51c6decc284fa11abb151d87e977e43723eb3849d1f409d87"
151
+ "sha256": "9c066a2aa80bb4a853faf9ec8c9d5e0887884e4541539f5e61c62dd369c715b3"
152
152
  },
153
153
  "skills/crow-accessibility/SKILL.md": {
154
154
  "executable": false,
@@ -968,10 +968,10 @@
968
968
  "skills/crow-uny-mcp/scripts/crow_mcp.py": {
969
969
  "executable": false,
970
970
  "kind": "file",
971
- "sha256": "160c814085dfdbe93e0166f2a773345dd1436f07a63dbf58d447f3f7674dc759"
971
+ "sha256": "7dd3660097c3afeadb58357939b51da518a4da7cf2d7e684c2618b22f6c4a87b"
972
972
  }
973
973
  },
974
- "revision": "b86c3ef157a305468fa37ce18f84cca40dc1b11d",
974
+ "revision": "11982e7c39ba1b449a442f8deb6468d5a27d26f4",
975
975
  "schema_version": 1,
976
- "version": "0.38.1"
976
+ "version": "0.38.2"
977
977
  }
@@ -133,6 +133,72 @@ if sys.platform == "darwin":
133
133
  pass
134
134
 
135
135
 
136
+ def prune_runtime_records(state_dir, keep_hours=24):
137
+ """Drop runtime records whose process is gone and that are no longer fresh.
138
+
139
+ Nothing ever removed these. Measured on a real machine: 1402 records, 1148
140
+ of them older than a day. They are tiny (0.4 MB), but the status reader
141
+ takes a bounded window over them, so the dead ones crowded out every live
142
+ server — it reported 2 live pids while 25 servers were running. Pruned by
143
+ whoever creates them; a reader must stay read-only. Metadata caches are
144
+ deliberately NOT touched: discarding a warm-up cache changes behaviour by
145
+ forcing cold starts, which is the owner's call, not hygiene.
146
+ """
147
+ removed = 0
148
+ try:
149
+ directory = Path(state_dir)
150
+ cutoff = time.time() - keep_hours * 3600
151
+ for path in directory.glob("runtime-*.json"):
152
+ try:
153
+ if path.stat().st_mtime > cutoff:
154
+ continue
155
+ data = json.loads(path.read_text())
156
+ pid = data.get("pid")
157
+ if isinstance(pid, int) and not isinstance(pid, bool) and process_identity(pid) is not None:
158
+ continue # still alive: not ours to remove
159
+ path.unlink()
160
+ removed += 1
161
+ except (OSError, ValueError):
162
+ continue
163
+ except OSError:
164
+ return removed
165
+ return removed
166
+
167
+
168
+ def serving_names():
169
+ """pid -> public name of every live `crow_mcp.py serve` on this machine.
170
+
171
+ The runtime registry records state and pids but not the server's NAME, so a
172
+ reader could only report anonymous liveness. The name is on the command
173
+ line; this is the one place allowed to look, because the status module
174
+ deliberately spawns nothing and borrows helpers from here.
175
+ """
176
+ names = {}
177
+ try:
178
+ out = subprocess.run(["/bin/ps", "-axo", "pid=,command="],
179
+ capture_output=True, text=True, timeout=10)
180
+ except (OSError, subprocess.SubprocessError):
181
+ return names
182
+ if out.returncode != 0:
183
+ return names
184
+ for line in out.stdout.splitlines():
185
+ text = line.strip()
186
+ if "crow_mcp.py" not in text or " serve" not in text:
187
+ continue
188
+ head, _, rest = text.partition(" ")
189
+ try:
190
+ pid = int(head)
191
+ except ValueError:
192
+ continue
193
+ parts = rest.split()
194
+ if "--name" in parts:
195
+ try:
196
+ names[pid] = parts[parts.index("--name") + 1]
197
+ except IndexError:
198
+ continue
199
+ return names
200
+
201
+
136
202
  def process_identity(pid):
137
203
  """Kernel start identity, not PID alone or second-granularity ps time."""
138
204
  try:
@@ -173,6 +239,9 @@ class Proxy:
173
239
  self.input_fd, self.output_fd = input_fd, output_fd
174
240
  self.store = PrivateStore(options.state_dir)
175
241
  self.registry = "runtime-" + str(os.getpid()) + "-" + uuid.uuid4().hex + ".json"
242
+ # Clean up after the ones that came before: this is the layer that
243
+ # creates the records, so it is the layer that retires them.
244
+ prune_runtime_records(options.state_dir)
176
245
  self.sel = selectors.DefaultSelector()
177
246
  self.sel.register(input_fd, selectors.EVENT_READ, "host")
178
247
  os.set_blocking(output_fd, False)