@andespindola/crow-skills 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.37.0"
3
+ __version__ = "0.38.0"
@@ -115,12 +115,23 @@ def real_home(home):
115
115
  # --- scheduler files ---------------------------------------------------------------------------
116
116
 
117
117
  def search_path(launcher):
118
- dirs = [os.path.dirname(launcher)]
118
+ """PATH for the scheduled run.
119
+
120
+ Order matters and used to be wrong: directories discovered with
121
+ shutil.which were appended before the curated list, so on a Mac with
122
+ Homebrew Python the plist ended up with /usr/bin AHEAD of
123
+ /opt/homebrew/bin. Under launchd that resolves python3 to the system 3.9,
124
+ and the launcher aborts with "Python 3.10+ é necessário" — which is why the
125
+ agent fired daily and failed silently for days. The curated directories now
126
+ come first, and CROW_SKILLS_PYTHON (see scheduler_files) pins the
127
+ interpreter outright so no PATH ordering can break it again.
128
+ """
129
+ dirs = [os.path.dirname(launcher), "/opt/homebrew/bin", "/usr/local/bin"]
119
130
  for tool in ("node", "python3", "git", "brew", "npm"):
120
131
  found = shutil.which(tool)
121
132
  if found:
122
133
  dirs.append(os.path.dirname(found))
123
- dirs += ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
134
+ dirs += ["/usr/bin", "/bin"]
124
135
  return ":".join(dict.fromkeys(d for d in dirs if d))
125
136
 
126
137
 
@@ -130,7 +141,13 @@ def scheduler_files(kind, store, home, launcher):
130
141
  args = [launcher, "autoupdate", "run", "--yes", "--json"]
131
142
  if kind == "launchd":
132
143
  plist = {"Label": LABEL, "ProgramArguments": args, "RunAtLoad": True, "StartCalendarInterval": DAILY,
133
- "EnvironmentVariables": {"PATH": search_path(launcher)}, "StandardOutPath": log, "StandardErrorPath": log,
144
+ "EnvironmentVariables": {"PATH": search_path(launcher),
145
+ # Pin the interpreter: this process is
146
+ # running on it, so it satisfies the
147
+ # launcher's 3.10+ requirement by
148
+ # construction, whatever PATH holds.
149
+ "CROW_SKILLS_PYTHON": sys.executable},
150
+ "StandardOutPath": log, "StandardErrorPath": log,
134
151
  "ProcessType": "Background", "LowPriorityIO": True, "Nice": 10}
135
152
  return {Path(home) / "Library" / "LaunchAgents" / (LABEL + ".plist"): plistlib.dumps(plist, sort_keys=True)}
136
153
  if kind == "systemd":
@@ -139,7 +156,9 @@ def scheduler_files(kind, store, home, launcher):
139
156
  command = " ".join('"' + a + '"' if " " in a else a for a in args)
140
157
  base = Path(home) / ".config" / "systemd" / "user"
141
158
  service = (HEADER + "\n[Unit]\nDescription=crow-skills autoupdate\n\n[Service]\nType=oneshot\n"
142
- "Environment=PATH=" + search_path(launcher) + "\nExecStart=" + command + "\nNice=10\n")
159
+ "Environment=PATH=" + search_path(launcher) +
160
+ "\nEnvironment=CROW_SKILLS_PYTHON=" + sys.executable +
161
+ "\nExecStart=" + command + "\nNice=10\n")
143
162
  timer = (HEADER + "\n[Unit]\nDescription=crow-skills autoupdate (diário e no login)\n\n[Timer]\nOnCalendar=daily\n"
144
163
  "OnStartupSec=5min\nPersistent=true\nRandomizedDelaySec=30min\nUnit=" + UNIT + ".service\n\n"
145
164
  "[Install]\nWantedBy=timers.target\n")
@@ -0,0 +1,319 @@
1
+ """Independent specialist bots.
2
+
3
+ Every way an agent could get help until now BLOCKED the agent that asked:
4
+ the host's own subagent tool makes the parent await the result, and
5
+ `crow_micro.py run` shells out with `subprocess.run(..., timeout=...)`. A
6
+ blocked base agent is not merely slow, it is unreachable: while it waits it
7
+ answers no broadcast, accepts no new demand, updates no state and can lend
8
+ nothing. That is why a machine full of "working" agents coordinates so little.
9
+
10
+ A bot here is a DETACHED process with an identity. The caller gets a handle
11
+ back immediately and keeps working. The bot registers itself through the
12
+ ordinary crow hooks, so it shows up as a real session with its own key — the
13
+ hooks are never stripped, because an agent spawned without them runs invisible
14
+ to everything that is supposed to watch it.
15
+
16
+ Idle costs nothing. Each bot pins a session id at spawn, so finishing a unit
17
+ lets the process EXIT while its conversation stays on disk; more work resumes
18
+ that same session (`claude --resume`, `codex exec resume`). An idle bot is a
19
+ row in a registry, not a parked process holding memory — which is what makes
20
+ lending one cheaper than spawning another.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import subprocess
26
+ import time
27
+ import uuid
28
+ from pathlib import Path
29
+
30
+ from . import broadcast
31
+ from .storage import atomic_json, ensure_dir
32
+
33
+ #: A bot that has not been touched for this long is stale: its conversation is
34
+ #: still on disk, but the world it reasoned about has moved on. Staleness, not
35
+ #: memory, is the limit on keeping bots around, since an idle one costs no RAM.
36
+ STALE_SECONDS = 2 * 60 * 60
37
+
38
+ REGISTRY_SCHEMA = "crow-bots/1"
39
+
40
+ #: Bot lifecycle. "working" means a process is live; "idle" means no process
41
+ #: and a resumable session; "lent" is idle but promised to another owner.
42
+ STATES = ("working", "idle", "lent", "gone")
43
+
44
+
45
+ def registry_path(home):
46
+ return broadcast.state_dir(home) / "bots.json"
47
+
48
+
49
+ def logs_dir(home):
50
+ return Path(home) / ".local" / "state" / "crow-skills" / "bots"
51
+
52
+
53
+ def _load(home):
54
+ data = broadcast.read_json(registry_path(home)) or {}
55
+ if data.get("schema") != REGISTRY_SCHEMA:
56
+ return {"schema": REGISTRY_SCHEMA, "bots": []}
57
+ data.setdefault("bots", [])
58
+ return data
59
+
60
+
61
+ def _save(home, data):
62
+ path = registry_path(home)
63
+ ensure_dir(path.parent)
64
+ atomic_json(path, data)
65
+
66
+
67
+ def _alive(pid):
68
+ if not pid:
69
+ return False
70
+ try:
71
+ os.kill(int(pid), 0)
72
+ except ProcessLookupError:
73
+ return False
74
+ except PermissionError:
75
+ return True
76
+ except (TypeError, ValueError):
77
+ return False
78
+ return True
79
+
80
+
81
+ def resolve_cli(name=None, home=None):
82
+ """Which runner to use. `auto` takes the first one actually installed, so a
83
+ machine with only devin still spawns bots without being configured."""
84
+ chain = ("claude", "codex", "gemini", "devin")
85
+ wanted = (name or "auto").strip().lower()
86
+ candidates = chain if wanted in ("", "auto") else (wanted,)
87
+ home = Path(home or Path.home())
88
+ for cli in candidates:
89
+ for base in ("/opt/homebrew/bin", "/usr/local/bin",
90
+ str(home / ".npm-global/bin"), str(home / ".local/bin")):
91
+ path = Path(base) / cli
92
+ if path.exists():
93
+ return cli, str(path)
94
+ return None, None
95
+
96
+
97
+ def _argv(cli, binary, brief, session_id, model=None, resume=False):
98
+ """The command line for one unit of work, pinning or resuming the session.
99
+
100
+ Pinning matters as much as detaching: without a stable session id the bot
101
+ cannot be given a second task later, so it would have to be respawned from
102
+ nothing every time — exactly the cost that borrowing is meant to avoid.
103
+ """
104
+ if cli == "claude":
105
+ argv = [binary, "-p", brief,
106
+ "--permission-mode", "bypassPermissions",
107
+ "--output-format", "stream-json", "--verbose"]
108
+ argv += ["--resume", session_id] if resume else ["--session-id", session_id]
109
+ if model:
110
+ argv += ["--model", model]
111
+ return argv
112
+ if cli == "codex":
113
+ argv = [binary, "exec"]
114
+ argv += ["resume", session_id, brief] if resume else [brief]
115
+ argv += ["--dangerously-bypass-approvals-and-sandbox"]
116
+ if model:
117
+ argv += ["-m", model]
118
+ return argv
119
+ if cli == "gemini":
120
+ return [binary, "-p", brief, "--yolo"]
121
+ if cli == "devin":
122
+ return [binary, "-p", brief, "--permission-mode", "dangerous",
123
+ "--respect-workspace-trust", "false"]
124
+ return None
125
+
126
+
127
+ def _launch(home, record, brief, resume):
128
+ """Start the process DETACHED and return at once.
129
+
130
+ start_new_session puts the bot in its own process group, so it neither dies
131
+ with the caller's terminal nor takes signals meant for it.
132
+ """
133
+ cli, binary = resolve_cli(record.get("cli"), home)
134
+ if not binary:
135
+ return None, "nenhum CLI de agente encontrado nesta máquina"
136
+ argv = _argv(cli, binary, brief, record["session"], record.get("model"), resume=resume)
137
+ if not argv:
138
+ return None, "CLI sem modo não interativo conhecido: " + str(cli)
139
+
140
+ log = logs_dir(home) / (record["id"] + ".log")
141
+ ensure_dir(log.parent)
142
+ env = dict(os.environ)
143
+ # The hooks read these and stamp the session, which is what turns a loose
144
+ # process into a bot with a parent, a role and a place in the tree.
145
+ env.update(CROW_BOT_ID=record["id"],
146
+ CROW_BOT_OWNER=record.get("owner") or "",
147
+ CROW_BOT_ROLE=record.get("role") or "")
148
+ # NOTE: the agent's own settings and hooks are deliberately left intact.
149
+ # Stripping them (`--setting-sources ""`) makes the bot invisible to the
150
+ # very roster that is supposed to supervise it.
151
+ try:
152
+ handle = open(log, "ab")
153
+ except OSError:
154
+ handle = subprocess.DEVNULL
155
+ try:
156
+ proc = subprocess.Popen(argv, cwd=record.get("cwd") or str(Path.home()),
157
+ env=env, stdin=subprocess.DEVNULL,
158
+ stdout=handle, stderr=subprocess.STDOUT,
159
+ start_new_session=True)
160
+ except OSError as exc:
161
+ return None, "falha ao iniciar o bot: " + str(exc)
162
+ return proc.pid, None
163
+
164
+
165
+ def spawn(home, role, brief, cli=None, model=None, owner=None, cwd=None):
166
+ """Create a specialist bot and hand the caller its id, without waiting."""
167
+ role = (role or "").strip()
168
+ brief = (brief or "").strip()
169
+ if not role:
170
+ raise ValueError("um bot precisa de papel: é o que permite reaproveitá-lo depois")
171
+ if not brief:
172
+ raise ValueError("um bot precisa de briefing")
173
+ record = {
174
+ "id": uuid.uuid4().hex[:10],
175
+ "session": str(uuid.uuid4()),
176
+ "role": role,
177
+ "brief": brief,
178
+ "cli": (cli or "auto"),
179
+ "model": model,
180
+ "owner": owner,
181
+ "cwd": cwd or os.getcwd(),
182
+ "state": "working",
183
+ "spawnedAt": broadcast.now_iso(),
184
+ "lastAssignedAt": broadcast.now_iso(),
185
+ "lentTo": None,
186
+ "runs": 1,
187
+ }
188
+ pid, error = _launch(home, record, brief, resume=False)
189
+ if error:
190
+ raise RuntimeError(error)
191
+ record["pid"] = pid
192
+ record["cli"] = resolve_cli(record["cli"], home)[0]
193
+ data = _load(home)
194
+ data["bots"].append(record)
195
+ _save(home, data)
196
+ return record
197
+
198
+
199
+ def assign(home, bot_id, brief, owner=None):
200
+ """Give more work to an existing bot by resuming its pinned session."""
201
+ data = _load(home)
202
+ record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
203
+ if not record:
204
+ raise ValueError("bot desconhecido: " + str(bot_id))
205
+ if _alive(record.get("pid")):
206
+ raise RuntimeError("o bot " + bot_id + " ainda está trabalhando; não empilhe tarefas nele")
207
+ pid, error = _launch(home, record, brief, resume=True)
208
+ if error:
209
+ raise RuntimeError(error)
210
+ record.update(pid=pid, state="working", brief=brief,
211
+ lastAssignedAt=broadcast.now_iso(),
212
+ runs=int(record.get("runs") or 0) + 1)
213
+ if owner:
214
+ record["lentTo"] = None if owner == record.get("owner") else owner
215
+ _save(home, data)
216
+ return record
217
+
218
+
219
+ def release(home, bot_id):
220
+ """Hand a borrowed bot back to its owner."""
221
+ data = _load(home)
222
+ record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
223
+ if not record:
224
+ raise ValueError("bot desconhecido: " + str(bot_id))
225
+ record["lentTo"] = None
226
+ _save(home, data)
227
+ return record
228
+
229
+
230
+ def stop(home, bot_id):
231
+ data = _load(home)
232
+ record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
233
+ if not record:
234
+ raise ValueError("bot desconhecido: " + str(bot_id))
235
+ pid = record.get("pid")
236
+ if _alive(pid):
237
+ try:
238
+ os.killpg(os.getpgid(int(pid)), 15)
239
+ except (OSError, ProcessLookupError):
240
+ pass
241
+ record["state"] = "gone"
242
+ _save(home, data)
243
+ return record
244
+
245
+
246
+ def _age(stamp):
247
+ try:
248
+ return time.time() - time.mktime(time.strptime((stamp or "")[:19], "%Y-%m-%dT%H:%M:%S"))
249
+ except (ValueError, TypeError):
250
+ return None
251
+
252
+
253
+ def listing(home, now=None):
254
+ """Every bot with its REAL state, derived from the process, not from what
255
+ was written when it was created."""
256
+ now = now or time.time()
257
+ data = _load(home)
258
+ out = []
259
+ changed = False
260
+ for record in data["bots"]:
261
+ if record.get("state") == "gone":
262
+ continue
263
+ working = _alive(record.get("pid"))
264
+ state = "working" if working else ("lent" if record.get("lentTo") else "idle")
265
+ if record.get("state") != state:
266
+ record["state"] = state
267
+ changed = True
268
+ age = _age(record.get("lastAssignedAt"))
269
+ row = dict(record)
270
+ row["state"] = state
271
+ row["ageSeconds"] = int(age) if age is not None else None
272
+ # Idle costs no memory, so the thing that disqualifies a bot for reuse
273
+ # is a conversation too old to still be about the current world.
274
+ row["stale"] = bool(age is not None and not working and age > STALE_SECONDS)
275
+ row["lendable"] = bool(state == "idle" and not row["stale"])
276
+ out.append(row)
277
+ if changed:
278
+ _save(home, data)
279
+ return out
280
+
281
+
282
+ def lendable(home, now=None):
283
+ """Idle, fresh bots another session may borrow instead of spawning one."""
284
+ return [b for b in listing(home, now) if b.get("lendable")]
285
+
286
+
287
+ def prune(home, keep_hours=24):
288
+ """Drop records whose process is gone and whose session is long stale."""
289
+ data = _load(home)
290
+ before = len(data["bots"])
291
+ cutoff = keep_hours * 3600
292
+ kept = []
293
+ for record in data["bots"]:
294
+ age = _age(record.get("lastAssignedAt"))
295
+ if record.get("state") == "gone" or (age is not None and age > cutoff and not _alive(record.get("pid"))):
296
+ continue
297
+ kept.append(record)
298
+ data["bots"] = kept
299
+ _save(home, data)
300
+ return {"removed": before - len(kept), "kept": len(kept)}
301
+
302
+
303
+ def render(rows):
304
+ if not rows:
305
+ return "Nenhum bot. Antes de criar um, veja se outra sessão tem um ocioso: crow-skills bot list --json"
306
+ lines = []
307
+ for b in rows:
308
+ mark = {"working": "●", "idle": "○", "lent": "→"}.get(b["state"], "·")
309
+ age = b.get("ageSeconds")
310
+ when = ("há " + str(int(age // 60)) + "min") if age is not None else "—"
311
+ extra = []
312
+ if b.get("lentTo"):
313
+ extra.append("emprestado a " + str(b["lentTo"])[:10])
314
+ if b.get("stale"):
315
+ extra.append("contexto velho")
316
+ lines.append(" " + mark + " " + b["id"] + " " + (b.get("role") or "?")[:28].ljust(28) +
317
+ (b.get("cli") or "?").ljust(7) + b["state"].ljust(8) + when +
318
+ (" · " + ", ".join(extra) if extra else ""))
319
+ return "\n".join(lines)
@@ -7,6 +7,7 @@ import sys
7
7
 
8
8
  from . import __version__
9
9
  from . import autoupdate
10
+ from . import bots
10
11
  from . import broadcast
11
12
  from . import events
12
13
  from . import groups as groups_mod
@@ -74,6 +75,10 @@ COMMANDS = {
74
75
  ["crow-skills mode", "crow-skills mode off", "crow-skills mode on"]),
75
76
  "events": ("Barramento de eventos dos hooks (crow-event/1) para terminais.",
76
77
  ["crow-skills events", "crow-skills events tail --limit 20", "crow-skills events tail --follow"]),
78
+ "bot": ("Bots especialistas independentes: criar sem bloquear, reaproveitar o ocioso, emprestar entre sessões.",
79
+ ["crow-skills bot list", "crow-skills bot spawn --role revisor-sql --brief \"revise o diff de migrations\"",
80
+ "crow-skills bot assign 9f2a1c \"agora valide o rollback\"", "crow-skills bot release 9f2a1c",
81
+ "crow-skills bot stop 9f2a1c"]),
77
82
  "broadcast": ("Sessões vivas dos CLIs, arquivos em edição, conflitos, reservas e mensagens entre sessões.",
78
83
  ["crow-skills broadcast", "crow-skills broadcast claim src/api --ttl 30m", "crow-skills broadcast release --all",
79
84
  "crow-skills broadcast say \"terminei o merge, a branch é tua\" --to codex", "crow-skills broadcast inbox",
@@ -204,6 +209,16 @@ def parser():
204
209
  child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
205
210
  child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
206
211
  child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
212
+ child = command_parser(sub, "bot", [common])
213
+ child.add_argument("action", nargs="?", default="list",
214
+ choices=("list", "spawn", "assign", "release", "stop", "prune"))
215
+ child.add_argument("target", nargs="?", help="assign/release/stop: id do bot")
216
+ child.add_argument("text", nargs="*", help="assign: o novo briefing")
217
+ child.add_argument("--role", help="spawn: a especialidade do bot (é o que permite reaproveitá-lo)")
218
+ child.add_argument("--brief", help="spawn: o briefing da primeira unidade")
219
+ child.add_argument("--cli", default="auto", help="spawn: claude, codex, gemini, devin ou auto (primeiro disponível)")
220
+ child.add_argument("--model", help="spawn: modelo para esta unidade")
221
+ child.add_argument("--lendable-only", action="store_true", help="list: só os ociosos que outra sessão pode pedir emprestado")
207
222
  child = command_parser(sub, "broadcast", [common])
208
223
  child.add_argument("action", nargs="?", default="status", choices=("status", "list", "claim", "release", "watch", "say", "reply", "inbox",
209
224
  "thread", "sent", "ack", "feed"))
@@ -676,6 +691,56 @@ def orphans_command(args, home, interactive):
676
691
  return data
677
692
 
678
693
 
694
+ def bot_command(args, home):
695
+ """Independent specialists. `list` comes first on purpose: the rule is to
696
+ look for an idle bot before creating another one."""
697
+ action = args.action
698
+ if action == "list":
699
+ rows = bots.lendable(home) if getattr(args, "lendable_only", False) else bots.listing(home)
700
+ return {"bots": rows, "text": bots.render(rows)}
701
+ if action == "spawn":
702
+ owner = broadcast.session_key(os.environ.get("CROW_HOST"), os.environ.get("CROW_SESSION"),
703
+ os.getcwd()) if os.environ.get("CROW_SESSION") else None
704
+ free = bots.lendable(home)
705
+ try:
706
+ record = bots.spawn(home, getattr(args, "role", None), getattr(args, "brief", None),
707
+ cli=getattr(args, "cli", "auto"), model=getattr(args, "model", None),
708
+ owner=owner, cwd=os.getcwd())
709
+ except (ValueError, RuntimeError) as exc:
710
+ raise CrowError("usage", str(exc))
711
+ out = {"bot": record, "text": "bot " + record["id"] + " (" + record["role"] + ") iniciado em " +
712
+ str(record.get("cli")) + ", pid " + str(record.get("pid")) + " — você não ficou bloqueado"}
713
+ if free:
714
+ # Not a refusal: the decision is the caller's, but spending a new
715
+ # process while an idle specialist sits there is the waste this
716
+ # command exists to avoid.
717
+ out["idle_available"] = [{"id": b["id"], "role": b.get("role")} for b in free]
718
+ out["text"] += "\nAviso: havia bot ocioso para reaproveitar — " + \
719
+ ", ".join(b["id"] + " (" + str(b.get("role")) + ")" for b in free)
720
+ return out
721
+ if action == "assign":
722
+ brief = " ".join(getattr(args, "text", []) or []).strip()
723
+ if not getattr(args, "target", None) or not brief:
724
+ raise CrowError("usage", "assign precisa do id do bot e do novo briefing.")
725
+ try:
726
+ record = bots.assign(home, args.target, brief)
727
+ except (ValueError, RuntimeError) as exc:
728
+ raise CrowError("usage", str(exc))
729
+ return {"bot": record, "text": "bot " + record["id"] + " retomou a sessão e já está trabalhando"}
730
+ if action in ("release", "stop"):
731
+ if not getattr(args, "target", None):
732
+ raise CrowError("usage", action + " precisa do id do bot.")
733
+ try:
734
+ record = bots.release(home, args.target) if action == "release" else bots.stop(home, args.target)
735
+ except ValueError as exc:
736
+ raise CrowError("usage", str(exc))
737
+ return {"bot": record, "text": "bot " + record["id"] + " " +
738
+ ("devolvido ao dono" if action == "release" else "encerrado")}
739
+ if action == "prune":
740
+ return bots.prune(home)
741
+ raise CrowError("usage", "ação de bot desconhecida: " + str(action))
742
+
743
+
679
744
  def broadcast_command(args, home):
680
745
  action = args.action
681
746
  cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
@@ -1231,6 +1296,8 @@ def run(args):
1231
1296
  return mcp_command(args, home)
1232
1297
  if args.command == "events":
1233
1298
  return events_command(args, home, store)
1299
+ if args.command == "bot":
1300
+ return bot_command(args, home)
1234
1301
  if args.command == "broadcast":
1235
1302
  return broadcast_command(args, home)
1236
1303
  if args.command == "resources":
@@ -391,6 +391,15 @@ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
391
391
  for field in ("cwd", "model", "permissionMode"):
392
392
  if event.get(field):
393
393
  snap[field] = event[field]
394
+ # A bot is spawned with these in its environment, so the session it
395
+ # registers carries its parent and its speciality. Without them a
396
+ # detached helper is just another anonymous session and the roster can
397
+ # show no tree, which is why lending one was never possible.
398
+ for field, var in (("botId", "CROW_BOT_ID"), ("botOwner", "CROW_BOT_OWNER"),
399
+ ("botRole", "CROW_BOT_ROLE")):
400
+ value = os.environ.get(var)
401
+ if value:
402
+ snap[field] = value
394
403
  if not snap.get("cwd") and fallback_cwd:
395
404
  snap["cwd"] = fallback_cwd # hosts that send no cwd: the hook runs in the session's directory
396
405
  kind = event["event"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
package/release.json CHANGED
@@ -28,7 +28,7 @@
28
28
  "crow_skills/__init__.py": {
29
29
  "executable": false,
30
30
  "kind": "file",
31
- "sha256": "e81ffb7e0427f976533c16e6649feba6cd9cb78085e564a57a1841b6ab4f08cf"
31
+ "sha256": "b83bfadfc540ccf6b59b8a1e5f283191d67f3fd4ca1bbd76934a9a45f0917658"
32
32
  },
33
33
  "crow_skills/__main__.py": {
34
34
  "executable": false,
@@ -43,7 +43,12 @@
43
43
  "crow_skills/autoupdate.py": {
44
44
  "executable": false,
45
45
  "kind": "file",
46
- "sha256": "10d8267ec1fd861d56af8423a2a1a71769ccc9d59a89fdf2f41858a6476666cd"
46
+ "sha256": "bab32c72d6ce57e0c5b1d4194e14a0be2880d50c85f5ebea2a5bfb6edb6c2693"
47
+ },
48
+ "crow_skills/bots.py": {
49
+ "executable": false,
50
+ "kind": "file",
51
+ "sha256": "4e01f1ae95ce5feed897146ad7c61250494845d2409da009044fce2272df9463"
47
52
  },
48
53
  "crow_skills/broadcast.py": {
49
54
  "executable": false,
@@ -53,7 +58,7 @@
53
58
  "crow_skills/cli.py": {
54
59
  "executable": false,
55
60
  "kind": "file",
56
- "sha256": "b245ff68ff48d48fecc076fc0cec7a24065a07ec205805285dbfe3a8faee652d"
61
+ "sha256": "04f2c49af4888fc5d8a1b28aee4b6b591ee64d2c38ef0f463800f79f7afc28c7"
57
62
  },
58
63
  "crow_skills/events.py": {
59
64
  "executable": false,
@@ -73,7 +78,7 @@
73
78
  "crow_skills/hook.py": {
74
79
  "executable": false,
75
80
  "kind": "file",
76
- "sha256": "225f706590ae59cd28f99d237403bfa6090a9a6c29b07fe668482f33ad627152"
81
+ "sha256": "ba8da0cdf6e8aa8d1002966c4b05718c74740c132b6d703f22071135d54a4342"
77
82
  },
78
83
  "crow_skills/integrations.py": {
79
84
  "executable": false,
@@ -143,7 +148,7 @@
143
148
  "package.json": {
144
149
  "executable": false,
145
150
  "kind": "file",
146
- "sha256": "1c30d4fd90f7cfc3536419fe86dc9229e3ea5efbededcf85b227954650de0e10"
151
+ "sha256": "265a2446df8eae2939ab60a804b38f572ad20c2987b47076fe068d026fccbe46"
147
152
  },
148
153
  "skills/crow-accessibility/SKILL.md": {
149
154
  "executable": false,
@@ -966,7 +971,7 @@
966
971
  "sha256": "160c814085dfdbe93e0166f2a773345dd1436f07a63dbf58d447f3f7674dc759"
967
972
  }
968
973
  },
969
- "revision": "df47e9b5e6507bb9c543ccc5d20e38e8d69d3239",
974
+ "revision": "95525c9fb9d566810a61b8465cd7536ce5599c08",
970
975
  "schema_version": 1,
971
- "version": "0.37.0"
976
+ "version": "0.38.0"
972
977
  }