codex-autorunner 1.2.1__py3-none-any.whl → 1.3.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 (55) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/config.py +176 -59
  3. codex_autorunner/core/filesystem.py +24 -0
  4. codex_autorunner/core/flows/controller.py +50 -12
  5. codex_autorunner/core/flows/runtime.py +8 -3
  6. codex_autorunner/core/hub.py +293 -16
  7. codex_autorunner/core/lifecycle_events.py +44 -5
  8. codex_autorunner/core/pma_delivery.py +81 -0
  9. codex_autorunner/core/pma_dispatches.py +224 -0
  10. codex_autorunner/core/pma_lane_worker.py +122 -0
  11. codex_autorunner/core/pma_queue.py +167 -18
  12. codex_autorunner/core/pma_reactive.py +91 -0
  13. codex_autorunner/core/pma_safety.py +58 -0
  14. codex_autorunner/core/pma_sink.py +104 -0
  15. codex_autorunner/core/pma_transcripts.py +183 -0
  16. codex_autorunner/core/safe_paths.py +117 -0
  17. codex_autorunner/housekeeping.py +77 -23
  18. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  19. codex_autorunner/integrations/agents/wiring.py +2 -0
  20. codex_autorunner/integrations/app_server/client.py +31 -0
  21. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  22. codex_autorunner/integrations/telegram/constants.py +1 -1
  23. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  24. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  25. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  26. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  27. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  28. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  29. codex_autorunner/integrations/telegram/helpers.py +30 -2
  30. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  31. codex_autorunner/static/docChatCore.js +2 -0
  32. codex_autorunner/static/hub.js +59 -0
  33. codex_autorunner/static/index.html +70 -54
  34. codex_autorunner/static/notificationBell.js +173 -0
  35. codex_autorunner/static/notifications.js +154 -36
  36. codex_autorunner/static/pma.js +96 -35
  37. codex_autorunner/static/styles.css +415 -4
  38. codex_autorunner/static/utils.js +5 -1
  39. codex_autorunner/surfaces/cli/cli.py +206 -129
  40. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  41. codex_autorunner/surfaces/web/app.py +193 -5
  42. codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
  43. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  44. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  45. codex_autorunner/tickets/agent_pool.py +6 -1
  46. codex_autorunner/tickets/outbox.py +27 -14
  47. codex_autorunner/tickets/replies.py +4 -10
  48. codex_autorunner/tickets/runner.py +1 -0
  49. codex_autorunner/workspace/paths.py +8 -3
  50. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  51. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
  52. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  53. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  54. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  55. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -18,6 +18,7 @@ import httpx
18
18
  from ....agents.opencode.client import OpenCodeProtocolError
19
19
  from ....agents.opencode.supervisor import OpenCodeSupervisorError
20
20
  from ....core.logging_utils import log_event
21
+ from ....core.pma_sink import PmaActiveSinkStore
21
22
  from ....core.state import now_iso
22
23
  from ....core.update import _normalize_update_target, _spawn_update_process
23
24
  from ....core.update_paths import resolve_update_paths
@@ -81,6 +82,7 @@ from ..helpers import (
81
82
  _with_conversation_id,
82
83
  derive_codex_features_command,
83
84
  format_codex_features,
85
+ format_public_error,
84
86
  parse_codex_features_list,
85
87
  )
86
88
  from ..state import (
@@ -158,12 +160,12 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
158
160
  if isinstance(exc, OpenCodeSupervisorError):
159
161
  detail = str(exc).strip()
160
162
  if detail:
161
- return f"OpenCode backend unavailable ({detail})."
163
+ return f"OpenCode backend unavailable ({format_public_error(detail)})."
162
164
  return "OpenCode backend unavailable."
163
165
  if isinstance(exc, OpenCodeProtocolError):
164
166
  detail = str(exc).strip()
165
167
  if detail:
166
- return f"OpenCode protocol error: {detail}"
168
+ return f"OpenCode protocol error: {format_public_error(detail)}"
167
169
  return "OpenCode protocol error."
168
170
  if isinstance(exc, json.JSONDecodeError):
169
171
  return "OpenCode returned invalid JSON."
@@ -174,15 +176,15 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
174
176
  except Exception:
175
177
  detail = None
176
178
  if detail:
177
- return f"OpenCode error: {detail}"
179
+ return f"OpenCode error: {format_public_error(detail)}"
178
180
  response_text = exc.response.text.strip()
179
181
  if response_text:
180
- return f"OpenCode error: {response_text}"
182
+ return f"OpenCode error: {format_public_error(response_text)}"
181
183
  return f"OpenCode request failed (HTTP {exc.response.status_code})."
182
184
  if isinstance(exc, httpx.RequestError):
183
185
  detail = str(exc).strip()
184
186
  if detail:
185
- return f"OpenCode request failed: {detail}"
187
+ return f"OpenCode request failed: {format_public_error(detail)}"
186
188
  return "OpenCode request failed."
187
189
  return None
188
190
 
@@ -239,15 +241,15 @@ def _format_httpx_exception(exc: Exception) -> Optional[str]:
239
241
  payload.get("detail") or payload.get("message") or payload.get("error")
240
242
  )
241
243
  if isinstance(detail, str) and detail:
242
- return detail
244
+ return format_public_error(detail)
243
245
  response_text = exc.response.text.strip()
244
246
  if response_text:
245
- return response_text
247
+ return format_public_error(response_text)
246
248
  return f"Request failed (HTTP {exc.response.status_code})."
247
249
  if isinstance(exc, httpx.RequestError):
248
250
  detail = str(exc).strip()
249
251
  if detail:
250
- return detail
252
+ return format_public_error(detail)
251
253
  return "Request failed."
252
254
  return None
253
255
 
@@ -1202,6 +1204,25 @@ class TelegramCommandHandlers(
1202
1204
  message.thread_id,
1203
1205
  apply_pma,
1204
1206
  )
1207
+ try:
1208
+ sink_store = PmaActiveSinkStore(Path(self._hub_root))
1209
+ if enabled:
1210
+ sink_store.set_telegram(
1211
+ chat_id=message.chat_id,
1212
+ thread_id=message.thread_id,
1213
+ topic_key=topic_key(message.chat_id, message.thread_id),
1214
+ )
1215
+ else:
1216
+ sink_store.clear()
1217
+ except Exception:
1218
+ log_event(
1219
+ self._logger,
1220
+ logging.WARNING,
1221
+ "telegram.pma.active_sink.update_failed",
1222
+ chat_id=message.chat_id,
1223
+ thread_id=message.thread_id,
1224
+ enabled=enabled,
1225
+ )
1205
1226
  status = "enabled" if enabled else "disabled"
1206
1227
  if enabled:
1207
1228
  hint = "Use /pma off to exit. Previous repo binding saved."
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone
9
9
  from pathlib import Path
10
10
  from typing import Any, Callable, Iterable, Optional, Sequence
11
11
 
12
+ from ...core.redaction import redact_text
12
13
  from ...core.state_roots import resolve_global_state_root
13
14
  from ...core.utils import (
14
15
  RepoNotFoundError,
@@ -2009,12 +2010,17 @@ def _extract_first_bold_span(text: str) -> Optional[str]:
2009
2010
 
2010
2011
 
2011
2012
  def _compose_agent_response(
2012
- messages: list[str],
2013
+ final_message: Optional[str] = None,
2013
2014
  *,
2015
+ messages: Optional[list[str]] = None,
2014
2016
  errors: Optional[list[str]] = None,
2015
2017
  status: Optional[str] = None,
2016
2018
  ) -> str:
2017
- cleaned = [msg.strip() for msg in messages if isinstance(msg, str) and msg.strip()]
2019
+ if isinstance(final_message, str) and final_message.strip():
2020
+ return final_message.strip()
2021
+ cleaned = [
2022
+ msg.strip() for msg in (messages or []) if isinstance(msg, str) and msg.strip()
2023
+ ]
2018
2024
  if not cleaned:
2019
2025
  cleaned_errors = [
2020
2026
  err.strip()
@@ -2183,3 +2189,25 @@ def _format_selection_prompt(base: str, page: int, total_pages: int) -> str:
2183
2189
  return base
2184
2190
  trimmed = base.rstrip(".")
2185
2191
  return f"{trimmed} (page {page + 1}/{total_pages})."
2192
+
2193
+
2194
+ def format_public_error(detail: str, *, limit: int = 200) -> str:
2195
+ """Format error detail for public Telegram messages with redaction and truncation.
2196
+
2197
+ This helper ensures all user-visible error text sent via Telegram is:
2198
+ - Short and readable
2199
+ - Redacted for known secret patterns
2200
+ - Does not include raw file contents or stack traces
2201
+
2202
+ Args:
2203
+ detail: Error detail string to format.
2204
+ limit: Maximum length of output (default 200).
2205
+
2206
+ Returns:
2207
+ Formatted error string with secrets redacted and length limited.
2208
+ """
2209
+ normalized = " ".join(detail.split())
2210
+ redacted = redact_text(normalized)
2211
+ if len(redacted) > limit:
2212
+ return f"{redacted[: limit - 3]}..."
2213
+ return redacted
@@ -18,6 +18,7 @@ from ...manifest import load_manifest
18
18
  from ...tickets import AgentPool
19
19
  from .adapter import chunk_message
20
20
  from .constants import TELEGRAM_MAX_MESSAGE_LENGTH
21
+ from .helpers import format_public_error
21
22
  from .state import parse_topic_key
22
23
 
23
24
 
@@ -163,7 +164,7 @@ class TelegramTicketFlowBridge:
163
164
  key, self._set_ticket_dispatch_marker(marker)
164
165
  )
165
166
 
166
- primary_key, _primary_record = primary
167
+ primary_key, primary_record = primary
167
168
  try:
168
169
  chat_id, thread_id, _scope = parse_topic_key(primary_key)
169
170
  except Exception as exc:
@@ -182,6 +183,8 @@ class TelegramTicketFlowBridge:
182
183
  seq=seq,
183
184
  content=content,
184
185
  archived_dir=archived_dir,
186
+ workspace_root=workspace_root,
187
+ repo_id=getattr(primary_record, "repo_id", None),
185
188
  )
186
189
  self._pause_targets[str(workspace_root)] = run_id
187
190
  except Exception as exc:
@@ -296,9 +299,14 @@ class TelegramTicketFlowBridge:
296
299
  def _format_ticket_flow_pause_reason(record: FlowRunRecord) -> str:
297
300
  state = record.state or {}
298
301
  engine = state.get("ticket_engine") or {}
299
- reason = (
302
+ reason_raw = (
300
303
  engine.get("reason") or record.error_message or "Paused without details."
301
304
  )
305
+ reason = (
306
+ format_public_error(str(reason_raw))
307
+ if reason_raw
308
+ else "Paused without details."
309
+ )
302
310
  return f"Reason: {reason}"
303
311
 
304
312
  def get_paused_ticket_flow(
@@ -373,6 +381,7 @@ class TelegramTicketFlowBridge:
373
381
  seq=seq,
374
382
  content=content,
375
383
  archived_dir=archived_dir,
384
+ workspace_root=workspace_root,
376
385
  )
377
386
  self._last_default_notification[workspace_root] = marker
378
387
  self._pause_targets[str(workspace_root)] = run_id
@@ -396,6 +405,8 @@ class TelegramTicketFlowBridge:
396
405
  seq: str,
397
406
  content: str,
398
407
  archived_dir: Optional[Path],
408
+ workspace_root: Optional[Path],
409
+ repo_id: Optional[str] = None,
399
410
  ) -> None:
400
411
  await self._send_dispatch_text(
401
412
  chat_id,
@@ -403,6 +414,8 @@ class TelegramTicketFlowBridge:
403
414
  run_id=run_id,
404
415
  seq=seq,
405
416
  content=content,
417
+ workspace_root=workspace_root,
418
+ repo_id=repo_id,
406
419
  )
407
420
  if self._pause_config.send_attachments and archived_dir:
408
421
  await self._send_dispatch_attachments(
@@ -421,9 +434,15 @@ class TelegramTicketFlowBridge:
421
434
  run_id: str,
422
435
  seq: str,
423
436
  content: str,
437
+ workspace_root: Optional[Path],
438
+ repo_id: Optional[str] = None,
424
439
  ) -> None:
425
440
  body = content.strip() or "(no dispatch message)"
426
- header = f"Ticket flow paused (run {run_id}). Latest dispatch #{seq}:\n\n"
441
+ source = self._format_dispatch_source(workspace_root, repo_id)
442
+ header = (
443
+ f"Ticket flow paused (run {run_id}). Latest dispatch #{seq}:\n"
444
+ f"Source: {source}\n\n"
445
+ )
427
446
  footer = "\n\nUse /flow resume to continue."
428
447
  full_text = f"{header}{body}{footer}"
429
448
 
@@ -446,6 +465,38 @@ class TelegramTicketFlowBridge:
446
465
  if idx == 0:
447
466
  await asyncio.sleep(0)
448
467
 
468
+ def _format_dispatch_source(
469
+ self, workspace_root: Optional[Path], repo_id: Optional[str]
470
+ ) -> str:
471
+ workspace_label = None
472
+ if isinstance(workspace_root, Path):
473
+ workspace_label = str(workspace_root)
474
+ repo_label = repo_id.strip() if isinstance(repo_id, str) else ""
475
+ if self._hub_root and self._manifest_path and self._manifest_path.exists():
476
+ try:
477
+ manifest = load_manifest(self._manifest_path, self._hub_root)
478
+ if workspace_root:
479
+ entry = manifest.get_by_path(self._hub_root, workspace_root)
480
+ else:
481
+ entry = None
482
+ if entry:
483
+ repo_label = entry.id or repo_label
484
+ if entry.display_name and entry.display_name != repo_label:
485
+ repo_label = f"{repo_label} ({entry.display_name})"
486
+ if entry.kind == "worktree" and entry.worktree_of:
487
+ repo_label = f"{repo_label} [worktree of {entry.worktree_of}]"
488
+ except Exception as exc:
489
+ self._logger.debug(
490
+ "telegram.ticket_flow.manifest_label_failed", exc_info=exc
491
+ )
492
+ if repo_label and workspace_label:
493
+ return f"{repo_label} @ {workspace_label}"
494
+ if repo_label:
495
+ return repo_label
496
+ if workspace_label:
497
+ return workspace_label
498
+ return "unknown workspace"
499
+
449
500
  async def _send_dispatch_attachments(
450
501
  self,
451
502
  chat_id: int,
@@ -309,6 +309,8 @@ export function createDocChat(config) {
309
309
  parts.push(`${msg.meta.steps} steps`);
310
310
  if (msg.meta.duration)
311
311
  parts.push(`${msg.meta.duration.toFixed(1)}s`);
312
+ if (msg.meta.tag)
313
+ parts.push(String(msg.meta.tag));
312
314
  if (state.contextUsagePercent !== null && msg.isFinal) {
313
315
  parts.push(`ctx left ${state.contextUsagePercent}%`);
314
316
  }
@@ -3,8 +3,10 @@ import { api, flash, statusPill, resolvePath, escapeHtml, confirmModal, inputMod
3
3
  import { registerAutoRefresh } from "./autoRefresh.js";
4
4
  import { HUB_BASE } from "./env.js";
5
5
  import { preserveScroll } from "./preserve.js";
6
+ import { initNotificationBell } from "./notificationBell.js";
6
7
  let hubData = { repos: [], last_scan_at: null };
7
8
  const prefetchedUrls = new Set();
9
+ let hubInboxHydrated = false;
8
10
  const HUB_CACHE_TTL_MS = 30000;
9
11
  const HUB_CACHE_KEY = `car:hub:${HUB_BASE || "/"}`;
10
12
  const HUB_USAGE_CACHE_KEY = `car:hub-usage:${HUB_BASE || "/"}`;
@@ -24,6 +26,8 @@ const hubUsageChartRange = document.getElementById("hub-usage-chart-range");
24
26
  const hubUsageChartSegment = document.getElementById("hub-usage-chart-segment");
25
27
  const hubVersionEl = document.getElementById("hub-version");
26
28
  const pmaVersionEl = document.getElementById("pma-version");
29
+ const hubInboxList = document.getElementById("hub-inbox-list");
30
+ const hubInboxRefresh = document.getElementById("hub-inbox-refresh");
27
31
  const UPDATE_STATUS_SEEN_KEY = "car_update_status_seen";
28
32
  const HUB_JOB_POLL_INTERVAL_MS = 1200;
29
33
  const HUB_JOB_TIMEOUT_MS = 180000;
@@ -83,7 +87,9 @@ function formatLastActivity(repo) {
83
87
  }
84
88
  function setButtonLoading(scanning) {
85
89
  const buttons = [
90
+ document.getElementById("hub-scan"),
86
91
  document.getElementById("hub-quick-scan"),
92
+ document.getElementById("hub-refresh"),
87
93
  ];
88
94
  buttons.forEach((btn) => {
89
95
  if (!btn)
@@ -915,6 +921,7 @@ async function refreshHub() {
915
921
  saveSessionCache(HUB_CACHE_KEY, hubData);
916
922
  renderSummary(data.repos || []);
917
923
  renderReposWithScroll(data.repos || []);
924
+ await loadHubInbox().catch(() => { });
918
925
  loadHubUsage({ silent: true }).catch(() => { });
919
926
  }
920
927
  catch (err) {
@@ -924,6 +931,46 @@ async function refreshHub() {
924
931
  setButtonLoading(false);
925
932
  }
926
933
  }
934
+ async function loadHubInbox(ctx) {
935
+ if (!hubInboxList)
936
+ return;
937
+ if (!hubInboxHydrated || ctx?.reason === "manual") {
938
+ hubInboxList.textContent = "Loading…";
939
+ }
940
+ try {
941
+ const payload = (await api("/hub/messages", { method: "GET" }));
942
+ const items = payload?.items || [];
943
+ const html = !items.length
944
+ ? '<div class="muted">No paused runs</div>'
945
+ : items
946
+ .map((item) => {
947
+ const title = item.message?.title || item.message?.mode || "Message";
948
+ const excerpt = item.message?.body ? item.message.body.slice(0, 160) : "";
949
+ const repoLabel = item.repo_display_name || item.repo_id;
950
+ const href = item.open_url || `/repos/${item.repo_id}/?tab=messages&run_id=${item.run_id}`;
951
+ return `
952
+ <a class="hub-inbox-item" href="${escapeHtml(resolvePath(href))}">
953
+ <div class="hub-inbox-item-header">
954
+ <span class="hub-inbox-repo">${escapeHtml(repoLabel)}</span>
955
+ <span class="pill pill-small pill-warn">paused</span>
956
+ </div>
957
+ <div class="hub-inbox-title">${escapeHtml(title)}</div>
958
+ <div class="hub-inbox-excerpt muted small">${escapeHtml(excerpt)}</div>
959
+ </a>
960
+ `;
961
+ })
962
+ .join("");
963
+ preserveScroll(hubInboxList, () => {
964
+ hubInboxList.innerHTML = html;
965
+ }, { restoreOnNextFrame: true });
966
+ hubInboxHydrated = true;
967
+ }
968
+ catch (_err) {
969
+ preserveScroll(hubInboxList, () => {
970
+ hubInboxList.innerHTML = "";
971
+ }, { restoreOnNextFrame: true });
972
+ }
973
+ }
927
974
  async function triggerHubScan() {
928
975
  setButtonLoading(true);
929
976
  try {
@@ -1138,14 +1185,22 @@ async function handleRepoAction(repoId, action) {
1138
1185
  }
1139
1186
  function attachHubHandlers() {
1140
1187
  initHubSettings();
1188
+ const scanBtn = document.getElementById("hub-scan");
1189
+ const refreshBtn = document.getElementById("hub-refresh");
1141
1190
  const quickScanBtn = document.getElementById("hub-quick-scan");
1142
1191
  const newRepoBtn = document.getElementById("hub-new-repo");
1143
1192
  const createCancelBtn = document.getElementById("create-repo-cancel");
1144
1193
  const createSubmitBtn = document.getElementById("create-repo-submit");
1145
1194
  const createRepoId = document.getElementById("create-repo-id");
1195
+ if (scanBtn) {
1196
+ scanBtn.addEventListener("click", () => triggerHubScan());
1197
+ }
1146
1198
  if (quickScanBtn) {
1147
1199
  quickScanBtn.addEventListener("click", () => triggerHubScan());
1148
1200
  }
1201
+ if (refreshBtn) {
1202
+ refreshBtn.addEventListener("click", () => refreshHub());
1203
+ }
1149
1204
  if (hubUsageRefresh) {
1150
1205
  hubUsageRefresh.addEventListener("click", () => loadHubUsage());
1151
1206
  }
@@ -1290,6 +1345,10 @@ export function initHub() {
1290
1345
  return;
1291
1346
  attachHubHandlers();
1292
1347
  initHubUsageChartControls();
1348
+ initNotificationBell();
1349
+ hubInboxRefresh?.addEventListener("click", () => {
1350
+ void loadHubInbox({ reason: "manual" });
1351
+ });
1293
1352
  const cachedHub = loadSessionCache(HUB_CACHE_KEY, HUB_CACHE_TTL_MS);
1294
1353
  if (cachedHub) {
1295
1354
  hubData = cachedHub;
@@ -23,15 +23,15 @@
23
23
  </div>
24
24
  <div class="hub-hero-actions">
25
25
  <button class="primary sm" id="hub-new-repo">+ New</button>
26
- <div class="notifications-bell" data-notifications-root="hub">
27
- <button class="ghost sm icon-btn notifications-bell-btn" data-notifications-trigger title="Notifications"
28
- aria-label="Notifications">
29
- <span aria-hidden="true">🔔</span>
30
- <span class="notifications-bell-badge hidden" data-notifications-badge></span>
31
- </button>
32
- <div class="notifications-dropdown hidden" data-notifications-dropdown role="menu"
33
- aria-label="Pending dispatches"></div>
34
- </div>
26
+ <button class="sm" id="hub-scan">Scan</button>
27
+ <button class="ghost sm" id="hub-refresh">Refresh</button>
28
+ <button class="ghost sm icon-btn notification-bell" id="hub-notification-bell" title="Dispatches">
29
+ <svg class="notification-bell-icon" viewBox="0 0 24 24" aria-hidden="true">
30
+ <path d="M6 9.5a6 6 0 0 1 12 0v3.5l1.6 2.2H4.4L6 13z" />
31
+ <path d="M9.8 18.2a2.2 2.2 0 0 0 4.4 0" />
32
+ </svg>
33
+ <span class="notification-badge hidden"></span>
34
+ </button>
35
35
  <button class="ghost sm icon-btn" id="hub-settings" title="Settings">⚙</button>
36
36
  </div>
37
37
  <div class="hub-mode-toggle" role="tablist" aria-label="Hub mode">
@@ -57,6 +57,15 @@
57
57
  <p class="muted small">missing</p>
58
58
  </div>
59
59
  </section>
60
+ <section class="hub-inbox">
61
+ <div class="hub-panel-header">
62
+ <span class="label">Inbox</span>
63
+ <div class="hub-panel-actions">
64
+ <button class="ghost sm" id="hub-inbox-refresh">Refresh</button>
65
+ </div>
66
+ </div>
67
+ <div class="hub-inbox-list" id="hub-inbox-list">Loading…</div>
68
+ </section>
60
69
  <section class="hub-usage-chart">
61
70
  <div class="hub-usage-chart-header">
62
71
  <span class="label">Usage Trend</span>
@@ -89,7 +98,7 @@
89
98
  </div>
90
99
  </section>
91
100
  </div>
92
- <div class="hub-shell hidden" id="pma-shell">
101
+ <div class="hub-shell hidden" id="pma-shell" data-pma-view="chat">
93
102
  <header class="hub-hero pma-hero">
94
103
  <div class="hub-hero-text pma-hero-text">
95
104
  <h1>Project Manager</h1>
@@ -97,15 +106,13 @@
97
106
  <span class="hub-version" id="pma-version">v–</span>
98
107
  </div>
99
108
  <div class="hub-hero-actions pma-hero-actions">
100
- <div class="notifications-bell" data-notifications-root="pma">
101
- <button class="ghost sm icon-btn notifications-bell-btn" data-notifications-trigger title="Notifications"
102
- aria-label="Notifications">
103
- <span aria-hidden="true">🔔</span>
104
- <span class="notifications-bell-badge hidden" data-notifications-badge></span>
105
- </button>
106
- <div class="notifications-dropdown hidden" data-notifications-dropdown role="menu"
107
- aria-label="Pending dispatches"></div>
108
- </div>
109
+ <button class="ghost sm icon-btn notification-bell" id="pma-notification-bell" title="Dispatches">
110
+ <svg class="notification-bell-icon" viewBox="0 0 24 24" aria-hidden="true">
111
+ <path d="M6 9.5a6 6 0 0 1 12 0v3.5l1.6 2.2H4.4L6 13z" />
112
+ <path d="M9.8 18.2a2.2 2.2 0 0 0 4.4 0" />
113
+ </svg>
114
+ <span class="notification-badge hidden"></span>
115
+ </button>
109
116
  <button class="ghost sm icon-btn" id="pma-settings" title="Settings">⚙</button>
110
117
  </div>
111
118
  <div class="hub-mode-toggle" role="tablist" aria-label="Hub mode">
@@ -123,42 +130,44 @@
123
130
  <select id="pma-chat-agent-select" title="Agent"></select>
124
131
  <select id="pma-chat-model-select" title="Model"></select>
125
132
  <select id="pma-chat-reasoning-select" title="Reasoning"></select>
133
+ <div class="pma-view-toggle" role="tablist" aria-label="PMA view">
134
+ <button class="pma-view-btn active" data-view="chat" role="tab" aria-selected="true" type="button">Chat</button>
135
+ <button class="pma-view-btn" data-view="memory" role="tab" aria-selected="false" type="button">Memory</button>
136
+ </div>
126
137
  </div>
127
138
  <div class="pma-chat-actions">
139
+ <button class="ghost sm icon-btn" id="pma-scan-repos-btn" title="Rescan hub repositories">↻</button>
128
140
  <button class="ghost sm hidden" id="pma-chat-cancel" title="Cancel">✕</button>
129
141
  <button class="pma-new-thread-btn" id="pma-chat-new-thread" title="Start a new chat thread">New
130
142
  thread</button>
131
143
  <span class="pma-status-pill" id="pma-chat-status">idle</span>
132
144
  </div>
133
145
  </div>
134
- <div class="pma-thread-info hidden" id="pma-thread-info">
135
- <div class="pma-thread-info-header">
136
- <span class="pma-thread-info-label">Thread</span>
137
- <span class="pill pill-small pill-idle" id="pma-thread-info-status">idle</span>
138
- </div>
139
- <div class="pma-thread-info-details">
140
- <div class="pma-thread-info-row">
141
- <span class="pma-thread-info-key muted">Agent</span>
142
- <span class="pma-thread-info-value" id="pma-thread-info-agent">–</span>
143
- </div>
144
- <div class="pma-thread-info-row">
145
- <span class="pma-thread-info-key muted">Thread ID</span>
146
- <span class="pma-thread-info-value" id="pma-thread-info-thread-id" title="Click to copy">–</span>
147
- </div>
148
- <div class="pma-thread-info-row">
149
- <span class="pma-thread-info-key muted">Turn ID</span>
150
- <span class="pma-thread-info-value" id="pma-thread-info-turn-id" title="Click to copy">–</span>
151
- </div>
152
- </div>
153
- </div>
154
- <div class="pma-repo-actions" id="pma-repo-actions">
155
- <button class="ghost sm" id="pma-scan-repos-btn">Scan repos</button>
156
- </div>
157
146
  </section>
158
- <section class="pma-chat-section">
147
+ <section class="pma-chat-section" id="pma-chat-section">
159
148
  <div class="pma-chat-main">
160
149
  <div class="pma-chat-stream" id="pma-chat-stream">
161
150
  <div class="pma-chat-error hidden" id="pma-chat-error"></div>
151
+ <div class="pma-thread-info hidden" id="pma-thread-info">
152
+ <div class="pma-thread-info-header">
153
+ <span class="pma-thread-info-label">Thread</span>
154
+ <span class="pill pill-small pill-idle" id="pma-thread-info-status">idle</span>
155
+ </div>
156
+ <div class="pma-thread-info-details">
157
+ <div class="pma-thread-info-row">
158
+ <span class="pma-thread-info-key muted">Agent</span>
159
+ <span class="pma-thread-info-value" id="pma-thread-info-agent">–</span>
160
+ </div>
161
+ <div class="pma-thread-info-row">
162
+ <span class="pma-thread-info-key muted">Thread ID</span>
163
+ <span class="pma-thread-info-value" id="pma-thread-info-thread-id" title="Click to copy">–</span>
164
+ </div>
165
+ <div class="pma-thread-info-row">
166
+ <span class="pma-thread-info-key muted">Turn ID</span>
167
+ <span class="pma-thread-info-value" id="pma-thread-info-turn-id" title="Click to copy">–</span>
168
+ </div>
169
+ </div>
170
+ </div>
162
171
  <div class="pma-history-header" id="pma-chat-history-header">
163
172
  <span class="pma-history-label">History</span>
164
173
  </div>
@@ -176,10 +185,12 @@
176
185
  <div class="pma-attachments-area" id="pma-attachments-area">
177
186
  <div class="pma-files-row">
178
187
  <span class="pma-files-label">In</span>
188
+ <button class="pma-icon-btn-small hidden pma-clear-btn" id="pma-inbox-clear" title="Clear inbox">🗑</button>
179
189
  <div class="pma-file-list filebox-list" id="pma-inbox-files"></div>
180
190
  </div>
181
191
  <div class="pma-files-row">
182
192
  <span class="pma-files-label">Out</span>
193
+ <button class="pma-icon-btn-small hidden pma-clear-btn" id="pma-outbox-clear" title="Clear outbox">🗑</button>
183
194
  <div class="pma-file-list filebox-list" id="pma-outbox-files"></div>
184
195
  <button class="pma-icon-btn-small" id="pma-outbox-refresh" title="Refresh files">↻</button>
185
196
  </div>
@@ -1042,6 +1053,22 @@
1042
1053
  </div>
1043
1054
  </div>
1044
1055
  </div>
1056
+ <!-- Dispatch Notifications Modal -->
1057
+ <div class="modal-overlay" hidden="" id="notification-modal">
1058
+ <div aria-describedby="notification-modal-body" aria-labelledby="notification-modal-title" aria-modal="true"
1059
+ class="modal-dialog notification-dialog" role="dialog" tabindex="-1">
1060
+ <div class="modal-header notification-modal-header">
1061
+ <span class="label" id="notification-modal-title">Dispatches</span>
1062
+ <div class="notification-modal-actions">
1063
+ <button class="ghost sm" id="notification-refresh">Refresh</button>
1064
+ <button class="ghost sm icon-btn" id="notification-close" title="Close">✕</button>
1065
+ </div>
1066
+ </div>
1067
+ <div class="modal-body" id="notification-modal-body">
1068
+ <div class="notification-list" id="notification-list">Loading…</div>
1069
+ </div>
1070
+ </div>
1071
+ </div>
1045
1072
  <!-- Repo Settings Modal -->
1046
1073
  <div class="modal-overlay" hidden="" id="repo-settings-modal">
1047
1074
  <div aria-describedby="repo-settings-modal-body" aria-labelledby="repo-settings-modal-title" aria-modal="true"
@@ -1153,17 +1180,6 @@
1153
1180
  </div>
1154
1181
  </div>
1155
1182
  </div>
1156
- <!-- Notifications Modal -->
1157
- <div class="modal-overlay hidden" id="notifications-modal">
1158
- <div aria-labelledby="notifications-modal-title" aria-modal="true" class="modal-dialog notifications-modal-dialog"
1159
- role="dialog" tabindex="-1">
1160
- <div class="notifications-modal-header">
1161
- <span class="label" id="notifications-modal-title">Pending dispatch</span>
1162
- <button class="ghost sm icon-btn" id="notifications-modal-close" title="Close">×</button>
1163
- </div>
1164
- <div class="notifications-modal-body-wrapper" id="notifications-modal-body"></div>
1165
- </div>
1166
- </div>
1167
1183
  <!-- Reason Details Modal -->
1168
1184
  <div class="modal-overlay" hidden="" id="reason-modal">
1169
1185
  <div aria-describedby="reason-modal-content" aria-labelledby="reason-modal-title" aria-modal="true"