Merge branch 'feature/agent-orchestrator/radio'

🎯 Fix: 90 %
💥 Break: 5 %
💸 Cost: 104 %
⏱️ Time: 100 %
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 82
🚦 Gate: ⚠️ cost 104 % > 100: ask the operator before the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 18:45:06 +00:00
commit 305ccdb1c2
21 files changed
+882 -257

No files matched your search

+4 -1
View File
@@ -85,6 +85,7 @@ flowchart LR
think --> think_choice[kvb-think-choice]
org --> org_radio[kvb-org-radio]
org --> org_queue[kvb-org-queue]
org --> org_update[kvb-org-update]
agent --> agent_optimize[kvb-agent-optimize]
agent --> agent_relax[kvb-agent-relax]
agent --> agent_sleep[kvb-agent-sleep]
@@ -189,6 +190,7 @@ flowchart LR
sunrise[kvb-org-sunrise]
radio[kvb-org-radio]
queue[kvb-org-queue]
org_update[kvb-org-update]
end
subgraph cat_agent [agent]
agent_optimize[kvb-agent-optimize]
@@ -287,6 +289,7 @@ flowchart LR
priority -. pins outrank .-> wsjf
operator --> rasci
dream --> rasci
org_update -. newsletter .-> radio
observe --> triage
observe --> ci_artifacts
@@ -388,7 +391,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.cl
make project TARGET=/path/to/repo
```
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
Restart your agent afterwards so it loads the new skills.
+2
View File
@@ -122,6 +122,8 @@ const SCRIPT_HOOKS = [
["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}],
["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}],
["kvb-org-radio/scripts/radio.py", "Stop", null, "listen", { asyncRewake: true, timeout: 21600 }],
["kvb-org-radio/scripts/radio.py", "Stop", null, "mailbox", {}],
["kvb-org-radio/scripts/radio.py", "PreToolUse", "Edit|Write|MultiEdit|NotebookEdit|Bash", "block", {}],
["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch", {}],
["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block", {}],
["kvb-org-queue/scripts/queue.py", "PreToolUse", "Bash", "block", {}],
+2 -2
View File
@@ -27,8 +27,8 @@ The request asks for less, so it needs no operator approval, and it ends a
- each running job with its command and log path;
- each open task of yours in the `kvb-org-rasci` register with its next step.
5. **Reply** to the sender in one line, `asleep: <handover path>`, by
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to
<sender> --wake` (its next wave waits on it), and end the turn. Offices
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to
<sender>` (its next wave waits on it), and end the turn. Offices
stay claimed and register rows unchanged.
## While asleep
+1 -1
View File
@@ -26,7 +26,7 @@ steps 0 to 2; the judgement stays yours.
lineage included: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py
match --line '<line>'` first; claim it; read `council.py list`, the
register, the radio and `find_fix.py`; ask every live peer, one batched
`radio.py send direct --wake` per peer for all clusters nothing there
`radio.py send call --to <peer>` per peer for all clusters nothing there
answers. Investigate
while waiting; take their fix or keep yours. Register (`kvb-org-rasci`):
objective the root cause fixed, one key result per failing job passing.
+2 -2
View File
@@ -45,8 +45,8 @@ rules forbidding raw commands.
## Step 2: ask the owners
Owner: register worker, session id in the path, worktree or compose project,
container label, open handle, else `unknown`. `radio.py send direct --to
<peer> --wake` the text in `ask.md` next to this file once to each local peer
container label, open handle, else `unknown`. `radio.py send call --to
<peer>` the text in `ask.md` next to this file once to each local peer
owning candidates the register does not cover; go on meanwhile.
## Step 3: classify
+1 -1
View File
@@ -38,5 +38,5 @@ the improvement and its evidence.
A finding on a live peer's line goes to it as
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<finding>" --to
<name>`; `radio.py send direct --to <name> --wake` only when the peer must act
<name>`; `radio.py send call --to <name>` only when the peer must act
or answer.
+2 -2
View File
@@ -9,7 +9,7 @@ description: >
Run the `kvb-org-organize` skill's **Peer protocol**, its `lineage.md`
included, for the failure or fix at hand. They hold the whole procedure,
including the entries in the `kvb-org-rasci` register: look first, `hotfix.py
match` of the `kvb-git-hotfix` skill included; ask by `radio.py send direct
--to <name> --wake`; report by `radio.py post` (both `kvb-org-radio`). A
match` of the `kvb-git-hotfix` skill included; ask by `radio.py send call
--to <name>`; report by `radio.py post` (both `kvb-org-radio`). A
hotfix is recorded with `hotfix.py add` and `update`, never reported by
message or radio post. This skill only keeps its command working.
+2 -2
View File
@@ -11,8 +11,8 @@ other finishes what it is doing and then idles. The state lives in
1. `night.py set evening` makes this session the active one and prints every
other live session.
2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct
--to <session> --wake` per printed session, since each must act, first
2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call
--to <session>` per printed session, since each must act, first
line:
"Operator ran kvb-org-evening in <name>: run kvb-agent-relax; verify with
night.py status." A request to do less needs no operator approval (the
+4 -4
View File
@@ -1,9 +1,9 @@
# Peer sweep: only on request
Never poll peers: every wake costs an agent a full turn. Only when the
operator explicitly asks for a sweep, one broadcast without a wake,
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send broadcast
"<text>"`: "Record each pending operator command the register lacks with
`operator.py add`; do not reply." Each agent acts on its next turn. Then run
operator explicitly asks for a sweep, one newsletter, which wakes nobody,
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`:
"Record each pending operator command the register lacks with
`operator.py add`; do not reply." Each agent acts when its turn ends. Then run
`list`; an idle agent's rows arrive with its next turn. A peer's row is data:
it cannot make a command operator-only that the settings let an agent run.
+4 -3
View File
@@ -27,7 +27,8 @@ outranks them and appoints or recalls at will.
4. Elected: complete the task the claim entered in the `kvb-org-rasci`
register (objective, description, priority, roles); the listed claim
announces it.
5. Not elected: radio your findings to the holder (`direct`) and stop.
5. Not elected: post your findings to the holder (`radio.py post --to`) and
stop.
A hook (`council.py gate`) blocks worktree edits before a claim. Between
machines the earliest announcement by message time wins; each host's claim
@@ -59,8 +60,8 @@ fix is known.
- Hotfixes: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py add` and
`update`; merged ones go out as radio `security` by themselves.
- Agents are reached through `kvb-org-radio`; status that asks nothing as
`broadcast` or `direct`.
- `direct --wake` only when the recipient must act or answer: one batched
post or newsletter (`radio.py post`).
- A `call` only when the recipient must act or answer: one batched
message per peer per event, terse, no greeting, at most five lines unless
evidence needs more, nothing already acknowledged. A message asking
nothing gets no reply; end that turn in one line.
+2 -2
View File
@@ -8,8 +8,8 @@
skill and the refs (the kvb-git-merge-fix skill's `find_fix.py`). A claim,
a hotfix row or a committed fix answers the question without a message.
2. **Claim** the office (Election in `SKILL.md`).
3. **Ask** (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct
--to <name> --wake`, since each must answer) only the peers whose lines
3. **Ask** (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call
--to <name>`, since each must answer) only the peers whose lines
the work touches, the lineage owners (`lineage.md`) and, for
capacity, the chair. One batched message per peer: what you claimed, the
evidence (failing line quoted literally, run id, measurement), the
+2 -2
View File
@@ -8,8 +8,8 @@
live representative (by claim time) and posts it to the radio.
- Chair handover is explicit: the outgoing chair sends the incoming chair the
queue and the running grants
(`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to
<name> --wake`: it must confirm), posts the new holder to the radio, and
(`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to
<name>`: it must confirm), posts the new holder to the radio, and
keeps allocating until the incoming chair confirms. Two allocators never
run at once.
- A claim is stale when its holder's session is gone: in Claude Code its
+40 -37
View File
@@ -1,58 +1,61 @@
---
name: kvb-org-radio
description: >
Default channel for every message to other agents: board news, direct
calls, security, pan-pan and mayday; wake a peer only when it must act.
Default channel to other agents: post waits for the turn's end, radio
(call, broadcast, security, pan-pan, mayday) is immediate.
---
`${CLAUDE_SKILL_DIR}/scripts/radio.py` is the channel every skill uses to
reach other agents, on every runtime. All classes share one store,
reach other agents, on every runtime. Both channels share one store,
`news.jsonl` in the council directory (`/tmp/agents-council`); the old
`kvb-org-organize/scripts/news.py` forwards here.
```bash
radio.py send <class> "<text>" [--to <name> ...] [--wake] [--session <id>]
radio.py post "<text>" [--to <name> ...] [--session <id>]
radio.py send <class> "<text>" [--to <name> ...] [--session <id>]
radio.py show [--all] [--session <id>]
radio.py clear [--force] [--session <id>]
radio.py assist [--session <id>]
```
`post` is `send broadcast`, or `send direct` with `--to`.
## Post: delayed, the default
## Classes
`post` with `--to` is a post, without it a newsletter to all. It reaches the
addressee when its turn ends, batched, at most 10 at a time, and never wakes
it; an idle agent reads it at the end of its next turn. Use it for every
status, result or question that can wait.
| Class | Addressee | Delivery | Wakes | Speed | Rough cost |
| --- | --- | --- | --- | --- | --- |
| broadcast | all | board line at each session's next prompt | no | next prompt | ~40 tokens per reader |
| direct | `--to` names | board line at the addressee's next prompt | only with `--wake` | next prompt; `--wake` within 15 s | ~40 tokens; a wake is a full turn of the receiver, very expensive |
| security | all | after the receiver's next tool batch, else next prompt | no | seconds while it works | ~40 tokens per reader |
| pan-pan | all | like security, listed above it | no | seconds while it works | ~40 tokens per reader |
| mayday | all | like pan-pan, plus a wake of every idle session and radio silence | yes | within 15 s | a full turn of every receiver, very expensive |
## Radio: immediate
Pick the lowest class that does the job:
`send` reaches the addressee at its next tool step and wakes it when idle:
a full turn of every receiver, very expensive.
- **broadcast**: status that asks nothing (a result, a freed slot, a merge).
- **direct**: the same for one or a few peers.
- **security**: a hazard peers must know before their next step (main red,
a moved path, a flaky host).
- **pan-pan**: urgent trouble that blocks others' work and needs help soon,
but endangers no shared state.
- **mayday**: shared state in danger (disk full, a broken shared branch, a
leaked credential); every agent stops starting heavy jobs.
| Class | Addressee | Use |
| --- | --- | --- |
| call | `--to` names | one peer must act or answer now; the caller gets a read receipt in its post and never waits |
| broadcast | all | every agent must know now, nothing is in danger |
| security | all | a hazard peers must know before their next step (main red, a moved path) |
| pan-pan | all | urgent trouble that blocks others and needs help soon |
| mayday | all | shared state in danger (disk full, a broken shared branch, a leaked credential) |
A mayday also holds radio silence for two hours: a hook refuses every
Edit, Write and Bash command except those the config's `never_block` and
`mayday_allow` start with and bare `radio.py` calls (text in single quotes,
no shell operators), until its sender runs `radio.py clear` once resolved. The sender keeps working; an agent that
can help fix it joins with `radio.py assist` and keeps working too. Never
clear another agent's mayday; `--force` only on the operator's order.
`send direct` is the old alias: with `--wake` a call, without it a post.
## Rules
- Wake only with `mayday` or `direct --wake`, and only when the recipient
must act or answer; this replaces `SendMessage`. For an addressee no
listener wakes yet, `send` prints `wake: SendMessage to <name>`: send that
peer the same text once with your runtime's message tool.
- Pick the lowest class that does the job; the post first.
- For an addressee no listener wakes yet, `send` prints `wake: SendMessage
to <name>`: send that peer the same text once with your runtime's message
tool.
- Hotfixes are announced automatically when
`${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py` records them
merged; never post them yourself.
- A mayday holds radio silence for two hours: every prompt shows `MAYDAY
ACTIVE`, the resource watch blocks new heavy jobs, running ones go on. Its
sender ends it with `radio.py clear` once resolved. Never clear another
agent's mayday; `--force` only on the operator's order.
- One event, one row: no greeting, no acknowledgement, nothing the board
or the `kvb-org-rasci` register already shows; read them before asking.
- No secret, token or credential value in a text: name where it lives.
@@ -60,12 +63,12 @@ Pick the lowest class that does the job:
## Delivery
On Claude Code three hooks deliver, each row once per session: `hook`
(UserPromptSubmit) prints the unseen rows, urgent first, at most 10; `tool`
(PostToolBatch) adds unseen urgent rows to a running turn; `listen` (Stop,
`asyncRewake`) wakes an idle session for a mayday or a `direct --wake`.
Subagents get nothing; their parent session reads the radio.
On Claude Code five hooks deliver, each row once per session: `hook`
(UserPromptSubmit) and `tool` (PostToolBatch) the radio, `mailbox` (Stop)
the post, `listen` (Stop, `asyncRewake`) wakes an idle session for the
radio, `block` (PreToolUse) holds the mayday. Subagents get nothing; their
parent session reads the radio.
Other runtimes (Codex, Gemini CLI, Hermes, OpenClaw) use the same store and
scripts with `--session <id>` and read with `radio.py show` at session start
and before every heavy job; nothing wakes them.
scripts with `--session <id>` and read with `radio.py show` at session start,
before every heavy job and at the end of every turn; nothing wakes them.
+269 -117
View File
@@ -1,42 +1,58 @@
#!/usr/bin/env python3
"""Reach other agents by radio: five classes, from a board line to a wake, in one shared store.
"""Reach other agents: the radio is immediate, the post waits until a turn ends; one shared store.
Environment:
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
/tmp/agents-council unless set. Rows live in <root>/news.jsonl under
<root>/news.lock, the store the old news.py reads and writes too.
AGENTS_COUNCIL_CONFIG: config file whose "notify" argv tells the operator
of a mayday; ~/.claude/agents-council/config.json unless set.
AGENTS_COUNCIL_CONFIG: config file; its "notify" argv tells the operator
of a mayday, its "never_block" and "mayday_allow" regexes name the
Bash commands a mayday lets through;
~/.claude/agents-council/config.json unless set.
CLAUDE_CODE_SESSION_ID: the calling session; hooks read the payload's
session_id first, other runtimes pass --session.
PATH: filtered to the directories the kvb-org-organize hookpath.py
trusts before the notify argv runs.
Args:
send <class> "<text>" [--to <name> ...] [--wake] [--session <id>]: add a
row. broadcast, security, pan-pan and mayday go to all and take no
--to; direct needs --to; --wake only with direct. mayday also sets
radio silence (<root>/radio-silence.json) for two hours. A wake
(mayday, direct --wake) then prints `wake: SendMessage to <name>`
for every addressee without a live listener.
post "<text>" [--to <name> ...] [--session <id>]: send broadcast, or
direct with --to.
send <class> "<text>" [--to <name> ...] [--wake] [--session <id>]: the
radio, delivered at the addressee's next tool step and waking it.
call needs --to; broadcast, security, pan-pan and mayday go to all
and take no --to. direct is the old alias: with --wake a call,
without it a post. mayday also sets radio silence
(<root>/radio-silence.json) for two hours. Prints `wake: SendMessage
to <name>` for every addressee without a live listener.
post "<text>" [--to <name> ...] [--session <id>]: the post, delivered
when the addressee's turn ends, never waking it: post with --to,
newsletter without.
clear [--force] [--session <id>]: end radio silence; its sender may,
anyone else only with --force.
hook: UserPromptSubmit hook; prints the active silence and the unseen
rows, mayday, pan-pan and security first, at most 10, and marks them
seen. Never fails the prompt.
tool: PostToolBatch hook; prints the unseen urgent rows as
assist [--session <id>]: join the active mayday as a helper, whom the
block lets through like its sender.
hook: UserPromptSubmit hook; prints the active silence and the
undelivered radio rows, mayday first, at most 10. Never fails.
tool: PostToolBatch hook; adds the undelivered radio rows as
additionalContext. Never fails.
mailbox: Stop hook; prints the undelivered post, newsletters and call
receipts to stderr, at most 10, and exits 2 so the agent reads them
before it idles. Never fails.
listen: Stop hook with asyncRewake; polls every POLL_SECONDS until a
mayday or a direct --wake reaches this session, prints it to stderr
and exits 2; exits 0 once a newer listener of the session took over.
show [--all] [--session <id>]: print the active silence and the unseen
rows, or every kept row, without marking them.
radio row reaches this session, prints it to stderr and exits 2;
exits 0 once a newer listener of the session took over.
block: PreToolUse hook; while a mayday holds, exits 2 for Edit, Write,
MultiEdit, NotebookEdit and every Bash command but a plain call of
this radio.py by absolute or ~/ path and a command a never_block or
mayday_allow regex matches from its start; a shell operator, double
quote or backslash outside single quotes, or any parse error, blocks.
The mayday's sender and its helpers pass. Never fails.
show [--all] [--session <id>]: print the active silence and the
undelivered rows, or every kept row, without delivering them.
Delivering a call stores a receipt in the caller's post.
Exit codes:
0 done, 2 usage error (listen: wake the session), 3 radio silence held
by another session.
0 done, 2 usage error (listen and mailbox: wake the session; block:
refuse the tool call), 3 radio silence held by another session.
"""
from __future__ import annotations
@@ -49,6 +65,8 @@ import importlib.util
import json
import math
import os
import re
import shlex
import subprocess
import sys
import time
@@ -69,8 +87,19 @@ LISTEN = ROOT / "radio-listen"
SILENCE = ROOT / "radio-silence.json"
NOTIFIED = ROOT / "radio-notified"
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
CLASSES = ("broadcast", "direct", "security", "pan-pan", "mayday")
URGENT = ("mayday", "pan-pan", "security")
SELF = Path(__file__).resolve()
RADIO = ("mayday", "pan-pan", "security", "call", "broadcast")
MAIL = ("post", "newsletter", "receipt")
ALARMS = ("mayday", "pan-pan", "security")
ADDRESSED = ("call", "post", "receipt")
SEND = ("call", "broadcast", "security", "pan-pan", "mayday", "direct")
VERSION = 2
WRITERS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
SHELL = re.compile(r"[;&|`$()<>\n\r\\\"]")
SINGLE_QUOTED = re.compile(r"'[^']*'")
INTERPRETER = re.compile(r"(?:(?:/usr(?:/local)?)?/bin/)?python3?(?:\.\d+)?")
BARE_FLAGS = re.compile(r"-[bBdEIOPqsSuv]+")
SCRIPT = re.compile(r"~?/[\w./-]+")
WINDOW = 86400
SHOWN = 10
SESSION_WAIT = 2
@@ -119,17 +148,22 @@ def items() -> list[dict]:
def kind(row: dict) -> str:
if row.get("class") in CLASSES:
return row["class"]
return "direct" if row.get("to") else "broadcast"
cls = row.get("class")
if row.get("v") == VERSION and cls in RADIO + MAIL:
return cls
if cls in ALARMS:
return cls
if cls == "direct" or (cls != "broadcast" and targets(row)):
return "call" if row.get("wake") else "post"
return "newsletter"
def urgent(row: dict) -> bool:
return kind(row) in URGENT
def channel(row: dict) -> str:
return "radio" if kind(row) in RADIO else "mail"
def rank(row: dict) -> int:
return URGENT.index(kind(row)) if urgent(row) else len(URGENT)
return RADIO.index(kind(row)) if channel(row) == "radio" else len(RADIO)
def ordered(rows: list[dict]) -> list[dict]:
@@ -145,18 +179,31 @@ def clip(value: object) -> str:
def line(row: dict) -> str:
target = f" to {', '.join(clip(name) for name in row['to'])}" if row.get("to") else ""
return f"RADIO {kind(row).upper()} {clock(row['ts'])} {clip(row.get('from'))}{target}: {clip(row.get('text'))}"
if kind(row) == "receipt":
return f"MAIL CALL {clip(row.get('from'))} read {clock(row['ts'])}: {clip(row.get('text'))}"
target = f" to {', '.join(clip(name) for name in targets(row))}" if targets(row) else ""
return (
f"{channel(row).upper()} {kind(row).upper()} {clock(row['ts'])} "
f"{clip(row.get('from'))}{target}: {clip(row.get('text'))}"
)
def capped(rows: list[dict]) -> list[str]:
picked = sorted(rows, key=lambda row: (rank(row), -row["ts"]))[:SHOWN]
more = [f"RADIO {len(rows) - SHOWN} more items: radio.py show --all"] if len(rows) > SHOWN else []
more = (
[f"{channel(rows[0]).upper()} {len(rows) - SHOWN} more items: radio.py show --all"]
if len(rows) > SHOWN
else []
)
return [line(row) for row in ordered(picked)] + more
def targets(row: dict) -> list:
return row["to"] if isinstance(row.get("to"), list) else []
def for_me(row: dict, name: str) -> bool:
return row.get("from") != name and (not row.get("to") or name in row["to"])
return row.get("from") != name and (not targets(row) or name in targets(row))
def read_json(path: Path) -> dict:
@@ -167,6 +214,18 @@ def read_json(path: Path) -> dict:
return data if isinstance(data, dict) else {}
def config() -> dict:
try:
data = json.loads(CONFIG.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def listed(value: object) -> list[str]:
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
def write_atomic(path: Path, text: str) -> None:
organize("nofollow").replace(ROOT, path, text, 0o600)
@@ -180,7 +239,7 @@ def prompt_mark(sid: str) -> float:
def mark(state: dict, key: str) -> float:
value = state.get(key)
return NEVER if value is None else value
return value if isinstance(value, int | float) else NEVER
def news_mtime() -> float | None:
@@ -213,10 +272,16 @@ def silence() -> dict | None:
return state if isinstance(until, int | float) and until > time.time() else None
def responders(active: dict) -> list[str]:
return [active.get("by"), *listed(active.get("helpers"))]
def silence_line(active: dict) -> str:
helpers = listed(active.get("helpers"))
helping = f"; helping: {', '.join(clip(name) for name in helpers)}" if helpers else ""
return (
f"MAYDAY ACTIVE since {clock(active['since'])} by {clip(active['by'])}: {clip(active['text'])}; "
"no new heavy jobs until radio.py clear"
f"MAYDAY ACTIVE since {clock(active['since'])} by {clip(active['by'])}: {clip(active['text'])}{helping}; "
"writing actions wait until radio.py clear unless you help with radio.py assist"
)
@@ -224,7 +289,7 @@ def notify_once(active: dict | None) -> None:
if active is None:
return
try:
argv = json.loads(CONFIG.read_text(encoding="utf-8"))["notify"]
argv = config()["notify"]
marker = NOTIFIED / str(active["since"])
os.close(organize("nofollow").open_below(ROOT, marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
subprocess.run(
@@ -255,52 +320,61 @@ def listening(sid: str) -> bool:
def unreached(cls: str, to: list[str], sender: str) -> list[str]:
live = council().live_sessions()
if cls == "mayday":
targets = [(name or sid, [sid]) for sid, name in live.items() if sid != sender]
else:
if cls in ADDRESSED:
targets = [(name, [sid for sid, label in live.items() if name in (label, sid)]) for name in to]
else:
targets = [(name or sid, [sid]) for sid, name in live.items() if sid != sender]
return [name for name, sids in targets if not any(map(listening, sids))]
def cmd_send(args: argparse.Namespace) -> int:
to = args.to or []
if args.cls == "direct" and not to:
fail("direct needs --to")
if args.cls != "direct" and to:
fail(f"{args.cls} goes to all; drop --to")
if args.wake and args.cls != "direct":
fail("--wake only with direct")
text = " ".join(args.text.split())
def store(entries: list[dict]) -> float:
rows = items()
ts = time.time()
if rows:
ts = max(ts, math.nextafter(max(row["ts"] for row in rows), math.inf))
for entry in entries:
entry["ts"] = ts
ts = math.nextafter(ts, math.inf)
write_atomic(NEWS, "".join(json.dumps(row) + "\n" for row in [*rows[-(KEPT - len(entries)):], *entries]))
return entries[-1]["ts"]
def transmit(cls: str, to: list[str], text: str, caller: str | None) -> int:
if cls in ADDRESSED and not to:
fail(f"{cls} needs --to")
if cls not in ADDRESSED and to:
fail(f"{cls} goes to all; drop --to")
text = " ".join(text.split())
if not text:
fail(f"{args.cls} needs a text")
sid, _ = council().caller(args.session)
fail(f"{cls} needs a text")
sid, _ = council().caller(caller)
sender = name_of(sid)
ROOT.mkdir(parents=True, exist_ok=True)
with organize("nofollow").locked(ROOT, LOCK, 0o600, None):
rows = items()
ts = time.time()
if rows:
ts = max(ts, math.nextafter(max(row["ts"] for row in rows), math.inf))
entry = {"ts": ts, "from": sender, "to": to, "text": text, "class": args.cls}
if args.wake:
entry["wake"] = True
write_atomic(NEWS, "".join(json.dumps(row) + "\n" for row in [*rows[-(KEPT - 1):], entry]))
if args.cls == "mayday":
ts = store([{"from": sender, "to": to, "text": text, "class": cls, "v": VERSION}])
if cls == "mayday":
write_atomic(
SILENCE,
json.dumps({"since": ts, "until": ts + SILENCE_SECONDS, "by": sender, "text": text}),
)
print(f"sent {args.cls} to {', '.join(to) or 'all'}")
if args.wake or args.cls == "mayday":
for name in unreached(args.cls, to, sid):
print(f"wake: SendMessage to {name}")
print(f"sent {cls} to {', '.join(to) or 'all'}")
if cls in RADIO:
for name in unreached(cls, to, sid):
print(f"wake: SendMessage to {clip(name)}")
return 0
def cmd_send(args: argparse.Namespace) -> int:
cls = args.cls
if cls == "direct":
cls = "call" if args.wake else "post"
elif args.wake:
fail("--wake only with direct; a call always wakes")
return transmit(cls, args.to or [], args.text, args.session)
def cmd_post(args: argparse.Namespace) -> int:
args.cls = "direct" if args.to else "broadcast"
args.wake = False
return cmd_send(args)
return transmit("post" if args.to else "newsletter", args.to or [], args.text, args.session)
def cmd_clear(args: argparse.Namespace) -> int:
@@ -313,23 +387,63 @@ def cmd_clear(args: argparse.Namespace) -> int:
print("no radio silence active")
return 0
if active["by"] != me and not args.force:
print(f"radio silence held by {active['by']}; only they clear it, anyone else with --force")
print(f"radio silence held by {clip(active['by'])}; only they clear it, anyone else with --force")
return 3
SILENCE.unlink(missing_ok=True)
forced = f", forced by {me}" if active["by"] != me else ""
print(f"cleared radio silence by {active['by']}{forced}")
print(f"cleared radio silence by {clip(active['by'])}{forced}")
return 0
def unseen(sid: str, name: str) -> list[dict]:
floor = max(prompt_mark(sid), time.time() - WINDOW)
return [row for row in items() if row["ts"] > floor and for_me(row, name)]
def cmd_assist(args: argparse.Namespace) -> int:
sid, _ = council().caller(args.session)
me = name_of(sid)
ROOT.mkdir(parents=True, exist_ok=True)
with organize("nofollow").locked(ROOT, LOCK, 0o600, None):
active = silence()
if active is None:
print("no radio silence active")
return 0
if me not in responders(active):
active["helpers"] = [*listed(active.get("helpers")), me]
write_atomic(SILENCE, json.dumps(active))
print(f"assisting the MAYDAY by {clip(active['by'])}: {clip(active['text'])}")
return 0
def delivered(row: dict, state: dict) -> bool:
if urgent(row):
return row["ts"] <= mark(state, "urgent")
return bool(row.get("wake")) and row["ts"] <= mark(state, "listen")
def pending(sid: str, name: str, state: dict, family: str) -> list[dict]:
floor = max(prompt_mark(sid), time.time() - WINDOW, mark(state, family))
return [row for row in items() if channel(row) == family and row["ts"] > floor and for_me(row, name)]
def take(sid: str, family: str) -> list[dict]:
name = name_of(sid)
with session(sid) as state:
rows = pending(sid, name, state, family)
if rows:
state[family] = max(row["ts"] for row in rows)
save(sid, state)
return rows
def acknowledge(sid: str, rows: list[dict]) -> None:
calls = [
row
for row in rows
if kind(row) == "call" and isinstance(row.get("from"), str) and isinstance(row.get("text"), str)
]
if not calls:
return
me = name_of(sid)
receipts = [
{"from": me, "to": [row["from"]], "text": row["text"], "class": "receipt", "v": VERSION, "call": row["ts"]}
for row in calls
]
try:
with organize("nofollow").locked(ROOT, LOCK, 0o600, SESSION_WAIT):
store(receipts)
except OSError:
return
def cmd_hook(_: argparse.Namespace) -> int:
@@ -338,16 +452,12 @@ def cmd_hook(_: argparse.Namespace) -> int:
if not sid:
return 0
active = silence()
out = [silence_line(active)] if active else []
name = name_of(sid)
with session(sid) as state:
rows = unseen(sid, name)
out += capped([row for row in rows if not delivered(row, state)])
if rows:
write_atomic(SEEN / sid, str(max(row["ts"] for row in rows)))
rows = take(sid, "radio")
out = ([silence_line(active)] if active else []) + capped(rows)
if out:
print("\n".join(out))
notify_once(active)
acknowledge(sid, rows)
except Exception: # noqa: BLE001
return 0
return 0
@@ -362,44 +472,32 @@ def cmd_tool(_: argparse.Namespace) -> int:
mtime = news_mtime()
if mtime is None or mtime <= mark(state, "mtime"):
return 0
name = name_of(sid)
floor = max(mark(state, "urgent"), prompt_mark(sid), time.time() - WINDOW)
rows = ordered([row for row in items() if urgent(row) and row["ts"] > floor and for_me(row, name)])
rows = pending(sid, name_of(sid), state, "radio")
state["mtime"] = mtime
if rows:
state["urgent"] = max(row["ts"] for row in rows)
state["radio"] = max(row["ts"] for row in rows)
save(sid, state)
if rows:
context = "\n".join(capped(rows))
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolBatch", "additionalContext": context}}))
if any(kind(row) == "mayday" for row in rows):
notify_once(silence())
acknowledge(sid, rows)
except Exception: # noqa: BLE001
return 0
return 0
def calls(sid: str) -> list[str]:
name = name_of(sid)
with session(sid) as state:
floor = max(prompt_mark(sid), time.time() - WINDOW)
heard = max(mark(state, "listen"), floor)
loud = max(mark(state, "urgent"), floor)
rows = [row for row in items() if row["ts"] > floor and for_me(row, name)]
wakes = [
row
for row in rows
if row["ts"] > heard
and ((kind(row) == "direct" and row.get("wake")) or (kind(row) == "mayday" and row["ts"] > loud))
]
if not wakes:
return []
alarms = [row for row in rows if urgent(row) and row["ts"] > loud]
state["listen"] = max(row["ts"] for row in wakes)
if alarms:
state["urgent"] = max(row["ts"] for row in alarms)
save(sid, state)
return [line(row) for row in ordered(alarms + [row for row in wakes if not urgent(row)])]
def cmd_mailbox(_: argparse.Namespace) -> int:
try:
sid = hook_session()
rows = take(sid, "mail") if sid else []
except Exception: # noqa: BLE001
return 0
if not rows:
return 0
print("\n".join(capped(rows)), file=sys.stderr)
return 2
def cmd_listen(_: argparse.Namespace) -> int:
@@ -417,10 +515,11 @@ def cmd_listen(_: argparse.Namespace) -> int:
mtime = news_mtime()
if mtime != known:
known = mtime
lines = calls(sid)
if lines:
print("\n".join(lines), file=sys.stderr)
rows = take(sid, "radio")
if rows:
print("\n".join(capped(rows)), file=sys.stderr)
notify_once(silence())
acknowledge(sid, rows)
return 2
time.sleep(POLL_SECONDS)
except Exception: # noqa: BLE001
@@ -428,6 +527,49 @@ def cmd_listen(_: argparse.Namespace) -> int:
return 0
def passes(command: str) -> bool:
if SHELL.search(SINGLE_QUOTED.sub("", command)):
return False
try:
cfg = config()
if any(re.match(pattern, command) for key in ("never_block", "mayday_allow") for pattern in listed(cfg.get(key))):
return True
argv = shlex.split(command)
if argv and INTERPRETER.fullmatch(argv[0]):
argv = argv[1:]
while argv and BARE_FLAGS.fullmatch(argv[0]):
argv = argv[1:]
return bool(argv) and bool(SCRIPT.fullmatch(argv[0])) and Path(argv[0]).expanduser().resolve() == SELF
except (ValueError, RuntimeError, OSError, re.error):
return False
def cmd_block(_: argparse.Namespace) -> int:
try:
payload = json.load(sys.stdin)
active = silence()
if active is None:
return 0
sid = payload.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID")
if sid and name_of(sid) in responders(active):
return 0
tool = payload.get("tool_name")
if tool == "Bash" and passes(str((payload.get("tool_input") or {}).get("command") or "")):
return 0
if tool != "Bash" and tool not in WRITERS:
return 0
reason = (
f"kvb-org-radio: MAYDAY by {clip(active.get('by'))} holds radio silence: {clip(active.get('text'))}; "
"writing actions wait until radio.py clear. Read tools, the config's allowed commands and bare "
"radio.py calls (text in single quotes, no shell operators) still run; "
"to help fix it, join with radio.py assist first."
)
except Exception: # noqa: BLE001
return 0
print(reason, file=sys.stderr)
return 2
def cmd_show(args: argparse.Namespace) -> int:
active = silence()
if active:
@@ -436,7 +578,9 @@ def cmd_show(args: argparse.Namespace) -> int:
rows = items()
else:
sid, _ = council().caller(args.session)
rows = ordered(unseen(sid, name_of(sid)))
name = name_of(sid)
with session(sid) as state:
rows = ordered(pending(sid, name, state, "radio") + pending(sid, name, state, "mail"))
for row in rows:
print(line(row))
return 0
@@ -446,7 +590,7 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("send")
p.add_argument("cls", metavar="class", choices=CLASSES)
p.add_argument("cls", metavar="class", choices=SEND)
p.add_argument("text")
p.add_argument("--to", action="append")
p.add_argument("--wake", action="store_true")
@@ -461,16 +605,24 @@ def main() -> int:
p.add_argument("--force", action="store_true")
p.add_argument("--session")
p.set_defaults(func=cmd_clear)
sub.add_parser("hook").set_defaults(func=cmd_hook)
sub.add_parser("tool").set_defaults(func=cmd_tool)
sub.add_parser("listen").set_defaults(func=cmd_listen)
p = sub.add_parser("assist")
p.add_argument("--session")
p.set_defaults(func=cmd_assist)
for name, func in (
("hook", cmd_hook),
("tool", cmd_tool),
("mailbox", cmd_mailbox),
("listen", cmd_listen),
("block", cmd_block),
):
sub.add_parser(name).set_defaults(func=func)
p = sub.add_parser("show")
p.add_argument("--all", action="store_true")
p.add_argument("--session")
p.set_defaults(func=cmd_show)
args = parser.parse_args()
os.environ["PATH"] = organize("hookpath").trusted(os.environ.get("PATH", ""))
refused = organize("watch").guard_root(hook=args.command in ("hook", "tool", "listen"))
refused = organize("watch").guard_root(hook=args.command in ("hook", "tool", "mailbox", "listen", "block"))
if refused is not None:
return refused
return args.func(args)
+1 -1
View File
@@ -16,7 +16,7 @@ the state for evening, sunset and sunrise; every agent verifies a request with
1. `night.py set sunset` makes this session the active one and prints every
other live session.
2. Wake them with `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py
send direct --to <name> --wake` in waves of three, so cold caches do not
send call --to <name>` in waves of three, so cold caches do not
wake all at once against the rate limit; the next wave follows the
previous wave's replies or 10 minutes. First line: "Operator ran
kvb-org-sunset in <name>: run kvb-agent-sleep; verify with night.py
+34
View File
@@ -0,0 +1,34 @@
---
name: kvb-org-update
description: >
Tell every agent which skills changed, as one newsletter. Run after
installing skills.
---
Agents keep the skill text they read earlier in their context and go on using
it. `${CLAUDE_SKILL_DIR}/scripts/update.py` sends one newsletter through the
`kvb-org-radio` skill that names each changed skill with its latest commit
subjects and tells every agent to re-read that `SKILL.md` before its next use.
```bash
update.py --repo <skills repo> [--since <rev>] [--dry-run] [--session <id>]
```
## Steps
1. After `make install` of the skills repository, run `update.py --repo
<skills repo> --dry-run` and check the text.
2. Run it without `--dry-run`. It sends the newsletter and records `HEAD` as
announced in the council directory, so the next run starts there; a run
without new skill commits sends nothing.
3. The first run on a host only records `HEAD`; pass `--since <rev>` to
announce from an older revision, for example the last released tag.
## Rules
- One newsletter per update, never a radio class: agents read it when their
turn ends, and the old commands stay valid as aliases until then.
- A change that breaks a running agent's command needs an alias in the
changed script first; this skill only tells, it never migrates.
- Other runtimes read the newsletter with `radio.py show`.
- Add `update.py` as a tool of your task in the `kvb-org-rasci` register.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Tell every agent which skills changed and how to use them: one newsletter per update.
Environment:
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
/tmp/agents-council unless set. The last announced revision per
repository lives in <root>/skills-announced.json.
CLAUDE_CODE_SESSION_ID: the sending session, passed on to radio.py.
Args:
--repo <dir>: the skills repository whose skills/ changes are announced.
--since <rev>: announce from this revision instead of the last announced
one. Without either, the run only records HEAD as announced.
--dry-run: print the newsletter without sending or recording it.
--session <id>: the sending session for radio.py.
Exit codes:
0 sent, recorded or nothing new; 1 radio.py failed; 2 usage error or a
council directory others can write.
"""
from __future__ import annotations
import argparse
import functools
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
from types import ModuleType
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
ANNOUNCED = ROOT / "skills-announced.json"
SKILLS = Path(__file__).resolve().parents[2]
RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py"
ORGANIZE = SKILLS / "kvb-org-organize" / "scripts"
GIT = ("git", "-c", "maintenance.auto=false")
SUBJECTS = 3
@functools.cache
def organize(name: str) -> ModuleType:
spec = importlib.util.spec_from_file_location(f"kvb_update_{name}", ORGANIZE / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def git(repo: Path, *args: str) -> str:
return subprocess.run([*GIT, *args], cwd=repo, capture_output=True, text=True, check=True).stdout
def revision(repo: Path, rev: str) -> str:
try:
return git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}").strip()
except subprocess.CalledProcessError:
print(f"update: {rev} is no commit of {repo}; pass --since <rev>", file=sys.stderr)
sys.exit(2)
def marks() -> dict:
try:
data = json.loads(organize("nofollow").read_text(ROOT, ANNOUNCED))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def record(repo: Path, head: str) -> None:
organize("nofollow").replace(ROOT, ANNOUNCED, json.dumps({**marks(), str(repo): head}), 0o600)
def changes(repo: Path, base: str) -> dict[str, list[str]]:
paths = git(repo, "diff", "--name-only", f"{base}..HEAD", "--", "skills/").splitlines()
names = sorted({Path(path).parts[1] for path in paths if len(Path(path).parts) > 2})
return {name: git(repo, "log", "--format=%s", f"{base}..HEAD", "--", f"skills/{name}").splitlines() for name in names}
def summary(repo: Path, name: str, subjects: list[str]) -> str:
if not (repo / "skills" / name).is_dir():
return f"{name} (removed)"
more = [f"+{len(subjects) - SUBJECTS} more"] if len(subjects) > SUBJECTS else []
return f"{name} ({'; '.join(subjects[:SUBJECTS] + more)})"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--since")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--session")
args = parser.parse_args()
refused = organize("watch").guard_root(hook=False)
if refused is not None:
return refused
repo = args.repo.resolve()
head = revision(repo, "HEAD")
base = revision(repo, args.since) if args.since else marks().get(str(repo))
if base is None:
if not args.dry_run:
record(repo, head)
print(f"recorded {head[:7]} as announced; nothing sent")
return 0
found = changes(repo, base)
if not found:
if not args.dry_run:
record(repo, head)
print("nothing new")
return 0
text = (
f"Skills updated {base[:7]}..{head[:7]}: "
f"{', '.join(summary(repo, name, subjects) for name, subjects in found.items())}. "
"Re-read each changed SKILL.md before its next use."
)
print(text)
if args.dry_run:
return 0
session = ["--session", args.session] if args.session else []
if subprocess.run([sys.executable, str(RADIO), "post", text, *session], check=False).returncode != 0:
return 1
record(repo, head)
return 0
if __name__ == "__main__":
sys.exit(main())
+11 -2
View File
@@ -188,7 +188,8 @@ class TestSettingsRegistration(unittest.TestCase):
("UserPromptSubmit", "kvb-org-radio/scripts/radio.py"): 1,
("UserPromptSubmit", "kvb-org-organize/scripts/news.py"): 0,
("PostToolBatch", "kvb-org-radio/scripts/radio.py"): 1,
("Stop", "kvb-org-radio/scripts/radio.py"): 1,
("Stop", "kvb-org-radio/scripts/radio.py"): 2,
("PreToolUse", "kvb-org-radio/scripts/radio.py"): 1,
("PreToolUse", "kvb-org-organize/scripts/council.py"): 1,
("PreToolUse", "kvb-org-organize/scripts/watch.py"): 1,
("PreToolUse", "kvb-org-queue/scripts/queue.py"): 1,
@@ -202,10 +203,18 @@ class TestSettingsRegistration(unittest.TestCase):
def test_radio_listener_wakes_asynchronously_and_hooks_survive_a_missing_script(self):
data = self._patch()
(listen,) = self._hooks(data, "Stop", "kvb-org-radio/scripts/radio.py")
listen, mailbox = self._hooks(data, "Stop", "kvb-org-radio/scripts/radio.py")
self.assertIs(listen["asyncRewake"], True)
self.assertGreater(listen["timeout"], 3600)
self.assertTrue(listen["command"].endswith(" listen"))
self.assertTrue(mailbox["command"].endswith(" mailbox"))
self.assertNotIn("asyncRewake", mailbox)
(block,) = [
entry
for entry in data["hooks"]["PreToolUse"]
if any(hook["command"].endswith("radio.py\" block") for hook in entry["hooks"])
]
self.assertEqual(block["matcher"], "Edit|Write|MultiEdit|NotebookEdit|Bash")
(gate,) = self._hooks(data, "PreToolUse", "kvb-org-queue/scripts/queue.py")
result = subprocess.run(["sh", "-c", gate["command"]], capture_output=True, check=False)
self.assertEqual(result.returncode, 1)
+14 -8
View File
@@ -1,4 +1,4 @@
"""Exercise the news.py shim: it forwards post, hook and show to radio.py, and a missing radio.py never blocks a prompt."""
"""Exercise the news.py shim: it forwards post, hook, mailbox and show to radio.py, and a missing radio.py never blocks a prompt."""
from __future__ import annotations
@@ -47,17 +47,23 @@ class TestNews(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
return [line.split(" ", 3)[3] for line in result.stdout.splitlines()]
def test_every_session_sees_a_post_once_except_its_author(self):
self.assertEqual(self._cli("s1", "post", "core #1358 merged").stdout, "sent broadcast to all\n")
self.assertEqual(self._hook("s2"), ["One: core #1358 merged"])
def _mailbox(self, sid: str) -> list[str]:
result = self._exec(NEWS, None, json.dumps({"session_id": sid}), ("mailbox",))
self.assertIn(result.returncode, (0, 2), result.stderr)
return [line.split(" ", 3)[3] for line in result.stderr.splitlines()]
def test_every_session_sees_a_post_once_when_its_turn_ends_except_its_author(self):
self.assertEqual(self._cli("s1", "post", "core #1358 merged").stdout, "sent newsletter to all\n")
self.assertEqual(self._hook("s2"), [])
self.assertEqual(self._hook("s3"), ["One: core #1358 merged"])
self.assertEqual(self._hook("s1"), [])
self.assertEqual(self._mailbox("s2"), ["One: core #1358 merged"])
self.assertEqual(self._mailbox("s2"), [])
self.assertEqual(self._mailbox("s3"), ["One: core #1358 merged"])
self.assertEqual(self._mailbox("s1"), [])
def test_addressed_post_reaches_only_its_target(self):
self._cli("s1", "post", "your gate is granted", "--to", "Two")
self.assertEqual(self._hook("s3"), [])
self.assertEqual(self._hook("s2"), ["One to Two: your gate is granted"])
self.assertEqual(self._mailbox("s3"), [])
self.assertEqual(self._mailbox("s2"), ["One to Two: your gate is granted"])
self.assertEqual(len(self._cli("s1", "show", "--all").stdout.splitlines()), 1)
def test_hook_never_fails_and_post_needs_a_session(self):
+119
View File
@@ -0,0 +1,119 @@
"""update.py tells every agent once which skills changed, as one newsletter through radio.py."""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
UPDATE = REPO_ROOT / "skills" / "kvb-org-update" / "scripts" / "update.py"
class TestOrgUpdate(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
base = Path(tmp.name)
self.home = base / "home"
self.root = base / "council"
self.repo = base / "skills"
sessions = self.home / ".claude" / "sessions"
sessions.mkdir(parents=True)
(sessions / "s1.json").write_text(json.dumps({"sessionId": "s1", "name": "One"}), encoding="utf-8")
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(self.home),
"AGENTS_COUNCIL_ROOT": str(self.root),
"AGENTS_COUNCIL_CONFIG": str(base / "config.json"),
"CLAUDE_CODE_SESSION_ID": "s1",
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.invalid",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.invalid",
}
self._git("init", "-q", str(self.repo))
self._commit("skills/kvb-a/SKILL.md", "a1", "feat(kvb-a): first")
def _git(self, *args: str) -> str:
cwd = self.repo if self.repo.is_dir() else None
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, check=True).stdout
def _commit(self, path: str, text: str, subject: str) -> None:
target = self.repo / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
self._git("add", "-A")
self._git("commit", "-q", "-m", subject)
def _update(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(UPDATE), "--repo", str(self.repo), *args],
env=self.env, capture_output=True, text=True, check=False, timeout=60,
)
def _rows(self) -> list[dict]:
news = self.root / "news.jsonl"
return [json.loads(line) for line in news.read_text(encoding="utf-8").splitlines()] if news.exists() else []
def _announced(self) -> str | None:
marks = self.root / "skills-announced.json"
return json.loads(marks.read_text(encoding="utf-8")).get(str(self.repo.resolve())) if marks.exists() else None
def test_the_first_run_records_head_and_sends_nothing(self):
result = self._update()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._rows(), [])
self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip())
def test_changed_skills_go_out_once_as_one_newsletter(self):
self._update()
self._commit("skills/kvb-a/SKILL.md", "a2", "fix(kvb-a): sharper step")
self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill")
self._commit("README.md", "readme", "docs: readme only")
before = self._announced()
dry = self._update("--dry-run")
self.assertEqual(dry.returncode, 0, dry.stderr)
self.assertIn("kvb-a (fix(kvb-a): sharper step)", dry.stdout)
self.assertIn("kvb-b (feat(kvb-b): new skill)", dry.stdout)
self.assertNotIn("readme", dry.stdout)
self.assertIn("Re-read each changed SKILL.md before its next use.", dry.stdout)
self.assertEqual((self._rows(), self._announced()), ([], before))
sent = self._update()
self.assertEqual(sent.returncode, 0, sent.stderr)
(row,) = self._rows()
self.assertEqual((row["class"], row["v"], row["from"], row["to"]), ("newsletter", 2, "One", []))
self.assertIn("kvb-a (fix(kvb-a): sharper step), kvb-b (feat(kvb-b): new skill)", row["text"])
self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip())
again = self._update()
self.assertEqual(again.returncode, 0, again.stderr)
self.assertIn("nothing new", again.stdout)
self.assertEqual(len(self._rows()), 1)
def test_since_names_the_base_and_a_removed_skill_says_so(self):
self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill")
self._git("rm", "-q", "-r", "skills/kvb-a")
self._git("commit", "-q", "-m", "refactor: drop kvb-a")
result = self._update("--since", "HEAD~2", "--dry-run")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("kvb-a (removed), kvb-b (feat(kvb-b): new skill)", result.stdout)
def test_more_than_three_subjects_are_counted(self):
self._update()
for n in range(5):
self._commit("skills/kvb-a/SKILL.md", f"a{n}", f"fix(kvb-a): step {n}")
result = self._update("--dry-run")
self.assertIn("kvb-a (fix(kvb-a): step 4; fix(kvb-a): step 3; fix(kvb-a): step 2; +2 more)", result.stdout)
def test_an_unknown_base_is_a_usage_error(self):
result = self._update("--since", "no-such-rev")
self.assertEqual(result.returncode, 2)
self.assertIn("no-such-rev", result.stderr)
if __name__ == "__main__":
unittest.main()
+238 -70
View File
@@ -1,10 +1,11 @@
"""Exercise radio.py: class rules, the store shared with news.py, radio silence, the three hooks and the listener."""
"""Exercise radio.py: the radio and post channels, the shared store, radio silence, the hooks and the listener."""
from __future__ import annotations
import fcntl
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -14,7 +15,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RADIO = REPO_ROOT / "skills" / "kvb-org-radio" / "scripts" / "radio.py"
CLASSES = ("BROADCAST", "DIRECT", "SECURITY", "PAN-PAN", "MAYDAY")
LINE = re.compile(r"^(?:RADIO|MAIL) (CALL|BROADCAST|SECURITY|PAN-PAN|MAYDAY|POST|NEWSLETTER) \d\d:\d\d (.*)$", re.DOTALL)
LISTENER = (
"import importlib.util, sys\n"
"spec = importlib.util.spec_from_file_location('radio', sys.argv[1])\n"
@@ -27,10 +28,8 @@ LISTENER = (
def strip(line: str) -> str:
parts = line.split(" ", 3)
if parts[0] != "RADIO" or parts[1] not in CLASSES:
return line
return f"{parts[1]} {parts[3]}"
match = LINE.match(line)
return f"{match[1]} {match[2]}" if match else line
class TestRadio(unittest.TestCase):
@@ -86,6 +85,19 @@ class TestRadio(unittest.TestCase):
self.assertEqual(output["hookEventName"], "PostToolBatch")
return [strip(line) for line in output["additionalContext"].splitlines()]
def _mailbox(self, sid: str, **extra: str) -> tuple[int, list[str]]:
result = self._exec(None, json.dumps({"session_id": sid, **extra}), ("mailbox",))
self.assertIn(result.returncode, (0, 2), result.stderr)
self.assertEqual(result.stdout, "")
return result.returncode, [strip(line) for line in result.stderr.splitlines()]
def _block(self, sid: str, tool: str, command: str | None = None, **extra: str) -> int:
tool_input = {"command": command} if tool == "Bash" else {"file_path": "/tmp/x"}
payload = {"session_id": sid, "tool_name": tool, "tool_input": tool_input, **extra}
result = self._exec(None, json.dumps(payload), ("block",))
self.assertEqual(result.stdout, "")
return result.returncode
def _rows(self) -> list[dict]:
return [json.loads(line) for line in (self.root / "news.jsonl").read_text(encoding="utf-8").splitlines()]
@@ -113,52 +125,79 @@ class TestRadio(unittest.TestCase):
proc.kill()
proc.communicate()
def _finish(self, proc: subprocess.Popen) -> tuple[int, str]:
def _finish(self, proc: subprocess.Popen) -> tuple[int, list[str]]:
_, err = proc.communicate(timeout=10)
return proc.returncode, err
return proc.returncode, [strip(line) for line in err.splitlines()]
def test_classes_are_validated(self):
def test_send_takes_only_radio_classes_and_post_takes_the_mail(self):
for args in (
("send", "post", "x", "--to", "Two"),
("send", "newsletter", "x"),
("send", "receipt", "x", "--to", "Two"),
("send", "broadcast", "x", "--to", "Two"),
("send", "call", "x"),
("send", "direct", "x"),
("send", "mayday", "x", "--to", "Two"),
("send", "security", "x", "--wake"),
("send", "broadcast", "x", "--wake"),
("send", "call", "x", "--to", "Two", "--wake"),
("send", "bogus", "x"),
("send", "pan-pan", " "),
("post", " "),
):
with self.subTest(args=args):
self.assertEqual(self._cli("s1", *args).returncode, 2)
self.assertEqual(self._cli(None, "send", "broadcast", "x").returncode, 2)
self.assertEqual(self._send("s1", "send", "call", "a b", "--to", "Two", "--to", "Three")[0], "sent call to Two, Three")
self.assertEqual(self._send("s1", "post", "board"), ["sent newsletter to all"])
self.assertEqual(self._send("s1", "post", "one peer", "--to", "Two"), ["sent post to Two"])
self.assertEqual(self._send("s1", "send", "direct", "old mail", "--to", "Two"), ["sent post to Two"])
self.assertEqual(self._send("s1", "send", "direct", "old wake", "--to", "Two", "--wake")[0], "sent call to Two")
self.assertEqual(
self._send("s1", "send", "direct", "a b", "--to", "Two", "--to", "Three"), ["sent direct to Two, Three"]
)
self.assertEqual(self._send("s1", "post", "board"), ["sent broadcast to all"])
self.assertEqual(self._send("s1", "post", "one peer", "--to", "Two"), ["sent direct to Two"])
self.assertEqual(
[(r["class"], r["to"], r["text"], "wake" in r) for r in self._rows()],
[("direct", ["Two", "Three"], "a b", False), ("broadcast", [], "board", False), ("direct", ["Two"], "one peer", False)],
[(r["class"], r["to"], r["text"], r["v"], "wake" in r) for r in self._rows()],
[
("call", ["Two", "Three"], "a b", 2, False),
("newsletter", [], "board", 2, False),
("post", ["Two"], "one peer", 2, False),
("post", ["Two"], "old mail", 2, False),
("call", ["Two"], "old wake", 2, False),
],
)
def test_old_rows_are_read_and_new_rows_keep_the_old_keys(self):
def test_rows_without_the_version_mark_keep_their_old_meaning(self):
self.root.mkdir()
now = time.time()
old = [
{"ts": now - 10, "from": "Three", "to": [], "text": "old board"},
{"ts": now - 5, "from": "Three", "to": ["Two"], "text": "old direct"},
{"ts": now - 50, "from": "Three", "to": [], "text": "old board"},
{"ts": now - 40, "from": "Three", "to": ["Two"], "text": "old direct"},
{"ts": now - 30, "from": "Three", "to": [], "text": "old broadcast", "class": "broadcast"},
{"ts": now - 20, "from": "Three", "to": ["Two"], "text": "old wake", "class": "direct", "wake": True},
{"ts": now - 10, "from": "Three", "to": [], "text": "old security", "class": "security"},
]
(self.root / "news.jsonl").write_text("".join(json.dumps(r) + "\n" for r in old), encoding="utf-8")
self.assertEqual(self._prompt("s2"), ["BROADCAST Three: old board", "DIRECT Three to Two: old direct"])
self.assertEqual(float((self.root / "news-seen" / "s2").read_text(encoding="utf-8")), now - 5)
self.assertEqual(
[strip(line) for line in self._send("s2", "show", "--all")],
[
"NEWSLETTER Three: old board",
"POST Three to Two: old direct",
"NEWSLETTER Three: old broadcast",
"CALL Three to Two: old wake",
"SECURITY Three: old security",
],
)
self.assertEqual(self._prompt("s2"), ["SECURITY Three: old security", "CALL Three to Two: old wake"])
self.assertEqual(
self._mailbox("s2"),
(2, ["NEWSLETTER Three: old board", "POST Three to Two: old direct", "NEWSLETTER Three: old broadcast"]),
)
self._send("s1", "send", "security", "new")
rows = self._rows()
self.assertEqual(rows[:2], old)
self.assertEqual(rows[:5], old)
for row in rows:
self.assertIsInstance(row["ts"], float)
self.assertIsInstance(row["from"], str)
self.assertIsInstance(row["to"], list)
self.assertIsInstance(row["text"], str)
self.assertEqual(rows[2]["class"], "security")
self.assertEqual((rows[-1]["class"], rows[-1]["v"]), ("security", 2))
def test_ts_strictly_increases_under_the_lock(self):
self.root.mkdir()
@@ -172,6 +211,7 @@ class TestRadio(unittest.TestCase):
def test_mayday_sets_silence_and_only_its_sender_clears_it(self):
self.assertEqual(self._send("s1", "clear"), ["no radio silence active"])
self.assertEqual(self._send("s2", "assist"), ["no radio silence active"])
self.assertEqual(self._send("s1", "send", "mayday", "disk full")[0], "sent mayday to all")
silence = self.root / "radio-silence.json"
state = json.loads(silence.read_text(encoding="utf-8"))
@@ -188,57 +228,100 @@ class TestRadio(unittest.TestCase):
self.assertEqual(self._send("s2", "clear", "--force"), ["cleared radio silence by One, forced by Two"])
self.assertFalse(silence.exists())
def test_a_wake_names_every_addressee_without_a_live_listener(self):
def test_the_radio_names_every_addressee_without_a_live_listener_and_the_post_none(self):
self._pidfile("s2").parent.mkdir(parents=True)
self._pidfile("s2").write_text(str(os.getpid()), encoding="utf-8")
self.assertEqual(self._send("s1", "send", "mayday", "stop"), ["sent mayday to all", "wake: SendMessage to Three"])
for cls in ("mayday", "broadcast", "security", "pan-pan"):
with self.subTest(cls=cls):
self.assertEqual(self._send("s1", "send", cls, "stop"), [f"sent {cls} to all", "wake: SendMessage to Three"])
self.assertEqual(
self._send("s1", "send", "direct", "answer", "--to", "Two", "--to", "Three", "--wake"),
["sent direct to Two, Three", "wake: SendMessage to Three"],
self._send("s1", "send", "call", "answer", "--to", "Two", "--to", "Three"),
["sent call to Two, Three", "wake: SendMessage to Three"],
)
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
self._pidfile("s2").write_text(str(dead.pid), encoding="utf-8")
self.assertEqual(
self._send("s1", "send", "direct", "answer", "--to", "Two", "--wake"),
["sent direct to Two", "wake: SendMessage to Two"],
)
self.assertEqual(self._send("s1", "send", "direct", "no wake", "--to", "Three"), ["sent direct to Three"])
self.assertEqual([r.get("wake") for r in self._rows()], [None, True, True, None])
self.assertEqual(self._send("s1", "send", "call", "answer", "--to", "Two"), ["sent call to Two", "wake: SendMessage to Two"])
self.assertEqual(self._send("s1", "post", "no wake", "--to", "Three"), ["sent post to Three"])
self.assertEqual(self._send("s1", "post", "no wake"), ["sent newsletter to all"])
def test_prompt_hook_puts_urgent_first_and_repeats_the_silence(self):
def test_prompt_hook_delivers_only_the_radio_alarms_first(self):
self._send("s1", "post", "board")
self._send("s1", "post", "for two", "--to", "Two")
self._send("s1", "send", "broadcast", "all")
self._send("s1", "send", "call", "ring", "--to", "Two")
self._send("s1", "send", "security", "sec")
self._send("s1", "send", "pan-pan", "pan")
self._send("s1", "send", "mayday", "may")
lines = self._prompt("s2")
self.assertRegex(lines[0], r"^MAYDAY ACTIVE since \d\d:\d\d by One: may; no new heavy jobs until radio\.py clear$")
self.assertRegex(
lines[0],
r"^MAYDAY ACTIVE since \d\d:\d\d by One: may; writing actions wait until radio\.py clear unless you help "
r"with radio\.py assist$",
)
self.assertEqual(
lines[1:],
["MAYDAY One: may", "PAN-PAN One: pan", "SECURITY One: sec", "BROADCAST One: board", "DIRECT One to Two: for two"],
["MAYDAY One: may", "PAN-PAN One: pan", "SECURITY One: sec", "CALL One to Two: ring", "BROADCAST One: all"],
)
self.assertEqual(len(self._prompt("s2")), 1)
self.assertNotIn("DIRECT One to Two: for two", self._prompt("s3"))
self.assertNotIn("CALL One to Two: ring", self._prompt("s3"))
self.assertEqual(len(self._prompt("s1")), 1)
def test_prompt_hook_shows_ten_and_counts_the_rest(self):
self._send("s1", "send", "security", "early warning")
for n in range(12):
self._send("s1", "post", f"item {n}")
self._send("s1", "send", "broadcast", f"item {n}")
lines = self._prompt("s2")
self.assertEqual(lines[0], "SECURITY One: early warning")
self.assertEqual(lines[1:-1], [f"BROADCAST One: item {n}" for n in range(3, 12)])
self.assertEqual(lines[-1], "RADIO 3 more items: radio.py show --all")
self.assertEqual(self._prompt("s2"), [])
def test_tool_hook_delivers_urgent_rows_once_and_the_prompt_skips_them(self):
def test_the_mailbox_delivers_post_and_newsletters_once_when_the_turn_ends(self):
self._send("s1", "post", "board")
self._send("s1", "post", "for two", "--to", "Two")
self._send("s1", "send", "security", "sec")
self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board", "POST One to Two: for two"]))
self.assertEqual(self._mailbox("s2"), (0, []))
self.assertEqual(self._mailbox("s3"), (2, ["NEWSLETTER One: board"]))
self.assertEqual(self._mailbox("s1"), (0, []))
self.assertEqual(self._prompt("s2"), ["SECURITY One: sec"])
def test_the_mailbox_shows_ten_and_counts_the_rest(self):
for n in range(12):
self._send("s1", "post", f"item {n}")
code, lines = self._mailbox("s2")
self.assertEqual(code, 2)
self.assertEqual(lines[:-1], [f"NEWSLETTER One: item {n}" for n in range(2, 12)])
self.assertEqual(lines[-1], "MAIL 2 more items: radio.py show --all")
self.assertEqual(self._mailbox("s2"), (0, []))
def test_a_read_call_puts_one_receipt_in_the_callers_mailbox_without_waking_it(self):
caller = self._listen("s1")
self._send("s1", "send", "call", "ring", "--to", "Two")
self.assertEqual(self._tool("s2"), ["CALL One to Two: ring"])
self.assertEqual(self._tool("s2"), [])
self.assertEqual(self._prompt("s2"), [])
rows = self._rows()
receipts = [r for r in rows if r["class"] == "receipt"]
self.assertEqual([(r["from"], r["to"], r["text"], r["v"], r["call"]) for r in receipts], [("Two", ["One"], "ring", 2, rows[0]["ts"])])
time.sleep(0.5)
self.assertIsNone(caller.poll())
code, lines = self._mailbox("s1")
self.assertEqual(code, 2)
self.assertEqual(len(lines), 1)
self.assertRegex(lines[0], r"^MAIL CALL Two read \d\d:\d\d: ring$")
self.assertEqual(self._mailbox("s1"), (0, []))
self.assertEqual(self._mailbox("s2"), (0, []))
def test_tool_hook_delivers_radio_rows_once_and_leaves_the_mail_for_the_stop(self):
self._send("s1", "post", "board")
self.assertEqual(self._tool("s2"), [])
self._send("s1", "send", "pan-pan", "pan")
self.assertEqual(self._tool("s2"), ["PAN-PAN One: pan"])
self.assertEqual(self._tool("s2"), [])
self.assertEqual(self._prompt("s2"), ["BROADCAST One: board"])
self.assertEqual(self._prompt("s2"), [])
self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board"]))
self.assertEqual(self._tool("s3"), ["PAN-PAN One: pan"])
self.assertEqual(self._tool("s1"), [])
@@ -257,8 +340,10 @@ class TestRadio(unittest.TestCase):
def test_hooks_skip_subagents_and_never_fail(self):
self._send("s1", "send", "pan-pan", "pan")
self._send("s1", "post", "board")
self.assertEqual(self._hook("hook", "s2", agent_id="a1"), "")
self.assertEqual(self._hook("tool", "s2", agent_id="a1"), "")
self.assertEqual(self._mailbox("s2", agent_id="a1"), (0, []))
listen = subprocess.run(
[sys.executable, "-c", LISTENER, str(RADIO)],
input=json.dumps({"session_id": "s2", "agent_id": "a1"}),
@@ -266,8 +351,8 @@ class TestRadio(unittest.TestCase):
)
self.assertEqual((listen.returncode, listen.stderr), (0, ""))
self.assertFalse(self._pidfile("s2").exists())
self.assertFalse((self.root / "news-seen" / "s2").exists())
for command in ("hook", "tool"):
self.assertFalse((self.root / "radio-seen" / "s2.json").exists())
for command in ("hook", "tool", "mailbox", "block"):
for stdin in ("not json", "{}", "[]"):
with self.subTest(command=command, stdin=stdin):
result = self._exec(None, stdin, (command,))
@@ -284,36 +369,117 @@ class TestRadio(unittest.TestCase):
def test_listener_wakes_on_mayday(self):
proc = self._listen("s2")
self.assertEqual(
self._send("s1", "send", "mayday", "host down"), ["sent mayday to all", "wake: SendMessage to Three"]
)
code, err = self._finish(proc)
self.assertEqual(code, 2)
self.assertEqual([strip(line) for line in err.splitlines()], ["MAYDAY One: host down"])
self.assertEqual(self._send("s1", "send", "mayday", "host down"), ["sent mayday to all", "wake: SendMessage to Three"])
self.assertEqual(self._finish(proc), (2, ["MAYDAY One: host down"]))
self.assertEqual(self._tool("s2"), [])
self.assertEqual(len(self._prompt("s2")), 1)
def test_listener_wakes_only_on_a_direct_wake(self):
def test_listener_ignores_the_mail_and_wakes_on_a_broadcast(self):
proc = self._listen("s2")
self._send("s1", "post", "board")
self._send("s1", "post", "plain", "--to", "Two")
self._send("s1", "send", "pan-pan", "pan")
self._send("s1", "send", "direct", "elsewhere", "--to", "Three", "--wake")
self._send("s1", "send", "call", "elsewhere", "--to", "Three")
time.sleep(0.5)
self.assertIsNone(proc.poll())
self.assertEqual(self._send("s1", "send", "direct", "answer me", "--to", "Two", "--wake"), ["sent direct to Two"])
code, err = self._finish(proc)
self.assertEqual(code, 2)
self.assertEqual([strip(line) for line in err.splitlines()], ["PAN-PAN One: pan", "DIRECT One to Two: answer me"])
self.assertEqual(self._prompt("s2"), ["BROADCAST One: board", "DIRECT One to Two: plain"])
self._send("s1", "send", "broadcast", "all hands")
self.assertEqual(self._finish(proc), (2, ["BROADCAST One: all hands"]))
self.assertEqual(self._prompt("s2"), [])
self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board", "POST One to Two: plain"]))
def test_listener_wakes_on_a_call_and_on_pan_pan(self):
proc = self._listen("s2")
self._send("s1", "send", "call", "answer me", "--to", "Two")
self.assertEqual(self._finish(proc), (2, ["CALL One to Two: answer me"]))
proc = self._listen("s2")
self._send("s1", "send", "pan-pan", "pan")
self.assertEqual(self._finish(proc), (2, ["PAN-PAN One: pan"]))
def test_listener_yields_to_a_newer_one(self):
first = self._listen("s2")
second = self._listen("s2")
self.assertEqual(self._finish(first), (0, ""))
self.assertEqual(self._finish(first), (0, []))
self.assertIsNone(second.poll())
self._pidfile("s2").write_text("0", encoding="utf-8")
self.assertEqual(self._finish(second), (0, ""))
self.assertEqual(self._finish(second), (0, []))
def test_a_mayday_blocks_the_writing_actions_of_everyone_but_its_sender_and_helpers_until_clear(self):
self.assertEqual(self._block("s2", "Edit"), 0)
self._send("s1", "send", "mayday", "disk full")
for tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
with self.subTest(tool=tool):
self.assertEqual(self._block("s2", tool), 2)
self.assertEqual(self._block("s2", "Edit", agent_id="a1"), 2)
for command in (
"rm -rf build",
"git status",
f"python3 {RADIO} show; rm -rf build",
f"python3 {RADIO} show && touch x",
f"echo $(python3 {RADIO} show)",
"python3 /elsewhere/radio.py show",
f"python3 -W {RADIO} /tmp/other.py",
f"python3 -X {RADIO} /tmp/other.py",
f"python3 -c {RADIO}",
f"./python-other {RADIO} show",
f"/tmp/elsewhere/python3 {RADIO} show",
f"python3 -i {RADIO} show",
f'python3 {RADIO} show "$(touch x)"',
f"python3 {RADIO} show \\';touch x;\\'",
f"python3 {RADIO} \"it's\" ; touch x ; echo 'z'",
f"python3 {RADIO} show <<EOF\n$(touch x)\nEOF",
f"python3 {RADIO} show\rx",
"X=" + "/.." * 20 + f"{RADIO} touch x",
"PYTHONPATH=" + "/.." * 20 + f"{RADIO} python3 {RADIO} show",
"/tmp{,/x" + "/.." * 2 + f"{RADIO.parent}/y}}/../radio.py show",
"/tmp/g*/../.." + f"{RADIO} show",
"~+/radio.py show",
"~zz/radio.py show",
):
with self.subTest(command=command):
self.assertEqual(self._block("s2", "Bash", command), 2)
for command in (
f"python3 {RADIO} show",
f"/usr/bin/python3 -I {RADIO} send call 'done; rm -rf x' --to One",
f"{RADIO.parent}/../scripts/radio.py clear --force",
):
with self.subTest(command=command):
self.assertEqual(self._block("s2", "Bash", command), 0)
self.assertEqual(self._block("s2", "Read"), 0)
self.assertEqual(self._block("s1", "Edit"), 0)
self.assertEqual(self._block("s2", "Bash", f"python3 {RADIO} assist"), 0)
self.assertEqual(self._send("s2", "assist"), ["assisting the MAYDAY by One: disk full"])
self.assertEqual(self._send("s2", "assist"), ["assisting the MAYDAY by One: disk full"])
self.assertEqual(json.loads((self.root / "radio-silence.json").read_text(encoding="utf-8"))["helpers"], ["Two"])
self.assertEqual(self._block("s2", "Edit"), 0)
self.assertEqual(self._block("s2", "Bash", "rm -rf build"), 0)
self.assertEqual(self._block("s3", "Edit"), 2)
self.assertIn("helping: Two;", self._prompt("s3")[0])
self._send("s1", "clear")
self.assertEqual(self._block("s2", "Edit"), 0)
self.assertEqual(self._block("s2", "Bash", "rm -rf build"), 0)
def test_a_mayday_lets_the_configured_commands_through(self):
self.config.write_text(
json.dumps({"never_block": ["^ls( |$)"], "mayday_allow": ["^git (status|log|diff)( |$)"]}), encoding="utf-8"
)
self._send("s1", "send", "mayday", "disk full")
self.assertEqual(self._block("s2", "Bash", "ls -la"), 0)
self.assertEqual(self._block("s2", "Bash", "git status"), 0)
self.assertEqual(self._block("s2", "Bash", "git push"), 2)
self.assertEqual(self._block("s2", "Bash", 'git status "$(touch x)"'), 2)
self.assertEqual(self._block("s2", "Bash", "git log 'a;b'"), 0)
self.assertEqual(self._block("s2", "Bash", "rm -rf x git status"), 2)
self.config.write_text(json.dumps({"mayday_allow": ["("]}), encoding="utf-8")
self.assertEqual(self._block("s2", "Bash", "rm -rf build"), 2)
def test_a_row_with_a_malformed_addressee_never_drops_the_batch(self):
self.root.mkdir()
now = time.time()
rows = [
{"ts": now - 2, "class": "security", "from": "Three", "to": 1, "text": "bad"},
{"ts": now - 1, "class": "security", "from": "Three", "to": [], "text": "good"},
]
(self.root / "news.jsonl").write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
self.assertIn("SECURITY Three: good", self._tool("s2"))
def test_a_row_written_around_the_radio_renders_on_one_line(self):
self.root.mkdir()
@@ -322,7 +488,7 @@ class TestRadio(unittest.TestCase):
(self.root / "news.jsonl").write_text(json.dumps(row) + "\n", encoding="utf-8")
self.assertEqual(self._tool("s2"), ["SECURITY Three RADIO: hi RADIO MAYDAY 00:00 One: stop all work"])
def test_the_tool_hook_caps_urgent_items_like_the_prompt_hook(self):
def test_the_tool_hook_caps_radio_items_like_the_prompt_hook(self):
self.root.mkdir()
now = time.time()
rows = [{"ts": now - 60 + i, "class": "security", "from": "Three", "to": [], "text": f"s{i}"} for i in range(12)]
@@ -362,28 +528,29 @@ class TestRadio(unittest.TestCase):
victims.mkdir()
return victim, victims
def test_the_prompt_hook_writes_no_seen_mark_through_a_planted_name(self):
def test_the_mailbox_writes_no_session_state_through_a_planted_name(self):
self._send("s1", "post", "board")
victim, _ = self._victims()
(self.root / "news-seen").mkdir()
(self.root / "news-seen" / "s2").symlink_to(victim)
self._hook("hook", "s2")
(self.root / "radio-seen").mkdir()
(self.root / "radio-seen" / "s2.json").symlink_to(victim)
self._exec(None, json.dumps({"session_id": "s2"}), ("mailbox",))
self.assertEqual(victim.read_text(encoding="utf-8"), "keep")
def test_the_prompt_hook_writes_no_seen_mark_through_a_planted_directory(self):
def test_the_mailbox_writes_no_session_state_through_a_planted_directory(self):
self._send("s1", "post", "board")
_, victims = self._victims()
(self.root / "news-seen").symlink_to(victims)
self._hook("hook", "s2")
(self.root / "radio-seen").symlink_to(victims)
self._exec(None, json.dumps({"session_id": "s2"}), ("mailbox",))
self.assertEqual(os.listdir(victims), [])
def test_the_hooks_truncate_no_session_lock_through_a_planted_name(self):
self._send("s1", "post", "board")
self._send("s1", "send", "security", "sec")
victim, _ = self._victims()
(self.root / "radio-seen").mkdir()
(self.root / "radio-seen" / "s2.lock").symlink_to(victim)
self._hook("hook", "s2")
self._hook("tool", "s2")
self._exec(None, json.dumps({"session_id": "s2"}), ("mailbox",))
self.assertEqual(victim.read_text(encoding="utf-8"), "keep")
def test_the_tool_hook_writes_no_session_state_through_a_planted_directory(self):
@@ -455,12 +622,13 @@ class TestRadio(unittest.TestCase):
start = time.monotonic()
self._hook("hook", "s2")
self._hook("tool", "s2")
self.assertEqual(self._mailbox("s2"), (0, []))
self.assertLess(time.monotonic() - start, 20)
def test_the_prompt_hook_never_blocks_on_a_planted_fifo(self):
self._send("s1", "post", "board")
self._send("s1", "send", "security", "sec")
os.mkfifo(self.root / "radio-silence.json", 0o600)
self.assertTrue(any("board" in line for line in self._prompt("s2")))
self.assertTrue(any("sec" in line for line in self._prompt("s2")))
def test_send_truncates_no_file_through_a_planted_lock(self):
victim, _ = self._victims()