ghost-reader 0.0.1__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 (39) hide show
  1. ghost_reader/.release-version +1 -0
  2. ghost_reader/__init__.py +3 -0
  3. ghost_reader/agent_loader.py +64 -0
  4. ghost_reader/cli.py +1124 -0
  5. ghost_reader/constants.py +75 -0
  6. ghost_reader/defaults/__init__.py +1 -0
  7. ghost_reader/defaults/personas/__init__.py +1 -0
  8. ghost_reader/defaults/personas/dex.yaml +30 -0
  9. ghost_reader/defaults/personas/elena.yaml +30 -0
  10. ghost_reader/defaults/personas/mara.yaml +30 -0
  11. ghost_reader/defaults/personas/pip.yaml +30 -0
  12. ghost_reader/defaults/personas/rook.yaml +30 -0
  13. ghost_reader/defaults/templates/__init__.py +1 -0
  14. ghost_reader/defaults/templates/blog-review.html +384 -0
  15. ghost_reader/defaults/templates/report.html +1293 -0
  16. ghost_reader/dialogue.py +283 -0
  17. ghost_reader/errors.py +2 -0
  18. ghost_reader/feedback_store.py +56 -0
  19. ghost_reader/io.py +59 -0
  20. ghost_reader/models.py +227 -0
  21. ghost_reader/paths.py +68 -0
  22. ghost_reader/project.py +277 -0
  23. ghost_reader/release.py +56 -0
  24. ghost_reader/report.py +264 -0
  25. ghost_reader/reviews.py +89 -0
  26. ghost_reader/revision.py +165 -0
  27. ghost_reader/round.py +155 -0
  28. ghost_reader/server.py +281 -0
  29. ghost_reader/sync.py +112 -0
  30. ghost_reader/telemetry.py +111 -0
  31. ghost_reader/time.py +20 -0
  32. ghost_reader/validators.py +66 -0
  33. ghost_reader/verify.py +255 -0
  34. ghost_reader-0.0.1.dist-info/METADATA +221 -0
  35. ghost_reader-0.0.1.dist-info/RECORD +39 -0
  36. ghost_reader-0.0.1.dist-info/WHEEL +5 -0
  37. ghost_reader-0.0.1.dist-info/entry_points.txt +2 -0
  38. ghost_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
  39. ghost_reader-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ghost_reader.constants import SCHEMA_VERSIONS
8
+ from ghost_reader.errors import GhostReaderError
9
+ from ghost_reader.io import write_yaml
10
+ from ghost_reader.paths import artifact_paths
11
+ from ghost_reader.project import load_manifest
12
+ from ghost_reader.reviews import feedback_summary, find_review_item, load_reviews
13
+ from ghost_reader.telemetry import append_event
14
+
15
+
16
+ def generate_revision_prompt(
17
+ project_root: Path,
18
+ home: Path,
19
+ session_id: str,
20
+ goal: str | None = None,
21
+ preserve: list[str] | None = None,
22
+ ) -> dict[str, str]:
23
+ manifest = load_manifest(project_root, session_id)
24
+ reviews = {
25
+ review["persona_id"]: review
26
+ for review in load_reviews(project_root, home, session_id)
27
+ }
28
+ feedback = feedback_summary(project_root, session_id)
29
+ if not feedback["feedback_found"]:
30
+ raise GhostReaderError(
31
+ "Feedback artifact not found. Run `ghost-reader feedback add` before generating a revision prompt."
32
+ )
33
+
34
+ selected_reviews: dict[str, dict[str, list[str]]] = defaultdict(
35
+ lambda: {"included_strengths": [], "included_concerns": []}
36
+ )
37
+ selected_details = []
38
+ change_hints = []
39
+ for selected in feedback["selected_items"]:
40
+ persona_id, item_id = selected["target_id"].split(":", 1)
41
+ review = reviews.get(persona_id)
42
+ if not review:
43
+ raise GhostReaderError(
44
+ f"Selected review `{selected['target_id']}` has no stored review artifact."
45
+ )
46
+ found = find_review_item(review, item_id)
47
+ if not found:
48
+ raise GhostReaderError(
49
+ f"Selected review item `{selected['target_id']}` was not found."
50
+ )
51
+ kind, item = found
52
+ bucket = "included_strengths" if kind == "strengths" else "included_concerns"
53
+ selected_reviews[persona_id][bucket].append(item_id)
54
+ selected_details.append(
55
+ {
56
+ "target_id": selected["target_id"],
57
+ "kind": kind[:-1],
58
+ "reason": item.get("reason"),
59
+ "locators": [
60
+ evidence.get("locator")
61
+ for evidence in item.get("evidence", [])
62
+ if evidence.get("locator")
63
+ ],
64
+ "reader_effect": item.get("reader_effect"),
65
+ "revision_hint": item.get("revision_hint"),
66
+ "user_note": feedback["user_notes"].get(selected["target_id"], ""),
67
+ }
68
+ )
69
+ if item.get("revision_hint"):
70
+ change_hints.append(item["revision_hint"])
71
+
72
+ preserve_items = preserve or []
73
+ prompt_goal = (
74
+ goal
75
+ or f"Revise {manifest.get('story_unit') or session_id} using selected Ghost Reader feedback."
76
+ )
77
+ current_round = manifest.get("current_round", 1)
78
+ revision_prompt = {
79
+ "schema_version": SCHEMA_VERSIONS["revision_prompt"],
80
+ "session_id": session_id,
81
+ "round": current_round,
82
+ "selected_reviews": [
83
+ {"persona_id": persona_id, **values}
84
+ for persona_id, values in sorted(selected_reviews.items())
85
+ ],
86
+ "user_notes": [
87
+ {"target_id": target_id, "note": note}
88
+ for target_id, note in feedback["user_notes"].items()
89
+ ],
90
+ "revision_brief": {
91
+ "goal": prompt_goal,
92
+ "preserve": preserve_items,
93
+ "change": change_hints,
94
+ },
95
+ "prompt_text": build_revision_prompt_text(
96
+ session_id,
97
+ manifest.get("story_unit"),
98
+ project_root,
99
+ current_round,
100
+ prompt_goal,
101
+ preserve_items,
102
+ selected_details,
103
+ ),
104
+ }
105
+ prompt_dir = artifact_paths(project_root, session_id)["prompts"]
106
+ yaml_path = prompt_dir / "revision-prompt.yaml"
107
+ markdown_path = prompt_dir / "revision-prompt.md"
108
+ write_yaml(yaml_path, revision_prompt)
109
+ markdown_path.write_text(revision_prompt["prompt_text"], encoding="utf-8")
110
+ append_event(
111
+ home,
112
+ project_root,
113
+ "revision_prompt_generated",
114
+ session_id,
115
+ meta={
116
+ "selected_review_item_ids": [
117
+ item["target_id"] for item in feedback["selected_items"]
118
+ ]
119
+ },
120
+ )
121
+ return {"yaml": str(yaml_path), "markdown": str(markdown_path)}
122
+
123
+
124
+ def build_revision_prompt_text(
125
+ session_id: str,
126
+ story_unit: str | None,
127
+ project_root: Path,
128
+ current_round: int,
129
+ goal: str,
130
+ preserve: list[str],
131
+ selected_details: list[dict[str, Any]],
132
+ ) -> str:
133
+ lines = [
134
+ "# Ghost Reader Revision Handoff",
135
+ "",
136
+ f"Session: {session_id}",
137
+ f"Round: {current_round}",
138
+ f"Story unit: {story_unit or session_id}",
139
+ f"Story project root: {project_root}",
140
+ f"Session artifacts: .ghostreader/sessions/{session_id}/",
141
+ "",
142
+ f"Goal: {goal}",
143
+ "",
144
+ "Source handling:",
145
+ "- Use source text already visible in the current conversation or available from the story project.",
146
+ "- If only source locators are available, ask for the affected passages before drafting revised prose.",
147
+ "- Do not reconstruct missing prose from review summaries, locators, or memory.",
148
+ "",
149
+ ]
150
+ if preserve:
151
+ lines.append("Preserve:")
152
+ lines.extend(f"- {item}" for item in preserve)
153
+ lines.append("")
154
+ lines.append("Selected feedback:")
155
+ for detail in selected_details:
156
+ lines.append(f"- {detail['target_id']} ({detail['kind']}): {detail['reason']}")
157
+ if detail.get("locators"):
158
+ lines.append(f" Source locators: {', '.join(detail['locators'])}")
159
+ if detail.get("reader_effect"):
160
+ lines.append(f" Reader effect: {detail['reader_effect']}")
161
+ if detail.get("revision_hint"):
162
+ lines.append(f" Revision hint: {detail['revision_hint']}")
163
+ if detail.get("user_note"):
164
+ lines.append(f" User note: {detail['user_note']}")
165
+ return "\n".join(lines) + "\n"
ghost_reader/round.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ghost_reader.constants import ROUND_DIRECTORIES
8
+ from ghost_reader.errors import GhostReaderError
9
+ from ghost_reader.paths import active_round_dir, round_dir
10
+ from ghost_reader.project import (
11
+ load_manifest,
12
+ load_round_manifest,
13
+ save_manifest,
14
+ save_round_manifest,
15
+ )
16
+ from ghost_reader.telemetry import append_event
17
+ from ghost_reader.time import now_iso
18
+
19
+
20
+ def ensure_round_structure(round_path: Path) -> None:
21
+ for child in ROUND_DIRECTORIES:
22
+ (round_path / child).mkdir(parents=True, exist_ok=True)
23
+
24
+
25
+ def round_init(
26
+ project_root: Path,
27
+ home: Path,
28
+ session_id: str,
29
+ story_unit: str | None = None,
30
+ source_file: str | None = None,
31
+ ) -> dict[str, Any]:
32
+ manifest = load_manifest(project_root, session_id)
33
+ current = manifest.get("current_round", 1)
34
+ new_round = current + 1
35
+ rp = round_dir(project_root, session_id, new_round)
36
+ rp.mkdir(parents=True, exist_ok=True)
37
+ ensure_round_structure(rp)
38
+
39
+ if source_file:
40
+ src = Path(source_file)
41
+ if not src.exists():
42
+ raise GhostReaderError(f"Source file not found: {source_file}")
43
+ shutil.copy2(src, rp / "context" / "source-text.md")
44
+
45
+ save_round_manifest(
46
+ project_root,
47
+ session_id,
48
+ new_round,
49
+ {
50
+ "round_id": new_round,
51
+ "schema_version": 1,
52
+ "previous_round": current,
53
+ "status": "reviews_pending",
54
+ "refinement_count": 0,
55
+ "created_at": now_iso(),
56
+ },
57
+ )
58
+
59
+ manifest["current_round"] = new_round
60
+ manifest.setdefault("rounds", []).append(
61
+ {
62
+ "id": new_round,
63
+ "status": "reviews_pending",
64
+ "created_at": now_iso(),
65
+ "refinement_count": 0,
66
+ }
67
+ )
68
+ if story_unit:
69
+ manifest["story_unit"] = story_unit
70
+ save_manifest(project_root, session_id, manifest)
71
+
72
+ append_event(
73
+ home,
74
+ project_root,
75
+ "round_created",
76
+ session_id,
77
+ meta={"round_id": new_round, "previous_round": current},
78
+ )
79
+
80
+ return {"round_id": new_round, "path": str(rp), "status": "reviews_pending"}
81
+
82
+
83
+ def round_status(project_root: Path, session_id: str) -> dict[str, Any]:
84
+ manifest = load_manifest(project_root, session_id)
85
+ rounds_output: list[dict[str, Any]] = []
86
+ for entry in manifest.get("rounds", []):
87
+ rid = entry["id"]
88
+ try:
89
+ rm = load_round_manifest(project_root, session_id, rid)
90
+ except GhostReaderError:
91
+ rm = {}
92
+ rp = round_dir(project_root, session_id, rid)
93
+ reviews_dir = rp / "reviews"
94
+ review_files = (
95
+ list(reviews_dir.glob("*.review.yaml"))
96
+ if reviews_dir.exists()
97
+ else []
98
+ )
99
+ rounds_output.append(
100
+ {
101
+ "id": rid,
102
+ "status": rm.get("status", entry.get("status", "unknown")),
103
+ "refinement_count": rm.get("refinement_count", 0),
104
+ "review_count": len(review_files),
105
+ "created_at": rm.get("created_at", entry.get("created_at", "")),
106
+ }
107
+ )
108
+ return {
109
+ "session_id": session_id,
110
+ "current_round": manifest.get("current_round", 1),
111
+ "rounds": rounds_output,
112
+ }
113
+
114
+
115
+ def refine_snapshot(
116
+ project_root: Path, home: Path, session_id: str
117
+ ) -> dict[str, Any]:
118
+ manifest = load_manifest(project_root, session_id)
119
+ current = manifest.get("current_round", 1)
120
+ rm = load_round_manifest(project_root, session_id, current)
121
+ current_refinement = rm.get("refinement_count", 0)
122
+ new_pass = current_refinement + 1
123
+
124
+ active = active_round_dir(project_root, session_id)
125
+ snapshot_dir = active / "refinements" / f"{new_pass:03d}"
126
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
127
+
128
+ reviews_src = active / "reviews"
129
+ if reviews_src.exists() and any(reviews_src.iterdir()):
130
+ shutil.copytree(reviews_src, snapshot_dir / "reviews", dirs_exist_ok=True)
131
+
132
+ feedback_src = active / "feedback" / "feedback.yaml"
133
+ if feedback_src.exists():
134
+ (snapshot_dir / "feedback").mkdir(parents=True, exist_ok=True)
135
+ shutil.copy2(feedback_src, snapshot_dir / "feedback" / "feedback.yaml")
136
+
137
+ rm["refinement_count"] = new_pass
138
+ save_round_manifest(project_root, session_id, current, rm)
139
+
140
+ for entry in manifest.get("rounds", []):
141
+ if entry["id"] == current:
142
+ entry["refinement_count"] = new_pass
143
+ entry["status"] = rm.get("status", entry.get("status", "unknown"))
144
+ break
145
+ save_manifest(project_root, session_id, manifest)
146
+
147
+ append_event(
148
+ home,
149
+ project_root,
150
+ "refinement_snapshot_created",
151
+ session_id,
152
+ meta={"round_id": current, "refinement_pass": new_pass},
153
+ )
154
+
155
+ return {"written": True, "path": str(snapshot_dir), "refinement_pass": new_pass}
ghost_reader/server.py ADDED
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ from ghost_reader.dialogue import dialogue_append, load_all_dialogue
13
+ from ghost_reader.errors import GhostReaderError
14
+ from ghost_reader.feedback_store import record_feedback
15
+ from ghost_reader.paths import artifact_paths
16
+ from ghost_reader.project import load_manifest
17
+ from ghost_reader.report import render_report
18
+ from ghost_reader.reviews import feedback_summary
19
+ from ghost_reader.verify import verify_session
20
+
21
+ # Set by serve_report after successful bind so tests can discover the actual port
22
+ _bound_port: int | None = None
23
+
24
+
25
+ def _cors_headers(handler: SimpleHTTPRequestHandler) -> None:
26
+ origin = handler.headers.get("Origin", "")
27
+ if origin:
28
+ try:
29
+ hostname = urlparse(origin).hostname
30
+ if hostname in ("127.0.0.1", "localhost", "::1"):
31
+ handler.send_header("Access-Control-Allow-Origin", origin)
32
+ except Exception:
33
+ pass
34
+ handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
35
+ handler.send_header("Access-Control-Allow-Headers", "Content-Type")
36
+
37
+
38
+ def _json_response(handler: SimpleHTTPRequestHandler, data: dict[str, Any], status: int = 200) -> None:
39
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
40
+ handler.send_response(status)
41
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
42
+ handler.send_header("Content-Length", str(len(body)))
43
+ handler.end_headers()
44
+ handler.wfile.write(body)
45
+
46
+
47
+ MAX_BODY_SIZE = 1_048_576 # 1 MiB
48
+
49
+
50
+ def _read_body(handler: SimpleHTTPRequestHandler) -> dict[str, Any] | None:
51
+ length = int(handler.headers.get("Content-Length", 0))
52
+ if not length:
53
+ return None
54
+ if length > MAX_BODY_SIZE:
55
+ return None
56
+ return json.loads(handler.rfile.read(length))
57
+
58
+
59
+ class _FeedbackHandler(SimpleHTTPRequestHandler):
60
+ project_root: Path
61
+ home: Path
62
+ session_id: str
63
+ report_subpath: str = ""
64
+
65
+ def log_message(self, fmt: str, *args: Any) -> None:
66
+ self.server._last_active = time.time()
67
+
68
+ def end_headers(self) -> None:
69
+ _cors_headers(self)
70
+ super().end_headers()
71
+
72
+ def do_OPTIONS(self) -> None:
73
+ self.send_response(200)
74
+ _cors_headers(self)
75
+ self.end_headers()
76
+
77
+ def do_POST(self) -> None:
78
+ parsed = urlparse(self.path)
79
+ if parsed.path == "/api/feedback":
80
+ self._handle_feedback()
81
+ elif parsed.path == "/api/dialogue":
82
+ self._handle_dialogue()
83
+ else:
84
+ self.send_error(404)
85
+
86
+ def do_GET(self) -> None:
87
+ parsed = urlparse(self.path)
88
+ if parsed.path == "/api/state":
89
+ self._handle_state()
90
+ elif parsed.path in ("/", "") and self.report_subpath:
91
+ self.send_response(302)
92
+ self.send_header("Location", f"/{self.report_subpath}")
93
+ self.end_headers()
94
+ else:
95
+ super().do_GET()
96
+
97
+ def _handle_feedback(self) -> None:
98
+ try:
99
+ body = _read_body(self)
100
+ if not body:
101
+ return _json_response(self, {"error": "empty body"}, 400)
102
+ feedback_path = record_feedback(
103
+ self.project_root,
104
+ self.home,
105
+ self.session_id,
106
+ body,
107
+ surface="html",
108
+ )
109
+ _json_response(self, {"ok": True, "path": str(feedback_path)})
110
+ except GhostReaderError as exc:
111
+ _json_response(self, {"error": str(exc)}, 400)
112
+ except Exception:
113
+ _json_response(self, {"error": "internal server error"}, 500)
114
+
115
+ def _handle_dialogue(self) -> None:
116
+ try:
117
+ body = _read_body(self)
118
+ if not body:
119
+ return _json_response(self, {"error": "empty body"}, 400)
120
+ persona_id = body.get("persona_id")
121
+ text = body.get("text")
122
+ if not persona_id or not text:
123
+ return _json_response(self, {"error": "persona_id and text required"}, 400)
124
+ result = dialogue_append(
125
+ self.project_root,
126
+ self.home,
127
+ self.session_id,
128
+ persona_id,
129
+ body.get("item_id", ""),
130
+ text,
131
+ thread_id=body.get("thread_id"),
132
+ round_id=body.get("round_id"),
133
+ item_uid=body.get("item_uid"),
134
+ )
135
+ _json_response(self, {"ok": True, "thread_id": result["thread_id"], "turn_index": result["turn_index"]})
136
+ except GhostReaderError as exc:
137
+ _json_response(self, {"error": str(exc)}, 400)
138
+ except Exception:
139
+ _json_response(self, {"error": "internal server error"}, 500)
140
+
141
+ def _handle_state(self) -> None:
142
+ try:
143
+ manifest = load_manifest(self.project_root, self.session_id)
144
+ try:
145
+ verification = verify_session(self.project_root, self.home, self.session_id)
146
+ except Exception:
147
+ verification = {"status": "incomplete", "ok": False}
148
+ feedback = feedback_summary(self.project_root, self.session_id)
149
+ dialogue = load_all_dialogue(
150
+ self.project_root, self.session_id, manifest.get("personas", [])
151
+ )
152
+ _json_response(self, {
153
+ "session_id": self.session_id,
154
+ "current_round": manifest.get("current_round", 1),
155
+ "verification_status": verification.get("status"),
156
+ "feedback_items": feedback.get("selected_items", []),
157
+ "dialogue_threads": _serialize_threads(dialogue),
158
+ "rounds": manifest.get("rounds", []),
159
+ "story_unit": manifest.get("story_unit"),
160
+ "personas": manifest.get("personas", []),
161
+ })
162
+ except GhostReaderError as exc:
163
+ _json_response(self, {"error": str(exc)}, 400)
164
+ except Exception:
165
+ _json_response(self, {"error": "internal server error"}, 500)
166
+
167
+
168
+ def _serialize_threads(dialogue: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
169
+ result: dict[str, list[dict[str, Any]]] = {}
170
+ for persona_id, turns in dialogue.items():
171
+ serialized = []
172
+ for turn in turns:
173
+ serialized.append({
174
+ "turn_index": turn.get("turn_index"),
175
+ "timestamp": turn.get("timestamp"),
176
+ "user": turn.get("user", ""),
177
+ "persona": turn.get("persona", ""),
178
+ "thread_id": turn.get("thread_id", ""),
179
+ })
180
+ if serialized:
181
+ result[persona_id] = serialized
182
+ return result
183
+
184
+
185
+ def _build_handler_class(project_root: Path, home: Path, session_id: str) -> type[_FeedbackHandler]:
186
+ class _BoundHandler(_FeedbackHandler):
187
+ pass
188
+
189
+ _BoundHandler.project_root = project_root
190
+ _BoundHandler.home = home
191
+ _BoundHandler.session_id = session_id
192
+ _BoundHandler.directory = str(project_root)
193
+ return _BoundHandler
194
+
195
+
196
+ def serve_report(
197
+ project_root: Path,
198
+ home: Path,
199
+ session_id: str,
200
+ port: int = 8765,
201
+ timeout: int = 0,
202
+ render: bool | None = None,
203
+ command_prefix: str = "ghost-reader",
204
+ template_name: str = "report",
205
+ pipe_wfd: int | None = None,
206
+ ) -> str:
207
+ payload_path = artifact_paths(project_root, session_id)["report"] / "payload.json"
208
+ should_render = render if render is not None else not payload_path.exists()
209
+ if should_render:
210
+ print("Auto-rendering report...")
211
+ render_report(project_root, home, session_id, command_prefix, template_name)
212
+
213
+ handler_class = _build_handler_class(project_root, home, session_id)
214
+ global _bound_port
215
+ _bound_port = None # Reset so each serve_report call gets a fresh port
216
+ actual_port = port
217
+ server = None
218
+ for attempt in range(port, port + 100):
219
+ try:
220
+ server = HTTPServer(("127.0.0.1", attempt), handler_class)
221
+ actual_port = server.server_address[1]
222
+ _bound_port = actual_port
223
+ break
224
+ except OSError:
225
+ continue
226
+ if server is None:
227
+ raise RuntimeError(f"Could not bind to any port starting from {port}")
228
+
229
+ url = f"http://localhost:{actual_port}"
230
+ manifest = load_manifest(project_root, session_id)
231
+ current_round = manifest.get("current_round", 1)
232
+ report_rel = f".ghostreader/sessions/{session_id}/round-{current_round}/report/index.html"
233
+ handler_class.report_subpath = report_rel
234
+ print(f"Ghost Reader serving at {url}/{report_rel}")
235
+
236
+ # Communicate port to parent AFTER all fallible setup completes
237
+ # but BEFORE entering the blocking serve loop. If load_manifest or
238
+ # any other setup step fails, the child's except block writes
239
+ # "error\n" to the pipe and the parent never sees "ok:", avoiding
240
+ # a silent crash where the parent thinks the server is running.
241
+ if pipe_wfd is not None:
242
+ os.write(pipe_wfd, f"ok:{actual_port}\n".encode())
243
+
244
+ if timeout > 0:
245
+ _run_with_timeout(server, url, timeout, session_id)
246
+ else:
247
+ _run_forever(server)
248
+ return url
249
+
250
+
251
+ def _run_forever(server: HTTPServer) -> None:
252
+ try:
253
+ server.serve_forever()
254
+ except KeyboardInterrupt:
255
+ pass
256
+ finally:
257
+ server.server_close()
258
+ print("Server stopped")
259
+
260
+
261
+ def _run_with_timeout(
262
+ server: HTTPServer, url: str, timeout: int, session_id: str
263
+ ) -> None:
264
+ server._last_active = time.time()
265
+ server.timeout = 1.0
266
+ try:
267
+ while True:
268
+ server.handle_request()
269
+ if time.time() - server._last_active > timeout:
270
+ print(
271
+ f"\nGhost Reader server idle for {timeout}s — shutting down.\n"
272
+ f"Tip: use `ghost-reader serve --session {session_id} --detach` "
273
+ f"for review sessions where you need time to deliberate.\n",
274
+ file=sys.stderr,
275
+ )
276
+ break
277
+ except KeyboardInterrupt:
278
+ pass
279
+ finally:
280
+ server.server_close()
281
+ print("Server stopped")