codex-autorunner 1.0.0__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 (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.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,8 +1,8 @@
1
1
  from importlib import resources
2
2
 
3
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
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
8
  "Engine",
@@ -1,5 +1,6 @@
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";
3
4
  const STORAGE_KEYS = {
4
5
  selected: "car.agent.selected",
5
6
  model: (agent) => `car.agent.${agent}.model`,
@@ -15,6 +16,22 @@ let agentList = [...FALLBACK_AGENTS];
15
16
  let defaultAgent = "codex";
16
17
  const modelCatalogs = new Map();
17
18
  const modelCatalogPromises = new Map();
19
+ const agentControlsRefresh = createSmartRefresh({
20
+ getSignature: (payload) => {
21
+ const agentsSig = payload.agents
22
+ .map((agent) => `${agent.id}:${agent.name || ""}:${agent.version || ""}:${agent.protocol_version || ""}`)
23
+ .join("|");
24
+ const catalogSig = payload.catalog
25
+ ? `${payload.catalog.default_model || ""}:${payload.catalog.models
26
+ .map((model) => `${model.id}:${model.display_name || ""}:${model.supports_reasoning ? "1" : "0"}:${model.reasoning_options.join(",")}`)
27
+ .join("|")}`
28
+ : "none";
29
+ return `${agentsSig}::${payload.defaultAgent}::${catalogSig}`;
30
+ },
31
+ render: (payload) => {
32
+ renderAgentControls(payload);
33
+ },
34
+ });
18
35
  function safeGetStorage(key) {
19
36
  try {
20
37
  return localStorage.getItem(key);
@@ -242,7 +259,7 @@ function resolveSelectedReasoning(agent, model) {
242
259
  }
243
260
  return model.reasoning_options[0] || "";
244
261
  }
245
- async function refreshControls() {
262
+ async function loadAgentControlsPayload() {
246
263
  try {
247
264
  await loadAgents();
248
265
  }
@@ -251,11 +268,6 @@ async function refreshControls() {
251
268
  ensureFallbackAgents();
252
269
  }
253
270
  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
271
  let catalog = modelCatalogs.get(selectedAgent);
260
272
  if (!catalog) {
261
273
  try {
@@ -266,6 +278,20 @@ async function refreshControls() {
266
278
  catalog = null;
267
279
  }
268
280
  }
281
+ return {
282
+ agents: [...agentList],
283
+ defaultAgent,
284
+ selectedAgent,
285
+ catalog: catalog || null,
286
+ };
287
+ }
288
+ function renderAgentControls(payload) {
289
+ const selectedAgent = payload.selectedAgent;
290
+ // Always update agent options first (uses in-memory agentList)
291
+ controls.forEach((control) => {
292
+ ensureAgentOptions(control.agentSelect);
293
+ });
294
+ const catalog = payload.catalog;
269
295
  // Update model and reasoning options
270
296
  controls.forEach((control) => {
271
297
  ensureModelOptions(control.modelSelect, catalog);
@@ -288,6 +314,9 @@ async function refreshControls() {
288
314
  }
289
315
  });
290
316
  }
317
+ export async function refreshAgentControls(request = {}) {
318
+ await agentControlsRefresh.refresh(loadAgentControlsPayload, request);
319
+ }
291
320
  async function handleAgentChange(nextAgent) {
292
321
  const previous = getSelectedAgent();
293
322
  setSelectedAgent(nextAgent);
@@ -298,17 +327,17 @@ async function handleAgentChange(nextAgent) {
298
327
  setSelectedAgent(previous);
299
328
  flash(`Failed to load ${getLabelText(nextAgent)} models; staying on ${getLabelText(previous)}.`, "error");
300
329
  }
301
- await refreshControls();
330
+ await refreshAgentControls({ force: true, reason: "manual" });
302
331
  }
303
332
  async function handleModelChange(nextModel) {
304
333
  const agent = getSelectedAgent();
305
334
  setSelectedModel(agent, nextModel);
306
- await refreshControls();
335
+ await refreshAgentControls({ force: true, reason: "manual" });
307
336
  }
308
337
  async function handleReasoningChange(nextReasoning) {
309
338
  const agent = getSelectedAgent();
310
339
  setSelectedReasoning(agent, nextReasoning);
311
- await refreshControls();
340
+ await refreshAgentControls({ force: true, reason: "manual" });
312
341
  }
313
342
  /**
314
343
  * @param {AgentControlConfig} [config]
@@ -343,10 +372,10 @@ export function initAgentControls(config = {}) {
343
372
  });
344
373
  }
345
374
  // Async refresh to load from API (will update if API returns different data)
346
- refreshControls().catch((err) => {
375
+ refreshAgentControls({ force: true, reason: "initial" }).catch((err) => {
347
376
  console.warn("Failed to refresh agent controls", err);
348
377
  });
349
378
  }
350
379
  export async function ensureAgentCatalog() {
351
- await refreshControls();
380
+ await refreshAgentControls({ force: true, reason: "manual" });
352
381
  }
@@ -1,18 +1,19 @@
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";
10
+ import { initRepoSettingsPanel, openRepoSettings } from "./settings.js";
11
11
  import { flash } 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";
16
17
  async function initRepoShell() {
17
18
  await initHealthGate();
18
19
  if (REPO_ID) {
@@ -36,9 +37,13 @@ async function initRepoShell() {
36
37
  const defaultTab = REPO_ID ? "tickets" : "analytics";
37
38
  registerTab("tickets", "Tickets");
38
39
  registerTab("inbox", "Inbox");
39
- registerTab("analytics", "Analytics");
40
40
  registerTab("workspace", "Workspace");
41
41
  registerTab("terminal", "Terminal");
42
+ // Menu tabs (shown in hamburger menu)
43
+ registerTab("analytics", "Analytics", { menuTab: true, icon: "📊" });
44
+ registerTab("archive", "Archive", { menuTab: true, icon: "📦" });
45
+ // Settings action in hamburger menu
46
+ registerHamburgerAction("settings", "Settings", "⚙", () => openRepoSettings());
42
47
  const initializedTabs = new Set();
43
48
  const lazyInit = (tabId) => {
44
49
  if (initializedTabs.has(tabId))
@@ -52,6 +57,9 @@ async function initRepoShell() {
52
57
  else if (tabId === "analytics") {
53
58
  initDashboard();
54
59
  }
60
+ else if (tabId === "archive") {
61
+ initArchive();
62
+ }
55
63
  else if (tabId === "tickets") {
56
64
  initTicketFlow();
57
65
  }