codex-autorunner 0.1.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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import time
8
+ from contextlib import ExitStack
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Iterable, Optional
12
+ from uuid import uuid4
13
+
14
+ from ..core.logging_utils import safe_log
15
+
16
+ _ASSET_VERSION_TOKEN = "__CAR_ASSET_VERSION__"
17
+ _REQUIRED_STATIC_ASSETS = (
18
+ "index.html",
19
+ "styles.css",
20
+ "bootstrap.js",
21
+ "loader.js",
22
+ "app.js",
23
+ "vendor/xterm.js",
24
+ "vendor/xterm-addon-fit.js",
25
+ "vendor/xterm.css",
26
+ )
27
+
28
+
29
+ def missing_static_assets(static_dir: Path) -> list[str]:
30
+ missing: list[str] = []
31
+ for rel_path in _REQUIRED_STATIC_ASSETS:
32
+ try:
33
+ if not (static_dir / rel_path).exists():
34
+ missing.append(rel_path)
35
+ except OSError:
36
+ missing.append(rel_path)
37
+ return missing
38
+
39
+
40
+ def _iter_asset_files(static_dir: Path) -> Iterable[Path]:
41
+ try:
42
+ for path in static_dir.rglob("*"):
43
+ try:
44
+ if path.is_dir():
45
+ continue
46
+ yield path
47
+ except OSError:
48
+ continue
49
+ except Exception:
50
+ return
51
+
52
+
53
+ def _hash_file(path: Path, digest: "hashlib._Hash") -> None:
54
+ try:
55
+ if path.is_symlink():
56
+ digest.update(b"SYMLINK:")
57
+ try:
58
+ target = path.readlink()
59
+ except OSError:
60
+ target = Path("dangling")
61
+ digest.update(str(target).encode("utf-8", errors="replace"))
62
+ return
63
+ with path.open("rb") as handle:
64
+ while True:
65
+ chunk = handle.read(1024 * 1024)
66
+ if not chunk:
67
+ break
68
+ digest.update(chunk)
69
+ except OSError:
70
+ digest.update(b"UNREADABLE")
71
+
72
+
73
+ def asset_version(static_dir: Path) -> str:
74
+ digest = hashlib.sha256()
75
+ files = sorted(_iter_asset_files(static_dir), key=lambda p: p.as_posix())
76
+ if not files:
77
+ return "0"
78
+ for path in files:
79
+ try:
80
+ rel_path = path.relative_to(static_dir)
81
+ except ValueError:
82
+ rel_path = path
83
+ digest.update(rel_path.as_posix().encode("utf-8", errors="replace"))
84
+ _hash_file(path, digest)
85
+ return digest.hexdigest()
86
+
87
+
88
+ def render_index_html(static_dir: Path, version: Optional[str]) -> str:
89
+ index_path = static_dir / "index.html"
90
+ text = index_path.read_text(encoding="utf-8")
91
+ if version:
92
+ text = text.replace(_ASSET_VERSION_TOKEN, version)
93
+ return text
94
+
95
+
96
+ def security_headers() -> dict[str, str]:
97
+ # CSP: scripts are all local with no inline JS; runtime UI uses inline styles.
98
+ return {
99
+ "Content-Security-Policy": (
100
+ "default-src 'self'; "
101
+ "script-src 'self'; "
102
+ "style-src 'self' 'unsafe-inline'; "
103
+ "img-src 'self' data:; "
104
+ "font-src 'self' data:; "
105
+ "connect-src 'self' ws: wss:; "
106
+ "frame-ancestors 'none'"
107
+ ),
108
+ "Referrer-Policy": "same-origin",
109
+ "X-Content-Type-Options": "nosniff",
110
+ "X-Frame-Options": "DENY",
111
+ }
112
+
113
+
114
+ def index_response_headers() -> dict[str, str]:
115
+ headers = {
116
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
117
+ "Pragma": "no-cache",
118
+ "Expires": "0",
119
+ }
120
+ headers.update(security_headers())
121
+ return headers
122
+
123
+
124
+ def resolve_static_dir() -> tuple[Path, Optional[ExitStack]]:
125
+ static_root = resources.files("codex_autorunner").joinpath("static")
126
+ if isinstance(static_root, Path):
127
+ if static_root.exists():
128
+ return static_root, None
129
+ fallback = Path(__file__).resolve().parent.parent / "static"
130
+ return fallback, None
131
+ stack = ExitStack()
132
+ try:
133
+ static_path = stack.enter_context(resources.as_file(static_root))
134
+ except Exception:
135
+ stack.close()
136
+ fallback = Path(__file__).resolve().parent.parent / "static"
137
+ return fallback, None
138
+ if static_path.exists():
139
+ return static_path, stack
140
+ stack.close()
141
+ fallback = Path(__file__).resolve().parent.parent / "static"
142
+ return fallback, None
143
+
144
+
145
+ def _cleanup_temp_dir(path: Path, logger: logging.Logger) -> None:
146
+ try:
147
+ shutil.rmtree(path)
148
+ except FileNotFoundError:
149
+ return
150
+ except Exception as exc:
151
+ safe_log(
152
+ logger,
153
+ logging.WARNING,
154
+ "Failed to remove temporary static cache dir %s",
155
+ path,
156
+ exc=exc,
157
+ )
158
+
159
+
160
+ def _acquire_cache_lock(lock_path: Path, logger: logging.Logger) -> bool:
161
+ try:
162
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
163
+ except FileExistsError:
164
+ return False
165
+ except Exception as exc:
166
+ safe_log(
167
+ logger,
168
+ logging.WARNING,
169
+ "Failed to create static cache lock %s",
170
+ lock_path,
171
+ exc=exc,
172
+ )
173
+ return False
174
+ try:
175
+ os.write(fd, str(os.getpid()).encode("utf-8"))
176
+ except Exception:
177
+ pass
178
+ finally:
179
+ try:
180
+ os.close(fd)
181
+ except Exception:
182
+ pass
183
+ return True
184
+
185
+
186
+ def _release_cache_lock(lock_path: Path, logger: logging.Logger) -> None:
187
+ try:
188
+ lock_path.unlink()
189
+ except FileNotFoundError:
190
+ return
191
+ except Exception as exc:
192
+ safe_log(
193
+ logger,
194
+ logging.WARNING,
195
+ "Failed to remove static cache lock %s",
196
+ lock_path,
197
+ exc=exc,
198
+ )
199
+
200
+
201
+ def _cache_dir_mtime(path: Path) -> float:
202
+ index_path = path / "index.html"
203
+ try:
204
+ return index_path.stat().st_mtime
205
+ except OSError:
206
+ try:
207
+ return path.stat().st_mtime
208
+ except OSError:
209
+ return 0.0
210
+
211
+
212
+ def _list_cache_entries(cache_root: Path) -> list[Path]:
213
+ if not cache_root.exists():
214
+ return []
215
+ entries: list[Path] = []
216
+ try:
217
+ for entry in cache_root.iterdir():
218
+ if entry.name.startswith("."):
219
+ continue
220
+ try:
221
+ if entry.is_dir():
222
+ entries.append(entry)
223
+ except OSError:
224
+ continue
225
+ except OSError:
226
+ return []
227
+ return entries
228
+
229
+
230
+ def _select_latest_valid_cache(cache_root: Path) -> Optional[Path]:
231
+ candidates = []
232
+ for entry in _list_cache_entries(cache_root):
233
+ if not missing_static_assets(entry):
234
+ candidates.append(entry)
235
+ if not candidates:
236
+ return None
237
+ candidates.sort(key=_cache_dir_mtime, reverse=True)
238
+ return candidates[0]
239
+
240
+
241
+ def _prune_cache_entries(
242
+ cache_root: Path,
243
+ *,
244
+ keep: set[Path],
245
+ max_cache_entries: int,
246
+ max_cache_age_days: Optional[int],
247
+ logger: logging.Logger,
248
+ ) -> None:
249
+ if max_cache_entries <= 0 and max_cache_age_days is None:
250
+ return
251
+ entries = _list_cache_entries(cache_root)
252
+ if not entries:
253
+ return
254
+ now = time.time()
255
+ if max_cache_age_days is not None:
256
+ cutoff = now - (max_cache_age_days * 86400)
257
+ for entry in list(entries):
258
+ if entry in keep:
259
+ continue
260
+ if _cache_dir_mtime(entry) < cutoff:
261
+ try:
262
+ shutil.rmtree(entry)
263
+ entries.remove(entry)
264
+ except Exception as exc:
265
+ safe_log(
266
+ logger,
267
+ logging.WARNING,
268
+ "Failed to remove stale static cache dir %s",
269
+ entry,
270
+ exc=exc,
271
+ )
272
+ if max_cache_entries > 0 and len(entries) > max_cache_entries:
273
+ removable = [entry for entry in entries if entry not in keep]
274
+ removable.sort(key=_cache_dir_mtime)
275
+ for entry in removable[: len(entries) - max_cache_entries]:
276
+ try:
277
+ shutil.rmtree(entry)
278
+ except Exception as exc:
279
+ safe_log(
280
+ logger,
281
+ logging.WARNING,
282
+ "Failed to remove old static cache dir %s",
283
+ entry,
284
+ exc=exc,
285
+ )
286
+
287
+
288
+ def materialize_static_assets(
289
+ cache_root: Path,
290
+ *,
291
+ max_cache_entries: int,
292
+ max_cache_age_days: Optional[int],
293
+ logger: logging.Logger,
294
+ ) -> tuple[Path, Optional[ExitStack]]:
295
+ static_dir, static_context = resolve_static_dir()
296
+ existing_cache = _select_latest_valid_cache(cache_root)
297
+ missing_source = missing_static_assets(static_dir)
298
+ if missing_source:
299
+ if static_context is not None:
300
+ static_context.close()
301
+ if existing_cache is not None:
302
+ _prune_cache_entries(
303
+ cache_root,
304
+ keep={existing_cache},
305
+ max_cache_entries=max_cache_entries,
306
+ max_cache_age_days=max_cache_age_days,
307
+ logger=logger,
308
+ )
309
+ return existing_cache, None
310
+ raise RuntimeError("Static UI assets missing; reinstall package")
311
+ fingerprint = asset_version(static_dir)
312
+ target_dir = cache_root / fingerprint
313
+ if target_dir.exists() and not missing_static_assets(target_dir):
314
+ if static_context is not None:
315
+ static_context.close()
316
+ _prune_cache_entries(
317
+ cache_root,
318
+ keep={target_dir},
319
+ max_cache_entries=max_cache_entries,
320
+ max_cache_age_days=max_cache_age_days,
321
+ logger=logger,
322
+ )
323
+ return target_dir, None
324
+ try:
325
+ cache_root.mkdir(parents=True, exist_ok=True)
326
+ except Exception as exc:
327
+ safe_log(
328
+ logger,
329
+ logging.WARNING,
330
+ "Failed to create static cache root %s",
331
+ cache_root,
332
+ exc=exc,
333
+ )
334
+ if static_context is not None:
335
+ static_context.close()
336
+ if existing_cache is not None:
337
+ return existing_cache, None
338
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
339
+ lock_path = cache_root / f".lock-{fingerprint}"
340
+ lock_acquired = _acquire_cache_lock(lock_path, logger)
341
+ if not lock_acquired:
342
+ deadline = time.monotonic() + 5.0
343
+ while time.monotonic() < deadline:
344
+ if target_dir.exists() and not missing_static_assets(target_dir):
345
+ if static_context is not None:
346
+ static_context.close()
347
+ _prune_cache_entries(
348
+ cache_root,
349
+ keep={target_dir},
350
+ max_cache_entries=max_cache_entries,
351
+ max_cache_age_days=max_cache_age_days,
352
+ logger=logger,
353
+ )
354
+ return target_dir, None
355
+ time.sleep(0.2)
356
+ temp_dir = cache_root / f".tmp-{fingerprint}-{uuid4().hex}"
357
+ try:
358
+ shutil.copytree(static_dir, temp_dir, symlinks=True)
359
+ missing = missing_static_assets(temp_dir)
360
+ if missing:
361
+ safe_log(
362
+ logger,
363
+ logging.WARNING,
364
+ "Static UI assets missing in cache copy %s: %s",
365
+ temp_dir,
366
+ ", ".join(missing),
367
+ )
368
+ raise RuntimeError("Static UI assets missing; reinstall package")
369
+ if target_dir.exists():
370
+ existing_missing = missing_static_assets(target_dir)
371
+ if not existing_missing:
372
+ _cleanup_temp_dir(temp_dir, logger)
373
+ if static_context is not None:
374
+ static_context.close()
375
+ _prune_cache_entries(
376
+ cache_root,
377
+ keep={target_dir},
378
+ max_cache_entries=max_cache_entries,
379
+ max_cache_age_days=max_cache_age_days,
380
+ logger=logger,
381
+ )
382
+ return target_dir, None
383
+ try:
384
+ shutil.rmtree(target_dir)
385
+ except Exception as exc:
386
+ safe_log(
387
+ logger,
388
+ logging.WARNING,
389
+ "Failed to replace stale static cache dir %s",
390
+ target_dir,
391
+ exc=exc,
392
+ )
393
+ raise RuntimeError(
394
+ "Static UI assets missing; reinstall package"
395
+ ) from exc
396
+ temp_dir.replace(target_dir)
397
+ except Exception as exc:
398
+ _cleanup_temp_dir(temp_dir, logger)
399
+ if static_context is not None:
400
+ static_context.close()
401
+ if existing_cache is not None:
402
+ return existing_cache, None
403
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
404
+ finally:
405
+ if lock_acquired:
406
+ _release_cache_lock(lock_path, logger)
407
+ if static_context is not None:
408
+ static_context.close()
409
+ _prune_cache_entries(
410
+ cache_root,
411
+ keep={target_dir},
412
+ max_cache_entries=max_cache_entries,
413
+ max_cache_age_days=max_cache_age_days,
414
+ logger=logger,
415
+ )
416
+ return target_dir, None
417
+
418
+
419
+ def require_static_assets(static_dir: Path, logger: logging.Logger) -> None:
420
+ missing = missing_static_assets(static_dir)
421
+ if not missing:
422
+ return
423
+ safe_log(
424
+ logger,
425
+ logging.ERROR,
426
+ "Static UI assets missing in %s: %s",
427
+ static_dir,
428
+ ", ".join(missing),
429
+ )
430
+ raise RuntimeError("Static UI assets missing; reinstall package")
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from ..core.state import persist_session_registry
9
+ from .pty_session import ActiveSession
10
+
11
+
12
+ def parse_last_seen_at(value: Optional[str]) -> Optional[float]:
13
+ if not value:
14
+ return None
15
+ try:
16
+ parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
17
+ except ValueError:
18
+ return None
19
+ return parsed.replace(tzinfo=timezone.utc).timestamp()
20
+
21
+
22
+ def session_last_touch(session: ActiveSession, record) -> float:
23
+ last_seen = parse_last_seen_at(getattr(record, "last_seen_at", None))
24
+ if last_seen is None:
25
+ return session.pty.last_active
26
+ return max(last_seen, session.pty.last_active)
27
+
28
+
29
+ def parse_tui_idle_seconds(config) -> Optional[float]:
30
+ notifications_cfg = (
31
+ config.notifications if isinstance(config.notifications, dict) else {}
32
+ )
33
+ idle_seconds = notifications_cfg.get("tui_idle_seconds")
34
+ if idle_seconds is None:
35
+ return None
36
+ try:
37
+ idle_seconds = float(idle_seconds)
38
+ except (TypeError, ValueError):
39
+ return None
40
+ if idle_seconds <= 0:
41
+ return None
42
+ return idle_seconds
43
+
44
+
45
+ def prune_terminal_registry(
46
+ state_path: Path,
47
+ terminal_sessions: dict[str, ActiveSession],
48
+ session_registry: dict,
49
+ repo_to_session: dict[str, str],
50
+ max_idle_seconds: Optional[int],
51
+ ) -> bool:
52
+ now = time.time()
53
+ removed_any = False
54
+ for session_id, session in list(terminal_sessions.items()):
55
+ if not session.pty.isalive():
56
+ session.close()
57
+ terminal_sessions.pop(session_id, None)
58
+ session_registry.pop(session_id, None)
59
+ removed_any = True
60
+ continue
61
+ if max_idle_seconds is not None and max_idle_seconds > 0:
62
+ last_touch = session_last_touch(session, session_registry.get(session_id))
63
+ if now - last_touch > max_idle_seconds:
64
+ session.close()
65
+ terminal_sessions.pop(session_id, None)
66
+ session_registry.pop(session_id, None)
67
+ removed_any = True
68
+ for session_id in list(session_registry.keys()):
69
+ if session_id not in terminal_sessions:
70
+ session_registry.pop(session_id, None)
71
+ removed_any = True
72
+ for repo_path, session_id in list(repo_to_session.items()):
73
+ if session_id not in session_registry:
74
+ repo_to_session.pop(repo_path, None)
75
+ removed_any = True
76
+ if removed_any:
77
+ persist_session_registry(state_path, session_registry, repo_to_session)
78
+ return removed_any
@@ -0,0 +1,16 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+ from .core.utils import canonicalize_path
5
+
6
+ WORKSPACE_ID_HEX_LEN = 12
7
+
8
+
9
+ def canonical_workspace_root(path: Path) -> Path:
10
+ return canonicalize_path(path)
11
+
12
+
13
+ def workspace_id_for_path(path: Path) -> str:
14
+ canonical = canonical_workspace_root(path)
15
+ digest = hashlib.sha256(str(canonical).encode("utf-8")).hexdigest()
16
+ return digest[:WORKSPACE_ID_HEX_LEN]