Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cmd/trace/cli/agent/codex/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strings"
"time"

"github.com/GrayCodeAI/trace/cmd/trace/cli/agent"
Expand Down Expand Up @@ -35,6 +36,7 @@ const (
HookNameUserPromptSubmit = "user-prompt-submit"
HookNameStop = "stop"
HookNamePreToolUse = "pre-tool-use"
HookNamePostToolUse = "post-tool-use"
)

// HookNames returns the hook verbs Codex supports.
Expand All @@ -44,6 +46,7 @@ func (c *CodexAgent) HookNames() []string {
HookNameUserPromptSubmit,
HookNameStop,
HookNamePreToolUse,
HookNamePostToolUse,
}
}

Expand All @@ -60,6 +63,8 @@ func (c *CodexAgent) ParseHookEvent(_ context.Context, hookName string, stdin io
case HookNamePreToolUse:
// PreToolUse has no lifecycle significance — pass through
return nil, nil //nolint:nilnil // nil event = no lifecycle action
case HookNamePostToolUse:
return c.parsePostToolUse(stdin)
default:
return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action
}
Expand Down Expand Up @@ -107,3 +112,64 @@ func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) {
Timestamp: time.Now(),
}, nil
}

func (c *CodexAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) {
raw, err := agent.ReadAndParseHookInput[postToolUseRaw](stdin)
if err != nil {
return nil, err
}

// Only apply_patch carries file changes worth tracking.
if raw.ToolName != "apply_patch" {
return nil, nil //nolint:nilnil // non-mutating tools have no lifecycle action
}

var input applyPatchInput
if err := json.Unmarshal(raw.ToolInput, &input); err != nil {
return nil, fmt.Errorf("failed to parse apply_patch input: %w", err)
}

added, updated, deleted := parseApplyPatchFiles(input.Patch)
if len(added) == 0 && len(updated) == 0 && len(deleted) == 0 {
return nil, nil //nolint:nilnil // empty patch has no lifecycle action
}

return &agent.Event{
Type: agent.ToolUse,
SessionID: raw.SessionID,
SessionRef: derefString(raw.TranscriptPath),
ToolName: raw.ToolName,
ToolUseID: raw.ToolUseID,
ModifiedFiles: updated,
NewFiles: added,
DeletedFiles: deleted,
Timestamp: time.Now(),
}, nil
}

// parseApplyPatchFiles extracts file paths from a Codex apply_patch envelope.
// The patch format uses markers:
//
// *** Add File: path
// *** Update File: path
// *** Delete File: path
func parseApplyPatchFiles(patch string) (added, updated, deleted []string) {
for line := range strings.SplitSeq(patch, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "*** Add File:"):
if p := strings.TrimSpace(strings.TrimPrefix(line, "*** Add File:")); p != "" {
added = append(added, p)
}
case strings.HasPrefix(line, "*** Update File:"):
if p := strings.TrimSpace(strings.TrimPrefix(line, "*** Update File:")); p != "" {
updated = append(updated, p)
}
case strings.HasPrefix(line, "*** Delete File:"):
if p := strings.TrimSpace(strings.TrimPrefix(line, "*** Delete File:")); p != "" {
deleted = append(deleted, p)
}
}
}
return added, updated, deleted
}
116 changes: 116 additions & 0 deletions cmd/trace/cli/agent/codex/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,119 @@ func TestParseHookEvent_MalformedJSON_ReturnsError(t *testing.T) {
_, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("{invalid json"))
require.Error(t, err)
}

func TestParseHookEvent_PostToolUse_ApplyPatch(t *testing.T) {
t.Parallel()
ag := &CodexAgent{}
input := `{
"session_id": "test-uuid",
"turn_id": "turn-1",
"transcript_path": null,
"cwd": "/tmp/repo",
"hook_event_name": "PostToolUse",
"model": "gpt-5",
"permission_mode": "default",
"tool_name": "apply_patch",
"tool_use_id": "call-patch",
"tool_input": {"patch": "*** Add File: a.go\n+hello\n*** Update File: b.go\n@@\n-old\n+new\n*** Delete File: c.go\n*** End Patch\n"},
"tool_response": "Patch applied successfully."
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostToolUse, strings.NewReader(input))
require.NoError(t, err)
require.NotNil(t, event)
require.Equal(t, agent.ToolUse, event.Type)
require.Equal(t, "test-uuid", event.SessionID)
require.Equal(t, "apply_patch", event.ToolName)
require.Equal(t, []string{"a.go"}, event.NewFiles)
require.Equal(t, []string{"b.go"}, event.ModifiedFiles)
require.Equal(t, []string{"c.go"}, event.DeletedFiles)
}

func TestParseHookEvent_PostToolUse_NonApplyPatch_ReturnsNil(t *testing.T) {
t.Parallel()
ag := &CodexAgent{}
input := `{
"session_id": "test-uuid",
"turn_id": "turn-1",
"transcript_path": null,
"cwd": "/tmp/repo",
"hook_event_name": "PostToolUse",
"model": "gpt-5",
"permission_mode": "default",
"tool_name": "shell",
"tool_use_id": "call-shell",
"tool_input": {"command": ["echo", "hi"]},
"tool_response": "hi\n"
}`

event, err := ag.ParseHookEvent(context.Background(), HookNamePostToolUse, strings.NewReader(input))
require.NoError(t, err)
require.Nil(t, event)
}

func TestParseApplyPatchFiles(t *testing.T) {
t.Parallel()

tests := []struct {
name string
patch string
wantAdded []string
wantUpdated []string
wantDeleted []string
}{
{
name: "all three operations",
patch: "*** Begin Patch\n" +
"*** Add File: docs/added.md\n" +
"+# added\n" +
"*** Update File: src/changed.go\n" +
"@@\n" +
"-old\n" +
"+new\n" +
"*** Delete File: tmp/gone.txt\n" +
"*** End Patch\n",
wantAdded: []string{"docs/added.md"},
wantUpdated: []string{"src/changed.go"},
wantDeleted: []string{"tmp/gone.txt"},
},
{
name: "empty patch",
patch: "",
wantAdded: nil,
wantUpdated: nil,
wantDeleted: nil,
},
{
name: "only adds",
patch: "*** Add File: a.go\n" +
"+line1\n" +
"*** Add File: b.go\n" +
"+line2\n",
wantAdded: []string{"a.go", "b.go"},
wantUpdated: nil,
wantDeleted: nil,
},
{
name: "no markers",
patch: "*** Begin Patch\n" +
"@@\n" +
"-old\n" +
"+new\n" +
"*** End Patch\n",
wantAdded: nil,
wantUpdated: nil,
wantDeleted: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
added, updated, deleted := parseApplyPatchFiles(tt.patch)
require.Equal(t, tt.wantAdded, added)
require.Equal(t, tt.wantUpdated, updated)
require.Equal(t, tt.wantDeleted, deleted)
})
}
}
22 changes: 22 additions & 0 deletions cmd/trace/cli/agent/codex/types.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package codex

import "encoding/json"

// HooksFile represents the .codex/hooks.json structure.
type HooksFile struct {
Hooks HookEvents `json:"hooks"`
Expand Down Expand Up @@ -63,6 +65,26 @@ type stopRaw struct {
LastAssistantMessage *string `json:"last_assistant_message"` // nullable
}

// postToolUseRaw is the JSON structure from PostToolUse hooks.
type postToolUseRaw struct {
SessionID string `json:"session_id"`
TurnID string `json:"turn_id"`
TranscriptPath *string `json:"transcript_path"` // nullable
CWD string `json:"cwd"`
HookEventName string `json:"hook_event_name"`
Model string `json:"model"`
PermissionMode string `json:"permission_mode"`
ToolName string `json:"tool_name"`
ToolUseID string `json:"tool_use_id"`
ToolInput json.RawMessage `json:"tool_input"`
ToolResponse json.RawMessage `json:"tool_response"`
}

// applyPatchInput is the structure of tool_input for apply_patch.
type applyPatchInput struct {
Patch string `json:"patch"`
}

// derefString safely dereferences a nullable string pointer.
func derefString(s *string) string {
if s == nil {
Expand Down
24 changes: 22 additions & 2 deletions cmd/trace/cli/agent/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const (
// (e.g., Gemini CLI's BeforeModel). The framework stores the model as a hint
// for subsequent TurnStart/TurnEnd events in the same session.
ModelUpdate

// ToolUse indicates a tool was used mid-turn (e.g., apply_patch, write_file).
// The framework merges the tool's file list into session.FilesTouched so that
// mid-turn commits have accurate carry-forward data.
ToolUse
)

// String returns a human-readable name for the event type.
Expand All @@ -61,6 +66,8 @@ func (e EventType) String() string {
return "SubagentEnd"
case ModelUpdate:
return "ModelUpdate"
case ToolUse:
return "ToolUse"
default:
return "Unknown"
}
Expand Down Expand Up @@ -96,6 +103,10 @@ type Event struct {
// ToolUseID identifies the tool invocation (for SubagentStart/SubagentEnd events).
ToolUseID string

// ToolName identifies the tool that was used (for ToolUse events).
// Agents set this to their native tool identifier (e.g., "apply_patch" for Codex).
ToolName string

// SubagentID identifies the subagent instance (for SubagentEnd events).
SubagentID string

Expand All @@ -109,11 +120,20 @@ type Event struct {
SubagentType string
TaskDescription string

// ModifiedFiles is a list of file paths modified by a subagent.
// ModifiedFiles is a list of file paths modified by a subagent or tool.
// Populated on SubagentEnd events when the agent provides this data
// directly via hook payload (e.g., Cursor's subagentStop).
// directly via hook payload (e.g., Cursor's subagentStop), and on
// ToolUse events for updated files (e.g., Codex apply_patch).
ModifiedFiles []string

// NewFiles is a list of file paths newly created by a tool.
// Populated on ToolUse events (e.g., Codex apply_patch "Add File").
NewFiles []string

// DeletedFiles is a list of file paths deleted by a tool.
// Populated on ToolUse events (e.g., Codex apply_patch "Delete File").
DeletedFiles []string

// ResponseMessage is an optional message to display to the user via the agent.
ResponseMessage string

Expand Down
2 changes: 1 addition & 1 deletion cmd/trace/cli/api/base_url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestResolveURLFromBase_RejectsNonHTTPScheme(t *testing.T) {
func TestRequireSecureURL_AllowsHTTPS(t *testing.T) {
t.Parallel()

if err := RequireSecureURL("https://trace.io"); err != nil {
if err := RequireSecureURL("https://entire.io"); err != nil {
t.Fatalf("RequireSecureURL(https) = %v, want nil", err)
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/trace/cli/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

const (
maxResponseBytes = 1 << 20
maxResponseBytes = 16 << 20 // 16 MiB – increased from 1 MiB to support large trail/checkpoint payloads (ported from upstream)
userAgent = "trace-cli"
)

Expand Down
Loading
Loading