codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,271 +1,3 @@
1
- from __future__ import annotations
1
+ """Backward-compatible workspace routes."""
2
2
 
3
- import io
4
- import zipfile
5
- from dataclasses import asdict
6
-
7
- from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
8
- from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
9
-
10
- from ..core import drafts as draft_utils
11
- from ..tickets.spec_ingest import (
12
- SpecIngestTicketsError,
13
- ingest_workspace_spec_to_tickets,
14
- )
15
- from ..web.schemas import (
16
- SpecIngestTicketsResponse,
17
- WorkspaceFileListResponse,
18
- WorkspaceResponse,
19
- WorkspaceTreeResponse,
20
- WorkspaceUploadResponse,
21
- WorkspaceWriteRequest,
22
- )
23
- from ..workspace.paths import (
24
- PINNED_DOC_FILENAMES,
25
- WORKSPACE_DOC_KINDS,
26
- list_workspace_files,
27
- list_workspace_tree,
28
- normalize_workspace_rel_path,
29
- read_workspace_doc,
30
- read_workspace_file,
31
- sanitize_workspace_filename,
32
- workspace_dir,
33
- workspace_doc_path,
34
- write_workspace_doc,
35
- write_workspace_file,
36
- )
37
-
38
-
39
- def build_workspace_routes() -> APIRouter:
40
- router = APIRouter(prefix="/api", tags=["workspace"])
41
-
42
- @router.get("/workspace", response_model=WorkspaceResponse)
43
- def get_workspace(request: Request):
44
- repo_root = request.app.state.engine.repo_root
45
- return {
46
- "active_context": read_workspace_doc(repo_root, "active_context"),
47
- "decisions": read_workspace_doc(repo_root, "decisions"),
48
- "spec": read_workspace_doc(repo_root, "spec"),
49
- }
50
-
51
- @router.get("/workspace/file", response_class=PlainTextResponse)
52
- def read_workspace(request: Request, path: str):
53
- repo_root = request.app.state.engine.repo_root
54
- try:
55
- content = read_workspace_file(repo_root, path)
56
- except ValueError as exc: # invalid path
57
- raise HTTPException(status_code=400, detail=str(exc)) from exc
58
- return PlainTextResponse(content)
59
-
60
- @router.put("/workspace/file", response_class=PlainTextResponse)
61
- def write_workspace(request: Request, payload: WorkspaceWriteRequest, path: str):
62
- repo_root = request.app.state.engine.repo_root
63
- try:
64
- # Normalize path the same way workspace helpers do to avoid traversal
65
- safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
66
- content = write_workspace_file(repo_root, path, payload.content)
67
- try:
68
- rel_repo_path = safe_path.relative_to(repo_root).as_posix()
69
- draft_utils.invalidate_drafts_for_path(repo_root, rel_repo_path)
70
- state_key = f"workspace_{rel_posix.replace('/', '_')}"
71
- draft_utils.remove_draft(repo_root, state_key)
72
- except Exception:
73
- pass
74
- except ValueError as exc:
75
- raise HTTPException(status_code=400, detail=str(exc)) from exc
76
- return PlainTextResponse(content)
77
-
78
- @router.put("/workspace/{kind}", response_model=WorkspaceResponse)
79
- def put_workspace(kind: str, payload: WorkspaceWriteRequest, request: Request):
80
- key = (kind or "").strip().lower()
81
- if key not in WORKSPACE_DOC_KINDS:
82
- raise HTTPException(status_code=400, detail="invalid workspace doc kind")
83
- repo_root = request.app.state.engine.repo_root
84
- write_workspace_doc(repo_root, key, payload.content)
85
- try:
86
- rel_path = workspace_doc_path(repo_root, key).relative_to(repo_root)
87
- draft_utils.invalidate_drafts_for_path(repo_root, rel_path.as_posix())
88
- state_key = f"workspace_{rel_path.name}"
89
- draft_utils.remove_draft(repo_root, state_key)
90
- except Exception:
91
- # best-effort invalidation; avoid blocking writes
92
- pass
93
- return {
94
- "active_context": read_workspace_doc(repo_root, "active_context"),
95
- "decisions": read_workspace_doc(repo_root, "decisions"),
96
- "spec": read_workspace_doc(repo_root, "spec"),
97
- }
98
-
99
- @router.get("/workspace/files", response_model=WorkspaceFileListResponse)
100
- def list_files(request: Request):
101
- repo_root = request.app.state.engine.repo_root
102
- files = [asdict(item) for item in list_workspace_files(repo_root)]
103
- return {"files": files}
104
-
105
- @router.get("/workspace/tree", response_model=WorkspaceTreeResponse)
106
- def get_workspace_tree(request: Request):
107
- repo_root = request.app.state.engine.repo_root
108
- tree = [asdict(item) for item in list_workspace_tree(repo_root)]
109
- return {"tree": tree}
110
-
111
- @router.post("/workspace/upload", response_model=WorkspaceUploadResponse)
112
- async def upload_workspace_files(
113
- request: Request,
114
- files: list[UploadFile] = File(...), # noqa: B008
115
- subdir: str = Form(""),
116
- ):
117
- if not files:
118
- raise HTTPException(status_code=400, detail="no files provided")
119
-
120
- repo_root = request.app.state.engine.repo_root
121
- base = workspace_dir(repo_root)
122
- target_dir = base
123
- if subdir:
124
- try:
125
- target_dir, _ = normalize_workspace_rel_path(repo_root, subdir)
126
- except ValueError as exc:
127
- raise HTTPException(status_code=400, detail=str(exc)) from exc
128
-
129
- target_dir.mkdir(parents=True, exist_ok=True)
130
-
131
- uploaded: list[dict[str, str | int]] = []
132
- for upload in files:
133
- filename = sanitize_workspace_filename(upload.filename or "")
134
- try:
135
- data = await upload.read()
136
- except (
137
- Exception
138
- ) as exc: # pragma: no cover - handled by FastAPI for most cases
139
- raise HTTPException(
140
- status_code=400, detail="failed to read upload"
141
- ) from exc
142
-
143
- dest = target_dir / filename
144
- dest.write_bytes(
145
- data
146
- ) # codeql[py/path-injection] dest sits under normalized workspace dir
147
- rel_path = dest.relative_to(base).as_posix()
148
- uploaded.append({"filename": filename, "path": rel_path, "size": len(data)})
149
-
150
- return {"status": "ok", "uploaded": uploaded}
151
-
152
- @router.get("/workspace/download")
153
- async def download_workspace_file(request: Request, path: str):
154
- repo_root = request.app.state.engine.repo_root
155
- try:
156
- safe_path, _ = normalize_workspace_rel_path(repo_root, path)
157
- except ValueError as exc:
158
- raise HTTPException(status_code=400, detail=str(exc)) from exc
159
-
160
- if not safe_path.exists() or safe_path.is_dir():
161
- raise HTTPException(status_code=404, detail="file not found")
162
-
163
- return FileResponse(
164
- path=safe_path, filename=safe_path.name
165
- ) # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
166
-
167
- @router.get("/workspace/download-zip")
168
- async def download_workspace_zip(request: Request, path: str = ""):
169
- repo_root = request.app.state.engine.repo_root
170
- base = workspace_dir(repo_root)
171
- base.mkdir(parents=True, exist_ok=True)
172
-
173
- target_dir = base
174
- zip_name = "workspace.zip"
175
- if path:
176
- try:
177
- target_dir, _ = normalize_workspace_rel_path(repo_root, path)
178
- except ValueError as exc:
179
- raise HTTPException(status_code=400, detail=str(exc)) from exc
180
- if not target_dir.exists() or not target_dir.is_dir():
181
- raise HTTPException(status_code=404, detail="folder not found")
182
- zip_name = f"{target_dir.name}.zip"
183
-
184
- buffer = io.BytesIO()
185
- base_real = base.resolve()
186
- with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
187
- for file_path in target_dir.rglob("*"):
188
- if file_path.is_dir():
189
- continue
190
- if file_path.is_symlink():
191
- try:
192
- file_path.resolve().relative_to(base_real)
193
- except Exception:
194
- continue
195
- arc_name = file_path.relative_to(target_dir).as_posix()
196
- zf.write(
197
- file_path, arc_name
198
- ) # codeql[py/path-injection] file_path constrained to workspace dir
199
-
200
- buffer.seek(0)
201
- return StreamingResponse(
202
- buffer,
203
- media_type="application/zip",
204
- headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
205
- )
206
-
207
- @router.post("/workspace/folder")
208
- async def create_workspace_folder(request: Request, path: str):
209
- repo_root = request.app.state.engine.repo_root
210
- try:
211
- safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
212
- except ValueError as exc:
213
- raise HTTPException(status_code=400, detail=str(exc)) from exc
214
-
215
- if safe_path.exists():
216
- raise HTTPException(status_code=400, detail="path already exists")
217
-
218
- safe_path.mkdir(parents=True, exist_ok=True)
219
- return {"status": "created", "path": rel_posix}
220
-
221
- @router.delete("/workspace/folder")
222
- async def delete_workspace_folder(request: Request, path: str):
223
- repo_root = request.app.state.engine.repo_root
224
- try:
225
- safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
226
- except ValueError as exc:
227
- raise HTTPException(status_code=400, detail=str(exc)) from exc
228
-
229
- if not safe_path.exists():
230
- raise HTTPException(status_code=404, detail="folder not found")
231
- if not safe_path.is_dir():
232
- raise HTTPException(status_code=400, detail="not a folder")
233
- if any(safe_path.iterdir()):
234
- raise HTTPException(status_code=400, detail="folder not empty")
235
-
236
- safe_path.rmdir()
237
- return {"status": "deleted", "path": rel_posix}
238
-
239
- @router.delete("/workspace/file")
240
- async def delete_workspace_file(request: Request, path: str):
241
- repo_root = request.app.state.engine.repo_root
242
- base = workspace_dir(repo_root)
243
- try:
244
- safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
245
- except ValueError as exc:
246
- raise HTTPException(status_code=400, detail=str(exc)) from exc
247
-
248
- if safe_path.parent == base and safe_path.name in PINNED_DOC_FILENAMES:
249
- raise HTTPException(status_code=400, detail="cannot delete pinned docs")
250
- if not safe_path.exists():
251
- raise HTTPException(status_code=404, detail="file not found")
252
- if safe_path.is_dir():
253
- raise HTTPException(status_code=400, detail="use folder delete endpoint")
254
-
255
- safe_path.unlink() # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
256
- return {"status": "deleted", "path": rel_posix}
257
-
258
- @router.post("/workspace/spec/ingest", response_model=SpecIngestTicketsResponse)
259
- def ingest_workspace_spec(request: Request):
260
- repo_root = request.app.state.engine.repo_root
261
- try:
262
- result = ingest_workspace_spec_to_tickets(repo_root)
263
- except SpecIngestTicketsError as exc:
264
- raise HTTPException(status_code=400, detail=str(exc)) from exc
265
- return {
266
- "status": "ok",
267
- "created": result.created,
268
- "first_ticket_path": result.first_ticket_path,
269
- }
270
-
271
- return router
3
+ from ..surfaces.web.routes.workspace import * # noqa: F401,F403
@@ -1,12 +1,12 @@
1
1
  from importlib import resources
2
2
 
3
- from .core.engine import Engine, LockError, clear_stale_lock, doctor
4
- from .web.app import create_app, create_hub_app, create_repo_app
5
- from .web.middleware import BasePathRouterMiddleware
3
+ from .core.runtime import LockError, RuntimeContext, clear_stale_lock, doctor
4
+ from .surfaces.web.app import create_app, create_hub_app, create_repo_app
5
+ from .surfaces.web.middleware import BasePathRouterMiddleware
6
6
 
7
7
  __all__ = [
8
- "Engine",
9
8
  "LockError",
9
+ "RuntimeContext",
10
10
  "BasePathRouterMiddleware",
11
11
  "clear_stale_lock",
12
12
  "create_app",
@@ -1,9 +1,13 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { api, flash } from "./utils.js";
3
+ import { createSmartRefresh } from "./smartRefresh.js";
4
+ import { REPO_ID } from "./env.js";
5
+ const API_PREFIX = REPO_ID ? "/api" : "/hub/pma";
6
+ const STORAGE_PREFIX = REPO_ID ? "car.agent" : "car.pma.agent";
3
7
  const STORAGE_KEYS = {
4
- selected: "car.agent.selected",
5
- model: (agent) => `car.agent.${agent}.model`,
6
- reasoning: (agent) => `car.agent.${agent}.reasoning`,
8
+ selected: `${STORAGE_PREFIX}.selected`,
9
+ model: (agent) => `${STORAGE_PREFIX}.${agent}.model`,
10
+ reasoning: (agent) => `${STORAGE_PREFIX}.${agent}.reasoning`,
7
11
  };
8
12
  const FALLBACK_AGENTS = [
9
13
  { id: "codex", name: "Codex" },
@@ -15,6 +19,22 @@ let agentList = [...FALLBACK_AGENTS];
15
19
  let defaultAgent = "codex";
16
20
  const modelCatalogs = new Map();
17
21
  const modelCatalogPromises = new Map();
22
+ const agentControlsRefresh = createSmartRefresh({
23
+ getSignature: (payload) => {
24
+ const agentsSig = payload.agents
25
+ .map((agent) => `${agent.id}:${agent.name || ""}:${agent.version || ""}:${agent.protocol_version || ""}`)
26
+ .join("|");
27
+ const catalogSig = payload.catalog
28
+ ? `${payload.catalog.default_model || ""}:${payload.catalog.models
29
+ .map((model) => `${model.id}:${model.display_name || ""}:${model.supports_reasoning ? "1" : "0"}:${model.reasoning_options.join(",")}`)
30
+ .join("|")}`
31
+ : "none";
32
+ return `${agentsSig}::${payload.defaultAgent}::${catalogSig}`;
33
+ },
34
+ render: (payload) => {
35
+ renderAgentControls(payload);
36
+ },
37
+ });
18
38
  function safeGetStorage(key) {
19
39
  try {
20
40
  return localStorage.getItem(key);
@@ -75,7 +95,7 @@ async function loadAgents() {
75
95
  }
76
96
  agentsLoadPromise = (async () => {
77
97
  try {
78
- const data = await api("/api/agents", { method: "GET" });
98
+ const data = await api(`${API_PREFIX}/agents`, { method: "GET" });
79
99
  const agents = Array.isArray(data?.agents) ? data.agents : [];
80
100
  // Only use API response if it contains valid agents
81
101
  if (agents.length > 0 && agents.every((a) => a && typeof a.id === "string")) {
@@ -135,7 +155,7 @@ async function loadModelCatalog(agent) {
135
155
  if (modelCatalogPromises.has(agent)) {
136
156
  return await modelCatalogPromises.get(agent) || null;
137
157
  }
138
- const promise = api(`/api/agents/${encodeURIComponent(agent)}/models`, {
158
+ const promise = api(`${API_PREFIX}/agents/${encodeURIComponent(agent)}/models`, {
139
159
  method: "GET",
140
160
  })
141
161
  .then((data) => {
@@ -242,7 +262,7 @@ function resolveSelectedReasoning(agent, model) {
242
262
  }
243
263
  return model.reasoning_options[0] || "";
244
264
  }
245
- async function refreshControls() {
265
+ async function loadAgentControlsPayload() {
246
266
  try {
247
267
  await loadAgents();
248
268
  }
@@ -251,11 +271,6 @@ async function refreshControls() {
251
271
  ensureFallbackAgents();
252
272
  }
253
273
  const selectedAgent = getSelectedAgent();
254
- // Always update agent options first (uses in-memory agentList)
255
- controls.forEach((control) => {
256
- ensureAgentOptions(control.agentSelect);
257
- });
258
- // Then try to load model catalog
259
274
  let catalog = modelCatalogs.get(selectedAgent);
260
275
  if (!catalog) {
261
276
  try {
@@ -266,6 +281,20 @@ async function refreshControls() {
266
281
  catalog = null;
267
282
  }
268
283
  }
284
+ return {
285
+ agents: [...agentList],
286
+ defaultAgent,
287
+ selectedAgent,
288
+ catalog: catalog || null,
289
+ };
290
+ }
291
+ function renderAgentControls(payload) {
292
+ const selectedAgent = payload.selectedAgent;
293
+ // Always update agent options first (uses in-memory agentList)
294
+ controls.forEach((control) => {
295
+ ensureAgentOptions(control.agentSelect);
296
+ });
297
+ const catalog = payload.catalog;
269
298
  // Update model and reasoning options
270
299
  controls.forEach((control) => {
271
300
  ensureModelOptions(control.modelSelect, catalog);
@@ -288,6 +317,9 @@ async function refreshControls() {
288
317
  }
289
318
  });
290
319
  }
320
+ export async function refreshAgentControls(request = {}) {
321
+ await agentControlsRefresh.refresh(loadAgentControlsPayload, request);
322
+ }
291
323
  async function handleAgentChange(nextAgent) {
292
324
  const previous = getSelectedAgent();
293
325
  setSelectedAgent(nextAgent);
@@ -298,17 +330,17 @@ async function handleAgentChange(nextAgent) {
298
330
  setSelectedAgent(previous);
299
331
  flash(`Failed to load ${getLabelText(nextAgent)} models; staying on ${getLabelText(previous)}.`, "error");
300
332
  }
301
- await refreshControls();
333
+ await refreshAgentControls({ force: true, reason: "manual" });
302
334
  }
303
335
  async function handleModelChange(nextModel) {
304
336
  const agent = getSelectedAgent();
305
337
  setSelectedModel(agent, nextModel);
306
- await refreshControls();
338
+ await refreshAgentControls({ force: true, reason: "manual" });
307
339
  }
308
340
  async function handleReasoningChange(nextReasoning) {
309
341
  const agent = getSelectedAgent();
310
342
  setSelectedReasoning(agent, nextReasoning);
311
- await refreshControls();
343
+ await refreshAgentControls({ force: true, reason: "manual" });
312
344
  }
313
345
  /**
314
346
  * @param {AgentControlConfig} [config]
@@ -343,10 +375,23 @@ export function initAgentControls(config = {}) {
343
375
  });
344
376
  }
345
377
  // Async refresh to load from API (will update if API returns different data)
346
- refreshControls().catch((err) => {
378
+ refreshAgentControls({ force: true, reason: "initial" }).catch((err) => {
347
379
  console.warn("Failed to refresh agent controls", err);
348
380
  });
349
381
  }
350
382
  export async function ensureAgentCatalog() {
351
- await refreshControls();
383
+ await refreshAgentControls({ force: true, reason: "manual" });
384
+ }
385
+ export function clearAgentSelectionStorage() {
386
+ if (REPO_ID)
387
+ return;
388
+ safeSetStorage(STORAGE_KEYS.selected, "");
389
+ const candidates = new Set([
390
+ ...agentList.map((agent) => agent.id),
391
+ ...FALLBACK_AGENTS.map((agent) => agent.id),
392
+ ]);
393
+ candidates.forEach((agentId) => {
394
+ safeSetStorage(STORAGE_KEYS.model(agentId), "");
395
+ safeSetStorage(STORAGE_KEYS.reasoning(agentId), "");
396
+ });
352
397
  }
@@ -1,18 +1,122 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { REPO_ID, HUB_BASE } from "./env.js";
3
3
  import { initHub } from "./hub.js";
4
- import { initTabs, registerTab } from "./tabs.js";
4
+ import { initTabs, registerTab, registerHamburgerAction } from "./tabs.js";
5
5
  import { initTerminal } from "./terminal.js";
6
6
  import { initTicketFlow } from "./tickets.js";
7
7
  import { initMessages, initMessageBell } from "./messages.js";
8
8
  import { initMobileCompact } from "./mobileCompact.js";
9
9
  import { subscribe } from "./bus.js";
10
- import { initRepoSettingsPanel } from "./settings.js";
11
- import { flash } from "./utils.js";
10
+ import { initRepoSettingsPanel, openRepoSettings } from "./settings.js";
11
+ import { flash, getAuthToken, repairModalBackgroundIfStuck, resolvePath, updateUrlParams, } from "./utils.js";
12
12
  import { initLiveUpdates } from "./liveUpdates.js";
13
13
  import { initHealthGate } from "./health.js";
14
14
  import { initWorkspace } from "./workspace.js";
15
15
  import { initDashboard } from "./dashboard.js";
16
+ import { initArchive } from "./archive.js";
17
+ import { initPMA } from "./pma.js";
18
+ import { initNotifications } from "./notifications.js";
19
+ let pmaInitialized = false;
20
+ async function initPMAView() {
21
+ if (!pmaInitialized) {
22
+ await initPMA();
23
+ pmaInitialized = true;
24
+ }
25
+ }
26
+ function showHubView() {
27
+ const hubShell = document.getElementById("hub-shell");
28
+ const pmaShell = document.getElementById("pma-shell");
29
+ if (hubShell)
30
+ hubShell.classList.remove("hidden");
31
+ if (pmaShell)
32
+ pmaShell.classList.add("hidden");
33
+ updateModeToggle("manual");
34
+ updateUrlParams({ view: null });
35
+ }
36
+ function showPMAView() {
37
+ const hubShell = document.getElementById("hub-shell");
38
+ const pmaShell = document.getElementById("pma-shell");
39
+ if (hubShell)
40
+ hubShell.classList.add("hidden");
41
+ if (pmaShell)
42
+ pmaShell.classList.remove("hidden");
43
+ updateModeToggle("pma");
44
+ void initPMAView();
45
+ updateUrlParams({ view: "pma" });
46
+ }
47
+ function updateModeToggle(mode) {
48
+ const manualBtns = document.querySelectorAll('[data-hub-mode="manual"]');
49
+ const pmaBtns = document.querySelectorAll('[data-hub-mode="pma"]');
50
+ manualBtns.forEach((btn) => {
51
+ const active = mode === "manual";
52
+ btn.classList.toggle("active", active);
53
+ btn.setAttribute("aria-selected", active ? "true" : "false");
54
+ });
55
+ pmaBtns.forEach((btn) => {
56
+ const active = mode === "pma";
57
+ btn.classList.toggle("active", active);
58
+ btn.setAttribute("aria-selected", active ? "true" : "false");
59
+ });
60
+ }
61
+ async function probePMAEnabled() {
62
+ const headers = {};
63
+ const token = getAuthToken();
64
+ if (token) {
65
+ headers.Authorization = `Bearer ${token}`;
66
+ }
67
+ try {
68
+ const res = await fetch(resolvePath("/hub/pma/agents"), {
69
+ method: "GET",
70
+ headers,
71
+ });
72
+ return res.ok;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function initHubShell() {
79
+ const hubShell = document.getElementById("hub-shell");
80
+ const repoShell = document.getElementById("repo-shell");
81
+ const manualBtns = Array.from(document.querySelectorAll('[data-hub-mode="manual"]'));
82
+ const pmaBtns = Array.from(document.querySelectorAll('[data-hub-mode="pma"]'));
83
+ if (hubShell)
84
+ hubShell.classList.remove("hidden");
85
+ if (repoShell)
86
+ repoShell.classList.add("hidden");
87
+ initHub();
88
+ initNotifications();
89
+ manualBtns.forEach((btn) => {
90
+ btn.addEventListener("click", () => {
91
+ showHubView();
92
+ });
93
+ });
94
+ pmaBtns.forEach((btn) => {
95
+ btn.addEventListener("click", () => {
96
+ showPMAView();
97
+ });
98
+ });
99
+ const urlParams = new URLSearchParams(window.location.search);
100
+ const requestedPMA = urlParams.get("view") === "pma";
101
+ const pmaEnabled = await probePMAEnabled();
102
+ if (!pmaEnabled) {
103
+ pmaBtns.forEach((btn) => {
104
+ btn.disabled = true;
105
+ btn.setAttribute("aria-disabled", "true");
106
+ btn.title = "Enable PMA in config to use Project Manager";
107
+ btn.classList.add("hidden");
108
+ btn.classList.remove("active");
109
+ btn.setAttribute("aria-selected", "false");
110
+ });
111
+ if (requestedPMA) {
112
+ showHubView();
113
+ }
114
+ return;
115
+ }
116
+ if (requestedPMA) {
117
+ showPMAView();
118
+ }
119
+ }
16
120
  async function initRepoShell() {
17
121
  await initHealthGate();
18
122
  if (REPO_ID) {
@@ -36,9 +140,13 @@ async function initRepoShell() {
36
140
  const defaultTab = REPO_ID ? "tickets" : "analytics";
37
141
  registerTab("tickets", "Tickets");
38
142
  registerTab("inbox", "Inbox");
39
- registerTab("analytics", "Analytics");
40
143
  registerTab("workspace", "Workspace");
41
144
  registerTab("terminal", "Terminal");
145
+ // Menu tabs (shown in hamburger menu)
146
+ registerTab("analytics", "Analytics", { menuTab: true, icon: "📊" });
147
+ registerTab("archive", "Archive", { menuTab: true, icon: "📦" });
148
+ // Settings action in hamburger menu
149
+ registerHamburgerAction("settings", "Settings", "⚙", () => openRepoSettings());
42
150
  const initializedTabs = new Set();
43
151
  const lazyInit = (tabId) => {
44
152
  if (initializedTabs.has(tabId))
@@ -52,6 +160,9 @@ async function initRepoShell() {
52
160
  else if (tabId === "analytics") {
53
161
  initDashboard();
54
162
  }
163
+ else if (tabId === "archive") {
164
+ initArchive();
165
+ }
55
166
  else if (tabId === "tickets") {
56
167
  initTicketFlow();
57
168
  }
@@ -80,22 +191,23 @@ async function initRepoShell() {
80
191
  if (repoShell?.hasAttribute("inert")) {
81
192
  const openModals = document.querySelectorAll(".modal-overlay:not([hidden])");
82
193
  const count = openModals.length;
83
- flash(count
84
- ? `UI inert: ${count} modal${count === 1 ? "" : "s"} open`
85
- : "UI inert but no modal is visible", "error");
194
+ if (!count && repairModalBackgroundIfStuck()) {
195
+ flash("Recovered from stuck modal state (UI was inert).", "info");
196
+ }
197
+ else {
198
+ flash(count
199
+ ? `UI inert: ${count} modal${count === 1 ? "" : "s"} open`
200
+ : "UI inert but no modal is visible", "error");
201
+ }
86
202
  }
87
203
  }
88
204
  function bootstrap() {
89
- const hubShell = document.getElementById("hub-shell");
90
- const repoShell = document.getElementById("repo-shell");
91
205
  if (!REPO_ID) {
92
- if (hubShell)
93
- hubShell.classList.remove("hidden");
94
- if (repoShell)
95
- repoShell.classList.add("hidden");
96
- initHub();
206
+ void initHubShell();
97
207
  return;
98
208
  }
209
+ const hubShell = document.getElementById("hub-shell");
210
+ const repoShell = document.getElementById("repo-shell");
99
211
  if (repoShell)
100
212
  repoShell.classList.remove("hidden");
101
213
  if (hubShell)