@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.
@@ -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":
@@ -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"))
@@ -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
- def handle(host, raw, payload, home, env=None):
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
- stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
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
- args = p.parse_args(argv)
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