git:20260826.a61c57b to git:20260831.7200cdb
209 added, 83 removed. Audit A to A.
# Codex memory plugin — commit decision design
This document records *why* the plugin commits when it commits. The commit
shape (which OpenViking session is sealed by which hook event) is the part
- worth understanding before reading code: the codex hook surface gives us
- **no clean SessionEnd signal**, so we have to reason about which observable
+ worth understanding before reading code: `SessionEnd` gives us a
+ deterministic end signal for graceful exits, but it does not cover signals,
+ crashes or older Codex builds, so we still reason about which observable
events imply "context for a particular codex `session_id` is gone".
## Vocabulary
- **codex `session_id`** — the codex thread/session id. Stable across
process restarts when zouk-daemon resumes the same thread; replaced when
`/clear`, `/new`, fresh codex startup, or zouk reset occurs.
- **OV session** — `viking://user/sessions/cx-<codex-session-id>`. New captures
derive the OV session id from the codex `session_id` with a `cx-` prefix,
append messages on every `Stop`, and commit it (which triggers OV's
memory extractor) at session-end-equivalent moments. `/messages`
auto-creates the OV session, so the plugin does not call session create.
- **State file** — `~/.openviking/codex-plugin-state/<safe-codex-session-id>.json`,
- shape `{ codexSessionId, ovSessionId, capturedTurnCount, createdAt, lastUpdatedAt }`.
- - **Active window** — state files whose `lastUpdatedAt` is within
- `ACTIVE_WINDOW_MS` (default 2 min) of "now". Used to detect "the codex
- session that just ended".
+ shape `{ codexSessionId, ovSessionId, transcriptPath, capturedTurnCount, createdAt, lastUpdatedAt }`.
+ - **End marker** — `<safe-codex-session-id>.ended.<timestamp>`, a sidecar written by the
+ `SessionEnd` parent hook, containing the timestamp at which it was
+ written. Its presence means "the thread ended and its commit has not been
+ confirmed yet"; its timestamp is the token that tells one exit's marker
+ from another's, and it lives in the filename so a conditional removal
+ targets an immutable path. A bare `<safe-codex-session-id>.ended` is a
+ pre-0.8.1 marker and is still read back.
+ - **Session lock** — `<safe-codex-session-id>.lock`, an exclusive mkdir lock
+ serializing the four writers that persist the whole state object. It holds
+ an `owner` file so a holder only ever releases its own lock.
## Codex hook surface (what we observe)
| Codex event | Fires when | What we learn |
|---|---|---|
| `SessionStart` source=`startup` | fresh codex process; `/new`; zouk daemon spawn-without-sessionId; zouk reset | new `session_id` was created |
| `SessionStart` source=`resume` | `/resume`; short reconnect; zouk daemon spawn-with-sessionId | same `session_id` continues; may need archive continuity |
| `SessionStart` source=`clear` | `/clear` (creates a fresh thread, preserves prior thread on disk as resumable) | new `session_id`; previous one orphaned |
| `UserPromptSubmit` | every user turn before model | recall context inject |
| `Stop` | end of every model turn (NOT end of session) | append turns to OV session |
| `PreCompact` | `/compact` or auto-compact | context is about to be summarized |
| `PostCompact` | after compaction | (unused) |
- | SIGTERM / SIGINT / Ctrl+C / `/exit` | process killed | **no hook fires** — confirmed in `codex-rs/hooks/src/events/` |
+ | `SessionEnd` | graceful thread shutdown: `/quit`, `/exit`, double Ctrl-C, EOF, end of a `codex exec` run | this thread's context is gone; commit it |
+ | SIGTERM / SIGHUP / terminal close / `kill -9` / crash | process killed | **no hook fires** — tui/exec install no signal handlers |
- Verified against codex-rs `main` 2026-05-10. Upstream issues #17421, #20374
- have requested a `SessionEnd` hook; OpenAI rejected with two reasons:
- "threads can always be resumed" and "/exit only makes sense in TUI". Not
- landing.
+ Verified against codex-rs `main` @ 6be2a6ca, 2026-08-28. `SessionEnd` shipped
+ in `rust-v0.145.0` and is present in every stable since; older Codex builds
+ (and TraeCode CLI builds without it) ignore the unknown event name, which is why
+ the fallback sweep stays.
## Commit triggers
We commit an OV session in exactly these places. Everything else is no-op
or append-only.
### 1. `PreCompact` — deterministic, current session
Codex fires `PreCompact` before summarizing. We catch up with any
unappended turns from the transcript, commit the OV session for this codex
`session_id`, and clear `ovSessionId` so the next `Stop` re-derives the
same `cx-<codex-session-id>` OV session id for the post-compact half.
`capturedTurnCount` is preserved unless the transcript was truncated by
compaction (see "Post-compact transcript shrink" below).
- ### 2. `SessionStart` source=`clear` — heuristic, same shape as `startup`
-
- `/clear` creates a brand-new codex `session_id` and orphans the previous
- in-memory thread (preserved on disk). Naively committing "every state file
- whose `codexSessionId` ≠ new id" would falsely commit concurrent codex
- processes' still-active sessions on the same machine.
+ ### 2. `SessionEnd` — deterministic, graceful exits
- Instead, we treat `clear` and `startup` identically: both run the
- **active-window heuristic** below. `/clear` only invalidates the current
- codex process's *previous* session; the heuristic correctly catches that
- session (a single recently-touched orphan) without trampling unrelated
- parallel codex processes.
+ This is the primary commit path. Codex fires `SessionEnd` when a thread
+ shuts down gracefully: `/quit`, `/exit`, double Ctrl-C, EOF on stdin, and
+ the end of a `codex exec` run. In a single TUI process every thread the
+ process touched via `/new` or `/resume` gets its own `SessionEnd`, all in a
+ burst at process exit — so `/new` on its own does not end the previous
+ thread; its end event arrives later, when the process leaves.
- ### 3. `SessionStart` source=`startup` — heuristic, active-window
+ The rollout is flushed to disk before the hook runs, so the hook sees the
+ complete transcript. `session-end.mjs` therefore catches up whatever turns
+ the last `Stop` never sent, then commits the OV session and clears
+ `ovSessionId` with `touch: false`.
- Triggered by `/new`, fresh codex CLI startup, and zouk daemon
- spawn-without-sessionId (including zouk's "reset codex" UI action).
+ The budget forces the work off the hook's own process. Codex allows 1s by
+ default, clamps a `timeout` in `hooks.json` to 3s, forces `async: true`
+ hooks to run synchronously, and ignores stdout — nowhere near enough for a
+ catch-up append plus a commit. So the parent hook does two cheap things and
+ exits: write the `.ended` sidecar (lock-free, before anything else, so the
+ sweep can still recover if the worker never runs) and detach a worker,
+ regardless of `writePathAsync`. Codex deliberately leaves cleanly detached
+ helpers running after a hook exits and only kills the process group on a
+ timeout, and the detached worker holds no inherited stdout/stderr, so Codex
+ does not wait on it.
- The hook script gates internally on `source ∈ {startup, clear}`. On a
- match, it iterates state files (excluding the new `session_id` itself) and
- counts how many were touched within `ACTIVE_WINDOW_MS`:
+ `SessionEnd` does **not** fire on SIGTERM, SIGHUP, a closed terminal,
+ `kill -9`, or a crash. When the TUI is attached to a `codex app-server`
+ daemon, it is deferred until the thread is unloaded (30 min) or the daemon
+ shuts down. Those cases fall to the fallback sweep.
- ```
- recently-active count ⇒ action
- ─────────────────────────────────
- 0 ⇒ no-op (no orphan to commit)
- 1 ⇒ commit it (the just-ended session)
- ≥2 ⇒ skip; rely on idle TTL
- ```
+ ### 3. `SessionStart` source=`startup` / `clear` — fallback sweep entry
- The single-recent case captures the common path: user runs codex, hits
- `/new` or `/clear` after a turn or two; the previous session's `Stop` just
- fired and bumped `lastUpdatedAt`; we commit it. The multi-recent case
- implies concurrent codex sessions are active; we can't tell which one (if
- any) ended, so we defer to idle TTL to clean up genuinely-dead ones.
+ Triggered by `/new`, `/clear`, fresh codex CLI startup, and zouk daemon
+ spawn-without-sessionId (including zouk's "reset codex" UI action).
- The count is over *activity*, including cursor-only states: a session
- `PreCompact` just committed has no live `ovSessionId` but may well still be
- running, and counting it keeps the `≥2` branch from sealing a sibling
- session mid-flight. Only a state that still has a live `ovSessionId` is
- actually committed; the single-recent case with nothing live is a no-op.
+ `/clear` creates a brand-new codex `session_id` and orphans the previous
+ in-memory thread (preserved on disk); `startup` may or may not follow an
+ exit that already committed. Either way the hook does not try to guess
+ which session just ended — that is `SessionEnd`'s job. It gates internally
+ on `source ∈ {startup, clear}` and runs the fallback sweep of rule 5 over
+ every state file except the new `session_id`.
### 4. `SessionStart` source=`resume` — never commits, optional archive inject
Short reconnects and `/resume` re-fire `SessionStart` for the same
`session_id`. Committing here would seal a still-active session. So
`resume` is a no-op for commit purposes.
All `SessionStart` sources (`startup`, `clear`, and `resume`) independently
load the shared OpenViking profile block unless
`OPENVIKING_NO_AUTO_INJECT=1`. The implementation is the same
`buildProfileBlock()` used by the other coding-agent integrations: full
`profile.md` plus abstract-annotated URI indexes for `preferences/` and
`entities/`, bounded by `OPENVIKING_PROFILE_TOKEN_BUDGET` with the shared
CJK-aware estimator. Profile loading does not alter the commit decision tree.
Resume may still need continuity after `PreCompact` or idle sweep already
committed the live OV session. If local state has `ovSessionId = null`
(or no state file remains), the hook derives `cx-<codex-session-id>`,
calls `GET /api/v1/sessions/{id}/context?token_budget=...`, and injects
`latest_archive_overview` via `hookSpecificOutput.additionalContext` when
present. The injected block includes
`viking://user/sessions/{id}/history/` so the model can use OpenViking MCP
read/search tools for exact prior details. When both profile and archive are
available, they are combined in one `SessionStart` response.
If local state still has a live `ovSessionId`, resume injection is skipped:
the session is appendable and Codex should already be resuming its own
transcript.
- ### 5. Idle TTL sweep — fallback
+ ### 5. Fallback sweep — everything `SessionEnd` could not reach
- State files with a live `ovSessionId` whose `lastUpdatedAt` is older than
- `IDLE_TTL_MS` (default 30 min) get committed. Their transcript cursor is
- preserved while `ovSessionId` is cleared. Mental model: a session not touched
- for 30 min is "temporarily concluded"; if the user resumes later, subsequent
- turns append under the same deterministic OV session id, and the next commit
- creates another archive there.
+ On `startup` and `clear`, every state file except the new `session_id` is
+ examined. A state that still has a live `ovSessionId` is committed for one
+ of two reasons:
+ | Reason | Condition | What it means |
+ |---|---|---|
+ | `ended_retry` | an `.ended.<timestamp>` sidecar is present | `SessionEnd` fired but its commit never completed: OV was unreachable, `/commit` failed, or the worker was killed (Windows job objects kill detached children) |
+ | `idle_ttl` | no marker and `lastUpdatedAt` older than `IDLE_TTL_MS` (default 30 min) | no `SessionEnd` was ever going to arrive: signals, crashes, Codex older than 0.145 / TraeCode CLI, app-server deferral, or a mid-turn zouk reset that cancelled `Stop` |
+
+ Before committing, the sweep appends whatever the state's recorded
+ `transcriptPath` still holds past `capturedTurnCount`, so a session whose
+ own workers never ran is not archived without its tail turns. If part of
+ that append fails, the sweep keeps `ovSessionId` and the marker and skips
+ the commit; the next sweep retries from the advanced cursor.
+
+ A marker enters the lock even when the state carries no live `ovSessionId`. `PreCompact` releases the id but leaves the cursor and `transcriptPath` behind, so the tail turns of a thread whose `SessionEnd` worker was killed are only reachable through that catch-up — the catch-up derives a live id by itself as soon as it has something to send. Only when the catch-up finds nothing new and there is still no live id is the marker cleared.
+
+ An unreadable transcript is never treated as an empty one. If the state records a `transcriptPath` that cannot be read, the sweep logs `transcript_unreadable`, keeps the live id and the marker, and skips the commit, exactly like a partial append — otherwise a transient read failure would archive a session without turns nobody has seen yet. `SessionEnd` and `PreCompact` apply the same guard.
+
+ Their transcript cursor is preserved while `ovSessionId` is cleared. Mental
+ model for the idle case: a session not touched for 30 min is "temporarily
+ concluded"; if the user resumes later, subsequent turns append under the
+ same deterministic OV session id, and the next commit creates another
+ archive there.
+
Releasing the session id instead of deleting the file writes state without
touching `lastUpdatedAt`, so a committed session doesn't look freshly active
- to the next `SessionStart`.
+ to the next `SessionStart`. A successful commit also removes the `.ended`
+ marker; a failed one keeps both marker and `ovSessionId` so the next sweep
+ retries.
- This covers:
- - SIGTERM / Ctrl+C / `/exit` (no hook fires; state file rots)
- - Crashes
- - Mid-turn zouk reset where `Stop` got cancelled before bumping
- `lastUpdatedAt`
- - The `≥2 recently-active` skip from rule 3
+ Each candidate is committed under its session lock as a **try-lock**: a lock
+ we cannot take immediately means a `SessionEnd` or `Stop` worker already
+ owns that session (the user quit and relaunched within seconds), so the
+ sweep logs the skip and leaves the state alone. State is reloaded inside the
+ lock before the commit, so the sweep never writes back a snapshot the worker
+ has since superseded. The `.ended` marker is re-read there too: if an
+ `ended_retry` candidate's marker is gone (the thread was resumed) or newer
+ than the snapshot (a later exit whose own worker will commit), the sweep
+ falls back to the idle rule and leaves anything younger than `IDLE_TTL_MS`
+ alone.
- **Sweep trigger**: at the tail of `session-start-commit.mjs` only. We do
- not sweep on every `Stop` because state-write-on-every-turn already gives
- us the freshness signal we need; running the sweep once per session start
- is the right cadence. The Stop hook contains a comment marking the option
- to add sweep there if codex's session creation rate is low enough that
- arbitrarily-orphaned state files accumulate.
+ **Sweep trigger**: at the tail of `session-start-commit.mjs` only. `Stop`
+ does not sweep; state-write-on-every-turn already gives us the freshness
+ signal, and once per session start is the right cadence.
- **Known limitation**: if the user never starts another codex on this
- machine, no sweep ever runs and the OV session stays open server-side
- forever. Accepted. Future work could add an MCP tool
- `openviking_commit_pending` so the model can commit explicitly.
+ **Known limitation**: if a session's `SessionEnd` never fired and the user
+ never starts another codex on this machine, no sweep ever runs and the OV
+ session stays open server-side forever. Accepted. Future work could add an
+ MCP tool `openviking_commit_pending` so the model can commit explicitly.
### 6. Cursor retention — same pass
A committed state file keeps living as a cursor (`ovSessionId: null`) so a
later resume appends instead of replaying. Kept forever that would leak one
file per codex session and make `listStates()` — which reads every file on
every `SessionStart` — slower over time, so the same pass retires them:
| State | Retired after |
|---|---|
| cursor-only, `capturedTurnCount > 0` | `COMMITTED_TTL_MS` (default 30 days) |
| cursor-only, nothing ever captured | `IDLE_TTL_MS` (default 30 min) |
A cursor that outlives its codex rollout has nothing left to resume from, and
a state file that never captured a turn is identical to no state file at all.
## Stop hook — append + threshold commit
Every `Stop` reads `transcript_path`, slices to `[capturedTurnCount, end)`,
and appends each new user/assistant turn to the OV session for this codex
`session_id` (the `/messages` endpoint auto-creates it on first append).
State is updated:
`{ovSessionId, capturedTurnCount, lastUpdatedAt: now}`.
After a successful append, Stop reads session meta and commits when
`pending_tokens >= OPENVIKING_COMMIT_TOKEN_THRESHOLD` (default 20000).
The threshold commit passes
`keep_recent_count=OPENVIKING_COMMIT_KEEP_RECENT_COUNT` (default 10) so
the newest turns stay live after archive/extract. This keeps long-running
sessions from waiting until PreCompact/SessionStart while still avoiding
commit-on-every-turn fragmentation.
## Injected context boundary
`UserPromptSubmit` stdin includes the user's `prompt` plus the Codex
`session_id`. Recall derives the same OpenViking session id used by Stop
capture (`cx-<safe-session-id>`) directly from the Codex session id and
calls `/api/v1/search/search` with that `session_id`, so OpenViking can
use recent session messages and archive overview during query expansion.
Recall does not read plugin state, so a corrupt or missing state file
cannot crash the recall hook. Recalled memory is sent back through
`hookSpecificOutput.additionalContext`, then Codex injects it into the
model turn. Transcript capture may later see that injected context
adjacent to the prompt, so plugin-generated recall and resume context are
wrapped in a deterministic boundary:
```text
<openviking-context source="auto-recall" format="digest">
OpenViking memory digest:
- ...
</openviking-context>
```
The compressor is still instructed not to generate XML/HTML wrappers. The
wrapper is added by the hook after compression so capture can strip it
mechanically. Legacy `<relevant-memory>` / `<relevant-memories>` blocks and
unwrapped `OpenViking memory digest:` blocks are stripped as backward
compatibility fallbacks.
## Edge cases handled
### Post-compact transcript shrink
Codex's `/compact` may rewrite or truncate `transcript_path`. After
compaction, if `allTurns.length < state.capturedTurnCount`, our slice
math underflows and we silently drop new turns. When this inequality is
detected on `Stop`, move `capturedTurnCount` to the latest human turn
so the current interaction is captured without replaying compacted history.
"Human turn" is not just `role === "user"` — `normalizeCaptureRole()` maps
tool results onto the user role as well, so `findLastHumanTurnIndex()`
additionally requires a `text` part. If the rewrite left no human turn at
all (index `-1`), we fall back to capturing the whole transcript and log
`fallback: "full_transcript"`; replaying beats losing the interaction.
**Trade-off**: turns older than that human turn are assumed captured. If
earlier `Stop` hooks failed to reach OV and compaction happened before they
were retried, those turns are dropped rather than duplicated.
### Commit failure
- When OV `/commit` returns non-2xx or times out, we currently log and treat
- the result as null. We must NOT call `clearState` on failure — keep the
- state file so the next sweep / SessionStart can retry. A transient OV
- outage shouldn't lose a session's worth of memory.
+ When OV `/commit` returns non-2xx or times out, we log the `trace_id` and
+ keep `ovSessionId` set. We must NOT call `clearState` on failure — keep the
+ state file, and in the `SessionEnd` path keep the `.ended` marker too, so
+ the next sweep retries. A transient OV outage shouldn't lose a session's
+ worth of memory. An unreadable transcript is handled the same way: no
+ commit, state and marker preserved.
- ### Race: SIGTERM before Stop completes
+ ### Race: exit before Stop completes
- Codex's tokio runtime cancels in-flight async tasks on SIGTERM, so the last
- turn's `Stop` hook may be aborted before it bumps `lastUpdatedAt`. This
- makes the state look older than it actually is. Consequence: that session
- may fall outside the 2 min active window when the user respawns codex and
- we can't commit it deterministically — idle TTL will catch it later.
+ Codex's tokio runtime cancels in-flight async tasks when the process goes
+ away, so the last turn's `Stop` hook may be aborted before it appends its
+ turns and bumps `lastUpdatedAt`. On a graceful exit this costs nothing: the
+ `SessionEnd` worker reads the flushed rollout and appends everything past
+ the cursor before committing. On a signal or a crash there is no
+ `SessionEnd`, the state looks older than it really is, and the idle TTL
+ sweep commits it at the next `SessionStart` — with the same catch-up, from
+ the `transcriptPath` and cursor the last completed `Stop` recorded. A
+ session whose very first `Stop` never completed has no recorded transcript,
+ so there is nothing for the sweep to catch up and it commits whatever the
+ OV session already holds.
+ ### Race: concurrent writers of the same state file
+
+ The `Stop` worker, `PreCompact`, the `SessionEnd` worker and the sweep all
+ persist the whole state object, so without serialization the last writer
+ wins and can resurrect a committed `ovSessionId` or rewind the cursor. All
+ four therefore run under `withSessionLock`, and all four load state *inside*
+ the lock. The lock is a directory (`mkdir` is atomic on every platform we
+ run on); a holder that dies leaves a lock that is abandoned once its mtime
+ is `staleMs` (5 min) old, and a live holder refreshes the mtime from the
+ batch-send callback so a long catch-up never looks stale. Wait budgets:
+ 120s for the `Stop` and `SessionEnd` workers, 40s for `PreCompact` (which
+ must still answer inside its 60s hook budget, and on timeout emits `{}` and
+ touches nothing), and 0 for the sweep.
+
+ Ownership makes the lock safe to abandon. The holder writes an `owner` file
+ inside the directory containing `<pid>:<uuid>`, and only releases (or
+ refreshes) a lock whose `owner` still matches its own — otherwise a taker
+ that lost a race would release a lock somebody else now holds. Takeover
+ happens in place, on that same `owner` file: the taker renames it aside and
+ then creates its own exclusively, two atomic steps that exactly one racer can
+ complete, and a racer that loses either one leaves the winner's lock intact.
+ The directory itself is never moved or removed during a takeover, because a
+ lock path that is momentarily absent would let another racer's `mkdir` succeed
+ alongside the taker.
+
+ ### Race: a marker and a worker that outlive each other
+
+ The `.ended` marker's timestamp is its identity. The `SessionEnd` parent
+ passes the timestamp it wrote to the detached worker through
+ `OPENVIKING_SESSION_END_TOKEN`; the worker, after taking the lock and before
+ any network call, re-reads the marker and returns without committing unless
+ it still matches — a cleared marker means the thread was resumed, a
+ different one belongs to a newer exit whose own worker will commit. A
+ commit only clears the marker it verified. Because each marker's timestamp
+ is part of its filename, a conditional removal enumerates the markers older
+ than its cutoff and unlinks exactly those paths — a marker written between
+ the enumeration and the unlink is a different file and survives untouched.
+
+ `Stop` and `PreCompact` clear the `.ended` marker at entry, because a turn
+ for this session proves the thread is alive again after a resume. They clear
+ it only if it is older than the hook's own start time, so a detached `Stop`
+ worker that boots after the next exit cannot erase that exit's fresh marker;
+ the `Stop` parent forwards its start time to its worker through
+ `OPENVIKING_HOOK_STARTED_AT`. `SessionStart` `source=resume` clears with its
+ own start time for the same reason.
+
### Commit-then-resume
After PreCompact we set `ovSessionId = null` but keep
`capturedTurnCount`. The next `Stop` for the same codex `session_id`
re-derives the same `cx-<codex-session-id>` OV session id and starts
appending from `capturedTurnCount`. Memory remains grouped under the same
OV session id, while commits create additional archives under that session.
## State file schema
```json
{
"codexSessionId": "0193af...", // codex thread id
"ovSessionId": "cx-0193af...-or-null", // null means "committed, awaiting next Stop or retirement"
+ "transcriptPath": "/path/rollout.jsonl", // last rollout seen; lets the sweep catch up
"capturedTurnCount": 7, // turns from transcript already appended
"createdAt": 1715000000000,
"lastUpdatedAt": 1715000300000
}
```
Legacy state files from earlier plugin versions may still contain a UUID
`ovSessionId`; those are now overwritten with the derived `cx-*` id on the
next resolve. The migration window for preserving old UUID sessions has
closed.
State files are atomic-write (tmpfile + rename) to survive crash mid-write.
+ Two sidecars live next to `<safe-codex-session-id>.json`:
+
+ | Path | Written by | Meaning |
+ |---|---|---|
+ | `<safe-id>.ended.<timestamp>` | `SessionEnd` parent hook (timestamp in the name, also the content; created exclusively, and bumped by a millisecond until that succeeds, so two exits in the same millisecond still get distinct markers) | the thread ended; its commit is not confirmed. Read back by `listStates()` as `endedAt`, which takes the largest timestamp when several markers exist. Removed by a commit that verified this exact timestamp, or by a `Stop` / `PreCompact` / `resume` that started after it was written — each removal unlinks the exact marker paths older than its cutoff, so it can never take out a newer exit's marker. A bare `<safe-id>.ended` (pre-0.8.1) is still honoured, with the timestamp read from its content |
+ | `<safe-id>.lock` | whichever writer currently holds the session | exclusive `mkdir` lock holding an `owner` file; abandoned when its mtime is older than 5 min, and taken over in place by claiming that `owner` file |
+
+ Keeping the end marker out of the JSON is deliberate: a whole-object
+ `saveState` from a concurrent worker cannot clobber a separate file, and the
+ parent hook can write it lock-free in about a millisecond. Neither sidecar
+ is picked up by `listStates()` or the doctor, which read `.json` only.
+
## Configuration
Env var overrides for tuning without rebuilding:
| Var | Default | Purpose |
|---|---|---|
| `OPENVIKING_CODEX_STATE_DIR` | `~/.openviking/codex-plugin-state` | state file dir |
- | `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` | `120000` (2 min) | rule-3 active window |
| `OPENVIKING_CODEX_IDLE_TTL_MS` | `1800000` (30 min) | idle sweep TTL |
+ | `OPENVIKING_CODEX_LOCK_WAIT_MS` | `120000` (`SessionEnd` worker), `40000` (`PreCompact`) | how long a writer waits for the session lock |
| `OPENVIKING_CODEX_COMMITTED_TTL_MS` | `2592000000` (30 days) | how long a committed cursor is kept for resume |
| `OPENVIKING_RECALL_TIMEOUT_MS` | `120000` (2 min) | whole UserPromptSubmit auto-recall deadline |
| `OPENVIKING_RECALL_COMPRESS` | `1` | set `0` / `off` to skip `codex exec` compression |
| `OPENVIKING_RECALL_COMPRESS_MODEL` | unset | custom first-choice compressor model; `off` disables compression |
| `OPENVIKING_RECALL_COMPRESS_THINKING` | unset | custom `model_reasoning_effort`; `default` means omit override; alias `OPENVIKING_RECALL_COMPRESS_REASONING_EFFORT` |
| `OPENVIKING_RECALL_COMPRESS_BASE_URL` | unset | custom API base URL for the nested `codex exec` compressor |
| `OPENVIKING_RECALL_COMPRESS_DETECT_ON_STARTUP` | `1` | recreate/cache compressor profile during every `SessionStart` |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TIMEOUT_MS` | `15000` | per-candidate compressor probe timeout |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS` | `604800000` (7 days) | cache TTL used by `UserPromptSubmit` reads |
| `OPENVIKING_RESUME_ARCHIVE_INJECT` | `1` | inject latest archive summary on `source=resume` when no live OV session is open |
| `OPENVIKING_RESUME_ARCHIVE_TOKEN_BUDGET` | `32000` | token budget for `/sessions/{id}/context` on resume |
| `OPENVIKING_RESUME_ARCHIVE_MAX_CHARS` | `6000` | max chars injected from latest archive overview |
| `OPENVIKING_CAPTURE_TOOL_MAX_CHARS` | `1000000` | guard cap on one tool part's `tool_output`; the server externalizes anything over `tool_output_externalization.threshold_chars` (default 20000) |
| `OPENVIKING_DEBUG` | `0` | enable hook debug log |
## Resume context inject
`SessionStart` `source=resume` runs only the archive-inject path above. It
never commits and never runs idle sweep. This keeps short reconnects cheap
while still restoring continuity after a committed archive. The API shape
is the existing session context endpoint; no archive listing UX is required
for the model.
Injected context is intentionally a summary, not raw history. If exact
commands, file paths, code snippets, config values, or tool outputs matter,
the injected `viking://` URI tells the model to use OpenViking MCP
read/search tools.
## Recall compressor profile
`codex exec` supports `--model` / `-m`, and Codex config overrides such as
`model_reasoning_effort` are passed with `-c`. The recall compressor uses
both:
```bash
codex -m <model> -c 'model_reasoning_effort="low"' exec ...
```
The compressor runs with `--ignore-user-config`, so it does not inherit the
main Codex process's provider table. When
`OPENVIKING_RECALL_COMPRESS_BASE_URL` is set, the plugin adds an isolated
provider for the nested request:
```bash
-c 'model_provider="openviking_compressor"' \
-c 'model_providers.openviking_compressor.name="openviking_compressor"' \
-c 'model_providers.openviking_compressor.base_url="<url>"'
```
The selected model and thinking effort remain profile data; the base URL is
runtime configuration and is supplied when the command is built.
`thinking=default` omits the `model_reasoning_effort` override. This is
important for model families whose default effort is tuned by Codex.
Model availability is re-probed at every `SessionStart`, not in every
`UserPromptSubmit`. Recreating the profile on each session start catches
cross-session env/config changes. The detector writes
`recall-compressor-profile.json` under `OPENVIKING_CODEX_STATE_DIR` and
auto-recall reads that cache. Cache misses in auto-recall use the first
candidate directly and fall back to deterministic digest if `codex exec`
fails.
Fallback order:
1. configured model/thinking (`OPENVIKING_RECALL_COMPRESS_MODEL` +
`OPENVIKING_RECALL_COMPRESS_THINKING`)
2. `gpt-5.3-codex-spark`, thinking `default`
3. `gpt-5.6-luna`, thinking `low`
4. off (deterministic digest, no child `codex exec`)
Configured `off` (`OPENVIKING_RECALL_COMPRESS=0`, model `off`, or thinking
`off`) skips all probing and writes a disabled profile.
+ ## What changed vs 0.7.x
+
+ - `SessionEnd` (Codex ≥ 0.145) is registered and is now the primary commit
+ path; `session-end.mjs` marks the session ended, then a detached worker
+ catches up missed turns and commits.
+ - The active-window heuristic and `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` are
+ gone. `SessionStart` `startup|clear` now only sweeps: `ended_retry` for
+ states with an `.ended` marker, `idle_ttl` for states past the idle TTL.
+ - A per-session `.lock` directory serializes the `Stop` worker,
+ `PreCompact`, the `SessionEnd` worker and the sweep, with a new
+ `OPENVIKING_CODEX_LOCK_WAIT_MS` wait budget and a try-lock for the sweep.
+ - The `.ended` sidecar is cleared by any `Stop`, `PreCompact` or
+ `source=resume` for that session.
+ - `ov-session.mjs` holds the OV HTTP client, transcript reader and catch-up
+ logic that `Stop`, `PreCompact` and `SessionEnd` used to duplicate;
+ `PreCompact` gains the post-compact shrink defense it lacked, and an
+ unreadable or empty transcript can no longer reset the cursor to 0.
+ - Behaviour change: after `/new` the abandoned thread is committed when the
+ process exits (the `SessionEnd` burst) or after the idle TTL — no longer
+ at the next `SessionStart`.
+
## What changed vs v0.3.1
- `SessionStart` matcher widened from `"clear"` to `"clear|startup|resume"`
so the active-window heuristic runs on /clear and /new (and zouk reset),
while `/resume` can inject latest archive context without commit/sweep.
- `session-start-commit.mjs` switches commit logic from "all non-current"
to active-window heuristic.
- Idle TTL sweep brought back, but only at the tail of
`session-start-commit.mjs` (not every `Stop`). Default TTL 30 min.
- `auto-capture.mjs` Stop hook guards against post-compact transcript
shrink (resets `capturedTurnCount` to 0 if `allTurns.length` < cached).
- Capture parsing shared by Stop and PreCompact now filters obvious hook
noise, strips deterministic OpenViking context wrappers, and compresses
tool calls/results instead of dropping them or storing full blobs.
- `auto-recall.mjs` has a whole-hook timeout (default 2 min) in addition
to per-request timeouts.
- Recall compression model selection is recreated at each SessionStart and
cached so each user prompt does not probe Codex model availability.
- All commit failure paths preserve state instead of clearing.
- All state writes go through tmpfile + rename for crash safety.
## Open questions / future work
- **MCP tool `openviking_commit_pending`**: explicit commit for the model
to call, useful when user knows they're about to exit.
- **Subagent hook events**: kimicode has them, codex doesn't yet.
When codex adds them, we should hook to keep subagent memory threads
separate from main session.
- - **Upstream `SessionEnd`**: rejected by OpenAI. If they reverse, idle
- TTL becomes redundant — replace with deterministic SessionEnd commit.
## Verified hook payload reference
```json
// SessionStart input (from codex-rs/hooks/schema/generated/session-start.command.input.schema.json)
{
"session_id": "0193af...",
"source": "startup" | "resume" | "clear",
"cwd": "/path/to/cwd",
"model": "gpt-5.5",
"permission_mode": "default" | "acceptEdits" | "plan" | "dontAsk" | "bypassPermissions",
"transcript_path": "/path/to/rollout.jsonl" | null,
"hook_event_name": "SessionStart"
}
// UserPromptSubmit input
{
"session_id": "0193af...",
"prompt": "user prompt text",
"cwd": "/path/to/cwd",
"model": "gpt-5.5",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit"
}
// Stop input
{
"session_id": "0193af...",
"turn_id": "turn-N",
"transcript_path": "/path/to/rollout.jsonl",
"last_assistant_message": "...",
"stop_hook_active": false,
"model": "gpt-5.5",
"permission_mode": "default",
"cwd": "/path/to/cwd",
"hook_event_name": "Stop"
}
// PreCompact input
{
"session_id": "0193af...",
"transcript_path": "/path/to/rollout.jsonl",
"trigger": "manual" | "auto",
"cwd": "/path/to/cwd",
"model": "gpt-5.5",
"hook_event_name": "PreCompact"
}
+
+ // SessionEnd input (Codex >= 0.145; `reason` is a constant)
+ {
+ "session_id": "0193af...",
+ "transcript_path": "/path/to/rollout.jsonl",
+ "cwd": "/path/to/cwd",
+ "hook_event_name": "SessionEnd",
+ "reason": "other"
+ }
```
Output schema for SessionStart / UserPromptSubmit supports
`hookSpecificOutput.additionalContext`. A SessionStart response may include
that field together with `systemMessage`, allowing profile injection and orphan
commit status to coexist. Stop / PreCompact only support
`{ continue, stopReason, suppressOutput, systemMessage }` — `{}` is a
- valid no-op.
+ valid no-op. SessionEnd output is ignored entirely; the hook cannot block
+ and cannot inject, so `session-end.mjs` prints `{}` for symmetry.