subcortex 0.3.0__py3-none-any.whl

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 (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/daemon.py ADDED
@@ -0,0 +1,502 @@
1
+ """subcortex daemon: stdlib ThreadingHTTPServer on 127.0.0.1:<port>.
2
+
3
+ Endpoints:
4
+
5
+ - ``POST /decide`` ``{state, questions, backend?}`` → ``{success, answers|error}``
6
+ - ``POST /verdict/prompt`` ``{prompt}`` → ``{success, verdict|error}``
7
+ - ``POST /verdict/output`` ``{output, context?, task?}`` → ``{success, verdict|error}``
8
+ - ``GET /health`` → ``{ok, version, backend, model}`` (no token needed)
9
+ - ``GET /stats`` → in-memory counters + uptime
10
+
11
+ Policy endpoints — the four behaviors from ``subcortex.policy``, for plugin-based
12
+ TUIs (OpenCode, Amp, ...) so no plugin re-implements thresholds or heuristics:
13
+
14
+ - ``POST /v1/prompt-hint`` ``{prompt, session_id?, tui?}`` → ``{success, hint|null}``
15
+ - ``POST /v1/tool-output`` ``{output, tool?, input?, failed?, session_id?, tui?, task?}``
16
+ → ``{success, replacement|null}``
17
+ - ``POST /v1/snapshot`` ``{session_id, messages, trigger?, tui?}`` → ``{success, saved}``
18
+ - ``POST /v1/restore`` ``{session_id, tui?}`` → ``{success, context|null}``
19
+
20
+ Every request except ``GET /health`` must carry the per-user token
21
+ (``auth.HEADER``); requests with an ``Origin``, a foreign ``Host`` or a
22
+ non-JSON body are refused, so web pages can't reach it either. Model work is
23
+ bounded: at most ``MAX_CONCURRENT_DECISIONS`` run at once and a request that
24
+ can't start before its client gives up (``X-Subcortex-Timeout-Ms``) gets 503
25
+ and fails open. The config file is re-read when it changes, and the daemon
26
+ exits (to be restarted by the next hook or the login service) when a newer
27
+ subcortex is installed underneath it.
28
+
29
+ Single instance via an ``fcntl`` lock in the data dir; PID file and a rotated
30
+ log alongside it. The backend is constructed lazily on the first request and
31
+ its model loads lazily on the first verdict.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import fcntl
37
+ import hmac
38
+ import json
39
+ import os
40
+ import signal
41
+ import sys
42
+ import threading
43
+ import time
44
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
45
+ from pathlib import Path
46
+ from types import SimpleNamespace
47
+ from typing import Any, Callable, Dict, List, Optional
48
+
49
+ from . import __version__, auth, ledger, policy, verdicts
50
+ from .backends import get_backend
51
+ from .backends.base import DecisionBackend
52
+ from .config import config_path, data_dir, load_config, lock_path, log_path, pid_path
53
+ from .metrics import METRICS
54
+
55
+ MAX_CONCURRENT_DECISIONS = 4
56
+ DEFAULT_CLIENT_TIMEOUT_S = 3.0
57
+ LOG_MAX_BYTES = 5_000_000
58
+ VERSION_CHECK_S = 30.0
59
+ _LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]")
60
+
61
+ BackendFactory = Callable[..., DecisionBackend]
62
+
63
+
64
+ def _daemon_backend(state: Any, override: Optional[str] = None) -> DecisionBackend:
65
+ """Cached per-name backend lookup; construction (not model load) happens here."""
66
+ _refresh_config(state)
67
+ name = str(override or state.config.get("backend") or "laya").strip().lower()
68
+ with state.lock:
69
+ if name not in state.backends:
70
+ state.backends[name] = state.factory(state.config, name)
71
+ return state.backends[name]
72
+
73
+
74
+ def _host_name(value: str) -> str:
75
+ value = value.strip().lower()
76
+ if value.startswith("["):
77
+ return value[:value.find("]") + 1] if "]" in value else value
78
+ return value.split(":", 1)[0]
79
+
80
+
81
+ def _config_stamp() -> Any:
82
+ try:
83
+ st = config_path().stat()
84
+ return (st.st_mtime_ns, st.st_size)
85
+ except OSError:
86
+ return None
87
+
88
+
89
+ def _refresh_config(state: Any) -> None:
90
+ """Pick up config changes (an opt-out must apply without a restart)."""
91
+ if not state.watch_config:
92
+ return
93
+ now = time.monotonic()
94
+ if now - state.config_checked < 1.0:
95
+ return
96
+ state.config_checked = now
97
+ stamp = _config_stamp()
98
+ if stamp == state.config_stamp:
99
+ return
100
+ fresh = load_config()
101
+ with state.lock:
102
+ backend_keys = ("backend", "model", "jev", "daemon_python")
103
+ if any(fresh.get(k) != state.config.get(k) for k in backend_keys):
104
+ state.backends.clear()
105
+ state.config, state.config_stamp = fresh, stamp
106
+
107
+
108
+ def _installed_version() -> str:
109
+ """The version string of the subcortex now on disk (may differ from ours)."""
110
+ try:
111
+ text = (Path(__file__).resolve().parent / "__init__.py").read_text(encoding="utf-8")
112
+ except OSError:
113
+ return __version__
114
+ for line in text.splitlines():
115
+ if line.startswith("__version__"):
116
+ return line.split("=", 1)[1].strip().strip("\"'")
117
+ return __version__
118
+
119
+
120
+ def make_handler(state: Any):
121
+ class Handler(BaseHTTPRequestHandler):
122
+ # Socket reads/writes only (not model time): a client that connects and
123
+ # stalls must not pin a thread forever.
124
+ timeout = 15
125
+
126
+ def _log(self, msg: str) -> None:
127
+ _append_log(getattr(self.server, "log_path", None), f"{self.log_date_time_string()} {msg}")
128
+
129
+ def log_message(self, fmt: str, *args: Any) -> None:
130
+ if getattr(self.server, "log_requests", False):
131
+ self._log(f"{self.address_string()} {fmt % args}")
132
+
133
+ def _refuse(self, code: int, error: str) -> None:
134
+ METRICS.record(f"refused_{code}")
135
+ self._send_json(code, {"success": False, "error": error})
136
+
137
+ def _allowed(self, needs_token: bool) -> bool:
138
+ """Only this user's own local processes: no browsers, no other users."""
139
+ host = _host_name(self.headers.get("Host") or "")
140
+ if host and host not in _LOCAL_HOSTS: # DNS rebinding
141
+ self._refuse(403, "foreign Host")
142
+ return False
143
+ if self.headers.get("Origin"):
144
+ self._refuse(403, "cross-origin requests are refused")
145
+ return False
146
+ if needs_token and state.token:
147
+ sent = self.headers.get(auth.HEADER) or ""
148
+ if not hmac.compare_digest(sent.encode(), state.token.encode()):
149
+ self._refuse(401, "missing or wrong token")
150
+ return False
151
+ return True
152
+
153
+ def _deadline(self) -> float:
154
+ try:
155
+ budget = float(self.headers.get("X-Subcortex-Timeout-Ms") or 0) / 1000.0
156
+ except ValueError:
157
+ budget = 0.0
158
+ budget = budget if 0 < budget <= 3600 else DEFAULT_CLIENT_TIMEOUT_S
159
+ return self.received + budget - 0.1
160
+
161
+ def _decision_slot(self) -> bool:
162
+ """Wait for a model slot only as long as the client still listens."""
163
+ wait = self._deadline() - time.monotonic()
164
+ if wait > 0 and state.slots.acquire(timeout=wait):
165
+ return True
166
+ self._refuse(503, "busy")
167
+ return False
168
+
169
+ def _send_json(self, code: int, obj: Dict[str, Any]) -> None:
170
+ body = json.dumps(obj).encode("utf-8")
171
+ self.send_response(code)
172
+ self.send_header("Content-Type", "application/json")
173
+ self.send_header("Content-Length", str(len(body)))
174
+ self.end_headers()
175
+ self.wfile.write(body)
176
+
177
+ def _read_json(self) -> Optional[Dict[str, Any]]:
178
+ content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
179
+ if content_type != "application/json":
180
+ return None
181
+ try:
182
+ length = int(self.headers.get("Content-Length") or 0)
183
+ except ValueError:
184
+ return None
185
+ if length <= 0 or length > 10_000_000:
186
+ return None
187
+ try:
188
+ data = json.loads(self.rfile.read(length))
189
+ except (ValueError, RecursionError, OSError): # garbage, pathological nesting, stalled client
190
+ return None
191
+ return data if isinstance(data, dict) else None
192
+
193
+ # -- GET -----------------------------------------------------------------
194
+
195
+ def do_GET(self) -> None:
196
+ self.received = time.monotonic()
197
+ if not self._allowed(needs_token=self.path != "/health"):
198
+ return
199
+ if self.path == "/health":
200
+ _refresh_config(state)
201
+ cfg = state.config
202
+ self._send_json(200, {
203
+ "ok": True,
204
+ "version": __version__,
205
+ "pid": os.getpid(),
206
+ "backend": cfg.get("backend", "laya"),
207
+ "model": cfg.get("model") if cfg.get("backend", "laya") == "laya"
208
+ else (cfg.get("jev") or {}).get("model"),
209
+ })
210
+ elif self.path == "/stats":
211
+ self._send_json(200, METRICS.snapshot())
212
+ else:
213
+ self._send_json(404, {"success": False, "error": "not found"})
214
+
215
+ # -- POST -----------------------------------------------------------------
216
+
217
+ def do_POST(self) -> None:
218
+ self.received = time.monotonic()
219
+ if not self._allowed(needs_token=True):
220
+ return
221
+ if self.path == "/decide":
222
+ self._handle_decide()
223
+ elif self.path == "/verdict/prompt":
224
+ self._handle_verdict_prompt()
225
+ elif self.path == "/verdict/output":
226
+ self._handle_verdict_output()
227
+ elif self.path in _POLICY_ROUTES:
228
+ self._handle_policy(_POLICY_ROUTES[self.path])
229
+ else:
230
+ self._send_json(404, {"success": False, "error": "not found"})
231
+
232
+ def _handle_decide(self) -> None:
233
+ payload = self._read_json()
234
+ if payload is None or "state" not in payload or "questions" not in payload:
235
+ self._send_json(400, {"success": False, "error": "need {state, questions}"})
236
+ return
237
+ if not self._decision_slot():
238
+ return
239
+ try:
240
+ backend = _daemon_backend(state, payload.get("backend"))
241
+ start = time.perf_counter()
242
+ result = backend.predict(payload["state"], payload["questions"])
243
+ METRICS.record("decide", round((time.perf_counter() - start) * 1000.0, 2))
244
+ reply = {"success": True, "answers": result.get("answers", result)}
245
+ for key in ("model", "usage"):
246
+ if key in result:
247
+ reply[key] = result[key]
248
+ except Exception as exc:
249
+ self._log(f"decide failed: {exc}")
250
+ reply = {"success": False, "error": str(exc)}
251
+ finally:
252
+ state.slots.release()
253
+ self._send_json(200, reply)
254
+
255
+ def _handle_verdict_prompt(self) -> None:
256
+ payload = self._read_json()
257
+ if payload is None or "prompt" not in payload:
258
+ self._send_json(400, {"success": False, "error": "need {prompt}"})
259
+ return
260
+ if not self._decision_slot():
261
+ return
262
+ try:
263
+ backend = _daemon_backend(state)
264
+ verdict = verdicts.classify_prompt(str(payload["prompt"]), backend=backend, config=state.config)
265
+ except Exception as exc:
266
+ self._log(f"verdict/prompt failed: {exc}")
267
+ verdict = None
268
+ finally:
269
+ state.slots.release()
270
+ if verdict is None:
271
+ self._send_json(200, {"success": False, "error": "verdict failed"})
272
+ else:
273
+ self._send_json(200, {"success": True, "verdict": verdict})
274
+
275
+ def _handle_verdict_output(self) -> None:
276
+ payload = self._read_json()
277
+ if payload is None or "output" not in payload:
278
+ self._send_json(400, {"success": False, "error": "need {output}"})
279
+ return
280
+ if not self._decision_slot():
281
+ return
282
+ try:
283
+ backend = _daemon_backend(state)
284
+ verdict = verdicts.judge_output(
285
+ str(payload["output"]),
286
+ context=str(payload.get("context", "")),
287
+ backend=backend,
288
+ config=state.config,
289
+ task=str(payload.get("task") or ""),
290
+ )
291
+ except Exception as exc:
292
+ self._log(f"verdict/output failed: {exc}")
293
+ verdict = None
294
+ finally:
295
+ state.slots.release()
296
+ if verdict is None:
297
+ self._send_json(200, {"success": False, "error": "verdict failed"})
298
+ else:
299
+ self._send_json(200, {"success": True, "verdict": verdict})
300
+
301
+ def _handle_policy(self, route: str) -> None:
302
+ payload = self._read_json()
303
+ if payload is None:
304
+ self._send_json(400, {"success": False, "error": "need a JSON object"})
305
+ return
306
+ _refresh_config(state)
307
+ cfg = state.config
308
+ # Plugins name their TUI so sessions of different TUIs never share state.
309
+ tui = str(payload.get("tui") or "plugin")
310
+ sid = payload.get("session_id")
311
+ decides = route in ("prompt-hint", "tool-output")
312
+ if decides and not self._decision_slot():
313
+ return
314
+ commits: List[Callable[[], None]] = []
315
+ try:
316
+ if decides:
317
+ backend = _daemon_backend(state)
318
+ if route == "prompt-hint":
319
+ policy.remember_prompt(sid, payload.get("prompt"), tui=tui)
320
+ hint = policy.prompt_hint(
321
+ payload.get("prompt"), cfg,
322
+ lambda p: verdicts.classify_prompt(p, backend=backend, config=cfg))
323
+ METRICS.record("hint_given" if hint else "hint_skipped")
324
+ result: Dict[str, Any] = {"hint": hint}
325
+ if hint:
326
+ commits.append(lambda: ledger.record("hint", tui))
327
+ elif route == "tool-output":
328
+ replacement = policy.trim_output(
329
+ payload.get("output"), cfg,
330
+ lambda o, c, t: verdicts.judge_output(o, context=c, backend=backend,
331
+ config=cfg, task=t),
332
+ tool=str(payload.get("tool") or ""),
333
+ tool_input=payload.get("input"),
334
+ failed=bool(payload.get("failed")),
335
+ task=str(payload.get("task") or policy.last_prompt(sid, tui=tui)))
336
+ METRICS.record("output_trimmed" if replacement else "output_kept")
337
+ result = {"replacement": replacement}
338
+ if replacement:
339
+ before, after = len(str(payload.get("output") or "")), len(replacement)
340
+ commits.append(lambda: ledger.record("trim", tui, before=before, after=after))
341
+ elif route == "snapshot":
342
+ messages = payload.get("messages")
343
+ saved = policy.save_snapshot(
344
+ sid, messages if isinstance(messages, list) else [],
345
+ cfg, str(payload.get("trigger") or ""), tui=tui)
346
+ result = {"saved": saved}
347
+ else: # restore: deleted only once the reply went out (claim-then-commit)
348
+ result = {"context": policy.restore_snapshot(sid, cfg, tui=tui, commits=commits)}
349
+ if result["context"]:
350
+ commits.append(lambda: ledger.record("restore", tui))
351
+ except Exception as exc:
352
+ self._log(f"/v1/{route} failed: {exc}")
353
+ self._send_json(200, {"success": False, "error": "policy failed"})
354
+ return
355
+ finally:
356
+ if decides:
357
+ state.slots.release()
358
+ self._send_json(200, {"success": True, **result})
359
+ self.wfile.flush()
360
+ for commit in commits:
361
+ try:
362
+ commit()
363
+ except Exception:
364
+ pass
365
+
366
+ return Handler
367
+
368
+
369
+ _POLICY_ROUTES = {
370
+ "/v1/prompt-hint": "prompt-hint",
371
+ "/v1/tool-output": "tool-output",
372
+ "/v1/snapshot": "snapshot",
373
+ "/v1/restore": "restore",
374
+ }
375
+
376
+
377
+ def _append_log(path: Optional[str], line: str) -> None:
378
+ if not path:
379
+ return
380
+ try:
381
+ log = Path(path)
382
+ if log.is_file() and log.stat().st_size > LOG_MAX_BYTES:
383
+ log.replace(log.with_name(log.name + ".1"))
384
+ with open(log, "a", encoding="utf-8") as fh:
385
+ fh.write(line.rstrip() + "\n")
386
+ except OSError:
387
+ pass
388
+
389
+
390
+ class _Server(ThreadingHTTPServer):
391
+ # The stdlib default backlog is 5: a burst of hooks from several sessions
392
+ # (plus plugins) would get connection resets. Every hook must get an answer.
393
+ request_queue_size = 256
394
+ daemon_threads = True
395
+ log_path: Optional[str] = None
396
+ log_requests = False
397
+
398
+ def server_bind(self) -> None:
399
+ # HTTPServer.server_bind resolves its own address (socket.getfqdn), a
400
+ # reverse-DNS lookup that can hang for many seconds on some machines.
401
+ # We only ever serve 127.0.0.1: skip it.
402
+ import socketserver
403
+
404
+ socketserver.TCPServer.server_bind(self)
405
+ self.server_name = "localhost"
406
+ self.server_port = self.server_address[1]
407
+
408
+ def handle_error(self, request: Any, client_address: Any) -> None:
409
+ # A client that gave up (hook budget spent) is normal, not an error.
410
+ exc = sys.exc_info()[1]
411
+ if isinstance(exc, (BrokenPipeError, ConnectionResetError, TimeoutError)):
412
+ return
413
+ import traceback
414
+
415
+ _append_log(self.log_path, f"request from {client_address} failed:\n{traceback.format_exc()}")
416
+
417
+
418
+ def create_server(
419
+ port: int,
420
+ config: Optional[Dict[str, Any]] = None,
421
+ backend_factory: Optional[BackendFactory] = None,
422
+ token: Optional[str] = None,
423
+ ) -> ThreadingHTTPServer:
424
+ """Build (but do not start) the daemon HTTP server. ``port=0`` picks an
425
+ ephemeral port — used by tests with a stubbed ``backend_factory``. The token
426
+ defaults to the per-user one in the data dir (created if missing); pass ""
427
+ to disable the check. A given ``config`` is used as is (never reloaded)."""
428
+ cfg = config or load_config()
429
+ state = SimpleNamespace(
430
+ config=cfg,
431
+ factory=backend_factory or get_backend,
432
+ backends={},
433
+ lock=threading.Lock(),
434
+ slots=threading.BoundedSemaphore(MAX_CONCURRENT_DECISIONS),
435
+ token=auth.ensure_token() if token is None else token,
436
+ watch_config=config is None,
437
+ config_checked=time.monotonic(),
438
+ config_stamp=_config_stamp(),
439
+ )
440
+ return _Server(("127.0.0.1", port), make_handler(state))
441
+
442
+
443
+ def _acquire_lock():
444
+ data_dir().mkdir(mode=0o700, parents=True, exist_ok=True)
445
+ fd = open(lock_path(), "w")
446
+ try:
447
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
448
+ except OSError:
449
+ fd.close()
450
+ return None
451
+ return fd
452
+
453
+
454
+ def _watch_version(server: ThreadingHTTPServer) -> None:
455
+ """Exit when a different subcortex version is installed underneath us, so
456
+ the next hook (or the login service) starts the new code."""
457
+ while True:
458
+ time.sleep(VERSION_CHECK_S)
459
+ installed = _installed_version()
460
+ if installed != __version__:
461
+ _append_log(getattr(server, "log_path", None),
462
+ f"subcortex {installed} is installed (running {__version__}); exiting to restart")
463
+ server.restart_requested = True
464
+ server.shutdown()
465
+ return
466
+
467
+
468
+ def run(port: Optional[int] = None, config: Optional[Dict[str, Any]] = None) -> int:
469
+ """Run the daemon in the foreground. Holds the single-instance lock for the
470
+ process lifetime; writes/removes the PID file; stops cleanly on SIGTERM."""
471
+ cfg = config or load_config()
472
+ _append_log(str(log_path()), f"{time.strftime('%Y-%m-%d %H:%M:%S')} subcortex {__version__} "
473
+ f"starting (pid {os.getpid()}, port {port or cfg['port']})")
474
+ lock_fd = _acquire_lock()
475
+ if lock_fd is None:
476
+ # Another instance is serving: not an error (a login service must not
477
+ # restart-loop because a hook already started the daemon).
478
+ print(f"subcortex daemon already running (lock: {lock_path()})", file=sys.stderr)
479
+ return 0
480
+ server = create_server(int(port or cfg["port"]), None if config is None else cfg)
481
+ server.log_path = str(log_path())
482
+ pid_path().write_text(str(os.getpid()))
483
+ # serve_forever must return (so the PID file is removed) on SIGTERM too.
484
+ signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown, daemon=True).start())
485
+ threading.Thread(target=_watch_version, args=(server,), daemon=True).start()
486
+ actual_port = server.server_address[1]
487
+ message = f"subcortex daemon {__version__} listening on 127.0.0.1:{actual_port} (pid {os.getpid()})"
488
+ _append_log(server.log_path, f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}")
489
+ print(message, flush=True)
490
+ try:
491
+ server.serve_forever()
492
+ except KeyboardInterrupt:
493
+ pass
494
+ finally:
495
+ server.server_close()
496
+ try:
497
+ pid_path().unlink()
498
+ except OSError:
499
+ pass
500
+ lock_fd.close()
501
+ # 75 (EX_TEMPFAIL) makes launchd/systemd start the new version; hooks would too.
502
+ return 75 if getattr(server, "restart_requested", False) else 0