ensure_agent_sdk.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. #!/usr/bin/env python3
  2. """SessionStart bootstrap: ensure claude_agent_sdk is importable for the
  3. agentic commit reviewer.
  4. If claude_agent_sdk already imports in the current python3, this is a no-op.
  5. Otherwise it creates a venv at ~/.claude/security/agent-sdk-venv and installs
  6. the SDK there. security_reminder_hook.py prepends that venv's site-packages to
  7. sys.path before attempting the SDK import, so the venv is used as a
  8. fallback only when the system install is missing.
  9. The venv lives under ~/.claude/security/ (same dir the plugin already uses
  10. for per-session state) so it persists across plugin updates — rebuilding
  11. on every update is 30-60s of wasted work for a package that changes far
  12. less often than the plugin does.
  13. """
  14. from __future__ import annotations
  15. import importlib.util
  16. import json
  17. import os
  18. import subprocess
  19. import sys
  20. import time
  21. from pathlib import Path
  22. # Shared state-dir resolver: SECURITY_WARNINGS_STATE_DIR → CLAUDE_CONFIG_DIR/security
  23. # → ~/.claude/security. See _base.state_dir for resolution precedence. Re-aliased
  24. # here to match the existing local name (state_dir was already a local var in
  25. # main() and _maybe_emit_user_notice).
  26. from _base import state_dir as _resolve_state_dir
  27. # Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry.
  28. NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python
  29. NOOP_VENV = 1 # venv already built and SDK imports from it
  30. BUILT = 2 # venv created + SDK pip-installed this run
  31. BUILD_FAILED = 3 # venv create or pip install raised/timed out
  32. # Outcome 4 was previously SKIP_WIN32; retired now that the consumer glob in
  33. # llm.py also matches Windows venv layout (Lib/site-packages). Don't reuse the
  34. # value — telemetry rows from older plugin builds still emit 4.
  35. SKIP_SENTINEL = 5 # another SessionStart is currently building
  36. HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
  37. # here no matter how the venv was built. See #2071.
  38. # Phase + err-kind integer encoding for sdk_bootstrap_phase / sdk_bootstrap_err.
  39. #
  40. # Earlier versions emitted these as STRINGS (e.g. "pip", "dns_fail"). CC's
  41. # plugin-metrics pipeline silently drops plugin-emitted string values —
  42. # only `bool|finite-number` plugin metrics reach BigQuery. (CC-core
  43. # metrics like `subscription_type` are exempt because they're injected
  44. # downstream of plugin validation.) Confirmed empirically: 185K
  45. # BUILD_FAILED rows in BQ had `sdk_bootstrap_phase`/`sdk_bootstrap_err`
  46. # = NULL despite the Python code emitting them. This left ~28K
  47. # BUILD_FAILED sessions/day with no diagnostic split — flying blind on
  48. # the real failure modes (pip-no-match vs dns-fail vs ssl-verify etc.).
  49. #
  50. # Fix: encode as small integers per the maps below. Values are
  51. # APPEND-ONLY for telemetry stability. Reserve 99 as the "unknown /
  52. # uncategorized" bucket so an unmapped err_kind (e.g., a new exception
  53. # type) still emits a non-zero signal.
  54. SDK_BOOTSTRAP_PHASE_CODES = {
  55. "pre": 1, # pre-venv (state_dir.mkdir, sentinel open)
  56. "venv": 2, # python -m venv --clear
  57. "pip": 3, # pip install
  58. "main": 4, # uncaught exception above main()
  59. }
  60. SDK_BOOTSTRAP_ERR_CODES = {
  61. "pip_no_match": 1,
  62. "dns_fail": 2,
  63. "conn_refused": 3,
  64. "ssl_verify": 4,
  65. "perm_denied": 5,
  66. "no_pip": 6,
  67. "disk_full": 7,
  68. "proxy_auth": 8,
  69. "stderr_timeout": 9, # pip stderr containing "timeout"/"timed out"
  70. "subprocess_timeout": 10, # subprocess.TimeoutExpired (>120s)
  71. # Venv-stage specific categories added after PR #2112 telemetry surfaced
  72. # 2,406 phase=2/err=99 sessions in the first 3h of v2.0.1 — venv phase
  73. # failing in ways the original pip-flavored patterns didn't catch. These
  74. # all split out of what was previously collapsing to _uncategorized.
  75. "venv_ensurepip_fail": 11, # Debian/Ubuntu missing python3-venv;
  76. # stderr mentions ensurepip non-zero exit
  77. # or "ensurepip is not available"
  78. "venv_path_too_long": 12, # Windows MAX_PATH (260) or POSIX
  79. # ENAMETOOLONG — venv writes deep paths
  80. # under state_dir/agent-sdk-venv/Lib/...
  81. "venv_no_module": 13, # `python3 -m venv` itself missing — "No
  82. # module named 'venv'" / "No module named venv"
  83. "venv_already_exists": 14, # Errno 17 / "file exists" — sentinel race
  84. # past O_EXCL or stale dir survived --clear
  85. "venv_setup_failed": 15, # Generic "virtual environment was not
  86. # created successfully" — catches the long
  87. # tail of venv setup failures that don't
  88. # match a more specific category above
  89. # 16–98 reserved for future categories; APPEND-ONLY.
  90. # 99 catches everything else (including "exc:<TypeName>" and "other:<tail>"
  91. # — the original string is debug-loggable but the integer is what makes
  92. # it to telemetry). For the "other:" tail, `sdk_bootstrap_stderr_sig`
  93. # carries a bounded integer hash so we can still distinguish patterns
  94. # in BQ aggregation.
  95. "_uncategorized": 99,
  96. }
  97. def _encode_phase(s):
  98. """Map err_phase string to its telemetry integer code, or 0 if unset.
  99. Empty/None → 0 lets `if encoded:` cleanly skip emission. Per
  100. SDK_BOOTSTRAP_PHASE_CODES, valid codes are 1-4."""
  101. return SDK_BOOTSTRAP_PHASE_CODES.get((s or "").strip(), 0)
  102. def _encode_err_kind(s):
  103. """Map err_kind string to its telemetry integer code, or 0 if unset.
  104. Direct hits use the static map; "exc:<X>" and "other:<tail>" both
  105. collapse to _uncategorized (99) — the raw string survives in debug
  106. logs, only the integer reaches BQ."""
  107. s = (s or "").strip()
  108. if not s:
  109. return 0
  110. if s in SDK_BOOTSTRAP_ERR_CODES:
  111. return SDK_BOOTSTRAP_ERR_CODES[s]
  112. # Prefix matches for the catch-all categories
  113. if s.startswith("exc:") or s.startswith("other:") or s == "other":
  114. return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
  115. # Unknown string — still emit as uncategorized rather than dropping
  116. return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
  117. def _encode_stderr_sig(err_kind):
  118. """Bounded integer hash of the stderr tail captured in "other:<tail>"
  119. err_kinds. Lets us distinguish patterns INSIDE the _uncategorized
  120. (code 99) bucket without unbounded cardinality.
  121. Returns 0 for non-"other:" err_kinds (so the field auto-omits from
  122. emit_metrics on categorized failures — see the emit block in main()).
  123. Strategy: take the tail's first ~30 chars (post-lowercase, post-trim),
  124. SHA-1, fold the first 2 bytes to 0–999. Different stderr messages
  125. cluster into different buckets; same stderr always maps to the same
  126. bucket. Cardinality is bounded at 1000, well below any "high
  127. cardinality" alarm — and a real failure mode typically produces
  128. near-identical stderr across thousands of machines, so 1000 buckets
  129. is comfortably wide.
  130. Why first ~30 chars: stderr like "ERROR: Command failed: <full
  131. path>" varies the tail wildly (paths) but the categorization signal
  132. is in the leading words. Dropping the suffix focuses the hash on
  133. the discriminative part.
  134. """
  135. if not err_kind or not err_kind.startswith("other:"):
  136. return 0
  137. import hashlib
  138. tail = err_kind[len("other:"):].strip().lower()[:30]
  139. if not tail:
  140. return 0
  141. h = hashlib.sha1(tail.encode("utf-8", errors="replace")).digest()
  142. return int.from_bytes(h[:2], "big") % 1000
  143. def _sdk_on_syspath() -> bool:
  144. # find_spec is ~10ms; actually importing the SDK pulls in
  145. # transitive deps and costs ~800ms — too heavy for a
  146. # per-SessionStart no-op check that most sessions hit.
  147. try:
  148. return importlib.util.find_spec("claude_agent_sdk") is not None
  149. except Exception:
  150. return False
  151. def _plugin_version_int() -> int:
  152. # Same encoding as security_reminder_hook._read_plugin_version_int so
  153. # metrics rows from both hooks join on pv.
  154. try:
  155. p = Path(__file__).parent.parent / ".claude-plugin" / "plugin.json"
  156. v = json.loads(p.read_text())["version"]
  157. major, minor, patch = (int(x) for x in v.split(".")[:3])
  158. return major * 10000 + minor * 100 + patch
  159. except Exception:
  160. return 0
  161. def main() -> tuple[int, str, str]:
  162. """Run the bootstrap. Returns (outcome, err_phase, err_kind).
  163. err_phase / err_kind are non-empty only on BUILD_FAILED — they let
  164. telemetry split bootstrap failures by root cause.
  165. """
  166. # Honesty check (fixes the misleading NOOP_VENV in #2071): the SDK
  167. # requires Python >=3.10 and uses 3.10+ syntax (match statements,
  168. # PEP 604 unions). On a 3.9 hook interpreter we CANNOT import it no
  169. # matter how the venv was built — llm.py runs in this same interpreter
  170. # and the syntax-level import will SyntaxError. macOS ships 3.9.6 as
  171. # the default `python3` and `/usr/bin` precedes Homebrew in PATH, so
  172. # this case is the default state for a large share of macOS users.
  173. #
  174. # sg-python.sh now prefers python3.10+ binaries so most users won't
  175. # reach this branch; the fallback to 3.9 is preserved for the
  176. # pattern-warning hooks that don't need the SDK. Reporting
  177. # HOOK_PY_INCOMPATIBLE here:
  178. # (a) avoids 30-60s of wasted pip install,
  179. # (b) avoids the lie where the venv_py probe says NOOP_VENV but the
  180. # consumer import fails, and
  181. # (c) gives telemetry a clean bucket to size the affected fleet.
  182. if sys.version_info < (3, 10):
  183. return (
  184. HOOK_PY_INCOMPATIBLE,
  185. "hook_py",
  186. f"py_{sys.version_info[0]}.{sys.version_info[1]}",
  187. )
  188. if _sdk_on_syspath():
  189. return NOOP_SYSTEM, "", ""
  190. state_dir = Path(_resolve_state_dir())
  191. venv = state_dir / "agent-sdk-venv"
  192. # Windows venvs put the interpreter at Scripts\python.exe; POSIX uses bin/python.
  193. if sys.platform == "win32":
  194. venv_py = venv / "Scripts" / "python.exe"
  195. else:
  196. venv_py = venv / "bin" / "python"
  197. # Another SessionStart (concurrent CC instance, same plugin) may already
  198. # be building. The sentinel lives NEXT TO the venv, not inside it —
  199. # `python -m venv --clear` wipes the target dir's contents, so an
  200. # in-venv sentinel would be deleted the instant we create the venv.
  201. # Stale sentinels (>5min) from a SIGKILL'd build are ignored.
  202. sentinel = state_dir / "agent-sdk-venv.building"
  203. if sentinel.exists():
  204. try:
  205. if time.time() - sentinel.stat().st_mtime < 300:
  206. return SKIP_SENTINEL, "", ""
  207. sentinel.unlink(missing_ok=True)
  208. except OSError:
  209. return SKIP_SENTINEL, "", ""
  210. # If a venv already exists and its python can import the SDK, done.
  211. if venv_py.exists():
  212. try:
  213. r = subprocess.run(
  214. [str(venv_py), "-c", "import claude_agent_sdk"],
  215. capture_output=True, timeout=10,
  216. )
  217. if r.returncode == 0:
  218. return NOOP_VENV, "", ""
  219. except Exception:
  220. pass # broken venv; rebuild below
  221. err_phase = ""
  222. err_kind = ""
  223. we_own_sentinel = False
  224. try:
  225. state_dir.mkdir(parents=True, exist_ok=True)
  226. # O_EXCL makes the sentinel an atomic lock — if two SessionStarts
  227. # race past the exists() check above, only one creates it.
  228. try:
  229. os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
  230. except FileExistsError:
  231. return SKIP_SENTINEL, "", ""
  232. we_own_sentinel = True
  233. err_phase = "venv"
  234. subprocess.run(
  235. [sys.executable, "-m", "venv", "--clear", str(venv)],
  236. capture_output=True, timeout=60, check=True,
  237. )
  238. # Some machines route pip through a private registry; we
  239. # don't pass --index-url here so we inherit that default. Outside
  240. # the user's machine, pip's own default registry applies — that's the same
  241. # exposure the user would have running `pip install` themselves, so
  242. # we're not widening the supply-chain surface.
  243. #
  244. # --prefer-binary: on ARM64 Windows, pip's default resolver picks a
  245. # `cryptography` version with no published binary wheel and tries to
  246. # build from source, which needs Rust/Cargo (almost never present
  247. # on user machines). The build fails and the whole bootstrap returns
  248. # BUILD_FAILED. A binary wheel exists on PyPI for an adjacent
  249. # version (`cryptography-46.0.3-cp311-abi3-win_arm64.whl`);
  250. # --prefer-binary tells pip to pick it. Cross-platform safe: no-op
  251. # on platforms where the latest version already has a wheel.
  252. err_phase = "pip"
  253. subprocess.run(
  254. [str(venv_py), "-m", "pip", "install", "--quiet",
  255. "--disable-pip-version-check", "--prefer-binary",
  256. "claude-agent-sdk"],
  257. capture_output=True, timeout=120, check=True,
  258. )
  259. return BUILT, "", ""
  260. except subprocess.CalledProcessError as e:
  261. # Capture a stderr fingerprint so telemetry can split BUILD_FAILED by
  262. # root cause (no-network, package-not-found, dns-fail, etc.).
  263. # Categorize first, then keep a short raw tail for the long tail of
  264. # unexpected modes.
  265. stderr_b = e.stderr or b""
  266. if isinstance(stderr_b, bytes):
  267. stderr_str = stderr_b.decode("utf-8", errors="replace")
  268. else:
  269. stderr_str = str(stderr_b)
  270. s = stderr_str.lower()
  271. # Venv-specific patterns checked FIRST — they overlap with some pip
  272. # patterns (e.g. "no module named ensurepip" could match no_pip OR
  273. # venv_ensurepip_fail; the venv-stage interpretation is the right
  274. # one when err_phase=="venv"). Order is venv-most-specific →
  275. # pip-historical → generic.
  276. if err_phase == "venv" and (
  277. "ensurepip is not available" in s
  278. or ("ensurepip" in s and "returned non-zero" in s)
  279. or "the virtual environment was not created" in s and "ensurepip" in s
  280. ):
  281. err_kind = "venv_ensurepip_fail"
  282. elif err_phase == "venv" and (
  283. "[errno 36]" in s
  284. or "file name too long" in s
  285. or "path too long" in s
  286. ):
  287. err_kind = "venv_path_too_long"
  288. elif err_phase == "venv" and (
  289. "no module named venv" in s
  290. or "no module named 'venv'" in s
  291. ):
  292. err_kind = "venv_no_module"
  293. elif err_phase == "venv" and (
  294. "[errno 17]" in s
  295. or ("file exists" in s and "venv" in s)
  296. ):
  297. err_kind = "venv_already_exists"
  298. elif "no matching distribution" in s or "could not find a version" in s:
  299. err_kind = "pip_no_match"
  300. elif "name or service not known" in s or "name resolution" in s \
  301. or "nodename nor servname" in s or "temporary failure in name" in s:
  302. err_kind = "dns_fail"
  303. elif "connection refused" in s or "connection reset" in s:
  304. err_kind = "conn_refused"
  305. elif "ssl" in s and ("verify" in s or "certificate" in s):
  306. err_kind = "ssl_verify"
  307. elif "permission denied" in s or "read-only file system" in s:
  308. err_kind = "perm_denied"
  309. elif "no module named pip" in s or "no module named ensurepip" in s:
  310. err_kind = "no_pip"
  311. elif "no space left" in s or "disk quota" in s:
  312. err_kind = "disk_full"
  313. elif "proxy" in s and ("authent" in s or "tunnel" in s or "407" in s):
  314. err_kind = "proxy_auth"
  315. elif "timeout" in s or "timed out" in s:
  316. err_kind = "stderr_timeout"
  317. elif err_phase == "venv" and (
  318. "virtual environment was not created" in s
  319. or "error: command" in s and "venv" in s
  320. ):
  321. # Generic venv-setup catch-all — matched AFTER the more specific
  322. # venv patterns above so we don't shadow them, but BEFORE the
  323. # other: fallback so generic venv setup failures get their own
  324. # bucket instead of polluting the long-tail signature space.
  325. err_kind = "venv_setup_failed"
  326. else:
  327. # First 60 chars of the last non-empty stderr line — bounded to
  328. # stay inside CC's metric value-length budget. Real failure modes
  329. # we haven't categorized show up here as a low-cardinality bucket.
  330. tail = next(
  331. (ln.strip() for ln in reversed(stderr_str.splitlines()) if ln.strip()),
  332. "",
  333. )[:60]
  334. err_kind = f"other:{tail}" if tail else "other"
  335. return BUILD_FAILED, err_phase, err_kind
  336. except subprocess.TimeoutExpired:
  337. return BUILD_FAILED, err_phase, "subprocess_timeout"
  338. except Exception as e:
  339. return BUILD_FAILED, err_phase, f"exc:{type(e).__name__}"
  340. finally:
  341. # Only remove the sentinel if THIS process created it. The
  342. # FileExistsError path above means another process owns the lock;
  343. # unconditionally unlinking here would delete its sentinel and let
  344. # a third concurrent SessionStart `venv --clear` over the in-flight
  345. # build.
  346. if we_own_sentinel:
  347. sentinel.unlink(missing_ok=True)
  348. def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None:
  349. """Return a one-time user-visible notice when the agentic reviewer is
  350. in a persistent broken state on this machine, or None if we've already
  351. shown the notice for this plugin version (or shouldn't show one).
  352. The marker file is plugin-version-keyed: a future plugin update can
  353. re-notify if behavior changes (e.g. we ship out-of-process SDK in v3
  354. and want to tell affected users it's fixed). Failures to write the
  355. marker degrade to "skip the notice this session" so we don't spam
  356. every SessionStart on a read-only home dir.
  357. Currently only HOOK_PY_INCOMPATIBLE qualifies. BUILD_FAILED is
  358. intentionally excluded — it covers transient causes (network failure,
  359. pip registry hiccup, in-flight rebuild) where the next session may
  360. succeed and a permanent notice would mislead.
  361. """
  362. if outcome != HOOK_PY_INCOMPATIBLE:
  363. return None
  364. try:
  365. state_dir = Path(_resolve_state_dir())
  366. marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}"
  367. if marker.exists():
  368. return None
  369. state_dir.mkdir(parents=True, exist_ok=True)
  370. # Write timestamp + Python version so the marker is self-documenting
  371. # if a user goes looking. O_EXCL would be racier with no real win
  372. # (two concurrent SessionStarts both showing the notice once is fine).
  373. marker.write_text(
  374. f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
  375. f"py={sys.version_info[0]}.{sys.version_info[1]}\n"
  376. )
  377. except OSError:
  378. return None
  379. return (
  380. f"⚠ security-guidance plugin: the cross-file commit reviewer "
  381. f"(layer 3 of 3 — catches IDOR, auth-bypass, cross-file SSRF) "
  382. f"is unavailable in this environment. It requires Python ≥3.10, "
  383. f"but the hook is running on "
  384. f"{sys.version_info[0]}.{sys.version_info[1]}.\n\n"
  385. f"Pattern checks and the single-shot LLM diff review are still "
  386. f"active. To enable the deeper reviewer, install Python 3.10+ "
  387. f"(e.g. `brew install python` on macOS) and restart Claude Code.\n\n"
  388. f"This notice is shown once per plugin version. "
  389. f"See: github.com/anthropics/claude-plugins-official/issues/2071"
  390. )
  391. if __name__ == "__main__":
  392. # Tell the harness this is async — venv create + pip install can take
  393. # 30-60s on a cold cache, well past the default sync hook timeout.
  394. # SessionStart runs before the user's first prompt; doing this in the
  395. # background means the first commit-review of the session usually finds
  396. # the venv ready.
  397. print(json.dumps({"async": True, "asyncTimeout": 180000}), flush=True)
  398. t0 = time.perf_counter()
  399. try:
  400. outcome, err_phase, err_kind = main()
  401. except Exception as exc:
  402. outcome, err_phase, err_kind = (
  403. BUILD_FAILED, "main", f"exc:{type(exc).__name__}"
  404. )
  405. # CC's async-hook registry scans stdout line-by-line after process exit
  406. # and takes the FIRST non-{"async":...} JSON line as the hook response;
  407. # its `metrics` key is forwarded to the hook metrics event on the
  408. # next attachments pass. Must be a single line — the registry splits on
  409. # \n and json-parses each independently.
  410. #
  411. # IMPORTANT — values must be bool|finite-number. The validation comment
  412. # has historically said "or short strings" but that was wrong: CC's
  413. # plugin-metrics pipeline silently drops plugin-emitted string values.
  414. # Stay inside the 10-key emit cap.
  415. metrics: dict[str, object] = {
  416. "sdk_bootstrap": outcome,
  417. "sdk_bootstrap_ms": round((time.perf_counter() - t0) * 1000),
  418. }
  419. if err_kind:
  420. # Encode phase + err_kind as integer codes (see
  421. # SDK_BOOTSTRAP_PHASE_CODES / SDK_BOOTSTRAP_ERR_CODES). Earlier
  422. # versions emitted these as strings and CC dropped them — restoring
  423. # the diagnostic split that 28K BUILD_FAILED/day need to triage by
  424. # root cause. err_phase defaults to "pre" when empty (pre-venv
  425. # failure path, e.g. state_dir.mkdir perm-denied).
  426. metrics["sdk_bootstrap_phase"] = _encode_phase(err_phase or "pre")
  427. metrics["sdk_bootstrap_err"] = _encode_err_kind(err_kind)
  428. # For "other:<tail>" (encoded err==99), emit a bounded integer
  429. # hash of the stderr tail so BQ can distinguish patterns inside
  430. # the _uncategorized bucket without unbounded cardinality. Zero
  431. # when err_kind is categorized — the schema reader treats 0 as
  432. # "no signal", matching the absence convention.
  433. sig = _encode_stderr_sig(err_kind)
  434. if sig:
  435. metrics["sdk_bootstrap_stderr_sig"] = sig
  436. pv = _plugin_version_int()
  437. if pv:
  438. metrics["pv"] = pv
  439. response: dict[str, object] = {"metrics": metrics}
  440. # One-time user-visible notice when the agentic reviewer is dead on
  441. # arrival. Uses hookSpecificOutput.additionalContext (SessionStart's
  442. # supported channel for surfacing text to both the model and the user)
  443. # plus systemMessage as a belt-and-suspenders. Marker-file-gated so
  444. # this fires exactly once per plugin version per install — see
  445. # _maybe_emit_user_notice.
  446. notice = _maybe_emit_user_notice(outcome, pv)
  447. if notice:
  448. response["hookSpecificOutput"] = {
  449. "hookEventName": "SessionStart",
  450. "additionalContext": notice,
  451. }
  452. response["systemMessage"] = notice
  453. print(json.dumps(response), flush=True)