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.
Files changed (60) hide show
  1. downbeat/__init__.py +1 -0
  2. downbeat/assets/commands/relay-monitor.md +37 -0
  3. downbeat/assets/commands/relay-peers.md +17 -0
  4. downbeat/assets/commands/relay-register.md +16 -0
  5. downbeat/assets/commands/relay-reply.md +36 -0
  6. downbeat/assets/commands/relay-send.md +25 -0
  7. downbeat/assets/hooks/relay-inbox.py +269 -0
  8. downbeat/assets/hooks/relay-poll-offer.py +116 -0
  9. downbeat/assets/hooks_manifest.json +8 -0
  10. downbeat/cli/__init__.py +0 -0
  11. downbeat/cli/__main__.py +190 -0
  12. downbeat/cli/commands/__init__.py +0 -0
  13. downbeat/cli/commands/init_cmd.py +362 -0
  14. downbeat/cli/commands/relay_cmds.py +304 -0
  15. downbeat/core/__init__.py +0 -0
  16. downbeat/core/errors.py +18 -0
  17. downbeat/core/groups.py +37 -0
  18. downbeat/core/logging.py +42 -0
  19. downbeat/core/models.py +160 -0
  20. downbeat/core/paths.py +27 -0
  21. downbeat/core/session.py +153 -0
  22. downbeat/core/state.py +70 -0
  23. downbeat/core/store.py +682 -0
  24. downbeat/core/watcher.py +129 -0
  25. downbeat/skill/SKILL.md +127 -0
  26. downbeat/skill/__init__.py +0 -0
  27. downbeat/tui/__init__.py +0 -0
  28. downbeat/tui/app.py +89 -0
  29. downbeat/tui/messages.py +9 -0
  30. downbeat/tui/screens/__init__.py +0 -0
  31. downbeat/tui/screens/broadcast_status.py +30 -0
  32. downbeat/tui/screens/chat.py +307 -0
  33. downbeat/tui/screens/help.py +63 -0
  34. downbeat/tui/screens/main.py +215 -0
  35. downbeat/tui/screens/message_detail.py +171 -0
  36. downbeat/tui/screens/peers.py +125 -0
  37. downbeat/tui/screens/quarantine.py +120 -0
  38. downbeat/tui/theme.tcss +64 -0
  39. downbeat/tui/widgets/__init__.py +0 -0
  40. downbeat/tui/widgets/add_peer_modal.py +59 -0
  41. downbeat/tui/widgets/chat_composer.py +101 -0
  42. downbeat/tui/widgets/chat_stream.py +327 -0
  43. downbeat/tui/widgets/clipboard.py +41 -0
  44. downbeat/tui/widgets/composer.py +87 -0
  45. downbeat/tui/widgets/confirm.py +35 -0
  46. downbeat/tui/widgets/edit_modal.py +49 -0
  47. downbeat/tui/widgets/find_message.py +57 -0
  48. downbeat/tui/widgets/inbox_list.py +61 -0
  49. downbeat/tui/widgets/log_viewer.py +63 -0
  50. downbeat/tui/widgets/message_view.py +62 -0
  51. downbeat/tui/widgets/peer_admin.py +111 -0
  52. downbeat/tui/widgets/peer_list.py +130 -0
  53. downbeat/tui/widgets/peer_tabs.py +89 -0
  54. downbeat/tui/widgets/rebind_modal.py +52 -0
  55. downbeat/tui/widgets/switch_acting_as.py +46 -0
  56. downbeat-0.1.1.dist-info/METADATA +193 -0
  57. downbeat-0.1.1.dist-info/RECORD +60 -0
  58. downbeat-0.1.1.dist-info/WHEEL +4 -0
  59. downbeat-0.1.1.dist-info/entry_points.txt +2 -0
  60. 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
+ }
File without changes