@andespindola/crow-skills 0.8.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,11 +7,13 @@ 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
13
14
  from . import integrations
14
15
  from . import progress
16
+ from . import resources
15
17
  from .agents import AGENTS, destinations, inventory
16
18
  from .source import CrowError, Source, valid_name
17
19
  from .storage import Store, checked
@@ -77,6 +79,23 @@ def parser():
77
79
  child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
78
80
  child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
79
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)
96
+ child = sub.add_parser("resources", parents=[common],
97
+ help="Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura)")
98
+ child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
80
99
  child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
81
100
  child.add_argument("action", choices=("on", "off", "status", "run"))
82
101
  child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
@@ -357,6 +376,146 @@ def events_command(args, home, store):
357
376
  return data
358
377
 
359
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
+
360
519
  def autoupdate_command(args, home, store, interactive):
361
520
  if args.action == "status":
362
521
  return autoupdate.status(store, home)
@@ -460,6 +619,12 @@ def run(args):
460
619
  return mode_command(args, home)
461
620
  if args.command == "events":
462
621
  return events_command(args, home, store)
622
+ if args.command == "broadcast":
623
+ return broadcast_command(args, home)
624
+ if args.command == "resources":
625
+ data = resources.probe(home, processes=not args.no_processes)
626
+ data["text"] = resources.render(data)
627
+ return data
463
628
  if args.command == "autoupdate":
464
629
  return autoupdate_command(args, home, store, interactive)
465
630
  if args.command in ("status", "doctor"):
@@ -586,6 +751,17 @@ def emit(command, data, machine):
586
751
  elif command == "progress":
587
752
  if data is not None:
588
753
  print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
754
+ elif command == "resources":
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"))
589
765
  elif command == "mode":
590
766
  print(data["indicator"] + " — " + data["applies"])
591
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
14
+ from . import autoupdate, broadcast, hook, progress, resources
15
15
  from .source import CrowError
16
16
 
17
17
  SNAPSHOT_SCHEMA = "crow-snapshot/1"
@@ -29,7 +29,7 @@ def ledger_runs(home, cwds):
29
29
  runs.append({"id": run["id"], "title": run["title"], "host": run["host"], "cwd": run["cwd"], "status": run["status"],
30
30
  "session": run["session"].get("id"), "path": run["path"], "updatedAt": run.get("updatedAt"),
31
31
  "done": info["done"], "failed": info["failed"], "total": info["total"], "running": info["running"],
32
- "progress": info["progress"],
32
+ "progress": info["progress"], "resources": run.get("resources"),
33
33
  "agents": [{"owner": progress.owner_label(u["owner"]), "unit": u["id"], "activity": u["title"]}
34
34
  for u in run["units"] if u["status"] == "running"]})
35
35
  return runs
@@ -54,9 +54,58 @@ def snapshot(home, store):
54
54
  except CrowError as exc:
55
55
  update = {"enabled": None, "error": exc.code}
56
56
  mode = hook.read_mode(home)
57
+ try:
58
+ machine = resources_field(home)
59
+ except Exception: # noqa: BLE001 - the snapshot never fails because of the probe
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
57
65
  return {"schema": SNAPSHOT_SCHEMA, "generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
58
66
  "mode": {k: mode[k] for k in ("mode", "updatedAt", "updatedBy", "source")}, "indicator": hook.indicator(mode["mode"]),
59
- "sessions": live, "runs": runs, "autoupdate": update}
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}
97
+
98
+
99
+ def resources_field(home):
100
+ """crow-resources/1 subset for terminals: memory, pressure and the recommendation.
101
+
102
+ Added in 0.9.0 without changing crow-snapshot/1: consumers that do not know the
103
+ field ignore it; the field carries its own schema for future changes.
104
+ """
105
+ data = resources.probe(home, processes=False, use_cache=True)
106
+ return {"schema": data["schema"], "generatedAt": data["generatedAt"], "cached": data["cached"],
107
+ "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"]},
108
+ "recommendation": data["recommendation"], "alert": resources.alert(data), "segment": resources.segment(data)}
60
109
 
61
110
 
62
111
  def render(data, unicode=True):
@@ -75,6 +124,18 @@ def render(data, unicode=True):
75
124
  unicode, counts=False)
76
125
  lines.append(("✓ " if unicode else "ok ") + str(run["done"]) + "/" + str(run["total"]) + " " + run["title"])
77
126
  update = data["autoupdate"]
127
+ machine = data.get("resources")
128
+ if machine and machine.get("alert"):
129
+ rec = machine["recommendation"]
130
+ lines.append("")
131
+ lines.append("memória: " + (machine["segment"] or "") + " · pressão " + machine["pressure"]["level"] + " · " + rec["mode"] +
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)")
78
139
  lines.append("")
79
140
  lines.append("autoupdate: " + ("ligado" if update.get("enabled") else "desligado") +
80
141
  ((" · última execução " + str(update["lastRun"]["startedAt"]) + (" ok" if update["lastRun"]["ok"] else " com falha"))