English | 简体中文
Purpose: document the core mechanics behind Codex CLI/App Server sessions (threads), persistence, resume, prompts, context, token usage, compaction, and local files. It also records how this project actually used those artifacts while building Windows monitoring and Lark handoff, so future session-management, migration, search, monitoring, analytics, and recovery features can build on a consistent mental model.
This document combines:
- Project observations from Windows + Codex CLI 0.155.x with an isolated
CODEX_HOME.- Public Codex implementation/documentation from OpenAI's Codex docs and the
openai/codexrepository. Internal storage formats can evolve and should not be treated as permanent public APIs.Most of the document is the Codex reference. The Claude Code side — its transcripts and process registry, the Claude Release Agent, and how both agents bind to a Lark scope — is covered under
/session listin section 28.
Lark Scope → /lark status | /lark new | /lark resume
Windows Runtime → /windows status | /windows release (Codex only)
Global Sessions → /session list | /session use | /session handoff | /session handback
Legacy command names remain compatibility aliases, but this document uses the grouped names for project-level session management.
Different Codex surfaces use slightly different terminology:
What a user thinks of as "one chat"
│
├── CLI often calls it a saved chat/session
├── App Server primarily calls it a Thread
└── rollout data may contain session_id / thread_id / turn_id
This project calls the persistent UUID used to identify/resume a long-lived conversation the Session ID, for example:
01a0beb6-d86e-70c0-8607-e4309012ba49
The App Server generally calls the same logical object a Thread.
A Thread contains Turns. A Turn is one user request plus the work triggered by it. A Turn contains Items such as user messages, agent messages, command execution, and tool calls.
flowchart TD
T["Thread / Session"] --> A["Turn 1"]
T --> B["Turn 2"]
T --> C["Turn 3"]
B --> B1["User Message"]
B --> B2["Agent Message"]
B --> B3["Tool Call"]
B --> B4["Tool Result"]
This project adds a separate Writer / Owner abstraction:
One Session
│
├─ Windows Codex TUI
└─ Lark scope
Only one logical writer should own the Session at a time.
That ownership layer is project-specific, not part of Codex's native persistence format.
The local bridge now exposes Session management through interactive Lark cards, not only text commands. This is intended to make remote operation practical from both Lark Mobile App and Lark Web:
/lark status
→ current Lark scope + quick actions
/windows status
→ Windows runtime sessions + Release actions
/session list
→ global inventory + Use / Handoff / Hand Back actions
Buttons carry only the action and its direction (Use in Lark, Handoff to Lark, Hand Back to Windows); the numbered row above identifies the Session and the hover tip repeats its title. Execution remains Session-ID based:
Display: the numbered row — Thread Name, or a short Session ID when unnamed
Target: exact full Session ID in the button payload
Project name and cwd are metadata only and must not be used as action identities because several Sessions can share the same project directory. This distinction is especially important on mobile, where buttons replace long manual selectors. Lark Web/Desktop can additionally show hover_tips; mobile clients rely on the visible button text.
Default Codex state typically lives under:
~/.codex
This project uses an isolated third-party provider home such as:
C:\Users\<user>\.codex-cli-thirdparty
selected through:
$env:CODEX_HOME = "$HOME\.codex-cli-thirdparty"A representative layout:
$CODEX_HOME/
├─ config.toml
├─ AGENTS.md
├─ session_index.jsonl
├─ sessions/
│ └─ YYYY/MM/DD/
│ └─ rollout-...-<SessionId>.jsonl
├─ archived_sessions/ # version/client dependent
├─ state_*.sqlite # newer thread metadata/state
├─ logs_*.sqlite # version dependent
├─ memories_*.sqlite # may exist in some builds/clients
├─ auth.json # may exist in OpenAI-auth environments
└─ other cache/runtime files
Do not assume every release exposes every file. Prefer capability detection over hard-coded presence assumptions.
flowchart TD
I["session_index.jsonl"] --> X["Session Inventory"]
R["sessions/.../rollout-*.jsonl"] --> X
D["state_*.sqlite"] --> X
I -->|Thread name / ID index| X
R -->|Canonical event history| X
D -->|Thread metadata / search / archive| X
X --> Y["resume / search / monitor / handoff"]
For this project:
- rollout JSONL is the most important history source;
session_index.jsonlis especially useful for thread names;state_*.sqliteis not yet a hard dependency, but is promising for future high-performance session management.
Example:
$CODEX_HOME\sessions\2026\09\20\
rollout-2026-09-20T05-07-30-01a0beb6-....jsonl
Each line is an independent JSON object.
discover new sessions
obtain Session ID
obtain cwd
read CLI version
read model/provider
read approval/permission metadata
infer Working/Waiting
read token/context usage
observe task_started/task_complete
session_meta
turn_context
event_msg
response_item
token_usage_record
world_state
The Codex implementation may also persist or interpret items such as:
Compacted
RetainedContext
WorldState
RealtimeItem
InterAgentCommunication
Parsers should therefore be forward-compatible:
switch (item.type) {
case 'session_meta':
case 'turn_context':
case 'event_msg':
// parse known data
break;
default:
// preserve or ignore unknown data
break;
}Never treat the currently observed event set as a permanent schema.
Fields observed by this project include:
session_id
id
timestamp
cwd
originator
cli_version
source
thread_source
model_provider
base_instructions
history_mode
context_window
git
Useful questions:
Which Session owns this rollout?
What was the starting cwd?
Which CLI version created it?
Where did the Thread originate?
What provider/base instructions were active initially?
A reliable handoff needs at least:
Session ID
+
cwd
Resuming a valid Session ID in the wrong project directory can still produce an incorrect working environment.
Sessions can originate from different clients/surfaces. Pickers and lists may filter by source. The existence of a rollout file does not guarantee a default UI picker will display that thread.
For programmatic recovery, stable Thread/Session ID is preferable.
Observed fields include:
turn_id
root_turn_id
cwd
workspace_roots
current_date
timezone
approval_policy
approvals_reviewer
sandbox_policy
permission_profile
model
comp_hash
personality
collaboration...
It answers:
Under what model, directory, permissions, time, and collaboration mode did this Turn run?
Do not use only initial session_meta for "current session state". Settings can change later.
Recommended aggregation:
session_meta initial values
↓
latest turn_context
↓
latest thread_settings_applied
↓
newer values override older ones
This is the same general strategy used by this project's Watch-CodexSession.ps1.
Observed inside event_msg, with settings such as:
model
model_provider_id
reasoning_effort
approval_policy
permission_profile
cwd
personality
collaboration_mode
For a status UI, the latest applied settings are often more useful than the initial metadata.
Example:
{
"id": "01a0...",
"thread_name": "Online-Web-Forms",
"updated_at": "2026-09-19T21:41:25.9104746Z"
}Important behavior:
append-only
one Session ID may have multiple rename records
latest updated_at / latest append wins
Wrong:
stop at the first matching Session ID
Correct:
scan all records for that ID
→ choose the latest
On Windows PowerShell 5.1, read it explicitly as UTF-8 to avoid mojibake for non-ASCII names.
In this project:
rollout
→ Session ID / cwd / state / model / usage
session_index.jsonl
→ Thread name
This project does not currently require the state DB, but newer Codex clients increasingly rely on a state database for thread metadata, search, archive state, and listing behavior.
Potential future uses:
high-performance /session list
hundreds/thousands of Thread searches
pagination
filters by cwd/source/provider
archived sessions
thread title/preview
fast Recent lists
rollout + session_index
already provide enough information for:
Windows discovery
Observer
thread names
cwd
model
usage
handoff
Direct coupling to internal SQLite schemas increases upgrade risk.
Prefer read-only access. Avoid manually writing the DB unless the current Codex schema and synchronization rules are fully understood.
A model request is better understood as layered context:
flowchart TD
A["Base / System Instructions"] --> M["Model Context"]
B["Developer / Client Instructions"] --> M
C["AGENTS.md hierarchy"] --> M
D["Session History"] --> M
E["Compaction Summary / Retained Context"] --> M
F["Current User Prompt"] --> M
G["Tool Results / Environment State"] --> M
A Prompt Inspector should not assume "user prompt + assistant response" is the complete model input.
This project observed:
base_instructions
inside session_meta.
It can help answer:
Which base instructions were active at session creation?
Did a Codex version/mode change the base prompt?
But it should not be treated as the complete set of system/developer context. Additional inputs may include:
AGENTS.md
developer instructions
sandbox/permission instructions
tool schemas
dynamic environment state
history
compaction summaries
Codex officially supports a layered discovery model including:
$CODEX_HOME/AGENTS.md
repository-root AGENTS.md
nested AGENTS.md / AGENTS.override.md
Instructions are assembled from the project root toward the current working directory, with more local rules appearing later and therefore taking precedence.
Good uses:
test commands
coding style
repository constraints
deployment rules
team workflow
Do not use AGENTS as a dumping ground for one-off session state.
If two identical user prompts behave differently, inspect both:
cwd
+
AGENTS instruction chain
User input and assistant/tool output are represented as session items/events.
response_item may correspond to:
assistant messages
tool/function interactions
Responses API items
A rollout is a machine event log, not a ready-to-display Markdown transcript.
Build a transcript through:
RolloutItem
→ normalize
→ filter internal-only items
→ map to user / assistant / tool timeline
A session inspector should distinguish:
persisted events
≠ complete model-visible context
≠ content suitable for user display
Stable product features should rely on:
user messages
assistant messages/finals
tool calls/results
turn metadata
reasoning summaries (when supported)
compaction summaries
Do not assume full internal chain-of-thought is persisted in plaintext or should be exposed.
Long sessions do not indefinitely resend the full raw history.
Codex supports:
/compact
automatic compaction
Conceptually:
flowchart LR
H["Long Raw History"] --> C["Compaction"]
C --> S["Compact Summary / Retained Context"]
S --> N["Future Model Context"]
H --> R["Persisted Rollout"]
Key distinction:
full history persisted on disk
is not the same as:
effective context sent to the model on the next turn
A replay tool should not blindly concatenate every rollout line into a future model request.
These are important for advanced tools.
Signals that historical context was compacted.
Represents context retained for future model calls after compaction.
Relates to environment/workspace state used by persistence or reconstruction.
Main lesson:
"Show full history" and "reconstruct model context" are two different problems.
Codex configuration supports values such as:
model_context_window = 128000
model_auto_compact_token_limit = 64000If omitted, model metadata/defaults are used.
External tools should not hard-code assumptions like:
this model is always 128K
compaction always happens at exactly X%
Both models and Codex implementations evolve.
During this project, a rollout could show approximately:
13.7K tokens
258K context window
A simple ratio suggests roughly:
95% left
while Codex TUI may display a value closer to:
99% left
Codex UI/context accounting can apply a different effective-window/baseline model, and compaction may trigger a context recomputation.
Therefore:
If exact parity with Codex
/statusmatters, do not derive the percentage solely from cumulative thread usage divided by context window.
Reuse the current Codex accounting logic when possible.
Observed fields include:
thread_id
turn_id
session_id
root_turn_id
response_id
usage
turn_token_usage
thread_token_usage
Typical usage dimensions:
input_tokens
cached_input_tokens
output_tokens
reasoning_output_tokens
total_tokens
The same record family can expose:
response usage
turn cumulative usage
thread cumulative usage
This distinction is essential:
current effective context
≠ thread cumulative tokens
≠ final API bill
A thread can accumulate millions of tokens over many turns while the current model context is much smaller because of compaction.
Conversely, a 50K current context does not mean the thread only consumed 50K tokens overall.
Rollout usage can support estimates, not necessarily an authoritative invoice.
A cost estimator needs:
input
cached input
output
model
provider
service tier
provider-specific pricing
Third-party providers especially require usage and pricing to be separated:
TokenUsageRecord
+
Model
+
Provider
+
Price Table
↓
Estimated Cost
Use OpenAI's pricing page only for OpenAI-billed traffic; third-party providers may price differently.
A dashboard should distinguish:
Context Window
Current Context Usage
Thread Cumulative Usage
Estimated Cost
Rate Limit
Subscription / Provider Quota
"Context 90% left" does not imply "API quota 90% left."
CLI supports:
codex resume
codex resume <Thread-ID>
App Server exposes:
thread/start
thread/resume
thread/fork
thread/read
thread/list
The App Server documentation treats the Thread as the primary conversation primitive.
For handoff metadata, preserve at minimum:
Session / Thread ID
cwd
A rollout path can be diagnostic/fallback metadata, but normal recovery should not depend too heavily on a filesystem path.
resume
→ continue appending to the original Thread
fork
→ derive a new Thread ID from existing history
flowchart LR
A["Thread A"] -->|resume| A2["Thread A continued"]
A -->|fork| B["Thread B"]
If a future Lark feature needs an experimental branch from the current session, use fork semantics rather than copying rollout files.
Codex App Server already provides:
thread/read
thread/list
which can inspect stored threads without resuming them.
For a productized remote Session Manager, consider moving toward:
Bridge
→ Codex App Server thread/list
→ thread/read
→ thread/resume
rather than continuously increasing direct dependencies on internal rollout/state schemas.
Direct rollout parsing remains valuable for:
debugging
forensics
custom analytics
compatibility fallback
Newer Codex flows support archive/unarchive behavior and may reflect it in:
archived_sessions
state DB
Future commands could expose:
/session list active
/session list archived
/session list all
This project does not currently rely on archive metadata.
During development we confirmed that immediately after:
codex3
there may be only:
Windows process
LaunchId
cwd
owner PowerShell PID
while a discoverable persistent session still needs:
Session ID
rollout
session/index metadata
A useful lifecycle model is:
Pending Launch
↓
Persisted Session
↓
Active Writer
↓
Detached / Archived
This explains why a visible Codex window may not immediately appear in /session list.
flowchart TD
P["Windows codex3 process"] --> A["Attach-CodexObserver.ps1"]
R["rollout JSONL"] --> A
R --> O["Watch-CodexSession.ps1"]
I["session_index.jsonl"] --> O
O --> S[".codex-monitor/status-SessionId.json"]
A --> L["launch mapping"]
A --> RA["Watch-CodexRelease.ps1"]
S --> LS["/windows status"]
S --> INV["/session list"]
L --> H["/session handoff"]
RA --> H
Uses:
process
cwd
launch time
rollout session_meta
command line (resume <Session-ID | unique thread name>)
session_index (thread name → Session ID)
to discover a persistent session — immediately for codex3 resume, on the first rollout write for a new session (see Codex attach and Observer lifecycle below).
Uses:
rollout + session_index
its Codex PID + creation time
to build a lightweight status projection, and exits when its Codex exits.
Combines:
session_index
rollout metadata
Windows monitor
Lark SessionStore
to present thread/project/owner/cwd information.
The card opens on category tabs (Handoff / Use / Hand Back / All) drawn from
the 10 most recent Sessions. sessionCategory() mirrors
sessionActionButtons(), so a tab lists exactly the Sessions that render its
button. A tab click recalls the old card and posts a new one: in-place
updateCard() (im.v1.message.patch) fails silently, because Feishu answers a
refused patch with HTTP 200 and a non-zero body code that the SDK discards.
run-flow.ts resolves what to resume from sessionCatalog.activeFor(scope, agent, cwdRealpath, policyFingerprint) first, and — for Claude only — falls
back to sessions.resumeFor(). Writing sessions.json alone is therefore not
a binding: Codex ignores it entirely. src/commands/session-binding.ts
(bindScopeToSession() / unbindScope()) writes both, mirroring upstream's
applyResume() and /new. The catalog key includes the cwd, and use
switches the cwd, so the identity is recomputed after setCwd(). Handback must
archive the catalog entry, or the next Lark message would still resume the
Session alongside the Windows writer.
A profile runs exactly one agent, so every /session command operates on the
profile's own agent, and the commands are identical on a Codex and a Claude
bot. SessionProvider (src/commands/session-provider.ts) separates the
agent-neutral shapes (SessionInventoryItem, HandoffState) from the code
that fills them; the Claude side lives in claude-session-state.ts,
claude-handoff.ts and src/session/claude-transcript.ts.
| Data | Source |
|---|---|
| Sessions, updated time | ~/.claude/projects/*/<uuid>.jsonl |
| cwd | cwd inside the transcript (the directory name is a lossy encoding) |
| Title | custom-title.json → latest ai-title → first prompt, unwrapping the bridge's <user_input> |
| Running on Windows | ~/.claude/sessions/<pid>.json (Claude's own live-process registry) |
Registry facts, each verified on a real machine and each contradicting an initial assumption:
updatedAtis not a heartbeat — it moves only on activity, so liveness comes from the PID and identity fromprocStart(a Windows FILETIME equal to the live process'sStartTime.ToFileTimeUtc()).claude -p, including the bridge's own runs, registers too, asentrypoint: sdk-cli; onlycliis a terminal window.- A window that has never been sent anything has no transcript yet; it is listed from the registry as No conversation yet and is not transferable.
EPERM is not "gone". The bridge is an upstream /RL LIMITED scheduled task
(\LarkChannelBridge.Bot.<profile>), so it stays non-elevated even when started
from an elevated terminal. Probing an elevated process from it returns EPERM,
which the old isProcessAlive() read as dead — elevated Claude windows showed
as Detached. processState() now distinguishes alive / denied / gone. Codex
never showed the bug because its liveness comes from the Observer's heartbeat
file; the PID fallback was silently wrong for elevated Codex writers too.
Handoff. Request-ClaudeRelease.ps1 terminates the writer only if it is a
single idle cli window whose procStart matches, then removes the registry
files the forced exit left behind. A non-elevated bridge cannot do this to an
elevated window, so — as Codex does with its codex3-started Release Agent —
the work is delegated to an elevated Claude Release Agent
(Watch-ClaudeRelease.ps1) over files in ~/.claude-monitor
(requests/, results/, heartbeat agent.json). Codex gets an elevated agent
for free because codex3 runs inside the elevated terminal; Claude is launched
directly, so Register-ClaudeReleaseAgent.ps1 registers a /RL HIGHEST
ONLOGON task — the bridge's own mechanism with a different run level.
Verified from a Medium-integrity process: with the agent running, an elevated
window turns Ready, and releasing a disposable elevated window returned
OK|RELEASED in about 2.3 s.
Transfers and Last Response. The handoff and /session use result cards both
show the Last Response ("Last Windows Response" for a handoff) with a Hand Back
button. Handback carries nothing back: Lark turns are written to the same
rollout / transcript on the same machine. It is display-only — the model reads
the full history from the rollout / transcript in either direction.
Two measured problems made Codex handoff states disagree with reality.
1. Resumed sessions were never attached. Attach matched only rollouts
written after launch. codex3 resume reopens an old rollout and writes
nothing until the first prompt, so an idle resume was never attached — no launch
mapping, no Release Agent, and a Windows writer invisible to the bridge. Real
case: the attach for codex3 resume Online-Web-Forms-Htmls sat at "still waiting
for matching rollout" while that session was still bound to Lark — an unnoticed
second writer.
Fix (Resolve-ResumeTarget in Attach-CodexObserver.ps1): take the argument
after resume from the Codex command line — a UUID directly, a name through
session_index.jsonl (latest record per id). A name is used only when exactly
one session carries it: the real index has "Show git status" on six sessions,
and a wrong guess would attach the Observer to the wrong session, so a later
handoff would terminate this Codex while binding another session to Lark. The
session's cwd must match the launch directory; otherwise — and for
resume --last or the picker — the original activity matching applies. Once the
command line names a session, only that session is claimed.
2. Get-Content -Tail hung on large rollouts. Get-RolloutMetadata read the
tail with Get-Content -Tail 200; on a 64 MB rollout with lines up to 1.27 MB it
ran for over five minutes (the 100-line head read took 39 ms). It now reads the
last 4 MB as bytes and JSON-parses only lines that can matter. Old and new
agreed on all 12 rollouts compared, 10–30× faster.
3. Stale Observers. An Observer watched only its Session ID's rollout, not
whether its Codex was alive; cleanup relied on codex3's finally block calling
Stop-CodexObserver.ps1, which never runs when the terminal is closed with the X
button. The Observer then heartbeated "Waiting" forever and the session looked
Windows-owned and handoff-ready (01a0bdff: Codex, terminal, and Release Agent
all gone, Observer alive).
Fix: Attach passes -CodexPid, -CodexCreatedUtc, and -LaunchId; on each
heartbeat the Observer checks its Codex, and if it is gone or its PID was reused
(creation time differs) it marks the status Exited, removes only this launch's
mapping and claim — what Stop-CodexObserver.ps1 does — and exits. Observers
started by hand (no PID) keep the old behaviour. For Observers that already
outlived their Codex, the bridge reports Needs validation · Codex exited; stale
Observer (pid N), not transferable, when the heartbeat is fresh but the
recorded Codex is provably gone (not EPERM). Deliberately not Detached: that
Observer blocks a new codex3 attach of the session, so a writer the bridge
cannot see may exist.
Codex states that are not transferable (HandoffState.transferable === false),
and why a handoff could only fail:
| Reason | Why |
|---|---|
| Launch mapping unavailable | Codex Release Agents are per session; without an attach there is none, and Release-CodexSession.ps1 exits with LAUNCH_MAPPING_NOT_FOUND |
| Release Agent not recorded | Nothing picks up the release request |
| Codex exited; stale Observer | Nothing to terminate; its Release Agent exited with Codex |
"Observer heartbeat stale" still offers Handoff — it can recover within seconds,
and the release chain validates authoritatively. Card categories, buttons, and
/session handoff all honour transferable; the command explains the reason and
the next step.
Large session rollouts can become very large.
Bad architecture:
every /session list call
→ walk every session
→ parse every JSONL file from beginning to end
Recommended:
Canonical history:
rollout
Indexes:
session_index / state DB
Live projection:
.codex-monitor/status
UI:
sessions command
The rollout is the historical source of truth, but list UIs should prefer indexes and projections.
Read:
session_index
rollout filenames
mtime/state DB
for:
Session ID
Thread name
rough update time
Read only early session_meta:
cwd
provider
CLI version
source
Tail rollout for:
task started/complete
settings
token usage
to derive:
Working/Waiting
model
context
Replay full rollout for:
conversation history
tool-call analytics
token/cost analysis
debugging
Do not make Level 4 the default inventory path.
Codex persistence alone does not guarantee that:
Windows
Lark
Desktop
VS Code
another machine
will not concurrently write to the same Thread.
This project therefore tracks:
Windows
Lark scope
Detached
Unknown / Unmanaged
using evidence such as:
Observer heartbeat
launch mapping
Release Agent
Lark SessionStore binding
Important limitation:
Absence of monitor evidence does not prove the absence of a Windows TUI.
An older unmanaged session can therefore be Unknown / Unmanaged, not truly Detached.
For multi-machine or multi-client expansion:
Telegram
Web dashboard
SSH
mobile app
an explicit lease model is recommended:
{
"sessionId": "01a0...",
"owner": "windows:host-a:pid-123",
"leaseId": "lease-...",
"acquiredAt": "...",
"heartbeatAt": "...",
"expiresAt": "..."
}This enables:
Acquire
Renew
Release
Force-expire
Owner conflict detection
and is more robust than inferring ownership from multiple runtime artifacts.
If this project evolves into a general remote Codex Session Manager, evaluate Codex App Server as the primary integration layer:
thread/list
thread/read
thread/resume
thread/fork
thread/archive
turn/start
turn/interrupt
Advantages:
Codex interprets its own storage
lower internal-schema coupling
runtime Thread status
built-in pagination/search
Keep rollout parsing for diagnostics and specialized analytics.
Before implementing a feature, ask:
- Is this Thread metadata or live runtime state?
- Should the source be rollout, session index, state DB, or App Server?
- Do I need full history or just metadata?
- Can this operation modify a Session and create a dual-writer race?
- Does it handle compaction correctly?
- Does it distinguish current context from cumulative token usage?
- Is it assuming an internal event/schema will never change?
- Can it tolerate old sessions with missing fields?
- Can it handle rename/archive/resume/fork?
- Is provider-specific pricing separated from token usage?
- Codex CLI:
https://developers.openai.com/codex/cli - Codex App Server / Thread API:
https://developers.openai.com/docs/app-server - Configuration reference:
https://developers.openai.com/docs/config-file/config-reference - Configuration sample:
https://developers.openai.com/docs/config-file/config-sample AGENTS.mdinstruction discovery:
https://developers.openai.com/docs/agent-configuration/agents-md- Codex customization overview:
https://developers.openai.com/docs/customization/overview
- GitHub:
https://github.com/openai/codex - Useful source-search terms:
RolloutRecorder,SessionIndex,ThreadManager,TokenUsage,Compacted,RetainedContext,ModelContext,thread/resume
- OpenAI API Pricing:
https://developers.openai.com/api/docs/pricing
The most important rules for future session tooling are:
Session / Thread ID is the primary identity
cwd is critical recovery metadata
rollout is the main historical event source
session_index is useful for names/indexing
state DB is useful for fast queries but its internal schema can evolve
full history != current model context
cumulative tokens != current context != final billing
compaction is part of recovery semantics
unknown events must be handled forward-compatibly
one Session should have only one logical writer at a time
For this project, Windows Observer + Release Agent + Lark SessionStore already form a usable ownership layer. The most valuable future directions are explicit ownership leases and Codex App Server integration.