Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func main() {
commands.SchemaCommand,
commands.GrepCommand,
commands.ConfigCommand,
commands.AICodeHooksCommand,
}
err = app.Run(os.Args)
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ func main() {
}
}

// AICode Hooks processor (hooks-based tracking)
if cfg.AICodeHooks != nil && cfg.AICodeHooks.Enabled != nil && *cfg.AICodeHooks.Enabled {
aicodeHooksProcessor := daemon.NewAICodeHooksProcessor(cfg)
aicodeHooksProcessor.Start(pubsub)
defer aicodeHooksProcessor.Stop()
slog.Info("AICodeHooks processor started")
}

// Start heartbeat resync service if codeTracking is enabled
if cfg.CodeTracking != nil && cfg.CodeTracking.Enabled != nil && *cfg.CodeTracking.Enabled {
heartbeatResyncService := daemon.NewHeartbeatResyncService(cfg)
Expand Down
204 changes: 204 additions & 0 deletions commands/aicode_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package commands

import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"time"

"github.com/google/uuid"
"github.com/malamtime/cli/daemon"
"github.com/malamtime/cli/model"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel/trace"
)

var AICodeHooksCommand = &cli.Command{
Name: "aicode-hooks",
Usage: "Track AI coding tool hook events",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "source",
Value: "claude-code",
Usage: "Source of the hook event (claude-code, codex, cursor)",
},
},
Action: commandAICodeHooks,
Subcommands: []*cli.Command{
AICodeHooksInstallCommand,
AICodeHooksUninstallCommand,
},
}

func commandAICodeHooks(c *cli.Context) error {
ctx, span := commandTracer.Start(c.Context, "aicode-hooks", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of os.ExpandEnv("$HOME/" + ...) is not platform-independent and violates the repository's general rules. On Windows, $HOME is not typically set. Please use os.UserHomeDir() to get the user's home directory and path/filepath.Join to construct the path.

For example:

homeDir, err := os.UserHomeDir()
if err != nil {
    slog.Error("Failed to determine home directory", "err", err)
    return err
}
SetupLogger(filepath.Join(homeDir, model.COMMAND_BASE_STORAGE_FOLDER))
References
  1. For platform-independent paths, use filepath.Join to combine segments and os.UserHomeDir() to get the home directory, rather than hardcoding path separators or environment variables like $HOME.


// Read JSON from stdin
input, err := io.ReadAll(os.Stdin)
if err != nil {
slog.Error("Failed to read stdin", slog.Any("err", err))
return err
}

if len(input) == 0 {
slog.Debug("No input received from stdin")
return nil
}

// Parse raw JSON payload
var rawPayload map[string]any
if err := json.Unmarshal(input, &rawPayload); err != nil {
slog.Error("Failed to parse JSON input", slog.Any("err", err))
return err
}

// Detect source
source := c.String("source")
if !c.IsSet("source") {
// Auto-detect: if JSON has hook_event_name, it's claude-code (default)
if _, ok := rawPayload["hook_event_name"]; ok {
source = model.AICodeHooksSourceClaudeCode
}
}

// Map source to client type
clientType := mapSourceToClientType(source)

// Build event data
eventData := model.AICodeHooksEventData{
EventID: uuid.New().String(),
ClientType: clientType,
Timestamp: time.Now().Unix(),
RawPayload: rawPayload,
}

// Parse common fields from raw payload
if v, ok := rawPayload["hook_event_name"].(string); ok {
eventData.HookEventName = v
}
if v, ok := rawPayload["session_id"].(string); ok {
eventData.SessionID = v
}
if v, ok := rawPayload["cwd"].(string); ok {
eventData.Cwd = v
}
if v, ok := rawPayload["permission_mode"].(string); ok {
eventData.PermissionMode = v
}
if v, ok := rawPayload["model"].(string); ok {
eventData.Model = v
}
if v, ok := rawPayload["tool_name"].(string); ok {
eventData.ToolName = v
}
if v, ok := rawPayload["tool_input"].(map[string]any); ok {
eventData.ToolInput = v
}
if v, ok := rawPayload["tool_response"].(map[string]any); ok {
eventData.ToolResponse = v
}
if v, ok := rawPayload["tool_use_id"].(string); ok {
eventData.ToolUseID = v
}
if v, ok := rawPayload["prompt"].(string); ok {
eventData.Prompt = v
}
if v, ok := rawPayload["error"].(string); ok {
eventData.Error = v
}
if v, ok := rawPayload["is_interrupt"].(bool); ok {
eventData.IsInterrupt = v
}
if v, ok := rawPayload["agent_id"].(string); ok {
eventData.AgentID = v
}
if v, ok := rawPayload["agent_type"].(string); ok {
eventData.AgentType = v
}
if v, ok := rawPayload["last_assistant_message"].(string); ok {
eventData.LastMessage = v
}
if v, ok := rawPayload["stop_hook_active"].(bool); ok {
eventData.StopHookActive = v
}
if v, ok := rawPayload["notification_type"].(string); ok {
eventData.NotificationType = v
}
if v, ok := rawPayload["notification_message"].(string); ok {
eventData.NotificationMessage = v
}
if v, ok := rawPayload["session_end_reason"].(string); ok {
eventData.SessionEndReason = v
}
if v, ok := rawPayload["transcript_path"].(string); ok {
eventData.TranscriptPath = v
}
Comment on lines +79 to +139
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This large block of type assertions to parse fields from rawPayload is repetitive and can be hard to maintain. Consider refactoring this to improve readability and reduce boilerplate. One approach is to define helper functions for extracting typed values from the map. For example:

func getString(payload map[string]any, key string) string {
    if v, ok := payload[key].(string); ok {
        return v
    }
    return ""
}

// ... in commandAICodeHooks ...
eventData.HookEventName = getString(rawPayload, "hook_event_name")

An even better approach would be to unmarshal the JSON directly into the model.AICodeHooksEventData struct, which would eliminate this manual parsing entirely.


// Try sending to daemon socket first
config, err := configService.ReadConfigFile(ctx)
if err != nil {
slog.Error("Failed to read config", slog.Any("err", err))
return err
}

socketPath := config.SocketPath
if daemon.IsSocketReady(ctx, socketPath) {
err := sendAICodeHooksToSocket(ctx, socketPath, eventData)
if err != nil {
slog.Error("Failed to send to daemon socket, trying direct HTTP", slog.Any("err", err))
sendAICodeHooksDirect(ctx, config, eventData)
}
} else {
slog.Debug("Daemon socket not available, sending direct HTTP")
sendAICodeHooksDirect(ctx, config, eventData)
}

return nil
}

func mapSourceToClientType(source string) string {
switch source {
case model.AICodeHooksSourceCodex:
return model.AICodeHooksClientCodex
case model.AICodeHooksSourceCursor:
return model.AICodeHooksClientCursor
default:
return model.AICodeHooksClientClaudeCode
}
}

func sendAICodeHooksToSocket(ctx context.Context, socketPath string, eventData model.AICodeHooksEventData) error {
return daemon.SendAICodeHooksToSocket(socketPath, eventData)
}

// sendAICodeHooksDirect sends event data directly via HTTP (fire-and-forget)
func sendAICodeHooksDirect(ctx context.Context, config model.ShellTimeConfig, eventData model.AICodeHooksEventData) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}

req := &model.AICodeHooksRequest{
Host: hostname,
Events: []model.AICodeHooksEventData{eventData},
}

endpoint := model.Endpoint{
Token: config.Token,
APIEndpoint: config.APIEndpoint,
}

// Fire-and-forget
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := model.SendAICodeHooksData(sendCtx, req, endpoint)
if err != nil {
slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err))
}
}()
Comment on lines +196 to +203
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Fire-and-forget goroutine in CLI process exits before HTTP request completes

In sendAICodeHooksDirect, the HTTP request is dispatched in a background goroutine (go func() at line 196). However, this function is called from the CLI command handler commandAICodeHooks which returns nil immediately after at commands/aicode_hooks.go:160. Once the command handler returns, app.Run() in cmd/cli/main.go:108 completes, CloseLogger() runs, and the process exits — killing the goroutine before the HTTP request can complete. This means the direct HTTP fallback path (used when the daemon socket is unavailable) will silently lose all event data. The fix is to make the HTTP call synchronous (remove the go func() wrapper), since the hook is already registered as async: true in Claude Code's settings, so Claude Code won't be blocked waiting for the shelltime process.

Suggested change
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := model.SendAICodeHooksData(sendCtx, req, endpoint)
if err != nil {
slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err))
}
}()
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := model.SendAICodeHooksData(sendCtx, req, endpoint)
if err != nil {
slog.Error("AICodeHooks: Direct HTTP send failed", slog.Any("err", err))
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
Loading
Loading