codex-agent-view 0.4.2 → 0.4.4

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,13 +1,402 @@
1
1
  const API_STATE_URL = "/api/state";
2
2
  const POLL_INTERVAL_MS = 2_000;
3
3
  const SESSION_TOKEN_KEY = "codex-agent-view-access-token";
4
+ const EXCLUDED_SESSION_KEY = "codex-agent-view-excluded-session";
5
+ const LANGUAGE_KEY = "codex-agent-view-language";
6
+ const SUPPORTED_LANGUAGES = new Set(["en", "ko", "es"]);
4
7
  const KNOWN_STATUSES = new Set(["running", "waiting", "completed", "unknown"]);
8
+ const VIEWER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
9
+ 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}$/;
10
+
11
+ const MESSAGES = Object.freeze({
12
+ en: Object.freeze({
13
+ metaDescription: "Read-only local status for Codex tasks and subagents.",
14
+ skipToContent: "Skip to content",
15
+ brandHome: "Codex Agent View home",
16
+ brandSubtitle: "Local read-only monitor",
17
+ languageLabel: "Language",
18
+ connectionConnecting: "Connecting to local status",
19
+ connectionConnected: "Local monitor connected",
20
+ connectionRetrying: "Disconnected · retrying",
21
+ authenticationRequired: "Live view authentication required",
22
+ heroEyebrow: "LOCAL · READ ONLY",
23
+ heroTitle: "See active work at a glance",
24
+ heroCopy: "Keep using the official Codex app while viewing the current status of parent tasks and subagents locally.",
25
+ freshnessAria: "Refresh information",
26
+ lastUpdatedLabel: "Last updated",
27
+ notYet: "Not yet",
28
+ refreshIntervalLabel: "Refresh interval",
29
+ twoSeconds: "2 seconds",
30
+ metricsAria: "Task status summary",
31
+ parentTasks: "Parent tasks",
32
+ currentlyObserved: "Currently observed",
33
+ runningAgents: "Running subagents",
34
+ workingNow: "Working now",
35
+ waitingStatus: "Waiting",
36
+ waitingExplanation: "Waiting for input or the next step",
37
+ completedAgents: "Completed subagents",
38
+ currentView: "In the current view",
39
+ liveWork: "LIVE WORK",
40
+ sessionsHeading: "Parent tasks and subagents",
41
+ loadingState: "Loading status.",
42
+ toolbarAria: "Filter automatically observed parent tasks and subagents",
43
+ searchLabel: "Filter list (optional)",
44
+ searchPlaceholder: "Find a parent task or subagent",
45
+ statusFilterLabel: "Status filter (optional)",
46
+ statusAll: "All statuses",
47
+ statusRunning: "Running",
48
+ statusWaiting: "Waiting",
49
+ statusCompleted: "Completed",
50
+ statusUnknown: "Unknown",
51
+ connectingCopy: "Connecting to the Codex app's local status.",
52
+ sessionListAria: "Codex parent task list",
53
+ privacyPrompt: "This view does not display user requests or tool inputs.",
54
+ privacyLocal: "Data is read only from the local monitor on this device.",
55
+ timeUnknown: "Time unavailable",
56
+ startedUnknown: "Start time unavailable",
57
+ noReceivedActivity: "No activity received",
58
+ parentTask: "PARENT TASK",
59
+ projectUnknown: "Project unavailable",
60
+ recentActivity: "Recent activity",
61
+ recent: "Recent",
62
+ subagentsCount: "Subagents · {count}",
63
+ subagentName: "Subagent {ordinal}",
64
+ agentProfile: "Role/profile · {profile}",
65
+ agentProfileNote: "Verified hooks do not expose assignment descriptions. Role/profile is shown when available.",
66
+ noSubagents: "No subagents have been observed for this parent task.",
67
+ noRecentActivity: "No recent activity to display.",
68
+ technicalInfo: "Technical information",
69
+ sessionId: "Session ID",
70
+ agentId: "Agent ID",
71
+ rawProfile: "Raw role/profile",
72
+ rawEvent: "Raw event",
73
+ rawTool: "Raw tool",
74
+ retry: "Reconnect",
75
+ resultsFiltered: "Showing {visible} of {total}",
76
+ resultsTotal: "{count} parent tasks",
77
+ searchEmptyTitle: "No matching results.",
78
+ searchEmptyCopy: "Try changing the search term or status filter.",
79
+ emptyWithDiagnosticsTitle: "No parent tasks can be displayed.",
80
+ emptyWithDiagnosticsCopy: "The local monitor received {count} activity records but could not apply them to displayable parent tasks.",
81
+ emptyTitle: "No task activity has been received in this observation window.",
82
+ emptyCopy: "The local monitor is connected. This result alone does not mean that Codex has no running parent tasks or subagents.",
83
+ emptyGuidanceTitle: "If work does not appear",
84
+ automaticTracking: "You do not need to enter or register task IDs. Activity from trusted hooks is added automatically.",
85
+ emptyStep1: "After installing the plugin, fully restart the official Codex app.",
86
+ emptyStep2: "In a new task, review and explicitly trust the Codex Agent View hook command.",
87
+ emptyStep3: "After trusting it, start a new task and run subagents. New activity is added automatically.",
88
+ observationBoundary: "Observation starts with the first trusted hook event. Earlier activity and activity missed while local collection was stopped cannot be replayed; restarting collection opens a new observation window.",
89
+ diagnosticsCount: "Validation information · {count}",
90
+ diagnosticOccurrences: "{count} occurrences",
91
+ disconnectedTitle: "Local status disconnected; retrying.",
92
+ authTitle: "This live view cannot be authenticated.",
93
+ retryWithState: "Reconnecting automatically every 2 seconds while keeping the last good state visible.",
94
+ retryWithoutState: "Reconnecting automatically every 2 seconds. You can leave this view open in the Codex app.",
95
+ missingToken: "This tab has no access token. Ask Codex Agent View to open the live view again in the Codex app.",
96
+ expiredToken: "This live view credential is no longer valid. Ask Codex Agent View to open the live view again in the Codex app.",
97
+ requestFailed: "Status request failed ({status})",
98
+ unknownConnectionError: "An unknown connection error occurred.",
99
+ offline: "This device is offline.",
100
+ invalidState: "The status response format is invalid.",
101
+ durationSeconds: "{count}s",
102
+ durationMinutes: "{count}m",
103
+ durationHours: "{count}h",
104
+ durationHoursMinutes: "{hours}h {minutes}m",
105
+ toolFallback: "Tool · {name}",
106
+ activityFallback: "Activity · {name}",
107
+ nameUnknown: "Unknown name",
108
+ activityUnknown: "Unknown",
109
+ approvalRequest: "{tool} approval requested",
110
+ agentStarted: "Subagent {ordinal} started",
111
+ agentStopped: "Subagent {ordinal} completed",
112
+ activitySessionStarted: "Observation started",
113
+ activitySessionEnded: "Observation ended",
114
+ activityTurnStarted: "Parent task started",
115
+ activityTurnStopped: "Parent task response completed",
116
+ activitySubagentStarted: "Subagent started",
117
+ activitySubagentStopped: "Subagent completed",
118
+ activityToolStarted: "Tool activity",
119
+ activityToolCompleted: "Tool activity",
120
+ activityPermissionRequested: "User approval requested",
121
+ toolApplyPatch: "File edit",
122
+ toolBash: "Terminal activity",
123
+ toolFollowup: "Subagent follow-up requested",
124
+ toolInterrupt: "Subagent interruption requested",
125
+ toolList: "Subagent status checked",
126
+ toolMessage: "Message sent to subagent",
127
+ toolSpawn: "Subagent started",
128
+ toolWaitAgent: "Waiting for subagent",
129
+ toolExec: "Terminal activity",
130
+ toolUserInput: "User input requested",
131
+ toolWait: "Waiting for completion",
132
+ toolStdin: "Terminal input",
133
+ roleExplorer: "Research",
134
+ roleReviewer: "Review",
135
+ roleWorker: "Implementation",
136
+ roleDefault: "General agent",
137
+ roleUnknown: "Not reported",
138
+ }),
139
+ ko: Object.freeze({
140
+ metaDescription: "공식 Codex 앱의 부모 작업과 작업 에이전트 상태를 로컬에서 읽기 전용으로 확인합니다.",
141
+ skipToContent: "본문으로 건너뛰기",
142
+ brandHome: "Codex Agent View 홈",
143
+ brandSubtitle: "로컬 읽기 전용 모니터",
144
+ languageLabel: "언어",
145
+ connectionConnecting: "로컬 상태 연결 중",
146
+ connectionConnected: "로컬 모니터 연결됨",
147
+ connectionRetrying: "연결 끊김 · 재시도 중",
148
+ authenticationRequired: "실시간 화면 인증 필요",
149
+ heroEyebrow: "로컬 · 읽기 전용",
150
+ heroTitle: "작업 흐름을 한눈에",
151
+ heroCopy: "공식 Codex 앱은 그대로 두고, 부모 작업과 작업 에이전트의 현재 상태만 로컬에서 확인합니다.",
152
+ freshnessAria: "상태 갱신 정보",
153
+ lastUpdatedLabel: "마지막 갱신",
154
+ notYet: "아직 없음",
155
+ refreshIntervalLabel: "갱신 주기",
156
+ twoSeconds: "2초",
157
+ metricsAria: "작업 상태 요약",
158
+ parentTasks: "부모 작업",
159
+ currentlyObserved: "현재 관찰 중",
160
+ runningAgents: "실행 중 에이전트",
161
+ workingNow: "작업 수행 중",
162
+ waitingStatus: "대기 상태",
163
+ waitingExplanation: "사용자 응답 또는 다음 작업 대기",
164
+ completedAgents: "완료 에이전트",
165
+ currentView: "현재 화면 기준",
166
+ liveWork: "실시간 작업",
167
+ sessionsHeading: "부모 작업과 작업 에이전트",
168
+ loadingState: "상태를 불러오는 중입니다.",
169
+ toolbarAria: "자동 수신된 부모 작업과 작업 에이전트 목록 필터",
170
+ searchLabel: "목록 필터 (선택)",
171
+ searchPlaceholder: "부모 작업·작업 에이전트 목록에서 찾기",
172
+ statusFilterLabel: "상태 필터 (선택)",
173
+ statusAll: "모든 상태",
174
+ statusRunning: "실행 중",
175
+ statusWaiting: "대기",
176
+ statusCompleted: "완료",
177
+ statusUnknown: "알 수 없음",
178
+ connectingCopy: "Codex 앱의 로컬 상태에 연결하고 있습니다.",
179
+ sessionListAria: "Codex 부모 작업 목록",
180
+ privacyPrompt: "이 화면은 사용자 요청 내용과 도구 입력 내용을 표시하지 않습니다.",
181
+ privacyLocal: "데이터는 이 기기의 로컬 모니터에서만 읽습니다.",
182
+ timeUnknown: "시간 정보 없음",
183
+ startedUnknown: "시작 시간 없음",
184
+ noReceivedActivity: "수신된 활동 없음",
185
+ parentTask: "부모 작업",
186
+ projectUnknown: "프로젝트 정보 없음",
187
+ recentActivity: "최근 활동",
188
+ recent: "최근",
189
+ subagentsCount: "작업 에이전트 · {count}",
190
+ subagentName: "작업 에이전트 {ordinal}",
191
+ agentProfile: "역할/프로필 · {profile}",
192
+ agentProfileNote: "검증된 hook은 할당 작업 설명을 제공하지 않습니다. 확인 가능한 역할/프로필만 표시합니다.",
193
+ noSubagents: "이 부모 작업에서 관찰된 작업 에이전트가 없습니다.",
194
+ noRecentActivity: "표시할 최근 활동이 없습니다.",
195
+ technicalInfo: "기술 정보",
196
+ sessionId: "세션 ID",
197
+ agentId: "에이전트 ID",
198
+ rawProfile: "원본 역할/프로필",
199
+ rawEvent: "원본 이벤트",
200
+ rawTool: "원본 도구",
201
+ retry: "다시 연결",
202
+ resultsFiltered: "전체 {total}개 중 {visible}개 표시",
203
+ resultsTotal: "{count}개 부모 작업",
204
+ searchEmptyTitle: "검색 결과가 없습니다.",
205
+ searchEmptyCopy: "검색어나 상태 필터를 바꿔 보세요.",
206
+ emptyWithDiagnosticsTitle: "표시 가능한 부모 작업이 없습니다.",
207
+ emptyWithDiagnosticsCopy: "로컬 모니터가 활동 정보 {count}건을 받았지만 표시 가능한 부모 작업으로 적용하지 못했습니다.",
208
+ emptyTitle: "이 관찰 화면에서 수신된 작업 활동이 없습니다.",
209
+ emptyCopy: "로컬 모니터 연결은 정상입니다. 이 결과만으로 Codex에 실행 중인 부모 작업이나 작업 에이전트가 없다고 판단할 수 없습니다.",
210
+ emptyGuidanceTitle: "표시되지 않을 때 확인 순서",
211
+ automaticTracking: "작업 ID를 입력하거나 작업별로 등록할 필요가 없습니다. 신뢰한 hook의 작업 활동이 이 목록에 자동으로 추가됩니다.",
212
+ emptyStep1: "플러그인을 설치한 뒤 공식 Codex 앱을 완전히 재시작했는지 확인합니다.",
213
+ emptyStep2: "새 작업에서 표시되는 Codex Agent View hook 명령을 검토하고 직접 신뢰합니다.",
214
+ emptyStep3: "신뢰 설정 후 새 작업을 시작해 작업 에이전트를 실행합니다. 새 활동은 이 목록에 자동으로 추가됩니다.",
215
+ observationBoundary: "관찰 화면은 첫 번째로 신뢰한 hook을 받은 시점부터 시작합니다. 그 전에 이미 지나간 활동과 로컬 상태 수집이 중단된 동안의 활동은 재생되지 않으며, 수집이 다시 시작되면 새 관찰 화면이 열립니다.",
216
+ diagnosticsCount: "검증 정보 · {count}건",
217
+ diagnosticOccurrences: "{count}건",
218
+ disconnectedTitle: "로컬 상태 연결이 끊겨 다시 시도 중입니다.",
219
+ authTitle: "이 실시간 화면을 인증할 수 없습니다.",
220
+ retryWithState: "2초마다 자동으로 다시 연결합니다. 마지막 정상 상태를 계속 표시합니다.",
221
+ retryWithoutState: "2초마다 자동으로 다시 연결합니다. Codex 앱에서 이 화면을 그대로 두어도 됩니다.",
222
+ missingToken: "이 탭에는 접근 토큰이 없습니다. Codex 앱에서 Codex Agent View의 실시간 화면 열기를 다시 요청하세요.",
223
+ expiredToken: "이 실시간 화면의 인증이 더 이상 유효하지 않습니다. Codex 앱에서 Codex Agent View의 실시간 화면 열기를 다시 요청하세요.",
224
+ requestFailed: "상태 요청 실패 ({status})",
225
+ unknownConnectionError: "알 수 없는 연결 오류가 발생했습니다.",
226
+ offline: "이 기기가 오프라인입니다.",
227
+ invalidState: "상태 응답 형식이 올바르지 않습니다.",
228
+ durationSeconds: "{count}초",
229
+ durationMinutes: "{count}분",
230
+ durationHours: "{count}시간",
231
+ durationHoursMinutes: "{hours}시간 {minutes}분",
232
+ toolFallback: "도구 · {name}",
233
+ activityFallback: "활동 · {name}",
234
+ nameUnknown: "이름 미상",
235
+ activityUnknown: "알 수 없음",
236
+ approvalRequest: "{tool} 승인 요청",
237
+ agentStarted: "작업 에이전트 {ordinal} 시작",
238
+ agentStopped: "작업 에이전트 {ordinal} 완료",
239
+ activitySessionStarted: "관찰 시작",
240
+ activitySessionEnded: "관찰 종료",
241
+ activityTurnStarted: "부모 작업 시작",
242
+ activityTurnStopped: "부모 작업 응답 완료",
243
+ activitySubagentStarted: "작업 에이전트 시작",
244
+ activitySubagentStopped: "작업 에이전트 완료",
245
+ activityToolStarted: "도구 작업",
246
+ activityToolCompleted: "도구 작업",
247
+ activityPermissionRequested: "사용자 승인 요청",
248
+ toolApplyPatch: "파일 수정",
249
+ toolBash: "터미널 작업",
250
+ toolFollowup: "에이전트 후속 작업 요청",
251
+ toolInterrupt: "에이전트 작업 중단 요청",
252
+ toolList: "에이전트 상태 확인",
253
+ toolMessage: "에이전트에게 메시지 전달",
254
+ toolSpawn: "작업 에이전트 시작",
255
+ toolWaitAgent: "에이전트 응답 대기",
256
+ toolExec: "터미널 작업",
257
+ toolUserInput: "사용자 입력 요청",
258
+ toolWait: "작업 완료 대기",
259
+ toolStdin: "터미널 입력",
260
+ roleExplorer: "조사",
261
+ roleReviewer: "검토",
262
+ roleWorker: "구현",
263
+ roleDefault: "일반 에이전트",
264
+ roleUnknown: "보고되지 않음",
265
+ }),
266
+ es: Object.freeze({
267
+ metaDescription: "Estado local y de solo lectura de tareas y subagentes de Codex.",
268
+ skipToContent: "Saltar al contenido",
269
+ brandHome: "Inicio de Codex Agent View",
270
+ brandSubtitle: "Monitor local de solo lectura",
271
+ languageLabel: "Idioma",
272
+ connectionConnecting: "Conectando al estado local",
273
+ connectionConnected: "Monitor local conectado",
274
+ connectionRetrying: "Desconectado · reintentando",
275
+ authenticationRequired: "Se requiere autenticar la vista",
276
+ heroEyebrow: "LOCAL · SOLO LECTURA",
277
+ heroTitle: "Observa el trabajo activo de un vistazo",
278
+ heroCopy: "Sigue usando la aplicación oficial de Codex mientras consultas localmente el estado de las tareas principales y los subagentes.",
279
+ freshnessAria: "Información de actualización",
280
+ lastUpdatedLabel: "Última actualización",
281
+ notYet: "Aún no",
282
+ refreshIntervalLabel: "Intervalo de actualización",
283
+ twoSeconds: "2 segundos",
284
+ metricsAria: "Resumen del estado de las tareas",
285
+ parentTasks: "Tareas principales",
286
+ currentlyObserved: "En observación",
287
+ runningAgents: "Subagentes activos",
288
+ workingNow: "Trabajando ahora",
289
+ waitingStatus: "En espera",
290
+ waitingExplanation: "Esperando una respuesta o el siguiente paso",
291
+ completedAgents: "Subagentes completados",
292
+ currentView: "En la vista actual",
293
+ liveWork: "TRABAJO EN VIVO",
294
+ sessionsHeading: "Tareas principales y subagentes",
295
+ loadingState: "Cargando el estado.",
296
+ toolbarAria: "Filtrar tareas principales y subagentes observados automáticamente",
297
+ searchLabel: "Filtrar lista (opcional)",
298
+ searchPlaceholder: "Buscar una tarea principal o un subagente",
299
+ statusFilterLabel: "Filtrar por estado (opcional)",
300
+ statusAll: "Todos los estados",
301
+ statusRunning: "En ejecución",
302
+ statusWaiting: "En espera",
303
+ statusCompleted: "Completado",
304
+ statusUnknown: "Desconocido",
305
+ connectingCopy: "Conectando al estado local de la aplicación Codex.",
306
+ sessionListAria: "Lista de tareas principales de Codex",
307
+ privacyPrompt: "Esta vista no muestra solicitudes del usuario ni entradas de herramientas.",
308
+ privacyLocal: "Los datos se leen únicamente del monitor local de este dispositivo.",
309
+ timeUnknown: "Hora no disponible",
310
+ startedUnknown: "Hora de inicio no disponible",
311
+ noReceivedActivity: "No se recibió actividad",
312
+ parentTask: "TAREA PRINCIPAL",
313
+ projectUnknown: "Proyecto no disponible",
314
+ recentActivity: "Actividad reciente",
315
+ recent: "Reciente",
316
+ subagentsCount: "Subagentes · {count}",
317
+ subagentName: "Subagente {ordinal}",
318
+ agentProfile: "Rol/perfil · {profile}",
319
+ agentProfileNote: "Los hooks verificados no proporcionan descripciones de la tarea asignada. Se muestra el rol/perfil cuando está disponible.",
320
+ noSubagents: "No se observaron subagentes para esta tarea principal.",
321
+ noRecentActivity: "No hay actividad reciente que mostrar.",
322
+ technicalInfo: "Información técnica",
323
+ sessionId: "ID de sesión",
324
+ agentId: "ID del agente",
325
+ rawProfile: "Rol/perfil original",
326
+ rawEvent: "Evento original",
327
+ rawTool: "Herramienta original",
328
+ retry: "Reconectar",
329
+ resultsFiltered: "Mostrando {visible} de {total}",
330
+ resultsTotal: "{count} tareas principales",
331
+ searchEmptyTitle: "No hay resultados.",
332
+ searchEmptyCopy: "Prueba otra búsqueda o filtro de estado.",
333
+ emptyWithDiagnosticsTitle: "No hay tareas principales que mostrar.",
334
+ emptyWithDiagnosticsCopy: "El monitor local recibió {count} registros de actividad, pero no pudo aplicarlos a tareas visibles.",
335
+ emptyTitle: "No se recibió actividad en esta ventana de observación.",
336
+ emptyCopy: "El monitor local está conectado. Este resultado no implica por sí solo que Codex no tenga tareas o subagentes activos.",
337
+ emptyGuidanceTitle: "Si el trabajo no aparece",
338
+ automaticTracking: "No es necesario introducir ni registrar IDs de tareas. La actividad de hooks confiables se añade automáticamente.",
339
+ emptyStep1: "Tras instalar el plugin, reinicia por completo la aplicación oficial de Codex.",
340
+ emptyStep2: "En una tarea nueva, revisa y autoriza explícitamente el comando hook de Codex Agent View.",
341
+ emptyStep3: "Después, inicia una tarea nueva y ejecuta subagentes. La actividad se añadirá automáticamente.",
342
+ observationBoundary: "La observación comienza con el primer evento de un hook autorizado. La actividad anterior o perdida mientras la recopilación local estuvo detenida no se puede reproducir; al reiniciarla se abre una ventana nueva.",
343
+ diagnosticsCount: "Información de validación · {count}",
344
+ diagnosticOccurrences: "{count} apariciones",
345
+ disconnectedTitle: "Se perdió la conexión local; reintentando.",
346
+ authTitle: "No se puede autenticar esta vista en vivo.",
347
+ retryWithState: "Se reconecta automáticamente cada 2 segundos y mantiene visible el último estado válido.",
348
+ retryWithoutState: "Se reconecta automáticamente cada 2 segundos. Puedes dejar esta vista abierta en Codex.",
349
+ missingToken: "Esta pestaña no tiene un token de acceso. Pide a Codex Agent View que vuelva a abrir la vista en Codex.",
350
+ expiredToken: "La credencial de esta vista ya no es válida. Pide a Codex Agent View que vuelva a abrir la vista en Codex.",
351
+ requestFailed: "Falló la solicitud de estado ({status})",
352
+ unknownConnectionError: "Se produjo un error de conexión desconocido.",
353
+ offline: "Este dispositivo está sin conexión.",
354
+ invalidState: "El formato de la respuesta de estado no es válido.",
355
+ durationSeconds: "{count} s",
356
+ durationMinutes: "{count} min",
357
+ durationHours: "{count} h",
358
+ durationHoursMinutes: "{hours} h {minutes} min",
359
+ toolFallback: "Herramienta · {name}",
360
+ activityFallback: "Actividad · {name}",
361
+ nameUnknown: "Nombre desconocido",
362
+ activityUnknown: "Desconocida",
363
+ approvalRequest: "Se solicitó aprobación para {tool}",
364
+ agentStarted: "Subagente {ordinal} iniciado",
365
+ agentStopped: "Subagente {ordinal} completado",
366
+ activitySessionStarted: "Observación iniciada",
367
+ activitySessionEnded: "Observación finalizada",
368
+ activityTurnStarted: "Tarea principal iniciada",
369
+ activityTurnStopped: "Respuesta de la tarea completada",
370
+ activitySubagentStarted: "Subagente iniciado",
371
+ activitySubagentStopped: "Subagente completado",
372
+ activityToolStarted: "Actividad de herramienta",
373
+ activityToolCompleted: "Actividad de herramienta",
374
+ activityPermissionRequested: "Se solicitó aprobación",
375
+ toolApplyPatch: "Edición de archivo",
376
+ toolBash: "Actividad de terminal",
377
+ toolFollowup: "Seguimiento del subagente solicitado",
378
+ toolInterrupt: "Interrupción del subagente solicitada",
379
+ toolList: "Estado de subagentes consultado",
380
+ toolMessage: "Mensaje enviado al subagente",
381
+ toolSpawn: "Subagente iniciado",
382
+ toolWaitAgent: "Esperando al subagente",
383
+ toolExec: "Actividad de terminal",
384
+ toolUserInput: "Se solicitó entrada del usuario",
385
+ toolWait: "Esperando finalización",
386
+ toolStdin: "Entrada de terminal",
387
+ roleExplorer: "Investigación",
388
+ roleReviewer: "Revisión",
389
+ roleWorker: "Implementación",
390
+ roleDefault: "Agente general",
391
+ roleUnknown: "No informado",
392
+ }),
393
+ });
5
394
 
6
- const STATUS_LABELS = Object.freeze({
7
- running: "실행 중",
8
- waiting: "대기",
9
- completed: "완료",
10
- unknown: "알 수 없음",
395
+ const STATUS_KEYS = Object.freeze({
396
+ running: "statusRunning",
397
+ waiting: "statusWaiting",
398
+ completed: "statusCompleted",
399
+ unknown: "statusUnknown",
11
400
  });
12
401
 
13
402
  const STATUS_ORDER = Object.freeze({
@@ -17,45 +406,86 @@ const STATUS_ORDER = Object.freeze({
17
406
  completed: 3,
18
407
  });
19
408
 
20
- const ACTIVITY_LABELS = Object.freeze({
21
- session_started: "관찰 시작",
22
- session_ended: "관찰 종료",
23
- turn_started: "부모 작업 시작",
24
- turn_stopped: "부모 작업 응답 완료",
25
- subagent_started: "작업 에이전트 시작",
26
- subagent_stopped: "작업 에이전트 완료",
27
- tool_started: "도구 작업",
28
- tool_completed: "도구 작업",
29
- permission_requested: "사용자 승인 요청",
409
+ const ACTIVITY_KEYS = Object.freeze({
410
+ session_started: "activitySessionStarted",
411
+ session_ended: "activitySessionEnded",
412
+ turn_started: "activityTurnStarted",
413
+ turn_stopped: "activityTurnStopped",
414
+ subagent_started: "activitySubagentStarted",
415
+ subagent_stopped: "activitySubagentStopped",
416
+ tool_started: "activityToolStarted",
417
+ tool_completed: "activityToolCompleted",
418
+ permission_requested: "activityPermissionRequested",
30
419
  });
31
420
 
32
- const TOOL_LABELS = Object.freeze({
33
- apply_patch: "파일 수정",
34
- bash: "터미널 작업",
35
- collaborationfollowup_task: "에이전트 후속 작업 요청",
36
- collaborationinterrupt_agent: "에이전트 작업 중단 요청",
37
- collaborationlist_agents: "에이전트 상태 확인",
38
- collaborationsend_message: "에이전트에게 메시지 전달",
39
- collaborationspawn_agent: "작업 에이전트 시작",
40
- collaborationwait_agent: "에이전트 응답 대기",
41
- exec_command: "터미널 작업",
42
- request_user_input: "사용자 입력 요청",
43
- wait: "작업 완료 대기",
44
- write_stdin: "터미널 입력",
421
+ const TOOL_KEYS = Object.freeze({
422
+ apply_patch: "toolApplyPatch",
423
+ bash: "toolBash",
424
+ collaborationfollowup_task: "toolFollowup",
425
+ collaborationinterrupt_agent: "toolInterrupt",
426
+ collaborationlist_agents: "toolList",
427
+ collaborationsend_message: "toolMessage",
428
+ collaborationspawn_agent: "toolSpawn",
429
+ collaborationwait_agent: "toolWaitAgent",
430
+ exec_command: "toolExec",
431
+ request_user_input: "toolUserInput",
432
+ wait: "toolWait",
433
+ write_stdin: "toolStdin",
45
434
  });
46
435
 
47
- const AGENT_ROLE_LABELS = Object.freeze({
48
- explorer: "조사 담당",
49
- reviewer: "검토 담당",
50
- worker: "작업 담당",
436
+ const AGENT_ROLE_KEYS = Object.freeze({
437
+ explorer: "roleExplorer",
438
+ reviewer: "roleReviewer",
439
+ worker: "roleWorker",
440
+ default: "roleDefault",
441
+ unknown: "roleUnknown",
51
442
  });
52
443
 
53
- const HIDDEN_AGENT_ROLES = new Set(["", "default", "unknown"]);
54
444
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
55
445
 
56
- function consumeAccessToken() {
57
- const fragment = new URLSearchParams(window.location.hash.slice(1));
58
- const fragmentToken = fragment.get("token")?.trim() || "";
446
+ function readStoredLanguage() {
447
+ try {
448
+ const stored = window.localStorage.getItem(LANGUAGE_KEY);
449
+ return SUPPORTED_LANGUAGES.has(stored) ? stored : "en";
450
+ } catch {
451
+ return "en";
452
+ }
453
+ }
454
+
455
+ let currentLanguage = readStoredLanguage();
456
+
457
+ function t(key, replacements = {}) {
458
+ const template = MESSAGES[currentLanguage][key] ?? MESSAGES.en[key] ?? key;
459
+ return Object.entries(replacements).reduce(
460
+ (result, [name, value]) => result.replaceAll(`{${name}}`, String(value)),
461
+ template,
462
+ );
463
+ }
464
+
465
+ function isExactLiveFragment(entries) {
466
+ const tokenEntries = entries.filter(([key]) => key === "token");
467
+ const excludeEntries = entries.filter(([key]) => key === "exclude");
468
+ return (
469
+ entries.length === tokenEntries.length + excludeEntries.length &&
470
+ tokenEntries.length === 1 &&
471
+ excludeEntries.length <= 1 &&
472
+ VIEWER_TOKEN_PATTERN.test(tokenEntries[0][1]) &&
473
+ (excludeEntries.length === 0 || CANONICAL_SESSION_ID_PATTERN.test(excludeEntries[0][1]))
474
+ );
475
+ }
476
+
477
+ function consumeLiveContext() {
478
+ const hasFragment = window.location.hash.length > 1;
479
+ const entries = hasFragment
480
+ ? [...new URLSearchParams(window.location.hash.slice(1)).entries()]
481
+ : [];
482
+ const validFragment = hasFragment && isExactLiveFragment(entries);
483
+ const fragmentToken = validFragment
484
+ ? entries.find(([key]) => key === "token")[1]
485
+ : "";
486
+ const fragmentExclude = validFragment
487
+ ? entries.find(([key]) => key === "exclude")?.[1] || ""
488
+ : "";
59
489
 
60
490
  if (window.location.hash) {
61
491
  window.history.replaceState(
@@ -66,25 +496,39 @@ function consumeAccessToken() {
66
496
  }
67
497
 
68
498
  let token = fragmentToken;
499
+ let excludedSessionId = fragmentExclude;
69
500
  try {
70
- if (fragmentToken) {
501
+ if (validFragment) {
71
502
  window.sessionStorage.setItem(SESSION_TOKEN_KEY, fragmentToken);
503
+ if (fragmentExclude) {
504
+ window.sessionStorage.setItem(EXCLUDED_SESSION_KEY, fragmentExclude);
505
+ } else {
506
+ window.sessionStorage.removeItem(EXCLUDED_SESSION_KEY);
507
+ }
72
508
  } else {
73
509
  token = window.sessionStorage.getItem(SESSION_TOKEN_KEY)?.trim() || "";
510
+ excludedSessionId = window.sessionStorage.getItem(EXCLUDED_SESSION_KEY)?.trim() || "";
74
511
  }
75
512
  } catch {
76
513
  // Storage can be unavailable in hardened browser contexts. The fragment
77
514
  // token remains usable for this page load and is never copied elsewhere.
78
515
  }
79
516
 
80
- return token;
517
+ return {
518
+ accessToken: VIEWER_TOKEN_PATTERN.test(token) ? token : "",
519
+ excludedSessionId: CANONICAL_SESSION_ID_PATTERN.test(excludedSessionId)
520
+ ? excludedSessionId
521
+ : "",
522
+ };
81
523
  }
82
524
 
83
- const accessToken = consumeAccessToken();
525
+ const { accessToken, excludedSessionId } = consumeLiveContext();
84
526
 
85
527
  const elements = Object.freeze({
86
528
  connectionStatus: document.querySelector("#connection-status"),
87
529
  connectionLabel: document.querySelector("#connection-label"),
530
+ language: document.querySelector("#language-select"),
531
+ metaDescription: document.querySelector("#meta-description"),
88
532
  lastUpdated: document.querySelector("#last-updated"),
89
533
  metricSessions: document.querySelector("#metric-sessions"),
90
534
  metricRunning: document.querySelector("#metric-running"),
@@ -98,12 +542,43 @@ const elements = Object.freeze({
98
542
  sessionList: document.querySelector("#session-list"),
99
543
  });
100
544
 
545
+ function applyStaticTranslations() {
546
+ document.documentElement.lang = currentLanguage;
547
+ elements.language.value = currentLanguage;
548
+ elements.metaDescription.content = t("metaDescription");
549
+ for (const element of document.querySelectorAll("[data-i18n]")) {
550
+ element.textContent = t(element.dataset.i18n);
551
+ }
552
+ for (const element of document.querySelectorAll("[data-i18n-aria-label]")) {
553
+ element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
554
+ }
555
+ for (const element of document.querySelectorAll("[data-i18n-placeholder]")) {
556
+ element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
557
+ }
558
+ }
559
+
560
+ function setLanguage(value) {
561
+ currentLanguage = SUPPORTED_LANGUAGES.has(value) ? value : "en";
562
+ try {
563
+ window.localStorage.setItem(LANGUAGE_KEY, currentLanguage);
564
+ } catch {
565
+ // The selected language still applies for this page load.
566
+ }
567
+ applyStaticTranslations();
568
+ setConnectionStatus(
569
+ elements.connectionStatus.dataset.status,
570
+ viewState.authenticationFailed ? t("authenticationRequired") : "",
571
+ );
572
+ render();
573
+ }
574
+
101
575
  const viewState = {
102
576
  updatedAtMs: null,
103
577
  sessions: [],
104
578
  diagnostics: [],
105
579
  hasLoaded: false,
106
580
  errorMessage: "",
581
+ errorKey: "",
107
582
  canRetry: true,
108
583
  authenticationFailed: false,
109
584
  requestInFlight: false,
@@ -139,26 +614,27 @@ function normalizedLookupKey(value) {
139
614
 
140
615
  function formatAgentRole(agentType) {
141
616
  const key = normalizedLookupKey(agentType);
142
- if (HIDDEN_AGENT_ROLES.has(key)) {
143
- return "";
144
- }
145
- return AGENT_ROLE_LABELS[key] || sanitizedFallbackLabel(agentType, "");
617
+ return AGENT_ROLE_KEYS[key]
618
+ ? t(AGENT_ROLE_KEYS[key])
619
+ : sanitizedFallbackLabel(agentType, t("roleUnknown"));
146
620
  }
147
621
 
148
622
  function formatToolLabel(toolName) {
149
623
  const key = normalizedLookupKey(toolName);
150
- return TOOL_LABELS[key] || `도구 · ${sanitizedFallbackLabel(toolName, "이름 미상")}`;
624
+ return TOOL_KEYS[key]
625
+ ? t(TOOL_KEYS[key])
626
+ : t("toolFallback", { name: sanitizedFallbackLabel(toolName, t("nameUnknown")) });
151
627
  }
152
628
 
153
629
  function formatActivityLabel(activity) {
154
630
  if (activity.eventName === "subagent_started" && activity.agentOrdinal !== null) {
155
- return `작업 에이전트 ${activity.agentOrdinal} 시작`;
631
+ return t("agentStarted", { ordinal: activity.agentOrdinal });
156
632
  }
157
633
  if (activity.eventName === "subagent_stopped" && activity.agentOrdinal !== null) {
158
- return `작업 에이전트 ${activity.agentOrdinal} 완료`;
634
+ return t("agentStopped", { ordinal: activity.agentOrdinal });
159
635
  }
160
636
  if (activity.eventName === "permission_requested" && activity.toolName) {
161
- return `${formatToolLabel(activity.toolName)} 승인 요청`;
637
+ return t("approvalRequest", { tool: formatToolLabel(activity.toolName) });
162
638
  }
163
639
  if (
164
640
  (activity.eventName === "tool_started" || activity.eventName === "tool_completed") &&
@@ -166,8 +642,11 @@ function formatActivityLabel(activity) {
166
642
  ) {
167
643
  return formatToolLabel(activity.toolName);
168
644
  }
169
- return ACTIVITY_LABELS[activity.eventName] ||
170
- `활동 · ${sanitizedFallbackLabel(activity.eventName, "알 수 없음")}`;
645
+ return ACTIVITY_KEYS[activity.eventName]
646
+ ? t(ACTIVITY_KEYS[activity.eventName])
647
+ : t("activityFallback", {
648
+ name: sanitizedFallbackLabel(activity.eventName, t("activityUnknown")),
649
+ });
171
650
  }
172
651
 
173
652
  function safeTimestamp(value) {
@@ -337,7 +816,7 @@ function normalizeState(value) {
337
816
  !Array.isArray(value.sessions) ||
338
817
  !Array.isArray(value.diagnostics)
339
818
  ) {
340
- throw new TypeError("상태 응답 형식이 올바르지 않습니다.");
819
+ throw new TypeError(t("invalidState"));
341
820
  }
342
821
 
343
822
  return {
@@ -363,10 +842,10 @@ function compareSessions(left, right) {
363
842
 
364
843
  function formatDateTime(timestampMs) {
365
844
  if (timestampMs === null) {
366
- return "시간 정보 없음";
845
+ return t("timeUnknown");
367
846
  }
368
847
 
369
- return new Intl.DateTimeFormat("ko-KR", {
848
+ return new Intl.DateTimeFormat(currentLanguage, {
370
849
  dateStyle: "medium",
371
850
  timeStyle: "medium",
372
851
  }).format(new Date(timestampMs));
@@ -374,12 +853,12 @@ function formatDateTime(timestampMs) {
374
853
 
375
854
  function formatRelativeTime(timestampMs) {
376
855
  if (timestampMs === null) {
377
- return "시간 정보 없음";
856
+ return t("timeUnknown");
378
857
  }
379
858
 
380
859
  const differenceSeconds = Math.round((timestampMs - Date.now()) / 1_000);
381
860
  const absoluteSeconds = Math.abs(differenceSeconds);
382
- const formatter = new Intl.RelativeTimeFormat("ko", { numeric: "auto" });
861
+ const formatter = new Intl.RelativeTimeFormat(currentLanguage, { numeric: "auto" });
383
862
 
384
863
  if (absoluteSeconds < 60) {
385
864
  return formatter.format(differenceSeconds, "second");
@@ -395,23 +874,25 @@ function formatRelativeTime(timestampMs) {
395
874
 
396
875
  function formatDuration(startedAtMs, stoppedAtMs) {
397
876
  if (startedAtMs === null) {
398
- return "시작 시간 없음";
877
+ return t("startedUnknown");
399
878
  }
400
879
 
401
880
  const endAtMs = stoppedAtMs ?? Date.now();
402
881
  const seconds = Math.max(0, Math.floor((endAtMs - startedAtMs) / 1_000));
403
882
  if (seconds < 60) {
404
- return `${seconds}초`;
883
+ return t("durationSeconds", { count: seconds });
405
884
  }
406
885
 
407
886
  const minutes = Math.floor(seconds / 60);
408
887
  if (minutes < 60) {
409
- return `${minutes}분`;
888
+ return t("durationMinutes", { count: minutes });
410
889
  }
411
890
 
412
891
  const hours = Math.floor(minutes / 60);
413
892
  const remainingMinutes = minutes % 60;
414
- return remainingMinutes ? `${hours}시간 ${remainingMinutes}분` : `${hours}시간`;
893
+ return remainingMinutes
894
+ ? t("durationHoursMinutes", { hours, minutes: remainingMinutes })
895
+ : t("durationHours", { count: hours });
415
896
  }
416
897
 
417
898
  function createStatusBadge(status) {
@@ -424,7 +905,7 @@ function createStatusBadge(status) {
424
905
  dot.setAttribute("aria-hidden", "true");
425
906
 
426
907
  const label = document.createElement("span");
427
- label.textContent = STATUS_LABELS[status];
908
+ label.textContent = t(STATUS_KEYS[status]);
428
909
 
429
910
  badge.append(dot, label);
430
911
  return badge;
@@ -439,7 +920,7 @@ function createTime(timestampMs, prefix) {
439
920
  }
440
921
 
441
922
  if (timestampMs === null) {
442
- wrapper.append("시간 정보 없음");
923
+ wrapper.append(t("timeUnknown"));
443
924
  return wrapper;
444
925
  }
445
926
 
@@ -451,12 +932,13 @@ function createTime(timestampMs, prefix) {
451
932
  return wrapper;
452
933
  }
453
934
 
454
- function createTechnicalDetails(rows) {
455
- const details = document.createElement("details");
456
- details.className = "technical-details";
935
+ function createTechnicalInfo(rows) {
936
+ const info = document.createElement("div");
937
+ info.className = "technical-info";
457
938
 
458
- const summary = document.createElement("summary");
459
- summary.textContent = "기술 정보";
939
+ const heading = document.createElement("p");
940
+ heading.className = "technical-info-title";
941
+ heading.textContent = t("technicalInfo");
460
942
  const list = document.createElement("dl");
461
943
 
462
944
  for (const [label, value] of rows) {
@@ -472,8 +954,8 @@ function createTechnicalDetails(rows) {
472
954
  list.append(term, description);
473
955
  }
474
956
 
475
- details.append(summary, list);
476
- return details;
957
+ info.append(heading, list);
958
+ return info;
477
959
  }
478
960
 
479
961
  function createAgentItem(agent) {
@@ -488,31 +970,27 @@ function createAgentItem(agent) {
488
970
  identity.className = "agent-identity";
489
971
  const name = document.createElement("strong");
490
972
  name.className = "agent-name";
491
- name.textContent = `작업 에이전트 ${agent.ordinal}`;
973
+ name.textContent = t("subagentName", { ordinal: agent.ordinal });
492
974
  identity.append(name);
493
975
 
494
976
  const roleLabel = formatAgentRole(agent.agentType);
495
- if (roleLabel) {
496
- const role = document.createElement("span");
497
- role.className = "agent-role";
498
- role.textContent = roleLabel;
499
- identity.append(role);
500
- }
977
+ const role = document.createElement("span");
978
+ role.className = "agent-role";
979
+ role.textContent = t("agentProfile", { profile: roleLabel });
980
+ identity.append(role);
501
981
  heading.append(identity, createStatusBadge(agent.status));
502
982
 
503
983
  const metadata = document.createElement("div");
504
984
  metadata.className = "agent-metadata";
505
985
  metadata.append(
506
- createTime(agent.lastActivityAtMs, "최근"),
986
+ createTime(agent.lastActivityAtMs, t("recent")),
507
987
  document.createTextNode(` · ${formatDuration(agent.startedAtMs, agent.stoppedAtMs)}`),
508
988
  );
509
989
 
510
- const technicalRows = [["에이전트 ID", agent.agentId]];
511
- if (roleLabel) {
512
- technicalRows.push(["원본 역할", agent.agentType]);
513
- }
990
+ const technicalRows = [[t("agentId"), agent.agentId]];
991
+ technicalRows.push([t("rawProfile"), agent.agentType]);
514
992
 
515
- item.append(heading, metadata, createTechnicalDetails(technicalRows));
993
+ item.append(heading, metadata, createTechnicalInfo(technicalRows));
516
994
  return item;
517
995
  }
518
996
 
@@ -536,12 +1014,12 @@ function createActivityItem(activity) {
536
1014
  metadata.className = "activity-metadata";
537
1015
  metadata.append(createStatusBadge(activity.status), createTime(activity.occurredAtMs, ""));
538
1016
 
539
- const technicalRows = [["원본 이벤트", activity.eventName]];
1017
+ const technicalRows = [[t("rawEvent"), activity.eventName]];
540
1018
  if (activity.toolName) {
541
- technicalRows.push(["원본 도구", activity.toolName]);
1019
+ technicalRows.push([t("rawTool"), activity.toolName]);
542
1020
  }
543
1021
 
544
- content.append(title, metadata, createTechnicalDetails(technicalRows));
1022
+ content.append(title, metadata, createTechnicalInfo(technicalRows));
545
1023
  item.append(marker, content);
546
1024
  return item;
547
1025
  }
@@ -566,20 +1044,20 @@ function createSessionCard(session) {
566
1044
  identity.className = "session-identity";
567
1045
  const eyebrow = document.createElement("span");
568
1046
  eyebrow.className = "session-kind";
569
- eyebrow.textContent = "부모 작업";
1047
+ eyebrow.textContent = t("parentTask");
570
1048
  const title = document.createElement("h3");
571
- title.textContent = session.workspaceLabel || "프로젝트 정보 없음";
1049
+ title.textContent = session.workspaceLabel || t("projectUnknown");
572
1050
  identity.append(
573
1051
  eyebrow,
574
1052
  title,
575
- createTechnicalDetails([["세션 ID", session.sessionId]]),
1053
+ createTechnicalInfo([[t("sessionId"), session.sessionId]]),
576
1054
  );
577
1055
 
578
1056
  const sessionState = document.createElement("div");
579
1057
  sessionState.className = "session-state";
580
1058
  sessionState.append(
581
1059
  createStatusBadge(session.status),
582
- createTime(session.lastActivityAtMs, "최근 활동"),
1060
+ createTime(session.lastActivityAtMs, t("recentActivity")),
583
1061
  );
584
1062
 
585
1063
  cardHeader.append(identity, sessionState);
@@ -590,8 +1068,11 @@ function createSessionCard(session) {
590
1068
  const agentPanel = document.createElement("section");
591
1069
  agentPanel.className = "session-panel";
592
1070
  const agentTitle = document.createElement("h4");
593
- agentTitle.textContent = `작업 에이전트 · ${session.agents.length}`;
594
- agentPanel.append(agentTitle);
1071
+ agentTitle.textContent = t("subagentsCount", { count: session.agents.length });
1072
+ const profileNote = document.createElement("p");
1073
+ profileNote.className = "agent-profile-note";
1074
+ profileNote.textContent = t("agentProfileNote");
1075
+ agentPanel.append(agentTitle, profileNote);
595
1076
 
596
1077
  if (session.agents.length) {
597
1078
  const list = document.createElement("ul");
@@ -601,13 +1082,13 @@ function createSessionCard(session) {
601
1082
  });
602
1083
  agentPanel.append(list);
603
1084
  } else {
604
- agentPanel.append(createEmptyPanel("이 부모 작업에서 관찰된 작업 에이전트가 없습니다."));
1085
+ agentPanel.append(createEmptyPanel(t("noSubagents")));
605
1086
  }
606
1087
 
607
1088
  const activityPanel = document.createElement("section");
608
1089
  activityPanel.className = "session-panel";
609
1090
  const activityTitle = document.createElement("h4");
610
- activityTitle.textContent = "최근 활동";
1091
+ activityTitle.textContent = t("recentActivity");
611
1092
  activityPanel.append(activityTitle);
612
1093
 
613
1094
  if (session.recentActivities.length) {
@@ -618,7 +1099,7 @@ function createSessionCard(session) {
618
1099
  });
619
1100
  activityPanel.append(list);
620
1101
  } else {
621
- activityPanel.append(createEmptyPanel("표시할 최근 활동이 없습니다."));
1102
+ activityPanel.append(createEmptyPanel(t("noRecentActivity")));
622
1103
  }
623
1104
 
624
1105
  panels.append(agentPanel, activityPanel);
@@ -659,29 +1140,34 @@ function sessionMatchesStatus(session, status) {
659
1140
  );
660
1141
  }
661
1142
 
1143
+ function observableSessions() {
1144
+ return viewState.sessions.filter((session) => session.sessionId !== excludedSessionId);
1145
+ }
1146
+
662
1147
  function filteredSessions() {
663
1148
  const query = elements.search.value.trim().toLocaleLowerCase();
664
1149
  const status = elements.statusFilter.value;
665
1150
 
666
- return [...viewState.sessions]
1151
+ return observableSessions()
667
1152
  .filter((session) => sessionMatchesQuery(session, query))
668
1153
  .filter((session) => sessionMatchesStatus(session, status))
669
1154
  .sort(compareSessions);
670
1155
  }
671
1156
 
672
1157
  function renderMetrics() {
673
- const agents = viewState.sessions.flatMap((session) => session.agents);
1158
+ const sessions = observableSessions();
1159
+ const agents = sessions.flatMap((session) => session.agents);
674
1160
  const countStatus = (status) => agents.filter((agent) => agent.status === status).length;
675
1161
 
676
- elements.metricSessions.textContent = String(viewState.sessions.length);
1162
+ elements.metricSessions.textContent = String(sessions.length);
677
1163
  elements.metricRunning.textContent = String(countStatus("running"));
678
- const waitingCount = countStatus("waiting") + viewState.sessions.filter(
1164
+ const waitingCount = countStatus("waiting") + sessions.filter(
679
1165
  (session) => session.status === "waiting",
680
1166
  ).length;
681
1167
  elements.metricWaiting.textContent = String(waitingCount);
682
1168
  elements.metricCompleted.textContent = String(countStatus("completed"));
683
1169
  elements.lastUpdated.textContent = !viewState.updatedAtMs
684
- ? "수신된 활동 없음"
1170
+ ? t("noReceivedActivity")
685
1171
  : formatDateTime(viewState.updatedAtMs);
686
1172
  }
687
1173
 
@@ -699,7 +1185,7 @@ function setStateMessage(kind, title, description, includeRetry = false) {
699
1185
  const retry = document.createElement("button");
700
1186
  retry.type = "button";
701
1187
  retry.className = "retry-button";
702
- retry.textContent = "다시 연결";
1188
+ retry.textContent = t("retry");
703
1189
  retry.addEventListener("click", refreshState);
704
1190
  elements.stateMessage.append(retry);
705
1191
  }
@@ -713,29 +1199,25 @@ function setEmptyObservationMessage() {
713
1199
  const copy = document.createElement("span");
714
1200
 
715
1201
  if (viewState.diagnostics.length) {
716
- heading.textContent = "표시 가능한 부모 작업이 없습니다.";
717
- copy.textContent = `로컬 모니터가 활동 정보 ${viewState.diagnostics.length}건을 받았지만 표시 가능한 부모 작업으로 적용하지 못했습니다.`;
1202
+ heading.textContent = t("emptyWithDiagnosticsTitle");
1203
+ copy.textContent = t("emptyWithDiagnosticsCopy", { count: viewState.diagnostics.length });
718
1204
  } else {
719
- heading.textContent = "이 관찰 화면에서 수신된 작업 활동이 없습니다.";
720
- copy.textContent = "로컬 모니터 연결은 정상입니다. 이 결과만으로 Codex에 실행 중인 부모 작업이나 작업 에이전트가 없다고 판단할 수 없습니다.";
1205
+ heading.textContent = t("emptyTitle");
1206
+ copy.textContent = t("emptyCopy");
721
1207
  }
722
1208
 
723
1209
  const guidance = document.createElement("div");
724
1210
  guidance.className = "empty-guidance";
725
1211
 
726
1212
  const guidanceTitle = document.createElement("h3");
727
- guidanceTitle.textContent = "표시되지 않을 때 확인 순서";
1213
+ guidanceTitle.textContent = t("emptyGuidanceTitle");
728
1214
 
729
1215
  const automaticTracking = document.createElement("p");
730
1216
  automaticTracking.className = "automatic-tracking";
731
- automaticTracking.textContent = "작업 ID를 입력하거나 작업별로 등록할 필요가 없습니다. 신뢰한 hook의 작업 활동이 이 목록에 자동으로 추가됩니다.";
1217
+ automaticTracking.textContent = t("automaticTracking");
732
1218
 
733
1219
  const steps = document.createElement("ol");
734
- for (const step of [
735
- "플러그인을 설치한 뒤 공식 Codex 앱을 완전히 재시작했는지 확인합니다.",
736
- "새 작업에서 표시되는 Codex Agent View hook 명령을 검토하고 직접 신뢰합니다.",
737
- "신뢰 설정 후 새 작업을 시작해 작업 에이전트를 실행합니다. 새 활동은 이 목록에 자동으로 추가됩니다.",
738
- ]) {
1220
+ for (const step of [t("emptyStep1"), t("emptyStep2"), t("emptyStep3")]) {
739
1221
  const item = document.createElement("li");
740
1222
  item.textContent = step;
741
1223
  steps.append(item);
@@ -743,15 +1225,16 @@ function setEmptyObservationMessage() {
743
1225
 
744
1226
  const boundary = document.createElement("p");
745
1227
  boundary.className = "observation-boundary";
746
- boundary.textContent = "관찰 화면은 첫 번째로 신뢰한 hook을 받은 시점부터 시작합니다. 그 전에 이미 지나간 활동과 로컬 상태 수집이 중단된 동안의 활동은 재생되지 않으며, 수집이 다시 시작되면 새 관찰 화면이 열립니다.";
1228
+ boundary.textContent = t("observationBoundary");
747
1229
 
748
1230
  guidance.append(guidanceTitle, automaticTracking, steps, boundary);
749
1231
 
750
1232
  if (viewState.diagnostics.length) {
751
- const diagnostics = document.createElement("details");
752
- diagnostics.className = "diagnostic-details";
753
- const summary = document.createElement("summary");
754
- summary.textContent = `검증 정보 ${viewState.diagnostics.length}건`;
1233
+ const diagnostics = document.createElement("div");
1234
+ diagnostics.className = "diagnostic-info";
1235
+ const diagnosticsTitle = document.createElement("p");
1236
+ diagnosticsTitle.className = "diagnostic-info-title";
1237
+ diagnosticsTitle.textContent = t("diagnosticsCount", { count: viewState.diagnostics.length });
755
1238
  const codes = document.createElement("ul");
756
1239
  const diagnosticCounts = new Map();
757
1240
  for (const { code } of viewState.diagnostics) {
@@ -761,10 +1244,10 @@ function setEmptyObservationMessage() {
761
1244
  const item = document.createElement("li");
762
1245
  const code = document.createElement("code");
763
1246
  code.textContent = diagnosticCode;
764
- item.append(code, ` · ${count}건`);
1247
+ item.append(code, ` · ${t("diagnosticOccurrences", { count })}`);
765
1248
  codes.append(item);
766
1249
  }
767
- diagnostics.append(summary, codes);
1250
+ diagnostics.append(diagnosticsTitle, codes);
768
1251
  guidance.append(diagnostics);
769
1252
  }
770
1253
 
@@ -772,10 +1255,11 @@ function setEmptyObservationMessage() {
772
1255
  }
773
1256
 
774
1257
  function renderSessions() {
1258
+ const sessions = observableSessions();
775
1259
  const visibleSessions = filteredSessions();
776
1260
  const hasFilters = elements.search.value.trim() || elements.statusFilter.value !== "all";
777
1261
 
778
- elements.toolbar.hidden = viewState.sessions.length === 0;
1262
+ elements.toolbar.hidden = sessions.length === 0;
779
1263
  elements.stateMessage.hidden = false;
780
1264
 
781
1265
  elements.sessionList.replaceChildren();
@@ -784,34 +1268,34 @@ function renderSessions() {
784
1268
  });
785
1269
 
786
1270
  elements.resultsSummary.textContent = hasFilters
787
- ? `전체 ${viewState.sessions.length}개 ${visibleSessions.length}개 표시`
788
- : `${viewState.sessions.length}개 부모 작업`;
1271
+ ? t("resultsFiltered", { total: sessions.length, visible: visibleSessions.length })
1272
+ : t("resultsTotal", { count: sessions.length });
789
1273
 
790
1274
  if (!viewState.hasLoaded) {
791
1275
  elements.sessionList.hidden = true;
792
- setStateMessage("loading", "상태를 불러오는 중입니다.", "로컬 모니터에 연결하고 있습니다.");
1276
+ setStateMessage("loading", t("loadingState"), t("connectingCopy"));
793
1277
  return;
794
1278
  }
795
1279
 
796
1280
  if (viewState.errorMessage) {
797
1281
  elements.sessionList.hidden = !visibleSessions.length;
798
1282
  const description = viewState.canRetry
799
- ? viewState.sessions.length
800
- ? "2초마다 자동으로 다시 연결합니다. 마지막 정상 상태를 계속 표시합니다."
801
- : "2초마다 자동으로 다시 연결합니다. Codex 앱에서 이 화면을 그대로 두어도 됩니다."
802
- : viewState.errorMessage;
1283
+ ? sessions.length
1284
+ ? t("retryWithState")
1285
+ : t("retryWithoutState")
1286
+ : viewState.errorKey ? t(viewState.errorKey) : viewState.errorMessage;
803
1287
  setStateMessage(
804
1288
  "error",
805
1289
  viewState.canRetry
806
- ? "로컬 상태 연결이 끊겨 다시 시도 중입니다."
807
- : "이 실시간 화면을 인증할 수 없습니다.",
1290
+ ? t("disconnectedTitle")
1291
+ : t("authTitle"),
808
1292
  description,
809
1293
  viewState.canRetry,
810
1294
  );
811
1295
  return;
812
1296
  }
813
1297
 
814
- if (!viewState.sessions.length) {
1298
+ if (!sessions.length) {
815
1299
  elements.sessionList.hidden = true;
816
1300
  setEmptyObservationMessage();
817
1301
  return;
@@ -821,8 +1305,8 @@ function renderSessions() {
821
1305
  elements.sessionList.hidden = true;
822
1306
  setStateMessage(
823
1307
  "empty",
824
- "검색 결과가 없습니다.",
825
- "검색어나 상태 필터를 바꿔 보세요.",
1308
+ t("searchEmptyTitle"),
1309
+ t("searchEmptyCopy"),
826
1310
  );
827
1311
  return;
828
1312
  }
@@ -840,9 +1324,9 @@ function render() {
840
1324
  function setConnectionStatus(status, labelOverride = "") {
841
1325
  elements.connectionStatus.dataset.status = status;
842
1326
  const labels = {
843
- connecting: "로컬 상태 연결 중",
844
- connected: "로컬 모니터 연결됨",
845
- error: "연결 끊김 · 재시도 중",
1327
+ connecting: t("connectionConnecting"),
1328
+ connected: t("connectionConnected"),
1329
+ error: t("connectionRetrying"),
846
1330
  };
847
1331
  elements.connectionLabel.textContent = labelOverride || labels[status];
848
1332
  }
@@ -856,8 +1340,9 @@ async function refreshState() {
856
1340
  viewState.hasLoaded = true;
857
1341
  viewState.canRetry = false;
858
1342
  viewState.authenticationFailed = true;
859
- viewState.errorMessage = "이 탭에는 접근 토큰이 없습니다. Codex 앱에서 Codex Agent View의 실시간 화면 열기를 다시 요청하세요.";
860
- setConnectionStatus("error", "실시간 화면 인증 필요");
1343
+ viewState.errorKey = "missingToken";
1344
+ viewState.errorMessage = t(viewState.errorKey);
1345
+ setConnectionStatus("error", t("authenticationRequired"));
861
1346
  render();
862
1347
  return;
863
1348
  }
@@ -882,13 +1367,14 @@ async function refreshState() {
882
1367
  viewState.hasLoaded = true;
883
1368
  viewState.canRetry = false;
884
1369
  viewState.authenticationFailed = true;
885
- viewState.errorMessage = "이 실시간 화면의 인증이 더 이상 유효하지 않습니다. Codex 앱에서 Codex Agent View의 실시간 화면 열기를 다시 요청하세요.";
886
- setConnectionStatus("error", "실시간 화면 인증 필요");
1370
+ viewState.errorKey = "expiredToken";
1371
+ viewState.errorMessage = t(viewState.errorKey);
1372
+ setConnectionStatus("error", t("authenticationRequired"));
887
1373
  return;
888
1374
  }
889
1375
 
890
1376
  if (!response.ok) {
891
- throw new Error(`상태 요청 실패 (${response.status})`);
1377
+ throw new Error(t("requestFailed", { status: response.status }));
892
1378
  }
893
1379
 
894
1380
  const nextState = normalizeState(await response.json());
@@ -897,6 +1383,7 @@ async function refreshState() {
897
1383
  viewState.diagnostics = nextState.diagnostics;
898
1384
  viewState.hasLoaded = true;
899
1385
  viewState.errorMessage = "";
1386
+ viewState.errorKey = "";
900
1387
  viewState.canRetry = true;
901
1388
  setConnectionStatus("connected");
902
1389
  } catch (error) {
@@ -904,7 +1391,8 @@ async function refreshState() {
904
1391
  viewState.canRetry = true;
905
1392
  viewState.errorMessage = error instanceof Error
906
1393
  ? error.message
907
- : "알 수 없는 연결 오류가 발생했습니다.";
1394
+ : t("unknownConnectionError");
1395
+ viewState.errorKey = "";
908
1396
  setConnectionStatus("error");
909
1397
  } finally {
910
1398
  viewState.requestInFlight = false;
@@ -914,6 +1402,7 @@ async function refreshState() {
914
1402
 
915
1403
  elements.search.addEventListener("input", renderSessions);
916
1404
  elements.statusFilter.addEventListener("change", renderSessions);
1405
+ elements.language.addEventListener("change", (event) => setLanguage(event.target.value));
917
1406
  elements.toolbar.addEventListener("submit", (event) => {
918
1407
  event.preventDefault();
919
1408
  });
@@ -923,7 +1412,8 @@ window.addEventListener("offline", () => {
923
1412
  return;
924
1413
  }
925
1414
  viewState.canRetry = true;
926
- viewState.errorMessage = "이 기기가 오프라인입니다.";
1415
+ viewState.errorMessage = t("offline");
1416
+ viewState.errorKey = "offline";
927
1417
  setConnectionStatus("error");
928
1418
  render();
929
1419
  });
@@ -933,6 +1423,7 @@ document.addEventListener("visibilitychange", () => {
933
1423
  }
934
1424
  });
935
1425
 
1426
+ applyStaticTranslations();
936
1427
  render();
937
1428
  refreshState();
938
1429
  if (accessToken) {