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
1 change: 1 addition & 0 deletions deploy/cc-sidecar.service
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Requires=docker.service
[Service]
Type=simple
User=mike
EnvironmentFile=-/etc/cc-sidecar/env
ExecStart=/home/mike/cc-sidecar/bin/cc-sidecar --config /etc/cc-sidecar/config.yaml
Restart=on-failure
RestartSec=5
Expand Down
12 changes: 9 additions & 3 deletions internal/session/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ func (t *Tracker) Stop() {
}

func (t *Tracker) check() {
t.mu.Lock()
defer t.mu.Unlock()
// Collect paths to complete under the lock, then process outside it.
var readyPaths []string

t.mu.Lock()
now := time.Now()

for path, tf := range t.files {
if tf.reported {
continue
Expand All @@ -117,7 +117,13 @@ func (t *Tracker) check() {

t.logger.Info("session idle, no claude process — completing", "path", path, "idle", idle)
tf.reported = true
readyPaths = append(readyPaths, path)
}
t.mu.Unlock()

// Parse transcripts and invoke callbacks outside the lock to avoid
// blocking Touch() during network I/O (NATS publish, KV lookup).
for _, path := range readyPaths {
completed := parseTranscript(path, t.logger)
if completed != nil {
t.onComplete(completed)
Expand Down
26 changes: 20 additions & 6 deletions internal/session/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ func parseTranscript(path string, logger *slog.Logger) *CompletedSession {
defer f.Close()

var (
sessionID string
workingDir string
filesChanged = make(map[string]bool)
firstTS time.Time
lastTS time.Time
sessionID string
workingDir string
filesChanged = make(map[string]bool)
firstTS time.Time
lastTS time.Time
hasAssistantMsg bool
)

scanner := bufio.NewScanner(f)
Expand Down Expand Up @@ -90,6 +91,11 @@ func parseTranscript(path string, logger *slog.Logger) *CompletedSession {
}
}

// Track whether the session produced any assistant responses.
if entry.Type == "assistant" {
hasAssistantMsg = true
}

// Extract file changes from tool_use entries.
extractFileChanges(line, filesChanged)
}
Expand Down Expand Up @@ -118,13 +124,21 @@ func parseTranscript(path string, logger *slog.Logger) *CompletedSession {
durationMs = lastTS.Sub(firstTS).Milliseconds()
}

// Determine exit code heuristically. CC JSONL transcripts don't record
// explicit exit codes. A session with no assistant messages likely indicates
// a startup failure or crash.
exitCode := 0
if !hasAssistantMsg {
exitCode = 1
}

return &CompletedSession{
SessionID: sessionID,
TranscriptPath: path,
FilesChanged: files,
WorkingDir: workingDir,
DurationMs: durationMs,
ExitCode: 0,
ExitCode: exitCode,
}
}

Expand Down
36 changes: 36 additions & 0 deletions internal/session/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ func TestIsUUIDLike(t *testing.T) {
}
}

func TestParseTranscript_ExitCodeZeroWithAssistant(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "aaaa1111-2222-3333-4444-555555555555.jsonl")

content := `{"type":"summary","sessionId":"aaaa1111-2222-3333-4444-555555555555","cwd":"/home/mike","timestamp":"2026-02-14T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello!"}]},"timestamp":"2026-02-14T10:01:00Z"}
`
os.WriteFile(path, []byte(content), 0644)

result := parseTranscript(path, testLogger())
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ExitCode != 0 {
t.Errorf("expected exit_code 0 for session with assistant response, got %d", result.ExitCode)
}
}

func TestParseTranscript_ExitCodeOneWithoutAssistant(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bbbb1111-2222-3333-4444-555555555555.jsonl")

content := `{"type":"summary","sessionId":"bbbb1111-2222-3333-4444-555555555555","cwd":"/home/mike","timestamp":"2026-02-14T10:00:00Z"}
{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-02-14T10:01:00Z"}
`
os.WriteFile(path, []byte(content), 0644)

result := parseTranscript(path, testLogger())
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ExitCode != 1 {
t.Errorf("expected exit_code 1 for session without assistant response, got %d", result.ExitCode)
}
}

func TestParseTranscript_DeduplicatesFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dedup-test-aaaa-bbbb-ccccddddeeee.jsonl")
Expand Down
16 changes: 14 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,14 @@ func main() {

// Create session tracker.
tracker := session.NewTracker(cfg.IdleThreshold, cfg.PollInterval, logger, func(s *session.CompletedSession) {
if err := pub.PublishCompleted(s, reg); err != nil {
logger.Error("failed to publish session completed", "error", err, "session_id", s.SessionID)
if s.ExitCode != 0 {
if err := pub.PublishFailed(s, reg); err != nil {
logger.Error("failed to publish session failed", "error", err, "session_id", s.SessionID)
}
} else {
if err := pub.PublishCompleted(s, reg); err != nil {
logger.Error("failed to publish session completed", "error", err, "session_id", s.SessionID)
}
}
})

Expand Down Expand Up @@ -93,6 +99,9 @@ func loadConfig(path string, logger *slog.Logger) Config {
if v := os.Getenv("CC_SIDECAR_NATS_URL"); v != "" {
cfg.NATS.URL = v
}
if v := os.Getenv("CC_SIDECAR_NATS_TOKEN"); v != "" {
cfg.NATS.Token = v
}
if v := os.Getenv("CC_SIDECAR_WATCH_DIR"); v != "" {
cfg.WatchDir = v
}
Expand All @@ -119,6 +128,9 @@ func loadConfig(path string, logger *slog.Logger) Config {
if v := os.Getenv("CC_SIDECAR_NATS_URL"); v != "" {
cfg.NATS.URL = v
}
if v := os.Getenv("CC_SIDECAR_NATS_TOKEN"); v != "" {
cfg.NATS.Token = v
}
if v := os.Getenv("CC_SIDECAR_WATCH_DIR"); v != "" {
cfg.WatchDir = v
}
Expand Down