Skip to content
21 changes: 14 additions & 7 deletions cmd/entire/cli/agent/geminicli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@ var (
_ agent.HookResponseWriter = (*GeminiCLIAgent)(nil)
)

// WriteHookResponse outputs a JSON hook response to stdout.
// Gemini CLI reads this JSON and displays the systemMessage to the user.
// WriteHookResponse outputs a hook response message as plain text to stdout.
//
// Why plain text and not JSON? Gemini CLI (as of v0.40.0) double-displays
// systemMessage when it arrives in JSON form: once via emitHookSystemMessage
// (rendered with the [hookName] source tag) and again via the SessionStart
// path's direct historyManager.addItem (rendered without a tag). With plain
// text, gemini's convertPlainTextToHookOutput synthesizes a systemMessage
// internally, the JSON-only emitHookSystemMessage event doesn't fire, and
// the user sees the banner exactly once.
func (g *GeminiCLIAgent) WriteHookResponse(message string) error {
resp := struct {
SystemMessage string `json:"systemMessage,omitempty"`
}{SystemMessage: message}
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
return fmt.Errorf("failed to encode hook response: %w", err)
if message == "" {
return nil
}
if _, err := fmt.Fprintln(os.Stdout, message); err != nil {
return fmt.Errorf("failed to write hook response: %w", err)
}
return nil
}
Expand Down
49 changes: 49 additions & 0 deletions cmd/entire/cli/agent/geminicli/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package geminicli

import (
"context"
"io"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -471,3 +473,50 @@ func TestReadAndParse_AgentHookInput(t *testing.T) {
t.Errorf("expected hook_event_name 'before-agent', got %q", result.HookEventName)
}
}

// captureStdout swaps os.Stdout for a pipe, runs fn, and returns what was
// written. Sequential (no t.Parallel) because os.Stdout is process-global.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
require.NoError(t, err)

original := os.Stdout
os.Stdout = w

done := make(chan []byte, 1)
go func() {
data, _ := io.ReadAll(r) //nolint:errcheck // best-effort drain
done <- data
}()

fn()
require.NoError(t, w.Close())
os.Stdout = original
got := <-done
require.NoError(t, r.Close())
return string(got)
}

// TestWriteHookResponse_PlainText_NoJSON verifies the response is emitted as
// plain text (not JSON). Gemini CLI v0.40.0 double-displays JSON systemMessage
// (once with the [hookName] tag, once without) — plain text takes only the
// non-tagged path so the user sees the banner once.
func TestWriteHookResponse_PlainText_NoJSON(t *testing.T) {
ag := &GeminiCLIAgent{}
out := captureStdout(t, func() {
require.NoError(t, ag.WriteHookResponse("hello banner"))
})

require.Equal(t, "hello banner\n", out, "expected exact plain-text output (no JSON envelope)")
require.False(t, strings.HasPrefix(strings.TrimSpace(out), "{"),
"output must not start with '{' — gemini's JSON parser would route it through the duplicate-display path")
}

func TestWriteHookResponse_EmptyMessage_WritesNothing(t *testing.T) {
ag := &GeminiCLIAgent{}
out := captureStdout(t, func() {
require.NoError(t, ag.WriteHookResponse(""))
})
require.Empty(t, out, "empty message should produce no output")
}
65 changes: 65 additions & 0 deletions cmd/entire/cli/agent/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package agent
import (
"context"
"fmt"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"

"github.com/entireio/cli/cmd/entire/cli/agent/types"
Expand Down Expand Up @@ -98,6 +101,68 @@ func Detect(ctx context.Context) (Agent, error) {
return detected[0], nil
}

// AgentForTranscriptPath returns the registered agent whose session directory
// for repoPath contains the given transcript path. Used to disambiguate which
// agent owns a session when multiple agents' hooks fire for the same session
// ID — a Cursor transcript path uniquely identifies a Cursor session even
// when Claude Code's hook is the one firing.
//
// Returns (nil, false) if transcriptPath is empty, no agent claims it, or any
// registry lookup fails. Match is by directory prefix (with a separator) so
// "/x/.claude/projects/abc.jsonl" doesn't accidentally match an agent rooted
// at "/x/.claude/projects/ab".
//
//nolint:revive // AgentForTranscriptPath: stutter is intentional for package callers (agent.AgentForTranscriptPath reads naturally)
func AgentForTranscriptPath(transcriptPath, repoPath string) (Agent, bool) {
if transcriptPath == "" {
return nil, false
}
abs, err := filepath.Abs(transcriptPath)
if err != nil {
abs = transcriptPath
}
for _, name := range List() {
ag, err := Get(name)
if err != nil {
continue
}
dir, err := ag.GetSessionDir(repoPath)
if err != nil || dir == "" {
continue
}
dirAbs, err := filepath.Abs(dir)
if err != nil {
dirAbs = dir
}
if pathHasDirPrefix(abs, dirAbs) {
return ag, true
}
}
return nil, false
}

// pathHasDirPrefix reports whether path is contained within dir (or equals it).
// Adds a trailing separator before prefix-matching so /a/bc doesn't match /a/b.
//
// On Windows, comparison is case-insensitive: NTFS/ReFS treat paths as
// case-insensitive, and filepath.Abs preserves whatever casing the input had,
// so a transcript path like `C:\Users\Bob\.cursor\...` and a session dir like
// `c:\users\bob\.cursor\...` refer to the same location but would not match
// under a byte-wise comparison.
func pathHasDirPrefix(path, dir string) bool {
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
dir = strings.ToLower(dir)
}
if path == dir {
return true
}
if !strings.HasSuffix(dir, string(filepath.Separator)) {
dir += string(filepath.Separator)
}
return strings.HasPrefix(path, dir)
}

// Agent name constants (registry keys)
const (
AgentNameClaudeCode types.AgentName = "claude-code"
Expand Down
129 changes: 129 additions & 0 deletions cmd/entire/cli/agent/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agent

import (
"context"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -134,6 +135,134 @@ func (d *detectableAgent) DetectPresence(_ context.Context) (bool, error) {
return true, nil
}

// sessionDirAgent is a mock with a configurable session dir, for path-prefix tests.
type sessionDirAgent struct {
mockAgent

name types.AgentName
agentType types.AgentType
sessionDir string
}

func (s *sessionDirAgent) Name() types.AgentName { return s.name }
func (s *sessionDirAgent) Type() types.AgentType { return s.agentType }
func (s *sessionDirAgent) GetSessionDir(_ string) (string, error) { return s.sessionDir, nil }

func TestAgentForTranscriptPath(t *testing.T) {
originalRegistry := make(map[types.AgentName]Factory)
registryMu.Lock()
for k, v := range registry {
originalRegistry[k] = v
}
registry = make(map[types.AgentName]Factory)
registryMu.Unlock()
t.Cleanup(func() {
registryMu.Lock()
registry = originalRegistry
registryMu.Unlock()
})

cursor := &sessionDirAgent{
name: types.AgentName("cursor"),
agentType: types.AgentType("Cursor"),
sessionDir: "/home/u/.cursor/projects/repo/agent-transcripts",
}
claude := &sessionDirAgent{
name: types.AgentName("claude-code"),
agentType: types.AgentType("Claude Code"),
sessionDir: "/home/u/.claude/projects/repo",
}
Comment thread
Soph marked this conversation as resolved.
Register(cursor.name, func() Agent { return cursor })
Register(claude.name, func() Agent { return claude })

cases := []struct {
name string
transcript string
wantAgent types.AgentType
wantOK bool
}{
{
name: "cursor IDE nested layout",
transcript: "/home/u/.cursor/projects/repo/agent-transcripts/abc/abc.jsonl",
wantAgent: cursor.Type(),
wantOK: true,
},
{
name: "cursor CLI flat layout",
transcript: "/home/u/.cursor/projects/repo/agent-transcripts/abc.jsonl",
wantAgent: cursor.Type(),
wantOK: true,
},
{
name: "claude code transcript",
transcript: "/home/u/.claude/projects/repo/abc.jsonl",
wantAgent: claude.Type(),
wantOK: true,
},
{
name: "empty transcript path returns false",
transcript: "",
wantOK: false,
},
{
name: "unrelated path returns false",
transcript: "/home/u/somewhere/else/transcript.jsonl",
wantOK: false,
},
{
name: "directory-prefix collision is rejected",
// Without a separator-aware prefix check, this would erroneously
// match an agent rooted at /home/u/.cursor/projects/rep.
transcript: "/home/u/.cursor/projects/repository/agent-transcripts/x.jsonl",
wantOK: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ag, ok := AgentForTranscriptPath(tc.transcript, "/repo")
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if ag.Type() != tc.wantAgent {
t.Errorf("agent = %q, want %q", ag.Type(), tc.wantAgent)
}
})
}
}

// TestPathHasDirPrefix_CaseSensitivity verifies the platform-dependent
// case-handling of pathHasDirPrefix. On Windows, NTFS/ReFS are case-
// insensitive and filepath.Abs preserves whatever casing the input had, so
// the transcript-path override must match across casing differences. On Unix
// the comparison stays case-sensitive (different cases are different files).
func TestPathHasDirPrefix_CaseSensitivity(t *testing.T) {
t.Parallel()

if runtime.GOOS == "windows" {
// Mixed-case paths that refer to the same NTFS location must match.
if !pathHasDirPrefix(`C:\Users\Bob\.cursor\projects\repo\agent-transcripts\abc.jsonl`,
`c:\users\bob\.cursor\projects\repo\agent-transcripts`) {
t.Errorf("expected case-insensitive match on Windows for mixed-case prefix")
}
// Equality with different casing should also match.
if !pathHasDirPrefix(`C:\Users\Bob\.cursor\projects\repo`,
`c:\users\bob\.cursor\projects\repo`) {
t.Errorf("expected case-insensitive equality match on Windows")
}
return
}

// Unix: case-sensitive — different casing means different files.
if pathHasDirPrefix("/Home/u/.cursor/projects/repo/x.jsonl",
"/home/u/.cursor/projects/repo") {
t.Errorf("expected case-sensitive comparison on %s", runtime.GOOS)
}
}

func TestAgentNameConstants(t *testing.T) {
if AgentNameClaudeCode != "claude-code" {
t.Errorf("expected AgentNameClaudeCode %q, got %q", "claude-code", AgentNameClaudeCode)
Expand Down
Loading
Loading