agent-recon 1.0.1

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 (44) hide show
  1. package/.claude/hooks/send-event-wsl.py +339 -0
  2. package/.claude/hooks/send-event.py +334 -0
  3. package/CHANGELOG.md +66 -0
  4. package/CONTRIBUTING.md +70 -0
  5. package/EULA.md +223 -0
  6. package/INSTALL.md +193 -0
  7. package/LICENSE +287 -0
  8. package/LICENSE-COMMERCIAL +241 -0
  9. package/PRIVACY.md +115 -0
  10. package/README.md +182 -0
  11. package/SECURITY.md +63 -0
  12. package/TERMS.md +233 -0
  13. package/install-service.ps1 +302 -0
  14. package/installer/cli.js +177 -0
  15. package/installer/detect.js +355 -0
  16. package/installer/install.js +195 -0
  17. package/installer/manifest.js +140 -0
  18. package/installer/package.json +12 -0
  19. package/installer/steps/api-keys.js +59 -0
  20. package/installer/steps/directory.js +41 -0
  21. package/installer/steps/env-report.js +48 -0
  22. package/installer/steps/hooks.js +149 -0
  23. package/installer/steps/service.js +159 -0
  24. package/installer/steps/tls.js +104 -0
  25. package/installer/steps/verify.js +117 -0
  26. package/installer/steps/welcome.js +46 -0
  27. package/installer/ui.js +133 -0
  28. package/installer/uninstall.js +233 -0
  29. package/installer/upgrade.js +289 -0
  30. package/package.json +58 -0
  31. package/public/index.html +13953 -0
  32. package/server/fixtures/allowlist-profiles.json +185 -0
  33. package/server/package.json +34 -0
  34. package/server/platform.js +270 -0
  35. package/server/rules/gitleaks.toml +3214 -0
  36. package/server/rules/security.yara +579 -0
  37. package/server/start.js +178 -0
  38. package/service/agent-recon.service +30 -0
  39. package/service/com.agent-recon.server.plist +56 -0
  40. package/setup-linux.sh +259 -0
  41. package/setup-macos.sh +264 -0
  42. package/setup-wsl.sh +248 -0
  43. package/setup.ps1 +171 -0
  44. package/start-agent-recon.bat +4 -0
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 PNW Great Loop LLC. All rights reserved.
3
+ # Licensed under the MIT License — see file header below.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this file, to deal in it without restriction, including without limitation
7
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8
+ # and/or sell copies, subject to the following conditions: The above copyright
9
+ # notice and this permission notice shall be included in all copies or
10
+ # substantial portions of the file. THE FILE IS PROVIDED "AS IS", WITHOUT
11
+ # WARRANTY OF ANY KIND.
12
+
13
+ """
14
+ Agent Recon — WSL Hook Forwarder
15
+ ==========================================
16
+ Drop-in replacement for send-event.py when Claude Code is running inside WSL
17
+ (including inside a tmux session or VSCode's WSL integrated terminal).
18
+
19
+ Key differences from the Windows version:
20
+ - Probes localhost:3131 first (WSL2 localhost-forwarding, always fastest)
21
+ - Falls back to detected Windows host IP (ip route → resolv.conf → hardcoded)
22
+ - Retries once on transient failure
23
+ - Tags every event with origin = "wsl" so the dashboard can display a WSL badge
24
+ - Optionally logs to ~/.claude/agent-recon-debug.log when AGENT_RECON_DEBUG=1
25
+ - stdlib only, exits 0, never blocks Claude
26
+
27
+ Usage in ~/.claude/settings.json (inside WSL — after running setup-wsl.sh):
28
+ "command": "python3 ~/.claude/hooks/send-event-wsl.py"
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ import urllib.request
37
+ import urllib.error
38
+ from datetime import datetime, timezone
39
+
40
+ AGENT_RECON_PORT = 3131
41
+ TIMEOUT_SEC = 3 # fast enough to stay non-blocking
42
+ RETRY_DELAY = 0.2 # seconds before single retry
43
+
44
+
45
+ # ── Debug logging ────────────────────────────────────────────────────────────
46
+
47
+ def _debug(msg: str) -> None:
48
+ """Append msg to ~/.claude/agent-recon-debug.log when AGENT_RECON_DEBUG=1."""
49
+ if not os.environ.get('AGENT_RECON_DEBUG'):
50
+ return
51
+ try:
52
+ log = os.path.expanduser('~/.claude/agent-recon-debug.log')
53
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')
54
+ with open(log, 'a') as fh:
55
+ fh.write(f"{ts} [wsl] {msg}\n")
56
+ except Exception:
57
+ pass
58
+
59
+
60
+ # ── Server URL resolution ────────────────────────────────────────────────────
61
+
62
+ def _windows_host_ip() -> str:
63
+ """
64
+ Return the Windows host IP as seen from WSL2.
65
+
66
+ The Windows host is the *default gateway* of the WSL2 virtual network.
67
+ We get it from 'ip route show default' first because /etc/resolv.conf
68
+ may contain WSL's internal DNS stub (10.255.255.254) rather than the
69
+ actual host IP when systemd-resolved or the WSL DNS proxy is active.
70
+ """
71
+ # Method 1: default gateway from ip route (most reliable — always the host)
72
+ try:
73
+ out = subprocess.check_output(
74
+ ["ip", "route", "show", "default"],
75
+ stderr=subprocess.DEVNULL,
76
+ timeout=2,
77
+ ).decode()
78
+ m = re.search(r"via\s+(\d{1,3}(?:\.\d{1,3}){3})", out)
79
+ if m:
80
+ return m.group(1)
81
+ except Exception:
82
+ pass
83
+
84
+ # Method 2: /etc/resolv.conf nameserver — only useful when it actually
85
+ # points to the Windows host (older WSL2 without DNS proxy).
86
+ # Skip the WSL-internal stub addresses (10.255.255.254, 127.x.x.x).
87
+ try:
88
+ with open("/etc/resolv.conf") as fh:
89
+ for line in fh:
90
+ line = line.strip()
91
+ if line.startswith("nameserver"):
92
+ ip = line.split()[1]
93
+ if re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", ip):
94
+ if not ip.startswith("10.255.") and not ip.startswith("127."):
95
+ return ip
96
+ except OSError:
97
+ pass
98
+
99
+ # No reliable fallback — return None so callers only try localhost
100
+ _debug("Could not determine Windows host IP — using localhost only")
101
+ return None
102
+
103
+
104
+ def _resolve_event_url() -> str:
105
+ """
106
+ Return the best reachable URL for the Agent Recon /event endpoint.
107
+
108
+ WSL2 (modern) proxies localhost → Windows host automatically, so we
109
+ probe localhost first. Only if that fails (e.g. localhostForwarding=false
110
+ in .wslconfig) do we try the detected gateway IP.
111
+ """
112
+ host_ip = _windows_host_ip()
113
+ candidates = ["localhost"] + ([host_ip] if host_ip else [])
114
+ for host in candidates:
115
+ url = f"http://{host}:{AGENT_RECON_PORT}/health"
116
+ try:
117
+ urllib.request.urlopen(url, timeout=1)
118
+ event_url = f"http://{host}:{AGENT_RECON_PORT}/event"
119
+ _debug(f"resolved server → {event_url}")
120
+ return event_url
121
+ except Exception:
122
+ _debug(f"probe failed: {url}")
123
+
124
+ # Both failed — return localhost anyway; the actual POST will also fail
125
+ # (silently), but at least we don't hang.
126
+ return f"http://localhost:{AGENT_RECON_PORT}/event"
127
+
128
+
129
+ # ── Transcript token extraction ──────────────────────────────────────────────
130
+
131
+ def _extract_usage_from_transcript(transcript_path: str):
132
+ """
133
+ Read the JSONL transcript and sum token usage across all assistant turns.
134
+ Claude Code's Stop hook omits usage, but the transcript file has per-turn
135
+ usage from each API response. Returns a dict or None on failure.
136
+ """
137
+ totals = {
138
+ 'input_tokens': 0,
139
+ 'output_tokens': 0,
140
+ 'cache_read_input_tokens': 0,
141
+ 'cache_creation_input_tokens': 0,
142
+ }
143
+ found = False
144
+ try:
145
+ with open(transcript_path, 'r', encoding='utf-8', errors='replace') as fh:
146
+ for line in fh:
147
+ line = line.strip()
148
+ if not line:
149
+ continue
150
+ try:
151
+ obj = json.loads(line)
152
+ except json.JSONDecodeError:
153
+ continue
154
+ usage = None
155
+ # Primary format: {"type": "assistant", "message": {"usage": {...}}}
156
+ if obj.get('type') == 'assistant':
157
+ usage = (obj.get('message') or {}).get('usage')
158
+ # Fallback: {"role": "assistant", "usage": {...}}
159
+ elif obj.get('role') == 'assistant':
160
+ usage = obj.get('usage')
161
+ if usage and isinstance(usage, dict):
162
+ totals['input_tokens'] += usage.get('input_tokens', 0) or 0
163
+ totals['output_tokens'] += usage.get('output_tokens', 0) or 0
164
+ totals['cache_read_input_tokens'] += usage.get('cache_read_input_tokens', 0) or 0
165
+ totals['cache_creation_input_tokens'] += usage.get('cache_creation_input_tokens', 0) or 0
166
+ found = True
167
+ except Exception:
168
+ return None
169
+ return totals if found else None
170
+
171
+
172
+ # ── Main ─────────────────────────────────────────────────────────────────────
173
+
174
+ def _post(url: str, data: bytes) -> None:
175
+ req = urllib.request.Request(
176
+ url,
177
+ data=data,
178
+ headers={"Content-Type": "application/json"},
179
+ method="POST",
180
+ )
181
+ urllib.request.urlopen(req, timeout=TIMEOUT_SEC)
182
+
183
+
184
+ def _detect_terminal_env() -> str:
185
+ """Return a short tag describing the WSL terminal environment."""
186
+ if os.environ.get("TMUX"):
187
+ return "tmux"
188
+ if os.environ.get("VSCODE_INJECTION") or os.environ.get("VSCODE_GIT_IPC_HANDLE"):
189
+ return "vscode-wsl"
190
+ return "wsl"
191
+
192
+
193
+ def _detect_environment() -> dict:
194
+ """Detect terminal environment. Returns {'flat': str, 'detail': str}."""
195
+ flat = _detect_terminal_env()
196
+
197
+ # --- OS ---
198
+ if sys.platform == 'win32':
199
+ os_tag = 'windows'
200
+ elif sys.platform == 'darwin':
201
+ os_tag = 'macos'
202
+ else:
203
+ # Check for WSL
204
+ try:
205
+ with open('/proc/version', 'r') as fh:
206
+ pv = fh.read().lower()
207
+ if 'microsoft' in pv or 'wsl' in pv:
208
+ os_tag = 'wsl'
209
+ else:
210
+ os_tag = 'linux'
211
+ except Exception:
212
+ os_tag = 'linux'
213
+
214
+ # --- Terminal emulator ---
215
+ if os.environ.get('WT_SESSION'):
216
+ terminal = 'wt'
217
+ elif os.environ.get('ITERM_SESSION_ID'):
218
+ terminal = 'iterm2'
219
+ elif os.environ.get('GNOME_TERMINAL_SCREEN'):
220
+ terminal = 'gnome-terminal'
221
+ elif os.environ.get('KONSOLE_VERSION'):
222
+ terminal = 'konsole'
223
+ elif os.environ.get('ALACRITTY_WINDOW_ID'):
224
+ terminal = 'alacritty'
225
+ elif os.environ.get('TERM_PROGRAM') == 'Apple_Terminal':
226
+ terminal = 'terminal-app'
227
+ else:
228
+ terminal = 'unknown'
229
+
230
+ # --- Shell ---
231
+ if os.environ.get('PSModulePath'):
232
+ shell = 'pwsh'
233
+ elif os.environ.get('BASH_VERSION'):
234
+ shell = 'bash'
235
+ elif os.environ.get('ZSH_VERSION'):
236
+ shell = 'zsh'
237
+ elif os.environ.get('COMSPEC') and not os.environ.get('PSModulePath'):
238
+ shell = 'cmd'
239
+ else:
240
+ shell = 'unknown'
241
+
242
+ # --- Multiplexer (optional) ---
243
+ mux = None
244
+ if os.environ.get('TMUX'):
245
+ mux = 'tmux'
246
+ elif os.environ.get('STY'):
247
+ mux = 'screen'
248
+
249
+ # --- IDE (optional) ---
250
+ ide = None
251
+ if os.environ.get('VSCODE_INJECTION') or os.environ.get('VSCODE_GIT_IPC_HANDLE'):
252
+ ide = 'vscode'
253
+ elif os.environ.get('JETBRAINS_IDE'):
254
+ ide = 'jetbrains'
255
+
256
+ # Build detail string — always 5 parts so positional parser is unambiguous
257
+ detail = f"{os_tag}/{terminal}/{shell}/{mux or 'none'}/{ide or 'none'}"
258
+
259
+ return {'flat': flat, 'detail': detail}
260
+
261
+
262
+ def main() -> None:
263
+ try:
264
+ raw = sys.stdin.read()
265
+ if not raw or not raw.strip():
266
+ sys.exit(0)
267
+
268
+ payload = json.loads(raw)
269
+
270
+ # Enrich with client-side timestamp (same as Windows forwarder)
271
+ payload["hook_timestamp"] = datetime.now(timezone.utc).isoformat()
272
+
273
+ # Mark this event as originating from WSL
274
+ payload["origin"] = "wsl"
275
+
276
+ # Detect terminal environment for diagnostics
277
+ env = _detect_environment()
278
+ payload["terminal_env"] = env["flat"]
279
+ payload["terminal_env_detail"] = env["detail"]
280
+
281
+ # Derive project name from cwd — walk up to find the git root so that
282
+ # sessions running from a subdirectory (e.g. agent-recon/server) still
283
+ # report the actual project name (agent-recon) instead of the leaf dir.
284
+ cwd = payload.get("cwd", "")
285
+ if cwd:
286
+ normalized = cwd.replace("\\", "/").rstrip("/")
287
+ parts = [p for p in normalized.split("/") if p]
288
+ project_name = None
289
+ for i in range(len(parts), 0, -1):
290
+ candidate = "/" + "/".join(parts[:i])
291
+ try:
292
+ if os.path.isdir(candidate + "/.git"):
293
+ project_name = parts[i - 1]
294
+ break
295
+ except Exception:
296
+ pass
297
+ if not project_name:
298
+ project_name = parts[-1] if parts else cwd
299
+ payload["project_name"] = project_name
300
+
301
+ # For Stop events: read the transcript to get cumulative token usage.
302
+ # Claude Code's Stop hook omits usage; the transcript has it per turn.
303
+ if payload.get("hook_event_name") == "Stop" and not payload.get("usage"):
304
+ tp = payload.get("transcript_path", "")
305
+ if tp:
306
+ usage = _extract_usage_from_transcript(tp)
307
+ if usage:
308
+ payload["usage"] = usage
309
+ _debug(f"transcript usage: in={usage['input_tokens']} out={usage['output_tokens']}")
310
+
311
+ ev_name = payload.get("hook_event_name", "?")
312
+ _debug(f"event={ev_name} sess={payload.get('session_id','?')[:12]}")
313
+
314
+ server_url = _resolve_event_url()
315
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
316
+
317
+ try:
318
+ _post(server_url, data)
319
+ _debug(f"posted OK → {server_url}")
320
+ except (urllib.error.URLError, OSError) as exc:
321
+ _debug(f"POST failed ({exc}), retrying in {RETRY_DELAY}s")
322
+ import time
323
+ time.sleep(RETRY_DELAY)
324
+ try:
325
+ _post(server_url, data)
326
+ _debug("retry OK")
327
+ except Exception as exc2:
328
+ _debug(f"retry failed: {exc2}")
329
+
330
+ except json.JSONDecodeError as exc:
331
+ _debug(f"JSON parse error: {exc}")
332
+ except Exception as exc:
333
+ _debug(f"unexpected error: {exc}")
334
+
335
+ sys.exit(0)
336
+
337
+
338
+ if __name__ == "__main__":
339
+ main()
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2026 PNW Great Loop LLC. All rights reserved.
3
+ # Licensed under the MIT License — see file header below.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this file, to deal in it without restriction, including without limitation
7
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8
+ # and/or sell copies, subject to the following conditions: The above copyright
9
+ # notice and this permission notice shall be included in all copies or
10
+ # substantial portions of the file. THE FILE IS PROVIDED "AS IS", WITHOUT
11
+ # WARRANTY OF ANY KIND.
12
+
13
+ """
14
+ Agent Recon — Cross-Platform Hook Forwarder
15
+ ==============================================
16
+ Reads the hook JSON payload from stdin, enriches it with a timestamp,
17
+ and POSTs it to the Agent Recon server via stdlib only (no pip install needed).
18
+
19
+ Works on Windows, macOS, and native Linux. WSL users should use
20
+ ``send-event-wsl.py`` instead.
21
+
22
+ Features:
23
+ - Retries once on transient failure
24
+ - Detects VSCode / PowerShell / CMD / macOS / Linux terminal environment
25
+ - Optionally logs to ~/.claude/agent-recon-debug.log
26
+ when AGENT_RECON_DEBUG=1 is set
27
+ - Always exits 0 so it never blocks or interferes with Claude Code
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import ssl
33
+ import sys
34
+ import urllib.request
35
+ import urllib.error
36
+ from datetime import datetime, timezone
37
+
38
+ AGENT_RECON_PORT = 3131
39
+ AGENT_RECON_HTTPS_PORT = int(os.environ.get('AGENT_RECON_HTTPS_PORT', '3132'))
40
+ TIMEOUT_SEC = 3 # seconds – fast enough to stay async
41
+ RETRY_DELAY = 0.2 # seconds before single retry
42
+
43
+ # Skip-verify SSL context for self-signed localhost certs (hybrid TLS mode)
44
+ _SSL_CTX = ssl.create_default_context()
45
+ _SSL_CTX.check_hostname = False
46
+ _SSL_CTX.verify_mode = ssl.CERT_NONE
47
+
48
+
49
+ # ── Debug logging ────────────────────────────────────────────────────────────
50
+
51
+ def _log_tag() -> str:
52
+ """Return a short platform tag for debug log lines."""
53
+ if sys.platform == 'win32':
54
+ return 'win'
55
+ if sys.platform == 'darwin':
56
+ return 'mac'
57
+ return 'linux'
58
+
59
+
60
+ def _debug(msg: str) -> None:
61
+ """Append msg to ~/.claude/agent-recon-debug.log when AGENT_RECON_DEBUG=1."""
62
+ if not os.environ.get('AGENT_RECON_DEBUG'):
63
+ return
64
+ try:
65
+ home = os.environ.get('USERPROFILE') or os.path.expanduser('~')
66
+ log = os.path.join(home, '.claude', 'agent-recon-debug.log')
67
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')
68
+ tag = _log_tag()
69
+ with open(log, 'a') as fh:
70
+ fh.write(f"{ts} [{tag}] {msg}\n")
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ # ── Helpers ──────────────────────────────────────────────────────────────────
76
+
77
+ def _detect_terminal_env() -> str:
78
+ """Return a short flat tag describing the terminal environment."""
79
+ is_vscode = os.environ.get('VSCODE_INJECTION') or os.environ.get('VSCODE_GIT_IPC_HANDLE')
80
+
81
+ # ── Windows ──────────────────────────────────────────────────────────
82
+ if sys.platform == 'win32':
83
+ if is_vscode:
84
+ return 'vscode-win'
85
+ if os.environ.get('PSModulePath'):
86
+ return 'powershell'
87
+ return 'cmd'
88
+
89
+ # ── macOS ────────────────────────────────────────────────────────────
90
+ if sys.platform == 'darwin':
91
+ if is_vscode:
92
+ return 'vscode-macos'
93
+ return 'macos'
94
+
95
+ # ── Linux / WSL ──────────────────────────────────────────────────────
96
+ try:
97
+ with open('/proc/version', 'r') as fh:
98
+ pv = fh.read().lower()
99
+ if 'microsoft' in pv or 'wsl' in pv:
100
+ return 'wsl'
101
+ except Exception:
102
+ pass
103
+ if is_vscode:
104
+ return 'vscode-linux'
105
+ return 'linux'
106
+
107
+
108
+ def _detect_environment() -> dict:
109
+ """Detect terminal environment. Returns {'flat': str, 'detail': str}."""
110
+ flat = _detect_terminal_env()
111
+
112
+ # --- OS ---
113
+ if sys.platform == 'win32':
114
+ os_tag = 'windows'
115
+ elif sys.platform == 'darwin':
116
+ os_tag = 'macos'
117
+ else:
118
+ # Check for WSL
119
+ try:
120
+ with open('/proc/version', 'r') as fh:
121
+ pv = fh.read().lower()
122
+ if 'microsoft' in pv or 'wsl' in pv:
123
+ os_tag = 'wsl'
124
+ else:
125
+ os_tag = 'linux'
126
+ except Exception:
127
+ os_tag = 'linux'
128
+
129
+ # --- Terminal emulator ---
130
+ if os.environ.get('WT_SESSION'):
131
+ terminal = 'wt'
132
+ elif os.environ.get('ITERM_SESSION_ID'):
133
+ terminal = 'iterm2'
134
+ elif os.environ.get('GNOME_TERMINAL_SCREEN'):
135
+ terminal = 'gnome-terminal'
136
+ elif os.environ.get('KONSOLE_VERSION'):
137
+ terminal = 'konsole'
138
+ elif os.environ.get('ALACRITTY_WINDOW_ID'):
139
+ terminal = 'alacritty'
140
+ elif os.environ.get('TERM_PROGRAM') == 'Apple_Terminal':
141
+ terminal = 'terminal-app'
142
+ else:
143
+ terminal = 'unknown'
144
+
145
+ # --- Shell ---
146
+ if os.environ.get('PSModulePath'):
147
+ shell = 'pwsh'
148
+ elif os.environ.get('BASH_VERSION'):
149
+ shell = 'bash'
150
+ elif os.environ.get('ZSH_VERSION'):
151
+ shell = 'zsh'
152
+ elif os.environ.get('COMSPEC') and not os.environ.get('PSModulePath'):
153
+ shell = 'cmd'
154
+ else:
155
+ shell = 'unknown'
156
+
157
+ # --- Multiplexer (optional) ---
158
+ mux = None
159
+ if os.environ.get('TMUX'):
160
+ mux = 'tmux'
161
+ elif os.environ.get('STY'):
162
+ mux = 'screen'
163
+
164
+ # --- IDE (optional) ---
165
+ ide = None
166
+ if os.environ.get('VSCODE_INJECTION') or os.environ.get('VSCODE_GIT_IPC_HANDLE'):
167
+ ide = 'vscode'
168
+ elif os.environ.get('JETBRAINS_IDE'):
169
+ ide = 'jetbrains'
170
+
171
+ # Build detail string — always 5 parts so positional parser is unambiguous
172
+ detail = f"{os_tag}/{terminal}/{shell}/{mux or 'none'}/{ide or 'none'}"
173
+
174
+ return {'flat': flat, 'detail': detail}
175
+
176
+
177
+ def _resolve_server() -> tuple:
178
+ """
179
+ Return (event_url, ssl_ctx_or_None) for the best reachable Agent Recon endpoint.
180
+ Tries HTTPS first (skip-verify for self-signed), falls back to HTTP.
181
+ """
182
+ candidates = [
183
+ (f"https://localhost:{AGENT_RECON_HTTPS_PORT}", _SSL_CTX),
184
+ (f"http://localhost:{AGENT_RECON_PORT}", None),
185
+ ]
186
+ for base, ctx in candidates:
187
+ try:
188
+ req = urllib.request.Request(f"{base}/health")
189
+ if ctx:
190
+ urllib.request.urlopen(req, context=ctx, timeout=1)
191
+ else:
192
+ urllib.request.urlopen(req, timeout=1)
193
+ return (f"{base}/event", ctx)
194
+ except Exception:
195
+ pass
196
+ return (f"http://localhost:{AGENT_RECON_PORT}/event", None)
197
+
198
+
199
+ def _post(url: str, data: bytes, ssl_ctx=None) -> None:
200
+ req = urllib.request.Request(
201
+ url,
202
+ data=data,
203
+ headers={"Content-Type": "application/json"},
204
+ method="POST",
205
+ )
206
+ if ssl_ctx:
207
+ urllib.request.urlopen(req, context=ssl_ctx, timeout=TIMEOUT_SEC)
208
+ else:
209
+ urllib.request.urlopen(req, timeout=TIMEOUT_SEC)
210
+
211
+
212
+ # ── Transcript token extraction ──────────────────────────────────────────────
213
+
214
+ def _extract_usage_from_transcript(transcript_path: str):
215
+ """
216
+ Read the JSONL transcript and sum token usage across all assistant turns.
217
+ Claude Code's Stop hook omits usage, but the transcript file has per-turn
218
+ usage from each API response. Returns a dict or None on failure.
219
+ """
220
+ totals = {
221
+ 'input_tokens': 0,
222
+ 'output_tokens': 0,
223
+ 'cache_read_input_tokens': 0,
224
+ 'cache_creation_input_tokens': 0,
225
+ }
226
+ found = False
227
+ try:
228
+ with open(transcript_path, 'r', encoding='utf-8', errors='replace') as fh:
229
+ for line in fh:
230
+ line = line.strip()
231
+ if not line:
232
+ continue
233
+ try:
234
+ obj = json.loads(line)
235
+ except json.JSONDecodeError:
236
+ continue
237
+ usage = None
238
+ # Primary format: {"type": "assistant", "message": {"usage": {...}}}
239
+ if obj.get('type') == 'assistant':
240
+ usage = (obj.get('message') or {}).get('usage')
241
+ # Fallback: {"role": "assistant", "usage": {...}}
242
+ elif obj.get('role') == 'assistant':
243
+ usage = obj.get('usage')
244
+ if usage and isinstance(usage, dict):
245
+ totals['input_tokens'] += usage.get('input_tokens', 0) or 0
246
+ totals['output_tokens'] += usage.get('output_tokens', 0) or 0
247
+ totals['cache_read_input_tokens'] += usage.get('cache_read_input_tokens', 0) or 0
248
+ totals['cache_creation_input_tokens'] += usage.get('cache_creation_input_tokens', 0) or 0
249
+ found = True
250
+ except Exception:
251
+ return None
252
+ return totals if found else None
253
+
254
+
255
+ # ── Main ─────────────────────────────────────────────────────────────────────
256
+
257
+ def main() -> None:
258
+ try:
259
+ raw = sys.stdin.read()
260
+ if not raw or not raw.strip():
261
+ sys.exit(0)
262
+
263
+ payload = json.loads(raw)
264
+
265
+ # Enrich with a precise client-side timestamp
266
+ payload["hook_timestamp"] = datetime.now(timezone.utc).isoformat()
267
+
268
+ # Tag with terminal environment for /diag breakdown
269
+ env = _detect_environment()
270
+ payload["terminal_env"] = env["flat"]
271
+ payload["terminal_env_detail"] = env["detail"]
272
+
273
+ # Derive project name — walk up to find git root for accuracy
274
+ cwd = payload.get("cwd", "")
275
+ if cwd:
276
+ normalized = cwd.replace("\\", "/").rstrip("/")
277
+ parts = [p for p in normalized.split("/") if p]
278
+ project_name = None
279
+ for i in range(len(parts), 0, -1):
280
+ # Try Windows-style path first, then POSIX
281
+ win_candidate = "\\".join(parts[:i])
282
+ posix_candidate = "/" + "/".join(parts[:i])
283
+ for candidate in [win_candidate, posix_candidate]:
284
+ try:
285
+ if os.path.isdir(candidate + "\\.git") or os.path.isdir(candidate + "/.git"):
286
+ project_name = parts[i - 1]
287
+ break
288
+ except Exception:
289
+ pass
290
+ if project_name:
291
+ break
292
+ if not project_name:
293
+ project_name = parts[-1] if parts else cwd
294
+ payload["project_name"] = project_name
295
+
296
+ # For Stop events: read the transcript to get cumulative token usage.
297
+ # Claude Code's Stop hook omits usage; the transcript has it per turn.
298
+ if payload.get("hook_event_name") == "Stop" and not payload.get("usage"):
299
+ tp = payload.get("transcript_path", "")
300
+ if tp:
301
+ usage = _extract_usage_from_transcript(tp)
302
+ if usage:
303
+ payload["usage"] = usage
304
+ _debug(f"transcript usage: in={usage['input_tokens']} out={usage['output_tokens']}")
305
+
306
+ ev_name = payload.get("hook_event_name", "?")
307
+ _debug(f"event={ev_name} sess={str(payload.get('session_id','?'))[:12]} env={payload['terminal_env']}")
308
+
309
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
310
+ server_url, ssl_ctx = _resolve_server()
311
+
312
+ try:
313
+ _post(server_url, data, ssl_ctx)
314
+ _debug(f"posted OK → {server_url}")
315
+ except (urllib.error.URLError, OSError) as exc:
316
+ _debug(f"POST failed ({exc}), retrying in {RETRY_DELAY}s")
317
+ import time
318
+ time.sleep(RETRY_DELAY)
319
+ try:
320
+ _post(server_url, data, ssl_ctx)
321
+ _debug("retry OK")
322
+ except Exception as exc2:
323
+ _debug(f"retry failed: {exc2}")
324
+
325
+ except json.JSONDecodeError as exc:
326
+ _debug(f"JSON parse error: {exc}")
327
+ except Exception as exc:
328
+ _debug(f"unexpected error: {exc}")
329
+
330
+ sys.exit(0)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()