@andespindola/crow-skills 0.38.0 → 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.0"
3
+ __version__ = "0.38.2"
@@ -179,6 +179,8 @@ def spawn(home, role, brief, cli=None, model=None, owner=None, cwd=None):
179
179
  "model": model,
180
180
  "owner": owner,
181
181
  "cwd": cwd or os.getcwd(),
182
+ "briefs": [brief],
183
+ "findings": [],
182
184
  "state": "working",
183
185
  "spawnedAt": broadcast.now_iso(),
184
186
  "lastAssignedAt": broadcast.now_iso(),
@@ -207,7 +209,13 @@ def assign(home, bot_id, brief, owner=None):
207
209
  pid, error = _launch(home, record, brief, resume=True)
208
210
  if error:
209
211
  raise RuntimeError(error)
210
- record.update(pid=pid, state="working", brief=brief,
212
+ # Keep every brief, do not overwrite. The knowledge a bot holds came from
213
+ # the base agent, so the briefs ARE the record of what it was taught; an
214
+ # assign that replaced the previous one erased run 1 when run 2 arrived,
215
+ # and with it the only trace of why the bot knows what it knows.
216
+ history = list(record.get("briefs") or ([record["brief"]] if record.get("brief") else []))
217
+ history.append(brief)
218
+ record.update(pid=pid, state="working", brief=brief, briefs=history[-20:],
211
219
  lastAssignedAt=broadcast.now_iso(),
212
220
  runs=int(record.get("runs") or 0) + 1)
213
221
  if owner:
@@ -216,6 +224,46 @@ def assign(home, bot_id, brief, owner=None):
216
224
  return record
217
225
 
218
226
 
227
+ def learned(home, bot_id, note):
228
+ """Record what a bot REPORTED BACK, as the base agent read it.
229
+
230
+ The bot keeps its own context and never touches the vault; only the base
231
+ agent does. So the findings have to be written down HERE by whoever read
232
+ the report, or they vanish with the session and the next bot of the same
233
+ role starts from nothing — paying again for knowledge already bought.
234
+
235
+ This is the local, machine-level record. Promoting it to the vault, so the
236
+ knowledge survives the machine and reaches a second one, stays the base
237
+ agent's job: the durable note is written through tesus, not from here.
238
+ """
239
+ note = (note or "").strip()
240
+ if not note:
241
+ raise ValueError("um aprendizado vazio não é aprendizado")
242
+ data = _load(home)
243
+ record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
244
+ if not record:
245
+ raise ValueError("bot desconhecido: " + str(bot_id))
246
+ findings = list(record.get("findings") or [])
247
+ findings.append({"at": broadcast.now_iso(), "note": note})
248
+ record["findings"] = findings[-50:]
249
+ _save(home, data)
250
+ return record
251
+
252
+
253
+ def knowledge(home, role):
254
+ """Everything known about a role, across its bots: the briefs that taught
255
+ them and the findings they returned. This is what seeds the NEXT bot of the
256
+ same role instead of briefing it from zero."""
257
+ role = (role or "").strip().lower()
258
+ briefs, findings = [], []
259
+ for b in _load(home)["bots"]:
260
+ if (b.get("role") or "").strip().lower() != role:
261
+ continue
262
+ briefs.extend(b.get("briefs") or ([b["brief"]] if b.get("brief") else []))
263
+ findings.extend(b.get("findings") or [])
264
+ return {"role": role, "briefs": briefs, "findings": findings}
265
+
266
+
219
267
  def release(home, bot_id):
220
268
  """Hand a borrowed bot back to its owner."""
221
269
  data = _load(home)
@@ -211,7 +211,7 @@ def parser():
211
211
  child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
212
212
  child = command_parser(sub, "bot", [common])
213
213
  child.add_argument("action", nargs="?", default="list",
214
- choices=("list", "spawn", "assign", "release", "stop", "prune"))
214
+ choices=("list", "spawn", "assign", "release", "stop", "prune", "learned", "knowledge"))
215
215
  child.add_argument("target", nargs="?", help="assign/release/stop: id do bot")
216
216
  child.add_argument("text", nargs="*", help="assign: o novo briefing")
217
217
  child.add_argument("--role", help="spawn: a especialidade do bot (é o que permite reaproveitá-lo)")
@@ -219,6 +219,7 @@ def parser():
219
219
  child.add_argument("--cli", default="auto", help="spawn: claude, codex, gemini, devin ou auto (primeiro disponível)")
220
220
  child.add_argument("--model", help="spawn: modelo para esta unidade")
221
221
  child.add_argument("--lendable-only", action="store_true", help="list: só os ociosos que outra sessão pode pedir emprestado")
222
+ child.add_argument("--role-of", dest="role_of", help="knowledge: o papel cujo conhecimento acumulado você quer ler")
222
223
  child = command_parser(sub, "broadcast", [common])
223
224
  child.add_argument("action", nargs="?", default="status", choices=("status", "list", "claim", "release", "watch", "say", "reply", "inbox",
224
225
  "thread", "sent", "ack", "feed"))
@@ -582,6 +583,32 @@ def ancestors(limit=16):
582
583
  return pids
583
584
 
584
585
 
586
+ #: The human owner speaking from outside any agent session (desktop app, bare
587
+ #: shell). Deliberately NOT one of hook.HOSTS: it is not an agent and must not
588
+ #: be mistaken for one in the roster or the feed.
589
+ OWNER_HOST = "owner"
590
+
591
+
592
+ def sender_owner(args, home, cwd):
593
+ """Who is SENDING. Same resolution as broadcast_owner, but a caller with no
594
+ live-agent ancestry is the owner's own console rather than an error.
595
+
596
+ A desktop app is reparented to launchd, so it matches no session and
597
+ broadcast_owner returns None; `say` then dereferenced it into
598
+ "AttributeError: 'NoneType' object has no attribute 'get'" and the owner
599
+ could never speak on the channel from outside a terminal. The identity is
600
+ deterministic so agents can address a reply back to it.
601
+
602
+ Scoped to sending ON PURPOSE: claims, inbox, sent and ack keep requiring a
603
+ real session, because attributing those to a console would be wrong.
604
+ """
605
+ found = broadcast_owner(args, home, cwd)
606
+ if found:
607
+ return found
608
+ return {"key": broadcast.session_key(OWNER_HOST, OWNER_HOST, None), "host": OWNER_HOST,
609
+ "session": None, "detected": "owner"}
610
+
611
+
585
612
  def broadcast_owner(args, home, cwd):
586
613
  """The session that runs this command: --session/--host, else the live session whose CLI pid is an ancestor."""
587
614
  live = broadcast.live_sessions(home)
@@ -710,6 +737,14 @@ def bot_command(args, home):
710
737
  raise CrowError("usage", str(exc))
711
738
  out = {"bot": record, "text": "bot " + record["id"] + " (" + record["role"] + ") iniciado em " +
712
739
  str(record.get("cli")) + ", pid " + str(record.get("pid")) + " — você não ficou bloqueado"}
740
+ known = bots.knowledge(home, getattr(args, "role", "") or "")
741
+ if known["briefs"] or known["findings"]:
742
+ # Informs, does not inject: silently prepending it would inflate the
743
+ # brief, which is the very context cost this is meant to protect.
744
+ out["knowledge"] = {"briefs": len(known["briefs"]), "findings": len(known["findings"])}
745
+ out["text"] += ("\nJá há conhecimento deste papel: " + str(len(known["briefs"])) + " briefing(s) e " +
746
+ str(len(known["findings"])) + " achado(s). Leia com `bot knowledge --role-of " +
747
+ (getattr(args, "role", "") or "") + "` e inclua no briefing o que valer.")
713
748
  if free:
714
749
  # Not a refusal: the decision is the caller's, but spending a new
715
750
  # process while an idle specialist sits there is the waste this
@@ -736,6 +771,29 @@ def bot_command(args, home):
736
771
  raise CrowError("usage", str(exc))
737
772
  return {"bot": record, "text": "bot " + record["id"] + " " +
738
773
  ("devolvido ao dono" if action == "release" else "encerrado")}
774
+ if action == "learned":
775
+ # The bot keeps its own context and never writes to the vault; whoever
776
+ # read its report records the finding here, or it dies with the session.
777
+ note = " ".join(getattr(args, "text", []) or []).strip()
778
+ if not getattr(args, "target", None) or not note:
779
+ raise CrowError("usage", "learned precisa do id do bot e do que ele reportou.")
780
+ try:
781
+ record = bots.learned(home, args.target, note)
782
+ except ValueError as exc:
783
+ raise CrowError("usage", str(exc))
784
+ return {"bot": record,
785
+ "text": "registrado no bot " + record["id"] + " (" + str(len(record.get("findings") or [])) +
786
+ " achado(s)). Isto é local: promova ao vault o que for durável."}
787
+ if action == "knowledge":
788
+ role = getattr(args, "role_of", None) or getattr(args, "target", None)
789
+ if not role:
790
+ raise CrowError("usage", "knowledge precisa do papel: --role-of <papel>.")
791
+ data = bots.knowledge(home, role)
792
+ n_b, n_f = len(data["briefs"]), len(data["findings"])
793
+ return {**data,
794
+ "text": ("papel \"" + data["role"] + "\": " + str(n_b) + " briefing(s) e " + str(n_f) +
795
+ " achado(s) acumulados" if n_b or n_f else
796
+ "papel \"" + data["role"] + "\": nada acumulado ainda")}
739
797
  if action == "prune":
740
798
  return bots.prune(home)
741
799
  raise CrowError("usage", "ação de bot desconhecida: " + str(action))
@@ -819,7 +877,9 @@ def broadcast_command(args, home):
819
877
  raise CrowError("not_found", "Nenhuma mensagem endereçada a esta sessão com esse(s) id(s) (expirada, inexistente, ou enviada por você).")
820
878
  return {"schema": broadcast.MESSAGES_SCHEMA, "messages": acked,
821
879
  "text": "leitura confirmada: " + ", ".join(str(m.get("id")) for m in acked)}
822
- owner = broadcast_owner(args, home, cwd)
880
+ # Sending is the one path where a caller outside any agent session is
881
+ # legitimate: it is the owner talking from their own console.
882
+ owner = sender_owner(args, home, cwd) if action in ("say", "reply") else broadcast_owner(args, home, cwd)
823
883
  if action in ("say", "reply"):
824
884
  reply_to = getattr(args, "reply_to", None)
825
885
  targets = list(args.targets)
@@ -833,6 +893,14 @@ def broadcast_command(args, home):
833
893
  ttl = broadcast.parse_ttl(args.ttl) if args.ttl else None
834
894
  except ValueError:
835
895
  raise CrowError("usage", "--ttl inválido: use 30m, 2h, 90s (máximo 24h).") from None
896
+ if not isinstance(owner, dict) or not owner.get("key"):
897
+ # send_message dereferences the sender inside a dict literal, so a
898
+ # missing one surfaced as "AttributeError: 'NoneType' object has no
899
+ # attribute 'get'". Guarded HERE, not in broadcast.py: that module
900
+ # deliberately carries no dependency on source/CrowError, and adding
901
+ # one closes an import cycle through hook.py (measured: 52 tests).
902
+ raise CrowError("usage", "Remetente desconhecido: informe --session/--host "
903
+ "ou envie a partir de uma sessão viva.")
836
904
  try:
837
905
  record = broadcast.send_message(home, " ".join(targets), owner, to=args.to, ttl=ttl, cwd=cwd,
838
906
  reply_to=reply_to, wake=not getattr(args, "no_wake", False))
@@ -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.0",
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": "b83bfadfc540ccf6b59b8a1e5f283191d67f3fd4ca1bbd76934a9a45f0917658"
31
+ "sha256": "7765d89d0c3b7dd8b7d765a61e6a507429a1b2f7710fae47b1d014183fb87e40"
32
32
  },
33
33
  "crow_skills/__main__.py": {
34
34
  "executable": false,
@@ -48,7 +48,7 @@
48
48
  "crow_skills/bots.py": {
49
49
  "executable": false,
50
50
  "kind": "file",
51
- "sha256": "4e01f1ae95ce5feed897146ad7c61250494845d2409da009044fce2272df9463"
51
+ "sha256": "b6119c6974d06152b3462c67368b0fd81d4be389419838eebf3c5007e421a10d"
52
52
  },
53
53
  "crow_skills/broadcast.py": {
54
54
  "executable": false,
@@ -58,7 +58,7 @@
58
58
  "crow_skills/cli.py": {
59
59
  "executable": false,
60
60
  "kind": "file",
61
- "sha256": "04f2c49af4888fc5d8a1b28aee4b6b591ee64d2c38ef0f463800f79f7afc28c7"
61
+ "sha256": "7fdd9af628596e8b7abc111b9277cc6498f5a68e6bdab9421e9aec35c0374ab3"
62
62
  },
63
63
  "crow_skills/events.py": {
64
64
  "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": "265a2446df8eae2939ab60a804b38f572ad20c2987b47076fe068d026fccbe46"
151
+ "sha256": "9c066a2aa80bb4a853faf9ec8c9d5e0887884e4541539f5e61c62dd369c715b3"
152
152
  },
153
153
  "skills/crow-accessibility/SKILL.md": {
154
154
  "executable": false,
@@ -213,7 +213,7 @@
213
213
  "skills/crow-behaviour/SKILL.md": {
214
214
  "executable": false,
215
215
  "kind": "file",
216
- "sha256": "f8e0f38537875772027868c2112071f38a8c7a699295bec057bf64e6c2930999"
216
+ "sha256": "e7f2cdb1acf214e78be8f8b7c27c013e591478df7ac99addd3e0891cbc0d13b9"
217
217
  },
218
218
  "skills/crow-behaviour/agents/openai.yaml": {
219
219
  "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": "95525c9fb9d566810a61b8465cd7536ce5599c08",
974
+ "revision": "11982e7c39ba1b449a442f8deb6468d5a27d26f4",
975
975
  "schema_version": 1,
976
- "version": "0.38.0"
976
+ "version": "0.38.2"
977
977
  }
@@ -62,6 +62,7 @@ Quando `crow-model-level` estiver instalada, o nível de cada unidade delegada
62
62
  - **O braçal é delegado por padrão, não por escolha sua.** Uma unidade é braçal quando você consegue especificar o resultado e aceitá-lo pela evidência, sem ver o caminho: a edição que você já decidiu, a varredura com critério fechado, a reescrita mecânica, a coleta de evidência, rodar um comando e relatar o que saiu, levantar como uma coisa funciona hoje. Se é preciso decidir no meio do caminho, não é braçal — é desenho, e é seu. Executar você mesmo uma unidade braçal porque "o contexto já está carregado" é a falha característica desta skill: troca o seu contexto, que é o recurso escasso e não se recupera, por trabalho que qualquer executor faria, e o briefing que você economizou custa menos do que o contexto que você gastou.
63
63
  - Ao delegar execução, descubra quem é o micro-agent em vez de presumir: `crow_micro.py probe` — com `codex` disponível e autenticado, os micro-agents são do codex; sem ele, são os do próprio CLI. Ao micro-agent vai só o braçal já especificado; mapear, planejar, desenhar e verificar no fim continuam seus. Leia [micro-agents](references/micro-agents.md).
64
64
  - **Micro-agent ou bot autônomo: decida pela natureza da demanda, não por hábito.** Resultado que você especifica e aceita pela evidência, dentro do turno, é **micro-agent** — efêmero, você verifica e descarta. Demanda que persiste além do turno, dispara por evento e precisa de estado próprio entre execuções é **bot autônomo** — um processo que sobrevive à sessão, com memória externa própria e gatilho; ele não lembra, relê o que gravou e reconstrói o contexto a cada disparo. Micro-agent é o default; bot só quando a persistência ou o gatilho de evento é a essência da demanda, não um "seria bom ter". Bot e micro-agent usam igualmente o crow-broadcast e a mesma fronteira de autonomia desta skill; o criador continua responsável por ambos, verifica o resultado e age quando qualquer um fica ocioso.
65
+ - **O conhecimento do bot é seu para guardar, não dele.** A memória do bot é apartada e perecível: ela morre com a sessão e envelhece quando ele fica ocioso. O que ele sabe veio de você, no briefing — ele é consumidor do conhecimento, nunca o guardião. Então, antes de criar um bot de um papel que já existiu, **leia o que já foi acumulado** (`crow-skills bot knowledge --role-of <papel>`) e inclua no briefing o que valer, em vez de ensinar de novo o que já foi pago. Quando o bot reporta, **registre o que ele devolveu** (`crow-skills bot learned <id> "<o que apurou>"`): o retorno não registrado é conhecimento que ninguém mais tem. Esse registro é local e mecânico; **promover ao durável é seu**, pela memória do dono, porque o bot não toca o vault — só você toca. Destile, não acumule: nota que cresce a cada briefing recria, em outro lugar, o custo de contexto que a delegação existe para cortar. E trate o retorno como alegação a julgar antes de virar registro, não como verdade.
65
66
  - Leia [adaptação ao runtime](references/orchestration.md) ao escolher workflow, subagentes ou execução sequencial. Não presuma nomes de ferramentas, paralelismo ou suporte a isolamento. Se o host exigir opt-in explícito do usuário para orquestração programável, esta skill não é esse opt-in: peça o opt-in da ferramenta, nunca permissão para aplicar esta skill, e sem ele siga com subagentes ou sequencialmente.
66
67
 
67
68
  ### 2. Planeje para produzir e verificar
@@ -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)