@andespindola/crow-skills 0.38.1 → 0.39.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.
@@ -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.39.0"
@@ -8,6 +8,7 @@ import sys
8
8
  from . import __version__
9
9
  from . import autoupdate
10
10
  from . import bots
11
+ from . import demands as demands_mod
11
12
  from . import broadcast
12
13
  from . import events
13
14
  from . import groups as groups_mod
@@ -75,6 +76,10 @@ COMMANDS = {
75
76
  ["crow-skills mode", "crow-skills mode off", "crow-skills mode on"]),
76
77
  "events": ("Barramento de eventos dos hooks (crow-event/1) para terminais.",
77
78
  ["crow-skills events", "crow-skills events tail --limit 20", "crow-skills events tail --follow"]),
79
+ "demand": ("Demandas do dono que ficam registradas até alguém assumir — sem validade, porque demanda sem resposta não é velha, é pendente.",
80
+ ["crow-skills demand list", "crow-skills demand list --pending",
81
+ "crow-skills demand add \"arrumar o scroll do painel\"",
82
+ "crow-skills demand take 9f2a1c", "crow-skills demand done 9f2a1c \"publicado em 0.2.26\""]),
78
83
  "bot": ("Bots especialistas independentes: criar sem bloquear, reaproveitar o ocioso, emprestar entre sessões.",
79
84
  ["crow-skills bot list", "crow-skills bot spawn --role revisor-sql --brief \"revise o diff de migrations\"",
80
85
  "crow-skills bot assign 9f2a1c \"agora valide o rollback\"", "crow-skills bot release 9f2a1c",
@@ -209,6 +214,13 @@ def parser():
209
214
  child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
210
215
  child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
211
216
  child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
217
+ child = command_parser(sub, "demand", [common])
218
+ child.add_argument("action", nargs="?", default="list",
219
+ choices=("list", "add", "take", "done", "fail", "release"))
220
+ child.add_argument("target", nargs="?", help="take/done/fail/release: id da demanda")
221
+ child.add_argument("text", nargs="*", help="add: o pedido; done/fail: a nota de desfecho")
222
+ child.add_argument("--pending", action="store_true", help="list: só o que ninguém assumiu")
223
+ child.add_argument("--thread", help="add: id da conversa do broadcast que originou o pedido")
212
224
  child = command_parser(sub, "bot", [common])
213
225
  child.add_argument("action", nargs="?", default="list",
214
226
  choices=("list", "spawn", "assign", "release", "stop", "prune", "learned", "knowledge"))
@@ -718,6 +730,45 @@ def orphans_command(args, home, interactive):
718
730
  return data
719
731
 
720
732
 
733
+ def demand_command(args, home):
734
+ """The owner's queue. `list` is the default action because the first thing
735
+ an agent should do is look at what is already waiting."""
736
+ action = args.action
737
+ cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
738
+ if action == "list":
739
+ live = {s.get("key") for s in broadcast.live_sessions(home)}
740
+ rows = demands_mod.listing(home, pending_only=getattr(args, "pending", False), live_keys=live)
741
+ return {"demands": rows, "text": demands_mod.render(rows)}
742
+ if action == "add":
743
+ # `target` is positional, so it swallows the first word of the demand;
744
+ # for add there is no id, and the whole tail is the request.
745
+ parts = ([getattr(args, "target", None)] if getattr(args, "target", None) else []) + list(getattr(args, "text", []) or [])
746
+ text = " ".join(p for p in parts if p).strip()
747
+ if not text:
748
+ raise CrowError("usage", "add precisa do pedido: crow-skills demand add \"o que fazer\".")
749
+ try:
750
+ record = demands_mod.add(home, text, thread=getattr(args, "thread", None))
751
+ except ValueError as exc:
752
+ raise CrowError("usage", str(exc))
753
+ return {"demand": record,
754
+ "text": "demanda " + record["id"] + " registrada e pendente; ela não expira."}
755
+ if not getattr(args, "target", None):
756
+ raise CrowError("usage", action + " precisa do id da demanda.")
757
+ note = " ".join(getattr(args, "text", []) or []).strip()
758
+ try:
759
+ if action == "take":
760
+ owner = broadcast_owner(args, home, cwd)
761
+ record = demands_mod.take(home, args.target, by=(owner or {}).get("key"))
762
+ return {"demand": record, "text": "demanda " + record["id"] + " é sua; devolva com release se não puder terminar."}
763
+ if action in ("done", "fail"):
764
+ record = demands_mod.close(home, args.target, "done" if action == "done" else "failed", note)
765
+ return {"demand": record, "text": "demanda " + record["id"] + " encerrada como " + record["state"] + "."}
766
+ record = demands_mod.release(home, args.target)
767
+ return {"demand": record, "text": "demanda " + record["id"] + " devolvida à fila."}
768
+ except (ValueError, RuntimeError) as exc:
769
+ raise CrowError("usage", str(exc))
770
+
771
+
721
772
  def bot_command(args, home):
722
773
  """Independent specialists. `list` comes first on purpose: the rule is to
723
774
  look for an idle bot before creating another one."""
@@ -1364,6 +1415,8 @@ def run(args):
1364
1415
  return mcp_command(args, home)
1365
1416
  if args.command == "events":
1366
1417
  return events_command(args, home, store)
1418
+ if args.command == "demand":
1419
+ return demand_command(args, home)
1367
1420
  if args.command == "bot":
1368
1421
  return bot_command(args, home)
1369
1422
  if args.command == "broadcast":
@@ -0,0 +1,195 @@
1
+ """The owner's demands, kept until somebody takes them.
2
+
3
+ A demand typed in the Crow Cockpit used to exist only as a broadcast message,
4
+ and broadcast messages expire (`stale_minutes`, 30 by default). So a demand
5
+ sent while no agent session was alive to receive it simply evaporated: nothing
6
+ to resume, because nothing had been written down. Closing the app looked like
7
+ the cause; the real cause was that the request was never recorded anywhere.
8
+
9
+ This is deliberately NOT a second memory. The vault remains the record: an
10
+ agent that takes a demand writes it there. This store exists for one narrow
11
+ reason — the cockpit is not an agent, carries no contract and has no
12
+ authenticated path to the vault, so it needs somewhere durable to leave a
13
+ request for the window in which nobody is listening. It holds the handoff, not
14
+ the history, and it should disappear the day the cockpit can record directly.
15
+
16
+ No TTL, on purpose: an unanswered demand is not stale, it is unanswered.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import uuid
22
+
23
+ from . import broadcast
24
+ from .storage import atomic_json, ensure_dir
25
+
26
+ SCHEMA = "crow-demands/1"
27
+
28
+ #: pending — written, nobody took it
29
+ #: taken — an agent is on it (with who and when)
30
+ #: done — finished, with a short note of the outcome
31
+ #: failed — attempted and abandoned, with the reason
32
+ STATES = ("pending", "taken", "done", "failed")
33
+
34
+ #: Keep finished demands around this long so the owner can still see what was
35
+ #: answered; they are history, not queue, and are pruned on write.
36
+ KEEP_DONE_HOURS = 72
37
+
38
+
39
+ def path(home):
40
+ return broadcast.state_dir(home) / "demands.json"
41
+
42
+
43
+ def _load(home):
44
+ data = broadcast.read_json(path(home)) or {}
45
+ if data.get("schema") != SCHEMA:
46
+ return {"schema": SCHEMA, "demands": []}
47
+ data.setdefault("demands", [])
48
+ return data
49
+
50
+
51
+ def _save(home, data):
52
+ target = path(home)
53
+ ensure_dir(target.parent)
54
+ atomic_json(target, data)
55
+
56
+
57
+ def _age_hours(stamp):
58
+ import time
59
+ try:
60
+ return (time.time() - time.mktime(time.strptime((stamp or "")[:19], "%Y-%m-%dT%H:%M:%S"))) / 3600
61
+ except (ValueError, TypeError):
62
+ return None
63
+
64
+
65
+ def add(home, text, thread=None, source=None):
66
+ """Record a demand. Called by whoever received it, before acting."""
67
+ text = (text or "").strip()
68
+ if not text:
69
+ raise ValueError("uma demanda vazia não é uma demanda")
70
+ record = {
71
+ "id": uuid.uuid4().hex[:10],
72
+ "text": text,
73
+ "state": "pending",
74
+ "createdAt": broadcast.now_iso(),
75
+ "thread": thread or None,
76
+ "source": source or "cockpit",
77
+ "takenBy": None,
78
+ "takenAt": None,
79
+ "closedAt": None,
80
+ "note": None,
81
+ "attempts": 0,
82
+ }
83
+ data = _load(home)
84
+ data["demands"].append(record)
85
+ _prune(data)
86
+ _save(home, data)
87
+ return record
88
+
89
+
90
+ def _find(data, demand_id):
91
+ return next((d for d in data["demands"] if d.get("id") == demand_id), None)
92
+
93
+
94
+ def take(home, demand_id, by=None):
95
+ """Claim a demand. Refuses one already in hand, so two agents do not both
96
+ start the same work — the duplication this queue exists to prevent."""
97
+ data = _load(home)
98
+ record = _find(data, demand_id)
99
+ if not record:
100
+ raise ValueError("demanda desconhecida: " + str(demand_id))
101
+ if record["state"] == "taken" and record.get("takenBy") not in (None, by):
102
+ raise RuntimeError("a demanda " + demand_id + " já está com " + str(record.get("takenBy")))
103
+ if record["state"] in ("done", "failed"):
104
+ raise RuntimeError("a demanda " + demand_id + " já foi encerrada como " + record["state"])
105
+ record.update(state="taken", takenBy=by, takenAt=broadcast.now_iso(),
106
+ attempts=int(record.get("attempts") or 0) + 1)
107
+ _save(home, data)
108
+ return record
109
+
110
+
111
+ def close(home, demand_id, state, note=None):
112
+ """Finish a demand. `note` is what the owner needs to read, not a status."""
113
+ if state not in ("done", "failed"):
114
+ raise ValueError("estado final inválido: " + str(state))
115
+ if state == "failed" and not (note or "").strip():
116
+ raise ValueError("uma demanda que falhou precisa dizer por quê")
117
+ data = _load(home)
118
+ record = _find(data, demand_id)
119
+ if not record:
120
+ raise ValueError("demanda desconhecida: " + str(demand_id))
121
+ record.update(state=state, closedAt=broadcast.now_iso(), note=(note or "").strip() or None)
122
+ _prune(data)
123
+ _save(home, data)
124
+ return record
125
+
126
+
127
+ def release(home, demand_id):
128
+ """Put a taken demand back. An agent that cannot finish must hand it back
129
+ rather than leave it held by a session that is about to die."""
130
+ data = _load(home)
131
+ record = _find(data, demand_id)
132
+ if not record:
133
+ raise ValueError("demanda desconhecida: " + str(demand_id))
134
+ if record["state"] != "taken":
135
+ raise RuntimeError("a demanda " + demand_id + " não está tomada")
136
+ record.update(state="pending", takenBy=None, takenAt=None)
137
+ _save(home, data)
138
+ return record
139
+
140
+
141
+ def _prune(data):
142
+ kept = []
143
+ for record in data["demands"]:
144
+ if record.get("state") in ("done", "failed"):
145
+ age = _age_hours(record.get("closedAt"))
146
+ if age is not None and age > KEEP_DONE_HOURS:
147
+ continue
148
+ kept.append(record)
149
+ data["demands"] = kept
150
+
151
+
152
+ def listing(home, pending_only=False, live_keys=None):
153
+ """The queue, with abandonment made visible.
154
+
155
+ A demand held by a session that no longer exists is reported as `orphaned`
156
+ rather than `taken`: it is the one state that looks like progress and is
157
+ not, and it is exactly what happens when the owner's agent dies mid-task.
158
+ """
159
+ data = _load(home)
160
+ rows = []
161
+ changed = False
162
+ for record in data["demands"]:
163
+ row = dict(record)
164
+ if record.get("state") == "taken" and live_keys is not None:
165
+ if record.get("takenBy") and record["takenBy"] not in live_keys:
166
+ row["orphaned"] = True
167
+ # Hand it back so somebody can pick it up again.
168
+ record.update(state="pending", takenBy=None, takenAt=None)
169
+ row["state"] = "pending"
170
+ changed = True
171
+ if pending_only and row.get("state") != "pending":
172
+ continue
173
+ rows.append(row)
174
+ if changed:
175
+ _save(home, data)
176
+ return rows
177
+
178
+
179
+ def render(rows):
180
+ if not rows:
181
+ return "(nenhuma demanda registrada)"
182
+ mark = {"pending": "○", "taken": "●", "done": "✓", "failed": "✗"}
183
+ lines = []
184
+ for d in rows:
185
+ text = broadcast.clean(d.get("text"), 90) if hasattr(broadcast, "clean") else str(d.get("text"))[:90]
186
+ extra = []
187
+ if d.get("orphaned"):
188
+ extra.append("estava com uma sessão que morreu, devolvida à fila")
189
+ if d.get("takenBy"):
190
+ extra.append("com " + str(d["takenBy"])[:10])
191
+ if d.get("note"):
192
+ extra.append(str(d["note"])[:60])
193
+ lines.append(" " + mark.get(d.get("state"), "·") + " [" + str(d.get("id")) + "] " + text +
194
+ (" · " + "; ".join(extra) if extra else ""))
195
+ return "\n".join(lines)
@@ -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.39.0",
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": "3b0211c6914097b573cea26e30b34fef698b83056d512dc3f2f1de2e0c51ade5"
32
32
  },
33
33
  "crow_skills/__main__.py": {
34
34
  "executable": false,
@@ -58,7 +58,12 @@
58
58
  "crow_skills/cli.py": {
59
59
  "executable": false,
60
60
  "kind": "file",
61
- "sha256": "7fdd9af628596e8b7abc111b9277cc6498f5a68e6bdab9421e9aec35c0374ab3"
61
+ "sha256": "506a45896915a0effa409a6763c95ddabf46953aa68e16f958af1d2088e7ad18"
62
+ },
63
+ "crow_skills/demands.py": {
64
+ "executable": false,
65
+ "kind": "file",
66
+ "sha256": "ce2436f20878b92eb175143d6b36f03ed62d490315bbb01958aa1646ae9d8225"
62
67
  },
63
68
  "crow_skills/events.py": {
64
69
  "executable": false,
@@ -93,7 +98,7 @@
93
98
  "crow_skills/mcp.py": {
94
99
  "executable": false,
95
100
  "kind": "file",
96
- "sha256": "a7de34a4f31d6b48ce640fca8b2a9086bd780d3d1d2aa953263e1caaa5c717d2"
101
+ "sha256": "cf88b7c1fa188eda74c43e6da17198b7b285cc97706d2003ddf7fbe6d8dd5c99"
97
102
  },
98
103
  "crow_skills/npm_entry.py": {
99
104
  "executable": false,
@@ -148,7 +153,7 @@
148
153
  "package.json": {
149
154
  "executable": false,
150
155
  "kind": "file",
151
- "sha256": "64e8d706972b11b51c6decc284fa11abb151d87e977e43723eb3849d1f409d87"
156
+ "sha256": "de3052ab0111c37ac40038dc78a1372e3c5696e87007a36fcc8241880bba9b14"
152
157
  },
153
158
  "skills/crow-accessibility/SKILL.md": {
154
159
  "executable": false,
@@ -213,7 +218,7 @@
213
218
  "skills/crow-behaviour/SKILL.md": {
214
219
  "executable": false,
215
220
  "kind": "file",
216
- "sha256": "e7f2cdb1acf214e78be8f8b7c27c013e591478df7ac99addd3e0891cbc0d13b9"
221
+ "sha256": "0d943e32841e808c9f3e60f2faa97bac08b1ce143ec56099354f2bb4939d82f4"
217
222
  },
218
223
  "skills/crow-behaviour/agents/openai.yaml": {
219
224
  "executable": false,
@@ -968,10 +973,10 @@
968
973
  "skills/crow-uny-mcp/scripts/crow_mcp.py": {
969
974
  "executable": false,
970
975
  "kind": "file",
971
- "sha256": "160c814085dfdbe93e0166f2a773345dd1436f07a63dbf58d447f3f7674dc759"
976
+ "sha256": "7dd3660097c3afeadb58357939b51da518a4da7cf2d7e684c2618b22f6c4a87b"
972
977
  }
973
978
  },
974
- "revision": "b86c3ef157a305468fa37ce18f84cca40dc1b11d",
979
+ "revision": "1eef9e88688b6baa3a085c2d803127c983573b98",
975
980
  "schema_version": 1,
976
- "version": "0.38.1"
981
+ "version": "0.39.0"
977
982
  }
@@ -62,6 +62,8 @@ 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
+ - **Antes de pegar trabalho novo, veja o que o dono já pediu e ninguém assumiu.** Demanda digitada no cockpit fica registrada numa fila local sem validade (`crow-skills demand list --pending`), porque mensagem de broadcast expira e pedido sem resposta não é velho, é pendente. Ao iniciar, e sempre que ficar ocioso, leia a fila: se houver demanda compatível com o seu escopo autorizado, **assuma** (`demand take <id>`), execute, e encerre dizendo o desfecho (`demand done <id> "<o que saiu>"` ou `demand fail <id> "<por quê>"`). Se não puder terminar, **devolva** (`demand release <id>`) em vez de morrer segurando — a fila marca como órfã a demanda presa a uma sessão que sumiu, mas devolver na hora é honesto e evita a espera. A fila é entrega, não memória: ao assumir, registre o pedido na memória do dono, que é onde ele passa a existir de verdade.
66
+
65
67
  - **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.
66
68
  - 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.
67
69
 
@@ -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)