codex-agent-view 0.2.0 → 0.3.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.
@@ -52,7 +52,7 @@ function printHelp() {
52
52
  process.stdout.write(`Codex Agent View
53
53
 
54
54
  Usage:
55
- codex-agent-view start [--port <port>] [--no-open]
55
+ codex-agent-view start [--port <port>] [--open]
56
56
  codex-agent-view status [--json]
57
57
  codex-agent-view doctor [--json]
58
58
  codex-agent-view install
@@ -60,12 +60,53 @@ Usage:
60
60
  codex-agent-view --version
61
61
 
62
62
  The monitor is read-only and binds only to 127.0.0.1.
63
+ Start prints the local URL without opening an external browser unless --open is set.
63
64
  `);
64
65
  }
65
66
 
66
- function optionValue(args, name) {
67
- const index = args.indexOf(name);
68
- return index === -1 ? undefined : args[index + 1];
67
+ function parseStartArgs(args) {
68
+ let open = false;
69
+ let legacyNoOpen = false;
70
+ let port = DEFAULT_PORT;
71
+ let portSeen = false;
72
+
73
+ for (let index = 0; index < args.length; index += 1) {
74
+ const argument = args[index];
75
+ if (argument === "--open") {
76
+ open = true;
77
+ continue;
78
+ }
79
+ if (argument === "--no-open") {
80
+ legacyNoOpen = true;
81
+ continue;
82
+ }
83
+ if (argument === "--port") {
84
+ if (portSeen) {
85
+ throw new Error("--port may only be specified once");
86
+ }
87
+ const value = args[index + 1];
88
+ if (value === undefined || value.startsWith("--")) {
89
+ throw new Error("--port requires a value");
90
+ }
91
+ port = Number(value);
92
+ portSeen = true;
93
+ index += 1;
94
+ continue;
95
+ }
96
+ if (argument.startsWith("-")) {
97
+ throw new Error(`unknown start option: ${argument}`);
98
+ }
99
+ throw new Error(`unexpected start argument: ${argument}`);
100
+ }
101
+
102
+ if (open && legacyNoOpen) {
103
+ throw new Error("--open and --no-open cannot be used together");
104
+ }
105
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
106
+ throw new Error("--port must be an integer from 0 to 65535");
107
+ }
108
+
109
+ return { open, port };
69
110
  }
70
111
 
71
112
  function run(command, args, { allowFailure = false } = {}) {
@@ -114,11 +155,7 @@ function openBrowser(url) {
114
155
  }
115
156
 
116
157
  async function start(args) {
117
- const requestedPort = optionValue(args, "--port");
118
- const port = requestedPort === undefined ? DEFAULT_PORT : Number(requestedPort);
119
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
120
- throw new Error("--port must be an integer from 0 to 65535");
121
- }
158
+ const options = parseStartArgs(args);
122
159
 
123
160
  const runtime = await inspectRuntime();
124
161
  if (runtime.kind === "unknown") {
@@ -130,10 +167,10 @@ async function start(args) {
130
167
  throw new Error("a Codex Agent View monitor is already running; stop it before starting another");
131
168
  }
132
169
 
133
- const monitor = await startMonitorServer({ port });
170
+ const monitor = await startMonitorServer({ port: options.port });
134
171
  process.stdout.write(`Codex Agent View is running at ${monitor.url}\n`);
135
172
  process.stdout.write("Press Ctrl+C to stop the in-memory monitor.\n");
136
- if (!args.includes("--no-open")) {
173
+ if (options.open) {
137
174
  openBrowser(monitor.url);
138
175
  }
139
176
 
@@ -172,32 +209,172 @@ async function status(args) {
172
209
  0,
173
210
  );
174
211
  process.stdout.write(`${sessions.length} task(s), ${agents} subagent(s) observed.\n`);
212
+ if (!Number.isFinite(snapshot.updated_at_ms) || snapshot.updated_at_ms <= 0) {
213
+ process.stdout.write(
214
+ "No hook event has reached this monitor yet. The monitor can be healthy while Codex skips an untrusted or not-yet-loaded hook. Run `codex-agent-view doctor`, review `/hooks` in Codex CLI, then use a new task.\n",
215
+ );
216
+ }
175
217
  for (const session of sessions) {
176
218
  process.stdout.write(`- ${session.session_id}: ${session.status} (${session.agents.length} agents)\n`);
177
219
  }
178
220
  }
179
221
 
222
+ function parsePluginList(result) {
223
+ if (result.code !== 0) return null;
224
+ try {
225
+ const parsed = JSON.parse(result.stdout);
226
+ return Array.isArray(parsed.installed) ? parsed.installed : [];
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ async function inspectInstalledHookBundle(pluginEntry) {
233
+ const sourcePath = pluginEntry?.source?.path;
234
+ if (typeof sourcePath !== "string" || sourcePath.length === 0) {
235
+ return {
236
+ hooks_file: false,
237
+ sender_file: false,
238
+ source_path: null,
239
+ wiring_ok: false,
240
+ };
241
+ }
242
+ const hooksPath = join(sourcePath, "hooks", "hooks.json");
243
+ const senderPath = join(sourcePath, "scripts", "send-hook.mjs");
244
+ const hooks = await readJsonRegularFile(hooksPath);
245
+ const sender = await pathExists(senderPath);
246
+ const hookGroups = hooks?.hooks;
247
+ const expectedEvents = [
248
+ "SessionStart",
249
+ "SessionEnd",
250
+ "UserPromptSubmit",
251
+ "Stop",
252
+ "SubagentStart",
253
+ "SubagentStop",
254
+ "PreToolUse",
255
+ "PostToolUse",
256
+ "PermissionRequest",
257
+ ];
258
+ const hooksFile = hookGroups && typeof hookGroups === "object";
259
+ const declaredEvents = hooksFile
260
+ ? expectedEvents.filter((event) => Array.isArray(hookGroups[event]))
261
+ : [];
262
+ return {
263
+ declared_events: declaredEvents,
264
+ hooks_file: Boolean(hooksFile),
265
+ sender_file: Boolean(sender?.isFile() && !sender.isSymbolicLink()),
266
+ source_path: sourcePath,
267
+ wiring_ok:
268
+ Boolean(hooksFile) &&
269
+ declaredEvents.length === expectedEvents.length &&
270
+ Boolean(sender?.isFile() && !sender.isSymbolicLink()),
271
+ };
272
+ }
273
+
180
274
  async function doctor(args) {
181
275
  const codex = await run("codex", ["--version"], { allowFailure: true });
182
276
  let monitor = { ok: false, message: "not running" };
183
277
  try {
184
278
  const snapshot = await fetchState();
185
- monitor = { ok: true, sessions: snapshot.sessions?.length || 0 };
279
+ monitor = {
280
+ events_received:
281
+ Number.isFinite(snapshot.updated_at_ms) && snapshot.updated_at_ms > 0,
282
+ ok: true,
283
+ sessions: snapshot.sessions?.length || 0,
284
+ updated_at_ms:
285
+ Number.isFinite(snapshot.updated_at_ms) && snapshot.updated_at_ms > 0
286
+ ? snapshot.updated_at_ms
287
+ : null,
288
+ };
186
289
  } catch (error) {
187
290
  monitor = { ok: false, message: error.message };
188
291
  }
189
292
  const plugins = await run("codex", ["plugin", "list", "--json"], {
190
293
  allowFailure: true,
191
294
  });
192
- let installed = false;
193
- try {
194
- const parsed = JSON.parse(plugins.stdout);
195
- installed = parsed.installed?.some((entry) => entry.pluginId === PLUGIN_ID) || false;
196
- } catch {}
295
+ const pluginList = parsePluginList(plugins);
296
+ const pluginEntry = pluginList?.find((entry) => entry.pluginId === PLUGIN_ID) || null;
297
+ const hookBundle = await inspectInstalledHookBundle(pluginEntry);
298
+ const diagnostics = [];
299
+ if (codex.code !== 0) {
300
+ diagnostics.push({
301
+ action: "Install or repair Codex CLI so `codex --version` succeeds.",
302
+ code: "codex_cli_unavailable",
303
+ severity: "error",
304
+ });
305
+ }
306
+ if (pluginList === null) {
307
+ diagnostics.push({
308
+ action: "Check `codex plugin list --json` and the installed Codex CLI version.",
309
+ code: "plugin_list_unavailable",
310
+ severity: "error",
311
+ });
312
+ } else if (!pluginEntry) {
313
+ diagnostics.push({
314
+ action: "Run `codex-agent-view install`.",
315
+ code: "plugin_not_installed",
316
+ severity: "error",
317
+ });
318
+ } else if (pluginEntry.enabled !== true) {
319
+ diagnostics.push({
320
+ action: "Enable Codex Agent View in the Codex plugin browser, then start a new task.",
321
+ code: "plugin_disabled",
322
+ severity: "error",
323
+ });
324
+ }
325
+ if (pluginEntry && !hookBundle.wiring_ok) {
326
+ diagnostics.push({
327
+ action: "Run `codex-agent-view install` again to restore the owned plugin bundle.",
328
+ code: "hook_bundle_invalid",
329
+ severity: "error",
330
+ });
331
+ }
332
+ if (pluginEntry && pluginEntry.version !== (await packageVersion())) {
333
+ diagnostics.push({
334
+ action: "Run `codex-agent-view install` to align the installed plugin with this CLI package.",
335
+ code: "plugin_version_mismatch",
336
+ severity: "warning",
337
+ });
338
+ }
339
+ if (!monitor.ok) {
340
+ diagnostics.push({
341
+ action: "Run `codex-agent-view start` before expecting live task events.",
342
+ code: "monitor_not_running",
343
+ severity: "warning",
344
+ });
345
+ }
346
+ if (pluginEntry) {
347
+ diagnostics.push({
348
+ action: "Review the current plugin hook definition in interactive Codex CLI `/hooks`.",
349
+ code: "hook_trust_unverified",
350
+ severity: "info",
351
+ });
352
+ }
353
+ if (monitor.ok && monitor.events_received === false) {
354
+ diagnostics.push({
355
+ action:
356
+ "Open Codex CLI, review and trust the current definition in `/hooks`, then start a new task. Restart a Codex app process that was already open during installation.",
357
+ code: "no_hook_events_observed",
358
+ severity: "warning",
359
+ });
360
+ }
197
361
  const report = {
198
362
  codex: { ok: codex.code === 0, version: codex.stdout.trim() || null },
363
+ diagnostics,
364
+ hook: {
365
+ ...hookBundle,
366
+ trust: pluginEntry ? "unknown" : "not_applicable",
367
+ trust_note: pluginEntry
368
+ ? "Codex CLI does not expose persisted hook trust through `plugin list --json`; review `/hooks` interactively."
369
+ : null,
370
+ },
199
371
  monitor,
200
- plugin: { installed },
372
+ plugin: {
373
+ enabled: pluginEntry?.enabled === true,
374
+ installed: Boolean(pluginEntry),
375
+ source_path: pluginEntry?.source?.path || null,
376
+ version: pluginEntry?.version || null,
377
+ },
201
378
  runtime_directory: runtimeDirectory(),
202
379
  };
203
380
  if (args.includes("--json")) {
@@ -205,8 +382,20 @@ async function doctor(args) {
205
382
  return;
206
383
  }
207
384
  process.stdout.write(`Codex CLI: ${report.codex.ok ? report.codex.version : "not available"}\n`);
208
- process.stdout.write(`Plugin: ${installed ? "installed" : "not installed"}\n`);
385
+ process.stdout.write(
386
+ `Plugin: ${report.plugin.installed ? `installed (${report.plugin.enabled ? "enabled" : "disabled"})` : "not installed"}\n`,
387
+ );
388
+ if (report.plugin.installed) {
389
+ process.stdout.write(`Hook bundle: ${report.hook.wiring_ok ? "valid" : "invalid"}\n`);
390
+ process.stdout.write("Hook trust: unknown (Codex exposes review through interactive `/hooks`)\n");
391
+ }
209
392
  process.stdout.write(`Monitor: ${monitor.ok ? "running" : monitor.message}\n`);
393
+ if (monitor.ok) {
394
+ process.stdout.write(`Hook events: ${monitor.events_received ? "observed" : "none observed"}\n`);
395
+ }
396
+ for (const diagnostic of diagnostics) {
397
+ process.stdout.write(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.action}\n`);
398
+ }
210
399
  }
211
400
 
212
401
  async function pathExists(path) {
@@ -362,8 +551,30 @@ async function install() {
362
551
  await run("codex", ["plugin", "marketplace", "add", destination, "--json"]);
363
552
  }
364
553
  await run("codex", ["plugin", "add", PLUGIN_ID, "--json"]);
554
+ const verification = await run("codex", ["plugin", "list", "--json"], {
555
+ allowFailure: true,
556
+ });
557
+ const installed = parsePluginList(verification)?.find(
558
+ (entry) => entry.pluginId === PLUGIN_ID,
559
+ );
560
+ if (!installed) {
561
+ throw new Error(
562
+ "Codex did not report the plugin as installed after registration; run `codex-agent-view doctor` for details",
563
+ );
564
+ }
565
+ if (installed.enabled !== true) {
566
+ throw new Error(
567
+ "the plugin was registered but is disabled; enable it in the Codex plugin browser, then run `codex-agent-view doctor`",
568
+ );
569
+ }
365
570
  process.stdout.write(`Installed ${PLUGIN_ID} from ${destination}.\n`);
366
- process.stdout.write("Review and trust the hook in the CLI /hooks screen, restart Codex, then start a new task.\n");
571
+ process.stdout.write("Registration verified: installed and enabled.\n");
572
+ process.stdout.write(
573
+ "Hook trust cannot be granted or inspected non-interactively. Review and trust this plugin's current hook definition in Codex CLI `/hooks`.\n",
574
+ );
575
+ process.stdout.write(
576
+ "If Codex was already open during installation, restart it completely, then create a new task.\n",
577
+ );
367
578
  }
368
579
 
369
580
  function isBroadRuntimeRoot(root) {
package/hooks/hooks.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "description": "Capture privacy-minimized Codex lifecycle payloads for the local companion monitor.",
3
3
  "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${PLUGIN_ROOT}/scripts/send-hook.mjs\"",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "SessionEnd": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${PLUGIN_ROOT}/scripts/send-hook.mjs\"",
21
+ "timeout": 3
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "UserPromptSubmit": [
27
+ {
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${PLUGIN_ROOT}/scripts/send-hook.mjs\"",
32
+ "timeout": 5
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "Stop": [
38
+ {
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node \"${PLUGIN_ROOT}/scripts/send-hook.mjs\"",
43
+ "timeout": 5
44
+ }
45
+ ]
46
+ }
47
+ ],
4
48
  "SubagentStart": [
5
49
  {
6
50
  "hooks": [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codex-agent-view",
3
- "version": "0.2.0",
4
- "description": "Local read-only companion monitor for Codex parent tasks and subagents.",
3
+ "version": "0.3.0",
4
+ "description": "Read-only Codex app task view with an optional local live monitor.",
5
5
  "type": "module",
6
6
  "main": "./src/core/index.mjs",
7
7
  "exports": "./src/core/index.mjs",
package/public/app.js CHANGED
@@ -65,6 +65,7 @@ const elements = Object.freeze({
65
65
  const viewState = {
66
66
  updatedAtMs: null,
67
67
  sessions: [],
68
+ diagnostics: [],
68
69
  hasLoaded: false,
69
70
  errorMessage: "",
70
71
  canRetry: true,
@@ -130,10 +131,22 @@ function normalizeActivity(value) {
130
131
  };
131
132
  }
132
133
 
134
+ function normalizeDiagnostic(value) {
135
+ const diagnostic = isRecord(value) ? value : {};
136
+ return {
137
+ code: safeString(diagnostic.code, "unknown_diagnostic"),
138
+ diagnosedAtMs: safeTimestamp(diagnostic.diagnosed_at_ms),
139
+ };
140
+ }
141
+
133
142
  function deriveSessionStatus(session, agents, recentActivities) {
134
143
  if (session.permission?.status === "waiting_for_user") {
135
144
  return "waiting";
136
145
  }
146
+ const reportedStatus = normalizeCoreStatus(session.status);
147
+ if (reportedStatus === "running" || reportedStatus === "completed") {
148
+ return reportedStatus;
149
+ }
137
150
  if (
138
151
  agents.some((agent) => agent.status === "running") ||
139
152
  recentActivities[0]?.status === "running"
@@ -154,6 +167,7 @@ function normalizeSession(value, index) {
154
167
 
155
168
  return {
156
169
  sessionId: safeString(session.session_id, `unknown-session-${index + 1}`),
170
+ workspaceLabel: safeString(session.workspace_label, ""),
157
171
  status: deriveSessionStatus(session, agents, recentActivities),
158
172
  lastActivityAtMs: safeTimestamp(session.last_seen_at_ms),
159
173
  agents,
@@ -166,7 +180,8 @@ function normalizeState(value) {
166
180
  !isRecord(value) ||
167
181
  value.schema_version !== 1 ||
168
182
  value.source_of_truth !== "hook" ||
169
- !Array.isArray(value.sessions)
183
+ !Array.isArray(value.sessions) ||
184
+ !Array.isArray(value.diagnostics)
170
185
  ) {
171
186
  throw new TypeError("상태 응답 형식이 올바르지 않습니다.");
172
187
  }
@@ -174,6 +189,7 @@ function normalizeState(value) {
174
189
  return {
175
190
  updatedAtMs: safeTimestamp(value.updated_at_ms),
176
191
  sessions: value.sessions.map(normalizeSession),
192
+ diagnostics: value.diagnostics.map(normalizeDiagnostic),
177
193
  };
178
194
  }
179
195
 
@@ -354,11 +370,12 @@ function createSessionCard(session) {
354
370
  identity.className = "session-identity";
355
371
  const eyebrow = document.createElement("span");
356
372
  eyebrow.className = "session-kind";
357
- eyebrow.textContent = "PARENT TASK";
358
- const title = document.createElement("h3");
373
+ eyebrow.append("PARENT TASK · ");
359
374
  const id = document.createElement("code");
360
375
  id.textContent = session.sessionId;
361
- title.append(id);
376
+ eyebrow.append(id);
377
+ const title = document.createElement("h3");
378
+ title.textContent = session.workspaceLabel || "프로젝트 정보 없음";
362
379
  identity.append(eyebrow, title);
363
380
 
364
381
  const sessionState = document.createElement("div");
@@ -420,6 +437,7 @@ function sessionMatchesQuery(session, query) {
420
437
 
421
438
  const searchableValues = [
422
439
  session.sessionId,
440
+ session.workspaceLabel,
423
441
  session.status,
424
442
  ...session.agents.flatMap((agent) => [agent.agentId, agent.agentType, agent.status]),
425
443
  ...session.recentActivities.flatMap((activity) => [
@@ -462,7 +480,7 @@ function renderMetrics() {
462
480
  elements.metricWaiting.textContent = String(waitingCount);
463
481
  elements.metricCompleted.textContent = String(countStatus("completed"));
464
482
  elements.lastUpdated.textContent = !viewState.updatedAtMs
465
- ? "시간 정보 없음"
483
+ ? "수신된 hook 없음"
466
484
  : formatDateTime(viewState.updatedAtMs);
467
485
  }
468
486
 
@@ -486,10 +504,77 @@ function setStateMessage(kind, title, description, includeRetry = false) {
486
504
  }
487
505
  }
488
506
 
507
+ function setEmptyObservationMessage() {
508
+ elements.stateMessage.className = "state-message state-empty state-empty-observation";
509
+ elements.stateMessage.replaceChildren();
510
+
511
+ const heading = document.createElement("strong");
512
+ const copy = document.createElement("span");
513
+
514
+ if (viewState.diagnostics.length) {
515
+ heading.textContent = "표시 가능한 task가 없습니다.";
516
+ copy.textContent = `Monitor가 hook 입력 ${viewState.diagnostics.length}건을 받았지만 유효한 session으로 적용하지 않았습니다.`;
517
+ } else {
518
+ heading.textContent = "이 관찰 창에서 수신된 hook event가 0건입니다.";
519
+ copy.textContent = "로컬 monitor 연결은 정상입니다. 이 결과만으로 Codex에 실행 중인 task나 agent가 없다고 판단할 수 없습니다.";
520
+ }
521
+
522
+ const guidance = document.createElement("div");
523
+ guidance.className = "empty-guidance";
524
+
525
+ const guidanceTitle = document.createElement("h3");
526
+ guidanceTitle.textContent = "표시되지 않을 때 확인 순서";
527
+
528
+ const automaticTracking = document.createElement("p");
529
+ automaticTracking.className = "automatic-tracking";
530
+ automaticTracking.textContent = "task ID를 입력하거나 task별로 등록할 필요가 없습니다. Trusted hook event가 자동으로 이 목록에 추가됩니다.";
531
+
532
+ const steps = document.createElement("ol");
533
+ for (const step of [
534
+ "Codex Agent View를 실행한 뒤 새 Codex task를 시작합니다.",
535
+ "설치된 plugin의 현재 hook command를 검토하고 직접 trust했는지 확인합니다.",
536
+ "plugin 설치 또는 hook trust 변경 후 공식 Codex 앱을 완전히 재시작하고 새 task에서 subagent를 실행합니다.",
537
+ ]) {
538
+ const item = document.createElement("li");
539
+ item.textContent = step;
540
+ steps.append(item);
541
+ }
542
+
543
+ const boundary = document.createElement("p");
544
+ boundary.className = "observation-boundary";
545
+ boundary.textContent = "Plugin 설치·trust 전에 이미 지나간 event와 monitor가 꺼져 있던 동안의 event는 재생되지 않습니다.";
546
+
547
+ guidance.append(guidanceTitle, automaticTracking, steps, boundary);
548
+
549
+ if (viewState.diagnostics.length) {
550
+ const diagnostics = document.createElement("details");
551
+ diagnostics.className = "diagnostic-details";
552
+ const summary = document.createElement("summary");
553
+ summary.textContent = `검증 diagnostic ${viewState.diagnostics.length}건`;
554
+ const codes = document.createElement("ul");
555
+ const diagnosticCounts = new Map();
556
+ for (const { code } of viewState.diagnostics) {
557
+ diagnosticCounts.set(code, (diagnosticCounts.get(code) ?? 0) + 1);
558
+ }
559
+ for (const [diagnosticCode, count] of diagnosticCounts) {
560
+ const item = document.createElement("li");
561
+ const code = document.createElement("code");
562
+ code.textContent = diagnosticCode;
563
+ item.append(code, ` · ${count}건`);
564
+ codes.append(item);
565
+ }
566
+ diagnostics.append(summary, codes);
567
+ guidance.append(diagnostics);
568
+ }
569
+
570
+ elements.stateMessage.append(heading, copy, guidance);
571
+ }
572
+
489
573
  function renderSessions() {
490
574
  const visibleSessions = filteredSessions();
491
575
  const hasFilters = elements.search.value.trim() || elements.statusFilter.value !== "all";
492
576
 
577
+ elements.toolbar.hidden = viewState.sessions.length === 0;
493
578
  elements.stateMessage.hidden = false;
494
579
 
495
580
  elements.sessionList.replaceChildren();
@@ -523,11 +608,7 @@ function renderSessions() {
523
608
 
524
609
  if (!viewState.sessions.length) {
525
610
  elements.sessionList.hidden = true;
526
- setStateMessage(
527
- "empty",
528
- "아직 관찰된 task가 없습니다.",
529
- "Codex에서 task나 subagent가 시작되면 이곳에 나타납니다.",
530
- );
611
+ setEmptyObservationMessage();
531
612
  return;
532
613
  }
533
614
 
@@ -597,6 +678,7 @@ async function refreshState() {
597
678
  const nextState = normalizeState(await response.json());
598
679
  viewState.updatedAtMs = nextState.updatedAtMs;
599
680
  viewState.sessions = nextState.sessions;
681
+ viewState.diagnostics = nextState.diagnostics;
600
682
  viewState.hasLoaded = true;
601
683
  viewState.errorMessage = "";
602
684
  viewState.canRetry = true;
package/public/index.html CHANGED
@@ -95,20 +95,25 @@
95
95
  </p>
96
96
  </div>
97
97
 
98
- <form class="toolbar" role="search" aria-label="task와 subagent 검색 및 필터">
98
+ <form
99
+ class="toolbar"
100
+ role="search"
101
+ aria-label="자동 수신된 task와 subagent 목록 필터"
102
+ hidden
103
+ >
99
104
  <div class="field field-search">
100
- <label for="session-search">검색</label>
105
+ <label for="session-search">목록 필터 (선택)</label>
101
106
  <input
102
107
  id="session-search"
103
108
  name="query"
104
109
  type="search"
105
- placeholder="Session ID, agent ID, 유형 또는 event"
110
+ placeholder="자동 수신된 task·agent 목록에서 찾기"
106
111
  autocomplete="off"
107
112
  spellcheck="false"
108
113
  >
109
114
  </div>
110
115
  <div class="field">
111
- <label for="status-filter">상태</label>
116
+ <label for="status-filter">상태 필터 (선택)</label>
112
117
  <select id="status-filter" name="status">
113
118
  <option value="all">모든 상태</option>
114
119
  <option value="running">실행 중</option>
package/public/styles.css CHANGED
@@ -362,6 +362,10 @@ h1 {
362
362
  gap: var(--space-3);
363
363
  }
364
364
 
365
+ .toolbar[hidden] {
366
+ display: none;
367
+ }
368
+
365
369
  .field {
366
370
  display: grid;
367
371
  gap: var(--space-2);
@@ -425,6 +429,74 @@ h1 {
425
429
  color: var(--text-secondary);
426
430
  }
427
431
 
432
+ .state-empty-observation {
433
+ justify-items: stretch;
434
+ text-align: left;
435
+ }
436
+
437
+ .state-empty-observation > strong,
438
+ .state-empty-observation > span {
439
+ justify-self: center;
440
+ text-align: center;
441
+ }
442
+
443
+ .empty-guidance {
444
+ width: min(42rem, 100%);
445
+ margin-top: var(--space-5);
446
+ padding: var(--space-4);
447
+ background: var(--surface-raised);
448
+ border: 1px solid var(--border-default);
449
+ border-radius: var(--radius-sm);
450
+ }
451
+
452
+ .empty-guidance h3 {
453
+ margin-bottom: var(--space-3);
454
+ font-size: var(--text-sm);
455
+ }
456
+
457
+ .automatic-tracking {
458
+ margin-bottom: var(--space-3);
459
+ color: var(--text-secondary);
460
+ font-size: var(--text-sm);
461
+ }
462
+
463
+ .empty-guidance ol {
464
+ margin: 0;
465
+ padding-left: 1.25rem;
466
+ color: var(--text-secondary);
467
+ font-size: var(--text-sm);
468
+ }
469
+
470
+ .empty-guidance li + li {
471
+ margin-top: var(--space-2);
472
+ }
473
+
474
+ .observation-boundary {
475
+ margin: var(--space-4) 0 0;
476
+ padding-top: var(--space-3);
477
+ color: var(--text-muted);
478
+ border-top: 1px solid var(--border-default);
479
+ font-size: var(--text-xs);
480
+ }
481
+
482
+ .diagnostic-details {
483
+ margin-top: var(--space-3);
484
+ color: var(--text-secondary);
485
+ font-size: var(--text-sm);
486
+ }
487
+
488
+ .diagnostic-details summary {
489
+ min-height: 2.75rem;
490
+ padding: var(--space-2) 0;
491
+ cursor: pointer;
492
+ font-weight: 700;
493
+ }
494
+
495
+ .diagnostic-details ul {
496
+ margin: 0;
497
+ padding-left: 1.25rem;
498
+ }
499
+
428
500
  .state-message.state-error {
429
501
  background: var(--danger-soft);
430
502
  border-color: color-mix(in srgb, var(--danger) 40%, transparent);