@grifhinz/logics-manager 2.8.0 → 2.9.0

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.
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import hashlib
5
+ import hmac
5
6
  import json
7
+ import secrets
6
8
  import mimetypes
7
9
  import os
8
10
  import re
@@ -10,6 +12,7 @@ import shutil
10
12
  import socket
11
13
  import subprocess
12
14
  import sys
15
+ import tomllib
13
16
  import webbrowser
14
17
  from dataclasses import dataclass
15
18
  from datetime import datetime
@@ -49,6 +52,8 @@ CDX_MISSION_STRENGTHS = {
49
52
  "deep": {"id": "deep", "label": "Deep", "timeout": 300, "reasoningEffort": "high", "power": "high"},
50
53
  "max": {"id": "max", "label": "Max", "timeout": 600, "reasoningEffort": "high", "power": "high"},
51
54
  }
55
+ CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS = 90
56
+ CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS = 600
52
57
  CDX_MISSION_CATALOG = {
53
58
  "full-audit": {
54
59
  "id": "full-audit",
@@ -142,6 +147,26 @@ GIT_FILE_PREVIEW_MAX_BYTES = 30000
142
147
  GIT_FILE_PREVIEW_MAX_CHARS = 20000
143
148
  FILE_PREVIEW_MAX_BYTES = 300000
144
149
  FILE_PREVIEW_MAX_CHARS = 200000
150
+ WORKSPACE_TREE_MAX_ENTRIES = 250
151
+ WORKSPACE_PREVIEW_MAX_BYTES = 30000
152
+ WORKSPACE_PREVIEW_MAX_CHARS = 20000
153
+ WORKSPACE_IGNORED_DIRS = {
154
+ ".git",
155
+ ".hg",
156
+ ".svn",
157
+ "__pycache__",
158
+ ".pytest_cache",
159
+ ".mypy_cache",
160
+ ".ruff_cache",
161
+ "node_modules",
162
+ "dist",
163
+ "build",
164
+ "coverage",
165
+ ".next",
166
+ ".turbo",
167
+ ".venv",
168
+ "venv",
169
+ }
145
170
  REPO_ROOT = Path(__file__).resolve().parents[1]
146
171
  PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
147
172
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
@@ -442,6 +467,7 @@ def viewer_data_payload(
442
467
  selected_id: str | None = None,
443
468
  *,
444
469
  auto_refresh_interval_seconds: int = 15,
470
+ auto_refresh_interval_forced: bool = False,
445
471
  projects: list[dict[str, Any]] | None = None,
446
472
  ) -> dict[str, Any]:
447
473
  capabilities = viewer_project_capabilities(repo_root)
@@ -457,6 +483,7 @@ def viewer_data_payload(
457
483
  "capabilities": capabilities,
458
484
  "projects": projects if projects is not None else viewer_project_registry(repo_root),
459
485
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
486
+ "autoRefreshIntervalForced": auto_refresh_interval_forced,
460
487
  "items": collect_viewer_items(repo_root),
461
488
  "updateInfo": get_update_info(_current_version()).to_payload(),
462
489
  "selectedId": selected_id,
@@ -611,9 +638,31 @@ def viewer_project_capabilities(
611
638
  else:
612
639
  cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
613
640
  cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
641
+ workspace = _viewer_capability(
642
+ "ready" if repo_root.is_dir() else "missing",
643
+ available=repo_root.is_dir(),
644
+ message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
645
+ detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
646
+ )
647
+ workshop_available = repo_root.is_dir()
648
+ terminals_available = workshop_available and workshop_terminals_available()
649
+ if terminals_available:
650
+ workshop_message = "Workshop command runner and PTY terminals are available."
651
+ elif workshop_available:
652
+ workshop_message = "Workshop command runner is available; PTY terminals require a Unix host with stdlib pty support."
653
+ else:
654
+ workshop_message = "Workshop is not available without a workspace root."
655
+ workshop = _viewer_capability(
656
+ "ready" if workshop_available else "missing",
657
+ available=workshop_available,
658
+ message=workshop_message,
659
+ detail={"terminalsAvailable": terminals_available, "commandsAvailable": workshop_available},
660
+ )
614
661
 
615
662
  return {
616
663
  "logics": logics,
664
+ "workspace": workspace,
665
+ "workshop": workshop,
617
666
  "git": git,
618
667
  "ci": ci,
619
668
  "cdx": cdx,
@@ -1156,6 +1205,278 @@ def git_file_preview_payload(
1156
1205
  }
1157
1206
 
1158
1207
 
1208
+ def _normalize_workspace_path(rel_path: str) -> str:
1209
+ normalized = unquote(rel_path or "").replace("\\", "/").strip()
1210
+ normalized = normalized.lstrip("/")
1211
+ if normalized in {"", "."}:
1212
+ return ""
1213
+ if normalized.startswith("~") or re.match(r"^[A-Za-z]:", normalized):
1214
+ raise ValueError("Unsafe workspace path.")
1215
+ parts = [part for part in normalized.split("/") if part not in {"", "."}]
1216
+ if any(part == ".." for part in parts):
1217
+ raise ValueError("Workspace path escapes root.")
1218
+ return "/".join(parts)
1219
+
1220
+
1221
+ def _resolve_workspace_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
1222
+ normalized = _normalize_workspace_path(rel_path)
1223
+ root = repo_root.resolve()
1224
+ target = (root / normalized).resolve()
1225
+ try:
1226
+ target.relative_to(root)
1227
+ except ValueError as exc:
1228
+ raise ValueError("Workspace path escapes root.") from exc
1229
+ return normalized, target
1230
+
1231
+
1232
+ def _workspace_entry_payload(root: Path, path: Path) -> dict[str, Any]:
1233
+ try:
1234
+ stat = path.stat()
1235
+ except OSError:
1236
+ stat = None
1237
+ rel_path = path.relative_to(root).as_posix()
1238
+ is_dir = path.is_dir()
1239
+ ignored = is_dir and path.name in WORKSPACE_IGNORED_DIRS
1240
+ return {
1241
+ "name": path.name or root.name,
1242
+ "path": rel_path,
1243
+ "kind": "directory" if is_dir else "file",
1244
+ "size": stat.st_size if stat else 0,
1245
+ "ignored": ignored,
1246
+ "childrenAvailable": is_dir and not ignored,
1247
+ }
1248
+
1249
+
1250
+ def workspace_tree_payload(
1251
+ repo_root: Path,
1252
+ rel_path: str = "",
1253
+ *,
1254
+ max_entries: int = WORKSPACE_TREE_MAX_ENTRIES,
1255
+ ) -> dict[str, Any]:
1256
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1257
+ root = repo_root.resolve()
1258
+ if not target.exists():
1259
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1260
+ if not target.is_dir():
1261
+ return {"state": "not-directory", "path": normalized, "message": "Workspace path is not a directory."}
1262
+ entries = []
1263
+ truncated = False
1264
+ try:
1265
+ children = sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower()))
1266
+ except OSError as exc:
1267
+ return {"state": "error", "path": normalized, "message": f"Unable to list workspace path: {exc}"}
1268
+ for child in children:
1269
+ if len(entries) >= max_entries:
1270
+ truncated = True
1271
+ break
1272
+ entries.append(_workspace_entry_payload(root, child))
1273
+ return {
1274
+ "state": "ok",
1275
+ "root": str(root),
1276
+ "path": normalized,
1277
+ "entries": entries,
1278
+ "truncated": truncated,
1279
+ "ignoredDirectories": sorted(WORKSPACE_IGNORED_DIRS),
1280
+ }
1281
+
1282
+
1283
+ def workspace_preview_payload(
1284
+ repo_root: Path,
1285
+ rel_path: str,
1286
+ *,
1287
+ max_bytes: int = WORKSPACE_PREVIEW_MAX_BYTES,
1288
+ max_chars: int = WORKSPACE_PREVIEW_MAX_CHARS,
1289
+ ) -> dict[str, Any]:
1290
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1291
+ if not target.exists():
1292
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1293
+ if target.is_dir():
1294
+ try:
1295
+ count = sum(1 for _ in target.iterdir())
1296
+ except OSError:
1297
+ count = 0
1298
+ return {
1299
+ "state": "directory",
1300
+ "path": normalized,
1301
+ "name": target.name or repo_root.resolve().name,
1302
+ "kind": "directory",
1303
+ "message": f"{count} item(s)",
1304
+ "childrenAvailable": target.name not in WORKSPACE_IGNORED_DIRS,
1305
+ }
1306
+ if not target.is_file():
1307
+ return {"state": "unsupported", "path": normalized, "message": "Workspace object cannot be previewed."}
1308
+ try:
1309
+ size = target.stat().st_size
1310
+ except OSError as exc:
1311
+ return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
1312
+ if size > max_bytes:
1313
+ return {
1314
+ "state": "oversized",
1315
+ "path": normalized,
1316
+ "name": target.name,
1317
+ "size": size,
1318
+ "message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
1319
+ }
1320
+ try:
1321
+ data = target.read_bytes()
1322
+ except OSError as exc:
1323
+ return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
1324
+ content_type = mimetypes.guess_type(target.name)[0] or ""
1325
+ if content_type.startswith("image/"):
1326
+ return {
1327
+ "state": "image",
1328
+ "path": normalized,
1329
+ "name": target.name,
1330
+ "size": size,
1331
+ "contentType": content_type,
1332
+ "message": "Image preview is available from the workspace file endpoint.",
1333
+ }
1334
+ if b"\x00" in data:
1335
+ return {
1336
+ "state": "unsupported",
1337
+ "path": normalized,
1338
+ "name": target.name,
1339
+ "size": size,
1340
+ "message": "Binary or unsupported file content cannot be previewed.",
1341
+ }
1342
+ try:
1343
+ content = data.decode("utf-8")
1344
+ except UnicodeDecodeError:
1345
+ return {
1346
+ "state": "unsupported",
1347
+ "path": normalized,
1348
+ "name": target.name,
1349
+ "size": size,
1350
+ "message": "Binary or unsupported file encoding cannot be previewed.",
1351
+ }
1352
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
1353
+ truncated = len(content) > max_chars
1354
+ if truncated:
1355
+ content = content[:max_chars]
1356
+ return {
1357
+ "state": "ok",
1358
+ "path": normalized,
1359
+ "name": target.name,
1360
+ "kind": "file",
1361
+ "size": size,
1362
+ "contentType": content_type or "text/plain",
1363
+ "content": content,
1364
+ "truncated": truncated,
1365
+ "logicsType": _logics_doc_type(normalized),
1366
+ "message": "",
1367
+ }
1368
+
1369
+
1370
+ WORKSHOP_COMMAND_MAX = 200
1371
+
1372
+
1373
+ def _workshop_command_id(group: str, name: str) -> str:
1374
+ safe = re.sub(r"[^a-z0-9._-]+", "-", f"{group}:{name}".lower()).strip("-") or "command"
1375
+ return safe[:80]
1376
+
1377
+
1378
+ def _discover_package_json_scripts(repo_root: Path) -> list[dict[str, Any]]:
1379
+ target = repo_root / "package.json"
1380
+ if not target.is_file():
1381
+ return []
1382
+ try:
1383
+ with target.open("rb") as handle:
1384
+ payload = json.load(handle)
1385
+ except (OSError, ValueError):
1386
+ return []
1387
+ scripts = payload.get("scripts") if isinstance(payload, dict) else None
1388
+ if not isinstance(scripts, dict):
1389
+ return []
1390
+ entries: list[dict[str, Any]] = []
1391
+ for name, command in scripts.items():
1392
+ if not isinstance(name, str) or not isinstance(command, str):
1393
+ continue
1394
+ entries.append(
1395
+ {
1396
+ "id": _workshop_command_id("npm", name),
1397
+ "source": "package.json",
1398
+ "group": "npm scripts",
1399
+ "name": name,
1400
+ "command": command,
1401
+ "runner": ["npm", "run", name],
1402
+ }
1403
+ )
1404
+ if len(entries) >= WORKSHOP_COMMAND_MAX:
1405
+ break
1406
+ return entries
1407
+
1408
+
1409
+ def _discover_pyproject_scripts(repo_root: Path) -> list[dict[str, Any]]:
1410
+ target = repo_root / "pyproject.toml"
1411
+ if not target.is_file():
1412
+ return []
1413
+ try:
1414
+ with target.open("rb") as handle:
1415
+ payload = tomllib.load(handle)
1416
+ except (OSError, tomllib.TOMLDecodeError):
1417
+ return []
1418
+ entries: list[dict[str, Any]] = []
1419
+ project_scripts = (payload.get("project") or {}).get("scripts")
1420
+ if isinstance(project_scripts, dict):
1421
+ for name, target_ref in project_scripts.items():
1422
+ if not isinstance(name, str) or not isinstance(target_ref, str):
1423
+ continue
1424
+ entries.append(
1425
+ {
1426
+ "id": _workshop_command_id("pyproject", name),
1427
+ "source": "pyproject.toml [project.scripts]",
1428
+ "group": "Project scripts",
1429
+ "name": name,
1430
+ "command": target_ref,
1431
+ "runner": [name],
1432
+ }
1433
+ )
1434
+ if len(entries) >= WORKSHOP_COMMAND_MAX:
1435
+ return entries
1436
+ poetry_scripts = (
1437
+ ((payload.get("tool") or {}).get("poetry") or {}).get("scripts")
1438
+ if isinstance(payload.get("tool"), dict)
1439
+ else None
1440
+ )
1441
+ if isinstance(poetry_scripts, dict):
1442
+ for name, target_ref in poetry_scripts.items():
1443
+ if not isinstance(name, str) or not isinstance(target_ref, str):
1444
+ continue
1445
+ entries.append(
1446
+ {
1447
+ "id": _workshop_command_id("poetry", name),
1448
+ "source": "pyproject.toml [tool.poetry.scripts]",
1449
+ "group": "Poetry scripts",
1450
+ "name": name,
1451
+ "command": target_ref,
1452
+ "runner": ["poetry", "run", name],
1453
+ }
1454
+ )
1455
+ if len(entries) >= WORKSHOP_COMMAND_MAX:
1456
+ break
1457
+ return entries
1458
+
1459
+
1460
+ def workshop_commands_payload(repo_root: Path) -> dict[str, Any]:
1461
+ if not repo_root.is_dir():
1462
+ return {"state": "unavailable", "commands": [], "message": "Workspace root is unavailable."}
1463
+ commands: list[dict[str, Any]] = []
1464
+ commands.extend(_discover_package_json_scripts(repo_root))
1465
+ commands.extend(_discover_pyproject_scripts(repo_root))
1466
+ seen: set[str] = set()
1467
+ deduped: list[dict[str, Any]] = []
1468
+ for entry in commands:
1469
+ if entry["id"] in seen:
1470
+ continue
1471
+ seen.add(entry["id"])
1472
+ deduped.append(entry)
1473
+ return {
1474
+ "state": "ok" if deduped else "empty",
1475
+ "commands": deduped,
1476
+ "message": "" if deduped else "No package.json or pyproject.toml entry points were found in the workspace root.",
1477
+ }
1478
+
1479
+
1159
1480
  def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
1160
1481
  context = {"branch": "", "headSha": "", "subject": "", "author": ""}
1161
1482
  commands = {
@@ -1195,28 +1516,21 @@ def _is_active_ci_status(run: dict[str, Any]) -> bool:
1195
1516
 
1196
1517
 
1197
1518
  def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
1198
- head_runs = [run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1519
+ ci_runs = [run for run in runs if str(run.get("name") or "").strip().lower() == "ci"]
1520
+ candidate_runs = ci_runs or runs
1521
+ head_runs = [run for run in candidate_runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1199
1522
  active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
1200
1523
  if active_head_run is not None:
1201
1524
  return active_head_run, "head-active"
1202
- failing_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1203
- if failing_head_run is not None:
1204
- return failing_head_run, "head-failing"
1205
- cancelled_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "cancelled"), None)
1206
- if cancelled_head_run is not None:
1207
- return cancelled_head_run, "head-cancelled"
1208
- unknown_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "unknown"), None)
1209
- if unknown_head_run is not None:
1210
- return unknown_head_run, "head-unknown"
1211
1525
  if head_runs:
1526
+ head_state = _ci_badge_state(str(head_runs[0].get("status") or ""), str(head_runs[0].get("conclusion") or ""))
1527
+ if head_state in {"failing", "cancelled", "unknown"}:
1528
+ return head_runs[0], f"head-{head_state}"
1212
1529
  return head_runs[0], "head"
1213
- active_branch_run = next((run for run in runs if _is_active_ci_status(run)), None)
1530
+ active_branch_run = next((run for run in candidate_runs if _is_active_ci_status(run)), None)
1214
1531
  if active_branch_run is not None:
1215
1532
  return active_branch_run, "branch-active"
1216
- failing_branch_run = next((run for run in runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1217
- if failing_branch_run is not None:
1218
- return failing_branch_run, "branch-failing"
1219
- return runs[0], "branch-latest"
1533
+ return candidate_runs[0], "branch-latest"
1220
1534
 
1221
1535
 
1222
1536
  def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
@@ -1605,6 +1919,13 @@ def _cdx_mission_prompt(
1605
1919
  raise ValueError("Unknown CDX mission.")
1606
1920
 
1607
1921
 
1922
+ def _cdx_mission_timeout(strength: dict[str, Any], *, allow_file_writes: bool = False, commit_at_end: bool = False) -> int:
1923
+ timeout = int(strength.get("timeout") or 180)
1924
+ if allow_file_writes or commit_at_end:
1925
+ return max(timeout, CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS)
1926
+ return timeout
1927
+
1928
+
1608
1929
  def _cdx_mission_command(
1609
1930
  repo_root: Path,
1610
1931
  mission_id: str,
@@ -1627,7 +1948,7 @@ def _cdx_mission_command(
1627
1948
  direct_fixes=mission_inputs.get("directFixes") == "true",
1628
1949
  commit_at_end=commit_at_end,
1629
1950
  )
1630
- timeout = int(strength.get("timeout") or 180)
1951
+ timeout = _cdx_mission_timeout(strength, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end)
1631
1952
  reasoning_effort = str(strength.get("reasoningEffort") or "medium")
1632
1953
  power = str(strength.get("power") or "medium")
1633
1954
  permission = "workspace-write" if allow_file_writes else "read-only"
@@ -1868,6 +2189,7 @@ def cdx_mission_plan_payload(
1868
2189
  "requestedCommitAtEnd": requested_commit_at_end,
1869
2190
  "supportsFileWrites": supports_file_writes,
1870
2191
  "permission": permission,
2192
+ "timeoutSeconds": _cdx_mission_timeout(strength_def, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end),
1871
2193
  "command": ["cdx", *command],
1872
2194
  "arguments": command,
1873
2195
  "warnings": warnings,
@@ -1895,9 +2217,10 @@ def cdx_mission_run_payload(
1895
2217
  if plan_payload.get("state") != "ok":
1896
2218
  return {"state": plan_payload.get("state") or "error", "message": plan_payload.get("message") or "Unable to plan CDX mission.", "plan": plan_payload.get("plan"), "run": None}
1897
2219
  plan = plan_payload["plan"]
1898
- timeout = int(plan["strength"].get("timeout") or 180)
2220
+ timeout = int(plan.get("timeoutSeconds") or plan["strength"].get("timeout") or 180)
2221
+ process_timeout = timeout + CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS
1899
2222
  try:
1900
- result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=timeout, runner=cdx_runner)
2223
+ result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=process_timeout, runner=cdx_runner)
1901
2224
  except subprocess.TimeoutExpired:
1902
2225
  return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
1903
2226
  except (OSError, subprocess.SubprocessError) as exc:
@@ -2116,6 +2439,556 @@ def _json_bytes(payload: Any) -> bytes:
2116
2439
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
2117
2440
 
2118
2441
 
2442
+ VIEWER_MUTATING_ROUTES = frozenset(
2443
+ {
2444
+ "/api/edit",
2445
+ "/api/open-file",
2446
+ "/api/open-repo-folder",
2447
+ "/api/bootstrap-logics",
2448
+ "/api/switch-project",
2449
+ "/api/cdx-report-request",
2450
+ "/api/cdx-mission-run",
2451
+ "/api/cdx-mission-apply-plan",
2452
+ "/api/workshop-command-start",
2453
+ "/api/workshop-command-stop",
2454
+ "/api/workshop-terminal-start",
2455
+ "/api/workshop-terminal-stop",
2456
+ "/api/workshop-terminal-input",
2457
+ "/api/workshop-terminal-resize",
2458
+ }
2459
+ )
2460
+
2461
+
2462
+ _WORKSHOP_SESSION_BUFFER_MAX = 4000
2463
+ _WORKSHOP_SESSION_TTL_SECONDS = 600
2464
+
2465
+
2466
+ class WorkshopCommandSession:
2467
+ """Sandboxed subprocess for a Workshop command run.
2468
+
2469
+ Captures merged stdout+stderr into a ring buffer that SSE consumers can
2470
+ tail incrementally via a monotonically increasing sequence number. Stop
2471
+ delivers SIGTERM (Unix) or CTRL_BREAK_EVENT (Windows) to the process
2472
+ group and falls back to SIGKILL if the process refuses to exit.
2473
+ """
2474
+
2475
+ def __init__(self, session_id: str, command_id: str, runner: list[str], cwd: Path):
2476
+ import collections
2477
+ import threading
2478
+ self.session_id = session_id
2479
+ self.command_id = command_id
2480
+ self.runner = list(runner)
2481
+ self.cwd = cwd
2482
+ self.started_at = ""
2483
+ self.finished_at = ""
2484
+ self.exit_code: int | None = None
2485
+ self.state = "starting"
2486
+ self.error: str = ""
2487
+ self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_SESSION_BUFFER_MAX)
2488
+ self._seq = 0
2489
+ self._lock = threading.Lock()
2490
+ self._proc: subprocess.Popen[bytes] | None = None
2491
+ self._reader: threading.Thread | None = None
2492
+ self._waiter: threading.Thread | None = None
2493
+ self._created_at = self._now()
2494
+ self._last_activity = self._created_at
2495
+
2496
+ @staticmethod
2497
+ def _now() -> float:
2498
+ import time
2499
+ return time.monotonic()
2500
+
2501
+ @staticmethod
2502
+ def _iso_now() -> str:
2503
+ return datetime.utcnow().isoformat(timespec="seconds") + "Z"
2504
+
2505
+ def append_line(self, channel: str, text: str) -> None:
2506
+ with self._lock:
2507
+ self._seq += 1
2508
+ self._buffer.append((self._seq, f"{channel}\t{text}"))
2509
+ self._last_activity = self._now()
2510
+
2511
+ def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
2512
+ with self._lock:
2513
+ snapshot = [(seq, line) for (seq, line) in self._buffer if seq > since_seq]
2514
+ return self._seq, snapshot
2515
+
2516
+ def status_payload(self) -> dict[str, Any]:
2517
+ with self._lock:
2518
+ return {
2519
+ "id": self.session_id,
2520
+ "commandId": self.command_id,
2521
+ "runner": list(self.runner),
2522
+ "state": self.state,
2523
+ "exitCode": self.exit_code,
2524
+ "startedAt": self.started_at,
2525
+ "finishedAt": self.finished_at,
2526
+ "lastSeq": self._seq,
2527
+ "error": self.error,
2528
+ }
2529
+
2530
+ def is_expired(self, ttl_seconds: float = _WORKSHOP_SESSION_TTL_SECONDS) -> bool:
2531
+ if self.state in {"running", "starting"}:
2532
+ return False
2533
+ return (self._now() - self._last_activity) > ttl_seconds
2534
+
2535
+ def start(self) -> None:
2536
+ import threading
2537
+ creation_flags = 0
2538
+ popen_kwargs: dict[str, Any] = {
2539
+ "cwd": str(self.cwd),
2540
+ "stdout": subprocess.PIPE,
2541
+ "stderr": subprocess.STDOUT,
2542
+ "stdin": subprocess.DEVNULL,
2543
+ }
2544
+ if sys.platform == "win32":
2545
+ creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
2546
+ popen_kwargs["creationflags"] = creation_flags
2547
+ else:
2548
+ popen_kwargs["start_new_session"] = True
2549
+ try:
2550
+ self._proc = subprocess.Popen(self.runner, **popen_kwargs)
2551
+ except (OSError, ValueError) as exc:
2552
+ self.state = "error"
2553
+ self.error = f"Unable to start command: {exc}"
2554
+ self.finished_at = self._iso_now()
2555
+ return
2556
+ self.started_at = self._iso_now()
2557
+ self.state = "running"
2558
+ self._reader = threading.Thread(target=self._read_loop, name=f"workshop-reader-{self.session_id}", daemon=True)
2559
+ self._reader.start()
2560
+ self._waiter = threading.Thread(target=self._wait_loop, name=f"workshop-waiter-{self.session_id}", daemon=True)
2561
+ self._waiter.start()
2562
+
2563
+ def _read_loop(self) -> None:
2564
+ proc = self._proc
2565
+ if proc is None or proc.stdout is None:
2566
+ return
2567
+ try:
2568
+ for raw in iter(proc.stdout.readline, b""):
2569
+ try:
2570
+ text = raw.decode("utf-8", errors="replace").rstrip("\r\n")
2571
+ except Exception:
2572
+ continue
2573
+ self.append_line("stdout", text)
2574
+ except (OSError, ValueError):
2575
+ pass
2576
+
2577
+ def _wait_loop(self) -> None:
2578
+ proc = self._proc
2579
+ if proc is None:
2580
+ return
2581
+ try:
2582
+ code = proc.wait()
2583
+ except Exception as exc:
2584
+ self.error = f"Wait failed: {exc}"
2585
+ code = -1
2586
+ with self._lock:
2587
+ self.exit_code = code
2588
+ self.finished_at = self._iso_now()
2589
+ self.state = "finished" if code == 0 else ("stopped" if code in (-15, 143, -9, 137) else "failed")
2590
+ self._last_activity = self._now()
2591
+
2592
+ def stop(self, *, timeout: float = 5.0) -> None:
2593
+ proc = self._proc
2594
+ if proc is None or proc.poll() is not None:
2595
+ return
2596
+ try:
2597
+ if sys.platform == "win32":
2598
+ sig = getattr(__import__("signal"), "CTRL_BREAK_EVENT", None)
2599
+ if sig is not None:
2600
+ proc.send_signal(sig)
2601
+ else:
2602
+ proc.terminate()
2603
+ else:
2604
+ import os as _os
2605
+ import signal as _signal
2606
+ try:
2607
+ _os.killpg(proc.pid, _signal.SIGTERM)
2608
+ except (OSError, ProcessLookupError):
2609
+ proc.terminate()
2610
+ except Exception:
2611
+ proc.terminate()
2612
+ try:
2613
+ proc.wait(timeout=timeout)
2614
+ except subprocess.TimeoutExpired:
2615
+ try:
2616
+ if sys.platform == "win32":
2617
+ proc.kill()
2618
+ else:
2619
+ import os as _os
2620
+ import signal as _signal
2621
+ _os.killpg(proc.pid, _signal.SIGKILL)
2622
+ except Exception:
2623
+ proc.kill()
2624
+
2625
+
2626
+ class WorkshopSessionRegistry:
2627
+ def __init__(self) -> None:
2628
+ import threading
2629
+ self._sessions: dict[str, WorkshopCommandSession] = {}
2630
+ self._lock = threading.Lock()
2631
+ self._counter = 0
2632
+
2633
+ def create(self, command_entry: dict[str, Any], repo_root: Path) -> WorkshopCommandSession:
2634
+ runner = command_entry.get("runner")
2635
+ if not isinstance(runner, list) or not runner or not all(isinstance(part, str) and part for part in runner):
2636
+ raise ValueError("Command entry is missing a valid runner.")
2637
+ if not repo_root.is_dir():
2638
+ raise ValueError("Workspace root is unavailable.")
2639
+ with self._lock:
2640
+ self._counter += 1
2641
+ session_id = f"ws-{self._counter:06d}"
2642
+ session = WorkshopCommandSession(
2643
+ session_id=session_id,
2644
+ command_id=str(command_entry.get("id") or ""),
2645
+ runner=runner,
2646
+ cwd=repo_root,
2647
+ )
2648
+ with self._lock:
2649
+ self._prune_locked()
2650
+ self._sessions[session_id] = session
2651
+ session.start()
2652
+ return session
2653
+
2654
+ def get(self, session_id: str) -> WorkshopCommandSession | None:
2655
+ with self._lock:
2656
+ return self._sessions.get(session_id)
2657
+
2658
+ def list(self) -> list[dict[str, Any]]:
2659
+ with self._lock:
2660
+ self._prune_locked()
2661
+ return [session.status_payload() for session in self._sessions.values()]
2662
+
2663
+ def _prune_locked(self) -> None:
2664
+ for sid in list(self._sessions.keys()):
2665
+ if self._sessions[sid].is_expired():
2666
+ del self._sessions[sid]
2667
+
2668
+ def shutdown(self) -> None:
2669
+ with self._lock:
2670
+ sessions = list(self._sessions.values())
2671
+ for session in sessions:
2672
+ session.stop(timeout=1.0)
2673
+
2674
+
2675
+ _WORKSHOP_TERMINAL_BUFFER_MAX = 8000
2676
+ _WORKSHOP_TERMINAL_TTL_SECONDS = 1800
2677
+ _WORKSHOP_TERMINAL_IDLE_KILL_SECONDS = 60.0
2678
+
2679
+
2680
+ def workshop_terminals_available() -> bool:
2681
+ """True when the host can spawn PTY sessions through the stdlib backend."""
2682
+ if sys.platform == "win32":
2683
+ return False
2684
+ try:
2685
+ import pty # noqa: F401
2686
+ import termios # noqa: F401
2687
+ except ImportError:
2688
+ return False
2689
+ return True
2690
+
2691
+
2692
+ def _default_workshop_shell() -> list[str]:
2693
+ candidate = os.environ.get("SHELL") or ""
2694
+ if candidate and os.access(candidate, os.X_OK):
2695
+ return [candidate, "-i"]
2696
+ for fallback in ("/bin/zsh", "/bin/bash", "/bin/sh"):
2697
+ if os.access(fallback, os.X_OK):
2698
+ return [fallback, "-i"]
2699
+ return ["/bin/sh"]
2700
+
2701
+
2702
+ class WorkshopTerminalSession:
2703
+ """Interactive PTY-backed terminal session using stdlib `pty`.
2704
+
2705
+ Reads from the master fd in a daemon thread, buffers output bytes
2706
+ (decoded utf-8 with replace) into a ring; writes from the client land
2707
+ on the master fd directly. Resize uses TIOCSWINSZ ioctl. Stop sends
2708
+ SIGTERM to the session leader and falls back to SIGKILL after a grace
2709
+ window. Unix-only: Windows callers must check
2710
+ workshop_terminals_available() before instantiating.
2711
+ """
2712
+
2713
+ def __init__(self, session_id: str, command: list[str], cwd: Path, *, label: str = ""):
2714
+ import collections
2715
+ import threading
2716
+ self.session_id = session_id
2717
+ self.command = list(command)
2718
+ self.cwd = cwd
2719
+ self.label = label or (command[0] if command else "shell")
2720
+ self.started_at = ""
2721
+ self.finished_at = ""
2722
+ self.exit_code: int | None = None
2723
+ self.state = "starting"
2724
+ self.error: str = ""
2725
+ self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_TERMINAL_BUFFER_MAX)
2726
+ self._seq = 0
2727
+ self._lock = threading.Lock()
2728
+ self._master_fd: int | None = None
2729
+ self._pid: int | None = None
2730
+ self._reader: threading.Thread | None = None
2731
+ self._reaper: threading.Thread | None = None
2732
+ self._created_at = self._now()
2733
+ self._last_activity = self._created_at
2734
+ self._listeners = 0
2735
+ self._idle_timer: threading.Timer | None = None
2736
+ self._idle_kill_seconds = _WORKSHOP_TERMINAL_IDLE_KILL_SECONDS
2737
+
2738
+ @staticmethod
2739
+ def _now() -> float:
2740
+ import time
2741
+ return time.monotonic()
2742
+
2743
+ @staticmethod
2744
+ def _iso_now() -> str:
2745
+ return datetime.utcnow().isoformat(timespec="seconds") + "Z"
2746
+
2747
+ def _append(self, text: str) -> None:
2748
+ with self._lock:
2749
+ self._seq += 1
2750
+ self._buffer.append((self._seq, text))
2751
+ self._last_activity = self._now()
2752
+
2753
+ def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
2754
+ with self._lock:
2755
+ snapshot = [(seq, chunk) for (seq, chunk) in self._buffer if seq > since_seq]
2756
+ return self._seq, snapshot
2757
+
2758
+ def status_payload(self) -> dict[str, Any]:
2759
+ with self._lock:
2760
+ return {
2761
+ "id": self.session_id,
2762
+ "label": self.label,
2763
+ "command": list(self.command),
2764
+ "state": self.state,
2765
+ "exitCode": self.exit_code,
2766
+ "startedAt": self.started_at,
2767
+ "finishedAt": self.finished_at,
2768
+ "lastSeq": self._seq,
2769
+ "error": self.error,
2770
+ }
2771
+
2772
+ def is_expired(self, ttl_seconds: float = _WORKSHOP_TERMINAL_TTL_SECONDS) -> bool:
2773
+ if self.state in {"running", "starting"}:
2774
+ return False
2775
+ return (self._now() - self._last_activity) > ttl_seconds
2776
+
2777
+ def attach_listener(self) -> None:
2778
+ """Register a live SSE consumer for this session."""
2779
+ with self._lock:
2780
+ self._listeners += 1
2781
+ if self._idle_timer is not None:
2782
+ self._idle_timer.cancel()
2783
+ self._idle_timer = None
2784
+
2785
+ def detach_listener(self) -> None:
2786
+ """Release an SSE consumer; arm the idle-kill timer if none remain."""
2787
+ import threading
2788
+ arm = False
2789
+ with self._lock:
2790
+ self._listeners = max(0, self._listeners - 1)
2791
+ if self._listeners == 0 and self.state in {"running", "starting"}:
2792
+ if self._idle_timer is not None:
2793
+ self._idle_timer.cancel()
2794
+ self._idle_timer = threading.Timer(self._idle_kill_seconds, self._on_idle_timeout)
2795
+ self._idle_timer.daemon = True
2796
+ arm = True
2797
+ if arm and self._idle_timer is not None:
2798
+ self._idle_timer.start()
2799
+
2800
+ def _on_idle_timeout(self) -> None:
2801
+ with self._lock:
2802
+ still_idle = self._listeners == 0 and self.state in {"running", "starting"}
2803
+ self._idle_timer = None
2804
+ if not still_idle:
2805
+ return
2806
+ # Best-effort: SIGTERM the session group, falling back to SIGKILL.
2807
+ self.stop(timeout=3.0)
2808
+
2809
+ def start(self) -> None:
2810
+ import threading
2811
+ if not workshop_terminals_available():
2812
+ self.state = "error"
2813
+ self.error = "PTY backend is not available on this host."
2814
+ self.finished_at = self._iso_now()
2815
+ return
2816
+ import pty
2817
+ try:
2818
+ pid, master_fd = pty.fork()
2819
+ except (OSError, RuntimeError) as exc:
2820
+ self.state = "error"
2821
+ self.error = f"Unable to fork PTY: {exc}"
2822
+ self.finished_at = self._iso_now()
2823
+ return
2824
+ if pid == 0:
2825
+ try:
2826
+ os.chdir(str(self.cwd))
2827
+ except OSError:
2828
+ pass
2829
+ env = os.environ.copy()
2830
+ env.setdefault("TERM", "xterm-256color")
2831
+ env.setdefault("COLORTERM", "truecolor")
2832
+ try:
2833
+ os.execvpe(self.command[0], self.command, env)
2834
+ except Exception as exc: # noqa: BLE001
2835
+ sys.stderr.write(f"Unable to exec {self.command[0]}: {exc}\n")
2836
+ os._exit(127)
2837
+ self._pid = pid
2838
+ self._master_fd = master_fd
2839
+ self.started_at = self._iso_now()
2840
+ self.state = "running"
2841
+ self._reader = threading.Thread(target=self._read_loop, name=f"workshop-pty-reader-{self.session_id}", daemon=True)
2842
+ self._reader.start()
2843
+ self._reaper = threading.Thread(target=self._reap_loop, name=f"workshop-pty-reaper-{self.session_id}", daemon=True)
2844
+ self._reaper.start()
2845
+
2846
+ def write(self, data: str) -> None:
2847
+ if not data or self._master_fd is None:
2848
+ return
2849
+ try:
2850
+ os.write(self._master_fd, data.encode("utf-8"))
2851
+ except OSError as exc:
2852
+ self.error = f"Write failed: {exc}"
2853
+
2854
+ def resize(self, rows: int, cols: int) -> None:
2855
+ if self._master_fd is None or rows <= 0 or cols <= 0:
2856
+ return
2857
+ try:
2858
+ import fcntl
2859
+ import struct
2860
+ import termios
2861
+ fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
2862
+ except (OSError, ImportError):
2863
+ return
2864
+
2865
+ def _read_loop(self) -> None:
2866
+ fd = self._master_fd
2867
+ if fd is None:
2868
+ return
2869
+ try:
2870
+ while True:
2871
+ try:
2872
+ chunk = os.read(fd, 4096)
2873
+ except OSError:
2874
+ break
2875
+ if not chunk:
2876
+ break
2877
+ try:
2878
+ text = chunk.decode("utf-8", errors="replace")
2879
+ except Exception: # noqa: BLE001
2880
+ continue
2881
+ self._append(text)
2882
+ finally:
2883
+ try:
2884
+ os.close(fd)
2885
+ except OSError:
2886
+ pass
2887
+
2888
+ def _reap_loop(self) -> None:
2889
+ pid = self._pid
2890
+ if pid is None:
2891
+ return
2892
+ try:
2893
+ _, status = os.waitpid(pid, 0)
2894
+ except OSError as exc:
2895
+ self.error = f"waitpid failed: {exc}"
2896
+ status = -1
2897
+ if isinstance(status, int):
2898
+ if os.WIFEXITED(status):
2899
+ code = os.WEXITSTATUS(status)
2900
+ elif os.WIFSIGNALED(status):
2901
+ code = -os.WTERMSIG(status)
2902
+ else:
2903
+ code = -1
2904
+ else:
2905
+ code = -1
2906
+ with self._lock:
2907
+ self.exit_code = code
2908
+ self.finished_at = self._iso_now()
2909
+ self.state = "finished" if code == 0 else ("stopped" if code in (-15, -9) else "failed")
2910
+ self._last_activity = self._now()
2911
+ if self._idle_timer is not None:
2912
+ self._idle_timer.cancel()
2913
+ self._idle_timer = None
2914
+
2915
+ def stop(self, *, timeout: float = 3.0) -> None:
2916
+ pid = self._pid
2917
+ if pid is None:
2918
+ return
2919
+ import signal as _signal
2920
+ import time as _time
2921
+ try:
2922
+ os.killpg(os.getpgid(pid), _signal.SIGTERM)
2923
+ except (OSError, ProcessLookupError):
2924
+ try:
2925
+ os.kill(pid, _signal.SIGTERM)
2926
+ except OSError:
2927
+ return
2928
+ deadline = _time.monotonic() + timeout
2929
+ while _time.monotonic() < deadline:
2930
+ with self._lock:
2931
+ if self.state in {"finished", "failed", "stopped"}:
2932
+ return
2933
+ _time.sleep(0.05)
2934
+ try:
2935
+ os.killpg(os.getpgid(pid), _signal.SIGKILL)
2936
+ except (OSError, ProcessLookupError):
2937
+ try:
2938
+ os.kill(pid, _signal.SIGKILL)
2939
+ except OSError:
2940
+ return
2941
+
2942
+
2943
+ class WorkshopTerminalRegistry:
2944
+ def __init__(self) -> None:
2945
+ import threading
2946
+ self._sessions: dict[str, WorkshopTerminalSession] = {}
2947
+ self._lock = threading.Lock()
2948
+ self._counter = 0
2949
+
2950
+ def create(self, command: list[str], cwd: Path, *, label: str = "") -> WorkshopTerminalSession:
2951
+ if not workshop_terminals_available():
2952
+ raise ValueError("PTY backend is not available on this host.")
2953
+ if not command or not isinstance(command, list):
2954
+ raise ValueError("Terminal command must be a non-empty list.")
2955
+ if not cwd.is_dir():
2956
+ raise ValueError("Workspace root is unavailable.")
2957
+ with self._lock:
2958
+ self._counter += 1
2959
+ session_id = f"wt-{self._counter:06d}"
2960
+ session = WorkshopTerminalSession(session_id=session_id, command=command, cwd=cwd, label=label)
2961
+ with self._lock:
2962
+ self._prune_locked()
2963
+ self._sessions[session_id] = session
2964
+ session.start()
2965
+ return session
2966
+
2967
+ def get(self, session_id: str) -> WorkshopTerminalSession | None:
2968
+ with self._lock:
2969
+ return self._sessions.get(session_id)
2970
+
2971
+ def list(self) -> list[dict[str, Any]]:
2972
+ with self._lock:
2973
+ self._prune_locked()
2974
+ return [session.status_payload() for session in self._sessions.values()]
2975
+
2976
+ def _prune_locked(self) -> None:
2977
+ for sid in list(self._sessions.keys()):
2978
+ if self._sessions[sid].is_expired():
2979
+ del self._sessions[sid]
2980
+
2981
+ def shutdown(self) -> None:
2982
+ with self._lock:
2983
+ sessions = list(self._sessions.values())
2984
+ for session in sessions:
2985
+ session.stop(timeout=1.0)
2986
+
2987
+
2988
+ def workshop_terminal_default_command() -> list[str]:
2989
+ return _default_workshop_shell()
2990
+
2991
+
2119
2992
  class LogicsViewerServer(ThreadingHTTPServer):
2120
2993
  def __init__(
2121
2994
  self,
@@ -2123,6 +2996,8 @@ class LogicsViewerServer(ThreadingHTTPServer):
2123
2996
  repo_root: Path,
2124
2997
  *,
2125
2998
  auto_refresh_interval_seconds: int = 15,
2999
+ auto_refresh_interval_forced: bool = False,
3000
+ lan_mode: bool = False,
2126
3001
  ):
2127
3002
  self.launch_repo_root = repo_root.resolve()
2128
3003
  self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
@@ -2130,18 +3005,41 @@ class LogicsViewerServer(ThreadingHTTPServer):
2130
3005
  self.active_project_id = _viewer_project_id(self.launch_repo_root)
2131
3006
  self.repo_root = self.launch_repo_root
2132
3007
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
3008
+ self.auto_refresh_interval_forced = auto_refresh_interval_forced
3009
+ self.lan_mode = bool(lan_mode)
3010
+ self.lan_token = secrets.token_urlsafe(32) if self.lan_mode else ""
3011
+ self.workshop_sessions = WorkshopSessionRegistry()
3012
+ self.workshop_terminals = WorkshopTerminalRegistry()
2133
3013
  super().__init__(server_address, LogicsViewerRequestHandler)
2134
3014
 
3015
+ def server_close(self) -> None:
3016
+ try:
3017
+ self.workshop_sessions.shutdown()
3018
+ finally:
3019
+ try:
3020
+ self.workshop_terminals.shutdown()
3021
+ finally:
3022
+ super().server_close()
3023
+
2135
3024
  def project_registry_payload(self) -> list[dict[str, Any]]:
2136
3025
  return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
2137
3026
 
2138
3027
  def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
2139
- return viewer_data_payload(
3028
+ payload = viewer_data_payload(
2140
3029
  self.repo_root,
2141
3030
  selected_id=selected_id,
2142
3031
  auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
3032
+ auto_refresh_interval_forced=self.auto_refresh_interval_forced,
2143
3033
  projects=self.project_registry_payload(),
2144
3034
  )
3035
+ payload["lanMode"] = bool(self.lan_mode)
3036
+ if self.lan_mode and self.lan_token:
3037
+ host, port = self.server_address[:2]
3038
+ lan_url = _network_viewer_url(str(host), int(port)) or build_viewer_url(str(host), int(port))
3039
+ payload["lanShareUrl"] = _append_lan_token(lan_url, self.lan_token)
3040
+ else:
3041
+ payload["lanShareUrl"] = ""
3042
+ return payload
2145
3043
 
2146
3044
  def switch_project(self, project_id: str) -> dict[str, Any]:
2147
3045
  target = self.project_root_by_id.get(project_id)
@@ -2185,8 +3083,175 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
2185
3083
  content_type = f"{content_type}; charset=utf-8"
2186
3084
  self._send_bytes(path.read_bytes(), content_type=content_type)
2187
3085
 
3086
+ def _stream_workshop_terminal(self, session: "WorkshopTerminalSession", parsed: Any) -> None:
3087
+ import time as _time
3088
+ try:
3089
+ since = int(parse_qs(parsed.query).get("since", ["0"])[0])
3090
+ except (TypeError, ValueError):
3091
+ since = 0
3092
+ try:
3093
+ self.send_response(200)
3094
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
3095
+ self.send_header("Cache-Control", "no-store")
3096
+ self.send_header("X-Accel-Buffering", "no")
3097
+ self.end_headers()
3098
+ except (BrokenPipeError, ConnectionResetError):
3099
+ return
3100
+ session.attach_listener()
3101
+ try:
3102
+ last_seq = since
3103
+ idle_ticks = 0
3104
+ while True:
3105
+ latest_seq, snapshot = session.tail(last_seq)
3106
+ if snapshot:
3107
+ idle_ticks = 0
3108
+ for seq, chunk in snapshot:
3109
+ last_seq = seq
3110
+ try:
3111
+ payload = json.dumps({"seq": seq, "data": chunk})
3112
+ self.wfile.write(f"event: data\ndata: {payload}\n\n".encode("utf-8"))
3113
+ self.wfile.flush()
3114
+ except (BrokenPipeError, ConnectionResetError):
3115
+ return
3116
+ state = session.state
3117
+ if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
3118
+ try:
3119
+ payload = json.dumps(session.status_payload())
3120
+ self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
3121
+ self.wfile.flush()
3122
+ except (BrokenPipeError, ConnectionResetError):
3123
+ return
3124
+ return
3125
+ idle_ticks += 1
3126
+ if idle_ticks >= 30:
3127
+ try:
3128
+ self.wfile.write(b": keep-alive\n\n")
3129
+ self.wfile.flush()
3130
+ except (BrokenPipeError, ConnectionResetError):
3131
+ return
3132
+ idle_ticks = 0
3133
+ _time.sleep(0.1)
3134
+ except (BrokenPipeError, ConnectionResetError):
3135
+ return
3136
+ finally:
3137
+ session.detach_listener()
3138
+
3139
+ def _stream_workshop_session(self, session: "WorkshopCommandSession", parsed: Any) -> None:
3140
+ import time as _time
3141
+ try:
3142
+ since = int(parse_qs(parsed.query).get("since", ["0"])[0])
3143
+ except (TypeError, ValueError):
3144
+ since = 0
3145
+ try:
3146
+ self.send_response(200)
3147
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
3148
+ self.send_header("Cache-Control", "no-store")
3149
+ self.send_header("X-Accel-Buffering", "no")
3150
+ self.end_headers()
3151
+ except (BrokenPipeError, ConnectionResetError):
3152
+ return
3153
+ last_seq = since
3154
+ idle_ticks = 0
3155
+ try:
3156
+ while True:
3157
+ latest_seq, snapshot = session.tail(last_seq)
3158
+ if snapshot:
3159
+ idle_ticks = 0
3160
+ for seq, line in snapshot:
3161
+ last_seq = seq
3162
+ try:
3163
+ channel, _, text = line.partition("\t")
3164
+ payload = json.dumps({"seq": seq, "channel": channel, "line": text})
3165
+ self.wfile.write(f"event: line\ndata: {payload}\n\n".encode("utf-8"))
3166
+ self.wfile.flush()
3167
+ except (BrokenPipeError, ConnectionResetError):
3168
+ return
3169
+ state = session.state
3170
+ if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
3171
+ try:
3172
+ payload = json.dumps(session.status_payload())
3173
+ self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
3174
+ self.wfile.flush()
3175
+ except (BrokenPipeError, ConnectionResetError):
3176
+ return
3177
+ return
3178
+ idle_ticks += 1
3179
+ if idle_ticks >= 30:
3180
+ try:
3181
+ self.wfile.write(b": keep-alive\n\n")
3182
+ self.wfile.flush()
3183
+ except (BrokenPipeError, ConnectionResetError):
3184
+ return
3185
+ idle_ticks = 0
3186
+ _time.sleep(0.2)
3187
+ except (BrokenPipeError, ConnectionResetError):
3188
+ return
3189
+
3190
+ def _client_is_loopback(self) -> bool:
3191
+ try:
3192
+ host = self.client_address[0]
3193
+ except (IndexError, AttributeError):
3194
+ return False
3195
+ if not host:
3196
+ return False
3197
+ if host in {"127.0.0.1", "::1"}:
3198
+ return True
3199
+ if host.startswith("127."):
3200
+ return True
3201
+ if host.startswith("::ffff:127."):
3202
+ return True
3203
+ return False
3204
+
3205
+ def _is_public_get_route(self, route: str) -> bool:
3206
+ """Static UI assets that must load before the JS can attach the bearer.
3207
+
3208
+ Browsers do not auto-attach Authorization headers to <script src>,
3209
+ <link href>, or @font-face fetches, and we cannot put ?t= on every
3210
+ asset URL the page references. We let these routes through
3211
+ unauthenticated; they expose no repository data — every actual
3212
+ payload lives under /api/* which stays gated.
3213
+ """
3214
+ if route in {"/", "/browser-host.js", "/viewer.css", "/vendor/mermaid.min.js"}:
3215
+ return True
3216
+ if route.startswith("/media/"):
3217
+ return True
3218
+ return False
3219
+
3220
+ def _lan_auth_passes(self, parsed: Any, *, method: str = "GET") -> bool:
3221
+ token = self.server.lan_token
3222
+ if not token:
3223
+ return True
3224
+ if self._client_is_loopback():
3225
+ return True
3226
+ if method == "GET" and self._is_public_get_route(parsed.path):
3227
+ return True
3228
+ header = self.headers.get("Authorization", "")
3229
+ if header.lower().startswith("bearer "):
3230
+ candidate = header.split(" ", 1)[1].strip()
3231
+ if candidate and hmac.compare_digest(candidate, token):
3232
+ return True
3233
+ query_token = (parse_qs(parsed.query).get("t") or [""])[0]
3234
+ if query_token and hmac.compare_digest(query_token, token):
3235
+ return True
3236
+ return False
3237
+
3238
+ def _send_lan_unauthorized(self) -> None:
3239
+ body = _json_bytes({"ok": False, "error": "LAN viewer requires a bearer token. Open the share URL from the launch banner."})
3240
+ try:
3241
+ self.send_response(HTTPStatus.UNAUTHORIZED)
3242
+ self.send_header("Content-Type", "application/json; charset=utf-8")
3243
+ self.send_header("Cache-Control", "no-store")
3244
+ self.send_header("WWW-Authenticate", 'Bearer realm="logics-viewer"')
3245
+ self.end_headers()
3246
+ self.wfile.write(body)
3247
+ except (BrokenPipeError, ConnectionResetError):
3248
+ return
3249
+
2188
3250
  def do_GET(self) -> None:
2189
3251
  parsed = urlparse(self.path)
3252
+ if not self._lan_auth_passes(parsed, method="GET"):
3253
+ self._send_lan_unauthorized()
3254
+ return
2190
3255
  route = parsed.path
2191
3256
  if route == "/":
2192
3257
  self._serve_file(VIEWER_ROOT / "index.html")
@@ -2266,10 +3331,91 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
2266
3331
  rel_path = params.get("path", [""])[0]
2267
3332
  self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
2268
3333
  return
3334
+ if route == "/api/workspace-tree":
3335
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
3336
+ try:
3337
+ self._send_json({"ok": True, "payload": workspace_tree_payload(self.server.repo_root, rel_path)})
3338
+ except ValueError as exc:
3339
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
3340
+ return
3341
+ if route == "/api/workspace-preview":
3342
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
3343
+ try:
3344
+ self._send_json({"ok": True, "payload": workspace_preview_payload(self.server.repo_root, rel_path)})
3345
+ except ValueError as exc:
3346
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
3347
+ return
3348
+ if route == "/api/workshop-commands":
3349
+ try:
3350
+ self._send_json({"ok": True, "payload": workshop_commands_payload(self.server.repo_root)})
3351
+ except ValueError as exc:
3352
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
3353
+ return
3354
+ if route == "/api/workshop-sessions":
3355
+ self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_sessions.list()}})
3356
+ return
3357
+ if route == "/api/workshop-terminals":
3358
+ self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_terminals.list(), "available": workshop_terminals_available()}})
3359
+ return
3360
+ if route.startswith("/api/workshop-terminal/"):
3361
+ tail = route[len("/api/workshop-terminal/"):]
3362
+ parts = tail.split("/", 1)
3363
+ session_id = parts[0]
3364
+ kind = parts[1] if len(parts) > 1 else "status"
3365
+ session = self.server.workshop_terminals.get(session_id)
3366
+ if session is None:
3367
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
3368
+ return
3369
+ if kind == "status":
3370
+ self._send_json({"ok": True, "payload": session.status_payload()})
3371
+ return
3372
+ if kind == "stream":
3373
+ self._stream_workshop_terminal(session, parsed)
3374
+ return
3375
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown terminal sub-resource.")
3376
+ return
3377
+ if route.startswith("/api/workshop-session/"):
3378
+ tail = route[len("/api/workshop-session/"):]
3379
+ parts = tail.split("/", 1)
3380
+ session_id = parts[0]
3381
+ kind = parts[1] if len(parts) > 1 else "status"
3382
+ session = self.server.workshop_sessions.get(session_id)
3383
+ if session is None:
3384
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
3385
+ return
3386
+ if kind == "status":
3387
+ self._send_json({"ok": True, "payload": session.status_payload()})
3388
+ return
3389
+ if kind == "stream":
3390
+ self._stream_workshop_session(session, parsed)
3391
+ return
3392
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown session sub-resource.")
3393
+ return
3394
+ if route == "/api/workspace-file":
3395
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
3396
+ try:
3397
+ payload = workspace_preview_payload(self.server.repo_root, rel_path)
3398
+ if payload.get("state") != "image":
3399
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
3400
+ return
3401
+ _normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
3402
+ self._serve_file(absolute)
3403
+ except (FileNotFoundError, ValueError) as exc:
3404
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
3405
+ return
2269
3406
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
2270
3407
 
2271
3408
  def do_POST(self) -> None:
2272
3409
  parsed = urlparse(self.path)
3410
+ if not self._lan_auth_passes(parsed, method="POST"):
3411
+ self._send_lan_unauthorized()
3412
+ return
3413
+ if self.server.lan_mode and parsed.path in VIEWER_MUTATING_ROUTES:
3414
+ self._send_error_json(
3415
+ HTTPStatus.FORBIDDEN,
3416
+ "Mutating endpoint refused: the viewer is exposed on the LAN in read-only mode.",
3417
+ )
3418
+ return
2273
3419
  if parsed.path == "/api/refresh":
2274
3420
  self._send_json(
2275
3421
  {
@@ -2292,6 +3438,105 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
2292
3438
  except FileNotFoundError as exc:
2293
3439
  self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2294
3440
  return
3441
+ if parsed.path == "/api/workshop-command-start":
3442
+ try:
3443
+ length = int(self.headers.get("Content-Length", "0") or "0")
3444
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3445
+ body = json.loads(raw_body or "{}")
3446
+ command_id = str(body.get("commandId") or "")
3447
+ if not command_id:
3448
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing commandId.")
3449
+ return
3450
+ catalog = workshop_commands_payload(self.server.repo_root)
3451
+ entry = next((c for c in catalog.get("commands", []) if c.get("id") == command_id), None)
3452
+ if entry is None:
3453
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown command id.")
3454
+ return
3455
+ session = self.server.workshop_sessions.create(entry, self.server.repo_root)
3456
+ self._send_json({"ok": True, "payload": session.status_payload()})
3457
+ except json.JSONDecodeError:
3458
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
3459
+ except ValueError as exc:
3460
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
3461
+ return
3462
+ if parsed.path == "/api/workshop-terminal-start":
3463
+ try:
3464
+ length = int(self.headers.get("Content-Length", "0") or "0")
3465
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3466
+ body = json.loads(raw_body or "{}")
3467
+ command_override = body.get("command")
3468
+ label = str(body.get("label") or "")
3469
+ command = command_override if isinstance(command_override, list) and all(isinstance(p, str) for p in command_override) and command_override else workshop_terminal_default_command()
3470
+ session = self.server.workshop_terminals.create(command, self.server.repo_root, label=label)
3471
+ self._send_json({"ok": True, "payload": session.status_payload()})
3472
+ except json.JSONDecodeError:
3473
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
3474
+ except ValueError as exc:
3475
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
3476
+ return
3477
+ if parsed.path == "/api/workshop-terminal-input":
3478
+ try:
3479
+ length = int(self.headers.get("Content-Length", "0") or "0")
3480
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3481
+ body = json.loads(raw_body or "{}")
3482
+ session_id = str(body.get("sessionId") or "")
3483
+ data = str(body.get("data") or "")
3484
+ session = self.server.workshop_terminals.get(session_id)
3485
+ if session is None:
3486
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
3487
+ return
3488
+ session.write(data)
3489
+ self._send_json({"ok": True})
3490
+ except json.JSONDecodeError:
3491
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
3492
+ return
3493
+ if parsed.path == "/api/workshop-terminal-resize":
3494
+ try:
3495
+ length = int(self.headers.get("Content-Length", "0") or "0")
3496
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3497
+ body = json.loads(raw_body or "{}")
3498
+ session_id = str(body.get("sessionId") or "")
3499
+ rows = int(body.get("rows") or 0)
3500
+ cols = int(body.get("cols") or 0)
3501
+ session = self.server.workshop_terminals.get(session_id)
3502
+ if session is None:
3503
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
3504
+ return
3505
+ session.resize(rows, cols)
3506
+ self._send_json({"ok": True})
3507
+ except (json.JSONDecodeError, ValueError):
3508
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid resize body.")
3509
+ return
3510
+ if parsed.path == "/api/workshop-terminal-stop":
3511
+ try:
3512
+ length = int(self.headers.get("Content-Length", "0") or "0")
3513
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3514
+ body = json.loads(raw_body or "{}")
3515
+ session_id = str(body.get("sessionId") or "")
3516
+ session = self.server.workshop_terminals.get(session_id)
3517
+ if session is None:
3518
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
3519
+ return
3520
+ session.stop()
3521
+ self._send_json({"ok": True, "payload": session.status_payload()})
3522
+ except json.JSONDecodeError:
3523
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
3524
+ return
3525
+ if parsed.path == "/api/workshop-command-stop":
3526
+ try:
3527
+ length = int(self.headers.get("Content-Length", "0") or "0")
3528
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
3529
+ body = json.loads(raw_body or "{}")
3530
+ session_id = str(body.get("sessionId") or "")
3531
+ session = self.server.workshop_sessions.get(session_id)
3532
+ if session is None:
3533
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
3534
+ return
3535
+ session.stop()
3536
+ self._send_json({"ok": True, "payload": session.status_payload()})
3537
+ except json.JSONDecodeError:
3538
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
3539
+ return
2295
3540
  if parsed.path == "/api/bootstrap-logics":
2296
3541
  try:
2297
3542
  bootstrap = bootstrap_payload(self.server.repo_root, check=False)
@@ -2394,22 +3639,82 @@ def create_viewer_server(
2394
3639
  port: int = 8765,
2395
3640
  *,
2396
3641
  auto_refresh_interval_seconds: int = 15,
3642
+ auto_refresh_interval_forced: bool = False,
3643
+ lan_mode: bool = False,
2397
3644
  ) -> LogicsViewerServer:
2398
3645
  return LogicsViewerServer(
2399
3646
  (host, port),
2400
3647
  repo_root,
2401
3648
  auto_refresh_interval_seconds=auto_refresh_interval_seconds,
3649
+ auto_refresh_interval_forced=auto_refresh_interval_forced,
3650
+ lan_mode=lan_mode,
2402
3651
  )
2403
3652
 
2404
3653
 
2405
- def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
2406
- if host not in {"0.0.0.0", "::", ""}:
2407
- return None
3654
+ def _render_qr_lines(url: str) -> list[str]:
3655
+ if not url:
3656
+ return []
2408
3657
  try:
2409
- candidate = socket.gethostbyname(socket.gethostname())
3658
+ import segno # type: ignore
3659
+ except ImportError:
3660
+ return [
3661
+ "+" + "-" * (len(url) + 2) + "+",
3662
+ "| " + url + " |",
3663
+ "+" + "-" * (len(url) + 2) + "+",
3664
+ "(Install the optional `segno` package to render a scannable QR matrix.)",
3665
+ ]
3666
+ try:
3667
+ qr = segno.make(url, error="m")
3668
+ buffer: list[str] = []
3669
+ qr.terminal(out=type("Buf", (), {"write": lambda self, value: buffer.append(value)})(), border=1)
3670
+ # segno's terminal output ends each line with newline; flatten back into lines.
3671
+ return ("".join(buffer)).splitlines() or [url]
3672
+ except Exception:
3673
+ return [url]
3674
+
3675
+
3676
+ def _append_lan_token(url: str, token: str) -> str:
3677
+ if not url or not token:
3678
+ return url
3679
+ sep = "&" if "?" in url else "?"
3680
+ return f"{url}{sep}t={quote(token, safe='')}"
3681
+
3682
+
3683
+ def _detect_lan_ip() -> str:
3684
+ """Best-effort detection of the host's primary LAN IPv4 address.
3685
+
3686
+ Uses the standard UDP-socket trick: open a non-blocking connection to a
3687
+ routable but unreachable target and read the local socket name. This
3688
+ yields the address the OS would use for outbound traffic, which is the
3689
+ one a phone on the same LAN should target.
3690
+ """
3691
+ candidate = ""
3692
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
3693
+ try:
3694
+ s.setblocking(False)
3695
+ try:
3696
+ s.connect(("10.255.255.255", 1))
3697
+ candidate = s.getsockname()[0]
3698
+ except OSError:
3699
+ candidate = ""
3700
+ finally:
3701
+ s.close()
3702
+ if candidate and not candidate.startswith("127."):
3703
+ return candidate
3704
+ try:
3705
+ fallback = socket.gethostbyname(socket.gethostname())
2410
3706
  except OSError:
3707
+ return ""
3708
+ if fallback and not fallback.startswith("127."):
3709
+ return fallback
3710
+ return ""
3711
+
3712
+
3713
+ def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
3714
+ if host not in {"0.0.0.0", "::", ""}:
2411
3715
  return None
2412
- if not candidate or candidate.startswith("127."):
3716
+ candidate = _detect_lan_ip()
3717
+ if not candidate:
2413
3718
  return None
2414
3719
  return build_viewer_url(candidate, port, focus=focus, read=read)
2415
3720
 
@@ -2422,13 +3727,18 @@ def render_start_status(
2422
3727
  network_url: str | None = None,
2423
3728
  bind_host: str = "localhost",
2424
3729
  auto_refresh_interval_seconds: int = 15,
3730
+ lan_mode: bool = False,
3731
+ lan_token: str | None = None,
3732
+ lan_url: str | None = None,
3733
+ qr_lines: list[str] | None = None,
2425
3734
  ) -> str:
3735
+ mode_label = "LAN read-only (token required)" if lan_mode else "read-only"
2426
3736
  lines = [
2427
3737
  "Logics viewer running:",
2428
3738
  f"Local: {url}",
2429
3739
  "",
2430
3740
  f"Repo: {repo_root.name}",
2431
- "Mode: read-only",
3741
+ f"Mode: {mode_label}",
2432
3742
  f"Bind: {bind_host}",
2433
3743
  f"Auto refresh: {auto_refresh_interval_seconds}s",
2434
3744
  ]
@@ -2436,6 +3746,16 @@ def render_start_status(
2436
3746
  lines.insert(2, f"Network: {network_url}")
2437
3747
  if focus:
2438
3748
  lines.append(f"Focus: {focus}")
3749
+ if lan_mode:
3750
+ lines.append("")
3751
+ lines.append("LAN exposure is active. Mutating endpoints are refused; non-loopback clients must present the session token below.")
3752
+ if lan_url:
3753
+ lines.append(f"Share URL: {lan_url}")
3754
+ if lan_token:
3755
+ lines.append(f"Token: {lan_token}")
3756
+ if qr_lines:
3757
+ lines.append("")
3758
+ lines.extend(qr_lines)
2439
3759
  return "\n".join(lines)
2440
3760
 
2441
3761
 
@@ -2443,10 +3763,15 @@ def build_parser() -> argparse.ArgumentParser:
2443
3763
  parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
2444
3764
  parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
2445
3765
  parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
3766
+ parser.add_argument(
3767
+ "--lan",
3768
+ action="store_true",
3769
+ help="Expose the viewer on the local network (0.0.0.0). Enforces read-only access and requires a per-session bearer token for non-loopback requests.",
3770
+ )
2446
3771
  parser.add_argument(
2447
3772
  "--refresh-interval",
2448
3773
  type=int,
2449
- default=15,
3774
+ default=None,
2450
3775
  help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
2451
3776
  )
2452
3777
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
@@ -2459,7 +3784,9 @@ def build_parser() -> argparse.ArgumentParser:
2459
3784
  def main(argv: list[str]) -> int:
2460
3785
  args = build_parser().parse_args(argv)
2461
3786
  repo_root = find_repo_root(Path.cwd())
2462
- if args.refresh_interval <= 0:
3787
+ refresh_interval_forced = args.refresh_interval is not None
3788
+ refresh_interval = args.refresh_interval if args.refresh_interval is not None else 15
3789
+ if refresh_interval <= 0:
2463
3790
  raise SystemExit("--refresh-interval must be a positive number of seconds.")
2464
3791
  if args.read and not args.focus:
2465
3792
  raise SystemExit("--read requires --focus.")
@@ -2467,15 +3794,24 @@ def main(argv: list[str]) -> int:
2467
3794
  focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
2468
3795
  except ValueError as exc:
2469
3796
  raise SystemExit(str(exc)) from exc
3797
+ bind_host = "0.0.0.0" if args.lan and args.host == "127.0.0.1" else args.host
2470
3798
  server = create_viewer_server(
2471
3799
  repo_root,
2472
- host=args.host,
3800
+ host=bind_host,
2473
3801
  port=args.port,
2474
- auto_refresh_interval_seconds=args.refresh_interval,
3802
+ auto_refresh_interval_seconds=refresh_interval,
3803
+ auto_refresh_interval_forced=refresh_interval_forced,
3804
+ lan_mode=bool(args.lan),
2475
3805
  )
2476
3806
  host, port = server.server_address[:2]
2477
3807
  url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
2478
3808
  network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
3809
+ lan_share_url = ""
3810
+ qr_lines: list[str] = []
3811
+ if args.lan and server.lan_token:
3812
+ base_for_lan = network_url or url
3813
+ lan_share_url = _append_lan_token(base_for_lan, server.lan_token)
3814
+ qr_lines = _render_qr_lines(lan_share_url)
2479
3815
  print(
2480
3816
  render_start_status(
2481
3817
  url,
@@ -2483,7 +3819,11 @@ def main(argv: list[str]) -> int:
2483
3819
  focus=focus,
2484
3820
  network_url=network_url,
2485
3821
  bind_host=str(host),
2486
- auto_refresh_interval_seconds=args.refresh_interval,
3822
+ auto_refresh_interval_seconds=refresh_interval,
3823
+ lan_mode=bool(args.lan),
3824
+ lan_token=server.lan_token if args.lan else None,
3825
+ lan_url=lan_share_url or None,
3826
+ qr_lines=qr_lines or None,
2487
3827
  ),
2488
3828
  flush=True,
2489
3829
  )