feat(kvb-org-radio): split radio and post, add kvb-org-update

The radio (call, broadcast, security, pan-pan, mayday) reaches its addressee at the next tool step and wakes it. The post (post with --to, newsletter without, call receipts) arrives through a new Stop hook when the turn ends and never wakes. Legacy rows map onto the two channels and send direct stays as an alias. A PreToolUse hook holds the mayday's radio silence for writing tools and Bash. Only the sender, helpers who join with radio.py assist, bare radio.py calls and the config's allowed commands pass, and any shell operator or parse error blocks. kvb-org-update announces changed skills as one newsletter. Callers moved to send call --to.

🎯 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:08:10 +00:00
1 parent 5cb5da81ec
commit 367fe56dd4
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()