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:onboarding-factory discovery flow

OpenCode Adapter

  • Package: core/adapters/inbound/agents/opencode/
  • Watches: SQLite WAL at ~/.local/share/opencode/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, content-filter) all map to turn_done
  • Adapter name: "opencode"
  • Stage: alpha — closes #100

Kiro CLI Adapter

  • Package: core/adapters/inbound/agents/kirocli/
  • Watches: JSONL transcripts at ~/.kiro/sessions/cli/<uuid>.jsonl (one file per session, live-appended)
  • Versioned event envelope (Prompt / AssistantMessage / ToolResults / Clear); no explicit end-of-turn marker — a text-only AssistantMessage maps to turn_done, one carrying toolUse blocks keeps the turn open
  • The transcript carries no cwd; the working directory is read from the <uuid>.json metadata sidecar next to it
  • CWD-based PID discovery via pgrep -x kiro-cli (the kiro-cli chat parent owns the session cwd; the transcript is not kept open between writes)
  • /clear continues in the same session file (a Clear event, no new UUID); headless --no-interactive runs persist no session files and are invisible
  • Adapter name: "kiro-cli"
  • Stage: alpha — closes #280

Gemini CLI Adapter

  • Package: core/adapters/inbound/agents/geminicli/
  • Watches: JSONL transcripts under ~/.gemini/tmp/ (session files named session-<timestamp>-<hash>; nested subagent chats at chats/<parent-uuid>/<child-uuid>.jsonl)
  • Process discovery via command-line match on bin/gemini (the OS process is node); the working directory 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, while benign info notices (e.g. "Model set to gemini-2.5-flash") are ignored
  • Adapter name: "gemini-cli"
  • Stage: alpha

Antigravity Adapter

  • Package: core/adapters/inbound/agents/antigravity/
  • One adapter covers both the agy CLI and the Antigravity IDE — they write byte-compatible transcripts to sibling brain stores
  • Watches: transcript.jsonl under ~/.gemini/antigravity-cli/brain/<conversation>/.system_generated/logs/ (CLI) and ~/.gemini/antigravity/brain/<conversation>/.system_generated/logs/ (IDE)
  • 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
  • Because 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"
  • Stage: alpha

Mistral Vibe Adapter

  • Package: core/adapters/inbound/agents/vibe/
  • Watches: messages.jsonl under ~/.vibe/logs/session/<session-id>/
  • Because the transcript filename is the constant messages.jsonl, the session id is derived from the parent directory name
  • Process discovery via command-line match: vibe is a Python console-script, so the OS process name is the interpreter and an exact-name match would never fire
  • Tool calls use the OpenAI nested function.name shape rather than a flat name
  • The transcript carries no timestamp, working directory, model, or usage — those are read from the sibling meta.json sidecar (config.active_model, config.auto_compact_threshold, stats.context_tokens), memoized by (mtime, size), so the model chip and context bar render
  • Opts into queued-turn boundary detection: vibe drains a mid-turn follow-up synchronously the instant the prior turn finishes, a genuinely distinct turn with no observable ready gap
  • Adapter name: "mistral-vibe"
  • Stage: alpha

GitHub Copilot Adapter

  • Package: core/adapters/inbound/agents/copilot/
  • Watches: events.jsonl under ~/.copilot/session-state/<session-id>/, relocatable via $COPILOT_HOME (which supersedes the deprecated --config-dir)
  • Because the transcript filename is the constant events.jsonl, the session id is derived from the parent directory name
  • Every record shares one envelope — {id, parentId, timestamp, type, data} — so parsing is a dispatch on type rather than a shape guess
  • Turn boundaries are explicit (assistant.turn_start / assistant.turn_end), so working/ready ride on real records instead of a quiet-tail heuristic. Copilot closes the turn after every tool call and opens a continuation, which the parser treats as continued work rather than a settle
  • Permission prompts route to waiting from the transcript itself (permission.requested / permission.completed, paired on requestId) — no hook installation required
  • /compact writes session.compaction_start / session.compaction_complete as ordinary persisted records, so the session holds working across the summarization call without needing a hook-driven overlay
  • The ! shell escape runs locally with no model turn; its injected records are skipped so a settled session is not bounced back to working with nothing left to close it
  • Tokens and cost are live from assistant.message.outputTokens per message — session.usage_checkpoint carries no counts, and the cumulative block exists only at session.shutdown
  • In-process subagents (task/explore) are counted on the parent: Copilot creates no child session directory and appends every subagent record to the parent's own events.jsonl, distinguished by a top-level agentId
  • The adapter is observe-only, gated behind a single transcripts permission
  • Adapter name: "copilot"
  • Stage: alpha

Hermes Agent Adapter

  • Package: core/adapters/inbound/agents/hermes/
  • Reads: the shared SQLite store $HERMES_HOME/state.db (default ~/.hermes/state.db) — Hermes writes no transcript files, so there is nothing per-session to watch. Opened read-only through core/pkg/sqlitero, never sql.Open directly
  • Every session — CLI, TUI, and the messaging-gateway platforms — lands in the same database: a sessions table (one row per session, carrying aggregate tokens, cost, cwd and parent linkage) and a messages table (one row per message, OpenAI-shaped)
  • A session row is INSERTed while the session is still live — roughly 2s after launch, with ended_at NULL — so the store is a live discovery source, not a post-hoc archive
  • finish_reason on assistant rows is an explicit turn boundary: stop ends the turn, tool_calls means more work follows, so no idle-flush heuristic is needed
  • sessions.cwd is populated only for TUI sessions — the interactive hermes chat REPL records source='cli' and carries an empty cwd, so the project binding is borrowed from the live process instead
  • Process discovery via command-line match: hermes is a Python console-script, so the OS process name is the interpreter and an exact-name match would never fire
  • Second agent.ProcessOwnedStore adapter after opencode, and the first whose store is shared across every surface the agent has
  • Writes (behind the hooks consent): three shell-hook entries in a marked block of $HERMES_HOME/config.yaml, plus the matching approvals in $HERMES_HOME/shell-hooks-allowlist.json — Hermes does not run a configured hook until it is approved. No code is installed: each entry runs irrlichd hook-post hermes, which resolves the daemon’s address at fire time
  • pre_approval_request / post_approval_response are the reason the channel exists: Hermes holds a pending approval in process memory and state.db has no approval table, so waiting is not reachable from the store at any polling interval. on_session_end fires once per turn and carries completed / interrupted
  • Adapter name: "hermes"
  • Stage: alpha

Muse Adapter

  • Package: core/adapters/inbound/agents/muse/
  • Watches: 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 rather than the file
  • Subagents are nested child copies at <parent-session-dir>/subagent/<child-id>/session.jsonl, so the parent link falls out of directory nesting with no file read
  • Binds the PID from each session’s own .session.lock stamp. Process discovery matches the muse-bin- prefix on the command line, because the OS process name embeds the version and build (muse-bin-1.2.1-R2847.1) and the muse launcher execs that pinned binary before a session starts — the wrapper is never observed running
  • Records are payload_type-discriminated envelopes. Some lines are instead a retained_frame that batches several independently re-encoded inner records; each child is routed through the same dispatcher a top-level line uses, then merged into the single verdict the one-event-per-line parser contract requires
  • The context-window limit comes from muse’s own locally cached ~/.local/share/muse/model-catalog/ files, not from the transcript, which never carries it
  • The session-index.db sidecar is deliberately never read — it was measured out of sync with the live session set, so it is not a reliable source for anything
  • The adapter is observe-only, gated behind a single transcripts permission. Muse offers no hook or control path
  • Account quota (separate consent row, “Muse account quota”): the Meta quota chip comes from Meta’s account API, not the transcript. It is declared on its own muse-account-api pseudo-adapter under the account-api permission (core/adapters/outbound/museaccountapi/). Once granted, the daemon reads the Muse OAuth credential (~/.config/muse/auth.json, or the ai.meta.dev.credentials macOS Keychain item) once per grant, and a sweep every 60 seconds refreshes each live top-level Muse session’s quota, calling POST https://api.meta.ai/muse-code/key only when the cached reading is older than 5 minutes. Revoking it stops further credential reads and requests and discards the cached reading
  • Adapter name: "muse"
  • Stage: alpha

DeepSeek Harness Adapter

  • Package: core/adapters/inbound/agents/dsh/
  • Watches: session.v<N>.jsonl.zstd under ~/.dsh/sessions/<workspace>/session-<uuid>/, relocatable via $DSH_HOME (then $DSH_HOME/sessions/); only the highest transcript generation in a session directory is read
  • Transcripts are concatenated, checksummed zstd frames; the shared tailer decodes them before the parser sees the JSONL records
  • The session ID comes from the session-<uuid> directory rather than the file. A native in-process subagent is linked to its parent from its transcript’s first record (origin: subagent plus parentSession); forks carry a parent too but stay top-level
  • Process discovery via command-line match on dsh, because the OS process name is node. The PID is bound by checking which process holds the session’s sibling session.lock open for writing, and concurrent sessions that share one PID stay separate
  • DeepSeek Harness’s own one-shot Codex and Claude Code provider processes are recognised by argv and parent PID, so they do not show up as duplicate sessions
  • The adapter is observe-only, gated behind a single transcripts permission
  • Adapter name: "dsh"
  • Stage: alpha

Junie Adapter

  • Package: core/adapters/inbound/agents/junie/
  • Watches: events.jsonl under ~/.junie/sessions/<session-id>/; skips the root index.jsonl, sibling state.json and transcript.md files, and nested task-* directories
  • Derives the session ID from the parent directory because every transcript uses the same events.jsonl filename
  • Reads ~/.junie/processes/<pid>-<session-id>-<hash>.json sidecars to bind the PID, session, and project. The newest startedAt sidecar owns a shared PID. The adapter checks process liveness and the Junie command line before it trusts a sidecar
  • Uses TaskState as the explicit turn boundary. Unknown event kinds are skipped because the Junie event schema is not versioned
  • Declares one read-only transcripts permission. Junie provides no hook or control path
  • Adapter name: "junie"
  • Stage: planned — the adapter exists, but recordings have not earned its maturity claims yet

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, shared-PID-owner, released-PID, process-name, argv-exclusion, session-host, launcher-PID, known-host, 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 in core/domain/agent/ as three orthogonal axes plus a permission list: Identity (name + branding), Process (how to find the OS process), Source (a sealed sum: where the conversation lives), and Permissions (the consent-gated capabilities the daemon may exercise).

// Top-level declaration — Identity × Process × Source × Permissions.
type Agent struct {
    Identity    Identity
    Process     Process
    Source      Source
    Permissions []Permission // consent-gated capabilities the daemon may exercise
}

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
    SharedPIDOwner   SharedPIDOwnerFunc       // optional: keep a root that still proves it owns a PID another root shares
    ReleasedPID      ReleasedPIDFunc          // optional: end a root whose long-lived host process released its transcript (#2080)
    LauncherPID      LauncherPIDFunc          // optional: process whose env + tty feed a hosted session's launcher (#2083)
    ExcludeArgv      func(argv []string) bool // reject a matched PID by argv shape; nil = never exclude
    SessionHostArgv  func(argv []string) bool // recognize a shared session host (the codex app-server, #2077): no pre-session, roots may bind to it
    RequireKnownHost bool                     // gate admission on OS-level process ancestry (issue #784)
}

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

type SharedPIDOwnerFunc func(cwd, transcriptPath string, pid int) bool // inconclusive = false
type ReleasedPIDFunc    func(cwd, transcriptPath string, pid int) bool // true only for a definitive "no longer holds it"
type LauncherPIDFunc    func(cwd, transcriptPath string, pid int) int  // 0 = keep pid

// 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
    DirFunc                 func() string             // optional resolver that replaces Dir, run only once observe consent is granted
    DirByOS                 map[string]string         // per-GOOS override of Dir, e.g. Windows %APPDATA%
    ExtraDirs               []string                  // additional roots watched alongside Dir
    SessionIDFromPath       func(path string) string  // override: derive session ID from path, not filename stem
    ParentSessionIDFromPath func(path string) string  // derive a session's parent from its transcript; "" = top-level
}

// 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 }

// Permission declares one consent-gated capability: what it touches, what
// feature it unlocks, and (for modify-kind entries) how to apply/undo it.
// Nothing is exercised until the user grants it (issue #570).
type Permission struct {
    Key             string          // stable identifier, e.g. "hooks", "transcripts"
    Kind            permission.Kind // modify (writes a file) or observe (reads only)
    Title           string          // short label for the wizard row
    FeatureUnlocked string          // what granting enables
    Touches         string          // what the daemon reads/writes when granted
    Detail          string          // expanded (i) text: exact paths/commands, how to undo
    Apply           func() error    // performs the modification on grant (nil for observe-kind)
    Remove          func() error    // undoes it on revoke (nil for observe-kind)
    Writes          *ManagedUserFile // non-nil when Apply writes a shared, user-owned file (follows $HOME, not IRRLICHT_HOME)
    Advisory        []AdvisoryWrite  // paths Apply is known to cause to be written that hold no irrlicht content (issue #1748)
}

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
}

Further optional hooks are detected by type assertion inside the tailer; implement only the ones the adapter needs. Three are shown below; the full set (also queuedTurnSplitter, rotationResetter and TranscriptPathAware) is in core/pkg/tailer/parser.go.

// 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
    Terminal        bool   // true when this activity event ends the agent's turn (fast-path debounce)
    Synthetic       bool   // true when injected by a hook handler, not a real transcript write (issue #905)
}

// 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/inbound/agents/processlifecycle/
  • 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, focus_requested, permissions_updated, and the history_*_v2 frames (full list: API Reference → GET /api/v1/sessions/stream)

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.

Since #1369 each adapter tier above planned has a machine-checked floor alongside its human criteria (platform tiers remain editorial). The floor is a set of core scenarios drawn from the 50 in the matrix — twelve of them, chosen for discriminating power rather than coverage (the set and the reasoning per scenario live in tools/onboarding-factory/internal/matrix/vocabulary.go). A scenario settles when it is recorded and observed, or when replaydata/agents/adapters.json declares a capability the adapter structurally lacks. The other 38 scenarios are optional and never block a promotion. of validate fails when an adapter claims a tier its core standing has not earned; claiming less is always allowed, because the time-and-adoption criteria below are invisible to it.

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 All() slice in core/adapters/inbound/agents/all.go
  • Emits the ordinary lifecycle states: working, waiting, ready (error additionally requires a failure signal the adapter can parse)
  • Scenario cells assessed via the assess verb of /ir:onboarding-factory, recorded in replaydata/agents/<name>/scenarios/<cell>/metadata.json
  • Machine-checked (#1369): the four alpha-floor core scenarios are settled — session-start, basic-turn, auto-executed-tool-call, turn-end-terminal-text. All four are reachable from lifecycle hooks alone, which is what makes alpha a day-one tier for an adapter that has hooks and no transcript parser: it asserts state only, no metrics.
  • An entry in replaydata/agents/adapters.json declaring this maturity and any missing capabilities. of validate fails if the claim exceeds what the core scenarios earn.
  • The alpha-floor cells recorded, committed and replay green via tools/replay-fixtures.sh (1-1_session-start, 2-1_basic-turn, 2-2_auto-executed-tool-call, 2-13_turn-end-terminal-text)

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:

  • Machine-checked (#1369): all nine state-core scenarios settled — the four alpha-floor ones plus session-end, long-idle-live-session, user-blocking-question, turn-aborted-by-error, multiple-sessions-same-cwd. Still no metrics scenario: that is the beta/stable line.
  • Remaining scenario cells assessed — verdict recorded per cell in replaydata/agents/<name>/scenarios/<cell>/metadata.json. Since #1369 these 38 non-core scenarios are optional: they are still assessed, recorded and rendered, but they do not hold a promotion back.
  • 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)
  • Machine-checked (#1369), adapters: all twelve core scenarios settled — the nine state-core plus the three metrics-core ones, model-identification, token-accounting, model-context-display. This is the only tier that asserts anything about tokens, cost or model identity.

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:onboarding-factory skill, which produces the canonical scenario fixtures used by the criteria above.