Before implementing, read the Agent Integration Checklist for design principles (full transcript storage, native format preservation) and validation criteria.
The Entire CLI uses inversion of control for agent integration: agents are passive data providers that translate their native hook payloads into normalized lifecycle events, and the framework handles all orchestration (state transitions, file detection, checkpoint saving, metadata generation). The flow is:
Agent hook invocation → ParseHookEvent() → Event → DispatchLifecycleEvent() → framework actions
An agent never calls strategy methods or manages session state directly. It only answers questions: "What event just happened?" and "What does the transcript say?"
Every agent must implement all 19 methods on the Agent interface:
| Group | Method | Purpose |
|---|---|---|
| Identity | Name() |
Registry key (e.g., "claude-code") |
Type() |
Display name for metadata (e.g., "Claude Code") |
|
Description() |
Human-readable description for UI | |
DetectPresence() |
Check if agent is configured in the repo | |
ProtectedDirs() |
Directories to preserve during rewind | |
| Event Mapping | HookNames() |
Hook verbs that become CLI subcommands |
ParseHookEvent() |
Core contribution surface - translate native hooks to Events | |
| Transcript | ReadTranscript() |
Read raw transcript bytes |
ChunkTranscript() |
Split large transcripts at format-aware boundaries | |
ReassembleTranscript() |
Recombine chunks into a single transcript | |
| Session Management | GetHookConfigPath() |
Path to hook config file |
SupportsHooks() |
Whether agent supports lifecycle hooks | |
ParseHookInput() |
Parse hook callback input from stdin | |
GetSessionID() |
Extract session ID from hook input | |
GetSessionDir() |
Where agent stores session data | |
ResolveSessionFile() |
Path to session transcript file | |
ReadSession() |
Read session data from agent's storage | |
WriteSession() |
Write session data for resumption | |
FormatResumeCommand() |
Command to resume a session |
| Interface | Methods | When to implement |
|---|---|---|
HookSupport |
InstallHooks, UninstallHooks, AreHooksInstalled, GetSupportedHooks |
Agent uses a config file for hook registration (e.g., settings.json) |
HookHandler |
GetHookNames |
Required for CLI hook registration — entire hooks <agent> <verb> subcommands are only created for agents implementing this interface. Typically delegates to HookNames(). |
TranscriptAnalyzer |
GetTranscriptPosition, ExtractModifiedFilesFromOffset |
You want richer checkpoints with transcript-derived file lists |
TranscriptPreparer |
PrepareTranscript |
Agent writes transcripts asynchronously and needs a flush/sync step |
TokenCalculator |
CalculateTokenUsage |
Agent's transcript contains token usage data |
SubagentAwareExtractor |
ExtractAllModifiedFiles, CalculateTotalTokenUsage |
Agent spawns subagents (like Claude Code's Task tool) |
HookResponseWriter |
WriteHookResponse |
Agent can display messages from hook responses (e.g., session start banner). Claude Code uses JSON systemMessage on stdout; Factory AI Droid uses plain text on stdout. |
ContextInjector |
InjectionEvent, RenderContextInjection |
Agent can inject text into the model's context window (distinct from HookResponseWriter, which targets the user). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (emitContextInjection) emits it once per normal session via session.State.ContextInjectionDecided, skipping review/investigate sessions, and only when fresh clone-local preferences say trails are enabled for the current repo/API/auth target. The API check happens before the prompt path (entire enable, successful entire trail ... commands, and stale/missing cache refresh on SessionStart all refresh ClonePreferences.TrailsEnabled using api.Client.TrailsEnabled); TurnStart performs no auth/network work and leaves unknown/stale caches undecided so a later refresh can still inject. Claude Code / Codex / Gemini inject at TurnStart using hookSpecificOutput.additionalContext (UserPromptSubmit / BeforeAgent); Pi and OpenCode emit a {"inject_context":...} envelope that their embedded extension applies (Pi via a before_agent_start message, OpenCode via experimental.chat.system.transform). |
FileWatcher |
GetWatchPaths, OnFileChange |
Agent doesn't support hooks; uses file-based detection instead |
Create a new directory under cmd/entire/cli/agent/:
cmd/entire/cli/agent/youragent/
├── youragent.go # Core Agent implementation + init()
├── lifecycle.go # ParseHookEvent + compile-time assertions
├── types.go # Hook input structs, transcript types, tool constants
├── hooks.go # HookSupport implementation (if applicable)
├── transcript.go # TranscriptAnalyzer implementation (if applicable)
├── lifecycle_test.go # Tests for ParseHookEvent
├── hooks_test.go # Tests for hook installation
└── transcript_test.go # Tests for transcript analysis
Define structs matching your agent's native hook JSON payloads:
package youragent
// Settings file structure (for HookSupport)
type YourAgentSettings struct {
Hooks YourAgentHooks `json:"hooks"`
}
type YourAgentHooks struct {
SessionStart []HookMatcher `json:"SessionStart,omitempty"`
SessionEnd []HookMatcher `json:"SessionEnd,omitempty"`
// ... other hook types your agent supports
}
type HookMatcher struct {
Matcher string `json:"matcher,omitempty"`
Hooks []HookEntry `json:"hooks"`
}
type HookEntry struct {
Type string `json:"type"`
Command string `json:"command"`
}
// Hook input structs - match your agent's JSON payloads
type sessionInfoRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
}
type promptInputRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Prompt string `json:"prompt"`
}
// Tool constants - tools in your agent that modify files
const (
ToolWrite = "write_file"
ToolEdit = "edit_file"
)
var FileModificationTools = []string{ToolWrite, ToolEdit}package youragent
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/paths"
)
//nolint:gochecknoinits // Agent self-registration is the intended pattern
func init() {
agent.Register("your-agent", NewYourAgent)
}
type YourAgent struct{}
func NewYourAgent() agent.Agent {
return &YourAgent{}
}
// --- Identity ---
func (a *YourAgent) Name() agent.AgentName { return "your-agent" }
func (a *YourAgent) Type() agent.AgentType { return "Your Agent" }
func (a *YourAgent) Description() string { return "Your Agent - description here" }
func (a *YourAgent) ProtectedDirs() []string { return []string{".youragent"} }
func (a *YourAgent) DetectPresence() (bool, error) {
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot = "."
}
_, err = os.Stat(filepath.Join(repoRoot, ".youragent"))
if err == nil {
return true, nil
}
return false, nil
}
// --- Transcript Storage ---
func (a *YourAgent) ReadTranscript(sessionRef string) ([]byte, error) {
data, err := os.ReadFile(sessionRef)
if err != nil {
return nil, fmt.Errorf("failed to read transcript: %w", err)
}
return data, nil
}
func (a *YourAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) {
// Use JSONL chunking for line-based formats
return agent.ChunkJSONL(content, maxSize)
// Or implement format-specific chunking (see geminicli for JSON example)
}
func (a *YourAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
return agent.ReassembleJSONL(chunks), nil
}
// --- Session Management ---
func (a *YourAgent) GetHookConfigPath() string { return ".youragent/settings.json" }
func (a *YourAgent) SupportsHooks() bool { return true }
func (a *YourAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID }
func (a *YourAgent) FormatResumeCommand(sessionID string) string { return "youragent --resume " + sessionID }
// ParseHookInput is part of the Agent interface and is called by integration tests.
// Provide a real implementation that populates at least SessionID and SessionRef.
func (a *YourAgent) ParseHookInput(hookType agent.HookType, r io.Reader) (*agent.HookInput, error) {
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](r)
if err != nil {
return nil, err
}
return &agent.HookInput{
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
}, nil
}
func (a *YourAgent) GetSessionDir(_ string) (string, error) {
return "", errors.New("not implemented")
}
func (a *YourAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
return filepath.Join(sessionDir, agentSessionID+".jsonl")
}
func (a *YourAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) {
return nil, errors.New("not implemented")
}
func (a *YourAgent) WriteSession(_ *agent.AgentSession) error {
return errors.New("not implemented")
}This is the main contribution surface for new agents. Map each of your agent's native hook names to the normalized EventType:
package youragent
import (
"io"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
)
// Hook name constants - these become CLI subcommands
const (
HookNameSessionStart = "session-start"
HookNameSessionEnd = "session-end"
HookNamePromptSubmit = "prompt-submit"
HookNameResponse = "response"
)
func (a *YourAgent) HookNames() []string {
return []string{
HookNameSessionStart,
HookNameSessionEnd,
HookNamePromptSubmit,
HookNameResponse,
}
}
func (a *YourAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) {
switch hookName {
case HookNameSessionStart:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
case HookNamePromptSubmit:
raw, err := agent.ReadAndParseHookInput[promptInputRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Prompt: raw.Prompt,
Timestamp: time.Now(),
}, nil
case HookNameResponse:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
case HookNameSessionEnd:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
default:
// Unknown hooks have no lifecycle significance
return nil, nil //nolint:nilnil
}
}Key decisions in ParseHookEvent:
- Return
nil, nilfor hooks with no lifecycle significance (pass-through hooks). This is not an error - it tells the framework to do nothing. - Every event should include the fields listed in the Event Field Requirements table.
SessionIDshould always be populated (the framework falls back to"unknown"forTurnEndif missing, but this degrades checkpoint quality).SessionRefis required forTurnStartandTurnEndbut optional forCompactionandSessionEnd. TurnStartshould includePromptif available - it's used for commit message generation.- Use
agent.ReadAndParseHookInput[T]- the generic helper reads stdin and unmarshals JSON in one step. - Set
Timestamptotime.Now()- the framework uses this for ordering.
See the Optional Interface Decision Tree section below.
Registration happens in init() in your main agent file. The import side-effect pattern ensures your agent is available when the CLI starts:
//nolint:gochecknoinits // Agent self-registration is the intended pattern
func init() {
agent.Register("your-agent", NewYourAgent)
}Then add a blank import in the CLI's command setup to ensure the package is loaded:
// In cmd/entire/cli/commands/ or wherever agents are imported
import (
_ "github.com/entireio/cli/cmd/entire/cli/agent/youragent"
)In lifecycle.go (or whichever file implements the optional interfaces), add compile-time checks:
// Compile-time interface assertions
var (
_ agent.TranscriptAnalyzer = (*YourAgent)(nil)
_ agent.TokenCalculator = (*YourAgent)(nil)
// Add one line per optional interface you implement
)Also add assertions for HookSupport and HookHandler in hooks.go if applicable:
var (
_ agent.HookSupport = (*YourAgent)(nil)
_ agent.HookHandler = (*YourAgent)(nil)
)If your agent uses a JSON config file for hooks (like Claude Code's .claude/settings.json, Gemini's .gemini/settings.json, Cursor's .cursor/hooks.json, Factory AI Droid's .factory/settings.json, or Copilot CLI's .github/hooks/entire.json), implement HookSupport:
func (a *YourAgent) InstallHooks(localDev bool, force bool) (int, error) {
// 1. Find repo root
repoRoot, err := paths.RepoRoot()
if err != nil {
return 0, err
}
// 2. Read existing settings (preserve unknown fields)
settingsPath := filepath.Join(repoRoot, ".youragent", "settings.json")
// ... read and parse ...
// 3. Build hook commands
// localDev mode delegates to scripts/entire-dev (agent.LocalDevHookScript),
// which compiles the CLI on demand and falls back to the entire binary on
// PATH when the tree does not build. It uses $(git rev-parse --show-toplevel)
// to resolve the repo root at runtime, so it works regardless of where the
// repo is checked out on disk.
// Note: Only Claude Code provides a PROJECT_DIR env var (CLAUDE_PROJECT_DIR);
// its prefix uses ${CLAUDE_PROJECT_DIR}/scripts/entire-dev instead. Other
// agents should use agent.LocalDevHookScript as shown here.
var cmdPrefix string
if localDev {
cmdPrefix = agent.LocalDevHookScript + " hooks your-agent "
} else {
cmdPrefix = "entire hooks your-agent "
}
// 4. Add hooks if they don't exist (idempotent)
// 5. Write settings back (preserving unknown fields)
return count, nil
}
func (a *YourAgent) UninstallHooks() error { /* reverse of install */ }
func (a *YourAgent) AreHooksInstalled() bool { /* check settings file */ }
func (a *YourAgent) GetSupportedHooks() []agent.HookType { /* list supported types */ }Also implement HookHandler — this is required for the CLI to register entire hooks <agent> <verb> subcommands:
func (a *YourAgent) GetHookNames() []string {
return a.HookNames() // delegate to the core interface method
}Test ParseHookEvent for every hook name your agent supports. See Testing Patterns below.
The framework dispatcher (DispatchLifecycleEvent in lifecycle.go) handles each event type as follows:
| Event Type | Framework Actions | Claude Code Hook | Gemini CLI Hook | Cursor Hook | OpenCode Hook | Factory AI Droid Hook | Copilot CLI Hook |
|---|---|---|---|---|---|---|---|
SessionStart |
Shows banner, checks concurrent sessions, fires state machine transition | session-start |
session-start |
session-start |
session-start |
session-start |
session-start |
TurnStart |
Captures pre-prompt state (git status, transcript position), ensures strategy setup, initializes session | user-prompt-submit |
before-agent |
before-submit-prompt |
turn-start |
user-prompt-submit |
user-prompt-submitted |
TurnEnd |
Validates transcript, extracts metadata (prompts, summary, files), detects file changes via git status, saves step + checkpoint, transitions phase to IDLE | stop |
after-agent |
stop |
turn-end |
stop |
agent-stop |
Compaction |
Fires compaction transition (stays ACTIVE), resets transcript offset | (not used) | pre-compress |
pre-compact |
compaction |
pre-compact |
(not used) |
SessionEnd |
Marks session as ENDED in state machine | session-end |
session-end |
session-end |
session-end |
session-end |
session-end |
SubagentStart |
Captures pre-task state (git status snapshot) | pre-task (PreToolUse[Task]) |
(not used) | subagent-start |
(not used) | pre-tool-use (config-level matcher: Task) |
(not used) |
SubagentEnd |
Extracts subagent modified files, detects changes, saves task checkpoint | post-task (PostToolUse[Task]) |
(not used) | subagent-stop |
(not used) | post-tool-use (config-level matcher: Task) |
subagent-stop |
| Event Type | Required Fields | Optional Fields |
|---|---|---|
SessionStart |
SessionID |
SessionRef, ResponseMessage, Metadata |
TurnStart |
SessionID, SessionRef |
Prompt, PreviousSessionID, Metadata |
TurnEnd |
SessionRef |
SessionID (falls back to "unknown"), Metadata |
Compaction |
SessionID |
SessionRef, Metadata |
SessionEnd |
SessionID |
SessionRef, Metadata |
SubagentStart |
SessionID, SessionRef, ToolUseID |
ToolInput, Metadata |
SubagentEnd |
SessionID, SessionRef, ToolUseID |
SubagentID, ToolInput, Metadata |
Metadata (map[string]string) holds agent-specific state that the framework stores and makes available on subsequent events. Use it for agent-internal tracking (e.g., cursor positions, background agent flags) that doesn't map to a dedicated Event field.
What it enables: Transcript-derived file lists (more accurate than git-status-only), extracted user prompts in checkpoint metadata, session summaries.
Without it: The framework still creates checkpoints using git-status-based file detection and stores the raw transcript. Prompts and summary fields will be empty.
Implement when: Your agent writes a parseable transcript (JSONL, JSON, or any structured format) and you can extract which files were modified and what the user asked.
Methods:
GetTranscriptPosition(path) (int, error)- Return current position. For JSONL: line count. For JSON with messages array: message count.ExtractModifiedFilesFromOffset(path, startOffset) (files, currentPosition, error)- Parse transcript from offset and return files touched by write/edit tools.
What it enables: A pre-read synchronization step before the framework reads the transcript.
Without it: The framework reads the transcript immediately, which may be incomplete if the agent writes asynchronously.
Implement when: Your agent writes transcripts asynchronously (e.g., Claude Code uses an async writer and needs to wait for a flush sentinel before reading).
Method:
PrepareTranscript(sessionRef) error- Wait until the transcript is fully written. Called beforeReadTranscript.
What it enables: Token usage metrics in checkpoint metadata (input, output, cache tokens, API call count).
Without it: Token usage fields are empty in checkpoint metadata.
Implement when: Your agent's transcript contains token/usage data per message.
Method:
CalculateTokenUsage(sessionRef, fromOffset) (*TokenUsage, error)- Sum token usage from offset to end of transcript.
What it enables: Includes files modified by spawned subagents in the checkpoint file list and aggregates subagent token usage.
Without it: Only the main agent's transcript is analyzed. Subagent modifications are still captured by git status but not attributed to the subagent.
Implement when: Your agent spawns subagents (task workers) that have their own transcripts.
Methods:
ExtractAllModifiedFiles(sessionRef, fromOffset, subagentsDir) ([]string, error)- Deduplicated file list from main + subagent transcripts.CalculateTotalTokenUsage(sessionRef, fromOffset, subagentsDir) (*TokenUsage, error)- Aggregated usage including subagents.
What it enables: entire enable automatically installs hooks into the agent's config file.
Without it: Users must manually configure hooks to call entire hooks <agent> <verb>.
Implement when: Your agent supports a config file with hook definitions (e.g., .claude/settings.json, .gemini/settings.json).
What it enables: Displaying messages to the user during hook execution (e.g., the "Entire CLI will link this conversation..." banner on session start).
Without it: No message is shown to the user on session start. The framework silently skips the output.
Implement when: Your agent displays hook stdout to the user. The output format is agent-specific:
- Claude Code: JSON
{"systemMessage":"..."}to stdout (parsed by Claude Code's UI) - Factory AI Droid: Plain text to stdout (displayed directly in terminal)
Methods:
WriteHookResponse(message string) error- Output a message via the agent's hook response protocol.
What it enables: Detecting session activity by watching file changes instead of hooks.
Without it: The agent must support hooks for the framework to receive events.
Implement when: Your agent doesn't support lifecycle hooks but writes session data to predictable file paths.
One JSON object per line. Each line is a transcript entry (user message, assistant message, tool use, etc.):
{"type":"user","message":{"role":"user","content":"Fix the bug"},"timestamp":"..."}
{"type":"assistant","message":{"role":"assistant","content":[...]},"timestamp":"..."}
Chunking: Use agent.ChunkJSONL(content, maxSize) - splits at newline boundaries.
Reassembly: Use agent.ReassembleJSONL(chunks) - concatenates with newlines.
Position: Line count (bufio.Reader + count \n).
Offset: Start parsing at line N (skip first N lines).
Single JSON object with a messages array:
{"messages": [{"type": "user", "content": "..."}, {"type": "gemini", "content": "..."}]}Chunking: Parse the JSON, split the messages array across chunks, marshal each chunk as a complete JSON object with a subset of messages.
Reassembly: Parse each chunk, concatenate all message arrays, marshal back.
Position: Message count (len(transcript.Messages)).
Offset: Start iterating messages at index N.
Single JSON object with info and messages array. Messages contain parts (text, tool calls with state). Similar to Gemini's pattern but with a different schema:
{"info": {"id": "...", "title": "..."}, "messages": [{"info": {"role": "user"}, "parts": [{"type": "text", "text": "..."}]}]}Key difference: OpenCode stores transcripts in a database, not files. The transcript is fetched via opencode export <sessionID> at turn-end and cached to .entire/tmp/<sessionID>.json.
Chunking: Parse JSON, distribute messages across chunks (each chunk is complete JSON with info + subset of messages).
Reassembly: Merge message arrays from all chunks, preserve info from first chunk.
Position: Message count (len(session.Messages)).
Offset: Start iterating messages at index N.
Pi uses JSONL (one JSON object per line) with a tree-shaped entry model. Every entry has id and optional parentId, enabling Pi's /fork and /clone branching. Each entry has type ("message" for conversational entries), timestamp, and (for message entries) a message object with role, content, and optional usage:
{"type":"message","id":"abc123","parentId":"xyz789","timestamp":"...","message":{"role":"user","content":"Fix the bug"}}
{"type":"message","id":"def456","parentId":"abc123","timestamp":"...","message":{"role":"assistant","content":[...],"usage":{"input":100,"output":200,"cacheRead":0,"cacheWrite":50}}}
User content can be a plain string or an array of content blocks (text, toolCall, toolResult). usage carries input, output, cacheRead, and cacheWrite token counts on assistant messages. Assistant messages also carry model (e.g. "gpt-5.5") and provider (e.g. "openai-codex").
Model backfill: Pi's hook events (session_start, before_agent_start, agent_end) carry no model field, so the model never reaches Event.Model and state.ModelName would otherwise stay empty. Pi instead implements the optional agent.ModelExtractor interface (ExtractModel), which reads message.model from the most recent active-branch assistant message. Condensation calls sessionStateBackfillModel to fill state.ModelName when it's empty, so checkpoint metadata records the model just like token usage is backfilled from the transcript.
Active branch resolution: Because Pi sessions form a tree, the transcript accumulates entries from both active and abandoned branches. Entire's pijsonl.ResolveActiveBranch() walks the parentId chain from the last message to the root. Only entries on the active branch contribute to token counts, file lists, extracted prompts, and the extracted model.
Chunking: Use agent.ChunkJSONL(content, maxSize) — splits at newline boundaries.
Reassembly: Use agent.ReassembleJSONL(chunks) — concatenates with newlines.
Position: Line count (via pijsonl.CountLines).
Offset: Start parsing at line N (via pijsonl.SkipLines).
Similar to Claude Code's JSONL format (one JSON object per line), but with a different envelope structure. Factory AI Droid wraps messages as:
{"type":"message","id":"...","message":{"role":"assistant","content":[...]}}
{"type":"message","id":"...","message":{"role":"user","content":"Fix the bug"}}
Unlike Claude Code's {"type":"assistant",...}, Droid uses type: "message" with the role inside the message object. The content field can be either a plain string or an array of content blocks (text, tool_result, etc.). Non-message entries (e.g., session_start) are skipped during parsing.
Chunking: Same as Claude Code - agent.ChunkJSONL(content, maxSize) splits at newline boundaries.
Reassembly: Same as Claude Code - agent.ReassembleJSONL(chunks) concatenates with newlines.
Position: Line count (same as Claude Code).
Offset: Start parsing at line N (skip first N lines).
The agent package provides format-agnostic entry points:
// These dispatch to the agent's ChunkTranscript/ReassembleTranscript methods
agent.ChunkTranscript(content, agentType) // agentType → agent lookup → format-aware chunking
agent.ReassembleTranscript(chunks, agentType) // agentType → agent lookup → format-aware reassembly
// Direct JSONL helpers (usable without an agent)
agent.ChunkJSONL(content, maxSize)
agent.ReassembleJSONL(chunks)
// Chunk file naming
agent.ChunkFileName("full.jsonl", 0) // "full.jsonl"
agent.ChunkFileName("full.jsonl", 1) // "full.jsonl.001"
agent.ParseChunkIndex("full.jsonl.002", "full.jsonl") // 2
agent.SortChunkFiles(files, "full.jsonl") // sorted by chunk indexClaude Code, Gemini CLI, Cursor, Factory AI Droid, and Copilot CLI use a JSON settings file in their config directory. The installation pattern is:
- Read existing settings as
map[string]json.RawMessageto preserve unknown fields - Parse only the hook types you modify into typed slices
- Remove existing Entire hooks (for
forcemode or mode-switching) - Add new hooks idempotently (check if command already exists)
- Marshal modified types back to the raw map
- Write the file with pretty-printing
Key principles:
- Preserve unknown fields - don't destroy user's custom hooks or settings
- Idempotent installs - running
entire enabletwice doesn't duplicate hooks - Support
localDevmode - usego run "$(git rev-parse --show-toplevel)"/...for development (only Claude Code provides aPROJECT_DIRenv var; other agents usegit rev-parseto resolve the repo root at runtime) - Identify Entire hooks by command prefix (e.g.,
"entire "orgo run "$(git rev-parse --show-toplevel)"/...)
{
"hooks": {
"SessionStart": [{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks claude-code session-start"}]}],
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks claude-code stop"}]}],
"PreToolUse": [{"matcher": "Task", "hooks": [{"type": "command", "command": "entire hooks claude-code pre-task"}]}]
}
}{
"hooksConfig": {"enabled": true},
"hooks": {
"SessionStart": [{"hooks": [{"name": "entire-session-start", "type": "command", "command": "entire hooks gemini session-start"}]}],
"AfterAgent": [{"hooks": [{"name": "entire-after-agent", "type": "command", "command": "entire hooks gemini after-agent"}]}]
}
}Note: Gemini CLI requires hooksConfig.enabled: true and each hook entry requires a name field.
{
"version": 1,
"hooks": {
"sessionStart": [{"command": "entire hooks cursor session-start"}],
"beforeSubmitPrompt": [{"command": "entire hooks cursor before-submit-prompt"}],
"stop": [{"command": "entire hooks cursor stop"}],
"sessionEnd": [{"command": "entire hooks cursor session-end"}],
"preCompact": [{"command": "entire hooks cursor pre-compact"}],
"subagentStart": [{"command": "entire hooks cursor subagent-start"}],
"subagentStop": [{"command": "entire hooks cursor subagent-stop"}]
}
}Note: Cursor uses camelCase hook names in .cursor/hooks.json and provides a conversation_id field as the session identifier.
{
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid session-start"}]},
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid user-prompt-submit"}]}
],
"SessionEnd": [
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid session-end"}]}
],
"UserPromptSubmit": [
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid user-prompt-submit"}]}
],
"Stop": [
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid stop"}]}
],
"PreToolUse": [
{"matcher": "Task", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid pre-tool-use"}]}
],
"PostToolUse": [
{"matcher": "Task", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid post-tool-use"}]}
],
"PreCompact": [
{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks factoryai-droid pre-compact"}]}
]
}
}Note: Factory AI Droid uses PascalCase hook names in .factory/settings.json with a nested matcher/hooks structure. The matcher field scopes which tool triggers the hook (empty string = all tools). SessionStart includes a second user-prompt-submit entry to ensure TurnStart fires in droid exec mode where UserPromptSubmit doesn't fire. PreToolUse/PostToolUse use "matcher": "Task" to scope to subagent tracking.
Pi uses a TypeScript extension file (.pi/extensions/entire/index.ts) instead of a JSON config. The extension is embedded in the Go binary via //go:embed and installed by entire enable --agent pi.
Pi extension hooks fire via execFile (non-blocking) to avoid blocking the agent. The extension listens for Pi lifecycle events (session_start, before_agent_start, agent_end, session_shutdown) and dispatches each to entire hooks pi <event> with a JSON payload containing type, cwd, session_file, session_id, and (for before_agent_start) prompt.
Key differences from JSON config agents:
-
Extension file is written/removed entirely (not partial JSON edits)
-
Uses
node:child_process.execFilefor all hook invocations -
Session ID is written to
.entire/tmp/pi/pi-active-sessionatsession_start/before_agent_startand cleared atsession_shutdown. It was meant to bridge races where a later hook arrives without an ID, but nothing reads it any more — see the nesting note below -
On
agent_end, the Pi JSONL transcript is captured to.entire/tmp/pi/<id>.jsonfor stable reference even if native Pi sessions are deleted -
session_shutdownis cleanup-only (noSessionEndevent) to avoid a race withagent_end's checkpoint save -
Idempotency via marker string check:
"Auto-generated by \entire enable --agent pi`"` -
Nested Pi processes do not forward session lifecycle. Pi has no subagent events; subagents come from an extension (Pi's
subagent/example) that spawnspi --mode json -p --no-sessionwith cwd inside the project — where Pi auto-discovers this same extension, so the child would otherwise forward its own lifecycle as the user's session. Two independent guards:- Extension: sets
ENTIRE_PI_NESTEDon its process (inherited by any Pi it spawns) and, when it is already set, registers only thetool_callbash hardening and skips the lifecycle handlers. The hardening stays on purpose — a nested Pi is non-interactive while inheriting the parent's TTY, and Entire's git hooks fire from.git/hooksregardless of this extension. The marker is read only here: Entire's own hook subprocesses inherit it from the parent, so treating it as a skip signal in Go would disable tracking for everyone. - CLI:
ParseHookEventskips any hook whose payload carries no resolvable session ID, rather than resolving it against the per-repo session-ID cache — that single-slot cache is what handed a sessionless child its parent's ID, letting the nested turn overwrite the parent's prompt and turn window.session_shutdownis exempt (it carries no session identity at all and must still clear the cache). Same shape as Copilot CLI's subordinate-session guard.
The guard removed the cache's only two readers, so it is now write-only; removing it outright is a follow-up, tracked with the separate problem that a single-slot per-repo store cannot represent two concurrent Pi sessions in one worktree.
- Extension: sets
See cmd/entire/cli/agent/pi/entire_extension.ts for the full extension source.
OpenCode uses a TypeScript plugin file (.opencode/plugins/entire.ts) instead of a JSON config. The plugin is auto-generated by entire enable --agent opencode and uses OpenCode's Bun-based plugin API.
Key differences from JSON config agents:
- Plugin file is written/removed entirely (not partial JSON edits)
- Uses
Bun.spawnSync()for hooks near process exit (turn-end,session-end) - Async
callHook()for non-critical hooks (session-start,turn-start,compaction) - Calls
opencode export <sessionID>on turn-end to fetch transcript (not file-based) - Idempotency via marker string check:
"Auto-generated by \entire enable --agent opencode`"`
Test every hook name, including pass-through hooks that return nil:
func TestParseHookEvent_TurnStart(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
input := `{"session_id": "sess-123", "transcript_path": "/tmp/t.jsonl", "prompt": "Fix the bug"}`
event, err := ag.ParseHookEvent(HookNamePromptSubmit, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.Type != agent.TurnStart {
t.Errorf("expected TurnStart, got %v", event.Type)
}
if event.Prompt != "Fix the bug" {
t.Errorf("expected prompt 'Fix the bug', got %q", event.Prompt)
}
}Hooks with no lifecycle action must return nil, nil:
func TestParseHookEvent_PassThrough_ReturnsNil(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
event, err := ag.ParseHookEvent("some-pass-through-hook", strings.NewReader(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event != nil {
t.Errorf("expected nil event, got %+v", event)
}
}func TestParseHookEvent_EmptyInput(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
_, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(""))
if err == nil {
t.Fatal("expected error for empty input")
}
if !strings.Contains(err.Error(), "empty hook input") {
t.Errorf("expected 'empty hook input' error, got: %v", err)
}
}
func TestParseHookEvent_MalformedJSON(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
_, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("not json"))
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}func TestInstallHooks_CreatesSettingsFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// ... set up test repo, create .youragent/ directory ...
ag := &YourAgent{}
count, err := ag.InstallHooks(false, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count == 0 {
t.Error("expected hooks to be installed")
}
// Verify settings file was created and contains hooks
data, err := os.ReadFile(filepath.Join(dir, ".youragent", "settings.json"))
// ... assert hooks are present ...
}
func TestInstallHooks_Idempotent(t *testing.T) {
t.Parallel()
// Install twice, second call should return count=0
ag := &YourAgent{}
ag.InstallHooks(false, false)
count, _ := ag.InstallHooks(false, false)
if count != 0 {
t.Errorf("expected 0 new hooks on second install, got %d", count)
}
}- Claude Code lifecycle tests:
cmd/entire/cli/agent/claudecode/lifecycle_test.go - Claude Code hooks tests:
cmd/entire/cli/agent/claudecode/hooks_test.go - Claude Code transcript tests:
cmd/entire/cli/agent/claudecode/transcript_test.go - Gemini CLI lifecycle tests:
cmd/entire/cli/agent/geminicli/lifecycle_test.go - Gemini CLI hooks tests:
cmd/entire/cli/agent/geminicli/hooks_test.go - Gemini CLI transcript tests:
cmd/entire/cli/agent/geminicli/transcript_test.go - Cursor IDE & CLI lifecycle tests:
cmd/entire/cli/agent/cursor/lifecycle_test.go - Cursor IDE & CLI hooks tests:
cmd/entire/cli/agent/cursor/hooks_test.go - Cursor IDE & CLI session tests:
cmd/entire/cli/agent/cursor/cursor_test.go - OpenCode lifecycle tests:
cmd/entire/cli/agent/opencode/lifecycle_test.go - OpenCode hooks tests:
cmd/entire/cli/agent/opencode/hooks_test.go - OpenCode transcript tests:
cmd/entire/cli/agent/opencode/transcript_test.go - Factory AI Droid lifecycle tests:
cmd/entire/cli/agent/factoryaidroid/lifecycle_test.go - Factory AI Droid hooks tests:
cmd/entire/cli/agent/factoryaidroid/hooks_test.go - Factory AI Droid transcript tests:
cmd/entire/cli/agent/factoryaidroid/transcript_test.go
Do NOT use go-git v5 for checkout or reset --hard operations. go-git v5 has a bug where worktree.Reset() with HardReset and worktree.Checkout() incorrectly delete untracked directories even when listed in .gitignore. This would destroy .entire/ and agent config directories. Use the git CLI instead. See CLAUDE.md for details.
Git commands return paths relative to the repository root, not the current working directory. When your code runs from a subdirectory, os.Getwd() gives wrong results for path construction. Always use paths.RepoRoot():
// WRONG
cwd, _ := os.Getwd()
absPath := filepath.Join(cwd, file) // Breaks from subdirectory
// CORRECT
repoRoot, _ := paths.RepoRoot()
absPath := filepath.Join(repoRoot, file)Some agents write transcripts asynchronously. If ReadTranscript is called before the write completes, the transcript will be incomplete. Implement TranscriptPreparer if your agent has this behavior. Claude Code solves this by writing a sentinel entry and polling for it (see waitForTranscriptFlush in claudecode/lifecycle.go).
ParseHookEvent returning (nil, nil) is not an error - it means the hook has no lifecycle significance. The framework (in hook_registry.go) checks:
event, parseErr := ag.ParseHookEvent(hookName, stdin)
if event != nil {
hookErr = DispatchLifecycleEvent(ag, event)
}
// nil event → no-op (hook is silently acknowledged)Use //nolint:nilnil to suppress the linter warning on intentional nil returns.
AgentNameis the registry key used in code ("claude-code","gemini","opencode","cursor","factoryai-droid","copilot-cli"). It appears in CLI commands:entire hooks cursor stop.AgentTypeis the display name stored in metadata and commit trailers ("Claude Code","Gemini CLI","OpenCode","Cursor","Factory AI Droid","Copilot CLI"). It's what users see.
Register constants for both in cmd/entire/cli/agent/registry.go when adding a new agent.
The strings returned by HookNames() become literal CLI subcommands under entire hooks <agent>. For example, if HookNames() returns ["session-start", "stop"], the CLI creates:
entire hooks your-agent session-startentire hooks your-agent stop
These commands read JSON from stdin and dispatch to ParseHookEvent. The agent's hook config should invoke these commands.
A minimal but functional agent skeleton. Copy this directory structure and fill in agent-specific details:
youragent/types.go
package youragent
// sessionInfoRaw matches your agent's session hook JSON payload.
type sessionInfoRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
}
// promptInputRaw matches your agent's prompt-submit hook JSON payload.
type promptInputRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Prompt string `json:"prompt"`
}youragent/youragent.go
package youragent
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/paths"
)
//nolint:gochecknoinits // Agent self-registration is the intended pattern
func init() {
agent.Register("your-agent", NewYourAgent)
}
type YourAgent struct{}
func NewYourAgent() agent.Agent { return &YourAgent{} }
func (a *YourAgent) Name() agent.AgentName { return "your-agent" }
func (a *YourAgent) Type() agent.AgentType { return "Your Agent" }
func (a *YourAgent) Description() string { return "Your Agent - brief description" }
func (a *YourAgent) ProtectedDirs() []string { return []string{".youragent"} }
func (a *YourAgent) DetectPresence() (bool, error) {
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot = "."
}
if _, err := os.Stat(filepath.Join(repoRoot, ".youragent")); err == nil {
return true, nil
}
return false, nil
}
func (a *YourAgent) ReadTranscript(sessionRef string) ([]byte, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // Path from agent hook
if err != nil {
return nil, fmt.Errorf("failed to read transcript: %w", err)
}
return data, nil
}
func (a *YourAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) {
return agent.ChunkJSONL(content, maxSize)
}
func (a *YourAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
return agent.ReassembleJSONL(chunks), nil
}
// --- Session Management ---
func (a *YourAgent) GetHookConfigPath() string { return "" }
func (a *YourAgent) SupportsHooks() bool { return true }
func (a *YourAgent) ParseHookInput(_ agent.HookType, r io.Reader) (*agent.HookInput, error) {
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](r)
if err != nil {
return nil, err
}
return &agent.HookInput{SessionID: raw.SessionID, SessionRef: raw.TranscriptPath}, nil
}
func (a *YourAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID }
func (a *YourAgent) GetSessionDir(_ string) (string, error) { return "", errors.New("not implemented") }
func (a *YourAgent) ResolveSessionFile(dir, id string) string { return filepath.Join(dir, id+".jsonl") }
func (a *YourAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) { return nil, errors.New("not implemented") }
func (a *YourAgent) WriteSession(_ *agent.AgentSession) error { return errors.New("not implemented") }
func (a *YourAgent) FormatResumeCommand(id string) string { return "youragent --resume " + id }youragent/lifecycle.go
package youragent
import (
"io"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
)
const (
HookNameSessionStart = "session-start"
HookNameSessionEnd = "session-end"
HookNamePromptSubmit = "prompt-submit"
HookNameResponse = "response"
)
func (a *YourAgent) HookNames() []string {
return []string{
HookNameSessionStart,
HookNameSessionEnd,
HookNamePromptSubmit,
HookNameResponse,
}
}
func (a *YourAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) {
switch hookName {
case HookNameSessionStart:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
case HookNamePromptSubmit:
raw, err := agent.ReadAndParseHookInput[promptInputRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Prompt: raw.Prompt,
Timestamp: time.Now(),
}, nil
case HookNameResponse:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
case HookNameSessionEnd:
raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin)
if err != nil {
return nil, err
}
return &agent.Event{
Type: agent.SessionEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Timestamp: time.Now(),
}, nil
default:
return nil, nil //nolint:nilnil // nil event = no lifecycle action
}
}youragent/lifecycle_test.go
package youragent
import (
"strings"
"testing"
"github.com/entireio/cli/cmd/entire/cli/agent"
)
func TestParseHookEvent_SessionStart(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
input := `{"session_id": "test-session", "transcript_path": "/tmp/transcript.jsonl"}`
event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event == nil {
t.Fatal("expected event, got nil")
}
if event.Type != agent.SessionStart {
t.Errorf("expected SessionStart, got %v", event.Type)
}
if event.SessionID != "test-session" {
t.Errorf("expected session_id 'test-session', got %q", event.SessionID)
}
}
func TestParseHookEvent_TurnStart(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
input := `{"session_id": "sess-1", "transcript_path": "/tmp/t.jsonl", "prompt": "Hello"}`
event, err := ag.ParseHookEvent(HookNamePromptSubmit, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.Type != agent.TurnStart {
t.Errorf("expected TurnStart, got %v", event.Type)
}
if event.Prompt != "Hello" {
t.Errorf("expected prompt 'Hello', got %q", event.Prompt)
}
}
func TestParseHookEvent_TurnEnd(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
input := `{"session_id": "sess-2", "transcript_path": "/tmp/t.jsonl"}`
event, err := ag.ParseHookEvent(HookNameResponse, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.Type != agent.TurnEnd {
t.Errorf("expected TurnEnd, got %v", event.Type)
}
}
func TestParseHookEvent_SessionEnd(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
input := `{"session_id": "sess-3", "transcript_path": "/tmp/t.jsonl"}`
event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.Type != agent.SessionEnd {
t.Errorf("expected SessionEnd, got %v", event.Type)
}
}
func TestParseHookEvent_UnknownHook(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
event, err := ag.ParseHookEvent("unknown", strings.NewReader(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event != nil {
t.Errorf("expected nil event, got %+v", event)
}
}
func TestParseHookEvent_EmptyInput(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
_, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(""))
if err == nil {
t.Fatal("expected error for empty input")
}
}
func TestParseHookEvent_MalformedJSON(t *testing.T) {
t.Parallel()
ag := &YourAgent{}
_, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("not json"))
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}