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
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
# Claude Code Session Tracker
# AI Coding Session Tracker

A real-time dashboard for monitoring Claude Code sessions across multiple projects. See what Claude is working on, which sessions need approval, and track PR/CI status.
A real-time dashboard for monitoring AI coding sessions from **Claude Code** and **Factory Droid** across multiple projects. See what your AI assistants are working on, which sessions need approval, and track PR/CI status.

## Features

- **Multi-tool support** - Monitor both Claude Code and Factory Droid sessions
- **Real-time updates** via Durable Streams
- **Kanban board** showing sessions by status (Working, Needs Approval, Waiting, Idle)
- **AI-powered summaries** of session activity using Claude Sonnet
- **PR & CI tracking** - see associated PRs and their CI status
- **Multi-repo support** - sessions grouped by GitHub repository
- **Source identification** - Visual badges distinguish Claude (✨) from Droid (🤖) sessions

https://github.com/user-attachments/assets/877a43af-25f9-4751-88eb-24e7bbda68da

## Supported Tools

| Tool | Session Location | Status |
|------|------------------|--------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` | ✅ Full support |
| [Factory Droid](https://docs.factory.ai/cli) | `~/.factory/sessions/` | ✅ Full support |

## Architecture

```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude Code │ │ Daemon │ │ UI │
│ Sessions │────▶│ (Watcher) │────▶│ (React) │
│ ~/.claude/ │ │ │ │ │
│ projects/ │ │ Durable Stream │ │ TanStack DB │
│ Claude Code │ │ │ │ │
│ ~/.claude/ │────▶│ Daemon │ │ UI │
│ projects/ │ │ (Watcher) │────▶│ (React) │
├─────────────────┤ │ │ │ │
│ Factory Droid │────▶│ Durable Stream │ │ TanStack DB │
│ ~/.factory/ │ │ │ │ │
│ sessions/ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```

### Daemon (`packages/daemon`)

Watches `~/.claude/projects/` for session log changes and:
- Parses JSONL log files incrementally
Watches both `~/.claude/projects/` and `~/.factory/sessions/` for session log changes and:
- Parses JSONL log files incrementally (handles both Claude and Droid formats)
- Normalizes different log formats to a common internal structure
- Derives session status using XState state machine
- Generates AI summaries via Claude Sonnet API
- Detects git branches and polls for PR/CI status
Expand Down
231 changes: 230 additions & 1 deletion packages/daemon/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,22 @@ import type {
LogEntry,
SessionMetadata,
UserEntry,
isUserEntry,
SessionSource,
DroidLogEntry,
DroidSessionStartEntry,
DroidMessageEntry,
} from "./types.js";

/**
* Detect the session source based on the filepath.
*/
export function detectSource(filepath: string): SessionSource {
if (filepath.includes("/.claude/")) return "claude";
if (filepath.includes("/.factory/")) return "droid";
// Default to claude for backward compatibility
return "claude";
}

export interface TailResult {
entries: LogEntry[];
newPosition: number;
Expand Down Expand Up @@ -169,3 +182,219 @@ export function extractEncodedDir(filepath: string): string {
// The encoded dir is the second-to-last part
return parts[parts.length - 2] ?? "";
}

// ============================================================================
// Droid-specific parsing functions
// ============================================================================

export interface DroidTailResult {
entries: LogEntry[];
newPosition: number;
hadPartialLine: boolean;
droidMetadata?: {
sessionId: string;
cwd: string;
title: string;
};
}

/**
* Incrementally read new JSONL entries from a Droid session file.
* Converts Droid entries to the common LogEntry format.
*/
export async function tailDroidJSONL(
filepath: string,
fromByte: number = 0
): Promise<DroidTailResult> {
const handle = await open(filepath, "r");

try {
const fileStat = await stat(filepath);

if (fromByte >= fileStat.size) {
return { entries: [], newPosition: fromByte, hadPartialLine: false };
}

const buffer = Buffer.alloc(fileStat.size - fromByte);
await handle.read(buffer, 0, buffer.length, fromByte);

const content = buffer.toString("utf8");
const lines = content.split("\n");

const entries: LogEntry[] = [];
let bytesConsumed = 0;
let hadPartialLine = false;
let droidMetadata: DroidTailResult["droidMetadata"];

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isLastLine = i === lines.length - 1;
const lineBytes = Buffer.byteLength(line, "utf8");

// Last line might be partial if file doesn't end with newline
if (isLastLine && !content.endsWith("\n") && line.length > 0) {
hadPartialLine = true;
break;
}

// Skip empty lines
if (!line.trim()) {
bytesConsumed += lineBytes + (isLastLine ? 0 : 1);
continue;
}

// Skip lines that don't look like JSON objects
const trimmed = line.trim();
if (!trimmed.startsWith("{")) {
bytesConsumed += lineBytes + 1;
continue;
}

try {
const rawEntry = JSON.parse(line) as DroidLogEntry;

// Handle session_start - extract metadata
if (rawEntry.type === "session_start") {
const startEntry = rawEntry as DroidSessionStartEntry;
droidMetadata = {
sessionId: startEntry.id,
cwd: startEntry.cwd,
title: startEntry.title,
};
bytesConsumed += lineBytes + 1;
continue;
}

// Handle message entries - convert to common format
if (rawEntry.type === "message") {
const msgEntry = rawEntry as DroidMessageEntry;
const converted = convertDroidMessageToLogEntry(msgEntry);
if (converted) {
entries.push(converted);
}
}

// Skip todo_state and other entry types
bytesConsumed += lineBytes + 1;
} catch {
// Malformed JSON - skip silently
bytesConsumed += lineBytes + 1;
}
}

return {
entries,
newPosition: fromByte + bytesConsumed,
hadPartialLine,
droidMetadata,
};
} finally {
await handle.close();
}
}

/**
* Convert a Droid message entry to the common LogEntry format.
*/
function convertDroidMessageToLogEntry(entry: DroidMessageEntry): LogEntry | null {
const { message, id, timestamp, parentId } = entry;

if (message.role === "user") {
// Convert to UserEntry
// Droid message content is compatible with Claude's UserEntry content
const content = message.content;
const userEntry: UserEntry = {
type: "user",
parentUuid: parentId ?? null,
uuid: id,
sessionId: "", // Will be filled in from metadata
timestamp,
cwd: "", // Will be filled in from metadata
version: "",
gitBranch: "",
isSidechain: false,
userType: "external",
message: {
role: "user",
content: content as string | import("./types.js").UserContentBlock[],
},
};
return userEntry;
}

if (message.role === "assistant") {
// Convert to AssistantEntry
// Droid uses the same content block format as Claude
const content = Array.isArray(message.content) ? message.content : [];

return {
type: "assistant",
parentUuid: parentId ?? null,
uuid: id,
sessionId: "", // Will be filled in from metadata
timestamp,
cwd: "", // Will be filled in from metadata
version: "",
gitBranch: "",
isSidechain: false,
userType: "external",
requestId: "",
message: {
role: "assistant",
model: "",
id: id,
content: content as any, // Content blocks are compatible
stop_reason: null,
},
};
}

return null;
}

/**
* Extract session metadata from Droid log entries.
* Uses the session_start entry for cwd/sessionId, and first user message for prompt.
*/
export function extractDroidMetadata(
entries: LogEntry[],
droidMeta?: DroidTailResult["droidMetadata"]
): SessionMetadata | null {
if (!droidMeta) return null;

let originalPrompt: string | undefined;
let startedAt: string | undefined;

for (const entry of entries) {
if ("timestamp" in entry && entry.timestamp && !startedAt) {
startedAt = entry.timestamp;
}

// Get original prompt from first user message (not tool result)
if (entry.type === "user" && !originalPrompt) {
const content = entry.message.content;
if (typeof content === "string") {
originalPrompt =
content.length > 300 ? content.slice(0, 300) + "..." : content;
} else if (Array.isArray(content)) {
// Look for text block in content array
const textBlock = content.find((block) => block.type === "text");
if (textBlock && "text" in textBlock) {
const text = textBlock.text;
originalPrompt =
text.length > 300 ? text.slice(0, 300) + "..." : text;
}
}
}

if (originalPrompt && startedAt) break;
}

return {
sessionId: droidMeta.sessionId,
cwd: droidMeta.cwd,
gitBranch: null, // Droid doesn't store branch in session_start
originalPrompt: originalPrompt ?? droidMeta.title ?? "(no prompt found)",
startedAt: startedAt ?? new Date().toISOString(),
};
}
5 changes: 5 additions & 0 deletions packages/daemon/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { createStateSchema } from "@durable-streams/state";
export const SessionStatusSchema = z.enum(["working", "waiting", "idle"]);
export type SessionStatus = z.infer<typeof SessionStatusSchema>;

// Session source - which CLI tool created the session
export const SessionSourceSchema = z.enum(["claude", "droid"]);
export type SessionSource = z.infer<typeof SessionSourceSchema>;

// Pending tool info
export const PendingToolSchema = z.object({
tool: z.string(),
Expand Down Expand Up @@ -47,6 +51,7 @@ export const SessionSchema = z.object({
gitRepoId: z.string().nullable(),
originalPrompt: z.string(),
status: SessionStatusSchema,
source: SessionSourceSchema, // Which CLI tool created this session
lastActivityAt: z.string(), // ISO timestamp
messageCount: z.number(),
hasPendingToolUse: z.boolean(),
Expand Down
2 changes: 2 additions & 0 deletions packages/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export class StreamServer {
gitRepoId: sessionState.gitRepoId,
originalPrompt: sessionState.originalPrompt,
status: sessionState.status.status,
source: sessionState.source,
lastActivityAt: sessionState.status.lastActivityAt,
messageCount: sessionState.status.messageCount,
hasPendingToolUse: sessionState.status.hasPendingToolUse,
Expand Down Expand Up @@ -176,6 +177,7 @@ export class StreamServer {
gitRepoId: sessionState.gitRepoId,
originalPrompt: sessionState.originalPrompt,
status: sessionState.status.status,
source: sessionState.source,
lastActivityAt: sessionState.status.lastActivityAt,
messageCount: sessionState.status.messageCount,
hasPendingToolUse: sessionState.status.hasPendingToolUse,
Expand Down
48 changes: 47 additions & 1 deletion packages/daemon/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Log entry types based on actual Claude Code session logs
// Log entry types based on actual Claude Code and Droid session logs

// Session source - which CLI tool created the session
export type SessionSource = "claude" | "droid";

export type LogEntry =
| UserEntry
Expand All @@ -7,6 +10,49 @@ export type LogEntry =
| QueueOperationEntry
| FileHistorySnapshotEntry;

// Droid-specific entry types (from ~/.factory/sessions/)
export interface DroidSessionStartEntry {
type: "session_start";
id: string;
title: string;
sessionTitle?: string;
owner?: string;
version?: number;
cwd: string;
}

export interface DroidMessageEntry {
type: "message";
id: string;
timestamp: string;
message: {
role: "user" | "assistant";
content: DroidContentBlock[] | string;
};
parentId?: string;
}

export interface DroidTodoStateEntry {
type: "todo_state";
id: string;
timestamp: string;
todos: {
todos: string;
};
messageIndex: number;
}

export type DroidContentBlock =
| TextBlock
| ToolUseBlock
| ToolResultBlock
| ThinkingBlock;

export type DroidLogEntry =
| DroidSessionStartEntry
| DroidMessageEntry
| DroidTodoStateEntry;

// Common fields on message entries
export interface BaseMessageEntry {
parentUuid: string | null;
Expand Down
Loading