@andespindola/crow-skills 0.9.0 → 0.10.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.
- package/README.md +3 -3
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/broadcast.py +987 -0
- package/crow_skills/cli.py +166 -0
- package/crow_skills/events.py +41 -2
- package/crow_skills/hook.py +256 -13
- package/crow_skills/integrations.py +58 -23
- package/docs/cli.md +18 -6
- package/package.json +1 -1
- package/release.json +22 -12
- package/skills/crow-behaviour/SKILL.md +1 -0
- package/skills/crow-behaviour/references/broadcast.md +45 -0
- package/skills/crow-behaviour/references/orchestration.md +2 -0
package/crow_skills/cli.py
CHANGED
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
9
|
from . import autoupdate
|
|
10
|
+
from . import broadcast
|
|
10
11
|
from . import events
|
|
11
12
|
from . import guide
|
|
12
13
|
from . import hook
|
|
@@ -78,6 +79,20 @@ def parser():
|
|
|
78
79
|
child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
|
|
79
80
|
child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
|
|
80
81
|
child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
|
|
82
|
+
child = sub.add_parser("broadcast", parents=[common],
|
|
83
|
+
help="crow-broadcast: sessões vivas dos CLIs, arquivos em edição, conflitos e reservas")
|
|
84
|
+
child.add_argument("action", nargs="?", default="status", choices=("status", "list", "claim", "release", "watch"))
|
|
85
|
+
child.add_argument("targets", nargs="*", help="claim: caminhos, globs ou área; release: ids das reservas")
|
|
86
|
+
child.add_argument("--ttl", default="30m", help="claim: validade (ex.: 30m, 2h; máximo 24h)")
|
|
87
|
+
child.add_argument("--note", help="claim: nota curta (o que será feito)")
|
|
88
|
+
child.add_argument("--session", help="claim/release/status: id da sessão dona; padrão: detectada pelo processo pai")
|
|
89
|
+
child.add_argument("--host", help="claim/release/status: CLI da sessão (claude, codex, gemini, agy, devin)")
|
|
90
|
+
child.add_argument("--cwd", help="Diretório de referência; padrão: o atual")
|
|
91
|
+
child.add_argument("--force", action="store_true",
|
|
92
|
+
help="claim: registrar mesmo com reserva conflitante; release: liberar reserva de outra sessão (decisão do usuário)")
|
|
93
|
+
child.add_argument("--all", action="store_true", help="release: todas as reservas desta sessão")
|
|
94
|
+
child.add_argument("--interval", type=float, default=2.0, help=argparse.SUPPRESS)
|
|
95
|
+
child.add_argument("--max-frames", type=int, default=None, help=argparse.SUPPRESS)
|
|
81
96
|
child = sub.add_parser("resources", parents=[common],
|
|
82
97
|
help="Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura)")
|
|
83
98
|
child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
|
|
@@ -361,6 +376,146 @@ def events_command(args, home, store):
|
|
|
361
376
|
return data
|
|
362
377
|
|
|
363
378
|
|
|
379
|
+
def ancestors(limit=16):
|
|
380
|
+
"""Process ids above this CLI (best effort): the agent CLI that runs it is one of them."""
|
|
381
|
+
pids, pid = [], os.getpid()
|
|
382
|
+
for _ in range(limit):
|
|
383
|
+
parent = None
|
|
384
|
+
try:
|
|
385
|
+
with open("/proc/" + str(pid) + "/stat", "rb") as handle:
|
|
386
|
+
parent = int(handle.read().rsplit(b")", 1)[1].split()[1])
|
|
387
|
+
except (OSError, ValueError, IndexError):
|
|
388
|
+
try:
|
|
389
|
+
import subprocess
|
|
390
|
+
done = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True, text=True, timeout=2)
|
|
391
|
+
parent = int(done.stdout.strip() or 0)
|
|
392
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
393
|
+
parent = None
|
|
394
|
+
if not parent or parent <= 1 or parent in pids:
|
|
395
|
+
break
|
|
396
|
+
pids.append(parent)
|
|
397
|
+
pid = parent
|
|
398
|
+
return pids
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def broadcast_owner(args, home, cwd):
|
|
402
|
+
"""The session that runs this command: --session/--host, else the live session whose CLI pid is an ancestor."""
|
|
403
|
+
live = broadcast.live_sessions(home)
|
|
404
|
+
host = getattr(args, "host", None)
|
|
405
|
+
if host and host not in hook.HOSTS:
|
|
406
|
+
raise CrowError("usage", "--host deve ser um de: " + ", ".join(hook.HOSTS))
|
|
407
|
+
if getattr(args, "session", None):
|
|
408
|
+
session = hook.token(args.session, 128)
|
|
409
|
+
matches = [s for s in live if s.get("session") == session and (not host or s.get("host") == host)]
|
|
410
|
+
if matches:
|
|
411
|
+
s = matches[0]
|
|
412
|
+
return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "session"}
|
|
413
|
+
if not host:
|
|
414
|
+
raise CrowError("not_found", "Sessão " + str(session) + " não está viva no broadcast; informe também --host.")
|
|
415
|
+
return {"key": broadcast.session_key(host, session, None), "host": host, "session": session, "detected": "session"}
|
|
416
|
+
chain = ancestors()
|
|
417
|
+
for s in live:
|
|
418
|
+
pid = broadcast.presence_of(s).get("pid")
|
|
419
|
+
if pid and pid in chain and (not host or s.get("host") == host):
|
|
420
|
+
return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "pid"}
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def broadcast_text(view, full=False):
|
|
425
|
+
me = view.get("me") or {}
|
|
426
|
+
repo = me.get("repo") or {}
|
|
427
|
+
lines = ["crow-broadcast · " + str(len(view["sessions"])) + " outra(s) sessão(ões) viva(s) · " + str(len(view["claims"])) +
|
|
428
|
+
" reserva(s)"]
|
|
429
|
+
if me:
|
|
430
|
+
lines.append("aqui: " + (repo.get("remote") or repo.get("root") or me.get("cwd") or "?") +
|
|
431
|
+
(" · branch " + str(me.get("branch")) if me.get("branch") else "") +
|
|
432
|
+
(" · sessão " + str(view.get("owner", {}).get("host")) if view.get("owner") else ""))
|
|
433
|
+
shown = view["sessions"] if full else broadcast.relevant(view)
|
|
434
|
+
now = broadcast.parse_time(view["generatedAt"]) or 0
|
|
435
|
+
for s in shown:
|
|
436
|
+
lines += broadcast.session_lines(s, view["_home"], now)
|
|
437
|
+
if not full and len(shown) < len(view["sessions"]):
|
|
438
|
+
lines.append("(+" + str(len(view["sessions"]) - len(shown)) + " em outros repositórios; crow-skills broadcast list)")
|
|
439
|
+
live = {s.get("key"): s for s in view["_live"]}
|
|
440
|
+
for claim in view["claims"]:
|
|
441
|
+
lines.append(broadcast.claim_line(claim, live, now))
|
|
442
|
+
if not shown and not view["claims"]:
|
|
443
|
+
lines.append("Nenhuma outra sessão viva neste repositório." if not full else "Nenhuma outra sessão viva.")
|
|
444
|
+
return "\n".join(lines)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def broadcast_view(args, home, owner=None):
|
|
448
|
+
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
449
|
+
owner = owner if owner is not None else broadcast_owner(args, home, cwd)
|
|
450
|
+
me_key = owner["key"] if owner else None
|
|
451
|
+
view = broadcast.overview(home, me_key, cwd=cwd)
|
|
452
|
+
if view["me"] is not None and view["me"].get("cwd") is None:
|
|
453
|
+
view["me"]["cwd"] = cwd
|
|
454
|
+
view["owner"] = owner
|
|
455
|
+
return view
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def public(view):
|
|
459
|
+
return {k: v for k, v in view.items() if not k.startswith("_")}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def broadcast_command(args, home):
|
|
463
|
+
action = args.action
|
|
464
|
+
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
465
|
+
if action in ("status", "list"):
|
|
466
|
+
view = broadcast_view(args, home)
|
|
467
|
+
view["_home"] = home
|
|
468
|
+
text = broadcast_text(view, full=action == "list")
|
|
469
|
+
data = public(view)
|
|
470
|
+
if action == "status":
|
|
471
|
+
data["relevant"] = [s["key"] for s in broadcast.relevant(view)]
|
|
472
|
+
data["text"] = text
|
|
473
|
+
return data
|
|
474
|
+
if action == "watch":
|
|
475
|
+
if getattr(args, "json", False):
|
|
476
|
+
raise CrowError("usage", "watch redesenha em texto; para JSON use status --json em polling.")
|
|
477
|
+
frames = 0
|
|
478
|
+
try:
|
|
479
|
+
while True:
|
|
480
|
+
view = broadcast_view(args, home)
|
|
481
|
+
view["_home"] = home
|
|
482
|
+
sys.stdout.write("\x1b[H\x1b[2J" + broadcast_text(view) + "\n")
|
|
483
|
+
sys.stdout.flush()
|
|
484
|
+
frames += 1
|
|
485
|
+
if args.max_frames is not None and frames >= args.max_frames:
|
|
486
|
+
return None
|
|
487
|
+
import time as _time
|
|
488
|
+
_time.sleep(max(0.2, args.interval))
|
|
489
|
+
except KeyboardInterrupt:
|
|
490
|
+
return None
|
|
491
|
+
owner = broadcast_owner(args, home, cwd)
|
|
492
|
+
if action == "claim":
|
|
493
|
+
if not args.targets:
|
|
494
|
+
raise CrowError("usage", "Informe o que reservar: caminhos, globs ou área (ex.: crow-skills broadcast claim src/api --ttl 30m).")
|
|
495
|
+
try:
|
|
496
|
+
ttl = broadcast.parse_ttl(args.ttl)
|
|
497
|
+
except ValueError:
|
|
498
|
+
raise CrowError("usage", "--ttl inválido: use 30m, 2h, 90s (máximo 24h).") from None
|
|
499
|
+
try:
|
|
500
|
+
claim, conflicts = broadcast.add_claim(home, args.targets, ttl, owner=owner, cwd=cwd, note=args.note, force=args.force)
|
|
501
|
+
except ValueError:
|
|
502
|
+
raise CrowError("usage", "Nenhum alvo válido para reservar.") from None
|
|
503
|
+
if claim is None:
|
|
504
|
+
holders = "; ".join(broadcast.claim_line(c, {}, broadcast.time.time()).lstrip("- ") for c in conflicts[:3])
|
|
505
|
+
raise CrowError("claim_conflict", "Área já reservada por outra sessão viva: " + holders +
|
|
506
|
+
". Coordene com ela ou use --force se o usuário decidir seguir.")
|
|
507
|
+
return {"claim": claim, "owner": owner, "conflicts": conflicts,
|
|
508
|
+
"note": None if owner else "Sessão dona não detectada; a reserva expira só pelo TTL. Use --session/--host."}
|
|
509
|
+
if not owner and (args.all or not args.targets):
|
|
510
|
+
raise CrowError("usage", "Sessão dona não detectada: informe --session/--host (ou os ids das reservas).")
|
|
511
|
+
removed, refused = broadcast.release_claims(home, ids=set(args.targets), owner_key=owner["key"] if owner else None,
|
|
512
|
+
everything=args.all or not args.targets, force=args.force)
|
|
513
|
+
if refused and not removed:
|
|
514
|
+
raise CrowError("claim_owner", "Reserva(s) de outra sessão: " + ", ".join(refused) +
|
|
515
|
+
". Só a sessão dona libera; --force só por decisão do usuário.")
|
|
516
|
+
return {"released": [c["id"] for c in removed], "refused": refused, "owner": owner}
|
|
517
|
+
|
|
518
|
+
|
|
364
519
|
def autoupdate_command(args, home, store, interactive):
|
|
365
520
|
if args.action == "status":
|
|
366
521
|
return autoupdate.status(store, home)
|
|
@@ -464,6 +619,8 @@ def run(args):
|
|
|
464
619
|
return mode_command(args, home)
|
|
465
620
|
if args.command == "events":
|
|
466
621
|
return events_command(args, home, store)
|
|
622
|
+
if args.command == "broadcast":
|
|
623
|
+
return broadcast_command(args, home)
|
|
467
624
|
if args.command == "resources":
|
|
468
625
|
data = resources.probe(home, processes=not args.no_processes)
|
|
469
626
|
data["text"] = resources.render(data)
|
|
@@ -596,6 +753,15 @@ def emit(command, data, machine):
|
|
|
596
753
|
print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
|
|
597
754
|
elif command == "resources":
|
|
598
755
|
print(data["text"])
|
|
756
|
+
elif command == "broadcast":
|
|
757
|
+
if data is not None:
|
|
758
|
+
if "text" in data:
|
|
759
|
+
print(data["text"])
|
|
760
|
+
elif "claim" in data:
|
|
761
|
+
print("reserva " + data["claim"]["id"] + ": " + ", ".join(data["claim"]["patterns"]) + " até " +
|
|
762
|
+
data["claim"]["expiresAt"] + ("" if data["owner"] else " (" + data["note"] + ")"))
|
|
763
|
+
else:
|
|
764
|
+
print("liberadas: " + (", ".join(data["released"]) or "nenhuma"))
|
|
599
765
|
elif command == "mode":
|
|
600
766
|
print(data["indicator"] + " — " + data["applies"])
|
|
601
767
|
elif command == "events":
|
package/crow_skills/events.py
CHANGED
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
import sys
|
|
12
12
|
import time
|
|
13
13
|
|
|
14
|
-
from . import autoupdate, hook, progress, resources
|
|
14
|
+
from . import autoupdate, broadcast, hook, progress, resources
|
|
15
15
|
from .source import CrowError
|
|
16
16
|
|
|
17
17
|
SNAPSHOT_SCHEMA = "crow-snapshot/1"
|
|
@@ -58,9 +58,42 @@ def snapshot(home, store):
|
|
|
58
58
|
machine = resources_field(home)
|
|
59
59
|
except Exception: # noqa: BLE001 - the snapshot never fails because of the probe
|
|
60
60
|
machine = None
|
|
61
|
+
try:
|
|
62
|
+
presence = broadcast_field(home)
|
|
63
|
+
except Exception: # noqa: BLE001 - the snapshot never fails because of the broadcast
|
|
64
|
+
presence = None
|
|
61
65
|
return {"schema": SNAPSHOT_SCHEMA, "generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
62
66
|
"mode": {k: mode[k] for k in ("mode", "updatedAt", "updatedBy", "source")}, "indicator": hook.indicator(mode["mode"]),
|
|
63
|
-
"sessions": live, "runs": runs, "autoupdate": update, "resources": machine}
|
|
67
|
+
"sessions": live, "runs": runs, "autoupdate": update, "resources": machine, "broadcast": presence}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def broadcast_field(home):
|
|
71
|
+
"""crow-broadcast/1 for terminals: live sessions grouped by repository, recent files, claims, conflicts.
|
|
72
|
+
|
|
73
|
+
Added in 0.10.0 without changing crow-snapshot/1 (consumers ignore unknown fields); the
|
|
74
|
+
field carries its own schema. ``conflicts`` lists files touched by two or more live
|
|
75
|
+
sessions within the conflict window and clones used by two or more live sessions.
|
|
76
|
+
"""
|
|
77
|
+
view = broadcast.overview(home)
|
|
78
|
+
window = view["config"]["conflict_window_minutes"] * 60
|
|
79
|
+
now = broadcast.parse_time(view["generatedAt"]) or 0
|
|
80
|
+
repos, files, clones = {}, {}, {}
|
|
81
|
+
for s in view["sessions"]:
|
|
82
|
+
repo = s.get("repo") or {}
|
|
83
|
+
if repo.get("id"):
|
|
84
|
+
repos.setdefault(repo["id"], {"id": repo["id"], "remote": repo.get("remote"), "sessions": []})["sessions"].append(s["key"])
|
|
85
|
+
clones.setdefault(repo.get("root"), []).append(s["key"])
|
|
86
|
+
for f in s["files"]:
|
|
87
|
+
if now - (broadcast.parse_time(f.get("ts")) or 0) <= window:
|
|
88
|
+
label = (f.get("repo") + ":" + f["rel"]) if f.get("repo") and f.get("rel") else f["path"]
|
|
89
|
+
keys = files.setdefault(label, [])
|
|
90
|
+
if s["key"] not in keys:
|
|
91
|
+
keys.append(s["key"])
|
|
92
|
+
conflicts = [{"kind": "file", "file": k, "sessions": v} for k, v in files.items() if len(v) > 1] + \
|
|
93
|
+
[{"kind": "clone", "root": k, "sessions": v} for k, v in clones.items() if k and len(v) > 1]
|
|
94
|
+
return {"schema": view["schema"], "generatedAt": view["generatedAt"], "config": view["config"],
|
|
95
|
+
"sessions": [{k: v for k, v in s.items() if k != "relation"} for s in view["sessions"]],
|
|
96
|
+
"repos": list(repos.values()), "claims": view["claims"], "conflicts": conflicts}
|
|
64
97
|
|
|
65
98
|
|
|
66
99
|
def resources_field(home):
|
|
@@ -97,6 +130,12 @@ def render(data, unicode=True):
|
|
|
97
130
|
lines.append("")
|
|
98
131
|
lines.append("memória: " + (machine["segment"] or "") + " · pressão " + machine["pressure"]["level"] + " · " + rec["mode"] +
|
|
99
132
|
" (até " + str(rec["maxParallelAgents"]) + " agente(s), " + str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s))")
|
|
133
|
+
shared = data.get("broadcast")
|
|
134
|
+
if shared and (shared.get("conflicts") or shared.get("claims")):
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append("broadcast: " + str(len([c for c in shared["conflicts"] if c["kind"] == "file"])) + " arquivo(s) em mais de uma sessão · " +
|
|
137
|
+
str(len([c for c in shared["conflicts"] if c["kind"] == "clone"])) + " clone(s) compartilhado(s) · " +
|
|
138
|
+
str(len(shared["claims"])) + " reserva(s) (crow-skills broadcast list)")
|
|
100
139
|
lines.append("")
|
|
101
140
|
lines.append("autoupdate: " + ("ligado" if update.get("enabled") else "desligado") +
|
|
102
141
|
((" · última execução " + str(update["lastRun"]["startedAt"]) + (" ok" if update["lastRun"]["ok"] else " com falha"))
|
package/crow_skills/hook.py
CHANGED
|
@@ -18,6 +18,15 @@ On a prompt event the hook also appends one memory line to the context, only
|
|
|
18
18
|
when the machine is under pressure or the recommended parallelism is not
|
|
19
19
|
"parallel" (crow_resources.py, copied beside this file; cached for a few
|
|
20
20
|
seconds, so most prompts cost one small file read).
|
|
21
|
+
|
|
22
|
+
crow-broadcast (crow_broadcast.py, copied beside this file): every event also
|
|
23
|
+
keeps the session's presence (git repository, CLI pid, PATHS of files its edit
|
|
24
|
+
tools target) in the snapshot; a prompt event appends a short digest of other
|
|
25
|
+
live sessions in the same repository, only when there are any; ``--check``
|
|
26
|
+
(a synchronous PreToolUse entry for edit and shell tools) warns about another
|
|
27
|
+
live session on the same file or clone without writing anything.
|
|
28
|
+
|
|
29
|
+
crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
|
|
21
30
|
"""
|
|
22
31
|
from datetime import datetime, timezone
|
|
23
32
|
import hashlib
|
|
@@ -193,6 +202,34 @@ def resources_module():
|
|
|
193
202
|
return _RESOURCES or None
|
|
194
203
|
|
|
195
204
|
|
|
205
|
+
_BROADCAST = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def broadcast_module():
|
|
209
|
+
"""crow-broadcast: the package module, or crow_broadcast.py beside this file; None when missing."""
|
|
210
|
+
global _BROADCAST
|
|
211
|
+
if _BROADCAST is None:
|
|
212
|
+
_BROADCAST = False
|
|
213
|
+
try:
|
|
214
|
+
if __package__:
|
|
215
|
+
from . import broadcast as module # type: ignore[attr-defined]
|
|
216
|
+
_BROADCAST = module
|
|
217
|
+
else:
|
|
218
|
+
import importlib.util
|
|
219
|
+
here = Path(os.path.abspath(__file__)).parent
|
|
220
|
+
for name in ("crow_broadcast.py", "broadcast.py"):
|
|
221
|
+
path = here / name
|
|
222
|
+
if path.is_file() and not path.is_symlink():
|
|
223
|
+
spec = importlib.util.spec_from_file_location("crow_broadcast", path)
|
|
224
|
+
module = importlib.util.module_from_spec(spec)
|
|
225
|
+
spec.loader.exec_module(module)
|
|
226
|
+
_BROADCAST = module
|
|
227
|
+
break
|
|
228
|
+
except Exception: # noqa: BLE001 - a broken module never breaks the hook
|
|
229
|
+
_BROADCAST = False
|
|
230
|
+
return _BROADCAST or None
|
|
231
|
+
|
|
232
|
+
|
|
196
233
|
def resource_hint(home):
|
|
197
234
|
"""The one-line memory hint, or None (no pressure, no probe, or any failure)."""
|
|
198
235
|
module = resources_module()
|
|
@@ -277,8 +314,11 @@ def git_branch(cwd):
|
|
|
277
314
|
return None
|
|
278
315
|
|
|
279
316
|
|
|
280
|
-
def update_session(home, event, extra=None, fallback_cwd=None):
|
|
281
|
-
"""Fold one event into the per-session snapshot (state, counters, last action).
|
|
317
|
+
def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
|
|
318
|
+
"""Fold one event into the per-session snapshot (state, counters, last action).
|
|
319
|
+
|
|
320
|
+
presence: (broadcast module, touched paths, parent pid) to fold the crow-broadcast
|
|
321
|
+
presence in the same locked write; a failure there never loses the event."""
|
|
282
322
|
directory = no_follow_dir(events_dir(home) / "sessions")
|
|
283
323
|
key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
|
|
284
324
|
path = directory / (key + ".json")
|
|
@@ -332,6 +372,12 @@ def update_session(home, event, extra=None, fallback_cwd=None):
|
|
|
332
372
|
snap["updatedAt"] = event.get("ts") or now_iso()
|
|
333
373
|
if extra:
|
|
334
374
|
snap.update(extra)
|
|
375
|
+
if presence:
|
|
376
|
+
module, touched, ppid = presence
|
|
377
|
+
try:
|
|
378
|
+
module.fold(snap, event, touched, ppid=ppid)
|
|
379
|
+
except Exception: # noqa: BLE001 - presence is best effort
|
|
380
|
+
pass
|
|
335
381
|
write_json(path, snap)
|
|
336
382
|
return snap
|
|
337
383
|
finally:
|
|
@@ -352,13 +398,13 @@ def prune_sessions(home, now=None):
|
|
|
352
398
|
pass
|
|
353
399
|
|
|
354
400
|
|
|
355
|
-
def record(home, fields, snapshot=False, fallback_cwd=None):
|
|
401
|
+
def record(home, fields, snapshot=False, fallback_cwd=None, presence=None):
|
|
356
402
|
"""Stamp and append an event; optionally fold it into the session snapshot."""
|
|
357
403
|
event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
|
|
358
404
|
event.update({k: v for k, v in fields.items() if v is not None})
|
|
359
405
|
append_event(home, event)
|
|
360
406
|
if snapshot:
|
|
361
|
-
return event, update_session(home, event, fallback_cwd=fallback_cwd)
|
|
407
|
+
return event, update_session(home, event, fallback_cwd=fallback_cwd, presence=presence)
|
|
362
408
|
return event, None
|
|
363
409
|
|
|
364
410
|
|
|
@@ -380,7 +426,7 @@ def sessions(home, live_only=True, now=None):
|
|
|
380
426
|
continue
|
|
381
427
|
snap["ageSeconds"] = int(age)
|
|
382
428
|
result.append(snap)
|
|
383
|
-
return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
|
|
429
|
+
return sorted(result, key=lambda s: str(s.get("updatedAt") or ""), reverse=True)
|
|
384
430
|
|
|
385
431
|
|
|
386
432
|
def tail(home, limit=50):
|
|
@@ -436,14 +482,26 @@ def foreign(host, payload, env, home):
|
|
|
436
482
|
return False
|
|
437
483
|
|
|
438
484
|
|
|
485
|
+
def tool_call(payload):
|
|
486
|
+
"""Antigravity sends the tool as {"toolCall": {"name", "args"}}."""
|
|
487
|
+
call = payload.get("toolCall")
|
|
488
|
+
return call if isinstance(call, dict) else {}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def workspace(payload):
|
|
492
|
+
"""Antigravity runs hooks from the hooks.json directory and sends the workspace as workspacePaths."""
|
|
493
|
+
paths = payload.get("workspacePaths")
|
|
494
|
+
return paths[0] if isinstance(paths, list) and paths and isinstance(paths[0], str) else None
|
|
495
|
+
|
|
496
|
+
|
|
439
497
|
def normalise(host, raw, payload):
|
|
440
498
|
kind = EVENTS.get(raw, "other")
|
|
441
|
-
tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
|
|
499
|
+
tool = token(first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name"), 64)
|
|
442
500
|
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
443
501
|
event = {
|
|
444
502
|
"host": host, "event": kind, "raw": token(raw, 40),
|
|
445
503
|
"session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
|
|
446
|
-
"cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
|
|
504
|
+
"cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot") or workspace(payload)),
|
|
447
505
|
"model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
|
|
448
506
|
else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
|
|
449
507
|
"permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
|
|
@@ -495,11 +553,107 @@ def output_for(host, raw, context=None, system=None, block=None):
|
|
|
495
553
|
return out or None
|
|
496
554
|
|
|
497
555
|
|
|
498
|
-
|
|
556
|
+
TOOL_START_EVENTS = ("PreToolUse", "BeforeTool")
|
|
557
|
+
SHELL_NAMES = frozenset(("Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
|
|
558
|
+
# Edit and shell tool names of every host (crow_broadcast.EDIT_TOOLS and SHELL_TOOLS; a test keeps
|
|
559
|
+
# them equal): only these tool events load the broadcast module.
|
|
560
|
+
BROADCAST_TOOLS = frozenset((
|
|
561
|
+
"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "write_file", "replace", "code_action", "file_change",
|
|
562
|
+
"propose_code", "edit_notebook", "write_blob", "write_to_file", "replace_file_content", "multi_replace_file_content",
|
|
563
|
+
"edit", "write", "notebook_edit",
|
|
564
|
+
"Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
|
|
565
|
+
# Hosts whose tool-start hook is synchronous in our install (its output is read): the warning goes
|
|
566
|
+
# out from the same call. Claude Code and Codex record asynchronously and get a separate --check entry.
|
|
567
|
+
SYNC_TOOL_HOSTS = ("gemini", "agy", "devin")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def tool_input_of(payload):
|
|
571
|
+
args = tool_call(payload).get("args")
|
|
572
|
+
if isinstance(args, dict):
|
|
573
|
+
return args
|
|
574
|
+
for key in ("tool_input", "toolInput", "input", "tool_args", "arguments", "args"):
|
|
575
|
+
value = payload.get(key)
|
|
576
|
+
if isinstance(value, dict):
|
|
577
|
+
return value
|
|
578
|
+
if isinstance(value, str) and key in ("arguments", "tool_input") and value[:1] == "{" and len(value) < MAX_STDIN:
|
|
579
|
+
try:
|
|
580
|
+
parsed = json.loads(value)
|
|
581
|
+
except ValueError:
|
|
582
|
+
continue
|
|
583
|
+
if isinstance(parsed, dict):
|
|
584
|
+
return parsed
|
|
585
|
+
return {}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# Where a warning can reach the model before/after a tool, per host (checked on the installed
|
|
589
|
+
# versions, see docs/broadcast.md): Claude Code and Codex read additionalContext on PreToolUse;
|
|
590
|
+
# Gemini CLI and Devin only on AfterTool/PostToolUse (their pre-tool hook can only block);
|
|
591
|
+
# Antigravity's PreToolUse reads only a decision (the next PreInvocation digest carries the warning).
|
|
592
|
+
PRE_CONTEXT_HOSTS = ("claude", "codex")
|
|
593
|
+
POST_CONTEXT_HOSTS = ("gemini", "devin")
|
|
594
|
+
POST_TOOL_EVENTS = ("PostToolUse", "AfterTool")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def tool_output(host, raw, text, block=False):
|
|
598
|
+
"""Tool-hook warning (model-visible where the host reads it) or, with block, a refusal."""
|
|
599
|
+
if block:
|
|
600
|
+
if host == "claude":
|
|
601
|
+
return {"hookSpecificOutput": {"hookEventName": raw, "permissionDecision": "deny", "permissionDecisionReason": text,
|
|
602
|
+
"additionalContext": text}}
|
|
603
|
+
if host in ("agy", "gemini"):
|
|
604
|
+
return {"decision": "deny", "reason": text}
|
|
605
|
+
return {"decision": "block", "reason": text}
|
|
606
|
+
if raw in TOOL_START_EVENTS and host not in PRE_CONTEXT_HOSTS:
|
|
607
|
+
return None
|
|
608
|
+
return {"hookSpecificOutput": {"hookEventName": raw, "additionalContext": text}, "systemMessage": text.split("\n", 1)[0]}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def broadcast_check(host, raw, home, module, me, touched, tree):
|
|
612
|
+
"""Run the conflict check; returns the host output or None. Never raises."""
|
|
613
|
+
try:
|
|
614
|
+
cfg = module.config(home)
|
|
615
|
+
pre = raw in TOOL_START_EVENTS
|
|
616
|
+
block = pre and bool(cfg["block_on_conflict"])
|
|
617
|
+
if pre and not block and host not in PRE_CONTEXT_HOSTS:
|
|
618
|
+
return None # nothing this host would show before the tool; the post-tool or prompt hook warns
|
|
619
|
+
text, conflict = module.check(home, (me or {}).get("key"), me, touched, tree, cfg=cfg)
|
|
620
|
+
if not text:
|
|
621
|
+
return None
|
|
622
|
+
return tool_output(host, raw, text, block=block and conflict)
|
|
623
|
+
except Exception: # noqa: BLE001
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def check_only(host, raw, payload, home):
|
|
628
|
+
"""--check: read-only conflict check before an edit/shell tool (no event, no snapshot write)."""
|
|
629
|
+
tool = first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name")
|
|
630
|
+
shell_input = tool_input_of(payload)
|
|
631
|
+
command = shell_input.get("command") or shell_input.get("cmd") or shell_input.get("CommandLine") or shell_input.get("commandLine")
|
|
632
|
+
if tool in SHELL_NAMES and "git" not in json.dumps(command or ""):
|
|
633
|
+
return None # a shell command without git cannot switch the clone's branch: nothing to load
|
|
634
|
+
module = broadcast_module()
|
|
635
|
+
if module is None or raw not in TOOL_START_EVENTS:
|
|
636
|
+
return None
|
|
637
|
+
event = normalise(host, raw, payload)
|
|
638
|
+
cwd = event.get("cwd") or process_cwd()
|
|
639
|
+
tool_input = tool_input_of(payload)
|
|
640
|
+
touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
641
|
+
tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
642
|
+
if not touched and not tree:
|
|
643
|
+
return None
|
|
644
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
645
|
+
me = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
|
|
646
|
+
me = dict(me, key=key, cwd=me.get("cwd") or cwd)
|
|
647
|
+
return broadcast_check(host, raw, home, module, me, touched, tree)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def handle(host, raw, payload, home, env=None, check=False):
|
|
499
651
|
"""Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
|
|
500
652
|
env = os.environ if env is None else env
|
|
501
653
|
if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
|
|
502
654
|
return None
|
|
655
|
+
if check:
|
|
656
|
+
return check_only(host, raw, payload, home)
|
|
503
657
|
event = normalise(host, raw, payload)
|
|
504
658
|
current = read_mode(home)
|
|
505
659
|
if raw in PROMPT_EVENTS:
|
|
@@ -512,7 +666,49 @@ def handle(host, raw, payload, home, env=None):
|
|
|
512
666
|
current = write_mode(home, wanted, host)
|
|
513
667
|
record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
|
|
514
668
|
return output_for(host, raw, block=indicator(current["mode"]) + " (crow-skills; vale para todos os CLIs a partir do próximo prompt)")
|
|
515
|
-
|
|
669
|
+
# The broadcast module is loaded only where it has work: prompts and session start (repo,
|
|
670
|
+
# pid, digest) and tool events of edit/shell tools (paths, checks). Other events keep the
|
|
671
|
+
# 0.9.0 cost; the snapshot's updatedAt is the heartbeat.
|
|
672
|
+
wanted = raw in PROMPT_EVENTS or raw == "SessionStart" or \
|
|
673
|
+
(event["event"] in ("tool_start", "tool_end") and event.get("tool") in BROADCAST_TOOLS)
|
|
674
|
+
module, presence, touched, tree, checked = broadcast_module() if wanted else None, None, [], [], []
|
|
675
|
+
if module is not None:
|
|
676
|
+
try:
|
|
677
|
+
cfg = module.config(home)
|
|
678
|
+
if cfg["enabled"]:
|
|
679
|
+
if event["event"] == "tool_start":
|
|
680
|
+
tool_input = tool_input_of(payload)
|
|
681
|
+
cwd = event.get("cwd") or process_cwd()
|
|
682
|
+
touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
683
|
+
checked = list(touched)
|
|
684
|
+
tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
685
|
+
if touched and cfg["block_on_conflict"]:
|
|
686
|
+
# An edit the conflict check is about to refuse is not published as this
|
|
687
|
+
# session's: otherwise the session that edited first would be locked out too.
|
|
688
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
689
|
+
mine = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
|
|
690
|
+
mine = dict(mine, key=key, cwd=mine.get("cwd") or cwd)
|
|
691
|
+
if module.check(home, key, mine, touched, (), cfg=cfg)[1]:
|
|
692
|
+
touched = []
|
|
693
|
+
presence = (module, touched, os.getppid())
|
|
694
|
+
else:
|
|
695
|
+
module = None
|
|
696
|
+
except Exception: # noqa: BLE001 - the broadcast never breaks the event
|
|
697
|
+
module, presence = None, None
|
|
698
|
+
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
|
|
699
|
+
if module is not None and host in SYNC_TOOL_HOSTS:
|
|
700
|
+
if raw in TOOL_START_EVENTS and (touched or tree or checked):
|
|
701
|
+
return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
|
|
702
|
+
if raw in POST_TOOL_EVENTS and host in POST_CONTEXT_HOSTS:
|
|
703
|
+
try:
|
|
704
|
+
tool_input = tool_input_of(payload)
|
|
705
|
+
cwd = event.get("cwd") or process_cwd()
|
|
706
|
+
after = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
707
|
+
tree_after = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
708
|
+
except Exception: # noqa: BLE001
|
|
709
|
+
after, tree_after = [], []
|
|
710
|
+
if after or tree_after:
|
|
711
|
+
return broadcast_check(host, raw, home, module, snap, after, tree_after)
|
|
516
712
|
if raw not in PROMPT_EVENTS and raw != "SessionStart":
|
|
517
713
|
return None
|
|
518
714
|
system = None
|
|
@@ -528,6 +724,13 @@ def handle(host, raw, payload, home, env=None):
|
|
|
528
724
|
memory = resource_hint(home)
|
|
529
725
|
if memory:
|
|
530
726
|
context += "\n" + memory
|
|
727
|
+
if module is not None and snap is not None:
|
|
728
|
+
try:
|
|
729
|
+
others = module.digest(home, snap.get("key"), me=snap)
|
|
730
|
+
except Exception: # noqa: BLE001
|
|
731
|
+
others = None
|
|
732
|
+
if others:
|
|
733
|
+
context += "\n" + others
|
|
531
734
|
return output_for(host, raw, context=context, system=system)
|
|
532
735
|
|
|
533
736
|
|
|
@@ -571,7 +774,36 @@ def update_metrics(home, host, payload):
|
|
|
571
774
|
|
|
572
775
|
# --- entry point --------------------------------------------------------------------------
|
|
573
776
|
|
|
777
|
+
def fast_hook_args(argv):
|
|
778
|
+
"""The hook's own argv parsed without argparse (~5 ms per call); None when it is anything else."""
|
|
779
|
+
if not argv or argv[0] != "hook":
|
|
780
|
+
return None
|
|
781
|
+
values, flags, i = {}, set(), 1
|
|
782
|
+
while i < len(argv):
|
|
783
|
+
name = argv[i]
|
|
784
|
+
if name in ("--host", "--event", "--home") and i + 1 < len(argv):
|
|
785
|
+
values[name[2:]] = argv[i + 1]
|
|
786
|
+
i += 2
|
|
787
|
+
elif name == "--check":
|
|
788
|
+
flags.add("check")
|
|
789
|
+
i += 1
|
|
790
|
+
else:
|
|
791
|
+
return None
|
|
792
|
+
if "host" not in values or "event" not in values:
|
|
793
|
+
return None
|
|
794
|
+
import types
|
|
795
|
+
return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
|
|
796
|
+
check="check" in flags)
|
|
797
|
+
|
|
798
|
+
|
|
574
799
|
def main(argv=None):
|
|
800
|
+
args = fast_hook_args(list(sys.argv[1:] if argv is None else argv))
|
|
801
|
+
if args is None:
|
|
802
|
+
args = parse_args(argv)
|
|
803
|
+
return run_main(args)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def parse_args(argv):
|
|
575
807
|
import argparse
|
|
576
808
|
p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
|
|
577
809
|
sub = p.add_subparsers(dest="command", required=True)
|
|
@@ -579,12 +811,16 @@ def main(argv=None):
|
|
|
579
811
|
hook.add_argument("--host", required=True)
|
|
580
812
|
hook.add_argument("--event", required=True)
|
|
581
813
|
hook.add_argument("--home")
|
|
814
|
+
hook.add_argument("--check", action="store_true")
|
|
582
815
|
mode = sub.add_parser("mode")
|
|
583
816
|
mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
|
|
584
817
|
mode.add_argument("--home")
|
|
585
818
|
mode.add_argument("--by")
|
|
586
819
|
mode.add_argument("--json", action="store_true")
|
|
587
|
-
|
|
820
|
+
return p.parse_args(argv)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def run_main(args):
|
|
588
824
|
home = Path(args.home).expanduser() if args.home else Path.home()
|
|
589
825
|
if args.command == "mode":
|
|
590
826
|
current = read_mode(home)
|
|
@@ -602,11 +838,18 @@ def main(argv=None):
|
|
|
602
838
|
try:
|
|
603
839
|
raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
|
|
604
840
|
payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
|
|
605
|
-
result = handle(args.host, args.event, payload, home)
|
|
606
|
-
if args.event == "SessionStart":
|
|
607
|
-
prune_sessions(home)
|
|
841
|
+
result = handle(args.host, args.event, payload, home, check=args.check)
|
|
608
842
|
if result:
|
|
609
843
|
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
844
|
+
sys.stdout.flush()
|
|
845
|
+
if args.event == "SessionStart" and not args.check:
|
|
846
|
+
prune_sessions(home)
|
|
847
|
+
module = broadcast_module()
|
|
848
|
+
if module is not None:
|
|
849
|
+
try:
|
|
850
|
+
module.prune_claims(home)
|
|
851
|
+
except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
|
|
852
|
+
pass
|
|
610
853
|
except Exception: # noqa: BLE001 - a hook must never break the agent
|
|
611
854
|
return 0
|
|
612
855
|
return 0
|