feat(agents): inject trail context into the model when trails are enabled · Entire
feat(agents): inject trail context into the model when trails are enabled
a96118d→main· dipree·1mo ago·21 files·+741 added/-9 removed
Add a ContextInjector capability so Entire teaches an agent about the entire trail command by injecting a one-line note into the model's context window (distinct from HookResponseWriter, which targets the user).
The dispatcher emits it once per session via strategy.ClaimContextInjection, claiming first so the trails-enabled probe runs at most once per session, and only when trails are literally enabled for the repo on the API (api.Client.TrailsEnabled probes the trails endpoint; 2xx => enabled).
Per-transport injection at TurnStart:
- Claude Code / Codex / Gemini: hookSpecificOutput.additionalContext (UserPromptSubmit / BeforeAgent), via RenderAdditionalContextHookOutput
- Pi: {"inject_context":...} envelope -> before_agent_start hidden message
- OpenCode: envelope captured from turn-start stdout -> appended to the system prompt via experimental.chat.system.transform
Injected string:
"A trail ties together the context for a branch. Use entire trail to view, create, update, or watch it."
Sessions
300c2bf2384fView transcript
?\ Inject Trail Context into Agent ModelPi·Opus 4.8·11 steps
Changes
21
cmd/entire/cli
agent
claudecode
Mlifecycle.go+15
Mlifecycle_test.go+27
codex
Mlifecycle.go+16
Mlifecycle_test.go+11
geminicli
Mlifecycle.go+18
Mlifecycle_test.go+11
Ainject.go+79
Ainject_test.go+74
opencode
Mentire_plugin.ts+53/-2
Mhooks_test.go+37/-2
Mlifecycle.go+23
pi
Mentire_extension.ts+40/-5
Mlifecycle.go+25
Mlifecycle_test.go+32
api
Atrails.go+24
Atrails_test.go+59
Mlifecycle.go+83
strategy
Msession_state.go+29
Msession_state_test.go+47
Mtrail_cmd_test.go+37
docs/architecture
Magent-guide.md+1
21 unmodified lines
22
23
24
25
26
27
28
8 unmodified lines
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
21 unmodified lines
_ agent.SkillEventExtractor = (*ClaudeCodeAgent)(nil)
_ agent.SubagentAwareExtractor = (*ClaudeCodeAgent)(nil)
_ agent.HookResponseWriter = (*ClaudeCodeAgent)(nil)
_ agent.ContextInjector = (*ClaudeCodeAgent)(nil)
// WriteHookResponse outputs a JSON hook response to stdout.
8 unmodified lines
return nil
}
// InjectionEvent reports that Claude Code injects model context at TurnStart
// (the UserPromptSubmit hook), which supports hookSpecificOutput.additionalContext.
func (c *ClaudeCodeAgent) InjectionEvent() agent.EventType { return agent.TurnStart }
// RenderContextInjection renders the UserPromptSubmit additionalContext payload
// Claude Code injects into the model context.
func (c *ClaudeCodeAgent) RenderContextInjection(inj agent.ContextInjection) ([]byte, error) {
out, err := agent.RenderAdditionalContextHookOutput("UserPromptSubmit", inj.Text)
if err != nil {
return nil, fmt.Errorf("render claude-code context injection: %w", err)
}
return out, nil
}
// HookNames returns the hook verbs Claude Code supports.
// These become subcommands: entire hooks claude-code <verb>
func (c *ClaudeCodeAgent) HookNames() []string {
Mcmd/entire/cli/agent/claudecode/lifecycle.go+15
541 unmodified lines
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
541 unmodified lines
t.Errorf("expected immediate return for nonexistent file, but took %v", elapsed)
}
}
func TestClaudeCodeAgent_ContextInjector(t *testing.T) {
t.Parallel()
c := &ClaudeCodeAgent{}
if got := c.InjectionEvent(); got != agent.TurnStart {
t.Errorf("InjectionEvent = %v, want TurnStart", got)
}
out, err := c.RenderContextInjection(agent.ContextInjection{Text: "use entire trail"})
if err != nil {
t.Fatalf("RenderContextInjection: %v", err)
}
var parsed struct {
HookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("invalid JSON: %v (%q)", err, string(out))
}
if parsed.HookSpecificOutput.HookEventName != "UserPromptSubmit" {
t.Errorf("hookEventName = %q, want UserPromptSubmit", parsed.HookSpecificOutput.HookEventName)
}
if parsed.HookSpecificOutput.AdditionalContext != "use entire trail" {
t.Errorf("additionalContext = %q", parsed.HookSpecificOutput.AdditionalContext)
}
}
Mcmd/entire/cli/agent/claudecode/lifecycle_test.go+27
14 unmodified lines
15
16
17
18
19
20
21
8 unmodified lines
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
14 unmodified lines
var (
_ agent.HookSupport = (*CodexAgent)(nil)
_ agent.HookResponseWriter = (*CodexAgent)(nil)
_ agent.ContextInjector = (*CodexAgent)(nil)
)
// WriteHookResponse outputs a JSON hook response to stdout.
8 unmodified lines
return nil
}
// InjectionEvent reports that Codex injects model context at TurnStart (its
// user-prompt-submit hook). Codex hosts Claude-compatible hooks, so it consumes
// the same hookSpecificOutput.additionalContext shape.
func (c *CodexAgent) InjectionEvent() agent.EventType { return agent.TurnStart }
// RenderContextInjection renders the Claude-style additionalContext payload
// Codex injects into the model context at user-prompt-submit.
func (c *CodexAgent) RenderContextInjection(inj agent.ContextInjection) ([]byte, error) {
out, err := agent.RenderAdditionalContextHookOutput("UserPromptSubmit", inj.Text)
if err != nil {
return nil, fmt.Errorf("render codex context injection: %w", err)
}
return out, nil
}
// Codex hook names — these become subcommands under `entire hooks codex`
const (
HookNameSessionStart = "session-start"
Mcmd/entire/cli/agent/codex/lifecycle.go+16
240 unmodified lines
241
242
243
244
245
246
247
248
249
250
251
252
253
254
240 unmodified lines
_, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("{invalid json"))
require.Error(t, err)
}
func TestCodexAgent_ContextInjector(t *testing.T) {
t.Parallel()
c := &CodexAgent{}
require.Equal(t, agent.TurnStart, c.InjectionEvent())
out, err := c.RenderContextInjection(agent.ContextInjection{Text: "use entire trail"})
require.NoError(t, err)
require.Contains(t, string(out), `"hookEventName":"UserPromptSubmit"`)
require.Contains(t, string(out), `"additionalContext":"use entire trail"`)
require.True(t, strings.HasSuffix(string(out), "\n"))
}
Mcmd/entire/cli/agent/codex/lifecycle_test.go+11
15 unmodified lines
16
17
18
19
20
21
22
15 unmodified lines
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
15 unmodified lines
_ agent.TranscriptAnalyzer = (*GeminiCLIAgent)(nil)
_ agent.TokenCalculator = (*GeminiCLIAgent)(nil)
_ agent.HookResponseWriter = (*GeminiCLIAgent)(nil)
_ agent.ContextInjector = (*GeminiCLIAgent)(nil)
)
// WriteHookResponse outputs a hook response message as plain text to stdout.
15 unmodified lines
return nil
}
// InjectionEvent reports that Gemini injects model context at TurnStart (its
// BeforeAgent hook). Gemini CLI's hook runner merges
// hookSpecificOutput.additionalContext into the model context (the plain-text
// path in WriteHookResponse is only a systemMessage double-display workaround,
// which does not apply to additionalContext).
func (g *GeminiCLIAgent) InjectionEvent() agent.EventType { return agent.TurnStart }
// RenderContextInjection renders the BeforeAgent additionalContext payload
// Gemini injects into the model context.
func (g *GeminiCLIAgent) RenderContextInjection(inj agent.ContextInjection) ([]byte, error) {
out, err := agent.RenderAdditionalContextHookOutput("BeforeAgent", inj.Text)
if err != nil {
return nil, fmt.Errorf("render gemini context injection: %w", err)
}
return out, nil
}
// HookNames returns the hook verbs Gemini CLI supports.
// These become subcommands: entire hooks gemini <verb>
func (g *GeminiCLIAgent) HookNames() []string {
Mcmd/entire/cli/agent/geminicli/lifecycle.go+18
519 unmodified lines
520
521
522
523
524
525
526
527
528
529
530
531
532
533
519 unmodified lines
})
require.Empty(t, out, "empty message should produce no output")
}
func TestGeminiCLIAgent_ContextInjector(t *testing.T) {
t.Parallel()
g := &GeminiCLIAgent{}
require.Equal(t, agent.TurnStart, g.InjectionEvent())
out, err := g.RenderContextInjection(agent.ContextInjection{Text: "use entire trail"})
require.NoError(t, err)
// Gemini's BeforeAgent hook is its prompt-submit equivalent.
require.Contains(t, string(out), `"hookEventName":"BeforeAgent"`)
require.Contains(t, string(out), `"additionalContext":"use entire trail"`)
}
Mcmd/entire/cli/agent/geminicli/lifecycle_test.go+11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package agent
import (
"encoding/json"
"fmt"
"strings"
)
// ContextInjection carries text that Entire asks an agent to place into the
// model's context window for the current session. An empty Text means there is
// nothing to inject.
type ContextInjection struct {
Text string
}
// ContextInjector is implemented by agents that can place additional context
// into the *model* at a specific lifecycle event — for example Pi's
// before_agent_start (TurnStart) message injection or OpenCode's
// experimental.chat.system.transform.
//
// This is deliberately distinct from HookResponseWriter: a hook response shows
// a banner to the *user*, whereas a ContextInjection reaches the *model*. An
// agent may implement both.
//
// The agent declares which lifecycle event it injects at (InjectionEvent) and
// renders the native payload its transport understands (RenderContextInjection).
// For extension-backed agents (Pi, OpenCode) that payload is written to the
// hook's stdout and the embedded extension applies it via the agent's native
// injection API.
type ContextInjector interface {
Agent
// InjectionEvent is the lifecycle event at which this agent emits an
// injection payload. The dispatcher only calls RenderContextInjection on
// matching events.
InjectionEvent() EventType
// RenderContextInjection returns the bytes to write to the hook's stdout to
// inject inj into the model, in the agent's native format. Returning an
// empty slice (or nil) means "write nothing".
RenderContextInjection(inj ContextInjection) ([]byte, error)
}
// AsContextInjector returns ag as a ContextInjector when it implements the
// interface. Mirrors AsHookResponseWriter so callers don't type-assert inline.
func AsContextInjector(ag Agent) (ContextInjector, bool) {
if ag == nil {
return nil, false
}
ci, ok := ag.(ContextInjector)
return ci, ok
}
// RenderAdditionalContextHookOutput renders the Claude-Code-style hook output
// that injects text into the model's context window:
//
// {"hookSpecificOutput":{"hookEventName":<event>,"additionalContext":<text>}}
//
// Claude Code, Codex (which hosts Claude-compatible hooks) and Gemini CLI all
// consume this shape on their prompt-submit hook (UserPromptSubmit / BeforeAgent)
// and merge additionalContext into the model context. Returns (nil, nil) for
// empty text so callers can write nothing.
func RenderAdditionalContextHookOutput(hookEventName, text string) ([]byte, error) {
if strings.TrimSpace(text) == "" {
return nil, nil
}
type hookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
}
payload := struct {
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
}{hookSpecificOutput{HookEventName: hookEventName, AdditionalContext: text}}
b, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal additionalContext hook output: %w", err)
}
return append(b, '\n'), nil
}
}
Acmd/entire/cli/agent/inject.go+79
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package agent
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func TestRenderAdditionalContextHookOutput(t *testing.T) {
t.Parallel()
out, err := RenderAdditionalContextHookOutput("UserPromptSubmit", "use entire trail")
if err != nil {
t.Fatalf("RenderAdditionalContextHookOutput: %v", err)
}
if !strings.HasSuffix(string(out), "\n") {
t.Errorf("payload must be newline-terminated, got %q", string(out))
}
var parsed struct {
HookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v (%q)", err, string(out))
}
if parsed.HookSpecificOutput.HookEventName != "UserPromptSubmit" {
t.Errorf("hookEventName = %q, want UserPromptSubmit", parsed.HookSpecificOutput.HookEventName)
}
if parsed.HookSpecificOutput.AdditionalContext != "use entire trail" {
t.Errorf("additionalContext = %q", parsed.HookSpecificOutput.AdditionalContext)
}
}
func TestRenderAdditionalContextHookOutput_EmptyTextRendersNothing(t *testing.T) {
t.Parallel()
for _, text := range []string{"", " ", "\n\t"} {
out, err := RenderAdditionalContextHookOutput("BeforeAgent", text)
if err != nil {
t.Fatalf("RenderAdditionalContextHookOutput(%q): %v", text, err)
}
if len(out) != 0 {
t.Errorf("empty text %q must render no payload, got %q", text, string(out))
}
}
}
// injectorOnly implements just enough of Agent + ContextInjector for the
// capability resolver test.
type injectorStub struct{ Agent }
func (injectorStub) InjectionEvent() EventType { return TurnStart }
//nolint:unparam // signature is dictated by the ContextInjector interface
func (injectorStub) RenderContextInjection(ContextInjection) ([]byte, error) {
return []byte("x"), nil
}
func TestAsContextInjector(t *testing.T) {
t.Parallel()
if ci, ok := AsContextInjector(nil); ok || ci != nil {
t.Errorf("AsContextInjector(nil) = (%v, %v), want (nil, false)", ci, ok)
}
ci, ok := AsContextInjector(injectorStub{})
if !ok || ci == nil {
t.Fatalf("AsContextInjector(injector) = (%v, %v), want non-nil/true", ci, ok)
}
if got := ci.InjectionEvent(); got != TurnStart {
t.Errorf("InjectionEvent = %v, want TurnStart", got)
}
}
Acmd/entire/cli/agent/inject_test.go+74
13 unmodified lines
14
15
16
17
18
19
20
21
22
46 unmodified lines
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
6 unmodified lines
121
122
123
124
125
126
127
128
129
130
131
132
133
134
40 unmodified lines
175
176
177
127
178
179
180
181
13 unmodified lines
let currentModel: string | null = null
// In-memory store for message metadata (role, tokens, etc.)
const messageStore = new Map<string, any>()
// One-time model-context injection captured from the turn-start hook's stdout,
// applied on the next LLM call via experimental.chat.system.transform.
let pendingInjection: string | null = null
/**
* Build the shell command for a hook invocation.
46 unmodified lines
}
}
// parseInjectedContext scans a hook's stdout for Entire's injection envelope
// ({