codex-autorunner 1.1.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,7 @@ from datetime import datetime
6
6
  from pathlib import Path
7
7
  from typing import Awaitable, Callable, Optional
8
8
 
9
+ from ...core.config import load_repo_config
9
10
  from ...core.flows import FlowStore
10
11
  from ...core.flows.controller import FlowController
11
12
  from ...core.flows.models import FlowRunRecord, FlowRunStatus
@@ -241,7 +242,8 @@ class TelegramTicketFlowBridge:
241
242
  db_path = workspace_root / ".codex-autorunner" / "flows.db"
242
243
  if not db_path.exists():
243
244
  return None
244
- store = FlowStore(db_path)
245
+ config = load_repo_config(workspace_root)
246
+ store = FlowStore(db_path, durable=config.durable_writes)
245
247
  try:
246
248
  store.initialize()
247
249
  runs = store.list_flow_runs(
@@ -305,7 +307,8 @@ class TelegramTicketFlowBridge:
305
307
  db_path = workspace_root / ".codex-autorunner" / "flows.db"
306
308
  if not db_path.exists():
307
309
  return None
308
- store = FlowStore(db_path)
310
+ config = load_repo_config(workspace_root)
311
+ store = FlowStore(db_path, durable=config.durable_writes)
309
312
  try:
310
313
  store.initialize()
311
314
  if preferred_run_id:
@@ -573,16 +576,19 @@ def _ticket_controller_for(repo_root: Path) -> FlowController:
573
576
  artifacts_root = repo_root / ".codex-autorunner" / "flows"
574
577
  from ...agents.registry import validate_agent_id
575
578
  from ...core.config import load_repo_config
576
- from ...core.engine import Engine
579
+ from ...core.runtime import RuntimeContext
580
+ from ...integrations.agents import build_backend_orchestrator
577
581
  from ...integrations.agents.wiring import (
578
582
  build_agent_backend_factory,
579
583
  build_app_server_supervisor_factory,
580
584
  )
581
585
 
582
586
  config = load_repo_config(repo_root)
583
- engine = Engine(
587
+ backend_orchestrator = build_backend_orchestrator(repo_root, config)
588
+ engine = RuntimeContext(
584
589
  repo_root,
585
590
  config=config,
591
+ backend_orchestrator=backend_orchestrator,
586
592
  backend_factory=build_agent_backend_factory(repo_root, config),
587
593
  app_server_supervisor_factory=build_app_server_supervisor_factory(config),
588
594
  agent_id_validator=validate_agent_id,
@@ -265,6 +265,7 @@ class TelegramMessageTransport:
265
265
  thread_id: Optional[int] = None,
266
266
  reply_to: Optional[int] = None,
267
267
  reply_markup: Optional[dict[str, Any]] = None,
268
+ parse_mode: Optional[str] = None,
268
269
  ) -> None:
269
270
  if _should_trace_message(text):
270
271
  text = _with_conversation_id(
@@ -279,9 +280,15 @@ class TelegramMessageTransport:
279
280
  )
280
281
  if prefix:
281
282
  text = f"{prefix}{text}"
282
- parse_mode = self._config.parse_mode
283
- if parse_mode:
284
- rendered, used_mode = self._render_message(text)
283
+ effective_parse_mode = parse_mode or self._config.parse_mode
284
+ if effective_parse_mode:
285
+ try:
286
+ rendered, used_mode = self._render_message(
287
+ text, parse_mode=effective_parse_mode
288
+ )
289
+ except TypeError:
290
+ # Back-compat for subclasses/tests that don't accept parse_mode kwarg
291
+ rendered, used_mode = self._render_message(text) # type: ignore[misc]
285
292
  if used_mode and len(rendered) > TELEGRAM_MAX_MESSAGE_LENGTH:
286
293
  overflow_mode = getattr(self._config, "message_overflow", "document")
287
294
  if overflow_mode == "split" and used_mode in (
@@ -0,0 +1,27 @@
1
+ """Template integration helpers."""
2
+
3
+ from .scan_agent import (
4
+ TemplateScanBackendError,
5
+ TemplateScanDecision,
6
+ TemplateScanError,
7
+ TemplateScanFormatError,
8
+ TemplateScanRejectedError,
9
+ build_template_scan_prompt,
10
+ format_template_scan_rejection,
11
+ parse_template_scan_output,
12
+ run_template_scan,
13
+ run_template_scan_with_orchestrator,
14
+ )
15
+
16
+ __all__ = [
17
+ "TemplateScanBackendError",
18
+ "TemplateScanDecision",
19
+ "TemplateScanError",
20
+ "TemplateScanFormatError",
21
+ "TemplateScanRejectedError",
22
+ "build_template_scan_prompt",
23
+ "format_template_scan_rejection",
24
+ "parse_template_scan_output",
25
+ "run_template_scan",
26
+ "run_template_scan_with_orchestrator",
27
+ ]
@@ -0,0 +1,312 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Iterable, Optional
7
+
8
+ from ...core.config import RepoConfig, load_hub_config
9
+ from ...core.ports.backend_orchestrator import BackendOrchestrator
10
+ from ...core.ports.run_event import Completed, Failed, OutputDelta, RunEvent
11
+ from ...core.prompts import TEMPLATE_SCAN_PROMPT
12
+ from ...core.templates import FetchedTemplate, TemplateScanRecord, write_scan_record
13
+
14
+ _FORMAT_ERROR_HINT = (
15
+ "FORMAT ERROR: Output must be EXACTLY ONE LINE of JSON in the specified schema. "
16
+ "Do not include markdown, code fences, or extra text."
17
+ )
18
+
19
+ _APPROVE_TOOL = "template_scan_approve"
20
+ _REJECT_TOOL = "template_scan_reject"
21
+ _ALLOWED_TOOLS = {_APPROVE_TOOL, _REJECT_TOOL}
22
+ _APPROVE_SEVERITIES = {"low", "medium"}
23
+ _REJECT_SEVERITIES = {"high"}
24
+
25
+
26
+ class TemplateScanError(RuntimeError):
27
+ pass
28
+
29
+
30
+ class TemplateScanFormatError(TemplateScanError):
31
+ pass
32
+
33
+
34
+ class TemplateScanRejectedError(TemplateScanError):
35
+ def __init__(self, record: TemplateScanRecord, message: str) -> None:
36
+ super().__init__(message)
37
+ self.record = record
38
+
39
+
40
+ class TemplateScanBackendError(TemplateScanError):
41
+ pass
42
+
43
+
44
+ @dataclasses.dataclass(frozen=True)
45
+ class TemplateScanDecision:
46
+ tool: str
47
+ blob_sha: str
48
+ severity: str
49
+ reason: str
50
+ evidence: Optional[list[str]]
51
+
52
+
53
+ def build_template_scan_prompt(template: FetchedTemplate) -> str:
54
+ prompt = TEMPLATE_SCAN_PROMPT
55
+ replacements = {
56
+ "repo_id": template.repo_id,
57
+ "repo_url": template.url,
58
+ "trusted_repo": str(template.trusted),
59
+ "path": template.path,
60
+ "ref": template.ref,
61
+ "commit_sha": template.commit_sha,
62
+ "blob_sha": template.blob_sha,
63
+ "template_content": template.content,
64
+ }
65
+ for key, value in replacements.items():
66
+ prompt = prompt.replace(f"{{{{{key}}}}}", str(value))
67
+ return prompt
68
+
69
+
70
+ def _extract_output_text(events: Iterable[RunEvent]) -> str:
71
+ deltas: list[str] = []
72
+ final_message: Optional[str] = None
73
+ for event in events:
74
+ if isinstance(event, Completed):
75
+ final_message = event.final_message
76
+ continue
77
+ if isinstance(event, OutputDelta) and event.delta_type in {
78
+ "assistant_message",
79
+ "assistant_stream",
80
+ }:
81
+ if event.content:
82
+ deltas.append(event.content)
83
+ if final_message:
84
+ return final_message
85
+ if deltas:
86
+ return "".join(deltas)
87
+ return ""
88
+
89
+
90
+ def _normalize_line(text: str) -> str:
91
+ line = text.strip()
92
+ if "\n" in line or "\r" in line:
93
+ raise TemplateScanFormatError("Output must be a single line")
94
+ return line
95
+
96
+
97
+ def _parse_json_line(text: str) -> dict[str, Any]:
98
+ try:
99
+ payload = json.loads(text)
100
+ except json.JSONDecodeError as exc:
101
+ raise TemplateScanFormatError(f"Invalid JSON: {exc}") from exc
102
+ if not isinstance(payload, dict):
103
+ raise TemplateScanFormatError("Output JSON must be an object")
104
+ return payload
105
+
106
+
107
+ def _coerce_evidence(value: Any) -> Optional[list[str]]:
108
+ if value is None or value == []:
109
+ return None
110
+ if isinstance(value, list):
111
+ evidence = [str(item) for item in value][:3]
112
+ return evidence or None
113
+ return [str(value)]
114
+
115
+
116
+ def parse_template_scan_output(
117
+ raw: str, *, expected_blob_sha: str
118
+ ) -> TemplateScanDecision:
119
+ line = _normalize_line(raw)
120
+ if not line:
121
+ raise TemplateScanFormatError("Empty output from scan agent")
122
+
123
+ payload = _parse_json_line(line)
124
+ tool = payload.get("tool")
125
+ blob_sha = payload.get("blob_sha")
126
+ severity = payload.get("severity")
127
+ reason = payload.get("reason")
128
+ evidence = _coerce_evidence(payload.get("evidence"))
129
+
130
+ if tool not in _ALLOWED_TOOLS:
131
+ raise TemplateScanFormatError("Missing or invalid tool field")
132
+ if not isinstance(blob_sha, str) or not blob_sha:
133
+ raise TemplateScanFormatError("Missing blob_sha field")
134
+ if blob_sha != expected_blob_sha:
135
+ raise TemplateScanFormatError("blob_sha mismatch")
136
+ if not isinstance(severity, str) or not severity:
137
+ raise TemplateScanFormatError("Missing severity field")
138
+ if not isinstance(reason, str) or not reason:
139
+ raise TemplateScanFormatError("Missing reason field")
140
+
141
+ if tool == _APPROVE_TOOL and severity not in _APPROVE_SEVERITIES:
142
+ raise TemplateScanFormatError("Invalid severity for approve decision")
143
+ if tool == _REJECT_TOOL and severity not in _REJECT_SEVERITIES:
144
+ raise TemplateScanFormatError("Invalid severity for reject decision")
145
+
146
+ return TemplateScanDecision(
147
+ tool=tool,
148
+ blob_sha=blob_sha,
149
+ severity=severity,
150
+ reason=reason,
151
+ evidence=evidence,
152
+ )
153
+
154
+
155
+ def _decision_to_record(
156
+ template: FetchedTemplate,
157
+ decision: TemplateScanDecision,
158
+ *,
159
+ scanner: Optional[dict[str, str]] = None,
160
+ ) -> TemplateScanRecord:
161
+ scanned_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
162
+ return TemplateScanRecord(
163
+ blob_sha=template.blob_sha,
164
+ repo_id=template.repo_id,
165
+ path=template.path,
166
+ ref=template.ref,
167
+ commit_sha=template.commit_sha,
168
+ trusted=template.trusted,
169
+ decision="approve" if decision.tool == _APPROVE_TOOL else "reject",
170
+ severity=decision.severity,
171
+ reason=decision.reason,
172
+ evidence=decision.evidence,
173
+ scanned_at=scanned_at,
174
+ scanner=scanner,
175
+ )
176
+
177
+
178
+ def _scanner_metadata(config: Optional[RepoConfig]) -> Optional[dict[str, str]]:
179
+ if config is None:
180
+ return None
181
+ model = config.codex_model or ""
182
+ reasoning = config.codex_reasoning or ""
183
+ metadata: dict[str, str] = {"agent": "codex"}
184
+ if model:
185
+ metadata["model"] = model
186
+ if reasoning:
187
+ metadata["reasoning"] = reasoning
188
+ return metadata or None
189
+
190
+
191
+ def format_template_scan_rejection(record: TemplateScanRecord) -> str:
192
+ lines = [
193
+ "Template scan rejected this template.",
194
+ f"repo_id={record.repo_id}",
195
+ f"path={record.path}",
196
+ f"ref={record.ref}",
197
+ f"commit_sha={record.commit_sha}",
198
+ f"blob_sha={record.blob_sha}",
199
+ f"reason={record.reason}",
200
+ "Pause and notify the user; do not continue with this template.",
201
+ ]
202
+ if record.evidence:
203
+ lines.append("evidence:")
204
+ lines.extend([f"- {item}" for item in record.evidence])
205
+ return "\n".join(lines)
206
+
207
+
208
+ async def run_template_scan_with_orchestrator(
209
+ *,
210
+ config: RepoConfig,
211
+ backend_orchestrator: BackendOrchestrator,
212
+ template: FetchedTemplate,
213
+ hub_root: Any,
214
+ max_attempts: int = 2,
215
+ ) -> TemplateScanRecord:
216
+ if max_attempts < 1:
217
+ raise ValueError("max_attempts must be >= 1")
218
+
219
+ prompt = build_template_scan_prompt(template)
220
+ state = _build_scan_state()
221
+ scanner = _scanner_metadata(config)
222
+ last_error: Optional[str] = None
223
+
224
+ for attempt in range(max_attempts):
225
+ if attempt > 0:
226
+ prompt = f"{_FORMAT_ERROR_HINT}\n\n{prompt}"
227
+ events: list[RunEvent] = []
228
+ try:
229
+ async for event in backend_orchestrator.run_turn(
230
+ agent_id="codex",
231
+ state=state,
232
+ prompt=prompt,
233
+ model=config.codex_model,
234
+ reasoning=config.codex_reasoning,
235
+ session_key=f"template_scan:{template.blob_sha}:{attempt}",
236
+ ):
237
+ events.append(event)
238
+ except Exception as exc:
239
+ raise TemplateScanBackendError(str(exc)) from exc
240
+
241
+ for event in events:
242
+ if isinstance(event, Failed):
243
+ raise TemplateScanBackendError(event.error_message)
244
+
245
+ output = _extract_output_text(events)
246
+ if not output:
247
+ last_error = "Empty scan output"
248
+ continue
249
+ try:
250
+ decision = parse_template_scan_output(
251
+ output, expected_blob_sha=template.blob_sha
252
+ )
253
+ except TemplateScanFormatError as exc:
254
+ last_error = str(exc)
255
+ continue
256
+
257
+ record = _decision_to_record(template, decision, scanner=scanner)
258
+ write_scan_record(record, hub_root)
259
+ if record.decision == "reject":
260
+ raise TemplateScanRejectedError(
261
+ record, format_template_scan_rejection(record)
262
+ )
263
+ return record
264
+
265
+ raise TemplateScanFormatError(last_error or "Scan output failed validation")
266
+
267
+
268
+ async def run_template_scan(
269
+ *,
270
+ ctx: Any,
271
+ template: FetchedTemplate,
272
+ max_attempts: int = 2,
273
+ ) -> TemplateScanRecord:
274
+ backend_orchestrator = getattr(ctx, "_backend_orchestrator", None)
275
+ if backend_orchestrator is None:
276
+ raise TemplateScanBackendError(
277
+ "Template scanning requires a backend orchestrator; configure Codex or OpenCode."
278
+ )
279
+
280
+ config = ctx.config
281
+ try:
282
+ hub_root = load_hub_config(config.root).root
283
+ except Exception:
284
+ hub_root = getattr(config, "root", None)
285
+ if hub_root is None:
286
+ raise TemplateScanBackendError("Missing hub root for scan cache writes")
287
+
288
+ return await run_template_scan_with_orchestrator(
289
+ config=config,
290
+ backend_orchestrator=backend_orchestrator,
291
+ template=template,
292
+ hub_root=hub_root,
293
+ max_attempts=max_attempts,
294
+ )
295
+
296
+
297
+ def _build_scan_state() -> Any:
298
+ from ...core.state import RunnerState
299
+
300
+ return RunnerState(
301
+ last_run_id=None,
302
+ status="running",
303
+ last_exit_code=None,
304
+ last_run_started_at=None,
305
+ last_run_finished_at=None,
306
+ autorunner_agent_override="codex",
307
+ autorunner_model_override=None,
308
+ autorunner_effort_override=None,
309
+ autorunner_approval_policy="never",
310
+ autorunner_sandbox_mode="readOnly",
311
+ autorunner_workspace_write_network=False,
312
+ )
@@ -1,12 +1,12 @@
1
1
  from importlib import resources
2
2
 
3
- from .core.engine import Engine, LockError, clear_stale_lock, doctor
3
+ from .core.runtime import LockError, RuntimeContext, clear_stale_lock, doctor
4
4
  from .surfaces.web.app import create_app, create_hub_app, create_repo_app
5
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,10 +1,13 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { api, flash } from "./utils.js";
3
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";
4
7
  const STORAGE_KEYS = {
5
- selected: "car.agent.selected",
6
- model: (agent) => `car.agent.${agent}.model`,
7
- 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`,
8
11
  };
9
12
  const FALLBACK_AGENTS = [
10
13
  { id: "codex", name: "Codex" },
@@ -92,7 +95,7 @@ async function loadAgents() {
92
95
  }
93
96
  agentsLoadPromise = (async () => {
94
97
  try {
95
- const data = await api("/api/agents", { method: "GET" });
98
+ const data = await api(`${API_PREFIX}/agents`, { method: "GET" });
96
99
  const agents = Array.isArray(data?.agents) ? data.agents : [];
97
100
  // Only use API response if it contains valid agents
98
101
  if (agents.length > 0 && agents.every((a) => a && typeof a.id === "string")) {
@@ -152,7 +155,7 @@ async function loadModelCatalog(agent) {
152
155
  if (modelCatalogPromises.has(agent)) {
153
156
  return await modelCatalogPromises.get(agent) || null;
154
157
  }
155
- const promise = api(`/api/agents/${encodeURIComponent(agent)}/models`, {
158
+ const promise = api(`${API_PREFIX}/agents/${encodeURIComponent(agent)}/models`, {
156
159
  method: "GET",
157
160
  })
158
161
  .then((data) => {
@@ -379,3 +382,16 @@ export function initAgentControls(config = {}) {
379
382
  export async function ensureAgentCatalog() {
380
383
  await refreshAgentControls({ force: true, reason: "manual" });
381
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
+ });
397
+ }
@@ -8,12 +8,115 @@ import { initMessages, initMessageBell } from "./messages.js";
8
8
  import { initMobileCompact } from "./mobileCompact.js";
9
9
  import { subscribe } from "./bus.js";
10
10
  import { initRepoSettingsPanel, openRepoSettings } from "./settings.js";
11
- import { flash } from "./utils.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
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
+ }
17
120
  async function initRepoShell() {
18
121
  await initHealthGate();
19
122
  if (REPO_ID) {
@@ -88,22 +191,23 @@ async function initRepoShell() {
88
191
  if (repoShell?.hasAttribute("inert")) {
89
192
  const openModals = document.querySelectorAll(".modal-overlay:not([hidden])");
90
193
  const count = openModals.length;
91
- flash(count
92
- ? `UI inert: ${count} modal${count === 1 ? "" : "s"} open`
93
- : "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
+ }
94
202
  }
95
203
  }
96
204
  function bootstrap() {
97
- const hubShell = document.getElementById("hub-shell");
98
- const repoShell = document.getElementById("repo-shell");
99
205
  if (!REPO_ID) {
100
- if (hubShell)
101
- hubShell.classList.remove("hidden");
102
- if (repoShell)
103
- repoShell.classList.add("hidden");
104
- initHub();
206
+ void initHubShell();
105
207
  return;
106
208
  }
209
+ const hubShell = document.getElementById("hub-shell");
210
+ const repoShell = document.getElementById("repo-shell");
107
211
  if (repoShell)
108
212
  repoShell.classList.remove("hidden");
109
213
  if (hubShell)