Native session capability matrix
This page is the INF-754 checkpoint for epic PLT-1804. It records what each
mandatory macOS client surface exposes to a passive session reporter. It is
the contract the session service (PLT-1807), the transport (INF-159) and the
three adapters (INF-750, INF-751, INF-752) build on. The design is in
.ai/specs/SPEC-plt-316-persistent-threads-session-tracking.md.
This checkpoint does not certify a release. INF-753 repeats the native evidence on the integrated revision, on two enrolled machines, with sessions a person starts directly.
Nor does it yet meet INF-754's exit condition. That needs each mandatory surface's hook, identity and trust behaviour observed in a session a person starts (the human-gated steps below), or an unsupported surface returned for a scope decision. INF-754 stays open until then, and the session service and the adapters wait on it.
Evidence tiers
Every cell below carries one of these labels. They are not interchangeable.
| Label | Meaning |
|---|---|
| Observed | The installed native binary dispatched its own hooks to the capture probe. Two kinds of start were used: an agent typed into a pseudo-terminal, or the client ran headless (claude -p, codex exec). The Claude Code hooks were injected with --settings rather than a settings file. This shows how the client dispatches its hooks and what its lifecycle events mean. It does not show what happens when a person starts the session. |
| Documented | Vendor documentation or the vendor's source at the installed tag says so. Nothing was dispatched on this machine. |
| Human-gated | Observation needs a person to act: trust a hook, start a GUI session, use a second machine, or log in. The steps are in Human-gated steps. |
Tested environment (machine 1)
| Item | Version |
|---|---|
| macOS | 27.0.1 |
| Claude Code CLI | 2.1.153 for the first headless run, then 2.1.296. The client updated itself between two runs on 2026-10-10. Every interactive result below is 2.1.296. Neither build puts its version in the hook payload. |
| Codex CLI | codex-cli 0.162.0-alpha.17.2, bundled inside the ChatGPT macOS app 26.1007.21159, which also hosts the local Codex desktop experience. It is not on PATH. |
| Cursor IDE | 3.21.16 |
cursor-agent CLI | 2025.09.17-25b418f. This surface is optional (D1). It was not evaluated: no account was configured for this evaluation, and this build predates the CLI hook support Cursor shipped in January 2026. |
Matrix
Claude Code CLI (mandatory)
| Capability | Result | Tier |
|---|---|---|
| Mechanism | Command hooks from settings files (~/.claude/settings.json, .claude/settings.json, .claude/settings.local.json) or --settings. Hooks from every level are merged, and all matching hooks run. | Observed / Documented |
| Native ID | session_id in every payload. CLAUDE_CODE_SESSION_ID in the hook environment equals it on both builds. | Observed |
| Start | SessionStart with source=startup. | Observed |
| Resume | --resume <id> fires SessionStart(resume) with the same session_id under a new client process. This is the spec's "one native session, several process episodes". | Observed |
| Compaction | /compact fires PreCompact(manual), SubagentStop and then SessionStart(compact), with the same session_id. Compaction is not a new session. | Observed |
| Clear | /clear fires SessionEnd(clear) for the old ID, then SessionStart(clear) with a new session_id in the same process. It is a new native session, not a new episode. | Observed |
| Turn end | Stop fires after each answer. It is not a session end. | Observed |
| Normal end | /exit fires SessionEnd(prompt_input_exit). A headless run fires SessionEnd(other). | Observed |
| Signals | SIGTERM, SIGHUP and closing the terminal each fire SessionEnd(other), with exit statuses 143, 129 and 129. SIGKILL fires nothing. | Observed |
| Idle | About 60 s after an answer with no input, Notification(idle_prompt) fires once. There is no periodic hook. | Observed |
| Liveness | No hook fires periodically. The hook environment carries CLAUDE_PID, and the client is the hook's direct parent process, so a sidecar can watch that process. | Observed |
| Version | Not in the payload or the environment. It must be resolved per episode from the client executable. | Observed |
| Deadlines | Probe version 1 finished in 14–36 ms, timed from the start of its capture, so without Node's startup. The documented SessionEnd budget is 1.5 s for all of a session's end hooks together. | Observed / Documented |
| Trust | Interactive sessions hold back every hook until the folder is trusted. On 2.1.296 a new git repository nested inside a trusted checkout still showed the trust dialog. Headless -p treats the folder as trusted and recorded no trust entry. | Observed |
| Nested directories | A session started in a nested repository inside a checkout ran that checkout's project hooks as well as its own. This held on both builds. | Observed |
| Other surfaces | The same hooks are documented to fire in the desktop app and the IDE extensions. | Documented |
Codex CLI and local Codex desktop (mandatory)
| Capability | Result | Tier |
|---|---|---|
| Mechanism | hooks.json, or [hooks] in config.toml, at user level (~/.codex/) and project level (<repo>/.codex/). Every layer's matching hooks run. The repository's .codex/hooks.json and scripts/codex-native-hooks.ts already use this mechanism. | Documented |
| Trust | Each non-managed hook must be trusted for its current definition hash. Trust is stored only in the user config.toml, so a project cannot trust its own hooks. Under codex exec, untrusted project hooks were skipped silently, with no warning on stderr, while already-trusted user-level hooks ran. | Observed / Documented |
| Native ID | session_id in the payload; subagent hooks carry the parent's. Shell commands receive CODEX_THREAD_ID; whether hook processes receive it is unverified. | Documented |
| Start / resume | SessionStart with source = startup, resume, clear or compact; the installed tag's schema also allows fork. Whether the ID is stable across resume is not documented. | Documented |
| End | SessionEnd always reports reason=other. It fires when the conversation is archived or deleted, when Codex closes normally, or after 30 minutes idle while no connected client has the conversation open. The payload cannot distinguish these causes. SessionEnd never fires for subagents. | Documented |
| Turn end | Stop, which carries turn_id. Under codex exec no Stop dispatch was seen, even from the trusted user-level hook, so its dispatch there is still to be shown in an interactive session. | Documented |
| Compaction | SessionStart(compact) and PreCompact/PostCompact exist. Whether the ID survives compaction is undocumented. | Documented |
| Deadlines | SessionEnd defaults to 1 s and is clamped at 3 s; other events default to 600 s. | Documented |
| Liveness | No periodic hook is documented, and SessionEnd may follow 30 minutes of idleness, so liveness needs the same bounded sidecar as Claude Code. Which process to watch is still to be shown. | Human-gated |
| Version | The payload carries model but no client version, so it must be resolved from the executable. | Documented |
| Desktop | The Codex app has an in-app hook-trust review flow. Whether the Codex tab inside the ChatGPT app 26.1007 shares that hook runtime is undocumented, and needs a session a person starts. | Human-gated |
Cursor IDE (mandatory)
| Capability | Result | Tier |
|---|---|---|
| Mechanism | hooks.json (version: 1) at enterprise, team, project (.cursor/hooks.json) and user (~/.cursor/hooks.json) level. All matching hooks run. Project hooks need a trusted workspace. | Documented |
| Native ID | conversation_id in every payload; sessionStart.session_id equals it. generation_id changes on every user message. Whether conversation_id survives reopening a chat is undocumented. | Documented |
| Lifecycle | sessionStart and sessionEnd exist. sessionEnd carries reason (completed, aborted, error, window_close, user_close). A vendor-acknowledged forum report says sessionEnd runs only on window close, after the shell is torn down, so its command may never run. stop fires per turn with status. | Documented |
| Claude hooks | Cursor loads Claude Code hooks from .claude/settings.json, .claude/settings.local.json and ~/.claude/settings.json by default, and maps their events onto its own. This repository's Claude hooks therefore also run in Cursor. | Documented |
| Environment | CURSOR_PROJECT_DIR, CURSOR_VERSION, CURSOR_USER_EMAIL and CURSOR_TRANSCRIPT_PATH; the payload carries cursor_version. | Documented |
| Resume | Whether reopening a chat keeps conversation_id and fires sessionStart again is undocumented. | Human-gated |
| Compaction | preCompact exists; whether compaction keeps conversation_id is undocumented. | Human-gated |
| Deadlines | A per-hook timeout exists, but no default is documented. Step 4 records how long each hook took, not the limit, so the limit itself stays to be shown. | Human-gated |
| Liveness | No periodic hook is documented, and sessionEnd may not run at all, so liveness needs a bounded sidecar. Which process to watch is still to be shown. | Human-gated |
| Version | cursor_version in the payload and CURSOR_VERSION in the hook environment. | Documented |
| Dispatch | Not yet observed on this machine. Step 4 below also checks that beforeSubmitPrompt lets a prompt through when the probe prints nothing. | Human-gated |
Design inputs for the adapters
These follow from the matrix. Each owning task confirms them against its own native canary.
- Liveness needs a sidecar for Claude Code. No hook fires periodically. A
bounded reporter tied to
CLAUDE_PID, sending a heartbeat every 30 s, is the only honest liveness source. It must stop when that process exits. The spec already allows a sidecar on these terms. - A missing end is normal. SIGKILL produces no
SessionEnd, and Codex's idleSessionEndcan arrive 30 minutes late, so a session with no end observation becomesdisconnectedthrough missing heartbeats. Its outcome stays unknown. - Identity transitions differ by client. Claude Code keeps the ID across
resume and compaction, and issues a new ID on
/clearinside the same process. The adapter must therefore close one session and open another in a single process episode. It must not merge them into one session. - Record the client version per episode. The version is not in the payload, and it changed under a running test session. Resolve it from the client executable each time an episode starts.
- Detect the host client. Cursor runs
.claude/settings*.jsonhooks by default. A Claude Code adapter that seescursor_versionorCURSOR_*must report the session as Cursor, or the session is registered twice under the wrong client. - End hooks only spool. Codex allows 1–3 s and Claude Code 1.5 s in total. An end hook validates its input and appends to the local queue; the network drain runs separately.
- Trust is the human's. Codex trust lives in the user's
config.toml, and Claude Code and Cursor trust is per folder or workspace. Setup never writes trust state. It prints the step the person takes. - Existing hooks stay. Other tools commonly install user-level hooks in all three clients. Adapters add their own entries and never rewrite or reorder an existing one.
Human-gated steps
Run these on each enrolled machine. In hook mode the capture probe records
only the payload's key names, ID digests, enumerated values, process ancestry
and git shape. An enumerated value is kept only when it is one of the values
the client documents for that field (a closed list in the probe), and a number
only in a documented count field within its bound. A version is
kept only in dotted numeric form with at most a known pre-release word, and a
model only as its family name, never the full value. Anything else, of any
shape, is recorded as <unrecorded>. It writes only to ~/.constellation/agent-session-probe/ (mode
0700), or to the folder CONSTELLATION_PROBE_DIR names. It prints nothing on
stdout and sends nothing; when it cannot record, or the spool is full, it
prints one stderr line naming only an error code, and still exits 0. Each
capture budgets its lookups against a deadline from the probe's own start:
750 ms for an end event, or for an event it cannot name, and 1.5 s for any
other. That is half of Claude Code's shared 1.5 s end-hook budget, and under the
2 s the probe sets for every Codex hook. Each lookup, and the wait for stdin,
gets only the time left, so an end event's record is normally written by then.
Only a very slow start can make it later, and then the lookups are skipped
rather than the record lost. The probe exits once its record is written, even
if a client holds its stdin open. A lookup the deadline or a failure cut short
is named in the record's enrichment, as timed_out, skipped or failed,
and enrichment.stdin_wait_ms records how long stdin was given; report
counts the gaps per session as gaps. From
probe version 2, elapsed_ms counts from the probe's process start, Node's
startup included (INF-770). init-workspace writes one client's hook file into the
workspace you name and never writes user-level configuration. Use one
workspace per client: Cursor also runs .claude/settings*.json hooks, so a
workspace holding two formats would record every Cursor event twice.
remove-workspace deletes exactly the file it wrote, and keeps it if its
content has changed since. It leaves the workspace's .git, the emptied client
folder, and any trust you granted in steps 1–4 for you to remove. The spool is
local evidence: its git digests are unsalted hashes of local paths, so
summarise it rather than share it raw. The probe refuses a client folder or
hook file that is a symlink, but it is a disposable harness for your own
scratch folder, not a tool hardened against a hostile workspace.
node scripts/agent-session-tracking/capability-probe.mjs init-workspace "$HOME/probe-claude" --client claude-code
node scripts/agent-session-tracking/capability-probe.mjs init-workspace "$HOME/probe-codex" --client codex
node scripts/agent-session-tracking/capability-probe.mjs init-workspace "$HOME/probe-cursor" --client cursor
node scripts/agent-session-tracking/capability-probe.mjs report
node scripts/agent-session-tracking/capability-probe.mjs remove-workspace "$HOME/probe-claude"
- Claude Code, started by a person. Open a terminal in
probe-claudeand runclaude. Accept the folder trust dialog yourself. Send one message, then/exit. Runclaude --continue, send a message, run/compact, then close the terminal window. Runreport: the session appears once, with two process episodes. - Codex CLI. Run
codexinprobe-codex. Trust the probe hooks with/hooks. Send a message, run/compact, then quit, thencodex resumethe same session. Check thatSessionStart(startup)fired (the hook file was accepted), thatPreCompactandPostCompactwere recorded, and thatSessionStart(resume)keeps the session digest. - Codex desktop. Open
probe-codexas a project in the ChatGPT app's Codex tab. Review and trust the hooks in-app, start a thread, close the app and reopen the same thread. - Cursor IDE. Open
probe-cursorin Cursor and trust it. Start an agent conversation, send two messages, then compact the conversation and send one more. Close the window, reopen the same chat and send another. Check thatsessionStartfired (the hook file was accepted), thatpreCompactandsessionEndwere recorded, that theconversation_iddigest held across compaction and reopening, and that the probe's empty output let every prompt through. Runreportto see how long each hook took, and anygaps. - Second machine. Repeat steps 1–4 on the second enrolled Mac.
- Reporter credential. Confirm which existing credential the reporter
enrolls with: the
pt logincredential store, or a SERVICE-principal key for headless use. No vendor credential is used.
Bounded test plan for the adapter tasks
Each adapter task runs these scenarios on both machines, with the integrated reporter in place of the probe. A failed scenario is retried at most twice with a changed hypothesis. After that it becomes a recorded blocker, never a waived row.
| Scenario | Claude Code | Codex CLI | Codex desktop | Cursor IDE |
|---|---|---|---|---|
| Direct start, explicit task bind | required | required | required | required |
| Resume keeps one native session | required | required | required | required |
| Compaction is not a new session | required | required | — | required |
New conversation in one process (/clear) | required | required | — | — |
| Normal end | required | required | required | required |
| SIGKILL or crash → disconnected ≤ 120 s | required | required | required | required |
| Offline replay does not mark live | required | required | required | required |
| Trust disabled → degraded, session intact | required | required | required | required |
| Hosted inside Cursor reports as Cursor | required | — | — | required |
| Uninstall leaves no process or hook | required | required | required | required |