Session Detection

How Irrlicht discovers and tracks AI coding agent sessions.

Detection Methods

Irrlicht uses three complementary methods to detect and track sessions. Together they provide fast, reliable discovery from the moment a process starts until the session ends.

Filesystem Watching

  • Technology: fsnotify (kqueue on macOS)
  • Recursively watches transcript directories for changes
  • Events: CREATE -- new session, WRITE -- activity, REMOVE -- session ended
  • Filters: only .jsonl files unless an adapter supplies its own session-ID rule (DeepSeek Harness reads .jsonl.zstd), ignores files older than max age (5 days default)

Process Scanning

  • Polls once per second per adapter via pgrep -x <name> (exact-name match) or pgrep -f <regex> (command-line match, used by aider); interval backs off when the PID set is stable
  • Creates pre-sessions (proc-<pid>) before transcripts exist
  • Discovers CWD via lsof -a -p <pid> -d cwd -Fn
  • Pre-sessions are replaced by real sessions when the transcript arrives

Process Exit Monitoring

  • kqueue EVFILT_PROC NOTE_EXIT for instant (~1ms) exit detection
  • Fallback: periodic liveness sweep via syscall.Kill(pid, 0) every 5s, backing off to 15s after 3 consecutive clean sweeps
  • Startup cleanup: synchronous dead PID check in seedFromDisk()

Supported Agents

Claude Code

  • Transcript location: ~/.claude/projects/<project>/<session-id>.jsonl
  • Flat directory structure (one level under projects/)
  • Adapter name: claude-code

OpenAI Codex

  • Transcript location: ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<session-id>.jsonl
  • Deep directory structure (recursive watching)
  • Adapter name: codex
  • Model detection from ~/.codex/config.toml

Pi Coding Agent

  • Transcript location: ~/.pi/agent/sessions/--<cwd-dashed>--/<timestamp>_<uuid>.jsonl
  • JSONL v3 format — session header on first line with cwd, parentSession, and version fields
  • Messages use role field: user, assistant, toolResult, bashExecution
  • Turn completion detected from stopReason: "stop" on assistant messages
  • Adapter name: pi
  • Model detection from ~/.pi/agent/settings.json (defaultModel field)
  • Supports multiple LLM providers (Anthropic, OpenAI, Google, xAI, Groq)

Aider

  • Transcript location: <cwd>/.aider.chat.history.md (markdown, per-CWD — not JSONL)
  • Process discovery via CommandLineMatch (/aider($| )) since the OS process is python
  • Waiting state pinned to a trailing ? contract on the most recent assistant block
  • Adapter name: aider

OpenCode

  • Storage location: SQLite WAL at ~/.local/share/opencode/opencode.db (watched via opencode.db-wal — no JSONL files)
  • fsnotify on the WAL file triggers polling of session/part tables; cursor uses (time_created, part_id) for dedup across identical-millisecond timestamps
  • Parent-child linking from the parent_id column — EventNewSession carries ParentSessionID directly; no path-based heuristics
  • EventRemoved emitted on session.time_archived so the daemon transitions to ready immediately rather than waiting for TTL
  • step-finish reasons (stop, interrupted, length, error, content-filter) all map to turn_done; cost + token snapshots come from MetricsProvider, bypassing the JSONL tailer
  • Adapter name: opencode
  • PID discovery via pgrep -x opencode + CWD match

Kiro CLI

  • Transcript location: ~/.kiro/sessions/cli/<uuid>.jsonl (one file per session, live-appended)
  • The transcript carries no cwd; the working directory is read from the <uuid>.json metadata sidecar next to it
  • A text-only AssistantMessage maps to turn_done; one carrying toolUse blocks keeps the turn open
  • PID discovery via pgrep -x kiro-cli + CWD match; headless --no-interactive runs persist no session files and are invisible
  • Adapter name: kiro-cli

Gemini CLI

  • Transcript location: ~/.gemini/tmp/<project>/chats/session-<timestamp>-<hash>.jsonl (the fswatcher recurses into each per-project chats/ subdirectory; nested subagent chats at chats/<parent-uuid>/<child-uuid>.jsonl)
  • The cwd is read from the transcript body, not a header line
  • No explicit end-of-turn marker — a text-only assistant message maps to turn_done; benign info notices are ignored
  • Process discovery via command-line match on bin/gemini (the OS process is node)
  • Adapter name: gemini-cli

Antigravity

  • Transcript location: transcript.jsonl under ~/.gemini/antigravity-cli/brain/<conversation>/.system_generated/logs/ (CLI) and ~/.gemini/antigravity/brain/<conversation>/.system_generated/logs/ (IDE)
  • One adapter covers both the agy CLI and the Antigravity IDE — they write byte-compatible transcripts to sibling brain stores
  • Discovery is transcript-first: the IDE hosts many conversations in one Electron process with no per-conversation PID, so PID-less sessions are first-class; the agy CLI is a standalone native binary matched by exact name
  • Since the transcript filename is the constant transcript.jsonl, the session id is derived from the conversation directory path
  • Tokens and the canonical model are read from the sibling conversation store ~/.gemini/antigravity{,-cli}/conversations/<conversation>.db (not the transcript) so the context bar renders
  • Adapter name: antigravity

Mistral Vibe

  • Transcript location: messages.jsonl under ~/.vibe/logs/session/<session-id>/
  • Since the transcript filename is the constant messages.jsonl, the session id is derived from the parent directory name
  • Discovery matches the command line rather than the process name: vibe is a Python console-script, so the OS process name is the interpreter
  • The transcript carries no timestamp, working directory, model, or usage — those come from the sibling meta.json sidecar, so the model chip and context bar render
  • Adapter name: mistral-vibe

GitHub Copilot

  • Transcript location: events.jsonl under ~/.copilot/session-state/<session-id>/, relocatable via $COPILOT_HOME
  • Since the transcript filename is the constant events.jsonl, the session id is derived from the parent directory name
  • The file is created only when the first prompt is sent, so the process scanner mints a proc-<pid> pre-session first and the real session arrives under a different id — already holding an open turn, so it is born working
  • Turn boundaries are explicit, and permission prompts route to waiting straight from the transcript with no hook installation
  • In-process subagents write into the parent's transcript rather than their own directory, so they are counted on the parent instead of appearing as child sessions
  • Adapter name: copilot

Hermes Agent

  • Store location: no transcript files at all — every session lives in the shared SQLite store $HERMES_HOME/state.db (default ~/.hermes/state.db), polled read-only
  • A session row is INSERTed while the session is live (roughly 2s after launch, ended_at NULL), so discovery does not wait for the session to end
  • finish_reason is an explicit turn boundary — stop ends the turn, tool_calls means more work follows — so working/ready need no quiet-tail heuristic
  • sessions.cwd is written only by the TUI gateway; the interactive hermes chat REPL records source='cli' with an empty cwd, so the project binding comes from the live process
  • Discovery matches the command line rather than the process name: hermes is a Python console-script, so the OS process name is the interpreter
  • Adapter name: hermes

Muse

  • Transcript location: session.jsonl under ~/.local/share/muse/sessions/<YYYY>/<MM>/<DD>/<session-id>/. The filename is a constant, so the session ID comes from the parent directory
  • Subagents write their own session.jsonl under the parent session’s subagent/<child-id>/ directory, so the parent link comes from directory nesting alone
  • A .session.lock file beside each transcript carries the owning PID. Discovery matches the muse-bin- command-line prefix, because the process name embeds the version and build and the muse launcher execs the pinned binary before a session starts
  • A kind:"run" terminal event is the explicit turn boundary, so no quiet-tail heuristic is needed to close a turn
  • A kind:"approval" request routes the session to waiting, and its decision releases it
  • Token usage is read from model_completed events only. Muse re-emits the same usage a second time as goal_usage_attribution, which the parser ignores so no call is counted twice
  • The context-window limit comes from muse’s cached ~/.local/share/muse/model-catalog/ files; the transcript never carries it
  • Adapter name: muse

Junie

  • Transcript location: events.jsonl under ~/.junie/sessions/<session-id>/
  • The session ID comes from the parent directory. The adapter skips Junie's root index, sibling snapshot and rendered transcript files, and nested task directories
  • Process sidecars under ~/.junie/processes/ provide the PID and project path. The adapter checks liveness, the command line, and newest-session ownership before it binds a PID
  • TaskState is an explicit turn boundary. An INPUT_REQUIRED event state routes the session to waiting
  • Adapter name: junie

DeepSeek Harness

  • Transcript location: session.v<N>.jsonl.zstd under ~/.dsh/sessions/<cwd-key>/session-<uuid>/, relocatable via $DSH_HOME (the adapter watches $DSH_HOME/sessions)
  • The log is concatenated zstd frames; the shared tailer decodes them before the adapter parses the JSONL records. Only the highest session.v<N> generation in a session directory is read
  • The session ID is the session-<uuid> directory name. Native subagents write to a bare-UUID directory whose log header carries origin: "subagent" and the parent session ID, which provides the parent link
  • Discovery matches the command line (dsh) because the OS process name is node; the PID comes from the process that holds the session’s session.lock open
  • A turn/end record is the explicit turn boundary; an approval/asked record routes the session to waiting, and approval/decided releases it
  • Adapter name: dsh

PID Discovery

Phase Mechanism
Discovery lsof -t <transcript> with retry at 500ms, 1s, 2s intervals
CWD fallback If lsof fails, matches claude processes by working directory
Registration kqueue EVFILT_PROC NOTE_EXIT
Liveness sweep syscall.Kill(pid, 0) every 5s, backing off to 15s after 3 consecutive clean sweeps
Startup cleanup Synchronous in seedFromDisk()

Subagent Detection

When Claude Code spawns subagents (via the Agent tool), each subagent creates its own transcript file at:

~/.claude/projects/<project>/<parent-session-id>/subagents/<agent-id>.jsonl

The daemon detects these files through the same filesystem watcher and derives the parent-child relationship from the path structure. Each subagent becomes an independent session with its own state machine, linked to the parent via parent_session_id.

Lifecycle

  • Detection: Filesystem watcher sees the new .jsonl file in the subagents/ directory
  • Tracking: Session created with parent_session_id set; exempt from orphan cleanup (no PID of its own)
  • Display: Parent session shows a purple badge with the count of active subagents
  • Cleanup: Child sessions are deleted when they finish (ready state), when their transcript becomes stale (>2min), or when the parent session is deleted (cascade)

File-Based Detection

Current Claude Code writes an isSidechain transcript under subagents/agent-*.jsonl for every Agent tool call — including Explore and Plan — so the filesystem watcher picks up every subagent as its own child session. An older in-process counting path (CountOpenSubagents) is kept as a seam for a future Claude Code revision that reintroduces true in-process subagents, but it currently always returns 0.

The API's subagent summary (session.ComputeSubagentSummary) merges parent and child sessions into a single count.

Session ID

  • File-based (UUID filename): UUID extracted from the filename (<uuid>.jsonl) — Claude Code, Kiro CLI. Codex reads the ID from the rollout’s session_meta header, falling back to the UUID at the end of rollout-<timestamp>-<uuid>.jsonl
  • File-based (path-derived): Antigravity, Mistral Vibe, GitHub Copilot, Junie, Muse and DeepSeek Harness use constant (for DeepSeek Harness, generation-numbered) transcript filenames (e.g. Antigravity’s transcript.jsonl, Junie’s events.jsonl), so their session IDs come from directory paths. Gemini CLI's filename is session-<timestamp>-<hash>, not a bare UUID
  • Process-based: proc-<pid> format, used for pre-sessions before a transcript file is found

Git Metadata

  • Branch: git rev-parse --abbrev-ref HEAD (strips worktree- prefix)
  • Project name: derived from git-common-dir (works correctly with worktrees)
  • CWD: tail-reads the last 32KB of the transcript to find the latest working directory

Transcript Parsing

The tailer reads JSONL transcripts line by line and extracts:

  • Model name -- normalized from various format variations
  • Token counts -- input, output, cache read, cache creation
  • Tool call state -- open/closed tracking with tool names
  • Context window size -- used for utilization pressure calculation
  • Cost estimation -- computed from token breakdown and model pricing tables
Note Transcript parsing is incremental. The tailer remembers its read offset and only processes new bytes when the file grows, keeping CPU usage minimal even with large transcripts.