Adapters

Inbound and outbound adapters that connect Irrlicht to the outside world.

Inbound Adapters (Event Sources)

Inbound adapters drive events into the application. Each watches a specific agent's transcript files or process activity and emits session lifecycle events that the core state machine consumes.

Claude Code Adapter

  • Package: core/adapters/inbound/agents/claudecode/
  • Watches: ~/.claude/projects/**/*.jsonl
  • Uses the shared fswatcher with recursive directory watching (two-level tree: root/<project>/<id>.jsonl)
  • Adapter name in session state: "claude-code"

Codex Adapter

  • Package: core/adapters/inbound/agents/codex/
  • Watches: ~/.codex/sessions/ (deep nesting: YYYY/MM/DD/*.jsonl)
  • Uses the shared fswatcher with recursive directory watching
  • Adapter name: "codex"
  • Model detection from ~/.codex/config.toml

Pi Adapter

  • Package: core/adapters/inbound/agents/pi/
  • Watches: ~/.pi/agent/sessions/ (nested: --<cwd-dashed>--/*.jsonl)
  • Uses the shared fswatcher with recursive directory watching
  • Adapter name: "pi"
  • Model detection from ~/.pi/agent/settings.json
  • JSONL v3 format with session header, tree-structured entries, and multi-provider support

Aider Adapter

  • Package: core/adapters/inbound/agents/aider/
  • Watches: per-CWD transcript file .aider.chat.history.md (markdown, not JSONL)
  • Process discovery via CommandLineMatch (/aider($| )) since the OS process is python
  • Adapter name: "aider"
  • Waiting state is pinned to a trailing ? contract on the most recent assistant block
  • Stage: alpha — first agent shipped through the /ir:onboard-agent discovery flow

OpenCode Adapter

  • Package: core/adapters/inbound/agents/opencode/
  • Watches: SQLite WAL at ~/.local/share/opencode/storage/opencode.db-wal via fsnotify; polls session/part tables on writes
  • First adapter on the SQLite-backed monitoring path: storage is a single WAL-mode database, not JSONL
  • Bypasses the JSONL tailer via a MetricsReader on its ProcessOwnedStore source variant for cost + token snapshots
  • Parent-child session linking via parent_id column — EventNewSession carries ParentSessionID directly, no path-based heuristics
  • CWD-based PID discovery via pgrep -x opencode
  • 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) all map to turn_done
  • Adapter name: "opencode"
  • Stage: alpha — closes #100

Process Scanner

  • Package: core/adapters/inbound/agents/processlifecycle/
  • 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
  • CWD discovery via lsof
  • Suppresses ghost pre-sessions when a real transcript exists

Shared File System Watcher

  • Package: core/adapters/inbound/agents/fswatcher/
  • Built on fsnotify (kqueue on macOS)
  • Recursive directory watching
  • Event types: EventNewSession, EventActivity, EventRemoved
  • Max age filtering
  • Race condition handling for new directories

Gas Town Orchestrator

  • Package: core/adapters/inbound/orchestrators/gastown/
  • Watches daemon state file + polls gt CLI
  • Role detection from session CWD paths
  • State model: global agents, codebases, worktrees, workers, work units

Adapter Interfaces

Every inbound agent adapter is wired up by exporting a single agent.Agent declaration. The daemon's bootstrap in core/cmd/irrlichd/main.go consumes one []agent.Agent slice; core/cmd/irrlichd/wiring.go dispatches each declaration on its Source variant to build the right combination of fswatcher and process scanner. The per-projection helpers in core/adapters/inbound/agents/maps.go derive the parser, PID-discovery, process-name, subagent-count, and metrics-reader maps the rest of the daemon consumes.

An adapter package therefore satisfies the contract by exporting a single top-level Agent() function that returns a fully-populated agent.Agent. Watching the filesystem, scanning processes, and broadcasting events are handled by the shared infrastructure (core/adapters/inbound/agents/fswatcher/, core/adapters/inbound/agents/processlifecycle/) plus the per-adapter watcher when the source needs one (e.g. OpenCode's SQLite WAL watcher).

Adapter declaration — agent.Agent

Defined as a sealed-sum across three orthogonal axes in core/domain/agent/: Identity (name + branding), Process (how to find the OS process), and Source (where the conversation lives).

// Top-level declaration — Identity × Process × Source.
type Agent struct {
    Identity Identity
    Process  Process
    Source   Source
}

type Identity struct {
    Name         string // adapter label on events, e.g. "claude-code"
    DisplayName  string // human-readable label, e.g. "Claude Code"
    IconSVGLight string // raw <svg>…</svg> markup, 14×14 (light theme)
    IconSVGDark  string // raw <svg>…</svg> markup, 14×14 (dark theme)
}

// Process bundles process-recognition with session-to-PID resolution.
// PIDForSession lives here rather than on Source so every variant gets
// it uniformly.
type Process struct {
    Match         ProcessMatcher
    PIDForSession PIDDiscoverFunc
}

type PIDDiscoverFunc func(
    cwd, transcriptPath string,
    disambiguate func([]int) int,
) (int, error)

// ProcessMatcher — sealed sum, exactly one of:
type ProcessMatcher interface{ isProcessMatcher() }

type ExactName struct {
    Name string // e.g. "claude" — runs as `pgrep -x claude`
}

type CommandPattern struct {
    Regex *regexp.Regexp // matched against the full command line
                        // via `pgrep -f`, e.g. "/aider($| )"
}

// Source — sealed sum, exactly one of:
type Source interface{ isSource() }

// JSONL transcripts under a fixed $HOME-relative directory tree
// (Claude Code, Codex, Pi).
type FilesUnderRoot struct {
    Dir    string     // path relative to $HOME, e.g. ".claude/projects"
    Parser FileParser // JSONLineParser or RawLineParser
}

// One transcript per running process inside its CWD (aider).
type FilesUnderCWD struct {
    Filename string        // basename only, e.g. ".aider.chat.history.md"
    Parser   RawLineParser // FilesUnderCWD always pairs with raw
}

// Session state lives in a structured store, typically SQLite
// (OpenCode → SQLite WAL).
type ProcessOwnedStore struct {
    PathForPID func(pid int) string // resolves the store path for a PID
    Reader     MetricsReader        // bypasses the JSONL-tailer path
}

// File-format dispatch carried inside FilesUnderRoot — JSONL or raw text.
type FileParser interface{ isFileParser() }

type JSONLineParser struct { NewParser func() LineParser }
type RawLineParser  struct { NewParser func() RawParser }

Which canonical scenarios apply to an adapter is no longer a static per-adapter declaration. It is judged per (scenario, adapter) cell by the assess verb of the /ir:onboarding-factory skill across three pillars (agent capability / daemon sensor capture / driver capability), recorded in each cell's replaydata/agents/<name>/scenarios/<cell>/metadata.json. None of it is read by Go daemon code.

Transcript parser — tailer.TranscriptParser

Defined in core/pkg/tailer/parser.go. The base contract is one method; raw-text formats (e.g. aider's markdown history) implement the optional RawLineParser in addition.

// Required for every adapter.
type TranscriptParser interface {
    // ParseLine maps one decoded JSONL line into a normalized event.
    // Return nil to silently skip the line.
    ParseLine(raw map[string]interface{}) *ParsedEvent
}

// Optional. Implemented by parsers whose source is not JSONL (aider).
// When present, the tailer skips its JSON pre-parse and hands the
// trimmed line to ParseLineRaw. ParseLine should be a nil-returning no-op.
type RawLineParser interface {
    ParseLineRaw(line string) *ParsedEvent
}

Three further optional hooks are detected by type assertion inside the tailer; implement only the ones the adapter needs.

// Synthesize turn_done when the source format has no in-band end-of-turn marker
// (aider's TUI prompt is not written to the file). The tailer calls IdleFlush
// after each pass with the wall-clock idle time since the last line.
type IdleFlusher interface {
    IdleFlush(idleFor time.Duration) *ParsedEvent
}

// Expose the in-progress turn's cost contribution so the live cost display
// reflects a streaming turn before it commits (Claude Code).
type PendingContributor interface {
    PendingContribution() *PerTurnContribution
}

// Checkpoint and restore stateful per-turn accumulation across daemon
// restarts (Claude Code requestId cursor, Codex cumulative-usage cursor).
type ParserStateProvider interface {
    GetParserLedger() ParserLedger
    SetParserLedger(ParserLedger)
}

The parser's output type, tailer.ParsedEvent, is the normalized contract every transcript format collapses into — event type, tool deltas, token snapshot / per-turn contribution, model, CWD, task deltas, subagent completions, and the user-interrupt / tool-denial flags consumed by the state classifier. See core/pkg/tailer/parser.go for the full struct.

PID discovery — agent.PIDDiscoverFunc

Defined in core/domain/agent/discovery.go. Each adapter maps a live session back to the OS process that owns it; strategies vary (CWD-based for Claude Code, transcript-writer for Codex/Pi). The disambiguate callback picks one PID when multiple candidates match.

type PIDDiscoverFunc func(
    cwd, transcriptPath string,
    disambiguate func([]int) int,
) (int, error)

Watcher output — agent.Event

Defined in core/domain/agent/event.go. Events carry no adapter field; identity flows through the merge pipeline via Watcher.Identity() (core/ports/inbound/watcher.go).

type EventType string

const (
    EventNewSession EventType = "new_session"
    EventActivity   EventType = "activity"
    EventRemoved    EventType = "removed"
)

type Event struct {
    Type            EventType
    SessionID       string // UUID portion of the filename (no .jsonl)
    ProjectDir      string // leaf directory under the watched root
    TranscriptPath  string // absolute path; for DB-backed adapters: "<db>?session=<id>"
    Size            int64
    CWD             string // working directory of the agent process
    ParentSessionID string // empty unless this is a subagent
}

// The port the daemon consumes. Each per-watcher drain goroutine in
// SessionDetector.Run() captures Identity() once and wraps every event
// with it, so per-adapter identity is a property of the watcher rather
// than the event payload.
type Watcher interface {
    Identity() agent.Identity
    Watch(ctx context.Context) error
    Subscribe() <-chan agent.Event
    Unsubscribe(ch <-chan agent.Event)
}

Outbound Adapters (System Integration)

Outbound adapters handle persistence, process monitoring, real-time communication, and other side effects that flow out of the core.

Filesystem Repository

  • Package: core/adapters/outbound/filesystem/
  • Persists session state as JSON files
  • Atomic writes via temp file + rename
  • Location: ~/Library/Application Support/Irrlicht/instances/

Process Watcher

  • Package: core/adapters/outbound/process/
  • kqueue EVFILT_PROC NOTE_EXIT monitoring
  • ~1ms exit detection latency
  • Periodic liveness sweep as fallback

WebSocket Hub

  • Package: core/adapters/outbound/websocket/
  • Fan-out to all connected clients
  • Non-blocking sends (drops slow subscribers)
  • Message types: session_created / session_updated / session_deleted, orchestrator_state

Git Resolver

  • Package: core/adapters/outbound/git/
  • Branch detection (strips worktree- prefix)
  • Project name from git-common-dir (worktree-aware)
  • CWD extraction from transcript tail (last 32KB)

Metrics Collector

  • Package: core/adapters/outbound/metrics/
  • Wraps TranscriptTailer
  • Extracts model, tokens, tool state, cost

mDNS Advertiser

  • Package: core/adapters/outbound/mdns/
  • Bonjour/Zeroconf service advertisement
  • Service type: _irrlicht._tcp
  • Includes hostname and IPv4 in TXT records

Logger

  • Package: core/adapters/outbound/logging/
  • Structured JSON logging
  • Auto-rotation at 10MB, 5 files retained

Maturity Stages

Adapters and platforms in the compatibility grid share the same four-tier ladder: planned, alpha, beta, stable. Each tier states a common ladder (time, bug count, real-world use) once and then a small set of component-specific artefact checks. Every box can be ticked from public artefacts (the repo, GitHub issues, tagged releases) -- stage assignment reduces to running through the checklist.

The shape borrows from Kubernetes feature stages (alpha experimental, beta opt-out-able, GA stable) and the CNCF graduation criteria (real adopters, time at the previous tier).

planned

  • Listed in the compatibility grid (README for adapters, homepage for platforms)

alpha — scaffolded, smoke path green

Common ladder:

  • Code lives in the canonical location for its component type
  • A documented smoke or replay path runs green from a clean checkout

For adapters, additionally:

  • Adapter package at core/adapters/inbound/agents/<name>/
  • Exports an agent.Agent from Agent() with the right Source variant (FilesUnderRoot / FilesUnderCWD / ProcessOwnedStore), parser, and PID-discovery hook
  • Registered in the allAgents slice in core/cmd/irrlichd/main.go
  • Emits all three lifecycle states: working, waiting, ready
  • replaydata/agents/<name>/capabilities.json populated against replaydata/agents/features.json
  • baseline-hello and full-lifecycle-toolcall fixtures committed and replay green via tools/replay-fixtures.sh

For platforms, additionally:

  • Builds and launches without manual intervention from the standard build script
  • Smoke path works (renders sessions / receives daemon updates)

beta — feature-complete + 30 days real use

Common ladder:

  • All alpha boxes ticked
  • At least 30 days since first shipped in a tagged Irrlicht release
  • At least one public report of real-world use by someone other than the author (GitHub issue, discussion, or PR comment)
  • Zero open bug-labeled issues against this component on ingo-eichhorst/Irrlicht

For adapters, additionally:

  • All scenarios in .claude/skills/ir:onboard-agent/scenarios.json whose requires is covered by the adapter's capabilities replay green
  • Model + cost extraction wired through core/adapters/outbound/metrics/
  • Subagent / parent-child linkage emits ParentSessionID where the agent supports subagents

For platforms, additionally:

  • All currently shipped Irrlicht features render correctly on this platform

stable — 6 months in use, code is settled

Common ladder:

  • All beta boxes ticked
  • At least 180 days since first shipped in a tagged Irrlicht release
  • Zero bug-labeled issues opened against this component in the last 30 days
  • Component code is settled: git log <component-path> --since=30.days.ago shows no refactors or new features — only reactive changes (upstream format updates, new platform versions)

For adapters, additionally:

  • Fixtures regenerated against the agent CLI version currently shipping upstream (matches the relevant entry in min_versions in scenarios.json)

Writing a New Adapter

The end-to-end path for adding an agent adapter -- from process discovery through fixture recording to stage promotion -- is documented under Adding a new agent adapter on the Contributing page. That section drives the /ir:onboard-agent skill, which produces the canonical scenario fixtures used by the criteria above.