provider

package
v0.17.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 12, 2026 License: MIT Imports: 29 Imported by: 0

README

pkg/provider

Provider-specific local behavior for Confab integrations. Current providers: Claude Code, Codex, and OpenCode. Each concrete provider owns paths, hook parsing, session discovery, and transcript metadata extraction. OpenCode is the exception to on-disk "discovery": it has no transcript file, so its live data is read from a local SQLite DB and materialized to a JSONL file (see the Opencode surface below).

The package defines a Provider interface and a HookInput interface (Phase 1 + 2 of the abstraction work — see CF-394). All three concrete provider types (ClaudeCode, Codex, Opencode) satisfy Provider; hook-input adapters in hookinput.go satisfy HookInput. As of CF-397 (Phase 3), pkg/sync/engine.go dispatches sync-loop behavior (root metadata, descendant discovery, chunk annotation) through the interface; as of CF-398 (Phase 4), session discovery (ScanSessions, FindSessionByID, ExtractMetadata, DefaultCWD) is also routed through the interface. cmd/ has no discovery-related provider-name branches.

Files

File Role
provider.go Provider and HookInput interfaces, sync-loop interfaces (TranscriptRegistrar, DescendantRegistrar, WorkflowRegistrar, ChunkView), SummaryLink / AnnotationResult types, provider name constants (NameClaudeCode, NameCodex, NameOpencode), the file-type constants (FileTypeTranscript, FileTypeAgent, FileTypeWorkflowJournal), the registry (Get(name)), NormalizeName(name), GetWithDir(name, dir) (a provider configured to install into a custom config dir — claude-code only this ticket, errors for codex/opencode), and BindingFor(p, configDir) — the single chokepoint pairing a provider with config.ResolveBinding (fills the provider's default dir) for per-(provider, dir) backend bindings (kata hpec)
detect.go DetectInstalled() []string returns the canonical names of providers whose CLI binary is on PATH or whose state/config dir is present (CF-572 — covers desktop-app and CLI-uninstalled installs), in fixed registry order. OrderedNames() []string exposes that registry order so callers don't re-hardcode the list. Uses the exported package-level LookPath and StateDirPresent vars (default to exec.LookPath / stateDirPresent) so tests can stub each signal. Backs confab setup auto-detect (CF-422) and confab status per-provider presence.
session.go SessionInfo and SessionMetadata — cross-provider shapes returned by the discovery interface methods. Also defines maxLinesForExtraction, the shared readHeadLines helper, and TruncateUTF8 — the single UTF-8-safe truncation function used by all providers for metadata fields.
codex_rollout.go CodexRolloutMetadata — wire-format metadata transmitted on the first chunk of every Codex rollout. Lives here (not pkg/sync) so the Codex implementation can construct one without a cycle; pkg/sync aliases it.
hookinput.go claudeHookInputAdapter, codexHookInputAdapter, and opencodeHookInputAdapter — wrap the typed structs in pkg/types so they satisfy HookInput. Required because the structs' existing exported SessionID field collides with a SessionID() method. The OpenCode adapter returns empty TranscriptPath()/HookEventName() (OpenCode has neither).
claude.go ClaudeCode — paths, transcript validation, parent-process detection, and the Provider methods. A configDirOverride field (set via GetWithDir) makes StateDir() precedence override > CONFAB_CLAUDE_DIR env > ~/.claude, so InstallHooks (passing p.SettingsPath() to the pkg/hookconfig * functions) installs into a custom config dir (kata hpec). ConfigDirFromTranscript(path) derives the config dir from a transcript path (<dir>/projects/<enc>/<id>.jsonl, anchored on the last projects segment, canonicalized) for runtime binding resolution. Sync-loop methods are no-ops except AnnotateChunk, which delegates to ExtractMetadata. Hook install/uninstall delegates to pkg/hookconfig; skill install/uninstall/status delegates to pkg/config
claude_discovery.go Claude session scanning (ScanSessions, FindSessionByID) and metadata extraction (ExtractMetadata, DefaultCWD). Walks ~/.claude/projects/, parses Claude transcript JSONL for summaries + first user messages, sanitizes HTML, truncates to types.MaxMetadataFieldLength/2 via the shared TruncateUTF8.
claude_agentids.go ClaudeCode.ExtractAgentIDsFromMessage and IsValidAgentID — Claude-only transcript-schema parsing for sidechain agent file discovery. Called from pkg/sync/tracker.go during chunk reads.
claude_workflows.go ClaudeCode.DiscoverWorkflowFiles (CF-533) — scans <session>/subagents/workflows/<runId>/ for workflow subagent transcripts + run journals and registers them via provider.WorkflowRegistrar with path-encoded backend names. workflowFileType classifies each file (agent / workflow_journal / skip). Unlike classic subagents, workflow agents have no agentId in the main transcript, so they are found by directory scan, not by ExtractAgentIDsFromMessage.
codex.go Codex — paths, transcript validation, parent-process detection, hook handling, and the Provider methods. InitTranscript attaches root rollout metadata from session_meta; DiscoverDescendants walks the SQLite subtree; DiscoverWorkflowFiles is a no-op (no Codex equivalent — the predicate is never invoked, so a Codex session never probes capabilities); AnnotateChunk attaches codex_rollout on FirstLine==1 and extracts first_user_message once per session via ExtractMetadata. Hook install/uninstall delegates to pkg/hookconfig; skill install/uninstall/status delegates to pkg/config
codex_discovery.go Codex rollout discovery: ScanSessions (interface), ScanCodexSessions (rich type), FindSessionByID (walks subagent UUIDs up to the root), package-private rollout resolution, ReadSessionInfo, ExtractFirstUserMessageFromLines, ExtractMetadata, DefaultCWD. Also houses CodexSessionInfo and the rollout-filename regex. Truncation uses the shared TruncateUTF8 with types.MaxMetadataFieldLength/2.
codex_state.go Codex local SQLite reader: StateDBPath(), WalkUpToRoot(threadUUID), ListSubtree(rootUUID). Used by the hook handler, confab save (via FindSessionByID's walk-up), and Codex.DiscoverDescendants to discover subagent rollouts and route them to the top-most root
opencode.go Opencode — paths (~/.config/opencode), TS plugin install/uninstall, parent-process detection, and the Provider methods. ShouldSpawnForInput returns false for subagent sessions (via an optional SessionParentID() accessor on the input); ScanSessions/FindSessionByID error (live-sync only; manual mode deferred); InitTranscript/DiscoverWorkflowFiles are no-ops; DiscoverDescendants (CF-538) walks the SQLite session.parent_id tree and calls OpencodeDescendantRegistrar.RegisterOpencodeChild per descendant — a missed type assertion logs Warn (a forgotten daemon setter is loud, not silent). OpencodeChildBackendName(childID) is the path-encoded file_name contract callers use. AnnotateChunk sets first_user_message from the first user message's first text part on the first transcript chunk (CF-540, via ocFirstUserMessageText) so synced sessions appear in the web session list (no summary — OpenCode has none). Embeds opencodePluginSourceRaw (kept byte-identical to plugins/confab-sync.ts by TestOpencodePluginSourceMatchesFile).
opencode_db.go OpenCodeDBReader — reads OpenCode's local SQLite DB (~/.local/share/opencode/opencode.db, or CONFAB_OPENCODE_DB / $XDG_DATA_HOME/opencode/opencode.db). Three public methods share a private openRO() so the read-only DSN flags stay in lockstep: (1) ReadSession(ctx, sessionID, sinceMessageID) runs a LEFT JOIN message + part indexed by message(session_id, time_created, id) + part(message_id, id) and returns []ocRawEnvelope with id/sessionID/messageID injected from row columns into the embedded JSON (those keys are never in data). HWM (m.id > sinceMessageID) makes the query incremental. (2) ReadSessionInfo(ctx, sessionID) (CF-549) reads directory + COALESCE(parent_id, '') from the session row for the resume path. (3) ListDescendants(ctx, rootSessionID) (CF-538) walks the session.parent_id recursive CTE for every descendant, capped at 1000 rows as a cycle defense, ordered by id. Returns empty strings (not error) on sql.ErrNoRows so a not-yet-committed row degrades to defaults. Replaces the deleted opencode_client.go / OpenCodeClient HTTP/SSE path (CF-543).
opencode_collector.go OpenCodeCollector — materializes one session's complete messages into a local JSONL file. Run(ctx) seeds the emitted-id set + high-water mark from any existing file, then ticks at the daemon-supplied interval (default CONFAB_SYNC_INTERVAL_MS = 30s). Each reconcile calls source.ReadSession(ctx, sessionID, highWaterMark), appends complete messages in id order, stops at the first incomplete one, and advances the HWM (append-only, monotonic, idempotent). Reconcile errors flow through a Warn-on-first-then-every-Nth cadence (N = ceil(60s / interval)) so a stuck collector is loud without spamming.
opencode_session.go OpenCode assembly + completeness gating (pure, no I/O): ocRawEnvelope ({info, parts} kept raw), shallow ocPeekInfo/ocPeekPart, ocIsComplete (user always; assistant on finish/error), ocKeepParts (terminal tool parts only), ocSerializeLine (preserves raw bytes, filters part set), ocSortByID.

Provider surfaces

ClaudeCode
  • Paths: StateDir, SettingsPath, ProjectsDir, transcript path validation against CONFAB_CLAUDE_DIR.
  • Discovery: ScanSessions, FindSessionByID, ExtractMetadata, DefaultCWD (the four Provider interface methods); plus ExtractAgentIDsFromMessage for classic sidechain agent file discovery and DiscoverWorkflowFiles for Workflow-tool subagent transcripts + run journals (directory-scanned, capability-gated — see claude_workflows.go and CF-533).
  • Hooks: ReadHookInput, ReadSessionHookInput, InstallHooks/UninstallHooks/IsHooksInstalled (delegate to pkg/hookconfig, which edits ~/.claude/settings.json).
  • Skills: InstallSkills installs /retro under ~/.claude/skills/ (and prunes retired skills); UninstallSkills removes bundled skills; IsSkillInstalled reports per-skill state (delegates to pkg/config).
  • Hook response: WriteHookResponse writes a types.ClaudeHookResponse.
  • Parent detection: parent PID monitoring helpers, Claude-specific.
Codex
  • Paths: StateDir (override via CONFAB_CODEX_DIR), SessionsDir, ConfigPath.
  • Discovery: ScanSessions, FindSessionByID (walks subagent UUIDs up to the root), ExtractMetadata, DefaultCWD (the four Provider interface methods).
  • Additional rollout helpers: ScanCodexSessions (rich CodexSessionInfo form), ReadSessionInfo, SessionIDFromRolloutPath, ExtractFirstUserMessageFromLines, internal walkRollouts helper.
  • Filtering: CodexSessionInfo.IsUserSession() excludes subagents/memory rollouts by thread_source and agent_* metadata.
  • Hooks: ReadHookInput, ReadSessionHookInput, InstallHooks/UninstallHooks/IsHooksInstalled (delegate to pkg/hookconfig, which edits ~/.codex/config.toml). Installs SessionStart, PreToolUse, and PostToolUse; shutdown remains parent-PID driven.
  • Skills: InstallSkills installs /retro under ~/.codex/skills/ (and prunes retired skills); UninstallSkills removes bundled skills; IsSkillInstalled reports per-skill state (delegates to pkg/config).
  • Hook response: WriteHookResponse writes a types.CodexHookResponse.
  • Parent detection: FindParentPID, IsProcess, MatchesProcess (regex (?i)\bcodex\b) for daemon parent-liveness monitoring, mirroring ClaudeCode.
  • Transcript metadata: ExtractFirstUserMessageFromLines reads the first event_msg.user_message from rollout lines, trims whitespace, and truncates to types.MaxMetadataFieldLength/2 (4KB) via the shared TruncateUTF8 on a UTF-8 boundary with ... suffix.
  • Path validation: ValidateRolloutPath requires an absolute path under SessionsDir matching rollout-<timestamp>-<uuid>.jsonl.
Codex daemon shutdown

Codex fires Stop at every agent/turn boundary, including root rollout stops while the interactive Codex session is still alive. Wiring confab hook session-end to [[hooks.Stop]] would therefore kill the root sync daemon prematurely. Instead:

  • Codex.InstallHooks writes [[hooks.SessionStart]], [[hooks.PreToolUse]], and [[hooks.PostToolUse]] into the managed block.
  • cmd/spawn.go stores Codex.FindParentPID() on the daemon at spawn time.
  • The daemon's main loop (pkg/daemon/daemon.go) monitors that PID and shuts down when the interactive Codex process exits — same mechanism Claude Code uses.
  • confab hook session-end --provider codex is rejected with an explicit error pointing users at their ~/.codex/config.toml.
  • Local state DB (codex_state.go): reads Codex's ~/.codex/state_*.sqlite (read-only, highest numeric suffix wins; CONFAB_CODEX_STATE_DB overrides). WalkUpToRoot(threadUUID) walks the thread_spawn_edges chain to the top-most root with a 5×50ms retry budget for the spawn-vs-edge race (and a thread_source='user' fast-path that skips retries for known roots). ListSubtree(rootUUID) returns every descendant via a recursive CTE. All paths degrade gracefully when the DB is unavailable — callers see (threadUUID, "", nil) for WalkUpToRoot and a nil slice for ListSubtree.
Opencode

OpenCode has no on-disk transcript file and no native hook system. A tiny TS plugin (plugins/confab-sync.ts, installed to ~/.config/opencode/plugins/) bridges lifecycle: on session.created it pipes {session_id, cwd, parent_id?, parent_pid} to confab hook session-start --provider opencode; on any allowlisted reconcile event (session.status/session.updated/session.compacted/session.error — CF-549) it sends the same payload with cwd:"" and the Go side resolves cwd + parent session id from SQLite via OpenCodeDBReader.ReadSessionInfo. The plugin caps itself at MAX_DAEMONS=32 to bound runaway-bot exposure. On dispose it fires confab hook session-end --provider opencode for each session it started. All data sync is the daemon's job — it reads OpenCode's local SQLite DB via OpenCodeDBReader (see opencode_db.go).

  • Paths: StateDir is ~/.config/opencode (override CONFAB_OPENCODE_CONFIG_DIR); PluginDir is <state>/plugins. The SQLite DB path is resolved separately by OpenCodeDBPath (CONFAB_OPENCODE_DB$XDG_DATA_HOME/opencode/opencode.db~/.local/share/opencode/opencode.db).
  • Hooks: InstallHooks/UninstallHooks/IsHooksInstalled write/remove/stat the plugin file (no pkg/hookconfig). WriteHookResponse is a no-op — the plugin does not read a hook response from stdout.
  • Skills: InstallSkills installs /retro under ~/.config/opencode/skills/ via config.ReconcileBundledSkills (generic template); UninstallSkills / IsSkillInstalled mirror Claude/Codex.
  • Shutdown: signalled by the plugin's disposesession-end, with parent-PID liveness as a backstop (FindParentPID matches \bopencode\b). SupportsCommitLinking is false — no GitHub commit/PR linking.
  • Discovery methods (ScanSessions, FindSessionByID) return errors — OpenCode sessions are captured live by the daemon's collector, and offline manual mode is deferred (would need its own SQLite session enumeration). ExtractMetadata is minimal; DefaultCWD returns filepath.Dir(transcriptPath).
  • ShouldSpawnForInput suppresses non-root sessions: it type-asserts an optional SessionParentID() string accessor on the input (satisfied by cmd.launchAsHookInput, fed from the plugin's parent_id) and returns false when a parent is present. Kept off the shared HookInput interface so Claude/Codex inputs need not implement it.
  • The collector (opencode_db.go + opencode_collector.go + opencode_session.go) is driven by the daemon, not the Provider interface — see pkg/daemon and the CLAUDE.md "OpenCode provider differences" section.
  • Subagent capture as sidechain files under the root is deferred (CF-538); CF-537 only suppresses subagent daemons.
  • The HTTP/SSE path the earlier collector used (CF-537) is gone: OpenCode v1.1.10+ ships the local HTTP server off by default, so that source could never sync for typical users (CF-542). The SQLite DB has always been present and is where the data actually lives.

Provider interface

Methods every provider must implement:

  • Name() string — canonical name (one of NameClaudeCode, NameCodex, NameOpencode).
  • CLIBinaryName() string — OS-level binary name used by DetectInstalled / confab status ("claude" for Claude Code, "codex" for Codex, "opencode" for OpenCode). Distinct from Name() because the canonical name (claude-code) is not the binary name.
  • StateDir() (string, error) — local state directory.
  • FindParentPID() int, IsProcess(pid int) bool — parent-process detection.
  • ParseSessionHook(io.Reader) (HookInput, error) — read a SessionStart hook payload and return the provider-agnostic view.
  • InstallHooks() (string, error) / UninstallHooks() (string, error) / IsHooksInstalled() (bool, error) — install/check the full hook set the provider requires. Claude installs 4 bundles (sync, PreToolUse, PostToolUse, UserPromptSubmit) and Codex installs 3 events (SessionStart, PreToolUse, PostToolUse), both delegating to pkg/hookconfig. OpenCode has no settings/config hooks: it writes a TS plugin to ~/.config/opencode/plugins/ directly (no pkg/hookconfig involvement).
  • SupportsCommitLinking() bool — true if the provider installs the PreToolUse + PostToolUse events that drive bidirectional GitHub linking. Used by cmd/hook_pretooluse.go and cmd/hook_posttooluse.go to silently no-op for any provider that doesn't support the flow. Claude Code and Codex return true; OpenCode returns false.
  • InstallSkills() error / UninstallSkills() error / IsSkillInstalled(name string) bool — manage bundled Confab skills in the provider's local skill layout.
  • WalkUpToRoot(sessionID string) (rootID, rootPath string, error) — Codex walks thread_spawn_edges; Claude is identity with empty rootPath.
  • ShouldSpawnForInput(in HookInput) bool — Codex returns false for subagent rollouts and for unreadable rollout files; OpenCode returns false for any session with a parent (subagents); Claude always returns true. os.IsNotExist is treated as a race-tolerance "spawn anyway" case.
  • WriteHookResponse(w, suppressOutput, systemMessage) error — write the provider-specific hook response JSON (ClaudeHookResponse vs CodexHookResponse). OpenCode is a no-op — its plugin reads no response from stdout.
  • InitTranscript(target TranscriptRegistrar, transcriptPath, externalID string) error — called from sync.Engine.Init after the tracker is initialized. Codex attaches root rollout metadata via target.SetCodexRollout; Claude is a no-op. Implementations never surface read failures as errors — they log warn and fall through.
  • DiscoverDescendants(reg DescendantRegistrar, externalID string) error — called once per SyncAll cycle, before the BFS loop. Codex walks the SQLite subtree and calls reg.RegisterCodexRollout per verified descendant. OpenCode walks session.parent_id and calls reg.(OpencodeDescendantRegistrar).RegisterOpencodeChild per descendant (CF-538) — the daemon supplies the wrapper that drives child-collector spawn; a missed type assertion logs Warn. Claude is a no-op (its agents are discovered transitively from transcript content inside tracker.DiscoverNewFiles). Must be idempotent across calls.
  • DiscoverWorkflowFiles(reg WorkflowRegistrar, allow func(fileType string) bool) (int, error) — called once per SyncAll cycle (CF-533). Claude scans subagents/workflows/<runId>/ and registers agent transcripts + run journals via reg.RegisterSidechainFile under path-encoded names, gating each file on allow(fileType) (the engine's per-flag capability predicate). The provider invokes allow only after finding a candidate file, so non-workflow sessions never trigger a backend probe. Codex and OpenCode are no-ops. Returns the count of newly-registered files; idempotent across calls.
  • AnnotateChunk(c ChunkView, sentFirstUserMessage bool, redact func(string) string) AnnotationResult — called for every chunk before upload. Providers attach chunk-level metadata via setters on c; summary links go in the returned AnnotationResult.SummaryLinks so the engine drives the HTTP. The redact closure is nil-safe and lets providers stay decoupled from pkg/redactor. Claude and Codex delegate to ExtractMetadata for the parsing work; OpenCode uses ocFirstUserMessageText to peek the materialized {info, parts} lines.
  • ScanSessions() ([]SessionInfo, error) — returns user-initiated sessions discoverable on disk, oldest first. Claude walks ~/.claude/projects/; Codex projects from ScanCodexSessions and extracts FirstUserMessage per rollout for the list-command title.
  • FindSessionByID(partialID string) (id, transcriptPath string, error) — resolves a full or partial ID. Claude is identity walk-up; Codex walks subagent UUIDs up to the root via WalkUpToRoot so callers transparently upload the whole tree.
  • ExtractMetadata(lines []string) SessionMetadata — in-memory parsing of the first maxLinesForExtraction (50) JSONL lines. Claude returns full Summary + FirstUserMessage + SummaryLinks; Codex returns only FirstUserMessage.
  • DefaultCWD(transcriptPath string) string — CWD to record on the upload. Claude returns filepath.Dir(transcriptPath); Codex reads session_meta.cwd with the dir fallback.

Get(name) and the registry

Get(name) returns the registered Provider for a canonical name (empty string defaults to claude-code). NormalizeName(name) is the same lookup but returns the canonical name string. The registry is a package-level read-only map populated at init time — to add a new provider, add its instance to the map and implement the interface.

Invariants

  • NameClaudeCode, NameCodex, and NameOpencode are the canonical wire values. Backend session uniqueness is (user_id, provider, external_id).
  • NormalizeName(name) returns claude-code for empty input (legacy default) and rejects unknown providers.
  • ClaudeStateDirEnv is duplicated between pkg/config/paths.go and pkg/provider/claude.go to break a circular import. The two MUST stay in sync; reviewers should catch any drift.
  • ClaudeCode preserves existing Claude Code behavior, including CONFAB_CLAUDE_DIR.
  • Claude hook parsing returns types.ClaudeHookInput; Codex hook parsing returns types.CodexHookInput. There is no generic normalized hook payload.
  • Codex.ExtractFirstUserMessageFromLines only considers event_msg.user_message — the first response_item.message[role=user] line in a Codex rollout contains an <environment_context> wrapper, not the user's prompt, and must be skipped.
  • TruncateUTF8 never returns a string longer than maxBytes, even on invalid UTF-8 input, and appends ... when truncation occurs (unless maxBytes < 3).
  • Codex.IsUserSession filters out subagents and memory rollouts so ScanSessions only surfaces top-level user sessions.
  • Codex.InstallHooks is idempotent and never strips unmanaged Codex config sections.
  • Codex.WalkUpToRoot is the single point that converts a firing thread UUID to its top-most root. All Codex daemon spawning and confab save invocations route through it, so subagent rollouts always upload under the root's session — never as orphan sessions.
  • Codex.WalkUpToRoot never returns the empty string for the root UUID; on any failure mode (no DB, schema mismatch, edge-race exhausted) it returns the input thread UUID so callers can keep moving.
  • Parent PID detection is part of the Provider interface (FindParentPID, IsProcess); the bodies remain provider-specific (different process-name patterns) and share the package-level getProcCmdline / getParentPID helpers in claude.go.
  • Agent-ID extraction (ClaudeCode.ExtractAgentIDsFromMessage) is intentionally Claude-only. Codex tracks subagents via its SQLite thread tree and never grows agent IDs in rollout JSONL — pkg/sync/tracker.go calls the Claude method on every chunk regardless of provider; the method's msgType != "user" early-return safely no-ops on Codex data.
  • Codex.FindSessionByID returns the ROOT thread for any partial UUID matching a subagent. The package-private findRolloutByID helper resolves concrete rollout files before the walk-up step.
  • DetectInstalled() and OrderedNames() return names in fixed registry order (claude-code, then codex, then opencode) regardless of LookPath / StateDirPresent lookup order. This determinism is load-bearing for setup output and tests.
  • CLIBinaryName() is the OS binary name ("claude", "codex", "opencode") — never the canonical provider name. The two diverge for Claude Code (claude-code vs claude).
  • Codex.InstallHooks installs SessionStart, PreToolUse, and PostToolUse. Daemon shutdown is driven by parent-PID liveness, never by Codex Stop.
  • Codex.InstallSkills writes only SKILL.md files under ~/.codex/skills/<name>/; optional Codex UI metadata such as agents/openai.yaml is not generated for Confab's bundled skills.
  • CodexRolloutMetadata JSON tags are wire-format pins. Existing rows in the backend's codex_rollouts table were written against these tags; renaming any field is a backwards-incompatible change. Adding new optional fields (with omitempty) is safe.
  • CodexRolloutMetadata string fields (cwd, model, agent_*) ride on the first chunk unredacted. Rollout content is redacted in pkg/sync.FileTracker.ReadChunk; this struct is not. Before adding a field that could carry free-text user content, plumb the redactor into Codex.InitTranscript / Codex.DiscoverDescendants — see the struct doc in codex_rollout.go.
  • Sync-loop providers (InitTranscript, DiscoverDescendants, DiscoverWorkflowFiles, AnnotateChunk) are called from a single goroutine inside the engine's sync loop. Implementations may mutate the passed TranscriptRegistrar / DescendantRegistrar / WorkflowRegistrar / ChunkView without locking; the engine does not call them concurrently for the same engine instance.

Used By

cmd/, pkg/hookconfig/ (provider provides the file paths; hookconfig does the file editing), pkg/sync/ (the engine dispatches root metadata, descendant discovery, and chunk annotation through the Provider interface; the tracker calls ClaudeCode{}.ExtractAgentIDsFromMessage directly for Claude sidechain discovery).

Documentation

Index

Constants

View Source
const (
	NameClaudeCode = "claude-code"
	NameCodex      = "codex"
	NameOpencode   = "opencode"
)
View Source
const (
	FileTypeTranscript      = "transcript"
	FileTypeAgent           = "agent"
	FileTypeWorkflowJournal = "workflow_journal"
)

FileType* are the sync file_type values the backend recognizes for a tracked file. FileTypeTranscript is a session's primary transcript/rollout; FileTypeAgent is any subagent sidechain (Claude agent-*.jsonl, Codex descendant rollouts, OpenCode child sessions, workflow subagent transcripts).

FileTypeWorkflowJournal is the sync file_type for a Claude workflow run's journal.jsonl (CF-533). The backend (CF-532) accepts and stores it but never Claude-parses it; it is excluded from token/transcript analytics. Workflow subagent transcripts use the ordinary FileTypeAgent file_type.

View Source
const ClaudeStateDirEnv = "CONFAB_CLAUDE_DIR"

ClaudeStateDirEnv is the environment variable to override the default Claude state directory.

View Source
const CodexStateDBEnv = "CONFAB_CODEX_STATE_DB"

CodexStateDBEnv overrides automatic state DB discovery. When set, points directly at a state_N.sqlite file (or any SQLite file with the expected schema). Used by tests; can also be set by power users debugging Codex state inconsistencies.

View Source
const CodexStateDirEnv = "CONFAB_CODEX_DIR"
View Source
const OpenCodeDBEnv = "CONFAB_OPENCODE_DB"

OpenCodeDBEnv overrides automatic OpenCode SQLite-DB discovery. When set, points directly at an opencode.db file (or any SQLite file with the expected schema). Used by tests; can also be set by power users debugging OpenCode session sync.

Variables

View Source
var LookPath = exec.LookPath

LookPath is the package-level seam tests stub to simulate CLI presence.

View Source
var StateDirPresent = stateDirPresent

StateDirPresent is the package-level seam tests stub to simulate a provider's state/config dir being present. It reports whether the dir into which the provider installs its hooks/plugins exists — the "configured locally" signal (desktop-app or CLI) that complements binary-on-PATH. Defaults to stateDirPresent (inspects the filesystem).

Functions

func BindingFor added in v0.17.1

func BindingFor(p Provider, configDir string) config.Binding

BindingFor resolves the backend binding for p's (provider, configDir). It is the single place that pairs a provider with config-dir binding resolution (kata hpec): it fills in the provider's DEFAULT config dir so an empty (or canonically-default) configDir collapses to the default binding (top-level config). Pass a default provider (from Get), NOT a GetWithDir override — the override's StateDir() is the custom dir itself and would always look "default".

func DetectInstalled

func DetectInstalled() []string

DetectInstalled returns the canonical names of providers a user actually uses — those whose CLI binary is on PATH OR whose state/config dir is present (desktop-app or CLI-uninstalled installs) — in fixed registry order. Each provider appears at most once. Result is never nil but may be empty.

func NormalizeName

func NormalizeName(name string) (string, error)

NormalizeName returns the canonical provider name. Backed by the registry so it can't drift from the Provider list.

func OpenCodeDBPath added in v0.17.0

func OpenCodeDBPath() (string, error)

OpenCodeDBPath resolves the OpenCode SQLite DB path in this order:

  1. CONFAB_OPENCODE_DB env override
  2. $XDG_DATA_HOME/opencode/opencode.db (when XDG_DATA_HOME is set)
  3. ~/.local/share/opencode/opencode.db

The returned path is not guaranteed to exist on disk; callers handle that via the reader's normal retry path.

func OpencodeChildBackendName added in v0.17.0

func OpencodeChildBackendName(childSessionID string) string

OpencodeChildBackendName returns the path-encoded backend file_name a daemon registrar should use when registering an OpenCode child file with the tracker. Forward slashes are load-bearing (the backend parses the path segments to resolve the child session id).

func OrderedNames added in v0.17.0

func OrderedNames() []string

OrderedNames returns the canonical provider names in fixed registry order. Callers that render or detect per-provider iterate this instead of re-hardcoding the list. Returns a fresh copy each call.

func ResetStateDBPathCacheForTest

func ResetStateDBPathCacheForTest()

ResetStateDBPathCacheForTest clears the cached state DB path so the next StateDBPath call re-evaluates the env var and glob. Production code resolves the path once per process; tests need to swap fixtures between cases. Lives in non-test code so cross-package tests (sync, daemon, cmd) can call it.

func ResetWalkUpRetryForTest

func ResetWalkUpRetryForTest()

ResetWalkUpRetryForTest restores production retry defaults.

func SetWalkUpRetryForTest

func SetWalkUpRetryForTest(attempts int, backoff time.Duration)

SetWalkUpRetryForTest shrinks WalkUpToRoot's retry budget for tests that would otherwise wait for the full production timeout. Pair with ResetWalkUpRetryForTest in t.Cleanup.

func TruncateUTF8 added in v0.17.0

func TruncateUTF8(s string, maxBytes int) string

TruncateUTF8 returns s truncated so its byte length is at most maxBytes, without splitting a multi-byte rune. If truncation occurs and maxBytes >= 3, appends "..." (3 ASCII bytes) to indicate continuation. Returns s unchanged if len(s) <= maxBytes. Returns "" when maxBytes <= 0.

Types

type AnnotationResult

type AnnotationResult struct {
	IncludedFirstUserMessage bool
	SummaryLinks             []SummaryLink
}

AnnotationResult is the structured return from AnnotateChunk. IncludedFirstUserMessage tells the engine whether to flip its sentFirstUserMessage flag. SummaryLinks (Claude only) tells the engine which parent summaries to link via the backend.

type ChunkView

type ChunkView interface {
	FileType() string
	FirstLine() int
	Lines() []string
	FileCodexRollout() *CodexRolloutMetadata
	SetCodexRolloutMetadata(*CodexRolloutMetadata)
	SetSummary(string)
	SetFirstUserMessage(string)
}

ChunkView is the structural view of a chunk + its source file that AnnotateChunk reads from and writes back into. pkg/sync's chunkView adapter satisfies it. Setters mutate the underlying chunk's metadata in place; accessors return read-only snapshots.

type ClaudeCode

type ClaudeCode struct {
	// contains filtered or unexported fields
}

ClaudeCode contains Claude Code-specific local behavior. configDirOverride, when non-empty, takes precedence over CONFAB_CLAUDE_DIR and the default in StateDir() — it is how `confab setup --provider claude-code --config-dir <dir>` retargets installation into a non-default config dir (kata hpec). The zero value (ClaudeCode{}) keeps today's behavior.

func (ClaudeCode) AnnotateChunk

func (p ClaudeCode) AnnotateChunk(c ChunkView, _ bool, redact func(string) string) AnnotationResult

AnnotateChunk extracts the local summary, first user message, and summary-link records from a Claude Code transcript chunk. Summary links are returned via AnnotationResult.SummaryLinks so the engine can perform the backend HTTP after AnnotateChunk returns — keeping the provider side-effect-free.

Non-transcript files are a no-op (Claude extracts only from transcripts).

Claude does not gate first-user-message extraction on a "first time" flag — the discovery helper handles dedup internally and the engine historically does not flip sentFirstUserMessage for Claude. The returned IncludedFirstUserMessage stays false so the engine's flag is untouched.

func (ClaudeCode) CLIBinaryName

func (ClaudeCode) CLIBinaryName() string

CLIBinaryName returns "claude" — the binary `claude` users install via the Claude Code installer.

func (ClaudeCode) ConfigDirFromTranscript added in v0.17.1

func (ClaudeCode) ConfigDirFromTranscript(transcriptPath string) (string, error)

ConfigDirFromTranscript derives the Claude config dir from a transcript path. Claude writes transcripts to <configDir>/projects/<enc-cwd>/<id>.jsonl, so the config dir is the directory three levels above the file — but only when the path actually has that shape (absolute, and the segment two levels up is literally "projects", which anchors on the LAST "projects" so a config dir whose own path contains "projects" still resolves correctly). The result is canonicalized so it matches a stored binding key. Returns an error when the path does not have the expected layout, so callers can fall back to the default binding.

func (ClaudeCode) DefaultCWD

func (p ClaudeCode) DefaultCWD(transcriptPath string) string

func (ClaudeCode) DiscoverDescendants

func (ClaudeCode) DiscoverDescendants(DescendantRegistrar, string) error

DiscoverDescendants is a no-op for Claude Code. Claude's agent files are discovered transitively from transcript content (agent IDs embedded in JSONL messages) inside tracker.DiscoverNewFiles — no external state DB lookup is required.

func (ClaudeCode) DiscoverWorkflowFiles added in v0.16.2

func (ClaudeCode) DiscoverWorkflowFiles(reg WorkflowRegistrar, allow func(fileType string) bool) (int, error)

DiscoverWorkflowFiles implements provider.Provider — see the interface doc.

func (ClaudeCode) ExtractAgentIDsFromMessage

func (ClaudeCode) ExtractAgentIDsFromMessage(message map[string]interface{}) []string

ExtractAgentIDsFromMessage extracts agent IDs from a parsed Claude transcript message. Checks both root-level toolUseResult.agentId and nested content blocks. Empty slice on non-user messages or missing fields.

func (ClaudeCode) ExtractMetadata

func (p ClaudeCode) ExtractMetadata(lines []string) SessionMetadata

ExtractMetadata parses summary, first user message, and summary links from in-memory Claude transcript lines. Lines beyond maxLinesForExtraction are ignored.

For summaries:

  • Entries with a leafUuid go to SummaryLinks (links to previous sessions).
  • Entries without leafUuid become the local Summary (last one wins).

For user messages: the first one encountered sets FirstUserMessage.

func (ClaudeCode) FindParentPID

func (p ClaudeCode) FindParentPID() int

FindParentPID walks up the process tree to find the Claude Code process.

func (ClaudeCode) FindSessionByID

func (p ClaudeCode) FindSessionByID(partialID string) (string, string, error)

FindSessionByID resolves a full or partial Claude session ID to its full ID and transcript path. Walk-up is identity for Claude (no thread tree). Returns an error on no-match or ambiguous prefix.

func (ClaudeCode) InitTranscript

func (ClaudeCode) InitTranscript(TranscriptRegistrar, string, string) error

InitTranscript is a no-op for Claude Code — there is no root rollout metadata to attach (Codex-only concern).

func (ClaudeCode) InstallHooks

func (p ClaudeCode) InstallHooks() (string, error)

InstallHooks installs all four Confab hook bundles (sync, PreToolUse, PostToolUse, UserPromptSubmit). Returns the settings.json path.

func (ClaudeCode) InstallSkills

func (p ClaudeCode) InstallSkills() error

InstallSkills installs the Claude Code skills shipped with confab (/retro) and prunes any retired skills left by older versions.

func (ClaudeCode) IsHooksInstalled

func (p ClaudeCode) IsHooksInstalled() (bool, error)

IsHooksInstalled reports whether all four Confab hook bundles for Claude Code are installed. Mirrors InstallHooks: true only when every bundle is present.

func (ClaudeCode) IsProcess

func (p ClaudeCode) IsProcess(pid int) bool

IsProcess checks if the given PID is a Claude Code process.

func (ClaudeCode) IsSkillInstalled

func (p ClaudeCode) IsSkillInstalled(name string) bool

IsSkillInstalled reports whether a shipped Claude Code skill exists.

func (ClaudeCode) MatchesProcess

func (ClaudeCode) MatchesProcess(cmd string) bool

MatchesProcess checks if a command string matches Claude Code.

func (ClaudeCode) Name

func (ClaudeCode) Name() string

Name returns the canonical Claude Code provider name.

func (ClaudeCode) OnAlreadyRunning added in v0.17.0

func (ClaudeCode) OnAlreadyRunning(string)

DefaultCWD returns filepath.Dir(transcriptPath); Claude has no richer per-session CWD source. OnAlreadyRunning is a no-op for Claude Code: hook deduplication is the normal path (Claude fires SessionStart on every turn). See Provider interface doc for the OpenCode-specific behavior.

func (ClaudeCode) ParseSessionHook

func (p ClaudeCode) ParseSessionHook(r io.Reader) (HookInput, error)

ParseSessionHook reads a Claude SessionStart hook payload and returns the provider-agnostic view.

func (ClaudeCode) ProjectsDir

func (p ClaudeCode) ProjectsDir() (string, error)

ProjectsDir returns the Claude projects directory.

func (ClaudeCode) ReadHookInput

func (ClaudeCode) ReadHookInput(r io.Reader) (*types.ClaudeHookInput, error)

ReadHookInput reads and validates Claude hook JSON.

func (ClaudeCode) ReadSessionHookInput

func (p ClaudeCode) ReadSessionHookInput(r io.Reader) (*types.ClaudeHookInput, error)

ReadSessionHookInput reads Claude session hook JSON and validates transcript_path.

func (ClaudeCode) ScanSessions

func (p ClaudeCode) ScanSessions() ([]SessionInfo, error)

ScanSessions walks ~/.claude/projects/ and returns all user sessions sorted oldest first. Permission errors per path are reported to stderr and don't fail the scan.

func (ClaudeCode) SettingsPath

func (p ClaudeCode) SettingsPath() (string, error)

SettingsPath returns the Claude settings file path.

func (ClaudeCode) ShouldSpawnForInput

func (ClaudeCode) ShouldSpawnForInput(HookInput) bool

ShouldSpawnForInput is unconditional for Claude Code.

func (ClaudeCode) StateDir

func (p ClaudeCode) StateDir() (string, error)

StateDir returns the Claude config/install directory. Precedence: configDirOverride (set via GetWithDir for `setup --config-dir`) > the CONFAB_CLAUDE_DIR env var > the default ~/.claude.

func (ClaudeCode) SupportsCommitLinking added in v0.16.1

func (ClaudeCode) SupportsCommitLinking() bool

SupportsCommitLinking reports that Claude Code installs PreToolUse + PostToolUse hooks that drive bidirectional GitHub linking.

func (ClaudeCode) UninstallHooks

func (p ClaudeCode) UninstallHooks() (string, error)

UninstallHooks removes all four Confab hook bundles. Returns the settings.json path even if no hooks were present.

func (ClaudeCode) UninstallSkills

func (p ClaudeCode) UninstallSkills() error

UninstallSkills removes the Claude Code skills shipped with confab.

func (ClaudeCode) ValidateTranscriptPath

func (p ClaudeCode) ValidateTranscriptPath(path string) error

ValidateTranscriptPath checks that a Claude transcript path is safe: - Must be absolute - Must not contain ".." components - Must resolve to a location under the Claude projects directory

func (ClaudeCode) WalkUpToRoot

func (ClaudeCode) WalkUpToRoot(sessionID string) (string, string, error)

WalkUpToRoot is the identity walk for Claude Code: there is no thread tree, so the firing session is always its own root and rootPath is "".

func (ClaudeCode) WriteHookResponse

func (ClaudeCode) WriteHookResponse(w io.Writer, suppressOutput bool, systemMessage string) error

WriteHookResponse writes a ClaudeHookResponse to w.

type Codex

type Codex struct{}

func (Codex) AnnotateChunk

func (p Codex) AnnotateChunk(c ChunkView, sentFirstUserMessage bool, redact func(string) string) AnnotationResult

AnnotateChunk attaches Codex-specific chunk metadata. Two concerns are handled independently:

  • first_user_message: extracted via ExtractMetadata from the root transcript's chunks (Codex emits the user prompt once at the start). Gated by c.FileType() == "transcript" + !sentFirstUserMessage. The closure `redact` (nil-safe) is applied before attaching.

  • codex_rollout: per-rollout metadata so the backend can upsert the `codex_rollouts` row. Emitted on the FIRST chunk of any Codex rollout (root or descendant) — detected via c.FirstLine() == 1. No persistent state flag is needed; retries preserve FirstLine == 1. Backend upsert is idempotent.

func (Codex) CLIBinaryName

func (Codex) CLIBinaryName() string

CLIBinaryName returns "codex" — the binary users install via Codex.

func (Codex) ConfigPath

func (p Codex) ConfigPath() (string, error)

func (Codex) DefaultCWD

func (p Codex) DefaultCWD(transcriptPath string) string

DefaultCWD reads session_meta.cwd from the rollout. Falls back to filepath.Dir on read failure or empty CWD so the upload still has a directory to record.

func (Codex) DiscoverDescendants

func (p Codex) DiscoverDescendants(reg DescendantRegistrar, rootThreadUUID string) error

DiscoverDescendants queries the local Codex SQLite state DB for every descendant of rootThreadUUID, verifies each rollout file exists and looks like an actual subagent (ValidateRolloutPath + !IsUserSession check on session_meta), and registers verified ones via reg.RegisterCodexRollout.

Idempotent across calls (skips already-tracked filenames). Degrades gracefully when the state DB is missing or its schema doesn't match — ListSubtree returns (nil, nil) and we return nil. Per-descendant verification failures log at warn level and skip the row.

func (Codex) DiscoverWorkflowFiles added in v0.16.2

func (Codex) DiscoverWorkflowFiles(WorkflowRegistrar, func(fileType string) bool) (int, error)

DiscoverWorkflowFiles is a no-op for Codex: workflow subagents are a Claude Code harness feature (the Workflow tool) with no Codex equivalent, so there are never workflow run dirs to scan. The allow predicate is intentionally never invoked, so a Codex session never triggers a backend capability probe.

func (Codex) ExtractFirstUserMessageFromLines

func (Codex) ExtractFirstUserMessageFromLines(lines []string) string

ExtractFirstUserMessageFromLines returns the first non-empty user message found in the given rollout lines, truncated to MaxMetadataFieldLength/2 bytes on a UTF-8 boundary. Returns "" when no user message is present.

func (Codex) ExtractMetadata

func (p Codex) ExtractMetadata(lines []string) SessionMetadata

ExtractMetadata returns the in-memory metadata for a Codex chunk. Summary and SummaryLinks stay empty — those are Claude-only concepts. Lines are capped to maxLinesForExtraction to mirror Claude's bound.

func (Codex) FindParentPID

func (p Codex) FindParentPID() int

FindParentPID walks up the process tree to find the Codex process. Mirrors ClaudeCode.FindParentPID for daemon parent-liveness monitoring.

func (Codex) FindSessionByID

func (p Codex) FindSessionByID(partialID string) (string, string, error)

FindSessionByID resolves a full or partial UUID to the ROOT thread's UUID and rollout path. If partialID matches a subagent, this walks up to the top-most user session via WalkUpToRoot so callers transparently upload the whole tree.

func (Codex) InitTranscript

func (p Codex) InitTranscript(target TranscriptRegistrar, transcriptPath, externalID string) error

InitTranscript reads the root rollout's session_meta and attaches the resulting CodexRolloutMetadata to the transcript file so the very first uploaded chunk carries codex_rollout metadata. On read failure (missing or malformed rollout file) logs at warn level and still attaches the bare-minimum metadata (ThreadUUID + RolloutPath) so the backend can upsert a row; CWD/Model/etc. stay empty. Matches pre-CF-397 behavior. Never returns an error — failure modes are recoverable.

func (Codex) InstallHooks

func (p Codex) InstallHooks() (string, error)

InstallHooks delegates to pkg/hookconfig.

func (Codex) InstallSkills

func (p Codex) InstallSkills() error

InstallSkills installs the Codex skills shipped with confab (/retro) and prunes any retired skills left by older versions.

func (Codex) IsHooksInstalled

func (p Codex) IsHooksInstalled() (bool, error)

IsHooksInstalled delegates to pkg/hookconfig, which parses ~/.codex/config.toml and returns true iff a confab command is registered under [[hooks.SessionStart]].

func (Codex) IsProcess

func (p Codex) IsProcess(pid int) bool

IsProcess checks if the given PID is a Codex process.

func (Codex) IsSkillInstalled

func (p Codex) IsSkillInstalled(name string) bool

IsSkillInstalled reports whether a shipped Codex skill exists.

func (Codex) ListSubtree

func (p Codex) ListSubtree(rootThreadUUID string) ([]CodexThreadRow, error)

ListSubtree returns every descendant of rootThreadUUID, at any depth, via a recursive CTE over `thread_spawn_edges` JOIN `threads`. Each returned row's ParentThreadUUID is the immediate parent (which may itself be a descendant of rootThreadUUID for grandchildren). The root itself is NOT returned.

Returns (nil, nil) when the DB doesn't exist or the schema doesn't match — degrades gracefully so daemon sync cycles continue uninterrupted.

func (Codex) MatchesProcess

func (Codex) MatchesProcess(cmd string) bool

MatchesProcess checks if a command string matches a Codex invocation.

func (Codex) Name

func (Codex) Name() string

Name returns the canonical Codex provider name.

func (Codex) OnAlreadyRunning added in v0.17.0

func (Codex) OnAlreadyRunning(string)

OnAlreadyRunning is a no-op for Codex: hook deduplication is the normal path (Codex fires SessionStart for every spawned subagent). See Provider interface doc for the OpenCode-specific behavior.

func (Codex) ParseSessionHook

func (p Codex) ParseSessionHook(r io.Reader) (HookInput, error)

ParseSessionHook reads a Codex SessionStart hook payload and returns the provider-agnostic view.

func (Codex) ReadHookInput

func (p Codex) ReadHookInput(r io.Reader) (*types.CodexHookInput, error)

func (Codex) ReadSessionHookInput

func (p Codex) ReadSessionHookInput(r io.Reader) (*types.CodexHookInput, error)

func (Codex) ReadSessionInfo

func (p Codex) ReadSessionInfo(path string) (CodexSessionInfo, error)

ReadSessionInfo reads a rollout's session_meta line and returns the parsed CodexSessionInfo (with file stat info populated).

func (Codex) ScanCodexSessions

func (p Codex) ScanCodexSessions() ([]CodexSessionInfo, error)

ScanCodexSessions returns the rich Codex-specific session info for every user-initiated rollout. Internal callers that need CodexSessionInfo's extras (CWD, Model, AgentRole, ...) use this directly; the cross-provider Provider.ScanSessions interface method projects to []SessionInfo.

func (Codex) ScanSessions

func (p Codex) ScanSessions() ([]SessionInfo, error)

ScanSessions projects ScanCodexSessions to the cross-provider SessionInfo shape. FirstUserMessage is extracted from each rollout's first event_msg.user_message line (capped to maxLinesForExtraction). Sessions are returned oldest first to match Claude's ordering.

func (Codex) SessionIDFromRolloutPath

func (Codex) SessionIDFromRolloutPath(path string) (string, bool)

SessionIDFromRolloutPath parses the UUID embedded in a Codex rollout filename. Returns ("", false) on filenames that don't match the rollout pattern.

func (Codex) SessionsDir

func (p Codex) SessionsDir() (string, error)

func (Codex) ShouldSpawnForInput

func (p Codex) ShouldSpawnForInput(in HookInput) bool

ShouldSpawnForInput inspects the rollout file's session_meta to decide whether the firing hook represents a user-initiated rollout (true) or a subagent (false).

func (Codex) StateDBPath

func (p Codex) StateDBPath() (string, error)

StateDBPath resolves the path to Codex's local state SQLite DB. Resolution order:

  1. CONFAB_CODEX_STATE_DB env var (escape hatch for tests/debugging).
  2. Glob `<StateDir>/state_*.sqlite`, parse the integer suffix between `_` and `.sqlite`, return the entry with the highest numeric suffix. If suffixes don't parse as integers, falls back to alphabetical max.
  3. Returns os.ErrNotExist if no candidate file exists.

The result is cached for the lifetime of the process via sync.Once.

func (Codex) StateDir

func (Codex) StateDir() (string, error)

func (Codex) SupportsCommitLinking added in v0.16.1

func (Codex) SupportsCommitLinking() bool

SupportsCommitLinking reports that Codex installs PreToolUse + PostToolUse hooks that drive bidirectional GitHub linking. See pkg/hookconfig/codex.go for the managed-block contents.

func (Codex) UninstallHooks

func (p Codex) UninstallHooks() (string, error)

UninstallHooks delegates to pkg/hookconfig.

func (Codex) UninstallSkills

func (p Codex) UninstallSkills() error

UninstallSkills removes the Codex skills shipped with confab.

func (Codex) ValidateRolloutPath

func (p Codex) ValidateRolloutPath(path string) error

func (Codex) WalkUpToRoot

func (p Codex) WalkUpToRoot(threadUUID string) (rootUUID, rootRolloutPath string, err error)

WalkUpToRoot walks the parent_thread_id chain in `thread_spawn_edges` starting from threadUUID until it reaches a thread with no parent edge. Returns the top-most root's UUID + that root's rollout_path.

If threadUUID is already a root (no parent edge), returns it unchanged. If the DB is unavailable or the thread isn't in the DB at all, returns (threadUUID, "", nil) — graceful degradation so callers don't need to branch on transient errors.

Built-in retry: the FIRST parent lookup retries up to walkUpRetryAttempts times with walkUpRetryBackoff between attempts. This absorbs the spawn-vs-edge race where Codex fires the SessionStart hook for a fresh subagent before the `thread_spawn_edges` row has been committed.

The retry only fires on the first hop; subsequent hops (grandchild → child → root walks) assume the chain is already stable in Codex's DB.

Logs the retry-attempt count + wall time at info level so we can later tune the retry budget against observed race timing.

func (Codex) WriteHookResponse

func (Codex) WriteHookResponse(w io.Writer, suppressOutput bool, systemMessage string) error

WriteHookResponse writes a CodexHookResponse to w.

type CodexRolloutMetadata

type CodexRolloutMetadata struct {
	ThreadUUID       string `json:"thread_uuid"`
	ParentThreadUUID string `json:"parent_thread_uuid,omitempty"` // "" for roots
	RolloutPath      string `json:"rollout_path"`
	CWD              string `json:"cwd,omitempty"`
	Model            string `json:"model,omitempty"`
	// Source is the flattened discriminator from Codex's polymorphic
	// session_meta.source field — a short string like "cli" or "subagent".
	// The backend's `codex_rollouts.source` column caps this at 64 chars.
	Source        string `json:"source,omitempty"`
	ThreadSource  string `json:"thread_source,omitempty"`
	AgentPath     string `json:"agent_path,omitempty"`
	AgentRole     string `json:"agent_role,omitempty"`
	AgentNickname string `json:"agent_nickname,omitempty"`
}

CodexRolloutMetadata is the per-rollout metadata transmitted on the FIRST chunk of a Codex rollout (root or descendant). The backend upserts it into the `codex_rollouts` table keyed by ThreadUUID. Omitted on chunks where chunk.FirstLine != 1, so the backend handler treats absence as "no metadata to record this round."

Codex-only; the backend rejects this field on non-codex sessions with 400.

Lives in pkg/provider so both pkg/sync (wire format) and pkg/provider's Codex implementation can construct one without a package import cycle. pkg/sync re-exports it via `type CodexRolloutMetadata = provider.CodexRolloutMetadata`.

Redaction: fields below are sourced from Codex's session_meta (and the SQLite state DB for descendants) and ride on the first chunk unredacted. Rollout *content* is redacted in pkg/sync.FileTracker.ReadChunk; these metadata fields are not. Current fields are short, structured values (path, model name, agent role). Before adding a field that could carry free-text user content, plumb the redactor into Codex.InitTranscript / Codex.DiscoverDescendants rather than extending this struct.

type CodexSessionInfo

type CodexSessionInfo struct {
	SessionID   string
	RolloutPath string
	CWD         string
	Model       string
	// Source is a short discriminator extracted from the rollout's `source`
	// field. Codex writes that field as either a bare string ("cli") for
	// user-initiated rollouts or a tagged object ({"subagent":{...}}) for
	// spawned subagents. The string case is passed through; the object case
	// is collapsed to its top-level key. Empty when session_meta omits the
	// field. Matches the backend's 64-char `codex_rollouts.source` column.
	Source        string
	ThreadSource  string
	AgentPath     string
	AgentRole     string
	AgentNickname string
	ModTime       time.Time
	SizeBytes     int64
}

CodexSessionInfo is the rich Codex-specific session metadata returned by ScanCodexSessions and ReadSessionInfo. Internal callers (cmd/save.go, engine init paths) need the extras (CWD, Model, AgentRole, ...) that don't fit on the cross-provider SessionInfo.

func (CodexSessionInfo) IsUserSession

func (s CodexSessionInfo) IsUserSession() bool

IsUserSession reports whether this rollout is a top-level user session (not a subagent or memory rollout).

type CodexThreadRow

type CodexThreadRow struct {
	ThreadUUID       string
	ParentThreadUUID string
	RolloutPath      string
	CWD              string
	Model            string
	Source           string
	ThreadSource     string
	AgentPath        string
	AgentRole        string
	AgentNickname    string
}

CodexThreadRow describes a single Codex thread as recorded in the local state DB's `threads` table. ParentThreadUUID is the immediate parent recorded in `thread_spawn_edges`, or "" for a root thread.

type DescendantRegistrar

type DescendantRegistrar interface {
	IsTracked(fileName string) bool
	RegisterCodexRollout(path, fileName string, isRoot bool, meta CodexRolloutMetadata)
}

DescendantRegistrar is the surface DiscoverDescendants uses to register newly-discovered sidechain files. *sync.FileTracker satisfies it via IsTracked + RegisterCodexRollout.

type HookInput

type HookInput interface {
	SessionID() string
	TranscriptPath() string
	CWD() string
	HookEventName() string
	ParentPID() int
}

HookInput is the provider-agnostic view of a hook payload, exposing the fields used by daemon spawning and bookkeeping. Concrete shapes (types.ClaudeHookInput, types.CodexHookInput) satisfy this via adapter types defined in hookinput.go.

type OpenCodeCollector added in v0.17.0

type OpenCodeCollector struct {
	// contains filtered or unexported fields
}

OpenCodeCollector materializes a single OpenCode session's messages into a local JSONL file that the ordinary file-based sync pipeline uploads.

OpenCode keeps session data in a local SQLite DB (one row per message, one row per part). The collector polls the DB on a ticker, fetches new messages strictly after the high-water mark, filters out non-terminal tool parts, walks in ULID order stopping at the first incomplete message (see ocIsComplete), and appends each emitted envelope as one line of JSON. The file stays append-only and monotonic — which is what pkg/sync's incremental line-offset tracking requires.

Idempotency across daemon restarts needs no extra persisted state: on start, seed re-builds the emitted-id set and the HWM from any existing output file.

func NewOpenCodeCollector added in v0.17.0

func NewOpenCodeCollector(source ocSource, sessionID, outputPath string, interval time.Duration) *OpenCodeCollector

NewOpenCodeCollector builds a collector for one session. interval is the poll cadence; pass 0 for a sensible default. The interval is wired from daemon.syncInterval so CONFAB_SYNC_INTERVAL_MS tunes both backend sync and SQLite polling with a single knob.

func (*OpenCodeCollector) Run added in v0.17.0

func (c *OpenCodeCollector) Run(ctx context.Context) error

Run seeds, then reconciles on every tick until ctx is cancelled. There is no remote stream to reconnect to — the SQLite DB is local and the poll is the only liveness mechanism.

type OpenCodeDBReader added in v0.17.0

type OpenCodeDBReader struct {
	// contains filtered or unexported fields
}

OpenCodeDBReader reads OpenCode session data from a local SQLite DB (~/.local/share/opencode/opencode.db). It is the only producer of the materialized {info, parts} JSONL the collector appends; everything downstream of that file is provider-agnostic.

Each ReadSession call opens the DB read-only, runs a single LEFT JOIN query, and closes — mirroring the Codex state-DB read pattern. The DB is concurrently written by OpenCode (WAL mode); the reader never writes, uses busy_timeout for transient lock contention, and tolerates seeing rows mid-write (downstream completeness gating in ocIsComplete handles that).

func NewOpenCodeDBReader added in v0.17.0

func NewOpenCodeDBReader(dbPath string) *OpenCodeDBReader

NewOpenCodeDBReader builds a reader bound to a specific DB path. The path is not validated until ReadSession runs.

func (*OpenCodeDBReader) ListDescendants added in v0.17.0

func (r *OpenCodeDBReader) ListDescendants(ctx context.Context, rootSessionID string) ([]string, error)

ListDescendants returns every descendant session ID under rootSessionID (at any depth), discovered via recursive walk of session.parent_id. The root itself is excluded; results are returned in ULID lex order (which equals chronological order for OpenCode session ids) so callers see a deterministic enumeration. Capped at listDescendantsLimit rows.

Returns an error only when the DB file is unreadable — callers (the provider's DiscoverDescendants) translate that to a Warn log + nil so the daemon's sync cycle continues uninterrupted past a transient DB-absence.

func (*OpenCodeDBReader) ReadSession added in v0.17.0

func (r *OpenCodeDBReader) ReadSession(ctx context.Context, sessionID, sinceMessageID string) ([]ocRawEnvelope, error)

ReadSession returns messages for sessionID strictly greater than sinceMessageID (pass "" for a full read), as raw {info, parts} envelopes ordered by (message.time_created, message.id) and by part.id within each message. The returned envelopes carry id/sessionID injected into info, and id/sessionID/messageID injected into each part — these live in DB columns, not in the stored JSON, so reconstruction is essential for the wire shape the materialized JSONL contract requires.

Returns (nil, nil) when the session has no qualifying rows yet (treated as "wait, retry" by the caller). Returns a clear error when the DB file is missing or unreadable.

func (*OpenCodeDBReader) ReadSessionInfo added in v0.17.0

func (r *OpenCodeDBReader) ReadSessionInfo(ctx context.Context, sessionID string) (directory, parentID string, err error)

ReadSessionInfo fetches a session row's directory and parent_id from the OpenCode SQLite DB. Returns empty strings (not an error) when the row is absent so the caller can proceed with best-effort defaults. Errors are returned only when the DB itself is unreadable.

Used by the resume path in cmd/hook_sessionstart.go to resolve the cwd + parent session id from a session_id-only payload (CF-549).

type Opencode added in v0.17.0

type Opencode struct{}

func (Opencode) AnnotateChunk added in v0.17.0

func (Opencode) AnnotateChunk(c ChunkView, sentFirstUserMessage bool, redact func(string) string) AnnotationResult

AnnotateChunk sets first_user_message on the first transcript chunk so synced OpenCode sessions appear in the web session list (CF-540) — the backend's list query hides any session with neither a summary nor a first_user_message, and the CLI is the only source for those fields. OpenCode has no summary concept, so only first_user_message is set (mirroring Codex). The text is the first user message's first text part, trimmed and redacted (redact is nil-safe). A malformed materialized line degrades to "no message found" rather than failing the sync — we wrote these lines ourselves, so a parse error signals a collector bug worth a debug log, not a blocked upload.

func (Opencode) CLIBinaryName added in v0.17.0

func (Opencode) CLIBinaryName() string

func (Opencode) DefaultCWD added in v0.17.0

func (Opencode) DefaultCWD(transcriptPath string) string

func (Opencode) DiscoverDescendants added in v0.17.0

func (Opencode) DiscoverDescendants(reg DescendantRegistrar, externalID string) error

DiscoverDescendants enumerates every OpenCode subagent session under externalID via the local SQLite DB and registers each as a path-encoded sidechain file under the root's backend session (CF-538). The reg must implement OpencodeDescendantRegistrar (the daemon-supplied wrapper that drives child-collector goroutine spawn). When the assertion misses (forgotten daemon setter, or unit tests using a plain *FileTracker), logs a Warn once and returns nil — the production path requires the wrapper; the log surfaces a misconfiguration that would otherwise be silent.

Per-tick semantics:

  1. Resolve the DB path (CONFAB_OPENCODE_DB env, then $XDG_DATA_HOME, then ~/.local/share). Provider is stateless: a fresh reader per call costs one stat + open.
  2. Recursive CTE walks session.parent_id descendants (capped at 1000 rows as a cycle defense).
  3. For each descendant: derive the nested local materialized path ~/.confab/opencode/<root>/children/<child>/messages.jsonl, then call reg.RegisterOpencodeChild. The registrar handles capability gating, file registration, and collector spawn — all idempotent.

DB unavailable → log Warn, return nil (consistent with Codex's behavior when its state DB is missing). The daemon's sync cycle continues uninterrupted past a transient DB-absence.

func (Opencode) DiscoverWorkflowFiles added in v0.17.0

func (Opencode) DiscoverWorkflowFiles(WorkflowRegistrar, func(string) bool) (int, error)

func (Opencode) ExtractMetadata added in v0.17.0

func (Opencode) ExtractMetadata([]string) SessionMetadata

func (Opencode) FindParentPID added in v0.17.0

func (Opencode) FindParentPID() int

func (Opencode) FindSessionByID added in v0.17.0

func (Opencode) FindSessionByID(string) (string, string, error)

FindSessionByID is unsupported for OpenCode for the same reason as ScanSessions; manual `confab save <id>` is deferred.

func (Opencode) InitTranscript added in v0.17.0

func (Opencode) InitTranscript(TranscriptRegistrar, string, string) error

func (Opencode) InstallHooks added in v0.17.0

func (p Opencode) InstallHooks() (string, error)

func (Opencode) InstallSkills added in v0.17.0

func (p Opencode) InstallSkills() error

func (Opencode) IsHooksInstalled added in v0.17.0

func (p Opencode) IsHooksInstalled() (bool, error)

func (Opencode) IsProcess added in v0.17.0

func (Opencode) IsProcess(pid int) bool

func (Opencode) IsSkillInstalled added in v0.17.0

func (p Opencode) IsSkillInstalled(name string) bool

func (Opencode) Name added in v0.17.0

func (Opencode) Name() string

func (Opencode) OnAlreadyRunning added in v0.17.0

func (Opencode) OnAlreadyRunning(externalID string)

OnAlreadyRunning logs a warning that a parallel opencode process resumed the same session — a real edge case that confab's lifecycle model does not currently support reliably (CF-549, M2). The log goes to the confab log file only, not to opencode's stderr.

func (Opencode) ParseSessionHook added in v0.17.0

func (p Opencode) ParseSessionHook(r io.Reader) (HookInput, error)

func (Opencode) PluginDir added in v0.17.0

func (p Opencode) PluginDir() (string, error)

func (Opencode) ReadSessionHookInput added in v0.17.0

func (p Opencode) ReadSessionHookInput(r io.Reader) (*types.OpenCodeHookInput, error)

func (Opencode) ScanSessions added in v0.17.0

func (Opencode) ScanSessions() ([]SessionInfo, error)

ScanSessions is unsupported for OpenCode: live capture happens via the sync daemon's SQLite-backed collector, and offline manual mode is deferred (would require its own SQLite session enumeration).

func (Opencode) ShouldSpawnForInput added in v0.17.0

func (Opencode) ShouldSpawnForInput(in HookInput) bool

ShouldSpawnForInput refuses subagent (non-root) OpenCode sessions so only the user-initiated root session spawns a daemon; CF-538 will capture subagents as sidechain files under the root. A session is a subagent when the plugin forwarded a parent session id (surfaced via an optional SessionParentID() accessor on the input — kept off the shared HookInput interface so Claude/ Codex inputs need not implement it). Inputs without the accessor (or with an empty parent id) are treated as root.

func (Opencode) StateDir added in v0.17.0

func (p Opencode) StateDir() (string, error)

func (Opencode) SupportsCommitLinking added in v0.17.0

func (Opencode) SupportsCommitLinking() bool

func (Opencode) UninstallHooks added in v0.17.0

func (p Opencode) UninstallHooks() (string, error)

func (Opencode) UninstallSkills added in v0.17.0

func (p Opencode) UninstallSkills() error

func (Opencode) WalkUpToRoot added in v0.17.0

func (Opencode) WalkUpToRoot(sessionID string) (string, string, error)

func (Opencode) WriteHookResponse added in v0.17.0

func (Opencode) WriteHookResponse(w io.Writer, _ bool, _ string) error

type OpencodeDescendantRegistrar added in v0.17.0

type OpencodeDescendantRegistrar interface {
	DescendantRegistrar

	// RegisterOpencodeChild registers the child file (path-encoded backend
	// file_name = "opencode/<childID>/messages.jsonl", file_type = "agent")
	// AND ensures a collector goroutine is running for it. Idempotent: a
	// repeat call for an already-tracked + already-collecting child is a
	// no-op. Capability-gated internally on opencode_subagent_files; when
	// the capability is off, both register and spawn no-op silently (the
	// engine logs the capability state once via resolveCaps).
	RegisterOpencodeChild(childSessionID, localPath string)
}

OpencodeDescendantRegistrar is the surface Opencode.DiscoverDescendants uses to register subagent sessions discovered in OpenCode's local SQLite (CF-538). The daemon supplies an implementation that wraps *FileTracker, performing capability-gated child registration AND idempotent collector goroutine spawn in one call.

*FileTracker does NOT satisfy this interface directly (no collector goroutine concept); the daemon's opencodeRegistrar wrapper does.

type Provider

type Provider interface {
	Name() string
	// CLIBinaryName is the OS-level binary name to look up via
	// exec.LookPath when detecting whether the provider is installed
	// locally (e.g. "claude" for Claude Code, "codex" for Codex).
	CLIBinaryName() string
	StateDir() (string, error)
	FindParentPID() int
	IsProcess(pid int) bool

	// SupportsCommitLinking reports whether this provider's hook system
	// can drive bidirectional GitHub linking (commit-trailer + PR-body
	// injection via PreToolUse; commit/PR URL linking via PostToolUse).
	// Used by cmd/ handlers to silently no-op for providers that don't
	// install those events.
	SupportsCommitLinking() bool

	// ParseSessionHook reads a SessionStart-style hook payload from r and
	// returns the provider-agnostic view.
	ParseSessionHook(r io.Reader) (HookInput, error)

	InstallHooks() (string, error)
	UninstallHooks() (string, error)
	IsHooksInstalled() (bool, error)

	// InstallSkills installs the bundled skills for this provider's local
	// skill layout.
	InstallSkills() error
	UninstallSkills() error
	IsSkillInstalled(name string) bool

	// WalkUpToRoot returns the root session ID and its rollout path. For
	// providers without a separate root file identifier (Claude Code),
	// rootPath is "".
	WalkUpToRoot(sessionID string) (rootID, rootPath string, err error)

	// ShouldSpawnForInput is the per-provider gate on whether a fresh
	// SessionStart should result in a daemon. Codex returns false for
	// subagent rollouts; Claude is always true.
	ShouldSpawnForInput(in HookInput) bool

	// WriteHookResponse writes a hook response payload to w. The response
	// shape is provider-specific but the (continue, suppressOutput,
	// systemMessage) tuple is shared.
	WriteHookResponse(w io.Writer, suppressOutput bool, systemMessage string) error

	// InitTranscript is called from sync.Engine.Init AFTER the backend has
	// returned the initial sync state and the transcript file has been
	// registered in the tracker. Codex reads session_meta and attaches
	// codex_rollout metadata to the root transcript so the first chunk
	// uploaded carries it. Claude is a no-op. The engine logs+continues on
	// error; implementations may return one for true I/O failures.
	InitTranscript(target TranscriptRegistrar, transcriptPath, externalID string) error

	// DiscoverDescendants is called once per SyncAll cycle, BEFORE the BFS
	// loop. Providers with an external discovery model (Codex: SQLite
	// subtree walk) register newly-discovered sidechain files via reg.
	// Must be idempotent across calls (skip already-tracked filenames).
	// Claude is a no-op (its agents are discovered transitively from
	// transcript content, handled in tracker.DiscoverNewFiles).
	DiscoverDescendants(reg DescendantRegistrar, externalID string) error

	// DiscoverWorkflowFiles is called once per SyncAll cycle, alongside
	// DiscoverDescendants, to register Claude workflow subagent transcripts
	// (subagents/workflows/<runId>/agent-<id>.jsonl) and run journals
	// (.../journal.jsonl) as path-encoded sidechain files (CF-533). The
	// allow predicate is supplied by the engine and gates each file by its
	// file_type against backend capability ("agent" → workflow_files,
	// "workflow_journal" → workflow_journal); the provider calls it only
	// after it has a candidate file, so non-workflow sessions never trigger
	// a backend capability probe. Returns the count of newly-registered
	// files (for logging). Claude scans the filesystem; Codex is a no-op
	// (no Workflow-tool equivalent). Must be idempotent across calls.
	DiscoverWorkflowFiles(reg WorkflowRegistrar, allow func(fileType string) bool) (int, error)

	// AnnotateChunk is called for every chunk read from a tracked file,
	// BEFORE upload. Providers attach provider-specific chunk metadata
	// (codex_rollout, first_user_message, summary). The redact closure is
	// nil-safe; providers must guard `if redact != nil { ... }` before
	// applying. Engine inspects the returned AnnotationResult to flip its
	// sentFirstUserMessage flag and dispatch any extracted summary links.
	AnnotateChunk(c ChunkView, sentFirstUserMessage bool, redact func(string) string) AnnotationResult

	// ScanSessions returns the user-initiated sessions discoverable on
	// disk for this provider, sorted oldest first. Subagent rollouts and
	// other non-user transcripts are filtered out.
	ScanSessions() ([]SessionInfo, error)

	// FindSessionByID resolves a full or partial session ID to its full
	// ID and transcript path. For providers with a thread tree (Codex)
	// this walks up to the root so the returned (id, path) refer to the
	// top-most user session — callers that want the unwalked rollout use
	// provider-specific methods.
	FindSessionByID(partialID string) (id, transcriptPath string, err error)

	// ExtractMetadata parses summary, first user message, and
	// (Claude-only) summary links from in-memory transcript lines.
	// Implementations cap the line count to bound cost.
	ExtractMetadata(lines []string) SessionMetadata

	// DefaultCWD returns the working directory to record alongside an
	// upload for this transcript path. Claude derives from the path;
	// Codex reads session_meta.cwd with a path-dir fallback.
	DefaultCWD(transcriptPath string) string

	// OnAlreadyRunning is invoked by maybeSpawnDaemon when a spawn is
	// denied because a daemon for this externalID is already alive.
	// Most providers no-op (hook deduplication is the normal path).
	// OpenCode logs a warning because, for opencode, this state means a
	// parallel process resumed the same session — an unsupported workflow.
	OnAlreadyRunning(externalID string)
}

Provider abstracts per-tool local behavior. Adding a new provider means implementing this interface and registering the instance.

func Get

func Get(name string) (Provider, error)

Get returns the registered Provider for name. An empty string resolves to Claude Code for backwards compatibility with NormalizeName.

func GetWithDir added in v0.17.1

func GetWithDir(name, dir string) (Provider, error)

GetWithDir returns a provider configured to install into / resolve against a non-default config dir (kata hpec). Only claude-code is wired this ticket; codex/opencode return an error until their fast-follow adds the override. dir == "" is equivalent to Get(name).

type SessionInfo

type SessionInfo struct {
	SessionID        string
	TranscriptPath   string
	ProjectPath      string
	ModTime          time.Time
	SizeBytes        int64
	Summary          string
	FirstUserMessage string
}

SessionInfo is the cross-provider shape returned by Provider.ScanSessions and Provider.FindSessionByID. Concrete provider types may keep richer internal forms (e.g. CodexSessionInfo) and project to SessionInfo at the seams.

type SessionMetadata

type SessionMetadata struct {
	Summary          string
	FirstUserMessage string
	SummaryLinks     []SummaryLink
}

SessionMetadata is the parsed metadata for a transcript file or in-memory chunk. SummaryLinks are Claude-only and stay nil for other providers.

type SummaryLink struct {
	Summary  string
	LeafUUID string
}

SummaryLink describes a parent-session summary link extracted from a Claude transcript chunk. The engine HTTPs them after AnnotateChunk returns; the provider remains side-effect-free.

type TranscriptRegistrar

type TranscriptRegistrar interface {
	SetCodexRollout(*CodexRolloutMetadata)
}

TranscriptRegistrar is the minimal surface InitTranscript sees on the root transcript's TrackedFile. *sync.TrackedFile satisfies it structurally via SetCodexRollout. Lives here (not pkg/sync) to avoid an import cycle.

type WorkflowRegistrar added in v0.16.2

type WorkflowRegistrar interface {
	SubagentsDir() string
	RegisterSidechainFile(path, name, fileType string) bool
}

WorkflowRegistrar is the surface DiscoverWorkflowFiles uses to register Claude workflow subagent transcripts + run journals as path-encoded sidechain files. *sync.FileTracker satisfies it. SubagentsDir exposes the session's <session>/subagents directory (workflow runs live beneath subagents/workflows/<runId>/); RegisterSidechainFile tracks a file by its backend file_name (forward-slash path-encoded) and reports whether it was newly added (vs. an in-place path/type correction of an existing entry).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL