From 347f6499f787bb80a620340a63266cb88171dd0c Mon Sep 17 00:00:00 2001 From: t2o2 Date: Thu, 15 Jan 2026 21:47:59 +0000 Subject: [PATCH 1/2] Add Droid (Factory CLI) session monitoring support - Add SessionSource type to distinguish between Claude and Droid sessions - Add Droid-specific log entry types and parser functions - Watch both ~/.claude/projects/ and ~/.factory/sessions/ directories - Normalize Droid entries to common LogEntry format for unified processing - Add source badge (Claude/Droid) to session cards in UI - Update schemas and mock data to include source field Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/daemon/src/parser.ts | 231 ++++++++++++++++++++- packages/daemon/src/schema.ts | 5 + packages/daemon/src/server.ts | 2 + packages/daemon/src/types.ts | 48 ++++- packages/daemon/src/watcher.ts | 118 ++++++++--- packages/ui/src/components/SessionCard.tsx | 27 ++- packages/ui/src/data/mockSessions.ts | 12 +- packages/ui/src/data/schema.ts | 5 + packages/ui/src/data/types.ts | 1 + 9 files changed, 415 insertions(+), 34 deletions(-) diff --git a/packages/daemon/src/parser.ts b/packages/daemon/src/parser.ts index f25ad60..053332a 100644 --- a/packages/daemon/src/parser.ts +++ b/packages/daemon/src/parser.ts @@ -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; @@ -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 { + 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(), + }; +} diff --git a/packages/daemon/src/schema.ts b/packages/daemon/src/schema.ts index 3853d00..f8cc113 100644 --- a/packages/daemon/src/schema.ts +++ b/packages/daemon/src/schema.ts @@ -5,6 +5,10 @@ import { createStateSchema } from "@durable-streams/state"; export const SessionStatusSchema = z.enum(["working", "waiting", "idle"]); export type SessionStatus = z.infer; +// Session source - which CLI tool created the session +export const SessionSourceSchema = z.enum(["claude", "droid"]); +export type SessionSource = z.infer; + // Pending tool info export const PendingToolSchema = z.object({ tool: z.string(), @@ -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(), diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index ee6101d..d596779 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -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, @@ -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, diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index d6aa445..3eed8a5 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -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 @@ -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; diff --git a/packages/daemon/src/watcher.ts b/packages/daemon/src/watcher.ts index ac61a6d..959670b 100644 --- a/packages/daemon/src/watcher.ts +++ b/packages/daemon/src/watcher.ts @@ -4,17 +4,22 @@ import { readFile, unlink, readdir } from "node:fs/promises"; import { join } from "node:path"; import { tailJSONL, + tailDroidJSONL, extractMetadata, + extractDroidMetadata, extractSessionId, extractEncodedDir, + detectSource, + type DroidTailResult, } from "./parser.js"; import { deriveStatus, statusChanged } from "./status.js"; import { getGitInfoCached, type GitInfo } from "./git.js"; -import type { LogEntry, SessionMetadata, StatusResult } from "./types.js"; +import type { LogEntry, SessionMetadata, StatusResult, SessionSource } from "./types.js"; import { log } from "./log.js"; const CLAUDE_PROJECTS_DIR = `${process.env.HOME}/.claude/projects`; -const SIGNALS_DIR = `${process.env.HOME}/.claude/session-signals`; +const CLAUDE_SIGNALS_DIR = `${process.env.HOME}/.claude/session-signals`; +const DROID_SESSIONS_DIR = `${process.env.HOME}/.factory/sessions`; export interface PendingPermission { session_id: string; @@ -49,6 +54,8 @@ export interface SessionState { status: StatusResult; entries: LogEntry[]; bytePosition: number; + // Session source - which CLI tool created this session + source: SessionSource; // GitHub repo info gitRepoUrl: string | null; // https://github.com/owner/repo gitRepoId: string | null; // owner/repo (for grouping) @@ -62,6 +69,8 @@ export interface SessionState { hasStopSignal?: boolean; // True when SessionEnd hook has fired (session closed) hasEndedSignal?: boolean; + // Droid-specific metadata (cached from session_start entry) + droidMetadata?: DroidTailResult["droidMetadata"]; } export interface SessionEvent { @@ -71,7 +80,8 @@ export interface SessionEvent { } export class SessionWatcher extends EventEmitter { - private watcher: FSWatcher | null = null; + private claudeWatcher: FSWatcher | null = null; + private droidWatcher: FSWatcher | null = null; private signalWatcher: FSWatcher | null = null; private sessions = new Map(); private pendingPermissions = new Map(); @@ -123,19 +133,20 @@ export class SessionWatcher extends EventEmitter { } async start(): Promise { + // Watch Claude Code sessions directory // Use directory watching instead of glob - chokidar has issues with // directories that start with dashes when using glob patterns - this.watcher = watch(CLAUDE_PROJECTS_DIR, { + this.claudeWatcher = watch(CLAUDE_PROJECTS_DIR, { ignored: /agent-.*\.jsonl$/, // Ignore agent sub-session files persistent: true, ignoreInitial: false, depth: 2, }); - this.watcher + this.claudeWatcher .on("add", (path) => { if (!path.endsWith(".jsonl")) return; - log("Watcher", `New file detected: ${path.split("/").slice(-2).join("/")}`); + log("Watcher", `[Claude] New file detected: ${path.split("/").slice(-2).join("/")}`); this.handleFile(path, "add"); }) .on("change", (path) => { @@ -145,8 +156,32 @@ export class SessionWatcher extends EventEmitter { .on("unlink", (path) => this.handleDelete(path)) .on("error", (error) => this.emit("error", error)); + // Watch Droid (Factory) sessions directory + this.droidWatcher = watch(DROID_SESSIONS_DIR, { + ignored: [/agent-.*\.jsonl$/, /\.settings\.json$/], // Ignore agent sub-sessions and settings files + persistent: true, + ignoreInitial: false, + depth: 2, + }); + + this.droidWatcher + .on("add", (path) => { + if (!path.endsWith(".jsonl")) return; + log("Watcher", `[Droid] New file detected: ${path.split("/").slice(-2).join("/")}`); + this.handleFile(path, "add"); + }) + .on("change", (path) => { + if (!path.endsWith(".jsonl")) return; + this.debouncedHandleFile(path); + }) + .on("unlink", (path) => this.handleDelete(path)) + .on("error", () => { + // Ignore errors - directory may not exist if Droid isn't installed + }); + // Watch signals directory for hook output (permission, stop, session-end) - this.signalWatcher = watch(SIGNALS_DIR, { + // Currently only Claude Code uses this, Droid may have its own mechanism + this.signalWatcher = watch(CLAUDE_SIGNALS_DIR, { persistent: true, ignoreInitial: false, depth: 0, @@ -169,10 +204,15 @@ export class SessionWatcher extends EventEmitter { // Ignore errors - directory may not exist if hooks aren't set up }); - // Wait for initial scan to complete - await new Promise((resolve) => { - this.watcher!.on("ready", resolve); - }); + // Wait for initial scan to complete for both watchers + await Promise.all([ + new Promise((resolve) => { + this.claudeWatcher!.on("ready", resolve); + }), + new Promise((resolve) => { + this.droidWatcher!.on("ready", resolve); + }), + ]); // Load any existing signal files await this.loadExistingSignals(); @@ -189,10 +229,10 @@ export class SessionWatcher extends EventEmitter { */ private async loadExistingSignals(): Promise { try { - const files = await readdir(SIGNALS_DIR); + const files = await readdir(CLAUDE_SIGNALS_DIR); for (const file of files) { if (file.endsWith(".json")) { - await this.handleSignalFile(join(SIGNALS_DIR, file)); + await this.handleSignalFile(join(CLAUDE_SIGNALS_DIR, file)); } } } catch { @@ -354,9 +394,14 @@ export class SessionWatcher extends EventEmitter { } stop(): void { - if (this.watcher) { - this.watcher.close(); - this.watcher = null; + if (this.claudeWatcher) { + this.claudeWatcher.close(); + this.claudeWatcher = null; + } + + if (this.droidWatcher) { + this.droidWatcher.close(); + this.droidWatcher = null; } if (this.signalWatcher) { @@ -387,7 +432,7 @@ export class SessionWatcher extends EventEmitter { // Try to delete the file try { - await unlink(join(SIGNALS_DIR, `${sessionId}.permission.json`)); + await unlink(join(CLAUDE_SIGNALS_DIR, `${sessionId}.permission.json`)); } catch { // File may already be deleted } @@ -402,7 +447,7 @@ export class SessionWatcher extends EventEmitter { this.stopSignals.delete(sessionId); try { - await unlink(join(SIGNALS_DIR, `${sessionId}.stop.json`)); + await unlink(join(CLAUDE_SIGNALS_DIR, `${sessionId}.stop.json`)); } catch { // File may already be deleted } @@ -464,17 +509,28 @@ export class SessionWatcher extends EventEmitter { try { const sessionId = extractSessionId(filepath); const existingSession = this.sessions.get(sessionId); + const source = detectSource(filepath); // Determine starting byte position const fromByte = existingSession?.bytePosition ?? 0; - // Read new entries - const { entries: newEntries, newPosition } = await tailJSONL( - filepath, - fromByte - ); + // Read new entries - use source-specific parser + let newEntries: LogEntry[]; + let newPosition: number; + let droidMetadata: DroidTailResult["droidMetadata"] | undefined; + + if (source === "droid") { + const result = await tailDroidJSONL(filepath, fromByte); + newEntries = result.entries; + newPosition = result.newPosition; + droidMetadata = result.droidMetadata ?? existingSession?.droidMetadata; + } else { + const result = await tailJSONL(filepath, fromByte); + newEntries = result.entries; + newPosition = result.newPosition; + } - if (newEntries.length === 0 && existingSession) { + if (newEntries.length === 0 && existingSession && !droidMetadata) { // No new data return; } @@ -504,9 +560,14 @@ export class SessionWatcher extends EventEmitter { isGitRepo: existingSession.gitRepoUrl !== null || existingSession.gitBranch !== null, }; } else { - metadata = extractMetadata(allEntries); - if (!metadata) { - // Not enough data yet + // Use source-specific metadata extraction + if (source === "droid") { + metadata = extractDroidMetadata(allEntries, droidMetadata); + } else { + metadata = extractMetadata(allEntries); + } + if (!metadata || !metadata.cwd) { + // Not enough data yet - either no metadata or cwd is missing return; } // Look up git info for new sessions @@ -558,6 +619,7 @@ export class SessionWatcher extends EventEmitter { const previousStatus = existingSession?.status; // Hook signals are authoritative for status - override JSONL-derived status + // Note: Currently only Claude Code uses signal files, Droid sessions use JSONL-derived status const pendingPermission = this.pendingPermissions.get(sessionId); const hasWorkingSig = this.workingSignals.has(sessionId); const hasStopSig = this.stopSignals.has(sessionId); @@ -590,6 +652,7 @@ export class SessionWatcher extends EventEmitter { status, entries: allEntries, bytePosition: newPosition, + source, gitRepoUrl: gitInfo.repoUrl, gitRepoId: gitInfo.repoId, branchChanged, @@ -597,6 +660,7 @@ export class SessionWatcher extends EventEmitter { hasWorkingSignal: hasWorkingSig, hasStopSignal: hasStopSig, hasEndedSignal: hasEndedSig, + droidMetadata, }; // Store session diff --git a/packages/ui/src/components/SessionCard.tsx b/packages/ui/src/components/SessionCard.tsx index 329a5da..1fcc5e1 100644 --- a/packages/ui/src/components/SessionCard.tsx +++ b/packages/ui/src/components/SessionCard.tsx @@ -110,6 +110,22 @@ function getCIStatusColor(status: CIStatus): "green" | "red" | "yellow" | "gray" } } +function getSourceBadge(source: Session["source"]) { + if (source === "droid") { + return ( + + πŸ€– Droid + + ); + } + // Claude is default, show a subtle indicator + return ( + + ✨ Claude + + ); +} + export function SessionCard({ session, disableHover }: SessionCardProps) { const showPendingTool = session.hasPendingToolUse && session.pendingTool; // Show path from ~ (e.g., ~/programs/project) @@ -120,11 +136,14 @@ export function SessionCard({ session, disableHover }: SessionCardProps) { - {/* Header: directory and time */} + {/* Header: directory, source, and time */} - - {dirPath} - + + + {dirPath} + + {getSourceBadge(session.source)} + {formatTimeAgo(session.lastActivityAt)} diff --git a/packages/ui/src/data/mockSessions.ts b/packages/ui/src/data/mockSessions.ts index 974259b..395a9db 100644 --- a/packages/ui/src/data/mockSessions.ts +++ b/packages/ui/src/data/mockSessions.ts @@ -1,4 +1,4 @@ -import type { SessionStatus } from "./types"; +import type { SessionStatus, SessionSource } from "./types"; export interface PendingTool { tool: "Edit" | "Write" | "Bash" | "Read" | "Grep" | "MultiEdit"; @@ -11,6 +11,7 @@ export interface MockSession { gitBranch: string | null; originalPrompt: string; status: SessionStatus; + source: SessionSource; lastActivityAt: string; messageCount: number; hasPendingToolUse: boolean; @@ -32,6 +33,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "main", originalPrompt: "Scaffold the UI with Vite and TanStack Router", status: "working", + source: "claude", lastActivityAt: new Date(now - 15 * 1000).toISOString(), messageCount: 12, hasPendingToolUse: false, @@ -46,6 +48,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "feature/radix-support", originalPrompt: "Add support for Radix UI themes integration", status: "working", + source: "droid", lastActivityAt: new Date(now - 8 * 1000).toISOString(), messageCount: 5, hasPendingToolUse: false, @@ -62,6 +65,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "fix/streaming", originalPrompt: "Fix the streaming response handler to properly chunk data", status: "waiting", + source: "claude", lastActivityAt: new Date(now - 2 * minute).toISOString(), messageCount: 8, hasPendingToolUse: true, @@ -76,6 +80,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "feat/kanban", originalPrompt: "Create the Kanban board component with drag and drop", status: "waiting", + source: "droid", lastActivityAt: new Date(now - 5 * minute).toISOString(), messageCount: 15, hasPendingToolUse: true, @@ -92,6 +97,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "main", originalPrompt: "Implement the HTTP endpoint for stream subscriptions", status: "waiting", + source: "claude", lastActivityAt: new Date(now - 3 * minute).toISOString(), messageCount: 22, hasPendingToolUse: false, @@ -108,6 +114,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "experiment/old", originalPrompt: "Experiment with different state management approaches", status: "idle", + source: "claude", lastActivityAt: new Date(now - 2 * hour).toISOString(), messageCount: 45, hasPendingToolUse: false, @@ -122,6 +129,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "post/ai-coding", originalPrompt: "Write a blog post about AI-assisted coding workflows", status: "idle", + source: "droid", lastActivityAt: new Date(now - 6 * hour).toISOString(), messageCount: 30, hasPendingToolUse: false, @@ -138,6 +146,7 @@ export const mockSessions: MockSession[] = [ gitBranch: null, originalPrompt: "Help me organize my project notes", status: "waiting", + source: "claude", lastActivityAt: new Date(now - 10 * minute).toISOString(), messageCount: 8, hasPendingToolUse: false, @@ -152,6 +161,7 @@ export const mockSessions: MockSession[] = [ gitBranch: "main", originalPrompt: "Create a bash script to clean up docker images", status: "idle", + source: "droid", lastActivityAt: new Date(now - 1 * hour).toISOString(), messageCount: 4, hasPendingToolUse: false, diff --git a/packages/ui/src/data/schema.ts b/packages/ui/src/data/schema.ts index 566581a..8ecc857 100644 --- a/packages/ui/src/data/schema.ts +++ b/packages/ui/src/data/schema.ts @@ -5,6 +5,10 @@ import { createStateSchema } from "@durable-streams/state"; export const SessionStatusSchema = z.enum(["working", "waiting", "idle"]); export type SessionStatus = z.infer; +// Session source - which CLI tool created the session +export const SessionSourceSchema = z.enum(["claude", "droid"]); +export type SessionSource = z.infer; + // Pending tool info export const PendingToolSchema = z.object({ tool: z.string(), @@ -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(), diff --git a/packages/ui/src/data/types.ts b/packages/ui/src/data/types.ts index 14a4519..bae2cf5 100644 --- a/packages/ui/src/data/types.ts +++ b/packages/ui/src/data/types.ts @@ -1 +1,2 @@ export type SessionStatus = "working" | "waiting" | "idle"; +export type SessionSource = "claude" | "droid"; From cc2c2c04d1c94a4da9a17291d23c75f9ee22bc29 Mon Sep 17 00:00:00 2001 From: t2o2 Date: Thu, 15 Jan 2026 21:48:34 +0000 Subject: [PATCH 2/2] Update README for multi-tool support (Claude Code + Factory Droid) - Rename to 'AI Coding Session Tracker' - Add supported tools table with links to documentation - Update architecture diagram to show both session sources - Document new features: multi-tool support and source badges Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2d1f05c..3fad532 100644 --- a/README.md +++ b/README.md @@ -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