| name | session-observer |
| description | Use when checking what another coding agent (Claude Code, Codex, or Cursor) just did in this project, reviewing a peer session, or catching up on new messages. Locates the active transcript, renders a tool-free digest, and tracks runtime-specific read positions. |
| license | MIT |
| compatibility | Agent Skills baseline; requires Node.js 22+. No third-party runtime dependencies. |
| argument-hint | [review|catch-up|catch-up-then-watch|locate|whoami|state|watch|watch-ctl|--watch] [--runtime <claude-code|codex|cursor|auto|both>] [--debug] |
| disable-model-invocation | false |
| user-invocable | true |
| allowed-tools | Bash, Read, AskUserQuestion |
| version | 1.0.23 |
| metadata | {"author":"thomas.stang","version":"1.0.23"} |
session-observer
Lets you (Claude Code, Codex, or Cursor) inspect another runtime's transcript for the current project, render a tool-free digest, and track runtime-specific read positions so follow-up checks surface only new content.
When to Use
Use this skill when any of the following applies:
| Trigger phrase | What to run |
|---|
check Codex / review the other terminal / summarize Codex's session | review --runtime codex |
check Claude / check what Claude said | review --runtime claude-code |
check Cursor / summarize Cursor's agent session | review --runtime cursor |
check again / anything new? | catch-up (auto runtime) |
what do you think of what was just said? | catch-up, then comment |
get up to speed | review once, catch-up thereafter |
start watching this session / keep watching Codex / respond when anything new appears | watch --runtime <peer> --until-stopped or --watch --runtime <peer> |
catch up and watch Claude / catch up, then keep watching <peer> | catch-up-then-watch --runtime <peer> --until-stopped |
which sessions are available? / find the session | locate |
reset / start over watching Codex | state reset --runtime codex, then review |
You are responsible for choosing the right subcommand. When the trigger phrase is ambiguous (e.g. "can you check?"), ask the user: "Full review of the session, or just what's new since last time?"
When NOT to Use
- When you already know what the peer did (skip the check).
- When you want to save findings to memory/vault (use
stoa-capture instead — this skill is read-only).
- When you only need a one-time answer. Use
review or catch-up; reserve watch for an active foreground monitoring session.
- When the target runtime is your own. Use
--runtime <peer> or let auto resolve the peer.
Arguments
Subcommands
| Subcommand | Purpose | State change |
|---|
review | Full digest from the start | None (unless --mark-read passed) |
catch-up | Delta: records since last read | Advances high-water mark on success |
catch-up-then-watch | Emit unread backlog, then enter foreground watcher | Advances high-water marks as backlog/deltas are consumed |
locate | Ranked candidate list (diagnostic) | None |
whoami | Resolve this session's runtime/session/path identity | None; fails closed when identity is ambiguous |
state get | Print current state | None |
state reset --runtime <r> | Reset all state for one runtime | Non-Cursor: zeroes offsets. Cursor: deletes all Cursor session state for replay |
state reset --session <r:id> | Reset one session | Non-Cursor: zeroes its offset. Cursor: deletes that session's state for replay |
state clear | Clear all tracked sessions | Clears legacy offsets and the shared Cursor state store |
watch | Foreground watcher for debounced catch-up updates | Advances high-water marks as emitted digests are consumed |
watch-ctl status | Print active watcher state | None |
watch-ctl pause | Pause event emission while polling continues | Writes a control directive |
watch-ctl resume | Resume event emission | Writes a control directive |
watch-ctl flush | Emit pending debounced updates immediately | Writes a control directive |
watch-ctl stop | Stop the active watcher | Signals the watcher and clears watch metadata on exit |
Flags (all subcommands accept these)
| Flag | Type | Default | Description |
|---|
--runtime <r> | claude-code|codex|cursor|auto | auto | Which runtime to read. auto picks the peer via SESSION_OBSERVER_SELF, a prior same-cwd state entry, or tier-population fallback. |
--cwd <path> | path | process.cwd() | Project directory to match transcripts against. |
--include-tools | boolean | false | Include compact [ToolName] args tool-call markers. |
--include-command-messages | boolean | false | Include Claude Code slash-command payload records such as <command-message>…</command-message>. |
--debug | boolean | false | Shorthand for --include-tools --include-tool-results. Adds [ToolName → result] output entries. For locate --json, includes lookup diagnostics such as Claude's expected project-dir slugs. |
--include-tool-results | boolean | false | Include tool-result markers without tool-call markers (unusual; emits a note suggesting --debug). |
--json | boolean | false | Machine-readable JSON output (default is markdown). |
--max-turns N | integer | — | Tail-slice to last N turn groups. |
--max-bytes N | integer | — | Tail-slice to last N bytes of content. |
--session <r:id> | string | — | Pin to a specific runtime:sessionId. Overrides rank winner. |
--snippet <text> | string | — | Prefer candidate transcripts containing this excerpt. Use when the user identifies a session by its last message or a memorable phrase. |
--mark-read | boolean | false | Advance the high-water mark after a review run. |
--watch | boolean | false | Top-level alias for the watch subcommand. |
Watch-only flags
| Flag | Type | Default | Description |
|---|
--runtime <r> | claude-code|codex|cursor|auto|both | auto | Which runtime to watch. both watches Claude Code and Codex in one foreground process. |
--debounce-sec N | number | 2 | Seconds of quiet before emitting a catch-up digest. |
--poll-sec N | number | 2 | Poll interval in seconds. |
--max-pending-sec N | number | 30 | Maximum seconds to hold continuous transcript changes before emitting even if the file never goes quiet. |
--max-runtime-min N | number | 0 | Auto-exit after N minutes; 0 runs until stopped. |
--heartbeat-sec N | number | 120 | Emit quiet metadata/status heartbeat lines every N seconds; 0 disables heartbeats. |
--until-stopped | boolean | false | Alias posture for unlimited foreground watching. Equivalent to --max-runtime-min 0. |
--interactive | boolean | false | Alias posture for live collaboration. Equivalent to --max-runtime-min 0. |
--event-log <path> | path | — | Write metadata-only JSONL event records. Message content stays on stdout. |
--json | boolean | false | Emit each watch event as one JSON line instead of markdown. |
--quiet-empty | boolean | false | Consume metadata-only growth and advance offsets without emitting an empty delta. |
--strict-baseline | boolean | false | Refuse a standalone watch that would establish a baseline past unread records. |
Default content filter
By default, only natural-language user/assistant messages are included. Tool calls, tool results, and Claude Code slash-command payload records (<command-message>, <command-name>, <command-args>) are excluded. Opt in with --include-tools (adds call markers), --include-command-messages (adds slash-command payloads), or --debug (adds tool markers and results).
A filtered or empty digest is not evidence that the peer was idle or that the transcript contains no activity. It only means no entries matched the current rendering options. Check the digest schema, declared index base, and raw accounting; broaden the filters when appropriate; or inspect the pinned transcript before drawing an absence conclusion.
If a digest would exceed the large-output threshold, the CLI automatically falls back to the last 8 user/assistant turn groups and adds a Large digest fallback warning. This protects catch-up from dumping pasted skill bodies or large transcript spans. Use --max-turns or --max-bytes for an explicit bound, or --include-command-messages when the slash-command payload itself is the thing being debugged.
Workflow
Step 1: Clarify if needed
Before running the CLI, resolve any ambiguity:
- Mode ambiguous (
"can you check?" with no verb hint) → ask: "Full review, or just what's new since last time?"
- Runtime ambiguous — default to
--runtime auto. If multiple runtimes have matching transcripts, auto first checks whether the state file has exactly one previously read session for this cwd and reuses that runtime; otherwise it exits 3 with ambiguousRuntime.
- User identifies a session by text — run
locate --runtime <r> --cwd "$PWD" --json --snippet "<excerpt>", confirm the matched sessionId/recordedCwd, then re-run with --session <runtime>:<id> if needed.
- Ties within winning tier (exit 3 with
ties) — present the top candidates; ask which to use; re-invoke with --session <runtime>:<id>.
- No candidates (exit 2) — run
locate --json --debug for diagnostics, then present widening options (sister worktree, specific cwd, global most-recent). Treat globalRecent as diagnostic only: if one candidate's path or slug clearly matches the current worktree and a newer candidate is unrelated, prefer the same-worktree candidate or ask before using it.
Use AskUserQuestion (Claude Code) or structured input / conversational ask (other runtimes) for disambiguation.
Step 2: Run the CLI
The CLI lives at the skill's install location. In this repository it is at:
<skill-dir>/scripts/session-observer.mjs
where <skill-dir> is skills/session-observer (repo-relative) or the installed path on the user's machine.
Basic invocation pattern:
node <skill-dir>/scripts/session-observer.mjs <subcommand> [flags]
Exit code handling:
| Exit code | Meaning | What to do |
|---|
| 0 | Success | Proceed to Step 3. |
| 1 | Hard error | Surface the error; do not update state. |
| 2 | No candidates (noMatch / noCandidates) | Offer widening options from the JSON payload (sisters, globalRecent). Do not silently jump to an unrelated globally recent transcript. |
| 3 | Needs user input (ties / ambiguousRuntime) | Present options from the JSON payload; re-invoke with --session or --runtime. |
| 4 | Schema mismatch | Auto-migrated (should not reach SKILL.md); report if seen. |
Per-mode CLI templates:
node <skill-dir>/scripts/session-observer.mjs review \
--runtime codex --cwd "$PWD"
node <skill-dir>/scripts/session-observer.mjs catch-up \
--runtime codex --cwd "$PWD"
node <skill-dir>/scripts/session-observer.mjs locate \
--runtime auto --cwd "$PWD" --json
node <skill-dir>/scripts/session-observer.mjs whoami --json
node <skill-dir>/scripts/session-observer.mjs locate \
--runtime claude-code --cwd "$PWD" --json --snippet "the last thing I saw"
node <skill-dir>/scripts/session-observer.mjs state get
node <skill-dir>/scripts/session-observer.mjs state reset --runtime codex
node <skill-dir>/scripts/session-observer.mjs state reset \
--session cursor:<session-id>
node <skill-dir>/scripts/session-observer.mjs watch \
--runtime codex --cwd "$PWD" --poll-sec 2 --debounce-sec 2 --max-pending-sec 30 --until-stopped
node <skill-dir>/scripts/session-observer.mjs catch-up-then-watch \
--runtime codex --cwd "$PWD" --until-stopped
node <skill-dir>/scripts/session-observer.mjs --watch \
--runtime codex --cwd "$PWD"
node <skill-dir>/scripts/session-observer.mjs watch-ctl status --json
node <skill-dir>/scripts/session-observer.mjs watch-ctl pause
node <skill-dir>/scripts/session-observer.mjs watch-ctl resume
node <skill-dir>/scripts/session-observer.mjs watch-ctl flush
node <skill-dir>/scripts/session-observer.mjs watch-ctl stop
JSON output for exit-2 / exit-3 payloads:
Pass --json to get machine-readable output. On exit 2 (noMatch):
{ "noMatch": true, "sisters": [...], "globalRecent": [...] }
On exit 3 (ties):
{ "ties": true, "candidates": [{ "runtime": "...", "sessionId": "...", ... }] }
On exit 3 (ambiguousRuntime):
{ "ambiguousRuntime": true, "runtimes": ["claude-code", "codex", "cursor"] }
Watch mode operation:
Use watch when the user explicitly asks to keep monitoring a peer session, respond as new peer activity arrives, or watch another terminal while the current invocation remains active. watch is a foreground process: keep it running, actively read or poll its stdout, and respond to each emitted digest until the user asks you to stop, watch-ctl stop exits the watcher, --max-runtime-min expires, or the process exits for another reason. Startup prints: Watcher is now active. Keep this process open and continue reading stdout. Do not treat baseline setup as a completed watch.
For combined catch-up/watch requests, run catch-up-then-watch. Starting watch alone establishes an initial baseline and does not emit already-unread transcript content.
Each emitted watch digest is equivalent to a debounced catch-up result and advances the runtime-specific high-water mark. Schema-v1/non-Cursor targets consume JSONL records. Cursor schema v2 consumes physical JSONL frames only after its stability, continuity, and delivery checks pass. The debounce waits for --debounce-sec seconds of quiet, but continuous writes are still emitted after --max-pending-sec seconds so a busy transcript cannot starve the watcher indefinitely. If the watcher prints JSON lines, route by stable event type: baseline, delta, heartbeat, stopped, or error. Respond to delta events with digest content; stay quiet on baseline and heartbeat unless their metadata shows a problem. If it prints markdown, read each emitted digest before commenting.
--quiet-empty is useful for collaboration watches: metadata-only growth still advances the offset, but no empty delta is printed. This does not mean nothing was written; it means the growth did not produce a rendered message under the active filters. --strict-baseline protects a standalone watch from silently skipping a previously unread range. Without it, such a start emits one baseline-gap warning with the zero-based skipped range; with it, startup refuses and leaves the prior offset intact. catch-up-then-watch first renders unread backlog and therefore does not create a baseline gap.
During polling, a pinned watcher may emit a deduplicated newer-session-candidate event with identity evidence for a newer same-cwd transcript. It is informational only: the watcher stays pinned and never auto-switches or claims that the candidate superseded the selected peer.
Quiet watches emit heartbeat/status lines every --heartbeat-sec seconds by default. Treat heartbeats as liveness/status only; they are not a reason to speak unless recordsBehind or healthy indicates a problem.
While watch is active, keep the collaboration posture. If the user asks a side question, answer it, then re-engage the foreground watcher unless the user explicitly told you to stop. If your host requires stopping the foreground process before you can answer, restart with catch-up-then-watch --runtime <peer> --cwd "$PWD" --until-stopped immediately after the response so unread backlog is consumed before the baseline is reset.
Automatic responses are bounded to the active invocation that started and is polling the watcher. In Claude Code, Codex, Cursor, and similar yield-after-turn harnesses, a backgrounded watch command does not wake the agent when stdout receives a new digest; the caller must keep reading stdout, periodically call watch-ctl status --json, or poll the transcript directly. Provider hook integrations that would wake a new invocation after this one ends are deferred; do not imply that watch events will automatically summon an agent after the active invocation has stopped watching.
watch-ctl status --json reports the resolved runtime/session/transcript for each target plus its declared indexBase, live drift positions, poll/event times, and health. Cursor targets additionally report independent engagement, activity, content, lifecycle, delivery, and health status facets plus continuity and buffering. Treat a blocked/unhealthy target or content behind the verified cursor as a watcher problem or unread/buffered input, not as peer idleness.
Multiple foreground watchers can run at once, including two sessions in the same worktree watching each other; a second watcher for the same target session is refused, since duplicates would race over the shared read offset. Use watch-ctl status --json to list active watchers. For pause, resume, flush, or stop, the command selects the watcher for the current cwd by default, falling back to the only matching watcher when the implicit cwd filter matches none; if more than one watcher matches, disambiguate with --runtime, --session, or --pid.
Step 3: Present digest and comment
Read the markdown digest. Then offer a take on what the peer did or said:
- Summarize the most recent work (last few turns).
- If the session is marked
ACTIVE, note that the peer may still be mid-turn.
- If you noticed something relevant (a bug, a decision, a question), call it out.
For Cursor schema v2, read the independent cursorEvidence.status facets.
content: "available" with lifecycle: "pending" is a valid content-first
observation, not a completed turn. health: "healthy" means the read and
continuity check succeeded; it does not prove peer progress. delivery: "uncertain" requires the stable entry keys to be replayed with that provenance.
Only a terminal-success confirmed-completion projection is eligible for
collaboration continuation.
Step 4: Catch-up bookkeeping
catch-up automatically advances the high-water mark on exit 0. Interpret every position by the digest's schema and indexBase, not by the runtime name alone:
- Schema v1 requires
zero-based-jsonl-record-index. It preserves Claude Code, Codex, and compatibility behavior: raw and rendered positions count parsed JSONL records.
- Cursor schema v2 requires
zero-based-jsonl-frame-index. Raw positions count physical frames, including metadata and terminal boundaries. Entry recordIndex is the delivery frame; sourceFrameIndex is provenance for the original content frame.
fromIndex is inclusive and nextIndex is the first unconsumed position under the declared base. Filtered entries still count in raw accounting.
- Unknown schemas, mismatched index bases, or a missing v2 index base are fail-closed. Never convert a v1 record cursor into a v2 frame cursor.
If raw accounting says 8 consumed positions and one rendered entry, that is normal: the active projection/filter omitted the others. Do not describe this as a range bug, peer idleness, or evidence that the omitted input does not exist.
The stored high-water mark is exclusive. In compatibility fields it may still be named lastRecordIndex; for Cursor v2 it means the next unread zero-based physical frame, not a parsed-record conversion.
If you used review and want the same bookkeeping, pass --mark-read.
Examples
Basic Usage
node skills/session-observer/scripts/session-observer.mjs \
review --runtime codex --cwd "$PWD"
node skills/session-observer/scripts/session-observer.mjs \
catch-up --runtime codex --cwd "$PWD"
node skills/session-observer/scripts/session-observer.mjs \
review --runtime cursor --cwd "$PWD"
node skills/session-observer/scripts/session-observer.mjs \
review --runtime codex --include-tools
node skills/session-observer/scripts/session-observer.mjs \
review --runtime codex --debug
node skills/session-observer/scripts/session-observer.mjs \
review --runtime codex --max-turns 10
Conversational
User: Can you check what Codex just did?
Agent: I'll run a review of the Codex session for this project.
node skills/session-observer/scripts/session-observer.mjs \
review --runtime codex --cwd "$PWD"
(Reads digest output.)
Codex has been working on the authentication module. The last few exchanges show it added src/auth/token.ts and ran a build check. No errors were reported. Want me to catch up on anything that arrives next?
Troubleshooting
No candidates found (exit 2, noMatch or noCandidates)
The skill did not find any transcripts for the target cwd and runtime.
Recovery options (presented in the JSON payload):
- Sister git worktrees — re-run with
--cwd <sister-path>. The payload includes sisters[].
- Specific cwd — re-run with
--cwd <path> for a different project directory.
- Global most-recent — diagnostic only. Do not use a globally newer unrelated transcript when another candidate path clearly belongs to the requested project/worktree; ask or pin with
--session <runtime>:<id>.
- Check that the peer runtime has been run in this project at all.
For Claude Code cwd issues, run locate --runtime claude-code --cwd "$PWD" --json --debug and inspect lookupDiagnostics.claudeCode[]. It shows the expected project-dir slug variants and whether each directory exists.
For Cursor cwd issues, the supported transcript store is:
~/.cursor/projects/<encoded-project>/agent-transcripts/<session-id>/<session-id>.jsonl
The encoded project slug splits cwd paths on / and . and joins non-empty segments with - (for example <project-cwd> → <encoded-project>). Slug evidence is diagnostic only; stateful Cursor reads require the exact session, canonical cwd, and canonical transcript path. Cursor's SQLite chat-history store at ~/.cursor/chats/*/store.db is intentionally out of scope for this skill.
Ties (exit 3, ties)
Two or more sessions have modification times within 5 seconds of each other.
Recovery: Pass --session <runtime>:<id> with one of the candidates from the candidates[] payload. Use locate --json first if you want to see all available sessions.
User identifies a session by last-message text
Run:
node skills/session-observer/scripts/session-observer.mjs locate \
--runtime claude-code --cwd "$PWD" --json --snippet "<excerpt>"
If the snippet.matches[] result identifies the expected sessionId and cwd, use --session <runtime>:<sessionId> for review or catch-up. --session is the recovery path for ties, no-match ambiguity, and user-confirmed session identity.
Ambiguous runtime (exit 3, ambiguousRuntime)
Multiple runtimes have sessions for this cwd, and no single prior same-cwd state entry resolved the preference. --runtime auto can't pick one safely.
Recovery: Re-run with --runtime claude-code, --runtime codex, or --runtime cursor.
Lock exhausted (exit 1)
Another session-observer process holds the state lock and did not release it. The CLI exits 1 with a Failed to ... could not acquire lock after N retries message.
Recovery: Check for a stuck process. If none, remove the lock file:
rm ~/.local/state/session-observer/state.json.lock
Transcript shrank or continuity changed
For non-Cursor schema-v1 state, the existing record-offset compatibility behavior applies when a transcript shrinks.
Cursor v2 does not silently reset. A shrink, prefix mismatch, replacement, unsupported rotation, file-identity failure, or unverified legacy position returns a structured continuity-blocked result and leaves state unchanged.
Recovery: Confirm the exact Cursor identity and choose explicit replay with state reset --session cursor:<session-id>. Use the broader runtime reset only when every Cursor session can be replayed.
Corrupt legacy offset state (warning on startup)
state.json contained invalid JSON. The skill backed it up to state.json.corrupt-<ts>.bak and started fresh with an empty state. All offsets are reset.
Recovery: Run state get to confirm the fresh state. If you need the previous offsets, inspect the .bak file manually.
Cursor v2 reset and corrupt/schema recovery
Cursor uses the shared schema-v2 store cursor-state.json in addition to the legacy state.json offset store. Reset behavior is deliberately runtime-specific:
state reset --session cursor:<session-id> deletes that Cursor session from both stores. The next catch-up replays it from the transcript as a fresh session.
state reset --runtime cursor deletes every Cursor session from both stores. Every Cursor session is eligible for replay on its next catch-up.
- Non-Cursor session/runtime resets retain their existing record-offset contract: tracked entries stay present and their
lastRecordIndex is reset to zero.
Corrupt JSON or an unsupported schema in cursor-state.json fails closed with CURSOR_STATE_RECOVERY_REQUIRED; normal reads, writes, and single-session resets do not silently discard shared state. The supported recovery command is a destructive whole-Cursor-store reset:
node <skill-dir>/scripts/session-observer.mjs state reset --runtime cursor
This replaces the shared Cursor store and removes legacy Cursor offset entries. It cannot preserve sibling Cursor sessions: resetting for one corrupt or incompatible store loses state for every Cursor session, and each sibling must replay from its transcript. The CLI's JSON diagnostic reports scope: "cursor-store", destructive: true, and preservesSiblingSessions: false; surface those consequences before running the recovery command.
Secondary destructive escape hatch
If the supported state reset commands themselves cannot recover the state directory, recursive deletion is a secondary, last-resort escape hatch:
rm -rf ~/.local/state/session-observer
This removes state for all runtimes, including Cursor v2 delivery/replay state and watcher metadata—not only the broken session or runtime. Use it only after the scoped reset commands fail and after confirming no active watcher or observer process is using the directory. Subsequent catch-up operations rebuild state by replaying transcripts.
Manual verification (does this work on my machine?)
Use the opt-in probe helper to test against your real transcript stores:
node skills/session-observer/scripts/probe-local.mjs \
--runtime claude-code --cwd "$PWD"
node skills/session-observer/scripts/probe-local.mjs \
--runtime codex --cwd "$PWD"
node skills/session-observer/scripts/probe-local.mjs \
--runtime cursor --cwd "$PWD"
Exit codes 0 (digest found) and 2 (no transcripts for this cwd) are both acceptable. Only exit 1 (hard error) indicates a problem.
Success Criteria