downbeat 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- downbeat/__init__.py +1 -0
- downbeat/assets/commands/relay-monitor.md +37 -0
- downbeat/assets/commands/relay-peers.md +17 -0
- downbeat/assets/commands/relay-register.md +16 -0
- downbeat/assets/commands/relay-reply.md +36 -0
- downbeat/assets/commands/relay-send.md +25 -0
- downbeat/assets/hooks/relay-inbox.py +269 -0
- downbeat/assets/hooks/relay-poll-offer.py +116 -0
- downbeat/assets/hooks_manifest.json +8 -0
- downbeat/cli/__init__.py +0 -0
- downbeat/cli/__main__.py +190 -0
- downbeat/cli/commands/__init__.py +0 -0
- downbeat/cli/commands/init_cmd.py +362 -0
- downbeat/cli/commands/relay_cmds.py +304 -0
- downbeat/core/__init__.py +0 -0
- downbeat/core/errors.py +18 -0
- downbeat/core/groups.py +37 -0
- downbeat/core/logging.py +42 -0
- downbeat/core/models.py +160 -0
- downbeat/core/paths.py +27 -0
- downbeat/core/session.py +153 -0
- downbeat/core/state.py +70 -0
- downbeat/core/store.py +682 -0
- downbeat/core/watcher.py +129 -0
- downbeat/skill/SKILL.md +127 -0
- downbeat/skill/__init__.py +0 -0
- downbeat/tui/__init__.py +0 -0
- downbeat/tui/app.py +89 -0
- downbeat/tui/messages.py +9 -0
- downbeat/tui/screens/__init__.py +0 -0
- downbeat/tui/screens/broadcast_status.py +30 -0
- downbeat/tui/screens/chat.py +307 -0
- downbeat/tui/screens/help.py +63 -0
- downbeat/tui/screens/main.py +215 -0
- downbeat/tui/screens/message_detail.py +171 -0
- downbeat/tui/screens/peers.py +125 -0
- downbeat/tui/screens/quarantine.py +120 -0
- downbeat/tui/theme.tcss +64 -0
- downbeat/tui/widgets/__init__.py +0 -0
- downbeat/tui/widgets/add_peer_modal.py +59 -0
- downbeat/tui/widgets/chat_composer.py +101 -0
- downbeat/tui/widgets/chat_stream.py +327 -0
- downbeat/tui/widgets/clipboard.py +41 -0
- downbeat/tui/widgets/composer.py +87 -0
- downbeat/tui/widgets/confirm.py +35 -0
- downbeat/tui/widgets/edit_modal.py +49 -0
- downbeat/tui/widgets/find_message.py +57 -0
- downbeat/tui/widgets/inbox_list.py +61 -0
- downbeat/tui/widgets/log_viewer.py +63 -0
- downbeat/tui/widgets/message_view.py +62 -0
- downbeat/tui/widgets/peer_admin.py +111 -0
- downbeat/tui/widgets/peer_list.py +130 -0
- downbeat/tui/widgets/peer_tabs.py +89 -0
- downbeat/tui/widgets/rebind_modal.py +52 -0
- downbeat/tui/widgets/switch_acting_as.py +46 -0
- downbeat-0.1.1.dist-info/METADATA +193 -0
- downbeat-0.1.1.dist-info/RECORD +60 -0
- downbeat-0.1.1.dist-info/WHEEL +4 -0
- downbeat-0.1.1.dist-info/entry_points.txt +2 -0
- downbeat-0.1.1.dist-info/licenses/LICENSE +21 -0
downbeat/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Continuously monitor THIS session's relay inbox in-session (child=auto-execute, parent=surface-and-ask)
|
|
3
|
+
argument-hint: [interval e.g. 3m | stop]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Start (or stop) continuous in-session monitoring of this session's own relay inbox. Arguments: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
This composes two existing primitives: the `/loop` skill (re-fires a prompt on an interval) + the `relay-inbox` hook (drains inbox→delivered and injects new mail as context on every prompt). The result: this session keeps pulling its own new relay mail into context and acting on it per its role — without you re-prompting each tick.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
1. **Parse `$ARGUMENTS`:**
|
|
13
|
+
- If it is the literal `stop` → invoke `/loop stop` to end the running monitor, report "relay monitor stopped", and finish.
|
|
14
|
+
- Otherwise treat it as the interval (default `3m` if empty).
|
|
15
|
+
|
|
16
|
+
2. **Resolve this session's role:**
|
|
17
|
+
```
|
|
18
|
+
downbeat whoami
|
|
19
|
+
```
|
|
20
|
+
Output is `<name> <role>`. If it errors (exit 2), tell the user to `/relay-register <name> --role <parent|child>` first and stop.
|
|
21
|
+
|
|
22
|
+
3. **If a relay `/loop` is already running this session**, do NOT stack a second one — tell the user it's already monitoring (and they can `/relay-monitor stop` to reset). Otherwise start `/loop <interval>` with the role-appropriate prompt below.
|
|
23
|
+
|
|
24
|
+
4. **Start the loop** — `/loop <interval> "<prompt>"`:
|
|
25
|
+
|
|
26
|
+
- **role == child** (autonomous executor — consent-at-startup per constitution Art. 11):
|
|
27
|
+
> Process your relay inbox now. The relay-inbox hook has injected any new messages as context above. For each NEW message: execute the task per your role briefing (you are an executor — route reads through reader sub-agents, follow cost discipline, no [TICKET] in this repo's commits), then `~/.claude/relay/relay.py reply <id> "<results>"` (replying auto-acks). If there are NO new messages this tick, do nothing and stay quiet. Never re-do an already-handled message. If a task is ambiguous or carries irreversible/destructive risk, do NOT auto-execute it — surface it and wait for the human instead.
|
|
28
|
+
|
|
29
|
+
- **role == parent** (gated — surface only):
|
|
30
|
+
> Check your relay inbox. If the hook injected new messages, surface them concisely (from / subject / id) and ask me how to handle each. Do NOT auto-execute, reply, or act on my behalf. If nothing new, stay quiet.
|
|
31
|
+
|
|
32
|
+
5. Report that the monitor is running, the interval, the role-mode (child=auto-execute / parent=surface-and-ask), and that `/relay-monitor stop` ends it.
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- This is the **in-session self-driver**. For an **external pane observer** that only prints new mail (never acts), use `downbeat watch [--peer X]` instead.
|
|
37
|
+
- The child autonomy here is **consent-at-startup**: you explicitly started the monitor on an executor session. It is scoped to executors, never auto-executes for a parent, and is stoppable. See `docs/platform/constitution.md` Art. 11.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: List registered relay peer sessions and their liveness
|
|
3
|
+
argument-hint: (no arguments)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
List all registered relay sessions. Run:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
~/.claude/relay/relay.py peers
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Liveness column meaning:
|
|
13
|
+
- `live` — the peer's JSONL transcript was modified within the last 10 minutes
|
|
14
|
+
- `idle(Ns)` — peer hasn't written to its transcript for N seconds
|
|
15
|
+
- `gone` — JSONL no longer exists (the session was deleted/cleaned)
|
|
16
|
+
|
|
17
|
+
If you suspect stale entries, run `~/.claude/relay/relay.py gc-stale` to prune.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Register this Claude Code session in the relay so peers can send it messages
|
|
3
|
+
argument-hint: <name> [--role parent|child]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Register this session in the relay with name and role from: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
Run exactly:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
~/.claude/relay/relay.py register $ARGUMENTS
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If the user provided no `--role`, the CLI defaults to `child`.
|
|
15
|
+
|
|
16
|
+
Report the CLI's output back verbatim. Do not retry on failure — surface the error to the user.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Reply to a relay message you previously received
|
|
3
|
+
argument-hint: <msg_id> <body...>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Reply to a previously received relay message. Arguments: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
## If `$ARGUMENTS` is EMPTY — treat this as an INBOX CHECK, not a reply
|
|
9
|
+
|
|
10
|
+
A bare `/relay-reply` (no `<msg_id>`) is the user's inbox-check shortcut. Do this:
|
|
11
|
+
|
|
12
|
+
1. Look at the CURRENT turn's context for a `### Relay inbox — N new message(s)` banner (the hook drains the inbox into context on each prompt). That block IS your pending mail.
|
|
13
|
+
2. If none is in context, verify directly: `downbeat whoami` for your peer name, then check `~/.claude/relay/inbox/<me>/` and `~/.claude/relay/delivered/<me>/` for `.json` files.
|
|
14
|
+
3. **If there ARE pending messages** (especially from the parent) → surface them and reply/handle as appropriate (fall through to the reply flow below with the real `<msg_id>`).
|
|
15
|
+
4. **If there are NONE → SKIP. Take no action.** Do not fabricate a reply, do not invent a `<msg_id>`, and do NOT send proactive/unsolicited messages to the parent. Just report "inbox empty, nothing to reply to" and stop.
|
|
16
|
+
|
|
17
|
+
## If `$ARGUMENTS` has a msg_id + body — reply normally
|
|
18
|
+
|
|
19
|
+
Parse `$ARGUMENTS` as: `<msg_id> <body...>` where `<msg_id>` is the 16-char hex id from a message in your inbox (look at the most recent relay message context), and the rest is the reply body.
|
|
20
|
+
|
|
21
|
+
If the body is long or multi-line:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
~/.claude/relay/relay.py reply <msg_id> "$(cat <<'EOF'
|
|
25
|
+
<body>
|
|
26
|
+
EOF
|
|
27
|
+
)"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Otherwise:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
~/.claude/relay/relay.py reply <msg_id> "<body>"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The reply is routed to the original sender's inbox using the `from` field of the message stored under `~/.claude/relay/processed/<your_name>/<msg_id>.json`. If the CLI errors with "msg_id not found", the message was either never received here or already replied to in a way that moved it.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Send a relay message to a peer Claude Code session
|
|
3
|
+
argument-hint: <to> <subject> <body...>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Send a relay message. Arguments: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
Parse `$ARGUMENTS` as: `<to> <subject> <body...>` where `<to>` is the peer name, `<subject>` is a single token (use quotes if multi-word), and everything after is the body.
|
|
9
|
+
|
|
10
|
+
If the body is long or multi-line, write it to a temp file first and pass via shell substitution:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
~/.claude/relay/relay.py send <to> "<subject>" "$(cat <<'EOF'
|
|
14
|
+
<body>
|
|
15
|
+
EOF
|
|
16
|
+
)"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Otherwise call directly:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/.claude/relay/relay.py send <to> "<subject>" "<body>"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Report the CLI's output (it includes the message id). The peer will receive the message on their next prompt or session start, prepended as system context by the `relay-inbox` hook.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Relay inbox hook — drains pending messages into additionalContext.
|
|
3
|
+
|
|
4
|
+
Wired into:
|
|
5
|
+
UserPromptSubmit — runs before every user prompt is processed
|
|
6
|
+
SessionStart — runs when a session starts/resumes (picks up offline mail)
|
|
7
|
+
|
|
8
|
+
Behavior:
|
|
9
|
+
1. Read the hook payload (JSON on stdin) → session_id
|
|
10
|
+
2. Reverse-lookup session_id in ~/.claude/relay/sessions.json → peer name
|
|
11
|
+
3. If unregistered, silent no-op (zero impact on non-relay sessions)
|
|
12
|
+
4. List ~/.claude/relay/inbox/<name>/*.json sorted by mtime
|
|
13
|
+
5. For each message, read body, accumulate into a single markdown block
|
|
14
|
+
6. Stamp `delivered_at` + `delivered_to_session_id` on each message and
|
|
15
|
+
atomically move it to delivered/<name>/<id>.json (NOT processed/ —
|
|
16
|
+
that's the v0.2 two-phase delivery state machine; messages need an
|
|
17
|
+
ack OR a matching reply OR a TUI archive to promote to processed/).
|
|
18
|
+
7. Emit hookSpecificOutput.additionalContext
|
|
19
|
+
|
|
20
|
+
Fails open: any exception → stderr + exit 0, never blocks.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import traceback
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
RELAY_DIR = Path.home() / ".claude" / "relay"
|
|
32
|
+
SESSIONS_FILE = RELAY_DIR / "sessions.json"
|
|
33
|
+
INBOX_DIR = RELAY_DIR / "inbox"
|
|
34
|
+
DELIVERED_DIR = RELAY_DIR / "delivered"
|
|
35
|
+
|
|
36
|
+
MAX_MESSAGES_PER_DRAIN = 20 # safety cap
|
|
37
|
+
|
|
38
|
+
# Phase 2: backflow-relay rendering.
|
|
39
|
+
# BACKFLOW_KIND must match the literal used in the downbeat SKILL.md and
|
|
40
|
+
# command texts — defined ONCE here (the only Python consumer). NEVER the
|
|
41
|
+
# underscore variant.
|
|
42
|
+
BACKFLOW_KIND = "backflow-ready"
|
|
43
|
+
BACKFLOW_FENCE_RE = re.compile(r"```json backflow\s*\n(.*?)```", re.DOTALL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_backflow(body):
|
|
47
|
+
"""Extract the FIRST ```json backflow fence from body.
|
|
48
|
+
|
|
49
|
+
Returns (payload, stripped_body). Valid = JSON parses AND has BOTH required
|
|
50
|
+
keys (proposed_updates_path, findings). Anything else -> (None, body) —
|
|
51
|
+
callers then decide whether an 'unparsed' note applies (only when the
|
|
52
|
+
message CLAIMED backflow via kind, or a fence is present but broken).
|
|
53
|
+
"""
|
|
54
|
+
m = BACKFLOW_FENCE_RE.search(body or "")
|
|
55
|
+
if not m:
|
|
56
|
+
return None, body
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(m.group(1))
|
|
59
|
+
except Exception:
|
|
60
|
+
return None, body
|
|
61
|
+
if (not isinstance(payload, dict)
|
|
62
|
+
or "proposed_updates_path" not in payload
|
|
63
|
+
or "findings" not in payload):
|
|
64
|
+
return None, body
|
|
65
|
+
stripped = (body[:m.start()] + body[m.end():]).strip()
|
|
66
|
+
return payload, stripped
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _now_iso():
|
|
70
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def name_for_session(session_id, sessions):
|
|
74
|
+
for name, meta in sessions.items():
|
|
75
|
+
if meta.get("session_id") == session_id:
|
|
76
|
+
return name
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _atomic_write_json(target: Path, payload: dict) -> None:
|
|
81
|
+
"""Write JSON atomically: temp in same dir, then os.replace."""
|
|
82
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
84
|
+
dir=str(target.parent), prefix=".tmp-", text=True
|
|
85
|
+
)
|
|
86
|
+
import os
|
|
87
|
+
try:
|
|
88
|
+
with os.fdopen(fd, "w") as f:
|
|
89
|
+
f.write(json.dumps(payload, indent=2))
|
|
90
|
+
os.replace(tmp_path, target)
|
|
91
|
+
except Exception:
|
|
92
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def drain_inbox(name, session_id):
|
|
97
|
+
"""Move pending messages for `name` from inbox/ to delivered/, stamping
|
|
98
|
+
delivery metadata. Returns the drained message dicts (for banner rendering).
|
|
99
|
+
|
|
100
|
+
Each message gets:
|
|
101
|
+
- delivered_at: ISO timestamp of this drain
|
|
102
|
+
- delivered_to_session_id: the session that received them
|
|
103
|
+
Existing `redelivery_count` is preserved (set by reconciler on requeue).
|
|
104
|
+
The file is unlinked from inbox/ AFTER the new version is committed to
|
|
105
|
+
delivered/ — so a crash mid-drain at worst leaves a duplicate in delivered/
|
|
106
|
+
while the inbox/ original remains; the next drain finds and finalizes it.
|
|
107
|
+
"""
|
|
108
|
+
inbox = INBOX_DIR / name
|
|
109
|
+
if not inbox.exists():
|
|
110
|
+
return []
|
|
111
|
+
files = sorted(inbox.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
112
|
+
files = files[:MAX_MESSAGES_PER_DRAIN]
|
|
113
|
+
drained = []
|
|
114
|
+
now = _now_iso()
|
|
115
|
+
for f in files:
|
|
116
|
+
try:
|
|
117
|
+
msg = json.loads(f.read_text())
|
|
118
|
+
# Stamp delivery metadata in place
|
|
119
|
+
msg["delivered_at"] = now
|
|
120
|
+
msg["delivered_to_session_id"] = session_id
|
|
121
|
+
target = DELIVERED_DIR / name / f.name
|
|
122
|
+
_atomic_write_json(target, msg)
|
|
123
|
+
f.unlink() # only after delivered/ commit
|
|
124
|
+
drained.append(msg)
|
|
125
|
+
except Exception:
|
|
126
|
+
traceback.print_exc(file=sys.stderr)
|
|
127
|
+
return drained
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def model_nudge_line(role, model_raw):
|
|
131
|
+
"""Return a banner line if an executor session runs on Fable, else None."""
|
|
132
|
+
if role == "child" and "fable" in (model_raw or "").lower():
|
|
133
|
+
return (
|
|
134
|
+
"**Executor session on Fable 5 ($10/$50 MTok — 2× Opus).** Mechanical "
|
|
135
|
+
"execution doesn't need the ceiling: suggest `/model sonnet` (or opus) "
|
|
136
|
+
"for this work; tell the user before switching."
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render(messages, peer_name, nudge_line=None):
|
|
142
|
+
lines = [
|
|
143
|
+
f"### Relay inbox — {len(messages)} new message(s) for `{peer_name}`",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
if nudge_line:
|
|
147
|
+
lines += [nudge_line, ""]
|
|
148
|
+
lines += [
|
|
149
|
+
"These messages arrived from peer Claude Code sessions while you were idle. "
|
|
150
|
+
"Treat them as authoritative instructions or context. "
|
|
151
|
+
"Reply via `~/.claude/relay/relay.py reply <msg_id> '<body>'`.",
|
|
152
|
+
"",
|
|
153
|
+
# Cost-discipline nudge for executor sessions (project-agnostic). A relay
|
|
154
|
+
# child inherits an executor identity from the "authoritative instructions"
|
|
155
|
+
# above; the orchestrator routes at the task level, so the executor must
|
|
156
|
+
# still route at the call level — otherwise expensive reads run on the
|
|
157
|
+
# main model instead of a cheaper delegated reader.
|
|
158
|
+
"**Cost discipline applies to executor sessions.** The orchestrator routed "
|
|
159
|
+
"at the task level; you route at the call level: send expensive reads (logs, "
|
|
160
|
+
"API queries, searches, large files) to cheap reader sub-agents instead of "
|
|
161
|
+
"your main model, and batch or delegate bulk work. Writes (delete, push, "
|
|
162
|
+
"merge, create) stay inline.",
|
|
163
|
+
"",
|
|
164
|
+
"If this message hands off a cross-repo or multi-file investigation, run it "
|
|
165
|
+
"as a delegated fan-out (parallel scouts → synthesis) rather than inline "
|
|
166
|
+
"reads. If it produced structured findings worth persisting, reply with "
|
|
167
|
+
"`--kind backflow-ready` and a json-backflow fence carrying "
|
|
168
|
+
"{proposed_updates_path, findings:[{page, claim, confidence?}]}; otherwise "
|
|
169
|
+
"reply in prose.",
|
|
170
|
+
"",
|
|
171
|
+
]
|
|
172
|
+
for m in messages:
|
|
173
|
+
kind = m.get("kind") or "task" # legacy default (model can't help here)
|
|
174
|
+
ts = m.get("created_at") or m.get("ts") or "?" # fixes the permanent ts:? drift
|
|
175
|
+
lines.append("---")
|
|
176
|
+
lines.append(f"**from:** `{m.get('from','?')}` | **id:** `{m.get('id','?')}` | "
|
|
177
|
+
f"**kind:** {kind} | **ts:** {ts}")
|
|
178
|
+
if m.get("in_reply_to"):
|
|
179
|
+
lines.append(f"**in_reply_to:** `{m['in_reply_to']}`")
|
|
180
|
+
if m.get("subject"):
|
|
181
|
+
lines.append(f"**subject:** {m.get('subject')}")
|
|
182
|
+
lines.append("")
|
|
183
|
+
payload, stripped_body = parse_backflow(m.get("body", ""))
|
|
184
|
+
if payload is not None:
|
|
185
|
+
# Fence content renders for ANY kind (belt-and-suspenders: survives a
|
|
186
|
+
# kind typo AND works before the CLI gains --kind). kind only adds
|
|
187
|
+
# the prominent header.
|
|
188
|
+
if kind == BACKFLOW_KIND:
|
|
189
|
+
lines.append("### 🔄 BACKFLOW — proposed updates from a delegated run")
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append(stripped_body or "(no summary)")
|
|
192
|
+
lines.append("")
|
|
193
|
+
lines.append(f"**Proposed-updates file:** `{payload['proposed_updates_path']}`")
|
|
194
|
+
for f_ in payload.get("findings", []):
|
|
195
|
+
page, claim = f_.get("page", "?"), f_.get("claim", "?")
|
|
196
|
+
conf = f_.get("confidence")
|
|
197
|
+
lines.append(f"- `[[{page}]]` — {claim}" + (f" _({conf})_" if conf else ""))
|
|
198
|
+
lines.append("")
|
|
199
|
+
# Guard rides with the CONTENT, not the kind (3R-6).
|
|
200
|
+
lines.append("**Surface these for human triage — do not auto-apply.**")
|
|
201
|
+
elif kind == BACKFLOW_KIND:
|
|
202
|
+
# Claimed backflow, couldn't deliver it (fence missing/invalid).
|
|
203
|
+
lines.append(m.get("body", "(empty)"))
|
|
204
|
+
lines.append("")
|
|
205
|
+
lines.append("_(unparsed backflow block — fence missing or invalid)_")
|
|
206
|
+
else:
|
|
207
|
+
# Plain message, nothing claimed -> NO backflow note (2R-7).
|
|
208
|
+
lines.append(m.get("body", "(empty)"))
|
|
209
|
+
lines.append("")
|
|
210
|
+
lines.append("---")
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def emit_context(event_name, text):
|
|
215
|
+
sys.stdout.write(json.dumps({
|
|
216
|
+
"hookSpecificOutput": {
|
|
217
|
+
"hookEventName": event_name,
|
|
218
|
+
"additionalContext": text,
|
|
219
|
+
}
|
|
220
|
+
}) + "\n")
|
|
221
|
+
sys.stdout.flush()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main():
|
|
225
|
+
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
226
|
+
try:
|
|
227
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
228
|
+
except Exception:
|
|
229
|
+
payload = {}
|
|
230
|
+
|
|
231
|
+
session_id = payload.get("session_id")
|
|
232
|
+
event_name = payload.get("hook_event_name") or "UserPromptSubmit"
|
|
233
|
+
|
|
234
|
+
if not session_id:
|
|
235
|
+
return
|
|
236
|
+
if not SESSIONS_FILE.exists():
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
sessions = json.loads(SESSIONS_FILE.read_text())
|
|
241
|
+
except Exception:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
name = name_for_session(session_id, sessions)
|
|
245
|
+
if not name:
|
|
246
|
+
return # unregistered session — silent no-op
|
|
247
|
+
|
|
248
|
+
drained = drain_inbox(name, session_id)
|
|
249
|
+
if not drained:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
nudge = None
|
|
253
|
+
try:
|
|
254
|
+
role = sessions.get(name, {}).get("role")
|
|
255
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
256
|
+
model_raw = json.loads(settings_path.read_text()).get("model", "")
|
|
257
|
+
nudge = model_nudge_line(role, model_raw)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass # fail open — nudge is advisory only
|
|
260
|
+
|
|
261
|
+
emit_context(event_name, render(drained, name, nudge_line=nudge))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
try:
|
|
266
|
+
main()
|
|
267
|
+
except Exception:
|
|
268
|
+
traceback.print_exc(file=sys.stderr)
|
|
269
|
+
sys.exit(0)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Relay poll-offer hook — PostToolUse on Bash.
|
|
3
|
+
|
|
4
|
+
After a `send` or `reply` via the relay CLI, inject a one-time system reminder
|
|
5
|
+
asking Claude to consider offering the user a `/loop 3m` inbox-poll setup.
|
|
6
|
+
|
|
7
|
+
State file: ~/.claude/relay/loop_offer_state.json
|
|
8
|
+
Keyed by session_id; each entry has {"hinted_at": ISO, "decided": bool}.
|
|
9
|
+
|
|
10
|
+
Fails open: any exception → stderr + exit 0, never blocks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
STATE_FILE = Path.home() / ".claude" / "relay" / "loop_offer_state.json"
|
|
21
|
+
|
|
22
|
+
# Match either path-based shim or the global CLI binary.
|
|
23
|
+
SEND_REPLY_RE = re.compile(
|
|
24
|
+
r"(?:relay\.py|downbeat)\s+(?:send|reply)\b"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
HINT = (
|
|
28
|
+
"The user just executed a relay `send`/`reply`. If they appear to be done "
|
|
29
|
+
"with local work for now AND there is no relay `/loop` already running in "
|
|
30
|
+
"this session, use AskUserQuestion to offer:\n\n"
|
|
31
|
+
"> \"You just sent a relay message. Want me to check the inbox every 3 "
|
|
32
|
+
"minutes for a reply and surface it when it arrives?\"\n\n"
|
|
33
|
+
"Options:\n"
|
|
34
|
+
"- Yes, poll every 3 min → invoke `/loop 3m Check the relay inbox via "
|
|
35
|
+
"~/.claude/relay/relay.py inbox. If there are new messages for a "
|
|
36
|
+
"registered peer, surface them concisely (sender, subject, id) and ask "
|
|
37
|
+
"how to handle each. If empty, stay silent.`\n"
|
|
38
|
+
"- Yes, poll every 5 min → same instruction with `/loop 5m`\n"
|
|
39
|
+
"- No, I'll check manually\n\n"
|
|
40
|
+
"If the user already has queued tasks or is actively working on something "
|
|
41
|
+
"else, do NOT interrupt — skip the offer this turn. This hint will only "
|
|
42
|
+
"fire ONCE per session."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_state() -> dict:
|
|
47
|
+
if not STATE_FILE.exists():
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(STATE_FILE.read_text() or "{}")
|
|
51
|
+
except Exception:
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_state(data: dict) -> None:
|
|
56
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
STATE_FILE.write_text(json.dumps(data, indent=2))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
try:
|
|
62
|
+
payload = json.load(sys.stdin)
|
|
63
|
+
except Exception:
|
|
64
|
+
# No stdin / not JSON → silent no-op
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
tool_name = payload.get("tool_name") or payload.get("toolName") or ""
|
|
68
|
+
if tool_name != "Bash":
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
|
|
72
|
+
command = (tool_input.get("command") or "").strip()
|
|
73
|
+
if not SEND_REPLY_RE.search(command):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Tool result inspection: only fire if the command succeeded.
|
|
77
|
+
tool_result = payload.get("tool_result") or payload.get("toolResult") or {}
|
|
78
|
+
# Exit code field varies; treat absent as success (best-effort).
|
|
79
|
+
exit_code = tool_result.get("exit_code")
|
|
80
|
+
if exit_code is not None and exit_code != 0:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
session_id = (
|
|
84
|
+
payload.get("session_id")
|
|
85
|
+
or payload.get("sessionId")
|
|
86
|
+
or ""
|
|
87
|
+
)
|
|
88
|
+
if not session_id:
|
|
89
|
+
# No session id → still emit the hint (better safe than silent), but
|
|
90
|
+
# we can't dedupe by session.
|
|
91
|
+
sys.stdout.write(json.dumps({"systemMessage": HINT}) + "\n")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
state = _load_state()
|
|
95
|
+
entry = state.get(session_id) or {}
|
|
96
|
+
if entry.get("hinted_at"):
|
|
97
|
+
# Already fired in this session — silent.
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
entry["hinted_at"] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
101
|
+
state[session_id] = entry
|
|
102
|
+
try:
|
|
103
|
+
_save_state(state)
|
|
104
|
+
except Exception:
|
|
105
|
+
traceback.print_exc(file=sys.stderr)
|
|
106
|
+
|
|
107
|
+
sys.stdout.write(json.dumps({"systemMessage": HINT}) + "\n")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
try:
|
|
112
|
+
main()
|
|
113
|
+
except Exception:
|
|
114
|
+
traceback.print_exc(file=sys.stderr)
|
|
115
|
+
# Never block tools
|
|
116
|
+
sys.exit(0)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Registration source of truth for `downbeat init`. Mirrors the real ~/.claude/settings.json hook layout: settings['hooks'][<event>] is a list of entries, each {matcher?, hooks:[{type,command,timeout}]}. Relay hooks may live INSIDE a multi-hook entry alongside non-relay hooks (e.g. cost-discipline) — init detects by command-substring and never clobbers neighbours. `command` here is the hook basename; init resolves it to <HOME>/.claude/hooks/<basename>. matcher null means the entry carries no matcher key.",
|
|
3
|
+
"events": [
|
|
4
|
+
{"event": "UserPromptSubmit", "matcher": null, "command": "relay-inbox.py", "timeout": 3},
|
|
5
|
+
{"event": "SessionStart", "matcher": "startup|resume", "command": "relay-inbox.py", "timeout": 3},
|
|
6
|
+
{"event": "PostToolUse", "matcher": "Bash", "command": "relay-poll-offer.py", "timeout": 3}
|
|
7
|
+
]
|
|
8
|
+
}
|
downbeat/cli/__init__.py
ADDED
|
File without changes
|