@andespindola/crow-skills 0.9.0 → 0.10.1

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,9 +79,25 @@ 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)")
99
+ child.add_argument("--exec-prefix", action="store_true",
100
+ help="Só imprimir o prefixo que roda um job pesado em prioridade baixa neste sistema (taskpolicy/nice/ionice/start /low)")
84
101
  child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
85
102
  child.add_argument("action", choices=("on", "off", "status", "run"))
86
103
  child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
@@ -361,6 +378,146 @@ def events_command(args, home, store):
361
378
  return data
362
379
 
363
380
 
381
+ def ancestors(limit=16):
382
+ """Process ids above this CLI (best effort): the agent CLI that runs it is one of them."""
383
+ pids, pid = [], os.getpid()
384
+ for _ in range(limit):
385
+ parent = None
386
+ try:
387
+ with open("/proc/" + str(pid) + "/stat", "rb") as handle:
388
+ parent = int(handle.read().rsplit(b")", 1)[1].split()[1])
389
+ except (OSError, ValueError, IndexError):
390
+ try:
391
+ import subprocess
392
+ done = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True, text=True, timeout=2)
393
+ parent = int(done.stdout.strip() or 0)
394
+ except (OSError, ValueError, subprocess.SubprocessError):
395
+ parent = None
396
+ if not parent or parent <= 1 or parent in pids:
397
+ break
398
+ pids.append(parent)
399
+ pid = parent
400
+ return pids
401
+
402
+
403
+ def broadcast_owner(args, home, cwd):
404
+ """The session that runs this command: --session/--host, else the live session whose CLI pid is an ancestor."""
405
+ live = broadcast.live_sessions(home)
406
+ host = getattr(args, "host", None)
407
+ if host and host not in hook.HOSTS:
408
+ raise CrowError("usage", "--host deve ser um de: " + ", ".join(hook.HOSTS))
409
+ if getattr(args, "session", None):
410
+ session = hook.token(args.session, 128)
411
+ matches = [s for s in live if s.get("session") == session and (not host or s.get("host") == host)]
412
+ if matches:
413
+ s = matches[0]
414
+ return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "session"}
415
+ if not host:
416
+ raise CrowError("not_found", "Sessão " + str(session) + " não está viva no broadcast; informe também --host.")
417
+ return {"key": broadcast.session_key(host, session, None), "host": host, "session": session, "detected": "session"}
418
+ chain = ancestors()
419
+ for s in live:
420
+ pid = broadcast.presence_of(s).get("pid")
421
+ if pid and pid in chain and (not host or s.get("host") == host):
422
+ return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "pid"}
423
+ return None
424
+
425
+
426
+ def broadcast_text(view, full=False):
427
+ me = view.get("me") or {}
428
+ repo = me.get("repo") or {}
429
+ lines = ["crow-broadcast · " + str(len(view["sessions"])) + " outra(s) sessão(ões) viva(s) · " + str(len(view["claims"])) +
430
+ " reserva(s)"]
431
+ if me:
432
+ lines.append("aqui: " + (repo.get("remote") or repo.get("root") or me.get("cwd") or "?") +
433
+ (" · branch " + str(me.get("branch")) if me.get("branch") else "") +
434
+ (" · sessão " + str(view.get("owner", {}).get("host")) if view.get("owner") else ""))
435
+ shown = view["sessions"] if full else broadcast.relevant(view)
436
+ now = broadcast.parse_time(view["generatedAt"]) or 0
437
+ for s in shown:
438
+ lines += broadcast.session_lines(s, view["_home"], now)
439
+ if not full and len(shown) < len(view["sessions"]):
440
+ lines.append("(+" + str(len(view["sessions"]) - len(shown)) + " em outros repositórios; crow-skills broadcast list)")
441
+ live = {s.get("key"): s for s in view["_live"]}
442
+ for claim in view["claims"]:
443
+ lines.append(broadcast.claim_line(claim, live, now))
444
+ if not shown and not view["claims"]:
445
+ lines.append("Nenhuma outra sessão viva neste repositório." if not full else "Nenhuma outra sessão viva.")
446
+ return "\n".join(lines)
447
+
448
+
449
+ def broadcast_view(args, home, owner=None):
450
+ cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
451
+ owner = owner if owner is not None else broadcast_owner(args, home, cwd)
452
+ me_key = owner["key"] if owner else None
453
+ view = broadcast.overview(home, me_key, cwd=cwd)
454
+ if view["me"] is not None and view["me"].get("cwd") is None:
455
+ view["me"]["cwd"] = cwd
456
+ view["owner"] = owner
457
+ return view
458
+
459
+
460
+ def public(view):
461
+ return {k: v for k, v in view.items() if not k.startswith("_")}
462
+
463
+
464
+ def broadcast_command(args, home):
465
+ action = args.action
466
+ cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
467
+ if action in ("status", "list"):
468
+ view = broadcast_view(args, home)
469
+ view["_home"] = home
470
+ text = broadcast_text(view, full=action == "list")
471
+ data = public(view)
472
+ if action == "status":
473
+ data["relevant"] = [s["key"] for s in broadcast.relevant(view)]
474
+ data["text"] = text
475
+ return data
476
+ if action == "watch":
477
+ if getattr(args, "json", False):
478
+ raise CrowError("usage", "watch redesenha em texto; para JSON use status --json em polling.")
479
+ frames = 0
480
+ try:
481
+ while True:
482
+ view = broadcast_view(args, home)
483
+ view["_home"] = home
484
+ sys.stdout.write("\x1b[H\x1b[2J" + broadcast_text(view) + "\n")
485
+ sys.stdout.flush()
486
+ frames += 1
487
+ if args.max_frames is not None and frames >= args.max_frames:
488
+ return None
489
+ import time as _time
490
+ _time.sleep(max(0.2, args.interval))
491
+ except KeyboardInterrupt:
492
+ return None
493
+ owner = broadcast_owner(args, home, cwd)
494
+ if action == "claim":
495
+ if not args.targets:
496
+ raise CrowError("usage", "Informe o que reservar: caminhos, globs ou área (ex.: crow-skills broadcast claim src/api --ttl 30m).")
497
+ try:
498
+ ttl = broadcast.parse_ttl(args.ttl)
499
+ except ValueError:
500
+ raise CrowError("usage", "--ttl inválido: use 30m, 2h, 90s (máximo 24h).") from None
501
+ try:
502
+ claim, conflicts = broadcast.add_claim(home, args.targets, ttl, owner=owner, cwd=cwd, note=args.note, force=args.force)
503
+ except ValueError:
504
+ raise CrowError("usage", "Nenhum alvo válido para reservar.") from None
505
+ if claim is None:
506
+ holders = "; ".join(broadcast.claim_line(c, {}, broadcast.time.time()).lstrip("- ") for c in conflicts[:3])
507
+ raise CrowError("claim_conflict", "Área já reservada por outra sessão viva: " + holders +
508
+ ". Coordene com ela ou use --force se o usuário decidir seguir.")
509
+ return {"claim": claim, "owner": owner, "conflicts": conflicts,
510
+ "note": None if owner else "Sessão dona não detectada; a reserva expira só pelo TTL. Use --session/--host."}
511
+ if not owner and (args.all or not args.targets):
512
+ raise CrowError("usage", "Sessão dona não detectada: informe --session/--host (ou os ids das reservas).")
513
+ removed, refused = broadcast.release_claims(home, ids=set(args.targets), owner_key=owner["key"] if owner else None,
514
+ everything=args.all or not args.targets, force=args.force)
515
+ if refused and not removed:
516
+ raise CrowError("claim_owner", "Reserva(s) de outra sessão: " + ", ".join(refused) +
517
+ ". Só a sessão dona libera; --force só por decisão do usuário.")
518
+ return {"released": [c["id"] for c in removed], "refused": refused, "owner": owner}
519
+
520
+
364
521
  def autoupdate_command(args, home, store, interactive):
365
522
  if args.action == "status":
366
523
  return autoupdate.status(store, home)
@@ -464,7 +621,11 @@ def run(args):
464
621
  return mode_command(args, home)
465
622
  if args.command == "events":
466
623
  return events_command(args, home, store)
624
+ if args.command == "broadcast":
625
+ return broadcast_command(args, home)
467
626
  if args.command == "resources":
627
+ if args.exec_prefix:
628
+ return resources.exec_prefix_data()
468
629
  data = resources.probe(home, processes=not args.no_processes)
469
630
  data["text"] = resources.render(data)
470
631
  return data
@@ -596,6 +757,15 @@ def emit(command, data, machine):
596
757
  print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
597
758
  elif command == "resources":
598
759
  print(data["text"])
760
+ elif command == "broadcast":
761
+ if data is not None:
762
+ if "text" in data:
763
+ print(data["text"])
764
+ elif "claim" in data:
765
+ print("reserva " + data["claim"]["id"] + ": " + ", ".join(data["claim"]["patterns"]) + " até " +
766
+ data["claim"]["expiresAt"] + ("" if data["owner"] else " (" + data["note"] + ")"))
767
+ else:
768
+ print("liberadas: " + (", ".join(data["released"]) or "nenhuma"))
599
769
  elif command == "mode":
600
770
  print(data["indicator"] + " — " + data["applies"])
601
771
  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,20 +58,56 @@ 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):
67
100
  """crow-resources/1 subset for terminals: memory, pressure and the recommendation.
68
101
 
69
102
  Added in 0.9.0 without changing crow-snapshot/1: consumers that do not know the
70
- field ignore it; the field carries its own schema for future changes.
103
+ field ignore it; the field carries its own schema for future changes. 0.10.1 adds
104
+ pressure.swap and cpu (additive).
71
105
  """
72
106
  data = resources.probe(home, processes=False, use_cache=True)
73
107
  return {"schema": data["schema"], "generatedAt": data["generatedAt"], "cached": data["cached"],
74
- "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"]},
108
+ "memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"],
109
+ "swap": data["pressure"].get("swap")},
110
+ "cpu": {k: data["cpu"].get(k) for k in ("count", "load1", "load5", "load15", "loadPerCpu1", "loadPerCpu5", "level", "reasons")},
75
111
  "recommendation": data["recommendation"], "alert": resources.alert(data), "segment": resources.segment(data)}
76
112
 
77
113
 
@@ -97,6 +133,12 @@ def render(data, unicode=True):
97
133
  lines.append("")
98
134
  lines.append("memória: " + (machine["segment"] or "") + " · pressão " + machine["pressure"]["level"] + " · " + rec["mode"] +
99
135
  " (até " + str(rec["maxParallelAgents"]) + " agente(s), " + str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s))")
136
+ shared = data.get("broadcast")
137
+ if shared and (shared.get("conflicts") or shared.get("claims")):
138
+ lines.append("")
139
+ lines.append("broadcast: " + str(len([c for c in shared["conflicts"] if c["kind"] == "file"])) + " arquivo(s) em mais de uma sessão · " +
140
+ str(len([c for c in shared["conflicts"] if c["kind"] == "clone"])) + " clone(s) compartilhado(s) · " +
141
+ str(len(shared["claims"])) + " reserva(s) (crow-skills broadcast list)")
100
142
  lines.append("")
101
143
  lines.append("autoupdate: " + ("ligado" if update.get("enabled") else "desligado") +
102
144
  ((" · última execução " + str(update["lastRun"]["startedAt"]) + (" ok" if update["lastRun"]["ok"] else " com falha"))