codex-agent-view 0.4.7 → 0.5.0

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.
package/public/app.js CHANGED
@@ -1,6 +1,13 @@
1
1
  const API_STATE_URL = "/api/state";
2
+ const API_EXCHANGE_URL = "/api/viewer/exchange";
2
3
  const POLL_INTERVAL_MS = 2_000;
3
4
  const SESSION_TOKEN_KEY = "codex-agent-view-access-token";
5
+ const RECOVERY_CREDENTIAL_KEY = "codex-agent-view-recovery-credential";
6
+ const RECOVERY_HEADER = "x-codex-agent-view-recovery";
7
+ const ACCESS_HEADER = "x-codex-agent-view-access";
8
+ const ACCESS_CLIENT_TTL_MS = 15 * 60 * 1_000;
9
+ const RECOVERY_CLIENT_TTL_MS = 30 * 60 * 1_000;
10
+ const RECOVERY_REFRESH_THRESHOLD_MS = 5 * 60 * 1_000;
4
11
  const EXCLUDED_SESSION_KEY = "codex-agent-view-excluded-session";
5
12
  const LANGUAGE_KEY = "codex-agent-view-language";
6
13
  const SUPPORTED_LANGUAGES = new Set(["en", "ko", "es"]);
@@ -14,6 +21,7 @@ const KNOWN_STATUSES = new Set([
14
21
  "unknown",
15
22
  ]);
16
23
  const VIEWER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
24
+ const SIGNED_CREDENTIAL_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/;
17
25
  const CANONICAL_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
26
 
19
27
  const MESSAGES = Object.freeze({
@@ -62,7 +70,7 @@ const MESSAGES = Object.freeze({
62
70
  completionNotObservedExplanation: "No end signal was received for this item, so completion cannot be confirmed.",
63
71
  staleExplanation: "This status is out of date and no end signal was received. Completion cannot be confirmed.",
64
72
  interruptedExplanation: "This activity was still open when the work ended; its own completion signal was not observed.",
65
- connectingCopy: "Connecting to the Codex app's local status.",
73
+ connectingCopy: "Connecting this browser to the local Codex monitor on this device.",
66
74
  sessionListAria: "Codex work list",
67
75
  privacyPrompt: "This view shows only a shortened request summary; it never displays the full request or tool inputs.",
68
76
  privacyLocal: "Data is read only from the local monitor on this device.",
@@ -89,9 +97,13 @@ const MESSAGES = Object.freeze({
89
97
  retry: "Retry connection",
90
98
  retryAuthentication: "Try this tab again",
91
99
  checkAuthentication: "Check authentication again",
92
- recoveryTitle: "Open a newly authenticated view",
93
- recoveryStep: "In the Codex app, select @codex-agent-view in the composer, then choose the actual $show-agents skill from the skill picker.",
94
- recoveryNote: "The skill opens a new live view with fresh authentication. No terminal command or external browser is needed.",
100
+ reconnectAuthentication: "Reconnect securely",
101
+ recoveryAvailableTitle: "Reconnect this live view",
102
+ recoveryAvailableStep: "This tab has a recent, read-only recovery credential.",
103
+ recoveryAvailableNote: "Select Reconnect securely to restore this browser tab without reopening Codex Agent View.",
104
+ recoveryTitle: "New authentication is required",
105
+ recoveryStep: "Return to the Codex app and run @codex-agent-view again.",
106
+ recoveryNote: "Codex Agent View opens a newly authenticated page in your default browser. For safety, this page cannot create or replace its own authentication.",
95
107
  resultsFiltered: "Showing {visible} of {total}",
96
108
  resultsTotal: "{count} work items",
97
109
  searchEmptyTitle: "No matching results.",
@@ -111,7 +123,7 @@ const MESSAGES = Object.freeze({
111
123
  disconnectedTitle: "Local status disconnected; retrying.",
112
124
  authTitle: "This live view cannot be authenticated.",
113
125
  retryWithState: "Reconnecting automatically every 2 seconds while keeping the last good state visible.",
114
- retryWithoutState: "Reconnecting automatically every 2 seconds. You can leave this view open in the Codex app.",
126
+ retryWithoutState: "Reconnecting automatically every 2 seconds. You can leave this browser tab open or retry immediately.",
115
127
  missingToken: "This tab does not have the authentication needed to display live work.",
116
128
  expiredToken: "This tab's live-view authentication was rejected or is no longer valid.",
117
129
  requestFailed: "Status request failed ({status})",
@@ -201,7 +213,7 @@ const MESSAGES = Object.freeze({
201
213
  completionNotObservedExplanation: "이 항목의 종료 신호를 받지 못해 완료 여부를 확정할 수 없습니다.",
202
214
  staleExplanation: "상태 정보가 오래되었고 종료 신호를 받지 못했습니다. 완료 여부를 확정할 수 없습니다.",
203
215
  interruptedExplanation: "전체 작업이 끝날 때 이 활동이 열린 상태였습니다. 이 활동 자체의 완료 신호는 확인되지 않았습니다.",
204
- connectingCopy: "Codex 앱의 로컬 상태에 연결하고 있습니다.",
216
+ connectingCopy: " 브라우저를 기기의 로컬 Codex 모니터에 연결하고 있습니다.",
205
217
  sessionListAria: "Codex 작업 목록",
206
218
  privacyPrompt: "이 화면은 짧게 줄인 요청 요약만 표시하며, 전체 요청이나 도구 입력은 표시하지 않습니다.",
207
219
  privacyLocal: "데이터는 이 기기의 로컬 모니터에서만 읽습니다.",
@@ -228,9 +240,13 @@ const MESSAGES = Object.freeze({
228
240
  retry: "연결 다시 시도",
229
241
  retryAuthentication: "이 탭에서 다시 시도",
230
242
  checkAuthentication: "인증 정보 다시 확인",
231
- recoveryTitle: " 인증 화면 열기",
232
- recoveryStep: "Codex 입력창에서 @codex-agent-view를 선택한 다음, 스킬 선택기에서 실제 $show-agents 스킬을 선택하세요.",
233
- recoveryNote: " 인증이 적용된 실시간 화면이 열립니다. 터미널 명령이나 외부 브라우저는 필요하지 않습니다.",
243
+ reconnectAuthentication: "안전하게 다시 연결",
244
+ recoveryAvailableTitle: " 실시간 화면 다시 연결",
245
+ recoveryAvailableStep: " 탭에 최근 발급된 읽기 전용 복구 인증 정보가 있습니다.",
246
+ recoveryAvailableNote: "안전하게 다시 연결을 누르면 Codex Agent View를 다시 열지 않고 이 브라우저 탭의 접근을 복구합니다.",
247
+ recoveryTitle: "새 인증이 필요합니다",
248
+ recoveryStep: "Codex 앱으로 돌아가 @codex-agent-view를 다시 실행하세요.",
249
+ recoveryNote: "Codex Agent View가 기본 브라우저에 새 인증 화면을 엽니다. 안전을 위해 이 페이지 자체에서는 인증 정보를 만들거나 교체할 수 없습니다.",
234
250
  resultsFiltered: "전체 {total}개 중 {visible}개 표시",
235
251
  resultsTotal: "작업 {count}개",
236
252
  searchEmptyTitle: "검색 결과가 없습니다.",
@@ -250,7 +266,7 @@ const MESSAGES = Object.freeze({
250
266
  disconnectedTitle: "로컬 상태 연결이 끊겨 다시 시도 중입니다.",
251
267
  authTitle: "이 실시간 화면을 인증할 수 없습니다.",
252
268
  retryWithState: "2초마다 자동으로 다시 연결합니다. 마지막 정상 상태를 계속 표시합니다.",
253
- retryWithoutState: "2초마다 자동으로 다시 연결합니다. Codex 앱에서 화면을 그대로 두어도 됩니다.",
269
+ retryWithoutState: "2초마다 자동으로 다시 연결합니다. 브라우저 탭을 열어 두거나 지금 바로 다시 시도할 수 있습니다.",
254
270
  missingToken: "이 탭에는 실시간 작업을 표시하는 데 필요한 인증 정보가 없습니다.",
255
271
  expiredToken: "이 탭의 실시간 화면 인증이 거부되었거나 더 이상 유효하지 않습니다.",
256
272
  requestFailed: "상태 요청 실패 ({status})",
@@ -340,7 +356,7 @@ const MESSAGES = Object.freeze({
340
356
  completionNotObservedExplanation: "No se recibió una señal de fin para este elemento, por lo que no se puede confirmar que haya terminado.",
341
357
  staleExplanation: "El estado está desactualizado y no se recibió una señal de fin. No se puede confirmar que haya terminado.",
342
358
  interruptedExplanation: "Esta actividad seguía abierta cuando terminó el trabajo; no se observó su propia señal de finalización.",
343
- connectingCopy: "Conectando al estado local de la aplicación Codex.",
359
+ connectingCopy: "Conectando este navegador al monitor local de Codex en este dispositivo.",
344
360
  sessionListAria: "Lista de trabajos de Codex",
345
361
  privacyPrompt: "Esta vista solo muestra un resumen abreviado de la solicitud; nunca muestra la solicitud completa ni las entradas de herramientas.",
346
362
  privacyLocal: "Los datos se leen únicamente del monitor local de este dispositivo.",
@@ -367,9 +383,13 @@ const MESSAGES = Object.freeze({
367
383
  retry: "Reintentar conexión",
368
384
  retryAuthentication: "Reintentar en esta pestaña",
369
385
  checkAuthentication: "Volver a comprobar la autenticación",
370
- recoveryTitle: "Abrir una vista con autenticación nueva",
371
- recoveryStep: "En el cuadro de texto de Codex, selecciona @codex-agent-view y luego elige la skill real $show-agents en el selector de skills.",
372
- recoveryNote: "La skill abre una vista en vivo nueva con autenticación actualizada. No necesitas la terminal ni un navegador externo.",
386
+ reconnectAuthentication: "Reconectar de forma segura",
387
+ recoveryAvailableTitle: "Reconectar esta vista en vivo",
388
+ recoveryAvailableStep: "Esta pestaña tiene una credencial reciente de recuperación de solo lectura.",
389
+ recoveryAvailableNote: "Selecciona Reconectar de forma segura para recuperar esta pestaña sin volver a abrir Codex Agent View.",
390
+ recoveryTitle: "Se necesita una autenticación nueva",
391
+ recoveryStep: "Vuelve a la aplicación Codex y ejecuta @codex-agent-view de nuevo.",
392
+ recoveryNote: "Codex Agent View abre una página recién autenticada en tu navegador predeterminado. Por seguridad, esta página no puede crear ni reemplazar su propia autenticación.",
373
393
  resultsFiltered: "Mostrando {visible} de {total}",
374
394
  resultsTotal: "{count} trabajos",
375
395
  searchEmptyTitle: "No hay resultados.",
@@ -389,7 +409,7 @@ const MESSAGES = Object.freeze({
389
409
  disconnectedTitle: "Se perdió la conexión local; reintentando.",
390
410
  authTitle: "No se puede autenticar esta vista en vivo.",
391
411
  retryWithState: "Se reconecta automáticamente cada 2 segundos y mantiene visible el último estado válido.",
392
- retryWithoutState: "Se reconecta automáticamente cada 2 segundos. Puedes dejar esta vista abierta en Codex.",
412
+ retryWithoutState: "Se reconecta automáticamente cada 2 segundos. Puedes dejar abierta esta pestaña o reintentar de inmediato.",
393
413
  missingToken: "Esta pestaña no tiene la autenticación necesaria para mostrar el trabajo en vivo.",
394
414
  expiredToken: "La autenticación de esta pestaña fue rechazada o ya no es válida.",
395
415
  requestFailed: "Falló la solicitud de estado ({status})",
@@ -520,14 +540,20 @@ function t(key, replacements = {}) {
520
540
 
521
541
  function isExactLiveFragment(entries) {
522
542
  const tokenEntries = entries.filter(([key]) => key === "token");
543
+ const grantEntries = entries.filter(([key]) => key === "grant");
523
544
  const excludeEntries = entries.filter(([key]) => key === "exclude");
524
- return (
545
+ const validGrant =
546
+ entries.length === 1 &&
547
+ grantEntries.length === 1 &&
548
+ SIGNED_CREDENTIAL_PATTERN.test(grantEntries[0][1]) &&
549
+ grantEntries[0][1].length <= 1_024;
550
+ const validLegacyToken =
525
551
  entries.length === tokenEntries.length + excludeEntries.length &&
526
552
  tokenEntries.length === 1 &&
527
553
  excludeEntries.length <= 1 &&
528
554
  VIEWER_TOKEN_PATTERN.test(tokenEntries[0][1]) &&
529
- (excludeEntries.length === 0 || CANONICAL_SESSION_ID_PATTERN.test(excludeEntries[0][1]))
530
- );
555
+ (excludeEntries.length === 0 || CANONICAL_SESSION_ID_PATTERN.test(excludeEntries[0][1]));
556
+ return validGrant || validLegacyToken;
531
557
  }
532
558
 
533
559
  function consumeLiveContext() {
@@ -536,8 +562,11 @@ function consumeLiveContext() {
536
562
  ? [...new URLSearchParams(window.location.hash.slice(1)).entries()]
537
563
  : [];
538
564
  const validFragment = hasFragment && isExactLiveFragment(entries);
565
+ const fragmentGrant = validFragment
566
+ ? entries.find(([key]) => key === "grant")?.[1] || ""
567
+ : "";
539
568
  const fragmentToken = validFragment
540
- ? entries.find(([key]) => key === "token")[1]
569
+ ? entries.find(([key]) => key === "token")?.[1] || ""
541
570
  : "";
542
571
  const fragmentExclude = validFragment
543
572
  ? entries.find(([key]) => key === "exclude")?.[1] || ""
@@ -555,13 +584,19 @@ function consumeLiveContext() {
555
584
  let excludedSessionId = fragmentExclude;
556
585
  try {
557
586
  if (validFragment) {
587
+ window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
588
+ }
589
+ if (fragmentGrant) {
590
+ window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
591
+ window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
592
+ } else if (validFragment && fragmentToken) {
558
593
  window.sessionStorage.setItem(SESSION_TOKEN_KEY, fragmentToken);
559
594
  if (fragmentExclude) {
560
595
  window.sessionStorage.setItem(EXCLUDED_SESSION_KEY, fragmentExclude);
561
596
  } else {
562
597
  window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
563
598
  }
564
- } else {
599
+ } else if (!fragmentGrant) {
565
600
  token = window.sessionStorage.getItem(SESSION_TOKEN_KEY)?.trim() || "";
566
601
  excludedSessionId = window.sessionStorage.getItem(EXCLUDED_SESSION_KEY)?.trim() || "";
567
602
  }
@@ -572,13 +607,205 @@ function consumeLiveContext() {
572
607
 
573
608
  return {
574
609
  accessToken: VIEWER_TOKEN_PATTERN.test(token) ? token : "",
610
+ bootstrapCredential: SIGNED_CREDENTIAL_PATTERN.test(fragmentGrant)
611
+ ? fragmentGrant
612
+ : "",
575
613
  excludedSessionId: CANONICAL_SESSION_ID_PATTERN.test(excludedSessionId)
576
614
  ? excludedSessionId
577
615
  : "",
578
616
  };
579
617
  }
580
618
 
581
- let { accessToken, excludedSessionId } = consumeLiveContext();
619
+ let { accessToken, bootstrapCredential, excludedSessionId } = consumeLiveContext();
620
+ let recoveredAccessToken = "";
621
+ let authenticationExchangeInFlight = false;
622
+
623
+ function readRecoveryCredential() {
624
+ try {
625
+ const raw = window.sessionStorage.getItem(RECOVERY_CREDENTIAL_KEY);
626
+ if (!raw) return "";
627
+ const value = JSON.parse(raw);
628
+ if (
629
+ value === null ||
630
+ typeof value !== "object" ||
631
+ Array.isArray(value) ||
632
+ !SIGNED_CREDENTIAL_PATTERN.test(value.credential) ||
633
+ value.credential.length > 1_024 ||
634
+ !Number.isSafeInteger(value.expires_at_ms) ||
635
+ value.expires_at_ms <= Date.now()
636
+ ) {
637
+ window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
638
+ return "";
639
+ }
640
+ return value.credential;
641
+ } catch {
642
+ return "";
643
+ }
644
+ }
645
+
646
+ function persistRecoveryCredential(
647
+ credential,
648
+ expiresInMs = RECOVERY_CLIENT_TTL_MS,
649
+ { force = false } = {},
650
+ ) {
651
+ if (!SIGNED_CREDENTIAL_PATTERN.test(credential) || credential.length > 1_024) {
652
+ return false;
653
+ }
654
+ try {
655
+ if (!force) {
656
+ const existingRaw = window.sessionStorage.getItem(RECOVERY_CREDENTIAL_KEY);
657
+ if (existingRaw) {
658
+ const existing = JSON.parse(existingRaw);
659
+ if (
660
+ existing !== null &&
661
+ typeof existing === "object" &&
662
+ SIGNED_CREDENTIAL_PATTERN.test(existing.credential) &&
663
+ Number.isSafeInteger(existing.expires_at_ms) &&
664
+ existing.expires_at_ms - Date.now() > RECOVERY_REFRESH_THRESHOLD_MS
665
+ ) {
666
+ return false;
667
+ }
668
+ }
669
+ }
670
+ window.sessionStorage.setItem(RECOVERY_CREDENTIAL_KEY, JSON.stringify({
671
+ credential,
672
+ expires_at_ms: Date.now() + Math.min(expiresInMs, RECOVERY_CLIENT_TTL_MS),
673
+ }));
674
+ return true;
675
+ } catch {
676
+ // A valid live view remains usable when origin storage is unavailable.
677
+ return false;
678
+ }
679
+ }
680
+
681
+ function storeRecoveryCredential(response) {
682
+ const credential = response.headers?.get(RECOVERY_HEADER) || "";
683
+ persistRecoveryCredential(credential);
684
+ return credential;
685
+ }
686
+
687
+ function refreshRecoveredAccess(response, stateAccessCredential, recoveryCredential) {
688
+ const credential = response.headers?.get(ACCESS_HEADER) || "";
689
+ if (SIGNED_CREDENTIAL_PATTERN.test(credential) && credential.length <= 1_024) {
690
+ recoveredAccessToken = credential;
691
+ if (
692
+ stateAccessCredential === accessToken &&
693
+ VIEWER_TOKEN_PATTERN.test(accessToken) &&
694
+ SIGNED_CREDENTIAL_PATTERN.test(recoveryCredential) &&
695
+ recoveryCredential.length <= 1_024
696
+ ) {
697
+ accessToken = "";
698
+ try {
699
+ window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
700
+ window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
701
+ } catch {
702
+ // The signed credentials remain usable when storage is unavailable.
703
+ }
704
+ }
705
+ }
706
+ }
707
+
708
+ function clearRejectedViewerToken() {
709
+ accessToken = "";
710
+ recoveredAccessToken = "";
711
+ try {
712
+ window.sessionStorage.removeItem(SESSION_TOKEN_KEY);
713
+ } catch {
714
+ // Storage can be unavailable in hardened browser contexts.
715
+ }
716
+ }
717
+
718
+ function clearRecoveryCredential() {
719
+ try {
720
+ window.sessionStorage.removeItem(RECOVERY_CREDENTIAL_KEY);
721
+ } catch {
722
+ // Storage can be unavailable in hardened browser contexts.
723
+ }
724
+ }
725
+
726
+ function validateExchangePayload(payload) {
727
+ return Boolean(
728
+ payload !== null &&
729
+ typeof payload === "object" &&
730
+ !Array.isArray(payload) &&
731
+ Object.keys(payload).sort().join(",") ===
732
+ "access_credential,access_expires_in_ms,excluded_session_id,recovery_credential,recovery_expires_in_ms,status" &&
733
+ payload.status === "exchanged" &&
734
+ SIGNED_CREDENTIAL_PATTERN.test(payload.access_credential) &&
735
+ payload.access_credential.length <= 1_024 &&
736
+ Number.isSafeInteger(payload.access_expires_in_ms) &&
737
+ payload.access_expires_in_ms > 0 &&
738
+ payload.access_expires_in_ms <= ACCESS_CLIENT_TTL_MS &&
739
+ SIGNED_CREDENTIAL_PATTERN.test(payload.recovery_credential) &&
740
+ payload.recovery_credential.length <= 1_024 &&
741
+ Number.isSafeInteger(payload.recovery_expires_in_ms) &&
742
+ payload.recovery_expires_in_ms > 0 &&
743
+ payload.recovery_expires_in_ms <= RECOVERY_CLIENT_TTL_MS &&
744
+ (
745
+ payload.excluded_session_id === null ||
746
+ (
747
+ typeof payload.excluded_session_id === "string" &&
748
+ CANONICAL_SESSION_ID_PATTERN.test(payload.excluded_session_id)
749
+ )
750
+ )
751
+ );
752
+ }
753
+
754
+ async function exchangeViewerCredential(credential, { source }) {
755
+ if (
756
+ authenticationExchangeInFlight ||
757
+ !SIGNED_CREDENTIAL_PATTERN.test(credential) ||
758
+ credential.length > 1_024
759
+ ) {
760
+ return false;
761
+ }
762
+
763
+ authenticationExchangeInFlight = true;
764
+ try {
765
+ const response = await fetch(API_EXCHANGE_URL, {
766
+ body: JSON.stringify({ credential }),
767
+ cache: "no-store",
768
+ credentials: "same-origin",
769
+ headers: {
770
+ Accept: "application/json",
771
+ "Content-Type": "application/json",
772
+ },
773
+ method: "POST",
774
+ });
775
+ if (!response.ok) {
776
+ await response.body?.cancel();
777
+ if (
778
+ (response.status === 401 || response.status === 403 || response.status === 409)
779
+ ) {
780
+ if (source === "recovery") {
781
+ clearRecoveryCredential();
782
+ } else if (source === "bootstrap") {
783
+ bootstrapCredential = "";
784
+ }
785
+ }
786
+ throw new Error(t("requestFailed", { status: response.status }));
787
+ }
788
+
789
+ const payload = await response.json();
790
+ if (!validateExchangePayload(payload)) {
791
+ throw new Error(t("invalidState"));
792
+ }
793
+
794
+ recoveredAccessToken = payload.access_credential;
795
+ if (source === "bootstrap") {
796
+ bootstrapCredential = "";
797
+ }
798
+ excludedSessionId = payload.excluded_session_id || "";
799
+ persistRecoveryCredential(
800
+ payload.recovery_credential,
801
+ payload.recovery_expires_in_ms,
802
+ { force: true },
803
+ );
804
+ return true;
805
+ } finally {
806
+ authenticationExchangeInFlight = false;
807
+ }
808
+ }
582
809
 
583
810
  const elements = Object.freeze({
584
811
  connectionStatus: document.querySelector("#connection-status"),
@@ -1272,27 +1499,37 @@ function setStateMessage(kind, title, description, retryMode = "") {
1272
1499
  copy.textContent = description;
1273
1500
  elements.stateMessage.append(heading, copy);
1274
1501
 
1502
+ const recoveryAvailable = retryMode === "authentication" && Boolean(
1503
+ accessToken || recoveredAccessToken || bootstrapCredential || readRecoveryCredential(),
1504
+ );
1505
+
1275
1506
  if (retryMode === "authentication") {
1276
1507
  const recovery = document.createElement("div");
1277
1508
  recovery.className = "recovery-guidance";
1278
1509
 
1279
1510
  const recoveryTitle = document.createElement("strong");
1280
- recoveryTitle.textContent = t("recoveryTitle");
1511
+ recoveryTitle.textContent = t(
1512
+ recoveryAvailable ? "recoveryAvailableTitle" : "recoveryTitle",
1513
+ );
1281
1514
  const recoveryStep = document.createElement("p");
1282
- recoveryStep.textContent = t("recoveryStep");
1515
+ recoveryStep.textContent = t(
1516
+ recoveryAvailable ? "recoveryAvailableStep" : "recoveryStep",
1517
+ );
1283
1518
  const recoveryNote = document.createElement("p");
1284
1519
  recoveryNote.className = "recovery-note";
1285
- recoveryNote.textContent = t("recoveryNote");
1520
+ recoveryNote.textContent = t(
1521
+ recoveryAvailable ? "recoveryAvailableNote" : "recoveryNote",
1522
+ );
1286
1523
  recovery.append(recoveryTitle, recoveryStep, recoveryNote);
1287
1524
  elements.stateMessage.append(recovery);
1288
1525
  }
1289
1526
 
1290
- if (retryMode) {
1527
+ if (retryMode && (retryMode !== "authentication" || recoveryAvailable)) {
1291
1528
  const retry = document.createElement("button");
1292
1529
  retry.type = "button";
1293
1530
  retry.className = "retry-button";
1294
1531
  retry.textContent = retryMode === "authentication"
1295
- ? (accessToken ? t("retryAuthentication") : t("checkAuthentication"))
1532
+ ? (accessToken ? t("retryAuthentication") : t("reconnectAuthentication"))
1296
1533
  : t("retry");
1297
1534
  retry.addEventListener(
1298
1535
  "click",
@@ -1302,12 +1539,30 @@ function setStateMessage(kind, title, description, retryMode = "") {
1302
1539
  }
1303
1540
  }
1304
1541
 
1305
- function retryAuthentication() {
1542
+ async function retryAuthentication() {
1306
1543
  const refreshedContext = consumeLiveContext();
1307
- accessToken = refreshedContext.accessToken;
1308
- excludedSessionId = refreshedContext.excludedSessionId;
1544
+ if (refreshedContext.accessToken) {
1545
+ accessToken = refreshedContext.accessToken;
1546
+ excludedSessionId = refreshedContext.excludedSessionId;
1547
+ }
1548
+ if (refreshedContext.bootstrapCredential) {
1549
+ bootstrapCredential = refreshedContext.bootstrapCredential;
1550
+ }
1551
+
1552
+ if (accessToken) {
1553
+ viewState.authenticationFailed = false;
1554
+ viewState.canRetry = true;
1555
+ viewState.errorKey = "";
1556
+ viewState.errorMessage = "";
1557
+ setConnectionStatus("connecting");
1558
+ render();
1559
+ await refreshState();
1560
+ return;
1561
+ }
1309
1562
 
1310
- if (!accessToken) {
1563
+ const credential = bootstrapCredential || readRecoveryCredential();
1564
+ const source = bootstrapCredential ? "bootstrap" : "recovery";
1565
+ if (!credential) {
1311
1566
  viewState.hasLoaded = true;
1312
1567
  viewState.canRetry = false;
1313
1568
  viewState.authenticationFailed = true;
@@ -1319,12 +1574,28 @@ function retryAuthentication() {
1319
1574
  }
1320
1575
 
1321
1576
  viewState.authenticationFailed = false;
1322
- viewState.canRetry = true;
1577
+ viewState.canRetry = false;
1323
1578
  viewState.errorKey = "";
1324
1579
  viewState.errorMessage = "";
1325
1580
  setConnectionStatus("connecting");
1326
1581
  render();
1327
- void refreshState();
1582
+
1583
+ try {
1584
+ const exchanged = await exchangeViewerCredential(credential, { source });
1585
+ if (!exchanged) return;
1586
+ viewState.canRetry = true;
1587
+ await refreshState();
1588
+ } catch (error) {
1589
+ viewState.hasLoaded = true;
1590
+ viewState.canRetry = false;
1591
+ viewState.authenticationFailed = true;
1592
+ viewState.errorKey = "expiredToken";
1593
+ viewState.errorMessage = error instanceof Error
1594
+ ? error.message
1595
+ : t("unknownConnectionError");
1596
+ setConnectionStatus("error", t("authenticationRequired"));
1597
+ render();
1598
+ }
1328
1599
  }
1329
1600
 
1330
1601
  function setEmptyObservationMessage() {
@@ -1472,7 +1743,8 @@ async function refreshState() {
1472
1743
  return;
1473
1744
  }
1474
1745
 
1475
- if (!accessToken) {
1746
+ const stateAccessCredential = accessToken || recoveredAccessToken;
1747
+ if (!stateAccessCredential) {
1476
1748
  viewState.hasLoaded = true;
1477
1749
  viewState.canRetry = false;
1478
1750
  viewState.authenticationFailed = true;
@@ -1489,17 +1761,26 @@ async function refreshState() {
1489
1761
  }
1490
1762
 
1491
1763
  try {
1764
+ const stateHeaders = {
1765
+ Accept: "application/json",
1766
+ Authorization: `Bearer ${stateAccessCredential}`,
1767
+ };
1768
+ if (
1769
+ stateAccessCredential === accessToken &&
1770
+ VIEWER_TOKEN_PATTERN.test(accessToken) &&
1771
+ CANONICAL_SESSION_ID_PATTERN.test(excludedSessionId)
1772
+ ) {
1773
+ stateHeaders["x-codex-agent-view-exclude-session"] = excludedSessionId;
1774
+ }
1492
1775
  const response = await fetch(API_STATE_URL, {
1493
1776
  cache: "no-store",
1494
1777
  credentials: "same-origin",
1495
- headers: {
1496
- Accept: "application/json",
1497
- Authorization: `Bearer ${accessToken}`,
1498
- },
1778
+ headers: stateHeaders,
1499
1779
  });
1500
1780
 
1501
1781
  if (response.status === 401 || response.status === 403) {
1502
1782
  await response.body?.cancel();
1783
+ clearRejectedViewerToken();
1503
1784
  viewState.hasLoaded = true;
1504
1785
  viewState.canRetry = false;
1505
1786
  viewState.authenticationFailed = true;
@@ -1513,6 +1794,8 @@ async function refreshState() {
1513
1794
  throw new Error(t("requestFailed", { status: response.status }));
1514
1795
  }
1515
1796
 
1797
+ const recoveryCredential = storeRecoveryCredential(response);
1798
+ refreshRecoveredAccess(response, stateAccessCredential, recoveryCredential);
1516
1799
  const nextState = normalizeState(await response.json());
1517
1800
  viewState.updatedAtMs = nextState.updatedAtMs;
1518
1801
  viewState.sessions = nextState.sessions;
@@ -1559,9 +1842,36 @@ document.addEventListener("visibilitychange", () => {
1559
1842
  }
1560
1843
  });
1561
1844
 
1562
- applyStaticTranslations();
1563
- render();
1564
- refreshState();
1565
- if (accessToken) {
1845
+ async function initializeLiveView() {
1846
+ applyStaticTranslations();
1847
+ render();
1848
+
1849
+ if (bootstrapCredential) {
1850
+ viewState.authenticationFailed = false;
1851
+ setConnectionStatus("connecting");
1852
+ try {
1853
+ const exchanged = await exchangeViewerCredential(bootstrapCredential, {
1854
+ source: "bootstrap",
1855
+ });
1856
+ if (exchanged) {
1857
+ await refreshState();
1858
+ }
1859
+ } catch (error) {
1860
+ viewState.hasLoaded = true;
1861
+ viewState.canRetry = false;
1862
+ viewState.authenticationFailed = true;
1863
+ viewState.errorKey = "expiredToken";
1864
+ viewState.errorMessage = error instanceof Error
1865
+ ? error.message
1866
+ : t("unknownConnectionError");
1867
+ setConnectionStatus("error", t("authenticationRequired"));
1868
+ render();
1869
+ }
1870
+ } else {
1871
+ await refreshState();
1872
+ }
1873
+
1566
1874
  window.setInterval(refreshState, POLL_INTERVAL_MS);
1567
1875
  }
1876
+
1877
+ void initializeLiveView();
package/public/index.html CHANGED
@@ -138,7 +138,7 @@
138
138
 
139
139
  <div id="state-message" class="state-message" role="status" aria-live="polite">
140
140
  <strong data-i18n="loadingState">Loading status.</strong>
141
- <span data-i18n="connectingCopy">Connecting to the Codex app's local status.</span>
141
+ <span data-i18n="connectingCopy">Connecting this browser to the local Codex monitor on this device.</span>
142
142
  </div>
143
143
 
144
144
  <ul id="session-list" class="session-list" aria-label="Codex work list" data-i18n-aria-label="sessionListAria" hidden></ul>