@andespindola/crow-skills 0.37.0 → 0.38.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.
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/autoupdate.py +23 -4
- package/crow_skills/bots.py +367 -0
- package/crow_skills/cli.py +136 -1
- package/crow_skills/hook.py +9 -0
- package/package.json +1 -1
- package/release.json +13 -8
- package/skills/crow-behaviour/SKILL.md +1 -0
package/crow_skills/__init__.py
CHANGED
|
@@ -115,12 +115,23 @@ def real_home(home):
|
|
|
115
115
|
# --- scheduler files ---------------------------------------------------------------------------
|
|
116
116
|
|
|
117
117
|
def search_path(launcher):
|
|
118
|
-
|
|
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 += ["/
|
|
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)
|
|
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) +
|
|
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,367 @@
|
|
|
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
|
+
"briefs": [brief],
|
|
183
|
+
"findings": [],
|
|
184
|
+
"state": "working",
|
|
185
|
+
"spawnedAt": broadcast.now_iso(),
|
|
186
|
+
"lastAssignedAt": broadcast.now_iso(),
|
|
187
|
+
"lentTo": None,
|
|
188
|
+
"runs": 1,
|
|
189
|
+
}
|
|
190
|
+
pid, error = _launch(home, record, brief, resume=False)
|
|
191
|
+
if error:
|
|
192
|
+
raise RuntimeError(error)
|
|
193
|
+
record["pid"] = pid
|
|
194
|
+
record["cli"] = resolve_cli(record["cli"], home)[0]
|
|
195
|
+
data = _load(home)
|
|
196
|
+
data["bots"].append(record)
|
|
197
|
+
_save(home, data)
|
|
198
|
+
return record
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def assign(home, bot_id, brief, owner=None):
|
|
202
|
+
"""Give more work to an existing bot by resuming its pinned session."""
|
|
203
|
+
data = _load(home)
|
|
204
|
+
record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
|
|
205
|
+
if not record:
|
|
206
|
+
raise ValueError("bot desconhecido: " + str(bot_id))
|
|
207
|
+
if _alive(record.get("pid")):
|
|
208
|
+
raise RuntimeError("o bot " + bot_id + " ainda está trabalhando; não empilhe tarefas nele")
|
|
209
|
+
pid, error = _launch(home, record, brief, resume=True)
|
|
210
|
+
if error:
|
|
211
|
+
raise RuntimeError(error)
|
|
212
|
+
# Keep every brief, do not overwrite. The knowledge a bot holds came from
|
|
213
|
+
# the base agent, so the briefs ARE the record of what it was taught; an
|
|
214
|
+
# assign that replaced the previous one erased run 1 when run 2 arrived,
|
|
215
|
+
# and with it the only trace of why the bot knows what it knows.
|
|
216
|
+
history = list(record.get("briefs") or ([record["brief"]] if record.get("brief") else []))
|
|
217
|
+
history.append(brief)
|
|
218
|
+
record.update(pid=pid, state="working", brief=brief, briefs=history[-20:],
|
|
219
|
+
lastAssignedAt=broadcast.now_iso(),
|
|
220
|
+
runs=int(record.get("runs") or 0) + 1)
|
|
221
|
+
if owner:
|
|
222
|
+
record["lentTo"] = None if owner == record.get("owner") else owner
|
|
223
|
+
_save(home, data)
|
|
224
|
+
return record
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def learned(home, bot_id, note):
|
|
228
|
+
"""Record what a bot REPORTED BACK, as the base agent read it.
|
|
229
|
+
|
|
230
|
+
The bot keeps its own context and never touches the vault; only the base
|
|
231
|
+
agent does. So the findings have to be written down HERE by whoever read
|
|
232
|
+
the report, or they vanish with the session and the next bot of the same
|
|
233
|
+
role starts from nothing — paying again for knowledge already bought.
|
|
234
|
+
|
|
235
|
+
This is the local, machine-level record. Promoting it to the vault, so the
|
|
236
|
+
knowledge survives the machine and reaches a second one, stays the base
|
|
237
|
+
agent's job: the durable note is written through tesus, not from here.
|
|
238
|
+
"""
|
|
239
|
+
note = (note or "").strip()
|
|
240
|
+
if not note:
|
|
241
|
+
raise ValueError("um aprendizado vazio não é aprendizado")
|
|
242
|
+
data = _load(home)
|
|
243
|
+
record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
|
|
244
|
+
if not record:
|
|
245
|
+
raise ValueError("bot desconhecido: " + str(bot_id))
|
|
246
|
+
findings = list(record.get("findings") or [])
|
|
247
|
+
findings.append({"at": broadcast.now_iso(), "note": note})
|
|
248
|
+
record["findings"] = findings[-50:]
|
|
249
|
+
_save(home, data)
|
|
250
|
+
return record
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def knowledge(home, role):
|
|
254
|
+
"""Everything known about a role, across its bots: the briefs that taught
|
|
255
|
+
them and the findings they returned. This is what seeds the NEXT bot of the
|
|
256
|
+
same role instead of briefing it from zero."""
|
|
257
|
+
role = (role or "").strip().lower()
|
|
258
|
+
briefs, findings = [], []
|
|
259
|
+
for b in _load(home)["bots"]:
|
|
260
|
+
if (b.get("role") or "").strip().lower() != role:
|
|
261
|
+
continue
|
|
262
|
+
briefs.extend(b.get("briefs") or ([b["brief"]] if b.get("brief") else []))
|
|
263
|
+
findings.extend(b.get("findings") or [])
|
|
264
|
+
return {"role": role, "briefs": briefs, "findings": findings}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def release(home, bot_id):
|
|
268
|
+
"""Hand a borrowed bot back to its owner."""
|
|
269
|
+
data = _load(home)
|
|
270
|
+
record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
|
|
271
|
+
if not record:
|
|
272
|
+
raise ValueError("bot desconhecido: " + str(bot_id))
|
|
273
|
+
record["lentTo"] = None
|
|
274
|
+
_save(home, data)
|
|
275
|
+
return record
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def stop(home, bot_id):
|
|
279
|
+
data = _load(home)
|
|
280
|
+
record = next((b for b in data["bots"] if b.get("id") == bot_id), None)
|
|
281
|
+
if not record:
|
|
282
|
+
raise ValueError("bot desconhecido: " + str(bot_id))
|
|
283
|
+
pid = record.get("pid")
|
|
284
|
+
if _alive(pid):
|
|
285
|
+
try:
|
|
286
|
+
os.killpg(os.getpgid(int(pid)), 15)
|
|
287
|
+
except (OSError, ProcessLookupError):
|
|
288
|
+
pass
|
|
289
|
+
record["state"] = "gone"
|
|
290
|
+
_save(home, data)
|
|
291
|
+
return record
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _age(stamp):
|
|
295
|
+
try:
|
|
296
|
+
return time.time() - time.mktime(time.strptime((stamp or "")[:19], "%Y-%m-%dT%H:%M:%S"))
|
|
297
|
+
except (ValueError, TypeError):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def listing(home, now=None):
|
|
302
|
+
"""Every bot with its REAL state, derived from the process, not from what
|
|
303
|
+
was written when it was created."""
|
|
304
|
+
now = now or time.time()
|
|
305
|
+
data = _load(home)
|
|
306
|
+
out = []
|
|
307
|
+
changed = False
|
|
308
|
+
for record in data["bots"]:
|
|
309
|
+
if record.get("state") == "gone":
|
|
310
|
+
continue
|
|
311
|
+
working = _alive(record.get("pid"))
|
|
312
|
+
state = "working" if working else ("lent" if record.get("lentTo") else "idle")
|
|
313
|
+
if record.get("state") != state:
|
|
314
|
+
record["state"] = state
|
|
315
|
+
changed = True
|
|
316
|
+
age = _age(record.get("lastAssignedAt"))
|
|
317
|
+
row = dict(record)
|
|
318
|
+
row["state"] = state
|
|
319
|
+
row["ageSeconds"] = int(age) if age is not None else None
|
|
320
|
+
# Idle costs no memory, so the thing that disqualifies a bot for reuse
|
|
321
|
+
# is a conversation too old to still be about the current world.
|
|
322
|
+
row["stale"] = bool(age is not None and not working and age > STALE_SECONDS)
|
|
323
|
+
row["lendable"] = bool(state == "idle" and not row["stale"])
|
|
324
|
+
out.append(row)
|
|
325
|
+
if changed:
|
|
326
|
+
_save(home, data)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def lendable(home, now=None):
|
|
331
|
+
"""Idle, fresh bots another session may borrow instead of spawning one."""
|
|
332
|
+
return [b for b in listing(home, now) if b.get("lendable")]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def prune(home, keep_hours=24):
|
|
336
|
+
"""Drop records whose process is gone and whose session is long stale."""
|
|
337
|
+
data = _load(home)
|
|
338
|
+
before = len(data["bots"])
|
|
339
|
+
cutoff = keep_hours * 3600
|
|
340
|
+
kept = []
|
|
341
|
+
for record in data["bots"]:
|
|
342
|
+
age = _age(record.get("lastAssignedAt"))
|
|
343
|
+
if record.get("state") == "gone" or (age is not None and age > cutoff and not _alive(record.get("pid"))):
|
|
344
|
+
continue
|
|
345
|
+
kept.append(record)
|
|
346
|
+
data["bots"] = kept
|
|
347
|
+
_save(home, data)
|
|
348
|
+
return {"removed": before - len(kept), "kept": len(kept)}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def render(rows):
|
|
352
|
+
if not rows:
|
|
353
|
+
return "Nenhum bot. Antes de criar um, veja se outra sessão tem um ocioso: crow-skills bot list --json"
|
|
354
|
+
lines = []
|
|
355
|
+
for b in rows:
|
|
356
|
+
mark = {"working": "●", "idle": "○", "lent": "→"}.get(b["state"], "·")
|
|
357
|
+
age = b.get("ageSeconds")
|
|
358
|
+
when = ("há " + str(int(age // 60)) + "min") if age is not None else "—"
|
|
359
|
+
extra = []
|
|
360
|
+
if b.get("lentTo"):
|
|
361
|
+
extra.append("emprestado a " + str(b["lentTo"])[:10])
|
|
362
|
+
if b.get("stale"):
|
|
363
|
+
extra.append("contexto velho")
|
|
364
|
+
lines.append(" " + mark + " " + b["id"] + " " + (b.get("role") or "?")[:28].ljust(28) +
|
|
365
|
+
(b.get("cli") or "?").ljust(7) + b["state"].ljust(8) + when +
|
|
366
|
+
(" · " + ", ".join(extra) if extra else ""))
|
|
367
|
+
return "\n".join(lines)
|
package/crow_skills/cli.py
CHANGED
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
9
|
from . import autoupdate
|
|
10
|
+
from . import 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,17 @@ 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", "learned", "knowledge"))
|
|
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")
|
|
222
|
+
child.add_argument("--role-of", dest="role_of", help="knowledge: o papel cujo conhecimento acumulado você quer ler")
|
|
207
223
|
child = command_parser(sub, "broadcast", [common])
|
|
208
224
|
child.add_argument("action", nargs="?", default="status", choices=("status", "list", "claim", "release", "watch", "say", "reply", "inbox",
|
|
209
225
|
"thread", "sent", "ack", "feed"))
|
|
@@ -567,6 +583,32 @@ def ancestors(limit=16):
|
|
|
567
583
|
return pids
|
|
568
584
|
|
|
569
585
|
|
|
586
|
+
#: The human owner speaking from outside any agent session (desktop app, bare
|
|
587
|
+
#: shell). Deliberately NOT one of hook.HOSTS: it is not an agent and must not
|
|
588
|
+
#: be mistaken for one in the roster or the feed.
|
|
589
|
+
OWNER_HOST = "owner"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def sender_owner(args, home, cwd):
|
|
593
|
+
"""Who is SENDING. Same resolution as broadcast_owner, but a caller with no
|
|
594
|
+
live-agent ancestry is the owner's own console rather than an error.
|
|
595
|
+
|
|
596
|
+
A desktop app is reparented to launchd, so it matches no session and
|
|
597
|
+
broadcast_owner returns None; `say` then dereferenced it into
|
|
598
|
+
"AttributeError: 'NoneType' object has no attribute 'get'" and the owner
|
|
599
|
+
could never speak on the channel from outside a terminal. The identity is
|
|
600
|
+
deterministic so agents can address a reply back to it.
|
|
601
|
+
|
|
602
|
+
Scoped to sending ON PURPOSE: claims, inbox, sent and ack keep requiring a
|
|
603
|
+
real session, because attributing those to a console would be wrong.
|
|
604
|
+
"""
|
|
605
|
+
found = broadcast_owner(args, home, cwd)
|
|
606
|
+
if found:
|
|
607
|
+
return found
|
|
608
|
+
return {"key": broadcast.session_key(OWNER_HOST, OWNER_HOST, None), "host": OWNER_HOST,
|
|
609
|
+
"session": None, "detected": "owner"}
|
|
610
|
+
|
|
611
|
+
|
|
570
612
|
def broadcast_owner(args, home, cwd):
|
|
571
613
|
"""The session that runs this command: --session/--host, else the live session whose CLI pid is an ancestor."""
|
|
572
614
|
live = broadcast.live_sessions(home)
|
|
@@ -676,6 +718,87 @@ def orphans_command(args, home, interactive):
|
|
|
676
718
|
return data
|
|
677
719
|
|
|
678
720
|
|
|
721
|
+
def bot_command(args, home):
|
|
722
|
+
"""Independent specialists. `list` comes first on purpose: the rule is to
|
|
723
|
+
look for an idle bot before creating another one."""
|
|
724
|
+
action = args.action
|
|
725
|
+
if action == "list":
|
|
726
|
+
rows = bots.lendable(home) if getattr(args, "lendable_only", False) else bots.listing(home)
|
|
727
|
+
return {"bots": rows, "text": bots.render(rows)}
|
|
728
|
+
if action == "spawn":
|
|
729
|
+
owner = broadcast.session_key(os.environ.get("CROW_HOST"), os.environ.get("CROW_SESSION"),
|
|
730
|
+
os.getcwd()) if os.environ.get("CROW_SESSION") else None
|
|
731
|
+
free = bots.lendable(home)
|
|
732
|
+
try:
|
|
733
|
+
record = bots.spawn(home, getattr(args, "role", None), getattr(args, "brief", None),
|
|
734
|
+
cli=getattr(args, "cli", "auto"), model=getattr(args, "model", None),
|
|
735
|
+
owner=owner, cwd=os.getcwd())
|
|
736
|
+
except (ValueError, RuntimeError) as exc:
|
|
737
|
+
raise CrowError("usage", str(exc))
|
|
738
|
+
out = {"bot": record, "text": "bot " + record["id"] + " (" + record["role"] + ") iniciado em " +
|
|
739
|
+
str(record.get("cli")) + ", pid " + str(record.get("pid")) + " — você não ficou bloqueado"}
|
|
740
|
+
known = bots.knowledge(home, getattr(args, "role", "") or "")
|
|
741
|
+
if known["briefs"] or known["findings"]:
|
|
742
|
+
# Informs, does not inject: silently prepending it would inflate the
|
|
743
|
+
# brief, which is the very context cost this is meant to protect.
|
|
744
|
+
out["knowledge"] = {"briefs": len(known["briefs"]), "findings": len(known["findings"])}
|
|
745
|
+
out["text"] += ("\nJá há conhecimento deste papel: " + str(len(known["briefs"])) + " briefing(s) e " +
|
|
746
|
+
str(len(known["findings"])) + " achado(s). Leia com `bot knowledge --role-of " +
|
|
747
|
+
(getattr(args, "role", "") or "") + "` e inclua no briefing o que valer.")
|
|
748
|
+
if free:
|
|
749
|
+
# Not a refusal: the decision is the caller's, but spending a new
|
|
750
|
+
# process while an idle specialist sits there is the waste this
|
|
751
|
+
# command exists to avoid.
|
|
752
|
+
out["idle_available"] = [{"id": b["id"], "role": b.get("role")} for b in free]
|
|
753
|
+
out["text"] += "\nAviso: havia bot ocioso para reaproveitar — " + \
|
|
754
|
+
", ".join(b["id"] + " (" + str(b.get("role")) + ")" for b in free)
|
|
755
|
+
return out
|
|
756
|
+
if action == "assign":
|
|
757
|
+
brief = " ".join(getattr(args, "text", []) or []).strip()
|
|
758
|
+
if not getattr(args, "target", None) or not brief:
|
|
759
|
+
raise CrowError("usage", "assign precisa do id do bot e do novo briefing.")
|
|
760
|
+
try:
|
|
761
|
+
record = bots.assign(home, args.target, brief)
|
|
762
|
+
except (ValueError, RuntimeError) as exc:
|
|
763
|
+
raise CrowError("usage", str(exc))
|
|
764
|
+
return {"bot": record, "text": "bot " + record["id"] + " retomou a sessão e já está trabalhando"}
|
|
765
|
+
if action in ("release", "stop"):
|
|
766
|
+
if not getattr(args, "target", None):
|
|
767
|
+
raise CrowError("usage", action + " precisa do id do bot.")
|
|
768
|
+
try:
|
|
769
|
+
record = bots.release(home, args.target) if action == "release" else bots.stop(home, args.target)
|
|
770
|
+
except ValueError as exc:
|
|
771
|
+
raise CrowError("usage", str(exc))
|
|
772
|
+
return {"bot": record, "text": "bot " + record["id"] + " " +
|
|
773
|
+
("devolvido ao dono" if action == "release" else "encerrado")}
|
|
774
|
+
if action == "learned":
|
|
775
|
+
# The bot keeps its own context and never writes to the vault; whoever
|
|
776
|
+
# read its report records the finding here, or it dies with the session.
|
|
777
|
+
note = " ".join(getattr(args, "text", []) or []).strip()
|
|
778
|
+
if not getattr(args, "target", None) or not note:
|
|
779
|
+
raise CrowError("usage", "learned precisa do id do bot e do que ele reportou.")
|
|
780
|
+
try:
|
|
781
|
+
record = bots.learned(home, args.target, note)
|
|
782
|
+
except ValueError as exc:
|
|
783
|
+
raise CrowError("usage", str(exc))
|
|
784
|
+
return {"bot": record,
|
|
785
|
+
"text": "registrado no bot " + record["id"] + " (" + str(len(record.get("findings") or [])) +
|
|
786
|
+
" achado(s)). Isto é local: promova ao vault o que for durável."}
|
|
787
|
+
if action == "knowledge":
|
|
788
|
+
role = getattr(args, "role_of", None) or getattr(args, "target", None)
|
|
789
|
+
if not role:
|
|
790
|
+
raise CrowError("usage", "knowledge precisa do papel: --role-of <papel>.")
|
|
791
|
+
data = bots.knowledge(home, role)
|
|
792
|
+
n_b, n_f = len(data["briefs"]), len(data["findings"])
|
|
793
|
+
return {**data,
|
|
794
|
+
"text": ("papel \"" + data["role"] + "\": " + str(n_b) + " briefing(s) e " + str(n_f) +
|
|
795
|
+
" achado(s) acumulados" if n_b or n_f else
|
|
796
|
+
"papel \"" + data["role"] + "\": nada acumulado ainda")}
|
|
797
|
+
if action == "prune":
|
|
798
|
+
return bots.prune(home)
|
|
799
|
+
raise CrowError("usage", "ação de bot desconhecida: " + str(action))
|
|
800
|
+
|
|
801
|
+
|
|
679
802
|
def broadcast_command(args, home):
|
|
680
803
|
action = args.action
|
|
681
804
|
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
@@ -754,7 +877,9 @@ def broadcast_command(args, home):
|
|
|
754
877
|
raise CrowError("not_found", "Nenhuma mensagem endereçada a esta sessão com esse(s) id(s) (expirada, inexistente, ou enviada por você).")
|
|
755
878
|
return {"schema": broadcast.MESSAGES_SCHEMA, "messages": acked,
|
|
756
879
|
"text": "leitura confirmada: " + ", ".join(str(m.get("id")) for m in acked)}
|
|
757
|
-
|
|
880
|
+
# Sending is the one path where a caller outside any agent session is
|
|
881
|
+
# legitimate: it is the owner talking from their own console.
|
|
882
|
+
owner = sender_owner(args, home, cwd) if action in ("say", "reply") else broadcast_owner(args, home, cwd)
|
|
758
883
|
if action in ("say", "reply"):
|
|
759
884
|
reply_to = getattr(args, "reply_to", None)
|
|
760
885
|
targets = list(args.targets)
|
|
@@ -768,6 +893,14 @@ def broadcast_command(args, home):
|
|
|
768
893
|
ttl = broadcast.parse_ttl(args.ttl) if args.ttl else None
|
|
769
894
|
except ValueError:
|
|
770
895
|
raise CrowError("usage", "--ttl inválido: use 30m, 2h, 90s (máximo 24h).") from None
|
|
896
|
+
if not isinstance(owner, dict) or not owner.get("key"):
|
|
897
|
+
# send_message dereferences the sender inside a dict literal, so a
|
|
898
|
+
# missing one surfaced as "AttributeError: 'NoneType' object has no
|
|
899
|
+
# attribute 'get'". Guarded HERE, not in broadcast.py: that module
|
|
900
|
+
# deliberately carries no dependency on source/CrowError, and adding
|
|
901
|
+
# one closes an import cycle through hook.py (measured: 52 tests).
|
|
902
|
+
raise CrowError("usage", "Remetente desconhecido: informe --session/--host "
|
|
903
|
+
"ou envie a partir de uma sessão viva.")
|
|
771
904
|
try:
|
|
772
905
|
record = broadcast.send_message(home, " ".join(targets), owner, to=args.to, ttl=ttl, cwd=cwd,
|
|
773
906
|
reply_to=reply_to, wake=not getattr(args, "no_wake", False))
|
|
@@ -1231,6 +1364,8 @@ def run(args):
|
|
|
1231
1364
|
return mcp_command(args, home)
|
|
1232
1365
|
if args.command == "events":
|
|
1233
1366
|
return events_command(args, home, store)
|
|
1367
|
+
if args.command == "bot":
|
|
1368
|
+
return bot_command(args, home)
|
|
1234
1369
|
if args.command == "broadcast":
|
|
1235
1370
|
return broadcast_command(args, home)
|
|
1236
1371
|
if args.command == "resources":
|
package/crow_skills/hook.py
CHANGED
|
@@ -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
package/release.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"crow_skills/__init__.py": {
|
|
29
29
|
"executable": false,
|
|
30
30
|
"kind": "file",
|
|
31
|
-
"sha256": "
|
|
31
|
+
"sha256": "5c2d124b8f54ae50df371268045f54ba0e08e8cdb27f2141773bfb80fba661b1"
|
|
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": "
|
|
46
|
+
"sha256": "bab32c72d6ce57e0c5b1d4194e14a0be2880d50c85f5ebea2a5bfb6edb6c2693"
|
|
47
|
+
},
|
|
48
|
+
"crow_skills/bots.py": {
|
|
49
|
+
"executable": false,
|
|
50
|
+
"kind": "file",
|
|
51
|
+
"sha256": "b6119c6974d06152b3462c67368b0fd81d4be389419838eebf3c5007e421a10d"
|
|
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": "
|
|
61
|
+
"sha256": "7fdd9af628596e8b7abc111b9277cc6498f5a68e6bdab9421e9aec35c0374ab3"
|
|
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": "
|
|
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": "
|
|
151
|
+
"sha256": "64e8d706972b11b51c6decc284fa11abb151d87e977e43723eb3849d1f409d87"
|
|
147
152
|
},
|
|
148
153
|
"skills/crow-accessibility/SKILL.md": {
|
|
149
154
|
"executable": false,
|
|
@@ -208,7 +213,7 @@
|
|
|
208
213
|
"skills/crow-behaviour/SKILL.md": {
|
|
209
214
|
"executable": false,
|
|
210
215
|
"kind": "file",
|
|
211
|
-
"sha256": "
|
|
216
|
+
"sha256": "e7f2cdb1acf214e78be8f8b7c27c013e591478df7ac99addd3e0891cbc0d13b9"
|
|
212
217
|
},
|
|
213
218
|
"skills/crow-behaviour/agents/openai.yaml": {
|
|
214
219
|
"executable": false,
|
|
@@ -966,7 +971,7 @@
|
|
|
966
971
|
"sha256": "160c814085dfdbe93e0166f2a773345dd1436f07a63dbf58d447f3f7674dc759"
|
|
967
972
|
}
|
|
968
973
|
},
|
|
969
|
-
"revision": "
|
|
974
|
+
"revision": "b86c3ef157a305468fa37ce18f84cca40dc1b11d",
|
|
970
975
|
"schema_version": 1,
|
|
971
|
-
"version": "0.
|
|
976
|
+
"version": "0.38.1"
|
|
972
977
|
}
|
|
@@ -62,6 +62,7 @@ Quando `crow-model-level` estiver instalada, o nível de cada unidade delegada
|
|
|
62
62
|
- **O braçal é delegado por padrão, não por escolha sua.** Uma unidade é braçal quando você consegue especificar o resultado e aceitá-lo pela evidência, sem ver o caminho: a edição que você já decidiu, a varredura com critério fechado, a reescrita mecânica, a coleta de evidência, rodar um comando e relatar o que saiu, levantar como uma coisa funciona hoje. Se é preciso decidir no meio do caminho, não é braçal — é desenho, e é seu. Executar você mesmo uma unidade braçal porque "o contexto já está carregado" é a falha característica desta skill: troca o seu contexto, que é o recurso escasso e não se recupera, por trabalho que qualquer executor faria, e o briefing que você economizou custa menos do que o contexto que você gastou.
|
|
63
63
|
- Ao delegar execução, descubra quem é o micro-agent em vez de presumir: `crow_micro.py probe` — com `codex` disponível e autenticado, os micro-agents são do codex; sem ele, são os do próprio CLI. Ao micro-agent vai só o braçal já especificado; mapear, planejar, desenhar e verificar no fim continuam seus. Leia [micro-agents](references/micro-agents.md).
|
|
64
64
|
- **Micro-agent ou bot autônomo: decida pela natureza da demanda, não por hábito.** Resultado que você especifica e aceita pela evidência, dentro do turno, é **micro-agent** — efêmero, você verifica e descarta. Demanda que persiste além do turno, dispara por evento e precisa de estado próprio entre execuções é **bot autônomo** — um processo que sobrevive à sessão, com memória externa própria e gatilho; ele não lembra, relê o que gravou e reconstrói o contexto a cada disparo. Micro-agent é o default; bot só quando a persistência ou o gatilho de evento é a essência da demanda, não um "seria bom ter". Bot e micro-agent usam igualmente o crow-broadcast e a mesma fronteira de autonomia desta skill; o criador continua responsável por ambos, verifica o resultado e age quando qualquer um fica ocioso.
|
|
65
|
+
- **O conhecimento do bot é seu para guardar, não dele.** A memória do bot é apartada e perecível: ela morre com a sessão e envelhece quando ele fica ocioso. O que ele sabe veio de você, no briefing — ele é consumidor do conhecimento, nunca o guardião. Então, antes de criar um bot de um papel que já existiu, **leia o que já foi acumulado** (`crow-skills bot knowledge --role-of <papel>`) e inclua no briefing o que valer, em vez de ensinar de novo o que já foi pago. Quando o bot reporta, **registre o que ele devolveu** (`crow-skills bot learned <id> "<o que apurou>"`): o retorno não registrado é conhecimento que ninguém mais tem. Esse registro é local e mecânico; **promover ao durável é seu**, pela memória do dono, porque o bot não toca o vault — só você toca. Destile, não acumule: nota que cresce a cada briefing recria, em outro lugar, o custo de contexto que a delegação existe para cortar. E trate o retorno como alegação a julgar antes de virar registro, não como verdade.
|
|
65
66
|
- Leia [adaptação ao runtime](references/orchestration.md) ao escolher workflow, subagentes ou execução sequencial. Não presuma nomes de ferramentas, paralelismo ou suporte a isolamento. Se o host exigir opt-in explícito do usuário para orquestração programável, esta skill não é esse opt-in: peça o opt-in da ferramenta, nunca permissão para aplicar esta skill, e sem ele siga com subagentes ou sequencialmente.
|
|
66
67
|
|
|
67
68
|
### 2. Planeje para produzir e verificar
|