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.
- package/.claude/hooks/send-event-wsl.py +339 -0
- package/.claude/hooks/send-event.py +334 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +70 -0
- package/EULA.md +223 -0
- package/INSTALL.md +193 -0
- package/LICENSE +287 -0
- package/LICENSE-COMMERCIAL +241 -0
- package/PRIVACY.md +115 -0
- package/README.md +182 -0
- package/SECURITY.md +63 -0
- package/TERMS.md +233 -0
- package/install-service.ps1 +302 -0
- package/installer/cli.js +177 -0
- package/installer/detect.js +355 -0
- package/installer/install.js +195 -0
- package/installer/manifest.js +140 -0
- package/installer/package.json +12 -0
- package/installer/steps/api-keys.js +59 -0
- package/installer/steps/directory.js +41 -0
- package/installer/steps/env-report.js +48 -0
- package/installer/steps/hooks.js +149 -0
- package/installer/steps/service.js +159 -0
- package/installer/steps/tls.js +104 -0
- package/installer/steps/verify.js +117 -0
- package/installer/steps/welcome.js +46 -0
- package/installer/ui.js +133 -0
- package/installer/uninstall.js +233 -0
- package/installer/upgrade.js +289 -0
- package/package.json +58 -0
- package/public/index.html +13953 -0
- package/server/fixtures/allowlist-profiles.json +185 -0
- package/server/package.json +34 -0
- package/server/platform.js +270 -0
- package/server/rules/gitleaks.toml +3214 -0
- package/server/rules/security.yara +579 -0
- package/server/start.js +178 -0
- package/service/agent-recon.service +30 -0
- package/service/com.agent-recon.server.plist +56 -0
- package/setup-linux.sh +259 -0
- package/setup-macos.sh +264 -0
- package/setup-wsl.sh +248 -0
- package/setup.ps1 +171 -0
- 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()
|