codex-agent-view 0.2.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/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +34 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +327 -0
- package/assets/logo-dark.svg +13 -0
- package/assets/logo.svg +13 -0
- package/bin/codex-agent-view.mjs +489 -0
- package/hooks/hooks.json +62 -0
- package/package.json +59 -0
- package/public/app.js +637 -0
- package/public/index.html +137 -0
- package/public/styles.css +821 -0
- package/scripts/capture-hook.mjs +143 -0
- package/scripts/send-hook.mjs +64 -0
- package/skills/codex-agent-view/SKILL.md +21 -0
- package/src/core/index.mjs +3 -0
- package/src/core/monitor-store.mjs +332 -0
- package/src/core/normalize-hook-payload.mjs +146 -0
- package/src/runtime/config.mjs +111 -0
- package/src/runtime/server.mjs +203 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
const API_STATE_URL = "/api/state";
|
|
2
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
3
|
+
const SESSION_TOKEN_KEY = "codex-agent-view-access-token";
|
|
4
|
+
const KNOWN_STATUSES = new Set(["running", "waiting", "completed", "unknown"]);
|
|
5
|
+
|
|
6
|
+
const STATUS_LABELS = Object.freeze({
|
|
7
|
+
running: "실행 중",
|
|
8
|
+
waiting: "대기",
|
|
9
|
+
completed: "완료",
|
|
10
|
+
unknown: "알 수 없음",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const STATUS_ORDER = Object.freeze({
|
|
14
|
+
running: 0,
|
|
15
|
+
waiting: 1,
|
|
16
|
+
unknown: 2,
|
|
17
|
+
completed: 3,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function consumeAccessToken() {
|
|
21
|
+
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
|
22
|
+
const fragmentToken = fragment.get("token")?.trim() || "";
|
|
23
|
+
|
|
24
|
+
if (window.location.hash) {
|
|
25
|
+
window.history.replaceState(
|
|
26
|
+
window.history.state,
|
|
27
|
+
"",
|
|
28
|
+
`${window.location.pathname}${window.location.search}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let token = fragmentToken;
|
|
33
|
+
try {
|
|
34
|
+
if (fragmentToken) {
|
|
35
|
+
window.sessionStorage.setItem(SESSION_TOKEN_KEY, fragmentToken);
|
|
36
|
+
} else {
|
|
37
|
+
token = window.sessionStorage.getItem(SESSION_TOKEN_KEY)?.trim() || "";
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Storage can be unavailable in hardened browser contexts. The fragment
|
|
41
|
+
// token remains usable for this page load and is never copied elsewhere.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const accessToken = consumeAccessToken();
|
|
48
|
+
|
|
49
|
+
const elements = Object.freeze({
|
|
50
|
+
connectionStatus: document.querySelector("#connection-status"),
|
|
51
|
+
connectionLabel: document.querySelector("#connection-label"),
|
|
52
|
+
lastUpdated: document.querySelector("#last-updated"),
|
|
53
|
+
metricSessions: document.querySelector("#metric-sessions"),
|
|
54
|
+
metricRunning: document.querySelector("#metric-running"),
|
|
55
|
+
metricWaiting: document.querySelector("#metric-waiting"),
|
|
56
|
+
metricCompleted: document.querySelector("#metric-completed"),
|
|
57
|
+
search: document.querySelector("#session-search"),
|
|
58
|
+
statusFilter: document.querySelector("#status-filter"),
|
|
59
|
+
toolbar: document.querySelector(".toolbar"),
|
|
60
|
+
resultsSummary: document.querySelector("#results-summary"),
|
|
61
|
+
stateMessage: document.querySelector("#state-message"),
|
|
62
|
+
sessionList: document.querySelector("#session-list"),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const viewState = {
|
|
66
|
+
updatedAtMs: null,
|
|
67
|
+
sessions: [],
|
|
68
|
+
hasLoaded: false,
|
|
69
|
+
errorMessage: "",
|
|
70
|
+
canRetry: true,
|
|
71
|
+
requestInFlight: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeString(value, fallback) {
|
|
79
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeTimestamp(value) {
|
|
83
|
+
return Number.isFinite(value) && value >= 0 && value <= 8_640_000_000_000_000
|
|
84
|
+
? value
|
|
85
|
+
: null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeStatus(value) {
|
|
89
|
+
return typeof value === "string" && KNOWN_STATUSES.has(value) ? value : "unknown";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeCoreStatus(value) {
|
|
93
|
+
if (value === "running") {
|
|
94
|
+
return "running";
|
|
95
|
+
}
|
|
96
|
+
if (value === "waiting" || value === "waiting_for_user") {
|
|
97
|
+
return "waiting";
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
value === "completed" ||
|
|
101
|
+
value === "stopped" ||
|
|
102
|
+
value === "stopped_without_start" ||
|
|
103
|
+
value === "completed_without_start" ||
|
|
104
|
+
value === "late_start_observed"
|
|
105
|
+
) {
|
|
106
|
+
return "completed";
|
|
107
|
+
}
|
|
108
|
+
return normalizeStatus(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeAgent(value, index) {
|
|
112
|
+
const agent = isRecord(value) ? value : {};
|
|
113
|
+
return {
|
|
114
|
+
agentId: safeString(agent.agent_id, `unknown-agent-${index + 1}`),
|
|
115
|
+
agentType: safeString(agent.agent_type, "unknown"),
|
|
116
|
+
status: normalizeCoreStatus(agent.status),
|
|
117
|
+
startedAtMs: safeTimestamp(agent.started_at_ms),
|
|
118
|
+
stoppedAtMs: safeTimestamp(agent.stopped_at_ms),
|
|
119
|
+
lastActivityAtMs: safeTimestamp(agent.last_seen_at_ms),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeActivity(value) {
|
|
124
|
+
const activity = isRecord(value) ? value : {};
|
|
125
|
+
return {
|
|
126
|
+
eventName: safeString(activity.type, "unknown_event"),
|
|
127
|
+
toolName: safeString(activity.tool_name, ""),
|
|
128
|
+
status: normalizeCoreStatus(activity.status),
|
|
129
|
+
occurredAtMs: safeTimestamp(activity.received_at_ms),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function deriveSessionStatus(session, agents, recentActivities) {
|
|
134
|
+
if (session.permission?.status === "waiting_for_user") {
|
|
135
|
+
return "waiting";
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
agents.some((agent) => agent.status === "running") ||
|
|
139
|
+
recentActivities[0]?.status === "running"
|
|
140
|
+
) {
|
|
141
|
+
return "running";
|
|
142
|
+
}
|
|
143
|
+
return "unknown";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeSession(value, index) {
|
|
147
|
+
const session = isRecord(value) ? value : {};
|
|
148
|
+
const agents = Array.isArray(session.agents)
|
|
149
|
+
? session.agents.map(normalizeAgent)
|
|
150
|
+
: [];
|
|
151
|
+
const recentActivities = Array.isArray(session.recent_activities)
|
|
152
|
+
? session.recent_activities.map(normalizeActivity)
|
|
153
|
+
: [];
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
sessionId: safeString(session.session_id, `unknown-session-${index + 1}`),
|
|
157
|
+
status: deriveSessionStatus(session, agents, recentActivities),
|
|
158
|
+
lastActivityAtMs: safeTimestamp(session.last_seen_at_ms),
|
|
159
|
+
agents,
|
|
160
|
+
recentActivities,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeState(value) {
|
|
165
|
+
if (
|
|
166
|
+
!isRecord(value) ||
|
|
167
|
+
value.schema_version !== 1 ||
|
|
168
|
+
value.source_of_truth !== "hook" ||
|
|
169
|
+
!Array.isArray(value.sessions)
|
|
170
|
+
) {
|
|
171
|
+
throw new TypeError("상태 응답 형식이 올바르지 않습니다.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
updatedAtMs: safeTimestamp(value.updated_at_ms),
|
|
176
|
+
sessions: value.sessions.map(normalizeSession),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function compareByActivity(left, right) {
|
|
181
|
+
return (right.lastActivityAtMs ?? -1) - (left.lastActivityAtMs ?? -1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compareAgents(left, right) {
|
|
185
|
+
const statusDifference = STATUS_ORDER[left.status] - STATUS_ORDER[right.status];
|
|
186
|
+
return statusDifference || compareByActivity(left, right);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function formatDateTime(timestampMs) {
|
|
190
|
+
if (timestampMs === null) {
|
|
191
|
+
return "시간 정보 없음";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return new Intl.DateTimeFormat("ko-KR", {
|
|
195
|
+
dateStyle: "medium",
|
|
196
|
+
timeStyle: "medium",
|
|
197
|
+
}).format(new Date(timestampMs));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatRelativeTime(timestampMs) {
|
|
201
|
+
if (timestampMs === null) {
|
|
202
|
+
return "시간 정보 없음";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const differenceSeconds = Math.round((timestampMs - Date.now()) / 1_000);
|
|
206
|
+
const absoluteSeconds = Math.abs(differenceSeconds);
|
|
207
|
+
const formatter = new Intl.RelativeTimeFormat("ko", { numeric: "auto" });
|
|
208
|
+
|
|
209
|
+
if (absoluteSeconds < 60) {
|
|
210
|
+
return formatter.format(differenceSeconds, "second");
|
|
211
|
+
}
|
|
212
|
+
if (absoluteSeconds < 3_600) {
|
|
213
|
+
return formatter.format(Math.round(differenceSeconds / 60), "minute");
|
|
214
|
+
}
|
|
215
|
+
if (absoluteSeconds < 86_400) {
|
|
216
|
+
return formatter.format(Math.round(differenceSeconds / 3_600), "hour");
|
|
217
|
+
}
|
|
218
|
+
return formatter.format(Math.round(differenceSeconds / 86_400), "day");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatDuration(startedAtMs, stoppedAtMs) {
|
|
222
|
+
if (startedAtMs === null) {
|
|
223
|
+
return "시작 시간 없음";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const endAtMs = stoppedAtMs ?? Date.now();
|
|
227
|
+
const seconds = Math.max(0, Math.floor((endAtMs - startedAtMs) / 1_000));
|
|
228
|
+
if (seconds < 60) {
|
|
229
|
+
return `${seconds}초`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const minutes = Math.floor(seconds / 60);
|
|
233
|
+
if (minutes < 60) {
|
|
234
|
+
return `${minutes}분`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hours = Math.floor(minutes / 60);
|
|
238
|
+
const remainingMinutes = minutes % 60;
|
|
239
|
+
return remainingMinutes ? `${hours}시간 ${remainingMinutes}분` : `${hours}시간`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function createStatusBadge(status) {
|
|
243
|
+
const badge = document.createElement("span");
|
|
244
|
+
badge.className = "status-badge";
|
|
245
|
+
badge.dataset.status = status;
|
|
246
|
+
|
|
247
|
+
const dot = document.createElement("span");
|
|
248
|
+
dot.className = "status-dot";
|
|
249
|
+
dot.setAttribute("aria-hidden", "true");
|
|
250
|
+
|
|
251
|
+
const label = document.createElement("span");
|
|
252
|
+
label.textContent = STATUS_LABELS[status];
|
|
253
|
+
|
|
254
|
+
badge.append(dot, label);
|
|
255
|
+
return badge;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createTime(timestampMs, prefix) {
|
|
259
|
+
const wrapper = document.createElement("span");
|
|
260
|
+
wrapper.className = "time-label";
|
|
261
|
+
|
|
262
|
+
if (prefix) {
|
|
263
|
+
wrapper.append(`${prefix} `);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (timestampMs === null) {
|
|
267
|
+
wrapper.append("시간 정보 없음");
|
|
268
|
+
return wrapper;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const time = document.createElement("time");
|
|
272
|
+
time.dateTime = new Date(timestampMs).toISOString();
|
|
273
|
+
time.title = formatDateTime(timestampMs);
|
|
274
|
+
time.textContent = formatRelativeTime(timestampMs);
|
|
275
|
+
wrapper.append(time);
|
|
276
|
+
return wrapper;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function createAgentItem(agent) {
|
|
280
|
+
const item = document.createElement("li");
|
|
281
|
+
item.className = "agent-item";
|
|
282
|
+
|
|
283
|
+
const heading = document.createElement("div");
|
|
284
|
+
heading.className = "agent-heading";
|
|
285
|
+
|
|
286
|
+
const identity = document.createElement("div");
|
|
287
|
+
identity.className = "agent-identity";
|
|
288
|
+
const type = document.createElement("span");
|
|
289
|
+
type.className = "agent-type";
|
|
290
|
+
type.textContent = agent.agentType;
|
|
291
|
+
const id = document.createElement("code");
|
|
292
|
+
id.textContent = agent.agentId;
|
|
293
|
+
identity.append(type, id);
|
|
294
|
+
heading.append(identity, createStatusBadge(agent.status));
|
|
295
|
+
|
|
296
|
+
const metadata = document.createElement("div");
|
|
297
|
+
metadata.className = "agent-metadata";
|
|
298
|
+
metadata.append(
|
|
299
|
+
createTime(agent.lastActivityAtMs, "최근"),
|
|
300
|
+
document.createTextNode(` · ${formatDuration(agent.startedAtMs, agent.stoppedAtMs)}`),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
item.append(heading, metadata);
|
|
304
|
+
return item;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function createActivityItem(activity) {
|
|
308
|
+
const item = document.createElement("li");
|
|
309
|
+
item.className = "activity-item";
|
|
310
|
+
|
|
311
|
+
const marker = document.createElement("span");
|
|
312
|
+
marker.className = "activity-marker";
|
|
313
|
+
marker.dataset.status = activity.status;
|
|
314
|
+
marker.setAttribute("aria-hidden", "true");
|
|
315
|
+
|
|
316
|
+
const content = document.createElement("div");
|
|
317
|
+
const title = document.createElement("div");
|
|
318
|
+
title.className = "activity-title";
|
|
319
|
+
const eventName = document.createElement("strong");
|
|
320
|
+
eventName.textContent = activity.eventName;
|
|
321
|
+
title.append(eventName);
|
|
322
|
+
|
|
323
|
+
if (activity.toolName) {
|
|
324
|
+
const tool = document.createElement("code");
|
|
325
|
+
tool.textContent = activity.toolName;
|
|
326
|
+
title.append(tool);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const metadata = document.createElement("div");
|
|
330
|
+
metadata.className = "activity-metadata";
|
|
331
|
+
metadata.append(createStatusBadge(activity.status), createTime(activity.occurredAtMs, ""));
|
|
332
|
+
|
|
333
|
+
content.append(title, metadata);
|
|
334
|
+
item.append(marker, content);
|
|
335
|
+
return item;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function createEmptyPanel(message) {
|
|
339
|
+
const empty = document.createElement("p");
|
|
340
|
+
empty.className = "panel-empty";
|
|
341
|
+
empty.textContent = message;
|
|
342
|
+
return empty;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function createSessionCard(session) {
|
|
346
|
+
const listItem = document.createElement("li");
|
|
347
|
+
const article = document.createElement("article");
|
|
348
|
+
article.className = "session-card";
|
|
349
|
+
|
|
350
|
+
const cardHeader = document.createElement("header");
|
|
351
|
+
cardHeader.className = "session-header";
|
|
352
|
+
|
|
353
|
+
const identity = document.createElement("div");
|
|
354
|
+
identity.className = "session-identity";
|
|
355
|
+
const eyebrow = document.createElement("span");
|
|
356
|
+
eyebrow.className = "session-kind";
|
|
357
|
+
eyebrow.textContent = "PARENT TASK";
|
|
358
|
+
const title = document.createElement("h3");
|
|
359
|
+
const id = document.createElement("code");
|
|
360
|
+
id.textContent = session.sessionId;
|
|
361
|
+
title.append(id);
|
|
362
|
+
identity.append(eyebrow, title);
|
|
363
|
+
|
|
364
|
+
const sessionState = document.createElement("div");
|
|
365
|
+
sessionState.className = "session-state";
|
|
366
|
+
sessionState.append(
|
|
367
|
+
createStatusBadge(session.status),
|
|
368
|
+
createTime(session.lastActivityAtMs, "최근 활동"),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
cardHeader.append(identity, sessionState);
|
|
372
|
+
|
|
373
|
+
const panels = document.createElement("div");
|
|
374
|
+
panels.className = "session-panels";
|
|
375
|
+
|
|
376
|
+
const agentPanel = document.createElement("section");
|
|
377
|
+
agentPanel.className = "session-panel";
|
|
378
|
+
const agentTitle = document.createElement("h4");
|
|
379
|
+
agentTitle.textContent = `Subagents · ${session.agents.length}`;
|
|
380
|
+
agentPanel.append(agentTitle);
|
|
381
|
+
|
|
382
|
+
if (session.agents.length) {
|
|
383
|
+
const list = document.createElement("ul");
|
|
384
|
+
list.className = "agent-list";
|
|
385
|
+
[...session.agents].sort(compareAgents).forEach((agent) => {
|
|
386
|
+
list.append(createAgentItem(agent));
|
|
387
|
+
});
|
|
388
|
+
agentPanel.append(list);
|
|
389
|
+
} else {
|
|
390
|
+
agentPanel.append(createEmptyPanel("이 task에서 관찰된 subagent가 없습니다."));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const activityPanel = document.createElement("section");
|
|
394
|
+
activityPanel.className = "session-panel";
|
|
395
|
+
const activityTitle = document.createElement("h4");
|
|
396
|
+
activityTitle.textContent = "최근 활동";
|
|
397
|
+
activityPanel.append(activityTitle);
|
|
398
|
+
|
|
399
|
+
if (session.recentActivities.length) {
|
|
400
|
+
const list = document.createElement("ol");
|
|
401
|
+
list.className = "activity-list";
|
|
402
|
+
session.recentActivities.slice(0, 6).forEach((activity) => {
|
|
403
|
+
list.append(createActivityItem(activity));
|
|
404
|
+
});
|
|
405
|
+
activityPanel.append(list);
|
|
406
|
+
} else {
|
|
407
|
+
activityPanel.append(createEmptyPanel("표시할 lifecycle event가 없습니다."));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
panels.append(agentPanel, activityPanel);
|
|
411
|
+
article.append(cardHeader, panels);
|
|
412
|
+
listItem.append(article);
|
|
413
|
+
return listItem;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sessionMatchesQuery(session, query) {
|
|
417
|
+
if (!query) {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const searchableValues = [
|
|
422
|
+
session.sessionId,
|
|
423
|
+
session.status,
|
|
424
|
+
...session.agents.flatMap((agent) => [agent.agentId, agent.agentType, agent.status]),
|
|
425
|
+
...session.recentActivities.flatMap((activity) => [
|
|
426
|
+
activity.eventName,
|
|
427
|
+
activity.toolName,
|
|
428
|
+
activity.status,
|
|
429
|
+
]),
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
return searchableValues.some((value) => value.toLocaleLowerCase().includes(query));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function sessionMatchesStatus(session, status) {
|
|
436
|
+
return (
|
|
437
|
+
status === "all" ||
|
|
438
|
+
session.status === status ||
|
|
439
|
+
session.agents.some((agent) => agent.status === status)
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function filteredSessions() {
|
|
444
|
+
const query = elements.search.value.trim().toLocaleLowerCase();
|
|
445
|
+
const status = elements.statusFilter.value;
|
|
446
|
+
|
|
447
|
+
return [...viewState.sessions]
|
|
448
|
+
.filter((session) => sessionMatchesQuery(session, query))
|
|
449
|
+
.filter((session) => sessionMatchesStatus(session, status))
|
|
450
|
+
.sort(compareByActivity);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function renderMetrics() {
|
|
454
|
+
const agents = viewState.sessions.flatMap((session) => session.agents);
|
|
455
|
+
const countStatus = (status) => agents.filter((agent) => agent.status === status).length;
|
|
456
|
+
|
|
457
|
+
elements.metricSessions.textContent = String(viewState.sessions.length);
|
|
458
|
+
elements.metricRunning.textContent = String(countStatus("running"));
|
|
459
|
+
const waitingCount = countStatus("waiting") + viewState.sessions.filter(
|
|
460
|
+
(session) => session.status === "waiting",
|
|
461
|
+
).length;
|
|
462
|
+
elements.metricWaiting.textContent = String(waitingCount);
|
|
463
|
+
elements.metricCompleted.textContent = String(countStatus("completed"));
|
|
464
|
+
elements.lastUpdated.textContent = !viewState.updatedAtMs
|
|
465
|
+
? "시간 정보 없음"
|
|
466
|
+
: formatDateTime(viewState.updatedAtMs);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function setStateMessage(kind, title, description, includeRetry = false) {
|
|
470
|
+
elements.stateMessage.className = `state-message state-${kind}`;
|
|
471
|
+
elements.stateMessage.replaceChildren();
|
|
472
|
+
|
|
473
|
+
const heading = document.createElement("strong");
|
|
474
|
+
heading.textContent = title;
|
|
475
|
+
const copy = document.createElement("span");
|
|
476
|
+
copy.textContent = description;
|
|
477
|
+
elements.stateMessage.append(heading, copy);
|
|
478
|
+
|
|
479
|
+
if (includeRetry) {
|
|
480
|
+
const retry = document.createElement("button");
|
|
481
|
+
retry.type = "button";
|
|
482
|
+
retry.className = "retry-button";
|
|
483
|
+
retry.textContent = "다시 연결";
|
|
484
|
+
retry.addEventListener("click", refreshState);
|
|
485
|
+
elements.stateMessage.append(retry);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderSessions() {
|
|
490
|
+
const visibleSessions = filteredSessions();
|
|
491
|
+
const hasFilters = elements.search.value.trim() || elements.statusFilter.value !== "all";
|
|
492
|
+
|
|
493
|
+
elements.stateMessage.hidden = false;
|
|
494
|
+
|
|
495
|
+
elements.sessionList.replaceChildren();
|
|
496
|
+
visibleSessions.forEach((session) => {
|
|
497
|
+
elements.sessionList.append(createSessionCard(session));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
elements.resultsSummary.textContent = hasFilters
|
|
501
|
+
? `전체 ${viewState.sessions.length}개 중 ${visibleSessions.length}개 표시`
|
|
502
|
+
: `${viewState.sessions.length}개 부모 task`;
|
|
503
|
+
|
|
504
|
+
if (!viewState.hasLoaded) {
|
|
505
|
+
elements.sessionList.hidden = true;
|
|
506
|
+
setStateMessage("loading", "상태를 불러오는 중입니다.", "로컬 monitor에 연결하고 있습니다.");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (viewState.errorMessage) {
|
|
511
|
+
elements.sessionList.hidden = !visibleSessions.length;
|
|
512
|
+
const description = viewState.sessions.length
|
|
513
|
+
? "마지막 정상 상태를 계속 표시합니다."
|
|
514
|
+
: viewState.errorMessage;
|
|
515
|
+
setStateMessage(
|
|
516
|
+
"error",
|
|
517
|
+
"로컬 monitor에 연결할 수 없습니다.",
|
|
518
|
+
description,
|
|
519
|
+
viewState.canRetry,
|
|
520
|
+
);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!viewState.sessions.length) {
|
|
525
|
+
elements.sessionList.hidden = true;
|
|
526
|
+
setStateMessage(
|
|
527
|
+
"empty",
|
|
528
|
+
"아직 관찰된 task가 없습니다.",
|
|
529
|
+
"Codex에서 task나 subagent가 시작되면 이곳에 나타납니다.",
|
|
530
|
+
);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!visibleSessions.length) {
|
|
535
|
+
elements.sessionList.hidden = true;
|
|
536
|
+
setStateMessage(
|
|
537
|
+
"empty",
|
|
538
|
+
"검색 결과가 없습니다.",
|
|
539
|
+
"검색어나 상태 필터를 바꿔 보세요.",
|
|
540
|
+
);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
elements.stateMessage.hidden = true;
|
|
545
|
+
elements.sessionList.hidden = false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function render() {
|
|
549
|
+
elements.stateMessage.hidden = false;
|
|
550
|
+
renderMetrics();
|
|
551
|
+
renderSessions();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function setConnectionStatus(status) {
|
|
555
|
+
elements.connectionStatus.dataset.status = status;
|
|
556
|
+
const labels = {
|
|
557
|
+
connecting: "로컬 상태 연결 중",
|
|
558
|
+
connected: "로컬 monitor 연결됨",
|
|
559
|
+
error: "연결 끊김",
|
|
560
|
+
};
|
|
561
|
+
elements.connectionLabel.textContent = labels[status];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function refreshState() {
|
|
565
|
+
if (viewState.requestInFlight) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!accessToken) {
|
|
570
|
+
viewState.hasLoaded = true;
|
|
571
|
+
viewState.canRetry = false;
|
|
572
|
+
viewState.errorMessage = "접근 token이 없습니다. monitor를 다시 실행해 새 주소를 여세요.";
|
|
573
|
+
setConnectionStatus("error");
|
|
574
|
+
render();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
viewState.requestInFlight = true;
|
|
579
|
+
if (!viewState.hasLoaded) {
|
|
580
|
+
setConnectionStatus("connecting");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
const response = await fetch(API_STATE_URL, {
|
|
585
|
+
cache: "no-store",
|
|
586
|
+
credentials: "same-origin",
|
|
587
|
+
headers: {
|
|
588
|
+
Accept: "application/json",
|
|
589
|
+
Authorization: `Bearer ${accessToken}`,
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
if (!response.ok) {
|
|
594
|
+
throw new Error(`상태 요청 실패 (${response.status})`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const nextState = normalizeState(await response.json());
|
|
598
|
+
viewState.updatedAtMs = nextState.updatedAtMs;
|
|
599
|
+
viewState.sessions = nextState.sessions;
|
|
600
|
+
viewState.hasLoaded = true;
|
|
601
|
+
viewState.errorMessage = "";
|
|
602
|
+
viewState.canRetry = true;
|
|
603
|
+
setConnectionStatus("connected");
|
|
604
|
+
} catch (error) {
|
|
605
|
+
viewState.hasLoaded = true;
|
|
606
|
+
viewState.errorMessage = error instanceof Error
|
|
607
|
+
? error.message
|
|
608
|
+
: "알 수 없는 연결 오류가 발생했습니다.";
|
|
609
|
+
setConnectionStatus("error");
|
|
610
|
+
} finally {
|
|
611
|
+
viewState.requestInFlight = false;
|
|
612
|
+
render();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
elements.search.addEventListener("input", renderSessions);
|
|
617
|
+
elements.statusFilter.addEventListener("change", renderSessions);
|
|
618
|
+
elements.toolbar.addEventListener("submit", (event) => {
|
|
619
|
+
event.preventDefault();
|
|
620
|
+
});
|
|
621
|
+
window.addEventListener("online", refreshState);
|
|
622
|
+
window.addEventListener("offline", () => {
|
|
623
|
+
viewState.errorMessage = "이 기기가 오프라인입니다.";
|
|
624
|
+
setConnectionStatus("error");
|
|
625
|
+
render();
|
|
626
|
+
});
|
|
627
|
+
document.addEventListener("visibilitychange", () => {
|
|
628
|
+
if (document.visibilityState === "visible") {
|
|
629
|
+
refreshState();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
render();
|
|
634
|
+
refreshState();
|
|
635
|
+
if (accessToken) {
|
|
636
|
+
window.setInterval(refreshState, POLL_INTERVAL_MS);
|
|
637
|
+
}
|