provider

package
v0.17.3 Latest Latest
Warning

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

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

README

pkg/provider

Provider-specific local behavior for Confab integrations. Current providers: Claude Code, Codex, OpenCode, and Cursor. 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). Cursor was added incrementally (kata tickets T1–T7): T2 ships the core provider (registration, types, path derivation, process matching); T3 adds transcript metadata extraction, AnnotateChunk, and offline scan (ScanSessions/FindSessionByID); T4 wires hook install/uninstall (sessionStart + sessionEnd in ~/.cursor/hooks.json, via pkg/hookconfig/cursor.go) and the --provider cursor cmd surface; T5–T6 add daemon wiring and subagent capture; T7 (r5mg) surfaces Cursor in auto-detection (provider.DetectInstalled), so bare confab setup wires it.

The package defines a Provider interface and a HookInput interface (Phase 1 + 2 of the abstraction work — see CF-394). All four concrete provider types (ClaudeCode, Codex, Opencode, Cursor) 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, RootTranscriptProvider, ChunkView), SummaryLink / AnnotationResult types, provider name constants (NameClaudeCode, NameCodex, NameOpencode, NameCursor), 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, errors for codex/opencode/cursor), 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, opencodeHookInputAdapter, and cursorHookInputAdapter — 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). The Cursor adapter's CWD() returns WorkspaceRoots[0] (Cursor has no separate cwd field).
cursor.go Cursor — paths (~/.cursor, env override CONFAB_CURSOR_DIR; ProjectsDir is <state>/projects), CursorHookInput parsing, and the Provider methods (T2 core). ParseSessionHook DERIVES the transcript path at sessionStart (it is null in the payload) via deriveTranscriptPath<projects>/<sanitize(workspace_roots[0])>/agent-transcripts/<id>/<id>.jsonl, where sanitizeWorkspaceRoot maps runs of non-alphanumerics to single hyphens (verified kata 6kys). WriteHookResponse writes {} (fire-and-forget; no context injection). MatchesProcess (regex cursor-agent|Cursor\.app|Cursor Helper) matches both the cursor-agent CLI and the Cursor desktop IDE without false-matching lowercase ~/.cursor/ paths. SupportsCommitLinking is true (65aq): bidirectional GitHub commit/PR linking via preToolUse (updated_input rewrite to inject the Confab-Link trailer / PR-body line) + postToolUse (link the resulting commit SHA / PR URL back to the session); handlers live in cmd/hook_tooluse_cursor.go. WalkUpToRoot/ShouldSpawnForInput are identity/always-true (subagents fire dedicated subagentStart/Stop, never sessionStart). InstallHooks/UninstallHooks/IsHooksInstalled (T4) delegate to pkg/hookconfig (InstallCursorHooks/UninstallCursorHooks/IsCursorHooksInstalled on <state>/hooks.json), installing sessionStart + sessionEnd + preToolUse + postToolUse (the tool-use events carry matcher Shell; 65aq); InstallSkills installs /retro under ~/.cursor/skills/ (generic template). DiscoverWorkflowFiles is a no-op (no Cursor Workflow-tool equivalent); DiscoverDescendants (T6, in cursor_subagents.go) captures subagent sidechains. Transcript work (T3, kata kk5t): ReadHookInput is the non-strict reader used on the spawn path; ReadSessionHookInput additionally requires + validates transcript_path (ValidateTranscriptPath: absolute, no .., under <projects>), mirroring claude.go. ExtractMetadata/extractCursorMetadata parse the first role=="user" line's first text part, stripping the <user_query>…</user_query> wrapper (stripCursorUserQuery) and truncating to types.MaxMetadataFieldLength/2 via TruncateUTF8; Summary stays empty and SummaryLinks nil (Cursor has neither). AnnotateChunk (spm9) sets, on every transcript chunk: first_user_message (redacted, listability), latest_message_at from the transcript file's mtime normalized to .UTC() (Cursor JSONL has no per-line timestamp, so the backend feeds session.last_message_at solely from this; os.Stat().ModTime() is Local-zoned and the backend trusts providers to send UTC, so without .UTC() web-list recency is off by the host tz offset — kata 1zjr), and summary from the CLI meta.json title when present (metaJSONTitle globs <state>/chats/*/<id>/meta.json for the optional title; CLI-only — absent for IDE sessions, which keep first_user_message alone). All best-effort: a missing file or meta.json never errors the chunk. The model is set engine-side from daemon config (sourced from the sessionStart hook via cursorHookInputAdapter.Model()), not here. ScanSessions/FindSessionByID walk <projects>/*/agent-transcripts/*/<id>.jsonl — a session is the file whose basename equals its parent dir name, which excludes subagent files under subagents/ (parseCursorSessionFromPath); this enables offline confab save <id> (Cursor writes real files). Modeled on claude.go + claude_discovery.go.
cursor_subagents.go Cursor.DiscoverDescendants (T6) — scans filepath.Dir(rootTranscript)/subagents/ each SyncAll cycle and registers every *.jsonl there as a file_type=agent sidechain with backend file_name = subagents/<id>.jsonl (forward slashes). Ungated — the backend accepts file_type=agent universally, so no capability probe (unlike Claude's workflow files). Type-asserts the registrar to WorkflowRegistrar (for RegisterSidechainFile) and RootTranscriptProvider (for the root path); deliberately does NOT use WorkflowRegistrar.SubagentsDir(), which is computed for Claude's nested <session-id>/subagents layout. Idempotent (RegisterSidechainFile returns false for already-tracked files).
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 are supported offline (kata t6d5) via the SQLite DB: ScanSessions maps OpenCodeDBReader.ListRootSessions rows → SessionInfo (TITLE from FirstUserMessageText; no summary; ModTime from time_created); FindSessionByID prefix-matches via MatchSessionIDs, resolves up to the root via ResolveOpencodeRoot, and materializes the root transcript on demand (MaterializeOpenCodeSession~/.confab/opencode/<root>/messages.jsonl) — descendants are captured by the save path's offline registrar (cmd/save_opencode.go). 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). All public methods share a private openRO() so the read-only DSN flags stay in lockstep, and the (message, part) join loop is the shared scanEnvelopes helper: (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. (4) Offline discovery (kata t6d5): ListRootSessions(ctx) returns []OpenCodeRootSession (id/directory/time_created) for roots (parent_id IS NULL), newest first; FirstUserMessageText(ctx, sessionID) does a bounded (firstUserMessageScanLimit) leading read for the list TITLE, reusing scanEnvelopes + ocFirstUserMessageText; MatchSessionIDs(ctx, prefix) prefix-matches session ids (LIKE with escaped metacharacters via likePrefix); ResolveOpencodeRoot(ctx, sessionID) walks parent_id up to the root (bounded by opencodeRootWalkLimit). 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. MaterializeOpenCodeSession(ctx, source, sessionID, outputPath, interval) (kata t6d5) is the exported one-shot wrapper — seed + a single reconcile, no ticker — used by the offline save/FindSessionByID path so offline and live materialization share completeness gating.
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, NameCursor).
  • CLIBinaryName() string — OS-level binary name used by DetectInstalled / confab status ("claude" for Claude Code, "codex" for Codex, "opencode" for OpenCode, "cursor-agent" for Cursor). 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. Cursor (T6) type-asserts reg to WorkflowRegistrar + RootTranscriptProvider, scans filepath.Dir(rootTranscript)/subagents/, and registers each *.jsonl as an ungated file_type=agent sidechain. 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 (SetSummary, SetFirstUserMessage, SetCodexRolloutMetadata, SetLatestMessageAt); c.FilePath() exposes the source file's path (Cursor stats its mtime and derives the session id for the meta.json title lookup). 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; Cursor (spm9) additionally sets latest_message_at (file mtime) and summary (CLI meta.json title).
  • 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 and Cursor return only FirstUserMessage (Cursor strips the <user_query> wrapper from the first role=="user" line).
  • 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; an empty name is an explicit ErrNoProvider rather than a silent claude-code fallback (kata frm7). 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, then cursor) 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"
	NameCursor     = "cursor"
)
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 CursorStateDirEnv = "CONFAB_CURSOR_DIR"

CursorStateDirEnv overrides the default Cursor state directory (~/.cursor) for tests and non-standard installs.

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 ErrNoProvider = errors.New("no provider specified")

ErrNoProvider is returned by Get/NormalizeName when the provider name is empty. The empty name used to silently alias to claude-code; that fallback was a correctness hazard now that multiple providers exist (kata frm7), so callers must pass a concrete provider name (or route through DetectInstalled).

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 MaterializeOpenCodeSession added in v0.17.3

func MaterializeOpenCodeSession(ctx context.Context, source ocSource, sessionID, outputPath string, interval time.Duration) (int, error)

Materialize runs a single seed + reconcile pass for one OpenCode session, writing every currently-complete message to outputPath, and returns the number of lines appended on this pass. It is the offline counterpart to Run (no ticker, no parent process): `confab save --provider opencode` calls it to produce the materialized JSONL the file-based sync engine then uploads.

Reuses the collector's reconcile — same completeness gating, ULID ordering, stop-at-first-incomplete, and idempotent re-seed from any existing file — so offline and live materialization can never diverge. interval is only used as the warn cadence floor; pass 0 for the default.

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. An empty name returns ErrNoProvider via Get (no implicit claude-code fallback; kata frm7).

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
	// FilePath is the absolute path of the source file this chunk was read
	// from. Cursor uses it to stat the transcript mtime (latest_message_at)
	// and to derive the session id for the CLI meta.json title lookup.
	FilePath() string
	FileCodexRollout() *CodexRolloutMetadata
	SetCodexRolloutMetadata(*CodexRolloutMetadata)
	SetSummary(string)
	SetFirstUserMessage(string)
	// SetLatestMessageAt records an explicit session timestamp on the chunk
	// metadata (Cursor only — its JSONL lines have no per-line timestamp).
	SetLatestMessageAt(time.Time)
}

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 Cursor added in v0.17.3

type Cursor struct{}

Cursor contains Cursor-specific local behavior (cursor-agent CLI + Cursor desktop IDE). This T2 scope covers the core provider: registration, types, path derivation, and process matching. Hook installation, daemon wiring, transcript parsing, and subagent capture are fleshed out in T3–T6.

func (Cursor) AnnotateChunk added in v0.17.3

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

AnnotateChunk annotates transcript chunks with the session metadata the backend needs for Cursor (spm9). On every transcript chunk it sets:

  • first_user_message — so the session is listable (the backend hides sessions with neither a summary nor a first_user_message; kata kk5t).
  • latest_message_at — from the transcript file's mtime. Cursor JSONL lines carry NO per-line timestamp, so the backend opts Cursor out of per-line extraction and feeds session.last_message_at SOLELY from this field. The mtime is universal across the CLI and IDE.
  • summary — from the CLI meta.json title when present (CLI-only; absent for IDE sessions, which keep first_user_message alone). Best-effort.

Non-transcript (agent sidechain) chunks are a no-op. IncludedFirstUserMessage stays false so the engine's sentFirstUserMessage flag is untouched (same as Claude). The model is set engine-side from the daemon config (sourced from the sessionStart hook), not here. Every read here is best-effort: a missing file or meta.json never errors the chunk.

func (Cursor) CLIBinaryName added in v0.17.3

func (Cursor) CLIBinaryName() string

CLIBinaryName returns "cursor-agent" — the CLI binary users install.

func (Cursor) DefaultCWD added in v0.17.3

func (Cursor) DefaultCWD(transcriptPath string) string

DefaultCWD returns filepath.Dir(transcriptPath). Cursor's transcript lives at .../agent-transcripts/<id>/<id>.jsonl, so this is the per-session directory.

func (Cursor) DiscoverDescendants added in v0.17.3

func (Cursor) DiscoverDescendants(reg DescendantRegistrar, _ string) error

DiscoverDescendants scans <root-dir>/subagents/ each SyncAll cycle and registers every *.jsonl file there as a file_type=agent sidechain. Idempotent: RegisterSidechainFile returns false for an already-tracked file, so re-scanning every cycle costs only a directory read. A no-op when the registrar lacks the sidechain surface, when the root transcript path is unavailable, or when the subagents directory does not exist (the common case).

func (Cursor) DiscoverWorkflowFiles added in v0.17.3

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

DiscoverWorkflowFiles is a no-op: Cursor has no Workflow-tool equivalent.

func (Cursor) ExtractMetadata added in v0.17.3

func (Cursor) ExtractMetadata(lines []string) SessionMetadata

ExtractMetadata parses the first user message from in-memory Cursor transcript lines. Cursor's JSONL message lines are {role, message:{content}} with NO top-level "type" field; status lines are {type:"turn_ended", …} and carry no role (kata 6kys §7). FirstUserMessage is the first role=="user" line's first text part, with the <user_query>…</user_query> wrapper stripped. Summary stays empty (Cursor has no inline summary) and SummaryLinks stays nil (Cursor has none). Lines beyond maxLinesForExtraction are ignored.

func (Cursor) FindParentPID added in v0.17.3

func (p Cursor) FindParentPID() int

FindParentPID walks up the process tree to find the Cursor process (CLI or IDE), mirroring ClaudeCode.FindParentPID. The IDE app process can be the grandparent of the hook (the hook is spawned by a "Cursor Helper (Plugin)" child of /Applications/Cursor.app), so the parent+grandparent walk suffices.

func (Cursor) FindSessionByID added in v0.17.3

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

FindSessionByID resolves a full or partial Cursor session ID to its full ID and transcript path (prefix match, mirroring the other providers). Walk-up is identity for Cursor (subagents fire their own hooks). Returns an error on no-match or ambiguous prefix.

func (Cursor) InitTranscript added in v0.17.3

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

InitTranscript is a no-op for Cursor — no root rollout metadata to attach.

func (Cursor) InstallHooks added in v0.17.3

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

InstallHooks installs Confab's sessionStart (daemon spawn), sessionEnd (signal shutdown), and preToolUse/postToolUse (GitHub commit/PR linking; 65aq) hooks into ~/.cursor/hooks.json, preserving user hooks. Returns the written hooks.json path.

func (Cursor) InstallSkills added in v0.17.3

func (p Cursor) InstallSkills() error

InstallSkills installs confab's bundled skills (/retro) into ~/.cursor/skills/. Cursor auto-loads global skills from <stateDir>/skills/ using the generic SKILL.md template (kata 6kys addendum 2), so the existing reconcile path works unchanged.

func (Cursor) IsHooksInstalled added in v0.17.3

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

IsHooksInstalled reports true only when both managed Cursor hook events (sessionStart + sessionEnd) carry a confab command.

func (Cursor) IsProcess added in v0.17.3

func (p Cursor) IsProcess(pid int) bool

IsProcess reports whether pid is a Cursor process (CLI or IDE).

func (Cursor) IsSkillInstalled added in v0.17.3

func (p Cursor) IsSkillInstalled(name string) bool

IsSkillInstalled reports whether a shipped Cursor skill exists.

func (Cursor) MatchesProcess added in v0.17.3

func (Cursor) MatchesProcess(cmd string) bool

MatchesProcess reports whether a command string matches a Cursor process.

func (Cursor) Name added in v0.17.3

func (Cursor) Name() string

Name returns the canonical Cursor provider name.

func (Cursor) OnAlreadyRunning added in v0.17.3

func (Cursor) OnAlreadyRunning(string)

OnAlreadyRunning is a no-op for Cursor: subagents never fire sessionStart, so an already-running hit is ordinary hook dedup, not an error.

func (Cursor) ParseSessionHook added in v0.17.3

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

ParseSessionHook reads a Cursor sessionStart-style hook payload and returns the provider-agnostic view. Because transcript_path is null at sessionStart, it derives the path from workspace_roots[0] + session_id so the standard launch path (which reads HookInput.TranscriptPath()) works unchanged. A payload that already carries an explicit transcript_path (e.g. sessionEnd) keeps it.

func (Cursor) ProjectsDir added in v0.17.3

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

ProjectsDir returns <stateDir>/projects, the root under which Cursor stores per-workspace transcripts.

func (Cursor) ReadHookInput added in v0.17.3

func (Cursor) ReadHookInput(r io.Reader) (*types.CursorHookInput, error)

ReadHookInput reads and validates raw Cursor hook JSON: session_id required and safe; transcript_path NOT required (null at sessionStart, derived by ParseSessionHook). This is the non-strict reader used on the spawn path.

func (Cursor) ReadSessionHookInput added in v0.17.3

func (p Cursor) ReadSessionHookInput(r io.Reader) (*types.CursorHookInput, error)

ReadSessionHookInput reads Cursor session hook JSON and additionally requires + validates transcript_path, mirroring ClaudeCode.ReadSessionHookInput. Used where a populated transcript_path is required (e.g. sessionEnd / offline flows); the spawn path uses ReadHookInput + path derivation instead.

func (Cursor) ScanSessions added in v0.17.3

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

ScanSessions walks <stateDir>/projects/*/agent-transcripts/*/<id>.jsonl and returns all user sessions sorted oldest first. Subagent sidechain files (nested under .../subagents/) are excluded — only the top-level transcript whose basename equals its parent directory name is a session. Cursor writes real transcript files, so offline `confab save <id>` is supported (unlike OpenCode, which has no on-disk file). Permission errors per path are reported to stderr and do not fail the scan.

func (Cursor) ShouldSpawnForInput added in v0.17.3

func (Cursor) ShouldSpawnForInput(HookInput) bool

ShouldSpawnForInput is unconditional for Cursor: subagents never fire sessionStart, so there is no duplicate-daemon risk to suppress (kata 6kys).

func (Cursor) StateDir added in v0.17.3

func (Cursor) StateDir() (string, error)

StateDir returns the Cursor config/install directory. Precedence: CONFAB_CURSOR_DIR > the default ~/.cursor.

func (Cursor) SupportsCommitLinking added in v0.17.3

func (Cursor) SupportsCommitLinking() bool

SupportsCommitLinking reports true: Cursor wires bidirectional GitHub commit/PR linking (65aq) via preToolUse (updated_input rewrite to inject the Confab-Link trailer / PR-body line) and postToolUse (link the resulting commit SHA / PR URL back to the session).

func (Cursor) UninstallHooks added in v0.17.3

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

UninstallHooks removes Confab's managed hook entries from ~/.cursor/hooks.json, preserving any user-authored hooks.

func (Cursor) UninstallSkills added in v0.17.3

func (p Cursor) UninstallSkills() error

UninstallSkills removes confab's bundled skills from ~/.cursor/skills/.

func (Cursor) ValidateTranscriptPath added in v0.17.3

func (p Cursor) ValidateTranscriptPath(path string) error

ValidateTranscriptPath checks that a Cursor transcript path is safe, mirroring ClaudeCode.ValidateTranscriptPath: absolute, no ".." components, and under the Cursor projects directory.

func (Cursor) WalkUpToRoot added in v0.17.3

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

WalkUpToRoot is the identity walk for Cursor: subagents fire their own dedicated subagentStart/Stop hooks (never sessionStart), so the firing session is always its own root and rootPath is "".

func (Cursor) WriteHookResponse added in v0.17.3

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

WriteHookResponse writes an empty JSON object ({}) and relies on exit 0. Cursor's sessionStart response schema is {env?, additional_context?} and is fire-and-forget; confab injects no context, so the Claude/Codex response shape (and systemMessage) is intentionally NOT emitted. suppressOutput and systemMessage are ignored.

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) FirstUserMessageText added in v0.17.3

func (r *OpenCodeDBReader) FirstUserMessageText(ctx context.Context, sessionID string) (string, error)

FirstUserMessageText returns the first user message's first text part for a session, used to populate the list TITLE column (OpenCode has no summary). Bounded: it reads only the small leading slice of the session needed to find the first user text, via a single LEFT JOIN over the user message with the lowest message id. Returns ("", nil) when there is no usable user text — the list TITLE is then blank, not an error.

firstUserMessageScanLimit caps how many leading messages are scanned so the secondary per-session read stays cheap even for long sessions.

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) ListRootSessions added in v0.17.3

func (r *OpenCodeDBReader) ListRootSessions(ctx context.Context) ([]OpenCodeRootSession, error)

ListRootSessions enumerates root sessions (parent_id IS NULL), newest first, for offline `confab list --provider opencode`. Children are excluded, mirroring the daemon's root-only spawn rule. Returns a clear error when the DB is missing/unreadable so the list command can report it (manual commands surface DB errors rather than the daemon's Warn-and-continue).

func (*OpenCodeDBReader) MatchSessionIDs added in v0.17.3

func (r *OpenCodeDBReader) MatchSessionIDs(ctx context.Context, partialID string) ([]string, error)

MatchSessionIDs returns every session id (root or descendant) whose id has partialID as a prefix, ordered for determinism. Used by FindSessionByID to resolve a partial id the way the file-based providers do. An empty partialID matches everything (the caller then reports ambiguity unless there is exactly one session).

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).

func (*OpenCodeDBReader) ResolveOpencodeRoot added in v0.17.3

func (r *OpenCodeDBReader) ResolveOpencodeRoot(ctx context.Context, sessionID string) (string, error)

ResolveOpencodeRoot walks session.parent_id up from sessionID to the topmost root (parent_id IS NULL) and returns its id. A session that is already a root returns itself. Bounded by opencodeRootWalkLimit as a cycle defense. Used by FindSessionByID so passing any descendant id resolves to the user-facing root (consistent with the root+descendants save scope).

type OpenCodeRootSession added in v0.17.3

type OpenCodeRootSession struct {
	ID          string
	Directory   string
	TimeCreated int64 // unix epoch seconds (session.time_created)
}

OpenCodeRootSession is one row returned by ListRootSessions: the minimal columns offline session discovery (ScanSessions) needs to build a SessionInfo. FirstUserMessage is fetched separately (a bounded secondary read) because it lives in the message/part tables, not the session row.

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(partialID string) (string, string, error)

FindSessionByID resolves a full or partial OpenCode session id to its full ROOT id, materializes the root's transcript to ~/.confab/opencode/<root>/messages.jsonl, and returns (rootID, path). A descendant id resolves up to its root (consistent with the root+descendants save scope); descendants themselves are materialized + registered later by the save path (saveOpencodeDescendants). Prefix match mirrors the other providers: a unique prefix is required (ambiguous → error). Honors CONFAB_OPENCODE_DB; a missing/unreadable DB returns a clear error.

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 enumerates OpenCode root sessions from the local SQLite DB for `confab list --provider opencode` (t6d5). Children are excluded (parent_id IS NULL), mirroring the daemon's root-only rule. Each row maps to a SessionInfo whose TITLE source is the first user message (OpenCode has no summary), read per-session via a bounded secondary query. The DB is opened read-only and honors CONFAB_OPENCODE_DB; a missing/unreadable DB returns a clear error.

TranscriptPath is left empty here: OpenCode has no on-disk transcript, so the path is produced lazily by FindSessionByID (which materializes on demand).

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 name is an explicit error (ErrNoProvider) rather than a silent claude-code fallback (kata frm7).

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 RootTranscriptProvider added in v0.17.3

type RootTranscriptProvider interface {
	RootTranscriptPath() string
}

RootTranscriptProvider exposes the session's root transcript path. Providers whose subagent files do NOT sit at SubagentsDir() (Cursor keeps them beside the transcript at filepath.Dir(transcript)/subagents, not under the Claude-shaped <session-id>/subagents) derive their subagents directory from this path. *sync.FileTracker satisfies it via RootTranscriptPath. The registrar passed to DiscoverDescendants is the *FileTracker, so Cursor type-asserts to this (plus WorkflowRegistrar) to reach what it needs.

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