Skip to content

Commit f2c3cfa

Browse files
committed
feat(msg): add Phase A - prompt detection, hooks, watcher refinement
- Add prompt-empty detection (Claude ❯, Codex ›) to avoid typing into panes where user is mid-input - Consolidated msg-hook CLI for Stop + UserPromptSubmit hooks (replaces old stop_hook.py) - Revised watcher: quick idle check, prompt detection, only injects when idle+empty, releases for busy/typing - Add /msg:inbox slash command for plugin-based notification - Add msg plugin to marketplace.json - Add --local flag for project-local DB (sandbox fallback) - Helpful error message when sandbox blocks ~/.msg/ writes - Watcher uses /msg:inbox (Claude) and /prompts:inbox (Codex) - 39 tests passing Ref: docs/msg-phase-a-plan.md, docs/msg-plan-v2.md
1 parent fe21834 commit f2c3cfa

14 files changed

Lines changed: 1207 additions & 208 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
"name": "voice",
3535
"source": "./plugins/voice",
3636
"description": "Audio feedback when Claude Code agent completes tasks using pocket-tts"
37+
},
38+
{
39+
"name": "msg",
40+
"source": "./plugins/msg",
41+
"description": "Inter-agent communication for Claude Code and Codex CLI sessions via threads and messages"
3742
}
3843
]
3944
}

claude_code_tools/msg/cli.py

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,47 @@
1010
import click
1111

1212
from .models import AgentKind
13-
from .store import MsgStore, DEFAULT_DB_PATH
13+
from .store import MsgStore, DEFAULT_DB_PATH, DEFAULT_DB_DIR
1414

1515

16-
def _get_store(db_path: str | None = None) -> MsgStore:
17-
return MsgStore(db_path or DEFAULT_DB_PATH)
16+
def _check_db_writable(db_dir: str) -> bool:
17+
"""Check if we can write to the DB directory."""
18+
from pathlib import Path
19+
try:
20+
Path(db_dir).mkdir(parents=True, exist_ok=True)
21+
test_file = os.path.join(db_dir, ".write_test")
22+
with open(test_file, "w") as f:
23+
f.write("test")
24+
os.remove(test_file)
25+
return True
26+
except OSError:
27+
return False
28+
29+
30+
def _get_local_db_path() -> str:
31+
"""Get project-local DB path."""
32+
try:
33+
result = subprocess.run(
34+
["git", "rev-parse", "--show-toplevel"],
35+
capture_output=True, text=True, timeout=5,
36+
)
37+
if result.returncode == 0:
38+
root = result.stdout.strip()
39+
return os.path.join(root, ".msg", "msg.db")
40+
except (FileNotFoundError, subprocess.TimeoutExpired):
41+
pass
42+
return os.path.join(os.getcwd(), ".msg", "msg.db")
43+
44+
45+
def _get_store(
46+
db_path: str | None = None,
47+
local: bool = False,
48+
) -> MsgStore:
49+
if db_path:
50+
return MsgStore(db_path)
51+
if local:
52+
return MsgStore(_get_local_db_path())
53+
return MsgStore(DEFAULT_DB_PATH)
1854

1955

2056
def _detect_tmux_pane() -> str | None:
@@ -193,16 +229,50 @@ def _ensure_watcher_running(store: MsgStore) -> None:
193229
@click.group()
194230
@click.option(
195231
"--db", default=None,
196-
help="Path to msg database (default: ~/.msg/msg.db)",
232+
help="Path to msg database",
233+
)
234+
@click.option(
235+
"--local", is_flag=True, default=False,
236+
help="Use project-local DB instead of global ~/.msg/",
197237
)
198238
@click.pass_context
199-
def cli(ctx: click.Context, db: str | None) -> None:
239+
def cli(
240+
ctx: click.Context,
241+
db: str | None,
242+
local: bool,
243+
) -> None:
200244
"""msg: Inter-agent communication for coding agents."""
201245
ctx.ensure_object(dict)
202-
store = _get_store(db)
246+
247+
# If no explicit path, check if global DB is writable
248+
if not db and not local:
249+
if not _check_db_writable(DEFAULT_DB_DIR):
250+
home = os.path.expanduser("~")
251+
click.echo(
252+
"Cannot write to ~/.msg/ "
253+
"(sandbox restriction).\n\n"
254+
"Ask the user which option they prefer:\n\n"
255+
"Option A: Global messaging "
256+
"(agents across any project can talk)\n"
257+
" User needs to add to "
258+
"~/.codex/config.toml:\n"
259+
" [sandbox_workspace_write]\n"
260+
f' writable_roots = ["{home}/.msg"]\n'
261+
" Then restart Codex.\n\n"
262+
"Option B: Local messaging "
263+
"(this project only)\n"
264+
" Works immediately, no config "
265+
"changes needed.\n\n"
266+
"If user chooses B, re-run the same "
267+
"command with --local flag prepended "
268+
"after 'msg', e.g.: "
269+
"msg --local register <name>",
270+
err=True,
271+
)
272+
sys.exit(1)
273+
274+
store = _get_store(db=db, local=local)
203275
ctx.obj["store"] = store
204-
# Auto-start watcher if not running
205-
# (skip if the command itself is 'watch' to avoid loop)
206276
if ctx.invoked_subcommand != "watch":
207277
_ensure_watcher_running(store)
208278

claude_code_tools/msg/hooks.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Hook commands for msg inter-agent communication.
2+
3+
Provides Stop and UserPromptSubmit hooks that check
4+
for unread messages and inject notifications into the
5+
agent's context. Used by both Claude Code and Codex CLI.
6+
7+
Both hooks use the same claim protocol as the watcher
8+
to prevent double-notification.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import subprocess
16+
import sys
17+
18+
import click
19+
20+
from .models import _new_uuid
21+
from .store import MsgStore
22+
23+
24+
def _find_self_agent(store: MsgStore) -> object | None:
25+
"""Find the agent registered for this pane."""
26+
pane_id = os.environ.get("TMUX_PANE")
27+
if not pane_id:
28+
return None
29+
30+
try:
31+
result = subprocess.run(
32+
["tmux", "display-message",
33+
"-t", pane_id,
34+
"-p", "#{session_name}"],
35+
capture_output=True, text=True, timeout=5,
36+
)
37+
tmux_session = result.stdout.strip()
38+
except (FileNotFoundError, subprocess.TimeoutExpired):
39+
return None
40+
41+
if not tmux_session:
42+
return None
43+
44+
agents = store.list_agents(tmux_session=tmux_session)
45+
for a in agents:
46+
if a.pane_id == pane_id:
47+
return a
48+
return None
49+
50+
51+
def _check_and_notify(
52+
hook_event: str,
53+
) -> None:
54+
"""Common logic for both Stop and UserPromptSubmit.
55+
56+
Reads JSON from stdin, checks DB for unread messages,
57+
claims deliveries, outputs JSON response.
58+
"""
59+
# Read hook input
60+
try:
61+
hook_input = json.load(sys.stdin)
62+
except (json.JSONDecodeError, EOFError):
63+
hook_input = {}
64+
65+
try:
66+
store = MsgStore()
67+
except Exception:
68+
_approve()
69+
return
70+
71+
me = _find_self_agent(store)
72+
if not me:
73+
_approve()
74+
return
75+
76+
# Check for unread messages
77+
messages = store.get_inbox(me.session_id)
78+
if not messages:
79+
_approve()
80+
return
81+
82+
# Claim deliveries (same protocol as watcher)
83+
claimer_id = f"hook-{hook_event}-{_new_uuid()[:8]}"
84+
claimed = store.claim_pending_deliveries(claimer_id)
85+
86+
# Filter to our deliveries only
87+
our_claims = [
88+
d for d in claimed
89+
if d["recipient_id"] == me.session_id
90+
]
91+
92+
# Build notification
93+
count = len(messages)
94+
senders = list(dict.fromkeys(
95+
m.get("from_name", "unknown") for m in messages
96+
))
97+
sender_str = ", ".join(senders)
98+
notification = (
99+
f"[MSG] {count} unread message(s) "
100+
f"from {sender_str}. "
101+
f"Run msg inbox when ready."
102+
)
103+
104+
# Mark claimed as notified
105+
for d in our_claims:
106+
store.mark_notified(d["id"])
107+
108+
# Output with additionalContext
109+
print(json.dumps({
110+
"hookSpecificOutput": {
111+
"hookEventName": hook_event,
112+
"additionalContext": notification,
113+
}
114+
}))
115+
116+
117+
def _approve() -> None:
118+
"""Output a simple approve response."""
119+
print(json.dumps({"decision": "approve"}))
120+
121+
122+
@click.group()
123+
def cli() -> None:
124+
"""msg-hook: Hook commands for msg notifications."""
125+
pass
126+
127+
128+
@cli.command()
129+
def stop() -> None:
130+
"""Stop hook — check inbox when agent stops."""
131+
_check_and_notify("Stop")
132+
133+
134+
@cli.command("prompt-submit")
135+
def prompt_submit() -> None:
136+
"""UserPromptSubmit hook — check inbox on user input."""
137+
_check_and_notify("UserPromptSubmit")
138+
139+
140+
def main() -> None:
141+
"""Entry point for msg-hook CLI."""
142+
cli()
143+
144+
145+
if __name__ == "__main__":
146+
main()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Detect whether a tmux pane's prompt is empty or has text.
2+
3+
Used by the watcher to decide if it's safe to type a
4+
slash command into the pane, or if the user is mid-typing.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import re
10+
import subprocess
11+
from enum import Enum
12+
13+
14+
class PromptState(str, Enum):
15+
"""State of an agent's input prompt."""
16+
17+
EMPTY = "empty" # Prompt visible, no user text
18+
HAS_TEXT = "has_text" # User is typing something
19+
UNKNOWN = "unknown" # Can't determine
20+
21+
# Prompt patterns: regex matching an empty prompt line.
22+
# The key is agent_kind, value is a compiled regex.
23+
# These match the prompt character with optional
24+
# whitespace and nothing else after it.
25+
PROMPT_PATTERNS: dict[str, re.Pattern] = {
26+
"claude": re.compile(
27+
r"^\s*[❯>]\s*$"
28+
),
29+
"codex": re.compile(
30+
r"^\s*[›>]\s*$"
31+
),
32+
}
33+
34+
# Patterns for a prompt with text after it
35+
PROMPT_WITH_TEXT_PATTERNS: dict[str, re.Pattern] = {
36+
"claude": re.compile(
37+
r"^\s*[❯>]\s+.+"
38+
),
39+
"codex": re.compile(
40+
r"^\s*[›>]\s+.+"
41+
),
42+
}
43+
44+
45+
def detect_prompt_state(
46+
pane_target: str,
47+
agent_kind: str = "claude",
48+
) -> PromptState:
49+
"""Check if a tmux pane's prompt is empty.
50+
51+
Args:
52+
pane_target: tmux pane identifier
53+
(e.g., "cctools:1.4" or "%12")
54+
agent_kind: "claude" or "codex"
55+
56+
Returns:
57+
PromptState indicating the prompt state.
58+
"""
59+
lines = _capture_last_lines(pane_target)
60+
if not lines:
61+
return PromptState.UNKNOWN
62+
63+
empty_pattern = PROMPT_PATTERNS.get(agent_kind)
64+
text_pattern = PROMPT_WITH_TEXT_PATTERNS.get(
65+
agent_kind,
66+
)
67+
68+
if not empty_pattern:
69+
return PromptState.UNKNOWN
70+
71+
# Scan all captured lines for prompt patterns.
72+
# The prompt may be surrounded by decorative lines
73+
# (separators, status bars, etc.) so we check all
74+
# lines, not just the last non-empty one.
75+
for line in reversed(lines):
76+
stripped = line.rstrip()
77+
if not stripped:
78+
continue
79+
if empty_pattern.match(stripped):
80+
return PromptState.EMPTY
81+
if text_pattern and text_pattern.match(stripped):
82+
return PromptState.HAS_TEXT
83+
84+
return PromptState.UNKNOWN
85+
86+
87+
def _capture_last_lines(
88+
pane_target: str,
89+
count: int = 15,
90+
) -> list[str]:
91+
"""Capture the last N lines from a tmux pane."""
92+
try:
93+
result = subprocess.run(
94+
[
95+
"tmux", "capture-pane",
96+
"-t", pane_target,
97+
"-p", # print to stdout
98+
],
99+
capture_output=True,
100+
text=True,
101+
timeout=5,
102+
)
103+
if result.returncode != 0:
104+
return []
105+
all_lines = result.stdout.splitlines()
106+
return all_lines[-count:] if all_lines else []
107+
except (FileNotFoundError, subprocess.TimeoutExpired):
108+
return []

claude_code_tools/msg/store.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
_now_iso,
2020
)
2121

22-
DEFAULT_DB_DIR = os.path.expanduser("~/.msg")
22+
DEFAULT_DB_DIR = os.environ.get(
23+
"MSG_DB_DIR",
24+
os.path.expanduser("~/.msg"),
25+
)
2326
DEFAULT_DB_PATH = os.path.join(DEFAULT_DB_DIR, "msg.db")
2427

2528
SCHEMA_SQL = """

0 commit comments

Comments
 (0)