codex-agent-view 0.4.5 → 0.4.7

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.7",
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.7` 사용법을 설명한다. **최초 설치만** 일반 터미널에서 아래 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.7
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.7`은 lifecycle 정확성 patch를 완성한다. `SessionEnd` 최종 우선순위, 늦은 event 차단과 정직한 **종료 미확인** 표시에 더해, 정상 또는 늦게 도착한 `SubagentStop`과 `PostToolUse`가 recent activity의 대응 start 항목을 **중지됨** 또는 **완료**로 갱신한다. 따라서 종료된 agent나 tool의 이전 recent activity가 잘못 **실행 중**으로 남지 않는다. 아래의 기존 제품 구성은 그대로 유지한다.
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.7`을 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.7
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.7` 설치 경로는 위의 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.7`. 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.7
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.7` completes the lifecycle-correctness patch. In addition to terminal `SessionEnd`, late-event protection, and honest **End not confirmed** state, normal or late `SubagentStop` and `PostToolUse` events now refine the matching earlier start entry in recent activity to **Stopped** or **Completed**. A finished agent or tool therefore no longer leaves its prior recent-activity row falsely **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.7` 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.7
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.7` 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.7",
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
  }
@@ -121,15 +232,60 @@ function addActivity(session, event, status, limit) {
121
232
  }
122
233
  }
123
234
 
235
+ function refineUnresolvedStartActivity(
236
+ session,
237
+ { type, idField, id, status },
238
+ ) {
239
+ const unresolvedStatuses = new Set([
240
+ "running",
241
+ "completion_not_observed",
242
+ "interrupted",
243
+ ]);
244
+ for (const activity of session.recent_activities) {
245
+ if (
246
+ activity.type === type &&
247
+ activity[idField] === id &&
248
+ unresolvedStatuses.has(activity.status)
249
+ ) {
250
+ activity.status = status;
251
+ break;
252
+ }
253
+ }
254
+ }
255
+
124
256
  function applySessionEvent(session, event, limits) {
125
257
  const lifecycle = session.lifecycle;
126
258
  if (event.type === "session_started") {
127
259
  const resumedAfterEnd = lifecycle.end_observed;
260
+ const startsNewEpoch =
261
+ resumedAfterEnd ||
262
+ event.session_start_source === "resume" ||
263
+ event.session_start_source === "clear";
264
+ if (event.session_start_source === "compact") {
265
+ if (resumedAfterEnd) {
266
+ return "stale";
267
+ }
268
+ lifecycle.start_observed = true;
269
+ lifecycle.started_at_ms ??= event.received_at_ms;
270
+ addActivity(session, event, "observed", limits.maxActivitiesPerSession);
271
+ return "applied";
272
+ }
273
+ if (lifecycle.start_observed && !startsNewEpoch) {
274
+ return "duplicate";
275
+ }
276
+ if (startsNewEpoch) {
277
+ if (!resumedAfterEnd) {
278
+ settleRootTurnActivity(session, "completion_not_observed");
279
+ clearPermission(session, "completion_not_observed");
280
+ settleRunningState(session, "completion_not_observed");
281
+ }
282
+ resetTransientState(session);
283
+ }
128
284
  lifecycle.start_observed = true;
129
- lifecycle.started_at_ms ??= event.received_at_ms;
285
+ lifecycle.started_at_ms = event.received_at_ms;
130
286
  lifecycle.end_observed = false;
131
287
  lifecycle.ended_at_ms = null;
132
- lifecycle.has_out_of_order_events ||= resumedAfterEnd;
288
+ lifecycle.has_out_of_order_events = false;
133
289
  addActivity(session, event, "observed", limits.maxActivitiesPerSession);
134
290
  return "applied";
135
291
  }
@@ -140,9 +296,11 @@ function applySessionEvent(session, event, limits) {
140
296
  lifecycle.end_observed = true;
141
297
  lifecycle.ended_at_ms = event.received_at_ms;
142
298
  lifecycle.has_out_of_order_events = !lifecycle.start_observed;
299
+ settleRootTurnActivity(session, "completed");
143
300
  session.root_turn.status = "completed";
144
301
  session.root_turn.stopped_at_ms ??= event.received_at_ms;
145
- session.permission = { status: "idle" };
302
+ clearPermission(session, "interrupted");
303
+ settleRunningState(session, "interrupted");
146
304
  addActivity(session, event, "completed", limits.maxActivitiesPerSession);
147
305
  return "applied";
148
306
  }
@@ -150,9 +308,10 @@ function applySessionEvent(session, event, limits) {
150
308
  function applyTurnEvent(session, event, limits) {
151
309
  const turn = session.root_turn;
152
310
  if (event.type === "turn_started") {
153
- if (turn.turn_id === event.turn_id && turn.status === "running") {
154
- return "duplicate";
311
+ if (turn.turn_id === event.turn_id) {
312
+ return turn.status === "running" ? "duplicate" : "stale";
155
313
  }
314
+ settleRunningState(session, "completion_not_observed");
156
315
  session.root_turn = {
157
316
  status: "running",
158
317
  turn_id: event.turn_id,
@@ -160,7 +319,12 @@ function applyTurnEvent(session, event, limits) {
160
319
  stopped_at_ms: null,
161
320
  has_out_of_order_events: false,
162
321
  };
163
- session.permission = { status: "idle" };
322
+ if (
323
+ session.permission.status !== "waiting_for_user" ||
324
+ session.permission.turn_id !== event.turn_id
325
+ ) {
326
+ clearPermission(session, "completion_not_observed");
327
+ }
164
328
  addActivity(session, event, "running", limits.maxActivitiesPerSession);
165
329
  return "applied";
166
330
  }
@@ -169,6 +333,9 @@ function applyTurnEvent(session, event, limits) {
169
333
  return "duplicate";
170
334
  }
171
335
  const startObserved = turn.turn_id === event.turn_id && turn.started_at_ms !== null;
336
+ if (startObserved) {
337
+ settleRootTurnActivity(session, "completed");
338
+ }
172
339
  session.root_turn = {
173
340
  status: "completed",
174
341
  turn_id: event.turn_id,
@@ -176,7 +343,15 @@ function applyTurnEvent(session, event, limits) {
176
343
  stopped_at_ms: event.received_at_ms,
177
344
  has_out_of_order_events: !startObserved,
178
345
  };
179
- session.permission = { status: "idle" };
346
+ if (
347
+ session.permission.status === "waiting_for_user" &&
348
+ session.permission.turn_id === event.turn_id
349
+ ) {
350
+ clearPermission(session, "completion_not_observed");
351
+ }
352
+ settleRunningState(session, "completion_not_observed", {
353
+ turnId: event.turn_id,
354
+ });
180
355
  addActivity(
181
356
  session,
182
357
  event,
@@ -192,6 +367,7 @@ function applySubagentEvent(session, event, limits) {
192
367
  agent = {
193
368
  agent_id: event.agent_id,
194
369
  agent_type: event.agent_type,
370
+ turn_id: event.turn_id,
195
371
  status: "unknown",
196
372
  started_at_ms: null,
197
373
  stopped_at_ms: null,
@@ -222,9 +398,18 @@ function applySubagentEvent(session, event, limits) {
222
398
  agent.stopped_at_ms = event.received_at_ms;
223
399
  agent.status = agent.start_observed ? "stopped" : "stopped_without_start";
224
400
  agent.has_out_of_order_events = !agent.start_observed;
401
+ if (agent.start_observed) {
402
+ refineUnresolvedStartActivity(session, {
403
+ type: "subagent_started",
404
+ idField: "agent_id",
405
+ id: event.agent_id,
406
+ status: "stopped",
407
+ });
408
+ }
225
409
  }
226
410
 
227
411
  agent.agent_type = event.agent_type;
412
+ agent.turn_id = event.turn_id;
228
413
  agent.last_seen_at_ms = Math.max(agent.last_seen_at_ms, event.received_at_ms);
229
414
  touchMapEntry(session.agents, event.agent_id, agent);
230
415
  trimMap(session.agents, limits.maxAgentsPerSession);
@@ -274,6 +459,14 @@ function applyToolEvent(session, event, limits) {
274
459
  tool.status = tool.start_observed ? "completed" : "completed_without_start";
275
460
  tool.has_out_of_order_events = !tool.start_observed;
276
461
  activityStatus = tool.status;
462
+ if (tool.start_observed) {
463
+ refineUnresolvedStartActivity(session, {
464
+ type: "tool_started",
465
+ idField: "tool_use_id",
466
+ id: event.tool_use_id,
467
+ status: "completed",
468
+ });
469
+ }
277
470
 
278
471
  const permission = session.permission;
279
472
  if (
@@ -282,7 +475,7 @@ function applyToolEvent(session, event, limits) {
282
475
  permission.turn_id === event.turn_id &&
283
476
  event.received_at_ms >= permission.requested_at_ms
284
477
  ) {
285
- session.permission = { status: "idle" };
478
+ clearPermission(session, "completed");
286
479
  }
287
480
  }
288
481
 
@@ -328,9 +521,29 @@ function applyPermissionEvent(session, event, limits) {
328
521
  }
329
522
 
330
523
  function applyEvent(session, event, limits) {
331
- if (event.type === "session_started" || event.type === "session_ended") {
524
+ if (event.type === "session_started") {
525
+ return applySessionEvent(session, event, limits);
526
+ }
527
+ if (
528
+ session.lifecycle.end_observed &&
529
+ event.type !== "tool_completed" &&
530
+ event.type !== "subagent_stopped"
531
+ ) {
532
+ return event.type === "session_ended" ? "duplicate" : "stale";
533
+ }
534
+ if (event.type === "session_ended") {
332
535
  return applySessionEvent(session, event, limits);
333
536
  }
537
+ if (
538
+ session.root_turn.status === "completed" &&
539
+ session.root_turn.turn_id === event.turn_id &&
540
+ (event.type === "turn_started" ||
541
+ event.type === "permission_requested" ||
542
+ event.type === "tool_started" ||
543
+ event.type === "subagent_started")
544
+ ) {
545
+ return "stale";
546
+ }
334
547
  if (event.type === "turn_started" || event.type === "turn_stopped") {
335
548
  return applyTurnEvent(session, event, limits);
336
549
  }
@@ -343,22 +556,51 @@ function applyEvent(session, event, limits) {
343
556
  return applyPermissionEvent(session, event, limits);
344
557
  }
345
558
 
346
- function snapshotSession(session) {
559
+ function snapshotSession(session, nowMs, staleAfterMs) {
560
+ const staleActive = isStaleActiveSession(session, nowMs, staleAfterMs);
347
561
  return {
348
562
  session_id: session.session_id,
349
563
  workspace_label: session.workspace_label,
350
564
  task_summary: session.task_summary,
351
- status: deriveSessionStatus(session),
565
+ status: deriveSessionStatus(session, nowMs, staleAfterMs),
352
566
  first_seen_at_ms: session.first_seen_at_ms,
353
567
  last_seen_at_ms: session.last_seen_at_ms,
354
568
  agents: [...session.agents.values()]
355
- .map(({ start_observed, stop_observed, ...agent }) => ({ ...agent }))
569
+ .map(({ start_observed, stop_observed, ...agent }) => ({
570
+ ...agent,
571
+ ...(staleActive && agent.status === "running"
572
+ ? { status: "completion_not_observed" }
573
+ : {}),
574
+ }))
356
575
  .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
357
- root_turn: { ...session.root_turn },
576
+ tools: [...session.tools.values()]
577
+ .map(({ start_observed, completion_observed, ...tool }) => ({
578
+ ...tool,
579
+ ...(staleActive && tool.status === "running"
580
+ ? { status: "completion_not_observed" }
581
+ : {}),
582
+ }))
583
+ .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
584
+ root_turn: {
585
+ ...session.root_turn,
586
+ ...(staleActive && session.root_turn.status === "running"
587
+ ? { status: "completion_not_observed" }
588
+ : {}),
589
+ },
358
590
  recent_activities: session.recent_activities.map((activity) => ({
359
591
  ...activity,
592
+ ...(staleActive &&
593
+ (activity.status === "running" ||
594
+ activity.status === "waiting_for_user")
595
+ ? { status: "completion_not_observed" }
596
+ : {}),
360
597
  })),
361
- permission: { ...session.permission },
598
+ permission: {
599
+ ...session.permission,
600
+ ...(staleActive && session.permission.status === "waiting_for_user"
601
+ ? { status: "completion_not_observed" }
602
+ : {}),
603
+ },
362
604
  };
363
605
  }
364
606
 
@@ -381,6 +623,10 @@ export function createMonitorStore(options = {}) {
381
623
  options.maxSessions ?? DEFAULT_LIMITS.maxSessions,
382
624
  "maxSessions",
383
625
  ),
626
+ staleAfterMs: positiveInteger(
627
+ options.staleAfterMs ?? DEFAULT_LIMITS.staleAfterMs,
628
+ "staleAfterMs",
629
+ ),
384
630
  };
385
631
  const now = options.now ?? Date.now;
386
632
  if (typeof now !== "function") {
@@ -445,12 +691,15 @@ export function createMonitorStore(options = {}) {
445
691
  }
446
692
 
447
693
  function getSnapshot() {
694
+ const snapshotAtMs = now();
448
695
  return {
449
696
  schema_version: 1,
450
697
  source_of_truth: "hook",
451
698
  updated_at_ms: updatedAtMs,
452
699
  sessions: [...sessions.values()]
453
- .map(snapshotSession)
700
+ .map((session) =>
701
+ snapshotSession(session, snapshotAtMs, limits.staleAfterMs),
702
+ )
454
703
  .sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
455
704
  diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
456
705
  };
@@ -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;