codex-agent-view 0.4.5 → 0.4.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-agent-view",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Follow Codex work and participating agent progress in a clear, read-only live view inside the official Codex app.",
5
5
  "author": {
6
6
  "name": "Junho Yoon",
package/README.ko.md CHANGED
@@ -10,10 +10,10 @@ Codex Agent View는 Codex가 지금 어떤 작업을 수행하고 있고 어떤
10
10
 
11
11
  ### 빠른 시작: 설치 후에는 Codex 앱 안에서만 사용
12
12
 
13
- 이 README는 `codex-agent-view@0.4.5` 사용법을 설명한다. **최초 설치만** 일반 터미널에서 아래 exact-version 명령으로 진행한다.
13
+ 이 README는 `codex-agent-view@0.4.6` 사용법을 설명한다. **최초 설치만** 일반 터미널에서 아래 exact-version 명령으로 진행한다.
14
14
 
15
15
  ```bash
16
- npm install --global codex-agent-view@0.4.5
16
+ npm install --global codex-agent-view@0.4.6
17
17
  codex-agent-view install
18
18
  ```
19
19
 
@@ -40,7 +40,7 @@ Live UI의 기본 언어는 영어이며 language selector에서 **English**, **
40
40
 
41
41
  ### 현재 상태
42
42
 
43
- `0.4.5`는 아래 구성에 작업 수준 요청 요약, 비개발자용 작업/참여 에이전트 용어, session ID제거한 live card와 화면 연결·인증 복구를 추가한다.
43
+ `0.4.6`은 lifecycle 정확성을 바로잡는 릴리스다. `SessionEnd`에 최종 우선순위를 부여하고, 끝나지 않은 child activity성공으로 추정하지 않은 정리하며, 늦게 도착한 event가 완료된 turn을 다시 실행 중으로 열지 못하게 한다. 종료 신호 없이 오래 남은 활동은 무기한 **실행 중**으로 두지 않고 **종료 미확인**으로 표시한다. 아래의 기존 제품 구성은 그대로 유지한다.
44
44
 
45
45
  - 공식 Codex 앱의 내장 thread tools를 우선 사용하는 app-native active-task snapshot skill
46
46
  - `.codex-plugin/plugin.json`, local marketplace catalog, genuine Codex skill
@@ -88,12 +88,15 @@ Codex Agent View는 historical audit이나 session replay 제품이 아니라
88
88
 
89
89
  - 앱 안의 현재 task snapshot은 공식 Codex 앱이 제공하는 내장 thread tools의 explicit status와 `subAgentActivity`를 우선 사용한다.
90
90
  - Hook event는 local monitor의 세부 lifecycle 상태에 대한 source of truth다. Monitor state는 bounded memory에만 있고 재시작하면 새 관찰 window가 시작된다. 별도 private viewer credential은 task history가 아니라 인증 metadata다.
91
+ - `Stop`은 관찰된 root turn과 session/work-item 요약을 즉시 `completed`로 표시한다. 진행 중이던 child agent나 tool은 자체 stop/tool completion 신호를 관찰하지 못했으므로 해당 row에서 별도로 `completion_not_observed`로 표시한다. `SessionEnd`는 terminal priority를 가지며, 그 시점에도 열려 있는 child agent·tool·permission은 완료로 추정하지 않고 `interrupted`로 표시한다.
92
+ - 공식 `SessionEnd` 전달은 최대 30분 지연될 수 있다. 종료 hook을 관찰하지 못한 채 활동이 열린 상태로 남으면 새 event가 없는 5분 뒤 `completion_not_observed`(**종료 미확인**)로 바꾸며 `completed`로 추정하지 않는다. 지연되거나 누락된 terminal event 때문에 오래된 활동을 완료·성공으로 잘못 표시하지 않기 위한 경계다.
91
93
  - Read-only viewer credential은 한 설치 수명 동안 monitor 재시작과 upgrade를 넘어 유지되며, runtime/control token은 별도 process-scoped credential이다.
92
94
  - 설치·trust·앱 재시작 뒤 첫 trusted hook이 backend를 자동 준비한다. 이는 monitor process 준비이며 Codex 화면이나 tab을 몰래 생성하는 기능이 아니다.
93
95
  - 외부 telemetry, 원격 server, account, 필수 SQLite/영구 event store가 없다.
94
96
  - 전체 prompt 원문, transcript path, 전체 tool input/output, assistant message를 monitor 상태나 UI에 저장·표시하지 않는다. 위에서 설명한 bounded/redacted 한 줄 작업 요약만 process memory에 유지할 수 있다.
95
97
  - 기본 monitor는 진행 중인 작업과 참여 에이전트를 먼저 정렬하고 사람이 읽을 수 있는 label/status를 우선하며, raw ID와 event name은 주 정보로 표시하지 않는다. Live card에는 session ID를 표시하지 않는다.
96
98
  - task/subagent 중지·재시작, message 전송, permission 자동 승인·거절 기능이 없다.
99
+ - Sender는 기존 bounded retry와 fail-open 동작을 유지한다. Disk queue나 persistent replay가 없으므로 hook budget 안에 전달하지 못한 event를 나중에 재생하지 않는다.
97
100
  - 별도로 실행한 App Server는 앱 내장 thread tools와 다른 process다. 공식 앱의 live source로 간주하거나 둘을 같은 API로 설명하지 않는다.
98
101
 
99
102
  별도로 실행한 Codex `0.146` App Server의 `thread/list` fallback도 실제 확인했지만 현재 root/subagent가 모두 `notLoaded`로 나타나 공식 앱의 live running/completed 상태를 공유하지 않았다. Persisted parent ID, alias, depth 보강은 가능했지만 live 판별에는 채택하지 않았다. `0.3.0`의 primary snapshot은 이 별도 server가 아니라 현재 공식 앱이 직접 제공하는 내장 thread tools를 사용한다.
@@ -211,16 +214,16 @@ Plugin enable/trust와 앱 재시작 뒤 생성되거나 재개되는 task는 tr
211
214
 
212
215
  ### npm 설치 명령 참고
213
216
 
214
- 아래 명령은 `0.4.5`를 exact version으로 설치한다.
217
+ 아래 명령은 `0.4.6`을 exact version으로 설치한다.
215
218
 
216
219
  ```bash
217
- npm install --global codex-agent-view@0.4.5
220
+ npm install --global codex-agent-view@0.4.6
218
221
  codex-agent-view install
219
222
  ```
220
223
 
221
224
  이 두 명령 뒤에는 Codex 앱을 완전히 다시 열고 Plugins 화면에서 설치·활성화와 hook trust를 확인한 다음 새 task를 만든다. 첫 trusted hook이 backend 준비와 event 전달을 내부 처리하므로 사용자가 monitor CLI를 실행하지 않는다. Plugin 카드의 **지금 사용해보기**로 `@codex-agent-view`를 선택하고 앱에서 `$show-agents`를 명시 선택한다. Panel을 닫은 뒤에도 같은 방식으로 skill을 다시 선택한다.
222
225
 
223
- `0.4.5` 설치 경로는 위의 global package 설치와 명시적인 `codex-agent-view install` command 조합이다. 이후 일반 사용은 Codex 앱 안에서 진행한다. 이전의 유효한 설치에서 upgrade하면 installation-owned read-only viewer credential을 유지하며, `0.4.3`에서 검증한 legacy `0.4.2` migration 동작도 그대로 보존한다. Token과 private URL은 출력하지 않는다.
226
+ `0.4.6` 설치 경로는 위의 global package 설치와 명시적인 `codex-agent-view install` command 조합이다. 이후 일반 사용은 Codex 앱 안에서 진행한다. 이전의 유효한 설치에서 upgrade하면 installation-owned read-only viewer credential을 유지하며, `0.4.3`에서 검증한 legacy `0.4.2` migration 동작도 그대로 보존한다. Token과 private URL은 출력하지 않는다.
224
227
 
225
228
  Version별 npm, install, migration, CI, tag와 GitHub Release evidence는 [docs/distribution.md](docs/distribution.md)에 보존한다. 각 evidence는 실제 확인한 뒤에만 갱신한다.
226
229
 
package/README.md CHANGED
@@ -8,12 +8,12 @@ Codex Agent View gives you a clear, read-only view of what Codex is working on a
8
8
 
9
9
  ## Quick start: install once, then stay inside the Codex app
10
10
 
11
- This README documents `codex-agent-view@0.4.5`. Use the exact-version command below for the one-time terminal installation.
11
+ This README documents `codex-agent-view@0.4.6`. Use the exact-version command below for the one-time terminal installation.
12
12
 
13
13
  Universal Plugins Directory search installation is not available yet, so use a regular terminal for the **initial installation only**:
14
14
 
15
15
  ```bash
16
- npm install --global codex-agent-view@0.4.5
16
+ npm install --global codex-agent-view@0.4.6
17
17
  codex-agent-view install
18
18
  ```
19
19
 
@@ -40,7 +40,7 @@ In short: install once in a terminal; perform snapshot queries, status checks, l
40
40
 
41
41
  ## Status
42
42
 
43
- Version `0.4.5` adds the work-level request summary, user-facing work/agent terminology, session-ID-free live cards, and in-panel connection/authentication recovery described above. It retains the app-native snapshot skill, privacy-minimized hooks, bounded in-memory reducer, local authenticated live backend, and explicit install/remove plus maintainer-diagnostic CLI commands.
43
+ Version `0.4.6` is the lifecycle-correctness release. It gives `SessionEnd` terminal priority, settles unfinished child activity without pretending it succeeded, prevents late events from reopening a finished turn, and reports prolonged unconfirmed activity as **End not confirmed** instead of leaving it indefinitely **Running**. It retains the app-native snapshot skill, privacy-minimized hooks, bounded in-memory reducer, local authenticated live backend, and explicit install/remove plus maintainer-diagnostic CLI commands.
44
44
 
45
45
  Plugin installation and lifecycle payloads were verified with Homebrew Codex CLI and the Codex executable embedded in the official app. However, a real-use attempt that installed and enabled `0.2.0` in an already-running official app process delivered zero events while two subagents ran. The monitor, registration, enablement, and installed bundle were healthy, while app logs showed no sender invocation. Evidence indicates that the same process retained a pre-install `hooks/list` snapshot; persisted exact-hook trust is not exposed through CLI JSON, so the precise skip boundary remains unconfirmed.
46
46
 
@@ -78,6 +78,8 @@ Codex Agent View is a live companion, not a historical audit or session-replay p
78
78
 
79
79
  - The app-native current-task snapshot prioritizes explicit status and `subAgentActivity` from the official Codex app's built-in thread tools.
80
80
  - Hooks remain the source of truth for detailed lifecycle state in the trusted-hook auto-prepared local live backend. Its operational state exists only in bounded process memory; restart begins a new observation window. The separate private viewer credential is authentication metadata, not stored task history.
81
+ - `Stop` marks the observed root turn and the session/work-item summary `completed` immediately. If a child agent or tool was still active, its own row is separately marked `completion_not_observed` because no child stop/tool completion signal was observed. `SessionEnd` has terminal priority; any child agent, tool, or permission still open at that point is shown as `interrupted`, not silently completed.
82
+ - Official `SessionEnd` delivery may be delayed by up to 30 minutes. If no ending hook is observed while activity still appears open, five minutes without a new event changes it to `completion_not_observed` (**End not confirmed**), never inferred `completed`. This keeps a delayed or missing terminal event from turning stale activity into a false success.
81
83
  - The viewer credential is read-only and remains stable across monitor restarts and package upgrades during one installation. The runtime/control token remains separate and process-scoped.
82
84
  - After installation, hook trust, and an app restart, the first trusted hook automatically prepares the backend. This prepares a local process; it does not create app UI without a user action.
83
85
  - There is no external telemetry, remote server, account, required SQLite/persistent event store, or remote control.
@@ -85,6 +87,7 @@ Codex Agent View is a live companion, not a historical audit or session-replay p
85
87
  - The default monitor sorts active work and participating agents first, uses human-readable labels and statuses as the primary presentation, and keeps raw IDs and event names out of the primary reading path. Session IDs are not shown in the live cards.
86
88
  - The product cannot stop or restart tasks/subagents, send messages, or approve/deny permissions.
87
89
  - Missing, duplicated, or out-of-order events remain visible as empty, unknown, or degraded state instead of being guessed away.
90
+ - The sender keeps its bounded retry and fail-open behavior. It has no disk-backed queue or persistent replay; an event that cannot be delivered within the hook budget is not replayed later.
88
91
 
89
92
  A separately launched Codex `0.146` App Server `thread/list` fallback was also tested. It reported both the current root and subagents as `notLoaded`, so it did not share the official app's live running/completed state. That separate process is not the same as the built-in thread tools exposed directly by the current official app; `0.3.0` uses the latter for its primary snapshot.
90
93
 
@@ -163,16 +166,16 @@ After plugin enablement/trust and an app restart, the first trusted hook interna
163
166
 
164
167
  ## Install from npm
165
168
 
166
- The commands below install `0.4.5` by exact version.
169
+ The commands below install `0.4.6` by exact version.
167
170
 
168
171
  ```bash
169
- npm install --global codex-agent-view@0.4.5
172
+ npm install --global codex-agent-view@0.4.6
170
173
  codex-agent-view install
171
174
  ```
172
175
 
173
176
  After these two commands, fully reopen the Codex app, verify installation, enablement, and hook trust, then create a new task. The first trusted hook prepares the backend and delivers its event internally, so users do not run monitor CLI commands. Use the plugin card's **Quick start** action to select `@codex-agent-view`, then explicitly select `$show-agents` in the app. Repeat that explicit skill selection after closing the panel.
174
177
 
175
- The `0.4.5` installation path is the global package install followed by the explicit `codex-agent-view install` command above. Routine use remains inside the Codex app afterward. When upgrading an older valid installation, `install` preserves the installation-owned read-only viewer credential; the legacy `0.4.2` migration behavior remains as documented for `0.4.3`. Neither token nor the private URL is printed.
178
+ The `0.4.6` installation path is the global package install followed by the explicit `codex-agent-view install` command above. Routine use remains inside the Codex app afterward. When upgrading an older valid installation, `install` preserves the installation-owned read-only viewer credential; the legacy `0.4.2` migration behavior remains as documented for `0.4.3`. Neither token nor the private URL is printed.
176
179
 
177
180
  Version-specific npm, install, migration, CI, tag, and GitHub Release evidence is preserved in [Distribution](docs/distribution.md). That evidence is updated only after each item is actually verified.
178
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-agent-view",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Follow Codex work and participating agent progress in a clear, read-only live view inside the official Codex app.",
5
5
  "type": "module",
6
6
  "main": "./src/core/index.mjs",
package/public/app.js CHANGED
@@ -4,7 +4,15 @@ const SESSION_TOKEN_KEY = "codex-agent-view-access-token";
4
4
  const EXCLUDED_SESSION_KEY = "codex-agent-view-excluded-session";
5
5
  const LANGUAGE_KEY = "codex-agent-view-language";
6
6
  const SUPPORTED_LANGUAGES = new Set(["en", "ko", "es"]);
7
- const KNOWN_STATUSES = new Set(["running", "waiting", "completed", "unknown"]);
7
+ const KNOWN_STATUSES = new Set([
8
+ "running",
9
+ "waiting",
10
+ "completed",
11
+ "completion_not_observed",
12
+ "stale",
13
+ "interrupted",
14
+ "unknown",
15
+ ]);
8
16
  const VIEWER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
9
17
  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
18
 
@@ -47,7 +55,13 @@ const MESSAGES = Object.freeze({
47
55
  statusRunning: "Running",
48
56
  statusWaiting: "Waiting",
49
57
  statusCompleted: "Completed",
58
+ statusCompletionNotObserved: "End not confirmed",
59
+ statusStale: "Stale · end not confirmed",
60
+ statusInterrupted: "Interrupted",
50
61
  statusUnknown: "Unknown",
62
+ completionNotObservedExplanation: "No end signal was received for this item, so completion cannot be confirmed.",
63
+ staleExplanation: "This status is out of date and no end signal was received. Completion cannot be confirmed.",
64
+ interruptedExplanation: "This activity was still open when the work ended; its own completion signal was not observed.",
51
65
  connectingCopy: "Connecting to the Codex app's local status.",
52
66
  sessionListAria: "Codex work list",
53
67
  privacyPrompt: "This view shows only a shortened request summary; it never displays the full request or tool inputs.",
@@ -180,7 +194,13 @@ const MESSAGES = Object.freeze({
180
194
  statusRunning: "실행 중",
181
195
  statusWaiting: "대기",
182
196
  statusCompleted: "완료",
197
+ statusCompletionNotObserved: "종료 확인 안 됨",
198
+ statusStale: "오래된 상태 · 종료 확인 안 됨",
199
+ statusInterrupted: "중단됨",
183
200
  statusUnknown: "알 수 없음",
201
+ completionNotObservedExplanation: "이 항목의 종료 신호를 받지 못해 완료 여부를 확정할 수 없습니다.",
202
+ staleExplanation: "상태 정보가 오래되었고 종료 신호를 받지 못했습니다. 완료 여부를 확정할 수 없습니다.",
203
+ interruptedExplanation: "전체 작업이 끝날 때 이 활동이 열린 상태였습니다. 이 활동 자체의 완료 신호는 확인되지 않았습니다.",
184
204
  connectingCopy: "Codex 앱의 로컬 상태에 연결하고 있습니다.",
185
205
  sessionListAria: "Codex 작업 목록",
186
206
  privacyPrompt: "이 화면은 짧게 줄인 요청 요약만 표시하며, 전체 요청이나 도구 입력은 표시하지 않습니다.",
@@ -313,7 +333,13 @@ const MESSAGES = Object.freeze({
313
333
  statusRunning: "En ejecución",
314
334
  statusWaiting: "En espera",
315
335
  statusCompleted: "Completado",
336
+ statusCompletionNotObserved: "Fin no confirmado",
337
+ statusStale: "Estado desactualizado · fin no confirmado",
338
+ statusInterrupted: "Interrumpido",
316
339
  statusUnknown: "Desconocido",
340
+ completionNotObservedExplanation: "No se recibió una señal de fin para este elemento, por lo que no se puede confirmar que haya terminado.",
341
+ staleExplanation: "El estado está desactualizado y no se recibió una señal de fin. No se puede confirmar que haya terminado.",
342
+ interruptedExplanation: "Esta actividad seguía abierta cuando terminó el trabajo; no se observó su propia señal de finalización.",
317
343
  connectingCopy: "Conectando al estado local de la aplicación Codex.",
318
344
  sessionListAria: "Lista de trabajos de Codex",
319
345
  privacyPrompt: "Esta vista solo muestra un resumen abreviado de la solicitud; nunca muestra la solicitud completa ni las entradas de herramientas.",
@@ -414,14 +440,26 @@ const STATUS_KEYS = Object.freeze({
414
440
  running: "statusRunning",
415
441
  waiting: "statusWaiting",
416
442
  completed: "statusCompleted",
443
+ completion_not_observed: "statusCompletionNotObserved",
444
+ stale: "statusStale",
445
+ interrupted: "statusInterrupted",
417
446
  unknown: "statusUnknown",
418
447
  });
419
448
 
420
449
  const STATUS_ORDER = Object.freeze({
421
450
  running: 0,
422
451
  waiting: 1,
423
- unknown: 2,
424
- completed: 3,
452
+ completion_not_observed: 2,
453
+ stale: 2,
454
+ interrupted: 3,
455
+ unknown: 4,
456
+ completed: 5,
457
+ });
458
+
459
+ const STATUS_EXPLANATION_KEYS = Object.freeze({
460
+ completion_not_observed: "completionNotObservedExplanation",
461
+ stale: "staleExplanation",
462
+ interrupted: "interruptedExplanation",
425
463
  });
426
464
 
427
465
  const ACTIVITY_KEYS = Object.freeze({
@@ -693,6 +731,9 @@ function normalizeCoreStatus(value) {
693
731
  ) {
694
732
  return "completed";
695
733
  }
734
+ if (value === "completion_not_observed" || value === "stale" || value === "interrupted") {
735
+ return value;
736
+ }
696
737
  return normalizeStatus(value);
697
738
  }
698
739
 
@@ -783,13 +824,13 @@ function normalizeDiagnostic(value) {
783
824
  }
784
825
 
785
826
  function deriveSessionStatus(session, agents, recentActivities) {
786
- if (session.permission?.status === "waiting_for_user") {
787
- return "waiting";
788
- }
789
827
  const reportedStatus = normalizeCoreStatus(session.status);
790
- if (reportedStatus === "running" || reportedStatus === "completed") {
828
+ if (reportedStatus !== "unknown") {
791
829
  return reportedStatus;
792
830
  }
831
+ if (session.permission?.status === "waiting_for_user") {
832
+ return "waiting";
833
+ }
793
834
  if (
794
835
  agents.some((agent) => agent.status === "running") ||
795
836
  recentActivities[0]?.status === "running"
@@ -924,12 +965,24 @@ function createStatusBadge(status) {
924
965
  dot.setAttribute("aria-hidden", "true");
925
966
 
926
967
  const label = document.createElement("span");
927
- label.textContent = t(STATUS_KEYS[status]);
968
+ label.textContent = t(STATUS_KEYS[status] ?? "statusUnknown");
928
969
 
929
970
  badge.append(dot, label);
930
971
  return badge;
931
972
  }
932
973
 
974
+ function createStatusExplanation(status) {
975
+ const messageKey = STATUS_EXPLANATION_KEYS[status];
976
+ if (!messageKey) {
977
+ return null;
978
+ }
979
+
980
+ const explanation = document.createElement("p");
981
+ explanation.className = "status-explanation";
982
+ explanation.textContent = t(messageKey);
983
+ return explanation;
984
+ }
985
+
933
986
  function createTime(timestampMs, prefix) {
934
987
  const wrapper = document.createElement("span");
935
988
  wrapper.className = "time-label";
@@ -1006,10 +1059,16 @@ function createAgentItem(agent) {
1006
1059
  document.createTextNode(` · ${formatDuration(agent.startedAtMs, agent.stoppedAtMs)}`),
1007
1060
  );
1008
1061
 
1062
+ const statusExplanation = createStatusExplanation(agent.status);
1063
+
1009
1064
  const technicalRows = [[t("agentId"), agent.agentId]];
1010
1065
  technicalRows.push([t("rawProfile"), agent.agentType]);
1011
1066
 
1012
- item.append(heading, metadata, createTechnicalInfo(technicalRows));
1067
+ item.append(heading, metadata);
1068
+ if (statusExplanation) {
1069
+ item.append(statusExplanation);
1070
+ }
1071
+ item.append(createTechnicalInfo(technicalRows));
1013
1072
  return item;
1014
1073
  }
1015
1074
 
@@ -1081,6 +1140,10 @@ function createSessionCard(session) {
1081
1140
  createStatusBadge(session.status),
1082
1141
  createTime(session.lastActivityAtMs, t("recentActivity")),
1083
1142
  );
1143
+ const statusExplanation = createStatusExplanation(session.status);
1144
+ if (statusExplanation) {
1145
+ sessionState.append(statusExplanation);
1146
+ }
1084
1147
 
1085
1148
  cardHeader.append(identity, sessionState);
1086
1149
 
@@ -1156,10 +1219,15 @@ function sessionMatchesQuery(session, query) {
1156
1219
  }
1157
1220
 
1158
1221
  function sessionMatchesStatus(session, status) {
1222
+ const matchesStatus = (candidate) => (
1223
+ candidate === status ||
1224
+ (status === "completion_not_observed" && candidate === "stale")
1225
+ );
1159
1226
  return (
1160
1227
  status === "all" ||
1161
- session.status === status ||
1162
- session.agents.some((agent) => agent.status === status)
1228
+ matchesStatus(session.status) ||
1229
+ session.agents.some((agent) => matchesStatus(agent.status)) ||
1230
+ session.recentActivities.some((activity) => matchesStatus(activity.status))
1163
1231
  );
1164
1232
  }
1165
1233
 
package/public/index.html CHANGED
@@ -129,6 +129,8 @@
129
129
  <option value="running" data-i18n="statusRunning">Running</option>
130
130
  <option value="waiting" data-i18n="statusWaiting">Waiting</option>
131
131
  <option value="completed" data-i18n="statusCompleted">Completed</option>
132
+ <option value="completion_not_observed" data-i18n="statusCompletionNotObserved">End not confirmed</option>
133
+ <option value="interrupted" data-i18n="statusInterrupted">Interrupted</option>
132
134
  <option value="unknown" data-i18n="statusUnknown">Unknown</option>
133
135
  </select>
134
136
  </div>
package/public/styles.css CHANGED
@@ -25,6 +25,10 @@
25
25
  --waiting-soft: #fff0ce;
26
26
  --completed: #45605f;
27
27
  --completed-soft: #e5eceb;
28
+ --unconfirmed: #8a4f18;
29
+ --unconfirmed-soft: #f9e8d6;
30
+ --interrupted: #765226;
31
+ --interrupted-soft: #f2e8d9;
28
32
  --unknown: #6b6678;
29
33
  --unknown-soft: #ece9f2;
30
34
  --danger: #a13838;
@@ -597,6 +601,16 @@ h1 {
597
601
  background: linear-gradient(110deg, var(--running-soft), var(--surface-raised) 72%);
598
602
  }
599
603
 
604
+ .session-card[data-status="completion_not_observed"],
605
+ .session-card[data-status="stale"] {
606
+ border-color: color-mix(in srgb, var(--unconfirmed) 52%, var(--border-default));
607
+ }
608
+
609
+ .session-card[data-status="completion_not_observed"] .session-header,
610
+ .session-card[data-status="stale"] .session-header {
611
+ background: linear-gradient(110deg, var(--unconfirmed-soft), var(--surface-raised) 72%);
612
+ }
613
+
600
614
  .session-header {
601
615
  display: flex;
602
616
  align-items: center;
@@ -666,6 +680,17 @@ h1 {
666
680
  background: var(--completed-soft);
667
681
  }
668
682
 
683
+ .status-badge[data-status="completion_not_observed"],
684
+ .status-badge[data-status="stale"] {
685
+ color: var(--unconfirmed);
686
+ background: var(--unconfirmed-soft);
687
+ }
688
+
689
+ .status-badge[data-status="interrupted"] {
690
+ color: var(--interrupted);
691
+ background: var(--interrupted-soft);
692
+ }
693
+
669
694
  .status-badge[data-status="running"] .status-dot,
670
695
  .activity-marker[data-status="running"] {
671
696
  background: var(--running);
@@ -681,6 +706,31 @@ h1 {
681
706
  background: var(--completed);
682
707
  }
683
708
 
709
+ .status-badge[data-status="completion_not_observed"] .status-dot,
710
+ .status-badge[data-status="stale"] .status-dot,
711
+ .activity-marker[data-status="completion_not_observed"],
712
+ .activity-marker[data-status="stale"] {
713
+ background: var(--unconfirmed);
714
+ }
715
+
716
+ .status-badge[data-status="interrupted"] .status-dot,
717
+ .activity-marker[data-status="interrupted"] {
718
+ background: var(--interrupted);
719
+ }
720
+
721
+ .status-explanation {
722
+ max-width: 34rem;
723
+ margin: 0;
724
+ color: var(--text-secondary);
725
+ font-size: var(--text-xs);
726
+ line-height: 1.4;
727
+ }
728
+
729
+ .session-state .status-explanation {
730
+ max-width: 24rem;
731
+ text-align: right;
732
+ }
733
+
684
734
  .time-label,
685
735
  .agent-metadata,
686
736
  .activity-metadata {
@@ -726,6 +776,13 @@ h1 {
726
776
  border-color: color-mix(in srgb, var(--running) 42%, var(--border-default));
727
777
  }
728
778
 
779
+ .agent-item[data-status="completion_not_observed"],
780
+ .agent-item[data-status="stale"],
781
+ .agent-item[data-status="interrupted"] {
782
+ background: color-mix(in srgb, var(--unconfirmed-soft) 58%, var(--surface-raised));
783
+ border-color: color-mix(in srgb, var(--unconfirmed) 38%, var(--border-default));
784
+ }
785
+
729
786
  .agent-heading {
730
787
  display: flex;
731
788
  align-items: center;
@@ -933,6 +990,10 @@ footer p {
933
990
  align-items: flex-start;
934
991
  }
935
992
 
993
+ .session-state .status-explanation {
994
+ text-align: left;
995
+ }
996
+
936
997
  .session-header {
937
998
  gap: var(--space-3);
938
999
  padding: var(--space-4);
@@ -980,6 +1041,10 @@ footer p {
980
1041
  --waiting-soft: #493819;
981
1042
  --completed: #a9bfbb;
982
1043
  --completed-soft: #34433f;
1044
+ --unconfirmed: #f2b879;
1045
+ --unconfirmed-soft: #493421;
1046
+ --interrupted: #dcc09a;
1047
+ --interrupted-soft: #423729;
983
1048
  --unknown: #bbb3ca;
984
1049
  --unknown-soft: #3c3746;
985
1050
  --danger: #f19a9a;
@@ -118,6 +118,19 @@ means that monitor process observed no hook events; it does not prove that the
118
118
  Codex app has no tasks. Restarting the in-memory monitor begins a new bounded
119
119
  observation window.
120
120
 
121
+ When a CLI or live-monitor snapshot returns lifecycle statuses, preserve their
122
+ meaning exactly:
123
+
124
+ - A session/work-item `completed` status is grounded in an observed `Stop` or
125
+ terminal `SessionEnd`; report it as observed completion, not inferred
126
+ success.
127
+ - `completion_not_observed` means active state had no new event for the default
128
+ five-minute window and no ending hook was observed. Render it as **End not
129
+ confirmed**. Never reinterpret it as either `running` or `completed`.
130
+ - `interrupted` means the parent/session became terminal while a child agent or
131
+ tool had no own stop/completion signal. Never rewrite it as `running` or
132
+ `completed`, and do not invent a success or failure result.
133
+
121
134
  After explicit installation, hook review/trust, and a Codex app restart, the
122
135
  first trusted hook normally prepares the local backend internally and retries
123
136
  delivery of that same privacy-minimized event. The user never registers a task
@@ -5,6 +5,7 @@ const DEFAULT_LIMITS = Object.freeze({
5
5
  maxAgentsPerSession: 100,
6
6
  maxDiagnostics: 100,
7
7
  maxSessions: 50,
8
+ staleAfterMs: 5 * 60 * 1000,
8
9
  });
9
10
 
10
11
  function positiveInteger(value, name) {
@@ -74,7 +75,30 @@ function applyWorkspaceLabel(session, event) {
74
75
  session.workspace_label_observed_at_ms = event.received_at_ms;
75
76
  }
76
77
 
77
- function deriveSessionStatus(session) {
78
+ function hasActiveState(session) {
79
+ return (
80
+ session.permission.status === "waiting_for_user" ||
81
+ session.root_turn.status === "running" ||
82
+ [...session.agents.values()].some(({ status }) => status === "running") ||
83
+ [...session.tools.values()].some(({ status }) => status === "running")
84
+ );
85
+ }
86
+
87
+ function isStaleActiveSession(session, nowMs, staleAfterMs) {
88
+ return (
89
+ !session.lifecycle.end_observed &&
90
+ hasActiveState(session) &&
91
+ nowMs - session.last_seen_at_ms >= staleAfterMs
92
+ );
93
+ }
94
+
95
+ function deriveSessionStatus(session, nowMs, staleAfterMs) {
96
+ if (session.lifecycle.end_observed) {
97
+ return "completed";
98
+ }
99
+ if (isStaleActiveSession(session, nowMs, staleAfterMs)) {
100
+ return "completion_not_observed";
101
+ }
78
102
  if (session.permission.status === "waiting_for_user") {
79
103
  return "waiting_for_user";
80
104
  }
@@ -85,12 +109,92 @@ function deriveSessionStatus(session) {
85
109
  ) {
86
110
  return "running";
87
111
  }
88
- if (session.lifecycle.end_observed) {
112
+ if (session.root_turn.status === "completed") {
89
113
  return "completed";
90
114
  }
91
115
  return "observed";
92
116
  }
93
117
 
118
+ function settleRunningState(session, status, options = {}) {
119
+ const turnId = options.turnId;
120
+ const settledAgentIds = new Set();
121
+ const settledToolUseIds = new Set();
122
+ for (const agent of session.agents.values()) {
123
+ if (
124
+ agent.status === "running" &&
125
+ (turnId === undefined || agent.turn_id === turnId)
126
+ ) {
127
+ agent.status = status;
128
+ settledAgentIds.add(agent.agent_id);
129
+ }
130
+ }
131
+ for (const tool of session.tools.values()) {
132
+ if (
133
+ tool.status === "running" &&
134
+ (turnId === undefined || tool.turn_id === turnId)
135
+ ) {
136
+ tool.status = status;
137
+ settledToolUseIds.add(tool.tool_use_id);
138
+ }
139
+ }
140
+ for (const activity of session.recent_activities) {
141
+ if (
142
+ activity.status === "running" &&
143
+ ((activity.type === "subagent_started" &&
144
+ settledAgentIds.has(activity.agent_id)) ||
145
+ (activity.type === "tool_started" &&
146
+ settledToolUseIds.has(activity.tool_use_id)))
147
+ ) {
148
+ activity.status = status;
149
+ }
150
+ }
151
+ }
152
+
153
+ function clearPermission(session, activityStatus) {
154
+ const permission = session.permission;
155
+ if (permission.status !== "waiting_for_user") {
156
+ return;
157
+ }
158
+ for (const activity of session.recent_activities) {
159
+ if (
160
+ activity.type === "permission_requested" &&
161
+ activity.status === "waiting_for_user" &&
162
+ activity.turn_id === permission.turn_id &&
163
+ activity.tool_name === permission.tool_name
164
+ ) {
165
+ activity.status = activityStatus;
166
+ break;
167
+ }
168
+ }
169
+ session.permission = { status: "idle" };
170
+ }
171
+
172
+ function settleRootTurnActivity(session, status) {
173
+ for (const activity of session.recent_activities) {
174
+ if (
175
+ activity.type === "turn_started" &&
176
+ activity.status === "running" &&
177
+ activity.turn_id === session.root_turn.turn_id
178
+ ) {
179
+ activity.status = status;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+
185
+ function resetTransientState(session) {
186
+ session.agents.clear();
187
+ session.tools.clear();
188
+ session.root_turn = {
189
+ status: "idle",
190
+ turn_id: null,
191
+ started_at_ms: null,
192
+ stopped_at_ms: null,
193
+ has_out_of_order_events: false,
194
+ };
195
+ session.permission = { status: "idle" };
196
+ }
197
+
94
198
  function touchMapEntry(map, key, value) {
95
199
  map.delete(key);
96
200
  map.set(key, value);
@@ -109,7 +213,14 @@ function addActivity(session, event, status, limit) {
109
213
  received_at_ms: event.received_at_ms,
110
214
  };
111
215
 
112
- for (const field of ["turn_id", "agent_id", "agent_type", "tool_name", "tool_use_id"]) {
216
+ for (const field of [
217
+ "turn_id",
218
+ "agent_id",
219
+ "agent_type",
220
+ "tool_name",
221
+ "tool_use_id",
222
+ "session_start_source",
223
+ ]) {
113
224
  if (field in event) {
114
225
  activity[field] = event[field];
115
226
  }
@@ -125,11 +236,35 @@ function applySessionEvent(session, event, limits) {
125
236
  const lifecycle = session.lifecycle;
126
237
  if (event.type === "session_started") {
127
238
  const resumedAfterEnd = lifecycle.end_observed;
239
+ const startsNewEpoch =
240
+ resumedAfterEnd ||
241
+ event.session_start_source === "resume" ||
242
+ event.session_start_source === "clear";
243
+ if (event.session_start_source === "compact") {
244
+ if (resumedAfterEnd) {
245
+ return "stale";
246
+ }
247
+ lifecycle.start_observed = true;
248
+ lifecycle.started_at_ms ??= event.received_at_ms;
249
+ addActivity(session, event, "observed", limits.maxActivitiesPerSession);
250
+ return "applied";
251
+ }
252
+ if (lifecycle.start_observed && !startsNewEpoch) {
253
+ return "duplicate";
254
+ }
255
+ if (startsNewEpoch) {
256
+ if (!resumedAfterEnd) {
257
+ settleRootTurnActivity(session, "completion_not_observed");
258
+ clearPermission(session, "completion_not_observed");
259
+ settleRunningState(session, "completion_not_observed");
260
+ }
261
+ resetTransientState(session);
262
+ }
128
263
  lifecycle.start_observed = true;
129
- lifecycle.started_at_ms ??= event.received_at_ms;
264
+ lifecycle.started_at_ms = event.received_at_ms;
130
265
  lifecycle.end_observed = false;
131
266
  lifecycle.ended_at_ms = null;
132
- lifecycle.has_out_of_order_events ||= resumedAfterEnd;
267
+ lifecycle.has_out_of_order_events = false;
133
268
  addActivity(session, event, "observed", limits.maxActivitiesPerSession);
134
269
  return "applied";
135
270
  }
@@ -140,9 +275,11 @@ function applySessionEvent(session, event, limits) {
140
275
  lifecycle.end_observed = true;
141
276
  lifecycle.ended_at_ms = event.received_at_ms;
142
277
  lifecycle.has_out_of_order_events = !lifecycle.start_observed;
278
+ settleRootTurnActivity(session, "completed");
143
279
  session.root_turn.status = "completed";
144
280
  session.root_turn.stopped_at_ms ??= event.received_at_ms;
145
- session.permission = { status: "idle" };
281
+ clearPermission(session, "interrupted");
282
+ settleRunningState(session, "interrupted");
146
283
  addActivity(session, event, "completed", limits.maxActivitiesPerSession);
147
284
  return "applied";
148
285
  }
@@ -150,9 +287,10 @@ function applySessionEvent(session, event, limits) {
150
287
  function applyTurnEvent(session, event, limits) {
151
288
  const turn = session.root_turn;
152
289
  if (event.type === "turn_started") {
153
- if (turn.turn_id === event.turn_id && turn.status === "running") {
154
- return "duplicate";
290
+ if (turn.turn_id === event.turn_id) {
291
+ return turn.status === "running" ? "duplicate" : "stale";
155
292
  }
293
+ settleRunningState(session, "completion_not_observed");
156
294
  session.root_turn = {
157
295
  status: "running",
158
296
  turn_id: event.turn_id,
@@ -160,7 +298,12 @@ function applyTurnEvent(session, event, limits) {
160
298
  stopped_at_ms: null,
161
299
  has_out_of_order_events: false,
162
300
  };
163
- session.permission = { status: "idle" };
301
+ if (
302
+ session.permission.status !== "waiting_for_user" ||
303
+ session.permission.turn_id !== event.turn_id
304
+ ) {
305
+ clearPermission(session, "completion_not_observed");
306
+ }
164
307
  addActivity(session, event, "running", limits.maxActivitiesPerSession);
165
308
  return "applied";
166
309
  }
@@ -169,6 +312,9 @@ function applyTurnEvent(session, event, limits) {
169
312
  return "duplicate";
170
313
  }
171
314
  const startObserved = turn.turn_id === event.turn_id && turn.started_at_ms !== null;
315
+ if (startObserved) {
316
+ settleRootTurnActivity(session, "completed");
317
+ }
172
318
  session.root_turn = {
173
319
  status: "completed",
174
320
  turn_id: event.turn_id,
@@ -176,7 +322,15 @@ function applyTurnEvent(session, event, limits) {
176
322
  stopped_at_ms: event.received_at_ms,
177
323
  has_out_of_order_events: !startObserved,
178
324
  };
179
- session.permission = { status: "idle" };
325
+ if (
326
+ session.permission.status === "waiting_for_user" &&
327
+ session.permission.turn_id === event.turn_id
328
+ ) {
329
+ clearPermission(session, "completion_not_observed");
330
+ }
331
+ settleRunningState(session, "completion_not_observed", {
332
+ turnId: event.turn_id,
333
+ });
180
334
  addActivity(
181
335
  session,
182
336
  event,
@@ -192,6 +346,7 @@ function applySubagentEvent(session, event, limits) {
192
346
  agent = {
193
347
  agent_id: event.agent_id,
194
348
  agent_type: event.agent_type,
349
+ turn_id: event.turn_id,
195
350
  status: "unknown",
196
351
  started_at_ms: null,
197
352
  stopped_at_ms: null,
@@ -225,6 +380,7 @@ function applySubagentEvent(session, event, limits) {
225
380
  }
226
381
 
227
382
  agent.agent_type = event.agent_type;
383
+ agent.turn_id = event.turn_id;
228
384
  agent.last_seen_at_ms = Math.max(agent.last_seen_at_ms, event.received_at_ms);
229
385
  touchMapEntry(session.agents, event.agent_id, agent);
230
386
  trimMap(session.agents, limits.maxAgentsPerSession);
@@ -282,7 +438,7 @@ function applyToolEvent(session, event, limits) {
282
438
  permission.turn_id === event.turn_id &&
283
439
  event.received_at_ms >= permission.requested_at_ms
284
440
  ) {
285
- session.permission = { status: "idle" };
441
+ clearPermission(session, "completed");
286
442
  }
287
443
  }
288
444
 
@@ -328,9 +484,29 @@ function applyPermissionEvent(session, event, limits) {
328
484
  }
329
485
 
330
486
  function applyEvent(session, event, limits) {
331
- if (event.type === "session_started" || event.type === "session_ended") {
487
+ if (event.type === "session_started") {
488
+ return applySessionEvent(session, event, limits);
489
+ }
490
+ if (
491
+ session.lifecycle.end_observed &&
492
+ event.type !== "tool_completed" &&
493
+ event.type !== "subagent_stopped"
494
+ ) {
495
+ return event.type === "session_ended" ? "duplicate" : "stale";
496
+ }
497
+ if (event.type === "session_ended") {
332
498
  return applySessionEvent(session, event, limits);
333
499
  }
500
+ if (
501
+ session.root_turn.status === "completed" &&
502
+ session.root_turn.turn_id === event.turn_id &&
503
+ (event.type === "turn_started" ||
504
+ event.type === "permission_requested" ||
505
+ event.type === "tool_started" ||
506
+ event.type === "subagent_started")
507
+ ) {
508
+ return "stale";
509
+ }
334
510
  if (event.type === "turn_started" || event.type === "turn_stopped") {
335
511
  return applyTurnEvent(session, event, limits);
336
512
  }
@@ -343,22 +519,51 @@ function applyEvent(session, event, limits) {
343
519
  return applyPermissionEvent(session, event, limits);
344
520
  }
345
521
 
346
- function snapshotSession(session) {
522
+ function snapshotSession(session, nowMs, staleAfterMs) {
523
+ const staleActive = isStaleActiveSession(session, nowMs, staleAfterMs);
347
524
  return {
348
525
  session_id: session.session_id,
349
526
  workspace_label: session.workspace_label,
350
527
  task_summary: session.task_summary,
351
- status: deriveSessionStatus(session),
528
+ status: deriveSessionStatus(session, nowMs, staleAfterMs),
352
529
  first_seen_at_ms: session.first_seen_at_ms,
353
530
  last_seen_at_ms: session.last_seen_at_ms,
354
531
  agents: [...session.agents.values()]
355
- .map(({ start_observed, stop_observed, ...agent }) => ({ ...agent }))
532
+ .map(({ start_observed, stop_observed, ...agent }) => ({
533
+ ...agent,
534
+ ...(staleActive && agent.status === "running"
535
+ ? { status: "completion_not_observed" }
536
+ : {}),
537
+ }))
356
538
  .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
357
- root_turn: { ...session.root_turn },
539
+ tools: [...session.tools.values()]
540
+ .map(({ start_observed, completion_observed, ...tool }) => ({
541
+ ...tool,
542
+ ...(staleActive && tool.status === "running"
543
+ ? { status: "completion_not_observed" }
544
+ : {}),
545
+ }))
546
+ .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
547
+ root_turn: {
548
+ ...session.root_turn,
549
+ ...(staleActive && session.root_turn.status === "running"
550
+ ? { status: "completion_not_observed" }
551
+ : {}),
552
+ },
358
553
  recent_activities: session.recent_activities.map((activity) => ({
359
554
  ...activity,
555
+ ...(staleActive &&
556
+ (activity.status === "running" ||
557
+ activity.status === "waiting_for_user")
558
+ ? { status: "completion_not_observed" }
559
+ : {}),
360
560
  })),
361
- permission: { ...session.permission },
561
+ permission: {
562
+ ...session.permission,
563
+ ...(staleActive && session.permission.status === "waiting_for_user"
564
+ ? { status: "completion_not_observed" }
565
+ : {}),
566
+ },
362
567
  };
363
568
  }
364
569
 
@@ -381,6 +586,10 @@ export function createMonitorStore(options = {}) {
381
586
  options.maxSessions ?? DEFAULT_LIMITS.maxSessions,
382
587
  "maxSessions",
383
588
  ),
589
+ staleAfterMs: positiveInteger(
590
+ options.staleAfterMs ?? DEFAULT_LIMITS.staleAfterMs,
591
+ "staleAfterMs",
592
+ ),
384
593
  };
385
594
  const now = options.now ?? Date.now;
386
595
  if (typeof now !== "function") {
@@ -445,12 +654,15 @@ export function createMonitorStore(options = {}) {
445
654
  }
446
655
 
447
656
  function getSnapshot() {
657
+ const snapshotAtMs = now();
448
658
  return {
449
659
  schema_version: 1,
450
660
  source_of_truth: "hook",
451
661
  updated_at_ms: updatedAtMs,
452
662
  sessions: [...sessions.values()]
453
- .map(snapshotSession)
663
+ .map((session) =>
664
+ snapshotSession(session, snapshotAtMs, limits.staleAfterMs),
665
+ )
454
666
  .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
455
667
  diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
456
668
  };
@@ -11,6 +11,12 @@ const NORMALIZED_EVENT_TYPES = Object.freeze({
11
11
  });
12
12
 
13
13
  const SESSION_EVENT_TYPES = new Set(["session_started", "session_ended"]);
14
+ const SESSION_START_SOURCES = new Set([
15
+ "startup",
16
+ "resume",
17
+ "clear",
18
+ "compact",
19
+ ]);
14
20
 
15
21
  const MAX_IDENTIFIER_LENGTH = 512;
16
22
  const MAX_LABEL_LENGTH = 256;
@@ -189,6 +195,13 @@ export function normalizeHookPayload(payload, options = {}) {
189
195
  }
190
196
 
191
197
  const event = commonEvent(payload, type, receivedAtMs);
198
+ if (
199
+ type === "session_started" &&
200
+ typeof payload.source === "string" &&
201
+ SESSION_START_SOURCES.has(payload.source)
202
+ ) {
203
+ event.session_start_source = payload.source;
204
+ }
192
205
  const workspaceLabel = optionalWorkspaceLabel(payload);
193
206
  if (workspaceLabel) {
194
207
  event.workspace_label = workspaceLabel;