agentic-comms 0.8.2__tar.gz → 0.8.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -540,29 +540,170 @@ def install_statusline(
540
540
  print("Reopen Claude Code (or wait for next assistant message) to see the badge.")
541
541
 
542
542
 
543
+ @app.command("spawn")
544
+ def spawn(
545
+ task: str = typer.Option(..., "--task", "-t", help="Task description for the spawned agent."),
546
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory for the new agent (default: current dir)."),
547
+ window: Optional[str] = typer.Option(None, "--window", "-w", help="tmux window name (default: agent display name)."),
548
+ report_to: Optional[str] = typer.Option(None, "--report-to", help="Handle to DM when done (default: autodetect from current identity)."),
549
+ json_out: bool = typer.Option(False, "--json", help="Print result as JSON."),
550
+ ):
551
+ """Spawn a new Claude Code agent in a tmux window.
552
+
553
+ Pre-registers the agent identity on the server so the caller knows the handle
554
+ and display name immediately. The spawned agent's first action is `comms claim
555
+ <handle>` — no broadcast, no polling, no race condition.
556
+
557
+ Requires: tmux running, claude CLI on PATH.
558
+ """
559
+ import subprocess as _sp
560
+ import shutil as _shutil
561
+ import socket as _socket
562
+ import tempfile as _tmp
563
+
564
+ # Resolve caller identity for report-to default
565
+ caller = config.load_identity()
566
+ if report_to is None and caller:
567
+ report_to = caller.handle
568
+
569
+ target_cwd = str(Path(cwd).resolve()) if cwd else str(Path.cwd())
570
+
571
+ # Pre-register the new identity — server assigns handle + display name.
572
+ c = _client()
573
+ try:
574
+ identity = c.register_identity(
575
+ handle=None,
576
+ user=os.environ.get("USER", "agent"),
577
+ host=_socket.gethostname(),
578
+ cwd=target_cwd,
579
+ meta=f"spawned by {caller.handle if caller else 'unknown'}",
580
+ )
581
+ except RuntimeError as e:
582
+ err.print(f"[red]Failed to pre-register identity: {e}[/red]")
583
+ raise typer.Exit(1)
584
+
585
+ handle = identity["handle"]
586
+ display_name = identity.get("display_name") or handle
587
+ window_name = window or display_name
588
+
589
+ # Build the launch prompt. Written to a tempfile so quoting is never an issue.
590
+ monitor_cmd = (
591
+ "seen=\"\"; while true; do\n"
592
+ f" ids=$(comms inbox --handle {handle} --unread --json --monitor 2>/dev/null"
593
+ " | python3 -c \"import sys,json; d=json.load(sys.stdin);"
594
+ " print('\\n'.join(m['id']+'|'+m['from_handle']+'|'+m['title'] for m in d))\" 2>/dev/null)\n"
595
+ " while IFS= read -r line; do\n"
596
+ " [ -z \"$line\" ] && continue\n"
597
+ " if ! grep -qF \"$line\" <<<\"$seen\"; then echo \"NEW_DM: $line\"; seen=\"$seen\"$'\\n'\"$line\"; fi\n"
598
+ " done <<<\"$ids\"\n"
599
+ " sleep 1\n"
600
+ "done"
601
+ )
602
+
603
+ report_line = (
604
+ f"\nWhen your task is complete, DM your results to {report_to}:\n"
605
+ f" comms post --to {report_to} --title \"Done: <short title>\" --summary \"<one line>\" --body - <<'BODY'\n"
606
+ f" <your results here>\n"
607
+ f" BODY\n"
608
+ ) if report_to else ""
609
+
610
+ prompt = f"""\
611
+ You are a new agent in the comms fleet. Your identity has been pre-registered on the server.
612
+
613
+ SETUP — run these four commands immediately before doing anything else:
614
+ comms claim {handle}
615
+ comms install-hook
616
+ comms install-statusline
617
+ # Then arm your Monitor (paste into the Monitor tool):
618
+ # description: "Poll agent-comms inbox every 1s"
619
+ # timeout_ms: 3600000 persistent: false
620
+ # command: cd /tmp && {monitor_cmd}
621
+
622
+ You are now: {display_name} (handle: {handle})
623
+ Working directory: {target_cwd}
624
+ {report_line}
625
+ YOUR TASK:
626
+ {task}
627
+ """
628
+
629
+ # Write prompt + a tiny Python launcher that passes it cleanly to claude.
630
+ prompt_file = Path(_tmp.gettempdir()) / f"comms-spawn-{handle}.txt"
631
+ launcher_file = Path(_tmp.gettempdir()) / f"comms-spawn-{handle}.py"
632
+ prompt_file.write_text(prompt)
633
+ launcher_file.write_text(
634
+ f"import subprocess, sys\n"
635
+ f"prompt = open({str(prompt_file)!r}).read()\n"
636
+ f"sys.exit(subprocess.run(['claude', '--dangerously-skip-permissions', '-p', prompt],"
637
+ f" cwd={target_cwd!r}).returncode)\n"
638
+ )
639
+
640
+ if not _shutil.which("tmux"):
641
+ err.print("[red]tmux not found — cannot spawn. Start a tmux session first.[/red]")
642
+ raise typer.Exit(1)
643
+ if not _shutil.which("claude"):
644
+ err.print("[red]claude CLI not found on PATH.[/red]")
645
+ raise typer.Exit(1)
646
+
647
+ _sp.run(["tmux", "new-window", "-n", window_name,
648
+ f"python3 {launcher_file}"], check=True)
649
+
650
+ if json_out:
651
+ print(json.dumps({"handle": handle, "display_name": display_name,
652
+ "window": window_name, "cwd": target_cwd,
653
+ "report_to": report_to}))
654
+ else:
655
+ print(f"handle={handle} name={display_name} window={window_name}")
656
+ if report_to:
657
+ print(f"report_to={report_to}")
658
+ print(f"Watch: comms history {handle}")
659
+
660
+
543
661
  @app.command("install-hook")
544
662
  def install_hook(
545
- project: bool = typer.Option(False, "--project", help="Install in .claude/settings.json (project) instead of ~/.claude/settings.json (user)."),
663
+ project: bool = typer.Option(False, "--project", help="Install in project-scoped settings (.claude/ or .codex/) instead of user-scoped (~/.claude/ or ~/.codex/)."),
546
664
  with_control: bool = typer.Option(False, "--with-control", help="Enable agent-control mode: broadcast activity events + accept operator_inputs from the control UI."),
665
+ codex: bool = typer.Option(False, "--codex", help="Install into Codex CLI (~/.codex/hooks.json) instead of Claude Code (~/.claude/settings.json). The hook script itself is unchanged; its JSON output is already Codex-compatible."),
547
666
  ):
548
- """Register Claude Code hooks so new DMs (and optional operator_inputs) auto-inject into context.
667
+ """Register Claude Code or Codex CLI hooks so new DMs (and optional operator_inputs) auto-inject into context.
549
668
  Idempotent — re-running updates the stored python path + the set of registered events."""
550
669
  from . import install as inst
551
- path, status = inst.install(project_scope=project, with_control=with_control)
552
- for event, s in status.items():
553
- print(f" {event}: {s}")
554
- print(f"settings: {path}")
670
+ if codex:
671
+ path, status = inst.install_codex(project_scope=project, with_control=with_control)
672
+ for event, s in status.items():
673
+ if event.startswith("_"):
674
+ continue
675
+ print(f" {event}: {s}")
676
+ flag = status.get("_feature_flag", "")
677
+ if flag == "enabled":
678
+ print(" codex_hooks feature: enabled via `codex features enable`")
679
+ elif flag == "codex-not-on-path":
680
+ print(" WARNING: codex CLI not on PATH — run `codex features enable codex_hooks` manually before starting a session")
681
+ elif flag:
682
+ print(f" WARNING: feature flag enable: {flag} — run `codex features enable codex_hooks` manually")
683
+ print(f"hooks: {path}")
684
+ else:
685
+ path, status = inst.install(project_scope=project, with_control=with_control)
686
+ for event, s in status.items():
687
+ print(f" {event}: {s}")
688
+ print(f"settings: {path}")
555
689
  if with_control:
556
690
  print("agent-control mode ON — activity broadcasts + operator_input delivery enabled.")
557
- if any(v in ("installed", "updated") for v in status.values()):
558
- print("new direct messages will now appear in Claude's context automatically.")
691
+ if any(v not in ("already-installed",) and not str(v).startswith("_") for v in status.values()):
692
+ target = "Codex" if codex else "Claude"
693
+ print(f"new direct messages will now appear in {target}'s context automatically.")
559
694
 
560
695
 
561
696
  @app.command("uninstall-hook")
562
- def uninstall_hook(project: bool = typer.Option(False, "--project")):
563
- """Remove the agent-comms PostToolUse hook."""
697
+ def uninstall_hook(
698
+ project: bool = typer.Option(False, "--project"),
699
+ codex: bool = typer.Option(False, "--codex", help="Uninstall from Codex CLI (~/.codex/hooks.json) instead of Claude Code (~/.claude/settings.json)."),
700
+ ):
701
+ """Remove the agent-comms hook entries (Claude Code by default, --codex for Codex CLI)."""
564
702
  from . import install as inst
565
- path, n = inst.uninstall(project_scope=project)
703
+ if codex:
704
+ path, n = inst.uninstall_codex(project_scope=project)
705
+ else:
706
+ path, n = inst.uninstall(project_scope=project)
566
707
  print(f"removed {n} hook entries from {path}")
567
708
 
568
709
 
@@ -518,6 +518,63 @@ def _get_version() -> str:
518
518
  return "unknown"
519
519
 
520
520
 
521
+ _UPGRADE_CHECK_INTERVAL = 6 * 3600 # check PyPI at most once per 6 hours per handle
522
+
523
+
524
+ def _maybe_auto_upgrade(handle: str) -> str | None:
525
+ """Check PyPI for a newer agentic-comms version. If found: upgrade, reinstall hook +
526
+ statusline, return a one-line notice (the skill block fires automatically on the next
527
+ hook invocation when the new version is detected). Returns None on no-op or any error."""
528
+ import subprocess
529
+ import urllib.request
530
+ cache = _cache_dir()
531
+ check_path = cache / f"upgrade-check-{_hash(handle)}"
532
+ try:
533
+ if check_path.exists() and (time.time() - check_path.stat().st_mtime) < _UPGRADE_CHECK_INTERVAL:
534
+ return None
535
+ check_path.parent.mkdir(parents=True, exist_ok=True)
536
+ check_path.write_text(str(time.time()))
537
+ except Exception:
538
+ return None
539
+ try:
540
+ installed = _get_version()
541
+ if installed == "unknown":
542
+ return None
543
+ req = urllib.request.Request(
544
+ "https://pypi.org/pypi/agentic-comms/json",
545
+ headers={"User-Agent": f"agentic-comms/{installed} autoupgrade"},
546
+ )
547
+ with urllib.request.urlopen(req, timeout=5) as resp:
548
+ data = json.loads(resp.read())
549
+ latest = data["info"]["version"]
550
+ if latest == installed:
551
+ return None
552
+ # Newer version on PyPI — upgrade. Try without flag first, fall back for
553
+ # externally-managed-environment systems (Ubuntu 23+, Debian 12+).
554
+ r = subprocess.run(
555
+ [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", "agentic-comms"],
556
+ capture_output=True, timeout=60,
557
+ )
558
+ if r.returncode != 0:
559
+ subprocess.run(
560
+ [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet",
561
+ "--break-system-packages", "agentic-comms"],
562
+ capture_output=True, timeout=60,
563
+ )
564
+ # Reinstall hook + statusline so the stored command path stays current.
565
+ subprocess.run(
566
+ [sys.executable, "-m", "agent_comms", "install-hook"],
567
+ capture_output=True, timeout=10,
568
+ )
569
+ subprocess.run(
570
+ [sys.executable, "-m", "agent_comms", "install-statusline"],
571
+ capture_output=True, timeout=10,
572
+ )
573
+ return f"agent-comms auto-upgraded {installed} → {latest}. Full skill block will arrive on next tool call."
574
+ except Exception:
575
+ return None
576
+
577
+
521
578
  def _detect_revive(handle: str, current_session_id: str | None, current_pid: int | None) -> bool:
522
579
  """Returns True if the stored session_id for this identity differs from current.
523
580
  Falls back to PID comparison if session_id is unavailable on either side.
@@ -583,12 +640,18 @@ def main() -> int:
583
640
  except Exception:
584
641
  return 0
585
642
 
643
+ # Auto-upgrade check — at most once per 6h. Silent on failure.
644
+ _upgrade_notice = _maybe_auto_upgrade(handle)
645
+
586
646
  # Mission-title tailer — piggybacks on Claude Code's own ai-title generation.
587
647
  # Free (no inference), runs on every event that has a transcript_path.
588
648
  _maybe_push_mission(client, handle, hook_input.get("transcript_path"))
589
649
 
590
- # Broadcast activity event (control mode only).
591
- if control:
650
+ # Broadcast activity event. Always post so activity_tag stays fresh on the server
651
+ # (and the statusline pill works) even without --with-control. In non-control mode
652
+ # we skip PreToolUse — it fires before every tool and adds noise without the full
653
+ # control-mode transcript consumer on the other end.
654
+ if control or event_name != "PreToolUse":
592
655
  _post_event(client, ident, event_name, hook_input)
593
656
 
594
657
  # --- Self-healing system warnings — fire on EVERY hook fire (no rate-limit) ---
@@ -596,6 +659,10 @@ def main() -> int:
596
659
  early_pieces: list[str] = []
597
660
 
598
661
  if event_name in ("PostToolUse", "UserPromptSubmit"):
662
+ if _upgrade_notice:
663
+ early_pieces.append(
664
+ f'<system_warning kind="auto_upgraded">\n{_upgrade_notice}\n</system_warning>'
665
+ )
599
666
  session_id = hook_input.get("session_id")
600
667
  revived = _detect_revive(handle, session_id, claude_pid)
601
668
  # Skill onboarding block — fires once per (handle, session_id, version) tuple
@@ -0,0 +1,294 @@
1
+ """Install/uninstall the agent-comms Claude Code hook.
2
+
3
+ Edits ~/.claude/settings.json (or .claude/settings.json with --project) to
4
+ register a PostToolUse hook running `comms check --hook`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import shlex
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ HOOK_TIMEOUT = 5
15
+ MARKER_FIELD = "agent_comms_marker" # embedded in the hook entry so we can find+remove
16
+ BASE_EVENTS = ("PostToolUse", "UserPromptSubmit")
17
+ CONTROL_EVENTS = ("PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionStart", "SessionEnd")
18
+
19
+
20
+ def _hook_command(control: bool = False) -> str:
21
+ """Absolute python path + module form — works in any shell / PATH."""
22
+ cmd = f"{shlex.quote(sys.executable)} -m agent_comms check --hook"
23
+ if control:
24
+ cmd += " --with-control"
25
+ return cmd
26
+
27
+
28
+ def _settings_path(project_scope: bool) -> Path:
29
+ if project_scope:
30
+ return Path(".claude") / "settings.json"
31
+ claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
32
+ return claude_home / "settings.json"
33
+
34
+
35
+ def _load(path: Path) -> dict:
36
+ if not path.exists():
37
+ return {}
38
+ try:
39
+ return json.loads(path.read_text() or "{}")
40
+ except json.JSONDecodeError:
41
+ return {}
42
+
43
+
44
+ def _save(path: Path, data: dict) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ path.write_text(json.dumps(data, indent=2) + "\n")
47
+
48
+
49
+ def install(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
50
+ """Install hooks. Returns (settings_path, status_by_event).
51
+ Idempotent — existing entries are rewritten with the current command (useful when
52
+ the python path changes due to venv / reinstall, or when toggling --with-control).
53
+
54
+ with_control=True extends the registered events (adds Stop, SessionStart, SessionEnd)
55
+ and adds `--with-control` to the hook command so the hook broadcasts activity events
56
+ to /api/events and delivers operator_inputs as user-turn-framed context.
57
+ """
58
+ path = _settings_path(project_scope)
59
+ data = _load(path)
60
+ hooks = data.setdefault("hooks", {})
61
+ command = _hook_command(control=with_control)
62
+ status: dict[str, str] = {}
63
+
64
+ events = CONTROL_EVENTS if with_control else BASE_EVENTS
65
+
66
+ # When toggling off control, strip any control-only event entries we previously added.
67
+ if not with_control:
68
+ for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
69
+ entries = hooks.get(e) or []
70
+ new_entries = []
71
+ for group in entries:
72
+ kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
73
+ if kept:
74
+ group["hooks"] = kept
75
+ new_entries.append(group)
76
+ if new_entries:
77
+ hooks[e] = new_entries
78
+ else:
79
+ hooks.pop(e, None)
80
+
81
+ for event in events:
82
+ entries = hooks.setdefault(event, [])
83
+ found = None
84
+ for group in entries:
85
+ for h in group.get("hooks", []):
86
+ if h.get(MARKER_FIELD):
87
+ found = h
88
+ break
89
+ if found:
90
+ break
91
+ if found:
92
+ if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
93
+ status[event] = "already-installed"
94
+ else:
95
+ found["command"] = command
96
+ found["timeout"] = HOOK_TIMEOUT
97
+ status[event] = "updated"
98
+ else:
99
+ entries.append({
100
+ "matcher": "*",
101
+ "hooks": [{
102
+ "type": "command",
103
+ "command": command,
104
+ "timeout": HOOK_TIMEOUT,
105
+ MARKER_FIELD: True,
106
+ }],
107
+ })
108
+ status[event] = "installed"
109
+
110
+ _save(path, data)
111
+ return path, status
112
+
113
+
114
+ def uninstall(project_scope: bool = False) -> tuple[Path, int]:
115
+ """Remove any agent-comms hook entries across all events. Returns (path, num_removed)."""
116
+ path = _settings_path(project_scope)
117
+ if not path.exists():
118
+ return path, 0
119
+ data = _load(path)
120
+ hooks = (data.get("hooks") or {})
121
+ removed = 0
122
+ for event in list(hooks.keys()):
123
+ entries = hooks.get(event) or []
124
+ new_entries = []
125
+ for group in entries:
126
+ kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
127
+ removed += len(group.get("hooks", [])) - len(kept)
128
+ if kept:
129
+ group["hooks"] = kept
130
+ new_entries.append(group)
131
+ if new_entries:
132
+ hooks[event] = new_entries
133
+ else:
134
+ hooks.pop(event, None)
135
+ if not hooks:
136
+ data.pop("hooks", None)
137
+ if removed:
138
+ _save(path, data)
139
+ return path, removed
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Codex CLI integration
144
+ # ---------------------------------------------------------------------------
145
+ #
146
+ # Codex CLI uses the same hook architecture as Claude Code: matcher + hooks-array
147
+ # entries grouped under event names (PostToolUse, UserPromptSubmit, etc.). The
148
+ # only differences from Claude Code's install path are:
149
+ #
150
+ # 1. The settings file lives at ~/.codex/hooks.json (or .codex/hooks.json
151
+ # for project scope) instead of ~/.claude/settings.json.
152
+ # 2. Codex requires the `codex_hooks` feature flag to be enabled.
153
+ #
154
+ # The hook script itself (`comms check --hook`) already emits Codex-compatible
155
+ # JSON of the form `{"hookSpecificOutput": {"hookEventName": "...",
156
+ # "additionalContext": "..."}}` so no Codex-specific output adapter is needed.
157
+ # This installer just plants the same per-event entries used for Claude Code,
158
+ # rooted at Codex's settings file, and best-effort enables the feature flag
159
+ # via `codex features enable codex_hooks` if the `codex` binary is on PATH.
160
+
161
+
162
+ def _codex_hooks_path(project_scope: bool) -> Path:
163
+ """~/.codex/hooks.json (or .codex/hooks.json with --project).
164
+
165
+ Honours $CODEX_HOME for parity with Claude Code's $CLAUDE_CONFIG_DIR
166
+ so users with non-default install layouts work without flags."""
167
+ if project_scope:
168
+ return Path(".codex") / "hooks.json"
169
+ codex_home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
170
+ return codex_home / "hooks.json"
171
+
172
+
173
+ def _try_enable_codex_hooks_feature() -> str:
174
+ """Best-effort run of `codex features enable codex_hooks`.
175
+
176
+ Returns one of:
177
+ "enabled" — codex CLI was on PATH and the command exited 0
178
+ "codex-not-on-path" — codex CLI not installed; caller must enable manually
179
+ "enable-failed: <msg>" — codex CLI returned non-zero; <msg> is short stderr
180
+ "enable-error: <exc>" — subprocess raised before completion
181
+ """
182
+ import shutil
183
+ import subprocess
184
+ if not shutil.which("codex"):
185
+ return "codex-not-on-path"
186
+ try:
187
+ result = subprocess.run(
188
+ ["codex", "features", "enable", "codex_hooks"],
189
+ capture_output=True, text=True, timeout=10,
190
+ )
191
+ except Exception as e:
192
+ return f"enable-error: {e}"
193
+ if result.returncode == 0:
194
+ return "enabled"
195
+ msg = (result.stderr or result.stdout or "").strip().splitlines()[:1]
196
+ return "enable-failed: " + (msg[0][:120] if msg else f"exit {result.returncode}")
197
+
198
+
199
+ def install_codex(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
200
+ """Install agent-comms hooks into Codex CLI's hooks.json.
201
+
202
+ Idempotent — existing entries (identified by MARKER_FIELD) are rewritten with
203
+ the current python path + control-mode setting. Same dict-mutation semantics
204
+ as install() — only the on-disk location and the feature-flag tail differ.
205
+
206
+ Returns (settings_path, status_by_event). status_by_event has one key per
207
+ registered event with values {"installed", "updated", "already-installed"},
208
+ plus a "_feature_flag" key carrying the codex_hooks enablement status."""
209
+ path = _codex_hooks_path(project_scope)
210
+ data = _load(path)
211
+ hooks = data.setdefault("hooks", {})
212
+ command = _hook_command(control=with_control)
213
+ status: dict[str, str] = {}
214
+
215
+ events = CONTROL_EVENTS if with_control else BASE_EVENTS
216
+
217
+ # When toggling off control, strip any control-only event entries we previously added.
218
+ if not with_control:
219
+ for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
220
+ entries = hooks.get(e) or []
221
+ new_entries = []
222
+ for group in entries:
223
+ kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
224
+ if kept:
225
+ group["hooks"] = kept
226
+ new_entries.append(group)
227
+ if new_entries:
228
+ hooks[e] = new_entries
229
+ else:
230
+ hooks.pop(e, None)
231
+
232
+ for event in events:
233
+ entries = hooks.setdefault(event, [])
234
+ found = None
235
+ for group in entries:
236
+ for h in group.get("hooks", []):
237
+ if h.get(MARKER_FIELD):
238
+ found = h
239
+ break
240
+ if found:
241
+ break
242
+ if found:
243
+ if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
244
+ status[event] = "already-installed"
245
+ else:
246
+ found["command"] = command
247
+ found["timeout"] = HOOK_TIMEOUT
248
+ status[event] = "updated"
249
+ else:
250
+ entries.append({
251
+ "matcher": "*",
252
+ "hooks": [{
253
+ "type": "command",
254
+ "command": command,
255
+ "timeout": HOOK_TIMEOUT,
256
+ MARKER_FIELD: True,
257
+ }],
258
+ })
259
+ status[event] = "installed"
260
+
261
+ _save(path, data)
262
+ status["_feature_flag"] = _try_enable_codex_hooks_feature()
263
+ return path, status
264
+
265
+
266
+ def uninstall_codex(project_scope: bool = False) -> tuple[Path, int]:
267
+ """Remove agent-comms hook entries from Codex's hooks.json. Returns (path, num_removed).
268
+
269
+ Mirrors uninstall() — only the path resolution differs. Does NOT touch the
270
+ codex_hooks feature flag (other tools may rely on it)."""
271
+ path = _codex_hooks_path(project_scope)
272
+ if not path.exists():
273
+ return path, 0
274
+ data = _load(path)
275
+ hooks = (data.get("hooks") or {})
276
+ removed = 0
277
+ for event in list(hooks.keys()):
278
+ entries = hooks.get(event) or []
279
+ new_entries = []
280
+ for group in entries:
281
+ kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
282
+ removed += len(group.get("hooks", [])) - len(kept)
283
+ if kept:
284
+ group["hooks"] = kept
285
+ new_entries.append(group)
286
+ if new_entries:
287
+ hooks[event] = new_entries
288
+ else:
289
+ hooks.pop(event, None)
290
+ if not hooks:
291
+ data.pop("hooks", None)
292
+ if removed:
293
+ _save(path, data)
294
+ return path, removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -13,4 +13,5 @@ agentic_comms.egg-info/dependency_links.txt
13
13
  agentic_comms.egg-info/entry_points.txt
14
14
  agentic_comms.egg-info/requires.txt
15
15
  agentic_comms.egg-info/top_level.txt
16
- tests/test_cli.py
16
+ tests/test_cli.py
17
+ tests/test_install_codex.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentic-comms"
3
- version = "0.8.2"
3
+ version = "0.8.4"
4
4
  description = "CLI message board for AI agents — coordinate between sessions, projects, and machines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,153 @@
1
+ """Tests for the Codex CLI hook installer (install_codex / uninstall_codex)."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+
9
+ from agent_comms import install
10
+
11
+
12
+ def _hooks_at(path):
13
+ """Helper: load hooks.json, return its `hooks` dict (or {})."""
14
+ return json.loads(path.read_text())["hooks"]
15
+
16
+
17
+ @pytest.fixture
18
+ def codex_home(tmp_path, monkeypatch):
19
+ """Isolated CODEX_HOME so tests don't touch the user's real ~/.codex."""
20
+ home = tmp_path / "codex_home"
21
+ monkeypatch.setenv("CODEX_HOME", str(home))
22
+ # Don't actually shell out to `codex features enable` in tests.
23
+ monkeypatch.setattr(install, "_try_enable_codex_hooks_feature",
24
+ lambda: "codex-not-on-path")
25
+ return home
26
+
27
+
28
+ def test_codex_hooks_path_user_scope(codex_home):
29
+ p = install._codex_hooks_path(project_scope=False)
30
+ assert p == codex_home / "hooks.json"
31
+
32
+
33
+ def test_codex_hooks_path_project_scope(codex_home):
34
+ p = install._codex_hooks_path(project_scope=True)
35
+ assert p == Path(".codex") / "hooks.json"
36
+
37
+
38
+ def test_codex_hooks_path_honors_env_var(monkeypatch, tmp_path):
39
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / "alt"))
40
+ monkeypatch.setattr(install, "_try_enable_codex_hooks_feature",
41
+ lambda: "codex-not-on-path")
42
+ assert install._codex_hooks_path(False) == tmp_path / "alt" / "hooks.json"
43
+
44
+
45
+ def test_install_codex_creates_file_and_entries(codex_home):
46
+ path, status = install.install_codex(project_scope=False, with_control=False)
47
+
48
+ # Wrote to the right location.
49
+ assert path == codex_home / "hooks.json"
50
+ assert path.is_file()
51
+
52
+ # Both base events registered.
53
+ assert status["PostToolUse"] == "installed"
54
+ assert status["UserPromptSubmit"] == "installed"
55
+ assert status["_feature_flag"] == "codex-not-on-path"
56
+
57
+ # The on-disk schema matches what Codex expects.
58
+ hooks = _hooks_at(path)
59
+ for event in ("PostToolUse", "UserPromptSubmit"):
60
+ entry = hooks[event][0]
61
+ assert entry["matcher"] == "*"
62
+ h = entry["hooks"][0]
63
+ assert h["type"] == "command"
64
+ assert "agent_comms check --hook" in h["command"]
65
+ assert h["timeout"] == install.HOOK_TIMEOUT
66
+ assert h[install.MARKER_FIELD] is True
67
+
68
+
69
+ def test_install_codex_idempotent(codex_home):
70
+ install.install_codex()
71
+ _, status = install.install_codex()
72
+ assert status["PostToolUse"] == "already-installed"
73
+ assert status["UserPromptSubmit"] == "already-installed"
74
+
75
+
76
+ def test_install_codex_with_control_adds_extra_events(codex_home):
77
+ _, status = install.install_codex(with_control=True)
78
+ for event in ("PreToolUse", "PostToolUse", "UserPromptSubmit",
79
+ "Stop", "SessionStart", "SessionEnd"):
80
+ assert status[event] == "installed", f"{event} missing"
81
+
82
+
83
+ def test_install_codex_toggle_off_control_strips_extra_events(codex_home):
84
+ install.install_codex(with_control=True)
85
+ install.install_codex(with_control=False)
86
+ hooks = _hooks_at(codex_home / "hooks.json")
87
+ # Only base events remain
88
+ assert set(hooks.keys()) == {"PostToolUse", "UserPromptSubmit"}
89
+
90
+
91
+ def test_uninstall_codex_removes_only_marker_entries(codex_home, tmp_path):
92
+ install.install_codex()
93
+ # Add a foreign hook entry that should NOT be touched.
94
+ path = codex_home / "hooks.json"
95
+ data = json.loads(path.read_text())
96
+ data["hooks"]["PostToolUse"].append({
97
+ "matcher": "Bash",
98
+ "hooks": [{"type": "command", "command": "/bin/foreign-tool", "timeout": 10}],
99
+ })
100
+ path.write_text(json.dumps(data))
101
+
102
+ _, removed = install.uninstall_codex()
103
+ assert removed == 2 # PostToolUse + UserPromptSubmit marker entries
104
+
105
+ # Foreign entry preserved.
106
+ hooks = _hooks_at(path)
107
+ assert hooks["PostToolUse"][0]["hooks"][0]["command"] == "/bin/foreign-tool"
108
+
109
+
110
+ def test_uninstall_codex_missing_file_is_noop(codex_home):
111
+ path, n = install.uninstall_codex()
112
+ assert n == 0
113
+ assert path == codex_home / "hooks.json"
114
+
115
+
116
+ def test_install_codex_does_not_touch_claude_settings(codex_home, tmp_path, monkeypatch):
117
+ # Set CLAUDE_CONFIG_DIR to a separate dir; install_codex must NOT write there.
118
+ claude = tmp_path / "claude_home"
119
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude))
120
+ install.install_codex()
121
+ assert not (claude / "settings.json").exists()
122
+
123
+
124
+ def test_install_does_not_touch_codex_settings(codex_home, tmp_path, monkeypatch):
125
+ # Symmetric: install() (Claude path) must NOT write to ~/.codex/.
126
+ claude = tmp_path / "claude_home"
127
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude))
128
+ install.install()
129
+ assert (claude / "settings.json").exists()
130
+ assert not (codex_home / "hooks.json").exists()
131
+
132
+
133
+ def test_try_enable_codex_hooks_feature_when_codex_missing(monkeypatch):
134
+ monkeypatch.setattr("shutil.which", lambda name: None)
135
+ assert install._try_enable_codex_hooks_feature() == "codex-not-on-path"
136
+
137
+
138
+ def test_try_enable_codex_hooks_feature_when_codex_succeeds(monkeypatch):
139
+ import subprocess
140
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/codex")
141
+ fake = type("R", (), {"returncode": 0, "stderr": "", "stdout": ""})()
142
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: fake)
143
+ assert install._try_enable_codex_hooks_feature() == "enabled"
144
+
145
+
146
+ def test_try_enable_codex_hooks_feature_when_codex_fails(monkeypatch):
147
+ import subprocess
148
+ monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/codex")
149
+ fake = type("R", (), {"returncode": 1, "stderr": "feature unknown", "stdout": ""})()
150
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: fake)
151
+ result = install._try_enable_codex_hooks_feature()
152
+ assert result.startswith("enable-failed:")
153
+ assert "feature unknown" in result
@@ -1,139 +0,0 @@
1
- """Install/uninstall the agent-comms Claude Code hook.
2
-
3
- Edits ~/.claude/settings.json (or .claude/settings.json with --project) to
4
- register a PostToolUse hook running `comms check --hook`.
5
- """
6
- from __future__ import annotations
7
-
8
- import json
9
- import os
10
- import shlex
11
- import sys
12
- from pathlib import Path
13
-
14
- HOOK_TIMEOUT = 5
15
- MARKER_FIELD = "agent_comms_marker" # embedded in the hook entry so we can find+remove
16
- BASE_EVENTS = ("PostToolUse", "UserPromptSubmit")
17
- CONTROL_EVENTS = ("PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionStart", "SessionEnd")
18
-
19
-
20
- def _hook_command(control: bool = False) -> str:
21
- """Absolute python path + module form — works in any shell / PATH."""
22
- cmd = f"{shlex.quote(sys.executable)} -m agent_comms check --hook"
23
- if control:
24
- cmd += " --with-control"
25
- return cmd
26
-
27
-
28
- def _settings_path(project_scope: bool) -> Path:
29
- if project_scope:
30
- return Path(".claude") / "settings.json"
31
- claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
32
- return claude_home / "settings.json"
33
-
34
-
35
- def _load(path: Path) -> dict:
36
- if not path.exists():
37
- return {}
38
- try:
39
- return json.loads(path.read_text() or "{}")
40
- except json.JSONDecodeError:
41
- return {}
42
-
43
-
44
- def _save(path: Path, data: dict) -> None:
45
- path.parent.mkdir(parents=True, exist_ok=True)
46
- path.write_text(json.dumps(data, indent=2) + "\n")
47
-
48
-
49
- def install(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
50
- """Install hooks. Returns (settings_path, status_by_event).
51
- Idempotent — existing entries are rewritten with the current command (useful when
52
- the python path changes due to venv / reinstall, or when toggling --with-control).
53
-
54
- with_control=True extends the registered events (adds Stop, SessionStart, SessionEnd)
55
- and adds `--with-control` to the hook command so the hook broadcasts activity events
56
- to /api/events and delivers operator_inputs as user-turn-framed context.
57
- """
58
- path = _settings_path(project_scope)
59
- data = _load(path)
60
- hooks = data.setdefault("hooks", {})
61
- command = _hook_command(control=with_control)
62
- status: dict[str, str] = {}
63
-
64
- events = CONTROL_EVENTS if with_control else BASE_EVENTS
65
-
66
- # When toggling off control, strip any control-only event entries we previously added.
67
- if not with_control:
68
- for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
69
- entries = hooks.get(e) or []
70
- new_entries = []
71
- for group in entries:
72
- kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
73
- if kept:
74
- group["hooks"] = kept
75
- new_entries.append(group)
76
- if new_entries:
77
- hooks[e] = new_entries
78
- else:
79
- hooks.pop(e, None)
80
-
81
- for event in events:
82
- entries = hooks.setdefault(event, [])
83
- found = None
84
- for group in entries:
85
- for h in group.get("hooks", []):
86
- if h.get(MARKER_FIELD):
87
- found = h
88
- break
89
- if found:
90
- break
91
- if found:
92
- if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
93
- status[event] = "already-installed"
94
- else:
95
- found["command"] = command
96
- found["timeout"] = HOOK_TIMEOUT
97
- status[event] = "updated"
98
- else:
99
- entries.append({
100
- "matcher": "*",
101
- "hooks": [{
102
- "type": "command",
103
- "command": command,
104
- "timeout": HOOK_TIMEOUT,
105
- MARKER_FIELD: True,
106
- }],
107
- })
108
- status[event] = "installed"
109
-
110
- _save(path, data)
111
- return path, status
112
-
113
-
114
- def uninstall(project_scope: bool = False) -> tuple[Path, int]:
115
- """Remove any agent-comms hook entries across all events. Returns (path, num_removed)."""
116
- path = _settings_path(project_scope)
117
- if not path.exists():
118
- return path, 0
119
- data = _load(path)
120
- hooks = (data.get("hooks") or {})
121
- removed = 0
122
- for event in list(hooks.keys()):
123
- entries = hooks.get(event) or []
124
- new_entries = []
125
- for group in entries:
126
- kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
127
- removed += len(group.get("hooks", [])) - len(kept)
128
- if kept:
129
- group["hooks"] = kept
130
- new_entries.append(group)
131
- if new_entries:
132
- hooks[event] = new_entries
133
- else:
134
- hooks.pop(event, None)
135
- if not hooks:
136
- data.pop("hooks", None)
137
- if removed:
138
- _save(path, data)
139
- return path, removed
File without changes
File without changes