Skip to content
26 changes: 26 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package agent

import (
"context"
"encoding/json"
"io"
"os/exec"

Expand Down Expand Up @@ -188,6 +189,31 @@ type TokenCalculator interface {
CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error)
}

// OutOfBandTokenSource provides token usage from a source other than the
// transcript. Antigravity is the only agent that needs this: agy never writes
// token data into its transcript or hook payloads — its title/statusline pipe
// is the only surface, captured to disk by `entire hooks antigravity
// title-tee` (see agent/antigravity/statusline.go).
//
// Flow: the lifecycle calls SnapshotTokenBaseline at TurnStart and stores the
// opaque baseline in PrePromptState; at TurnEnd (when transcript-based
// calculation yields nothing) it calls CalculateTokenUsageSince to get the
// checkpoint-scoped delta — the same cumulative-totals-minus-baseline pattern
// Codex uses, sourced out-of-band.
type OutOfBandTokenSource interface {
Agent

// SnapshotTokenBaseline returns an opaque, agent-defined marker of the
// current cumulative token position for the session. A nil baseline with
// nil error means "no usage observed yet" (delta will count from zero).
SnapshotTokenBaseline(ctx context.Context, sessionID string) (json.RawMessage, error)

// CalculateTokenUsageSince computes usage between the baseline and now.
// A nil result with nil error means no data is available (degrade to no
// token counts, never to an error).
CalculateTokenUsageSince(ctx context.Context, sessionID string, baseline json.RawMessage) (*TokenUsage, error)
}

// TextGenerator is an optional interface for agents whose CLI supports
// non-interactive text generation (e.g., claude --print).
// Used for AI-powered metadata generation (trail titles, summaries).
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/agent/antigravity/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type AntigravityAgent struct {
CommandRunner agent.TextCommandRunner
}

var _ agent.OutOfBandTokenSource = (*AntigravityAgent)(nil)

// NewAntigravityAgent creates a new AntigravityAgent instance.
func NewAntigravityAgent() agent.Agent {
return &AntigravityAgent{}
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/agent/antigravity/antigravity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func TestDetectPresence(t *testing.T) {
t.Run("hooks installed", func(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)
t.Setenv(configDirEnv, t.TempDir())

ag := &AntigravityAgent{}
if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
Expand Down
15 changes: 15 additions & 0 deletions cmd/entire/cli/agent/antigravity/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

Expand Down Expand Up @@ -59,6 +60,20 @@ func (a *AntigravityAgent) InstallHooks(ctx context.Context, localDev bool, forc

candidate := buildEntireHookConfig(cmdPrefix, localDev)

// Title tee: agy's only token-usage surface (same payload as the
// statusline script). Run this BEFORE the idempotency early-return: the
// title slot lives in agy's GLOBAL settings.json, independent of this
// repo's .agents/hooks.json. If repo hooks already match but the global
// slot is missing or stale (upgrade from a pre-title-tee version, a failed
// first install, or `entire agent add` without --force), re-running setup
// must still repair it — otherwise the doctor's "re-run setup" hint is a
// no-op. InstallTitleTee is itself idempotent. Best-effort: a failure to
// claim the global slot must not fail repo-level hook setup.
if err := InstallTitleTee(localDev); err != nil {
logging.Warn(ctx, "failed to install antigravity title tee",
"error", err.Error())
}

// Idempotency check: compare candidate against existing "entire" entry by
// re-marshaling both to compact JSON for a stable comparison.
if !force {
Expand Down
43 changes: 43 additions & 0 deletions cmd/entire/cli/agent/antigravity/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
func TestInstallHooks_FreshRepo(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}
n, err := a.InstallHooks(context.Background(), false, false)
Expand Down Expand Up @@ -49,6 +50,7 @@ func TestInstallHooks_FreshRepo(t *testing.T) {
func TestInstallHooks_Idempotent(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}

Expand All @@ -74,6 +76,7 @@ func TestInstallHooks_Idempotent(t *testing.T) {
func TestInstallHooks_PreservesForeignHooks(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

// Pre-seed .agents/hooks.json with a foreign entry
agentsDir := filepath.Join(tmpDir, ".agents")
Expand Down Expand Up @@ -127,6 +130,7 @@ func TestInstallHooks_PreservesForeignHooks(t *testing.T) {
func TestUninstallHooks_LeavesForeignHooks(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}

Expand Down Expand Up @@ -187,6 +191,7 @@ func TestUninstallHooks_LeavesForeignHooks(t *testing.T) {
func TestInstallHooks_LocalDevWritesQuotedSubshell(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}
if _, err := a.InstallHooks(context.Background(), true, false); err != nil {
Expand All @@ -211,6 +216,7 @@ func TestInstallHooks_LocalDevWritesQuotedSubshell(t *testing.T) {
func TestAreHooksInstalled(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}

Expand All @@ -226,3 +232,40 @@ func TestAreHooksInstalled(t *testing.T) {
t.Error("AreHooksInstalled() = false after install, want true")
}
}

// TestInstallHooks_IdempotentStillRepairsTitleTee guards the regression where
// the repo-hooks idempotency early-return skipped the global title-tee install,
// leaving Antigravity checkpoints without token counts after an upgrade or a
// failed first title install (and making the doctor's "re-run setup" hint a
// no-op). Re-running InstallHooks must repair the missing global slot even when
// the repo's .agents/hooks.json already matches.
func TestInstallHooks_IdempotentStillRepairsTitleTee(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
t.Setenv(configDirEnv, t.TempDir())

a := &AntigravityAgent{}
if _, err := a.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("first InstallHooks: %v", err)
}
if !TitleTeeInstalled() {
t.Fatal("title tee should be installed after the first InstallHooks")
}

// Simulate a missing/stale global slot while repo hooks remain correct.
if err := UninstallTitleTee(); err != nil {
t.Fatalf("UninstallTitleTee: %v", err)
}
if TitleTeeInstalled() {
t.Fatal("precondition: title tee should be gone before the idempotent re-install")
}

// Second install hits the repo-hooks idempotency early-return, but must
// still re-install the missing global title tee.
if _, err := a.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("second InstallHooks: %v", err)
}
if !TitleTeeInstalled() {
t.Error("idempotent InstallHooks must repair the missing title tee")
}
}
Loading
Loading