codex-autorunner 0.1.2__py3-none-any.whl → 1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,490 @@
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 resolve_static_dir() -> tuple[Path, Optional[ExitStack]]:
41
+ """Locate packaged static assets."""
42
+
43
+ static_root = resources.files("codex_autorunner").joinpath("static")
44
+ if isinstance(static_root, Path):
45
+ if static_root.exists():
46
+ return static_root, None
47
+ fallback = Path(__file__).resolve().parent.parent / "static"
48
+ return fallback, None
49
+
50
+ stack = ExitStack()
51
+ try:
52
+ static_path = stack.enter_context(resources.as_file(static_root))
53
+ except Exception:
54
+ stack.close()
55
+ fallback = Path(__file__).resolve().parent.parent / "static"
56
+ return fallback, None
57
+ if static_path.exists():
58
+ return static_path, stack
59
+
60
+ stack.close()
61
+ fallback = Path(__file__).resolve().parent.parent / "static"
62
+ return fallback, None
63
+
64
+
65
+ def _iter_static_source_files(source_dir: Path) -> Iterable[Path]:
66
+ try:
67
+ for path in source_dir.rglob("*.ts"):
68
+ try:
69
+ if path.name.endswith(".d.ts"):
70
+ continue
71
+ if path.is_dir():
72
+ continue
73
+ yield path
74
+ except OSError:
75
+ continue
76
+ except Exception:
77
+ return
78
+
79
+
80
+ def _stale_static_sources(source_dir: Path, static_dir: Path) -> list[str]:
81
+ stale: list[str] = []
82
+ for source in _iter_static_source_files(source_dir):
83
+ try:
84
+ rel_path = source.relative_to(source_dir)
85
+ except ValueError:
86
+ rel_path = Path(source.name)
87
+ target = (static_dir / rel_path).with_suffix(".js")
88
+ try:
89
+ source_mtime = source.stat().st_mtime
90
+ except OSError:
91
+ continue
92
+ try:
93
+ target_mtime = target.stat().st_mtime
94
+ except OSError:
95
+ stale.append(rel_path.as_posix())
96
+ continue
97
+ if source_mtime > target_mtime:
98
+ stale.append(rel_path.as_posix())
99
+ return stale
100
+
101
+
102
+ def warn_on_stale_static_assets(static_dir: Path, logger: logging.Logger) -> None:
103
+ source_dir = Path(__file__).resolve().parent.parent / "static_src"
104
+ if not source_dir.exists():
105
+ return
106
+ stale = _stale_static_sources(source_dir, static_dir)
107
+ if not stale:
108
+ return
109
+ preview = ", ".join(stale[:5])
110
+ suffix = f" (+{len(stale) - 5} more)" if len(stale) > 5 else ""
111
+ safe_log(
112
+ logger,
113
+ logging.WARNING,
114
+ "Static assets appear stale; run `pnpm run build`. Newer sources: %s%s",
115
+ preview,
116
+ suffix,
117
+ )
118
+
119
+
120
+ def _iter_asset_files(static_dir: Path) -> Iterable[Path]:
121
+ try:
122
+ for path in static_dir.rglob("*"):
123
+ try:
124
+ if path.is_dir():
125
+ continue
126
+ yield path
127
+ except OSError:
128
+ continue
129
+ except Exception:
130
+ return
131
+
132
+
133
+ def _hash_file(path: Path, digest: "hashlib._Hash") -> None:
134
+ try:
135
+ if path.is_symlink():
136
+ digest.update(b"SYMLINK:")
137
+ try:
138
+ target = path.readlink()
139
+ except OSError:
140
+ target = Path("dangling")
141
+ digest.update(str(target).encode("utf-8", errors="replace"))
142
+ return
143
+ with path.open("rb") as handle:
144
+ while True:
145
+ chunk = handle.read(1024 * 1024)
146
+ if not chunk:
147
+ break
148
+ digest.update(chunk)
149
+ except OSError:
150
+ digest.update(b"UNREADABLE")
151
+
152
+
153
+ def asset_version(static_dir: Path) -> str:
154
+ digest = hashlib.sha256()
155
+ files = sorted(_iter_asset_files(static_dir), key=lambda p: p.as_posix())
156
+ if not files:
157
+ return "0"
158
+ for path in files:
159
+ try:
160
+ rel_path = path.relative_to(static_dir)
161
+ except ValueError:
162
+ rel_path = path
163
+ digest.update(rel_path.as_posix().encode("utf-8", errors="replace"))
164
+ _hash_file(path, digest)
165
+ return digest.hexdigest()
166
+
167
+
168
+ def render_index_html(static_dir: Path, version: Optional[str]) -> str:
169
+ index_path = static_dir / "index.html"
170
+ text = index_path.read_text(encoding="utf-8")
171
+ if version:
172
+ text = text.replace(_ASSET_VERSION_TOKEN, version)
173
+ return text
174
+
175
+
176
+ def security_headers() -> dict[str, str]:
177
+ # CSP: scripts are all local with no inline JS; runtime UI uses inline styles.
178
+ return {
179
+ "Content-Security-Policy": (
180
+ "default-src 'self'; "
181
+ "script-src 'self'; "
182
+ "style-src 'self' 'unsafe-inline'; "
183
+ "img-src 'self' data:; "
184
+ "font-src 'self' data:; "
185
+ "connect-src 'self' ws: wss:; "
186
+ "frame-ancestors 'none'"
187
+ ),
188
+ "Referrer-Policy": "same-origin",
189
+ "X-Content-Type-Options": "nosniff",
190
+ "X-Frame-Options": "DENY",
191
+ }
192
+
193
+
194
+ def index_response_headers() -> dict[str, str]:
195
+ headers = {
196
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
197
+ "Pragma": "no-cache",
198
+ "Expires": "0",
199
+ }
200
+ headers.update(security_headers())
201
+ return headers
202
+
203
+
204
+ def _cleanup_temp_dir(path: Path, logger: logging.Logger) -> None:
205
+ try:
206
+ shutil.rmtree(path)
207
+ except FileNotFoundError:
208
+ return
209
+ except Exception as exc:
210
+ safe_log(
211
+ logger,
212
+ logging.WARNING,
213
+ "Failed to remove temporary static cache dir %s",
214
+ path,
215
+ exc=exc,
216
+ )
217
+
218
+
219
+ def _acquire_cache_lock(lock_path: Path, logger: logging.Logger) -> bool:
220
+ try:
221
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
222
+ except FileExistsError:
223
+ return False
224
+ except Exception as exc:
225
+ safe_log(
226
+ logger,
227
+ logging.WARNING,
228
+ "Failed to create static cache lock %s",
229
+ lock_path,
230
+ exc=exc,
231
+ )
232
+ return False
233
+ try:
234
+ os.write(fd, str(os.getpid()).encode("utf-8"))
235
+ except Exception:
236
+ pass
237
+ finally:
238
+ try:
239
+ os.close(fd)
240
+ except Exception:
241
+ pass
242
+ return True
243
+
244
+
245
+ def _release_cache_lock(lock_path: Path, logger: logging.Logger) -> None:
246
+ try:
247
+ lock_path.unlink()
248
+ except FileNotFoundError:
249
+ return
250
+ except Exception as exc:
251
+ safe_log(
252
+ logger,
253
+ logging.WARNING,
254
+ "Failed to remove static cache lock %s",
255
+ lock_path,
256
+ exc=exc,
257
+ )
258
+
259
+
260
+ def _cache_dir_mtime(path: Path) -> float:
261
+ index_path = path / "index.html"
262
+ try:
263
+ return index_path.stat().st_mtime
264
+ except OSError:
265
+ try:
266
+ return path.stat().st_mtime
267
+ except OSError:
268
+ return 0.0
269
+
270
+
271
+ def _list_cache_entries(cache_root: Path) -> list[Path]:
272
+ if not cache_root.exists():
273
+ return []
274
+ entries: list[Path] = []
275
+ try:
276
+ for entry in cache_root.iterdir():
277
+ if entry.name.startswith("."):
278
+ continue
279
+ try:
280
+ if entry.is_dir():
281
+ entries.append(entry)
282
+ except OSError:
283
+ continue
284
+ except OSError:
285
+ return []
286
+ return entries
287
+
288
+
289
+ def _select_latest_valid_cache(cache_root: Path) -> Optional[Path]:
290
+ candidates = []
291
+ for entry in _list_cache_entries(cache_root):
292
+ if not missing_static_assets(entry):
293
+ candidates.append(entry)
294
+ if not candidates:
295
+ return None
296
+ candidates.sort(key=_cache_dir_mtime, reverse=True)
297
+ return candidates[0]
298
+
299
+
300
+ def _prune_cache_entries(
301
+ cache_root: Path,
302
+ *,
303
+ keep: set[Path],
304
+ max_cache_entries: int,
305
+ max_cache_age_days: Optional[int],
306
+ logger: logging.Logger,
307
+ ) -> None:
308
+ if max_cache_entries <= 0 and max_cache_age_days is None:
309
+ return
310
+ entries = _list_cache_entries(cache_root)
311
+ if not entries:
312
+ return
313
+ now = time.time()
314
+ if max_cache_age_days is not None:
315
+ cutoff = now - (max_cache_age_days * 86400)
316
+ for entry in list(entries):
317
+ if entry in keep:
318
+ continue
319
+ if _cache_dir_mtime(entry) < cutoff:
320
+ try:
321
+ shutil.rmtree(entry)
322
+ entries.remove(entry)
323
+ except Exception as exc:
324
+ safe_log(
325
+ logger,
326
+ logging.WARNING,
327
+ "Failed to remove stale static cache dir %s",
328
+ entry,
329
+ exc=exc,
330
+ )
331
+ if max_cache_entries > 0 and len(entries) > max_cache_entries:
332
+ removable = [entry for entry in entries if entry not in keep]
333
+ removable.sort(key=_cache_dir_mtime)
334
+ for entry in removable[: len(entries) - max_cache_entries]:
335
+ try:
336
+ shutil.rmtree(entry)
337
+ except Exception as exc:
338
+ safe_log(
339
+ logger,
340
+ logging.WARNING,
341
+ "Failed to remove old static cache dir %s",
342
+ entry,
343
+ exc=exc,
344
+ )
345
+
346
+
347
+ def materialize_static_assets(
348
+ cache_root: Path,
349
+ *,
350
+ max_cache_entries: int,
351
+ max_cache_age_days: Optional[int],
352
+ logger: logging.Logger,
353
+ ) -> tuple[Path, Optional[ExitStack]]:
354
+ static_dir, static_context = resolve_static_dir()
355
+ existing_cache = _select_latest_valid_cache(cache_root)
356
+ missing_source = missing_static_assets(static_dir)
357
+ if missing_source:
358
+ if static_context is not None:
359
+ static_context.close()
360
+ if existing_cache is not None:
361
+ _prune_cache_entries(
362
+ cache_root,
363
+ keep={existing_cache},
364
+ max_cache_entries=max_cache_entries,
365
+ max_cache_age_days=max_cache_age_days,
366
+ logger=logger,
367
+ )
368
+ return existing_cache, None
369
+ raise RuntimeError("Static UI assets missing; reinstall package")
370
+ fingerprint = asset_version(static_dir)
371
+ target_dir = cache_root / fingerprint
372
+ if target_dir.exists() and not missing_static_assets(target_dir):
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
+ cache_root.mkdir(parents=True, exist_ok=True)
385
+ except Exception as exc:
386
+ safe_log(
387
+ logger,
388
+ logging.WARNING,
389
+ "Failed to create static cache root %s",
390
+ cache_root,
391
+ exc=exc,
392
+ )
393
+ if static_context is not None:
394
+ static_context.close()
395
+ if existing_cache is not None:
396
+ return existing_cache, None
397
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
398
+ lock_path = cache_root / f".lock-{fingerprint}"
399
+ lock_acquired = _acquire_cache_lock(lock_path, logger)
400
+ if not lock_acquired:
401
+ deadline = time.monotonic() + 5.0
402
+ while time.monotonic() < deadline:
403
+ if target_dir.exists() and not missing_static_assets(target_dir):
404
+ if static_context is not None:
405
+ static_context.close()
406
+ _prune_cache_entries(
407
+ cache_root,
408
+ keep={target_dir},
409
+ max_cache_entries=max_cache_entries,
410
+ max_cache_age_days=max_cache_age_days,
411
+ logger=logger,
412
+ )
413
+ return target_dir, None
414
+ time.sleep(0.2)
415
+ temp_dir = cache_root / f".tmp-{fingerprint}-{uuid4().hex}"
416
+ try:
417
+ shutil.copytree(static_dir, temp_dir, symlinks=True)
418
+ missing = missing_static_assets(temp_dir)
419
+ if missing:
420
+ safe_log(
421
+ logger,
422
+ logging.WARNING,
423
+ "Static UI assets missing in cache copy %s: %s",
424
+ temp_dir,
425
+ ", ".join(missing),
426
+ )
427
+ raise RuntimeError("Static UI assets missing; reinstall package")
428
+ if target_dir.exists():
429
+ existing_missing = missing_static_assets(target_dir)
430
+ if not existing_missing:
431
+ _cleanup_temp_dir(temp_dir, logger)
432
+ if static_context is not None:
433
+ static_context.close()
434
+ _prune_cache_entries(
435
+ cache_root,
436
+ keep={target_dir},
437
+ max_cache_entries=max_cache_entries,
438
+ max_cache_age_days=max_cache_age_days,
439
+ logger=logger,
440
+ )
441
+ return target_dir, None
442
+ try:
443
+ shutil.rmtree(target_dir)
444
+ except Exception as exc:
445
+ safe_log(
446
+ logger,
447
+ logging.WARNING,
448
+ "Failed to replace stale static cache dir %s",
449
+ target_dir,
450
+ exc=exc,
451
+ )
452
+ raise RuntimeError(
453
+ "Static UI assets missing; reinstall package"
454
+ ) from exc
455
+ temp_dir.replace(target_dir)
456
+ except Exception as exc:
457
+ _cleanup_temp_dir(temp_dir, logger)
458
+ if static_context is not None:
459
+ static_context.close()
460
+ if existing_cache is not None:
461
+ return existing_cache, None
462
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
463
+ finally:
464
+ if lock_acquired:
465
+ _release_cache_lock(lock_path, logger)
466
+ if static_context is not None:
467
+ static_context.close()
468
+ _prune_cache_entries(
469
+ cache_root,
470
+ keep={target_dir},
471
+ max_cache_entries=max_cache_entries,
472
+ max_cache_age_days=max_cache_age_days,
473
+ logger=logger,
474
+ )
475
+ return target_dir, None
476
+
477
+
478
+ def require_static_assets(static_dir: Path, logger: logging.Logger) -> None:
479
+ missing = missing_static_assets(static_dir)
480
+ if not missing:
481
+ warn_on_stale_static_assets(static_dir, logger)
482
+ return
483
+ safe_log(
484
+ logger,
485
+ logging.ERROR,
486
+ "Static UI assets missing in %s: %s",
487
+ static_dir,
488
+ ", ".join(missing),
489
+ )
490
+ raise RuntimeError("Static UI assets missing; reinstall package")
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ...core.logging_utils import safe_log
8
+ from .static_assets import (
9
+ asset_version,
10
+ materialize_static_assets,
11
+ missing_static_assets,
12
+ require_static_assets,
13
+ )
14
+
15
+
16
+ def _update_static_files(static_files: object, static_dir: Path) -> None:
17
+ try:
18
+ static_files.directory = static_dir
19
+ static_files.all_directories = static_files.get_directories( # type: ignore[attr-defined]
20
+ static_dir,
21
+ static_files.packages, # type: ignore[attr-defined]
22
+ )
23
+ static_files.config_checked = False
24
+ except Exception:
25
+ return
26
+
27
+
28
+ def refresh_static_assets(app: object) -> bool:
29
+ lock = getattr(getattr(app, "state", None), "static_assets_lock", None)
30
+ if lock is None or not lock.acquire(blocking=False):
31
+ return False
32
+ try:
33
+ state = getattr(app, "state", None)
34
+ if state is None:
35
+ return False
36
+ current_dir = getattr(state, "static_dir", None)
37
+ if isinstance(current_dir, Path) and not missing_static_assets(current_dir):
38
+ return True
39
+ config = getattr(state, "config", None)
40
+ logger = getattr(state, "logger", None)
41
+ static_candidates = []
42
+ if config is not None:
43
+ static_candidates.append(config.static_assets)
44
+ hub_static = getattr(state, "hub_static_assets", None)
45
+ if hub_static is not None and (
46
+ not static_candidates
47
+ or hub_static.cache_root != static_candidates[0].cache_root
48
+ ):
49
+ static_candidates.append(hub_static)
50
+ for static_cfg in static_candidates:
51
+ try:
52
+ static_dir, static_context = materialize_static_assets(
53
+ static_cfg.cache_root,
54
+ max_cache_entries=static_cfg.max_cache_entries,
55
+ max_cache_age_days=static_cfg.max_cache_age_days,
56
+ logger=logger,
57
+ )
58
+ require_static_assets(static_dir, logger)
59
+ except Exception as exc:
60
+ if logger is not None:
61
+ safe_log(
62
+ logger,
63
+ logging.WARNING,
64
+ "Static assets refresh failed for cache root %s",
65
+ static_cfg.cache_root,
66
+ exc=exc,
67
+ )
68
+ continue
69
+ old_context: Optional[object] = getattr(
70
+ state, "static_assets_context", None
71
+ )
72
+ if old_context is not None:
73
+ try:
74
+ old_context.close()
75
+ except Exception:
76
+ pass
77
+ state.static_dir = static_dir
78
+ state.static_assets_context = static_context
79
+ state.asset_version = asset_version(static_dir)
80
+ static_files = getattr(state, "static_files", None)
81
+ if static_files is not None:
82
+ _update_static_files(static_files, static_dir)
83
+ return True
84
+ return False
85
+ finally:
86
+ lock.release()
@@ -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,27 @@
1
+ """Ticket-based workflow primitives.
2
+
3
+ This package provides a simple, file-backed orchestration layer built around
4
+ markdown tickets with YAML frontmatter.
5
+ """
6
+
7
+ from .agent_pool import AgentPool, AgentTurnRequest, AgentTurnResult
8
+ from .models import (
9
+ DEFAULT_MAX_TOTAL_TURNS,
10
+ TicketDoc,
11
+ TicketFrontmatter,
12
+ TicketResult,
13
+ TicketRunConfig,
14
+ )
15
+ from .runner import TicketRunner
16
+
17
+ __all__ = [
18
+ "DEFAULT_MAX_TOTAL_TURNS",
19
+ "AgentPool",
20
+ "AgentTurnRequest",
21
+ "AgentTurnResult",
22
+ "TicketDoc",
23
+ "TicketFrontmatter",
24
+ "TicketResult",
25
+ "TicketRunConfig",
26
+ "TicketRunner",
27
+ ]