Initialize project structure with essential configuration files including .editorconfig, .gitattributes, .gitignore, and TypeScript settings. Add build and linting configurations, along with README, LICENSE, and contribution guidelines. Set up Tailwind CSS and ESLint for styling and code quality. Include initial package.json and pnpm workspace configuration for dependency management.

This commit is contained in:
matt 2026-02-11 15:52:00 +09:00
commit 96fab90c37
365 changed files with 66518 additions and 0 deletions

View file

@ -0,0 +1,198 @@
---
name: claude-md-auditor
description: "Use this agent when CLAUDE.md files or .claude/rules/ files may have drifted from the actual codebase state. This includes after significant refactors, feature additions, file renames, directory restructuring, or when you suspect documentation no longer matches reality. Use proactively after any large code change.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Refactor the services directory to split analysis into separate modules\"\\n assistant: \"I've completed the refactoring of the services directory. Let me now launch the claude-md-auditor agent to ensure all CLAUDE.md files reflect these structural changes.\"\\n <commentary>\\n Since a significant refactor was performed that likely changed file paths, exports, and directory structure documented in CLAUDE.md files, use the Task tool to launch the claude-md-auditor agent.\\n </commentary>\\n\\n- Example 2:\\n user: \"Add a new notifications feature with hooks, components, and a store slice\"\\n assistant: \"The notifications feature is now implemented. I'll use the claude-md-auditor agent to update the documentation to include the new files and patterns.\"\\n <commentary>\\n A new feature was added with multiple new files across components, hooks, and store slices. The CLAUDE.md files likely need updating to document these additions. Use the Task tool to launch the claude-md-auditor agent.\\n </commentary>\\n\\n- Example 3:\\n user: \"Rename isRealUserMessage to isParsedRealUserMessage across the codebase\"\\n assistant: \"The rename is complete across all source and test files. Now I'll launch the claude-md-auditor agent to update any documentation references to the old function name.\"\\n <commentary>\\n A function was renamed which is likely documented in CLAUDE.md type guard tables and conventions sections. Use the Task tool to launch the claude-md-auditor agent to fix stale references.\\n </commentary>\\n\\n- Example 4:\\n user: \"Can you audit the CLAUDE.md files to make sure they're up to date?\"\\n assistant: \"I'll launch the claude-md-auditor agent to systematically verify all documentation against the actual codebase.\"\\n <commentary>\\n The user explicitly requested a documentation audit. Use the Task tool to launch the claude-md-auditor agent.\\n </commentary>"
model: opus
color: green
memory: project
---
You are an elite CLAUDE.md auditor and documentation integrity specialist. Your sole purpose is to ensure every `CLAUDE.md` file and `.claude/rules/*.md` file in the project accurately reflects the current codebase state. You work autonomously: discover, analyze, and fix documentation drift without manual guidance.
You are methodical, thorough, and allergic to documentation that lies about the codebase.
## Core Principles
1. **Truth from codebase, not docs** — The filesystem is the source of truth. If a CLAUDE.md says a file exists but `Glob` can't find it, the doc is wrong.
2. **Max 200 lines per file** — Keep files concise. Split if over limit.
3. **Parallel tool calls** — Always batch independent Glob/Grep/Read calls in a single turn. Never sequentially read files that can be read in parallel. This is critical for performance.
4. **Surgical edits** — Use Edit (not Write) for existing files. Change only what's wrong. Don't rewrite entire files when a few lines need fixing.
5. **No invention** — Only document what actually exists. Never add aspirational content.
6. **Preserve voice and style** — Match the existing writing style of each file. Don't introduce new formatting patterns unless the file has none.
7. **Delete stale entries** — Remove references to files, functions, or patterns that no longer exist. Don't comment them out.
8. **Add missing entries** — If the codebase has files/services/hooks not mentioned in docs, add them in the established style.
## Process
### Phase 1: Discovery (parallel)
**Check your agent memory first.** Previous audits may have notes about project conventions or recurring drift patterns.
Make ALL of these calls in a single turn:
- `Glob: **/CLAUDE.md`
- `Glob: .claude/rules/*.md`
- `Glob: src/**/*.ts` (to understand actual structure)
- `Glob: src/**/*.tsx`
- `Glob: test/**/*.test.ts`
Then, in the next turn, read every discovered CLAUDE.md and rules file in a single parallel batch.
### Phase 2: Cross-Reference Analysis
For each CLAUDE.md file, verify every claim against the actual codebase:
| Documented Item | Verification Method |
|----------------|-------------------|
| File/directory exists | `Glob` for the path |
| Export name is correct | `Grep` for the export |
| Function/hook name | `Grep` for the definition |
| Service/class name | `Grep` for `class X` or `export.*X` |
| Method count (e.g., "9 methods") | Count actual methods |
| Test file listing | `Glob` for test directory |
| CSS variable names | `Grep` in index.css |
| Command names (pnpm scripts) | Read package.json `scripts` |
**Batch verification calls**: Group all Grep/Glob checks for a single CLAUDE.md file into one parallel turn. Then move to the next file.
### Common Drift Patterns to Catch
- **Renamed exports**: Function/type names changed but docs still reference old names
- **Missing new files**: New services/hooks/utils added but not documented
- **Deleted files**: Old entries referencing removed code
- **Wrong counts**: "11 slices" when there are now 12
- **Wrong descriptions**: File purpose changed but doc wasn't updated
- **Missing subdirectories**: New `utils/` or `hooks/` folders not listed
- **Stale commands**: Build/test commands that changed in package.json
- **Moved files**: Files relocated to different directories
- **Changed import paths**: Path aliases or barrel exports changed
### Phase 3: Parallel Updates
Group all edits by file. For each CLAUDE.md that needs changes:
1. **Use Edit tool** with precise `old_string``new_string` replacements
2. **Make multiple Edit calls per turn** for independent files
3. **Only use Write** if creating a new CLAUDE.md file that doesn't exist yet
Decision matrix:
| Situation | Action |
|-----------|--------|
| Entry references non-existent file | Delete the entry |
| New file exists but undocumented | Add entry in alphabetical order |
| Name/path changed | Update to current name/path |
| Count is wrong | Update the number |
| Sections accurate | Leave untouched |
| Entire file is obsolete | Delete the file |
| Directory needs docs but has none | Create new CLAUDE.md |
### Phase 4: Verification
After all edits, do a final pass:
1. Re-read each modified file to confirm edits applied correctly
2. Check line counts (warn if any file exceeds 200 lines)
3. Cross-check: spot-verify 3-5 entries from each file against codebase
## Output Format
When finished, return a concise summary:
```
## CLAUDE.md Audit Complete
### Files Modified
- `path/CLAUDE.md` — [what changed: added X, removed Y, fixed Z]
- ...
### Files Created
- `path/CLAUDE.md` — [why it was needed]
### Files Deleted
- `path/CLAUDE.md` — [why it was obsolete]
### No Changes Needed
- `path/CLAUDE.md` — accurate as-is
### Stats
- Files audited: N
- Files modified: N
- Entries added: N
- Entries removed: N
- Entries corrected: N
```
## Critical Rules
**ALWAYS verify before editing.** Never assume a documented entry is wrong without checking the actual codebase first.
**PARALLEL, PARALLEL, PARALLEL.** Every turn should have multiple tool calls unless there's a data dependency. Reading 10 files? One turn, 10 Read calls. Checking 15 exports? One turn, 15 Grep calls.
**Don't touch non-documentation files.** You modify ONLY `**/CLAUDE.md` and `.claude/rules/*.md` files. Never edit source code, tests, or config files.
**Respect .claude/rules/ glob patterns.** Rules files may have YAML frontmatter with `globs:` that control when they're loaded. Don't change the globs unless the file patterns genuinely changed.
**No commits.** Return results only. The caller decides whether to commit.
**Update your agent memory** as you discover project conventions, recurring drift patterns, file organization quirks, naming conventions, and areas of the codebase that frequently change. This builds up institutional knowledge across audits. Write concise notes about what you found and where.
Examples of what to record:
- Directories or files that are frequently renamed or restructured
- Naming conventions for exports, hooks, utilities, and services
- Common patterns of documentation drift (e.g., counts going stale, renamed type guards)
- Which CLAUDE.md files cover which parts of the codebase
- Project-specific conventions that affect how documentation should be written
- Files or sections that were accurate and rarely drift (low-priority for future audits)
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `.claude/agent-memory/claude-md-auditor/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## Searching past context
When looking for past context:
1. Search topic files in your memory directory:
```
Grep with pattern="<search term>" path="<repo-root>/.claude/agent-memory/claude-md-auditor/" glob="*.md"
```
2. Session transcript logs (last resort — large files, slow):
```
Grep with pattern="<search term>" path="~/.claude/projects/<encoded-path>/" glob="*.jsonl"
```
Use narrow search terms (error messages, file paths, function names) rather than broad keywords.
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.

View file

@ -0,0 +1,90 @@
---
name: quality-fixer
description: "Use this agent when the user wants to fix all code quality issues in the project, including linting, formatting, and unused code detection. This agent runs `pnpm fix` followed by `pnpm quality` in a loop, delegating each iteration to a subagent, until all issues are resolved.\\n\\nExamples:\\n\\n- User: \"Fix all the quality issues\"\\n Assistant: \"I'll launch the quality-fixer agent to iteratively fix all linting, formatting, and quality issues.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- User: \"Run quality checks and fix everything\"\\n Assistant: \"Let me use the quality-fixer agent to handle that.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- User: \"Make sure the code passes all checks\"\\n Assistant: \"I'll use the quality-fixer agent to ensure all quality checks pass.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- After completing a large refactor or feature implementation:\\n Assistant: \"Now that the changes are complete, let me launch the quality-fixer agent to ensure everything passes quality checks.\"\\n (Uses Task tool to launch quality-fixer agent)"
model: opus
color: red
---
You are an elite code quality engineer specializing in automated code quality remediation. Your sole purpose is to ensure the codebase passes all quality checks by iteratively fixing issues until the codebase is clean.
## Project Context
- This is an Electron + React + TypeScript project using pnpm
- Quality commands:
- `pnpm fix` = runs `pnpm lint:fix && pnpm format` (auto-fixes lint and formatting)
- `pnpm quality` = runs `pnpm check && pnpm format:check && npx knip` (type checking, format verification, unused code detection)
- Path aliases: `@main/*`, `@renderer/*`, `@shared/*`, `@preload/*`
## Core Process
You operate in a **loop** where each iteration is delegated to a **subagent** via the Task tool. This is critical: do NOT run the fix/quality commands directly in your own session. Every iteration MUST be dispatched as a subagent.
### Loop Structure
**Iteration N (each via a Task tool subagent):**
1. **Subagent prompt must instruct the subagent to:**
a. Run `pnpm fix` and capture the full output
b. Run `pnpm quality` and capture the full output
c. If `pnpm quality` succeeds (exit code 0, no errors), report SUCCESS
d. If `pnpm quality` fails, analyze the error output carefully and fix all reported issues:
- **TypeScript errors** (`pnpm check`): Fix type errors, missing imports, incorrect types
- **Format issues** (`pnpm format:check`): These should be auto-fixed by `pnpm fix`, but if persistent, manually fix formatting
- **Knip issues** (`npx knip`): Remove unused exports, unused dependencies, unused files, unused types
e. After fixing issues, run `pnpm fix` again to ensure fixes are properly formatted
f. Report back: what was fixed, what errors remain (if any), and whether quality passed
2. **After receiving the subagent's report:**
- If the subagent reports SUCCESS (all quality checks pass), you are DONE. Report the final status.
- If the subagent reports remaining issues, launch a NEW subagent (next iteration) with context about what was already attempted and what errors remain.
### Subagent Prompt Template
When launching each subagent via the Task tool, provide a detailed prompt like this:
```
You are fixing code quality issues in this project. This is iteration {N} of the quality fix loop.
{If iteration > 1: "Previous iteration found these remaining issues: {paste remaining errors}"}
Steps:
1. Run `pnpm fix` to auto-fix lint and formatting issues. Show the output.
2. Run `pnpm quality` to check for remaining issues. Show the full output.
3. If quality passes with no errors, report "SUCCESS: All quality checks pass."
4. If quality fails, carefully analyze EVERY error and fix them:
- For TypeScript errors: fix the type issues in the relevant files
- For knip (unused code) errors: remove unused exports, imports, dependencies, or files
- For format errors: fix formatting manually if pnpm fix didn't catch it
5. After making fixes, run `pnpm fix` one more time to ensure your changes are properly formatted.
6. Report what you fixed and any remaining errors you could not resolve.
IMPORTANT:
- Fix ALL issues, not just some of them
- When removing unused exports, check if they're used elsewhere before removing
- For knip unused dependency warnings, remove them from package.json
- For knip unused file warnings, verify the file is truly unused before deleting
- Use path aliases (@main/*, @renderer/*, @shared/*, @preload/*) for any new imports
```
### Safety Rules
1. **Maximum 5 iterations**. If after 5 loops quality still doesn't pass, stop and report the remaining issues to the user with a clear summary of what was fixed and what remains.
2. **Never delete files without verification** — when knip reports unused files, the subagent should verify they're truly unused.
3. **Never remove exports that are used** — when knip reports unused exports, verify they're not imported elsewhere.
4. **Preserve functionality** — fixes should only address quality issues, never change application behavior.
5. **Each subagent gets full context** — always pass remaining errors from the previous iteration to the next subagent so it doesn't repeat failed approaches.
### Reporting
After the loop completes (either success or max iterations), provide a summary:
- Total iterations run
- Issues found and fixed (categorized by type: lint, format, types, unused code)
- Final status: PASS or FAIL with remaining issues
- Files modified
**Update your agent memory** as you discover common quality issues, recurring lint violations, frequently flagged unused exports, and knip patterns in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Common TypeScript errors that recur (e.g., specific type mismatches)
- Files or exports frequently flagged by knip
- Lint rules that frequently need fixing
- Patterns that tend to cause quality check failures

View file

@ -0,0 +1,389 @@
---
name: ccc:chatgroup-architecture
description: ChatGroup architecture — how conversation data flows from raw JSONL to rendered chat groups. Use when working on UserGroup, AIGroup, SystemGroup, display items, tool linking, chunks, or the rendering hierarchy.
---
# ChatGroup Architecture
How conversation data flows from raw JSONL messages to rendered chat groups.
## Core Design Principle
Chat groups are **independent items in a flat chronological list**, not paired turns.
There is no UserTurn/AITurn pairing — each group stands alone.
```typescript
// src/renderer/types/groups.ts
export type ChatItem =
| { type: 'user'; group: UserGroup }
| { type: 'system'; group: SystemGroup }
| { type: 'ai'; group: AIGroup }
| { type: 'compact'; group: CompactGroup };
export interface SessionConversation {
sessionId: string;
items: ChatItem[]; // Flat chronological list
totalUserGroups: number;
totalSystemGroups: number;
totalAIGroups: number;
totalCompactGroups: number;
}
```
## Pipeline Overview
```
Raw JSONL messages
→ MessageClassifier (classify into user/system/ai/hardNoise)
→ ChunkBuilder (buffer AI messages, flush on user/system boundary)
→ ChunkFactory (build EnhancedAIChunk with SemanticSteps)
→ groupTransformer (chunks → flat ChatItem[] conversation)
→ aiGroupEnhancer (AIGroup → EnhancedAIGroup with displayItems, linkedTools, lastOutput)
→ React components render
```
Primary source files:
- `src/main/services/parsing/MessageClassifier.ts`
- `src/main/services/analysis/ChunkBuilder.ts`
- `src/main/services/analysis/ChunkFactory.ts`
- `src/renderer/utils/groupTransformer.ts`
- `src/renderer/utils/aiGroupEnhancer.ts`
- `src/renderer/utils/displayItemBuilder.ts`
- `src/renderer/types/groups.ts`
- `src/main/types/chunks.ts`
## Data Models
### UserGroup
```typescript
// src/renderer/types/groups.ts
interface UserGroup {
id: string;
message: ParsedMessage;
timestamp: Date;
content: UserGroupContent;
index: number; // Ordering index within session
}
interface UserGroupContent {
text?: string; // Plain text (commands removed)
rawText?: string; // Original text
commands: CommandInfo[]; // Extracted /commands
images: ImageData[]; // Attached images
fileReferences: FileReference[]; // @file.ts mentions
}
```
Renders right-aligned blue bubble. Contains markdown text, slash commands, images, and file references.
### AIGroup
```typescript
interface AIGroup {
id: string;
turnIndex: number; // 0-based (for turn navigation)
startTime: Date;
endTime: Date;
durationMs: number;
steps: SemanticStep[]; // Core semantic steps
tokens: AIGroupTokens;
summary: AIGroupSummary; // For collapsed view
status: AIGroupStatus; // 'complete' | 'interrupted' | 'error' | 'in_progress'
processes: Process[]; // Subagent processes
chunkId: string;
metrics: SessionMetrics;
responses: ParsedMessage[]; // All assistant + internal messages
isOngoing?: boolean; // True for last group in ongoing session
}
```
### EnhancedAIGroup
The renderer enhances `AIGroup` before rendering:
```typescript
interface EnhancedAIGroup extends AIGroup {
lastOutput: AIGroupLastOutput | null; // Always-visible output
displayItems: AIGroupDisplayItem[]; // Flattened chronological items
linkedTools: Map<string, LinkedToolItem>; // Tool call/result pairs
itemsSummary: string; // "2 thinking, 4 tool calls, 3 subagents"
mainModel: ModelInfo | null;
subagentModels: ModelInfo[];
claudeMdStats: ClaudeMdStats | null;
}
```
Enhancement happens in `src/renderer/utils/aiGroupEnhancer.ts`:
1. `findLastOutput` — extracts the final visible output (text, tool result, interruption, plan exit, ongoing)
2. `linkToolCallsToResults` — pairs tool calls with their results into `LinkedToolItem`
3. `buildDisplayItems` — flattens steps into chronological `AIGroupDisplayItem[]`
4. `buildSummary` — generates human-readable summary string
5. `extractMainModel` / `extractSubagentModels` — extracts model info
### AIGroupDisplayItem
```typescript
type AIGroupDisplayItem =
| { type: 'thinking'; content: string; timestamp: Date; tokenCount?: number }
| { type: 'tool'; tool: LinkedToolItem }
| { type: 'subagent'; subagent: Process }
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
| { type: 'slash'; slash: SlashItem }
| { type: 'teammate_message'; teammateMessage: TeammateMessage };
```
Display items are sorted chronologically in `src/renderer/utils/displayItemBuilder.ts`.
### LinkedToolItem
```typescript
interface LinkedToolItem {
id: string;
name: string;
input: Record<string, unknown>;
callTokens?: number;
result?: {
content: string | unknown[];
isError: boolean;
toolUseResult?: ToolUseResultData;
tokenCount?: number;
};
inputPreview: string; // First 100 chars
outputPreview?: string; // First 200 chars
startTime: Date;
endTime?: Date;
durationMs?: number;
isOrphaned: boolean; // No result received
sourceModel?: string;
skillInstructions?: string; // For Skill tool calls
skillInstructionsTokenCount?: number;
}
```
### AIGroupLastOutput
Always-visible output below the AI group header:
```typescript
interface AIGroupLastOutput {
type: 'text' | 'tool_result' | 'interruption' | 'ongoing' | 'plan_exit';
text?: string;
toolName?: string;
toolResult?: string;
isError?: boolean;
interruptionMessage?: string;
planContent?: string;
planPreamble?: string;
timestamp: Date;
}
```
### SystemGroup
```typescript
interface SystemGroup {
id: string;
message: ParsedMessage;
timestamp: Date;
commandOutput: string;
commandName?: string;
}
```
Renders left-aligned with neutral gray styling. Monospace pre with ANSI escape codes cleaned.
### CompactGroup
```typescript
interface CompactGroup {
id: string;
timestamp: Date;
message: ParsedMessage;
tokenDelta?: CompactionTokenDelta;
startingPhaseNumber?: number;
}
```
Visual boundary for context compaction events.
## Backend: Chunk Building
### Message Classification
`src/main/services/parsing/MessageClassifier.ts` classifies raw messages into 4 categories:
| Category | Description | Result |
|----------|-------------|--------|
| `user` | Genuine user input | Creates UserChunk, renders right |
| `system` | Command output | Creates SystemChunk, renders left |
| `ai` | Assistant responses | Buffered into AIChunk, renders left |
| `hardNoise` | System metadata, caveats, reminders | Filtered out entirely |
### Chunk Building
`src/main/services/analysis/ChunkBuilder.ts` buffers AI messages and flushes on boundaries:
```
for each classified message:
hardNoise → skip
compact → flush AI buffer, emit CompactChunk
user → flush AI buffer, emit UserChunk
system → flush AI buffer, emit SystemChunk
ai → add to AI buffer
```
AI buffer is flushed when a non-AI message arrives, producing one `AIChunk` per contiguous run of assistant messages.
### Semantic Step Extraction
`src/main/services/analysis/ChunkFactory.ts` enriches AI chunks:
1. Build tool executions from message content blocks
2. Collect sidechain messages within the time range
3. Link subagent processes to the chunk
4. Extract semantic steps (`SemanticStep[]`)
5. Fill timeline gaps
6. Calculate step context accumulation
7. Build step groups
```typescript
type SemanticStepType = 'thinking' | 'tool_call' | 'tool_result' | 'subagent' | 'output' | 'interruption';
interface SemanticStep {
id: string;
type: SemanticStepType;
startTime: Date;
endTime?: Date;
durationMs: number;
content: { /* type-specific fields */ };
tokens?: { input: number; output: number; cached?: number };
context: 'main' | 'subagent';
}
```
## Rendering
### Component Hierarchy
```
ChatHistory
└─ ChatHistoryItem (router)
├─ UserChatGroup → right-aligned blue bubble
├─ SystemChatGroup → left-aligned gray block
├─ AIChatGroup → left-aligned, collapsible
│ ├─ Header (model, summary, tokens, duration, timestamp)
│ ├─ DisplayItemList (when expanded)
│ │ ├─ ThinkingItem
│ │ ├─ LinkedToolItem (Read, Edit, Write, Skill, etc.)
│ │ ├─ SubagentItem (with nested trace)
│ │ ├─ TextItem
│ │ ├─ SlashItem
│ │ └─ TeammateMessageItem
│ └─ LastOutputDisplay (always visible)
└─ CompactBoundary
```
Primary render files:
- `src/renderer/components/chat/ChatHistory.tsx`
- `src/renderer/components/chat/ChatHistoryItem.tsx`
- `src/renderer/components/chat/UserChatGroup.tsx`
- `src/renderer/components/chat/SystemChatGroup.tsx`
- `src/renderer/components/chat/AIChatGroup.tsx`
- `src/renderer/components/chat/DisplayItemList.tsx`
- `src/renderer/components/chat/LastOutputDisplay.tsx`
### AI Group: Collapsed vs Expanded
**Collapsed** (default):
- Header: Bot icon, "Claude", model badge, items summary, chevron
- Right side: context badge, token usage, duration, timestamp
- Last output (always visible below header)
**Expanded**:
- All collapsed content, plus:
- `DisplayItemList` with chronologically ordered items
- Each display item can be individually expanded (nested expansion)
### Last Output Rendering
Always visible regardless of expansion state. Renders based on `type`:
| Type | Rendering |
|------|-----------|
| `text` | Markdown in code-bg rounded block |
| `tool_result` | Tool name + pre-formatted result |
| `interruption` | Warning banner with AlertTriangle |
| `plan_exit` | Plan preamble + plan content in special block |
| `ongoing` | Ongoing session banner (last AI group only) |
## Per-Tab UI State Isolation
Each tab maintains **completely independent** expansion state via `tabUISlice.ts`:
```typescript
interface TabUIState {
expandedAIGroupIds: Set<string>;
expandedDisplayItemIds: Map<string, Set<string>>;
expandedSubagentTraceIds: Set<string>;
showContextPanel: boolean;
selectedContextPhase: number | null;
savedScrollTop?: number;
}
```
Accessed via `useTabUI()` hook which reads `tabId` from `TabUIContext`:
```typescript
// src/renderer/hooks/useTabUI.tsx
const { isAIGroupExpanded, toggleAIGroupExpansion, expandAIGroup,
getExpandedDisplayItemIds, toggleDisplayItemExpansion,
isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI();
```
Auto-expansion triggers:
- Error deep linking (contains highlighted error tool)
- Search results (contains search match)
## Store Integration
### Session Detail Slice
`src/renderer/store/slices/sessionDetailSlice.ts` manages the fetch pipeline:
```
fetchSessionDetail(projectId, sessionId, tabId?)
→ IPC: getSessionDetail (returns chunks + processes)
→ transformChunksToConversation (chunks → ChatItem[])
→ processSessionClaudeMd (compute CLAUDE.md stats)
→ processSessionContextWithPhases (compute context stats)
→ store in global state + per-tab tabSessionData
```
Key state:
| Field | Purpose |
|-------|---------|
| `sessionDetail` | Raw session data from main process |
| `conversation` | Transformed `SessionConversation` |
| `conversationLoading` | True during fetch (causes ChatHistory unmount) |
| `tabSessionData` | Per-tab copies of session data |
| `sessionClaudeMdStats` | CLAUDE.md injection stats per AI group |
| `sessionContextStats` | Context stats per AI group |
| `sessionPhaseInfo` | Phase boundary info |
### Real-Time Updates
`refreshSessionInPlace` re-fetches and transforms without setting `conversationLoading: true`, avoiding ChatHistory unmount/remount flicker.
## Invariants
1. Chat items are always flat and chronological — no nesting at the conversation level.
2. AI groups are self-contained — all semantic steps, tool links, and display items are computed per group.
3. Display items within an AI group are chronologically sorted.
4. Per-tab UI state is fully isolated — expanding a group in one tab doesn't affect another.
5. Last output is always visible regardless of AI group expansion state.
6. `conversationLoading: true` unmounts ChatHistory — avoid setting it unnecessarily for existing tabs.

View file

@ -0,0 +1,356 @@
---
name: ccc:design-system
description: Design system and visual language — theming, CSS variables, Tailwind config, component styling patterns, icon usage, animations, and z-index layers. Use when creating or modifying UI components, working with the dark/light theme, or debugging visual issues.
---
# Design System & Visual Language
How the theming, color palette, component patterns, and styling conventions work.
## Theme Architecture
Two themes (dark/light) driven by CSS custom properties in `src/renderer/index.css`.
Toggled via `useTheme()` hook which adds/removes `light` class on `document.documentElement`.
Flash prevention: a script in `index.html` applies the cached theme before React loads.
### Theme Hook
```typescript
// src/renderer/hooks/useTheme.ts
const { theme, resolvedTheme, isDark, isLight } = useTheme();
// theme: 'dark' | 'light' | 'system'
// resolvedTheme: 'dark' | 'light' (after system resolution)
```
## Styling Convention
**Colors**: Always via CSS variables (theme-aware). Use inline `style` or Tailwind classes mapped to variables.
**Layout/spacing**: Tailwind utility classes.
**Icons**: `lucide-react` with `size-*` Tailwind classes.
```tsx
// Preferred: inline style for theme-aware colors, Tailwind for layout
<div className="flex items-center gap-2 rounded-md px-3 py-2"
style={{ backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }}>
<Bot className="size-4 shrink-0" style={{ color: COLOR_TEXT_SECONDARY }} />
</div>
// Also valid: Tailwind classes that reference CSS variables
<div className="bg-surface text-text border-border">
<div className="bg-surface-raised text-text-secondary">
```
### TypeScript Constants
`src/renderer/constants/cssVariables.ts` centralizes CSS variable strings:
```typescript
import { COLOR_TEXT_MUTED, CARD_BG, CARD_BORDER_STYLE } from '@renderer/constants/cssVariables';
<span style={{ color: COLOR_TEXT_MUTED }}>Muted text</span>
<div style={{ backgroundColor: CARD_BG, border: CARD_BORDER_STYLE }}>Card</div>
```
Constants cover: text colors, surfaces, borders, code blocks, diff, cards, tags, prose.
## CSS Variable Reference
All defined in `src/renderer/index.css` under `:root` (dark) and `:root.light`.
### Surfaces
| Variable | Dark | Light | Usage |
|----------|------|-------|-------|
| `--color-surface` | `#141416` | `#f9f9f7` | Main background |
| `--color-surface-raised` | `#27272a` | `#f0efed` | Elevated surfaces |
| `--color-surface-overlay` | `#27272a` | `#e8e7e4` | Overlays/modals |
| `--color-surface-sidebar` | `#0f0f11` | `#f1f0ee` | Sidebar background |
### Text
| Variable | Dark | Light | Usage |
|----------|------|-------|-------|
| `--color-text` | `#fafafa` | `#1c1b19` | Primary text |
| `--color-text-secondary` | `#a1a1aa` | `#4d4b46` | Secondary text |
| `--color-text-muted` | `#71717a` | `#6d6b65` | Muted text |
### Borders
| Variable | Dark | Light |
|----------|------|-------|
| `--color-border` | `rgba(255,255,255,0.05)` | `#d5d3cf` |
| `--color-border-subtle` | `rgba(255,255,255,0.05)` | `#e3e1dd` |
| `--color-border-emphasis` | `rgba(255,255,255,0.1)` | `#a8a5a0` |
### Chat Bubbles
**User bubble** (right-aligned):
| Variable | Dark | Light |
|----------|------|-------|
| `--chat-user-bg` | `#27272a` | `#eae9e6` |
| `--chat-user-text` | `#a1a1aa` | `#5a5955` |
| `--chat-user-border` | `rgba(255,255,255,0.08)` | `#d5d3cf` |
| `--chat-user-shadow` | `0 1px 0 0 rgba(255,255,255,0.03)` | `0 1px 2px 0 rgba(0,0,0,0.04)` |
| `--chat-user-tag-bg` | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.05)` |
| `--chat-user-tag-text` | `#e4e4e7` | `#3a3935` |
| `--chat-user-tag-border` | `rgba(255,255,255,0.12)` | `rgba(0,0,0,0.08)` |
**AI message**:
| Variable | Dark | Light |
|----------|------|-------|
| `--chat-ai-border` | `rgba(255,255,255,0.05)` | `#d5d3cf` |
| `--chat-ai-icon` | `#71717a` | `#6d6b65` |
**System bubble**:
| Variable | Dark | Light |
|----------|------|-------|
| `--chat-system-bg` | `rgba(39,39,42,0.5)` | `#eae9e6` |
| `--chat-system-text` | `#d4d4d8` | `#3a3935` |
### Code & Syntax
| Variable | Dark | Light |
|----------|------|-------|
| `--code-bg` | `#1c1c1e` | `#f0efed` |
| `--code-header-bg` | `#1c1c1e` | `#eae9e6` |
| `--code-border` | `rgba(255,255,255,0.1)` | `#d5d3cf` |
| `--code-line-number` | `#52525b` | `#a8a5a0` |
| `--code-filename` | `#60a5fa` | `#2563eb` |
| `--inline-code-bg` | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.05)` |
| `--inline-code-text` | `#e4e4e7` | `#3a3935` |
Syntax highlighting: `--syntax-string`, `--syntax-comment`, `--syntax-number`, `--syntax-keyword`, `--syntax-type`, `--syntax-operator`, `--syntax-function`. Dark uses vibrant colors; light uses GitHub-inspired palette.
### Semantic Blocks
**Thinking**: Purple tones (`--thinking-bg`, `--thinking-border`, `--thinking-text`)
**Tool call**: Amber tones (`--tool-call-bg`, `--tool-call-border`, `--tool-call-text`)
**Tool result success**: Green tones (`--tool-result-success-bg/border/text`)
**Tool result error**: Red tones (`--tool-result-error-bg/border/text`)
**Output**: Gray tones (`--output-bg`, `--output-border`, `--output-text`)
**Interruption**: Red (`--interruption-bg/border/text`)
**Warning**: Amber (`--warning-bg/border/text`)
**Plan exit**: Green (`--plan-exit-bg/header-bg/border/text`)
### Diff Viewer
| Variable | Dark | Light |
|----------|------|-------|
| `--diff-added-bg` | `rgba(34,197,94,0.15)` | `rgba(34,197,94,0.1)` |
| `--diff-added-text` | `#4ade80` | `#166534` |
| `--diff-removed-bg` | `rgba(239,68,68,0.15)` | `rgba(239,68,68,0.1)` |
| `--diff-removed-text` | `#f87171` | `#991b1b` |
### Cards (Subagents)
| Variable | Dark | Light |
|----------|------|-------|
| `--card-bg` | `#121212` | `#f9f9f7` |
| `--card-border` | `#27272a` | `#d5d3cf` |
| `--card-header-bg` | `#18181b` | `#f0efed` |
| `--card-header-hover` | `#1f1f23` | `#eae9e6` |
| `--card-icon-muted` | `#52525b` | `#a8a5a0` |
| `--card-separator` | `#3f3f46` | `#d5d3cf` |
### Badges
Status badges: `--badge-error-bg/text`, `--badge-warning-bg/text`, `--badge-success-bg/text`, `--badge-info-bg/text`, `--badge-neutral-bg/text`.
Tags: `--tag-bg`, `--tag-text`, `--tag-border`.
### Search Highlights
| Variable | Dark | Light |
|----------|------|-------|
| `--highlight-bg` | `rgba(202,138,4,0.7)` | `#facc15` |
| `--highlight-bg-inactive` | `rgba(113,63,18,0.5)` | `#fef08a` |
| `--highlight-ring` | `#facc15` | `#ca8a04` |
### Scrollbar
Custom scrollbar styling via `--scrollbar-thumb`, `--scrollbar-thumb-hover`, `--scrollbar-thumb-active`.
## Tailwind Config
`tailwind.config.js` maps CSS variables to Tailwind classes:
```javascript
colors: {
surface: {
DEFAULT: 'var(--color-surface)',
raised: 'var(--color-surface-raised)',
overlay: 'var(--color-surface-overlay)',
sidebar: 'var(--color-surface-sidebar)',
code: 'var(--code-bg)',
},
border: {
DEFAULT: 'var(--color-border)',
subtle: 'var(--color-border-subtle)',
emphasis: 'var(--color-border-emphasis)',
},
text: {
DEFAULT: 'var(--color-text)',
secondary: 'var(--color-text-secondary)',
muted: 'var(--color-text-muted)',
},
semantic: {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
},
// Backward-compatible alias
'claude-dark': { bg: 'var(--color-surface)', surface: 'var(--color-surface-raised)', ... },
}
```
Plugin: `@tailwindcss/typography` for markdown prose.
## Icon Library
`lucide-react` across 55+ components.
**Sizes**: `size-3` (tiny), `size-3.5` (small), `size-4` (standard), `size-5` (medium), `size-10` (empty states)
**Pattern**: `className` for size, `style` for color:
```tsx
<Bot className="size-4 shrink-0" style={{ color: COLOR_TEXT_SECONDARY }} />
<Loader2 className="size-3.5 shrink-0 animate-spin" style={{ color: '#3b82f6' }} />
```
**Common icons**: `Bot` (AI), `User` (user), `ChevronRight`/`ChevronDown` (expansion), `Check`/`Copy` (clipboard), `Info` (tooltips), `Loader2` (loading), `Clock` (duration), `Terminal` (traces), `CheckCircle2` (completed).
## Team Colors
`src/renderer/constants/teamColors.ts` defines 8 color sets for teammate visualization:
```typescript
import { getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors';
const colors = getTeamColorSet('blue');
// colors.border: '#3b82f6' — border accent
// colors.badge: 'rgba(59, 130, 246, 0.15)' — badge background
// colors.text: '#60a5fa' — text color
```
Available colors: blue, green, red, yellow, purple, cyan, orange, pink.
## Component Patterns
### User Chat Bubble
Right-aligned with rounded corners, subtle shadow, copy overlay on hover:
```tsx
<div className="flex justify-end">
<div className="max-w-[85%]">
<div className="rounded-2xl rounded-br-sm px-4 py-3"
style={{
backgroundColor: 'var(--chat-user-bg)',
border: '1px solid var(--chat-user-border)',
boxShadow: 'var(--chat-user-shadow)',
}}>
<ReactMarkdown>{text}</ReactMarkdown>
</div>
</div>
</div>
```
### AI Group Header
Collapsible with model badge, summary, metrics:
```tsx
<div onClick={toggle} className="flex cursor-pointer items-center gap-2">
<Bot className="size-4" style={{ color: COLOR_TEXT_SECONDARY }} />
<span>Claude</span>
<span className={getModelColorClass(model.family)}>{model.name}</span>
<span style={{ color: COLOR_TEXT_MUTED }}>{itemsSummary}</span>
<ChevronDown className={`size-3.5 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</div>
```
### Subagent Card
Linear-style card with nested expansion:
```tsx
<div style={{ backgroundColor: CARD_BG, border: CARD_BORDER_STYLE }}>
<div className="flex cursor-pointer items-center gap-2 px-3 py-2"
style={{ backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent' }}>
<ChevronRight className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
{/* colored dot + badge + description + metrics */}
</div>
{isExpanded && <div className="space-y-3 p-3">{/* content */}</div>}
</div>
```
### Copy Button (Overlay)
Gradient-fade overlay that appears on group hover:
```tsx
<div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div style={{ background: `linear-gradient(to right, transparent, ${bgColor})` }} />
<button><Copy className="size-3.5" /></button>
</div>
```
### Popover via Portal
Token usage and context badges use portaled popovers to escape stacking context:
```tsx
{showPopover && createPortal(
<div style={{
backgroundColor: 'var(--color-surface-raised)',
border: '1px solid var(--color-border)',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
zIndex: 99999,
}}>
{/* content */}
</div>,
document.body
)}
```
## Animations
### CSS Keyframes (in `index.css`)
- `shimmer` — skeleton loading shimmer effect
- `skeleton-fade-in` — staggered fade-in for skeleton cards
- `splash-slide` — splash screen loading bar
### Tailwind Utilities
| Class | Usage |
|-------|-------|
| `animate-spin` | Loading spinners (`Loader2`) |
| `animate-ping` | Pulsing dots (ongoing state) |
| `transition-transform` | Chevron rotation |
| `transition-colors` | Hover color changes |
| `transition-opacity` | Copy button fade-in |
| `transition-all duration-300` | Card highlight transitions |
| `duration-[3000ms]` | Highlight ring fade-out |
## Z-Index Layers
| Z-Index | Usage |
|---------|-------|
| `z-10` | Copy button overlays, dropdown backdrops |
| `z-20` | Dropdown menus, search bar |
| `z-30` | Pane split drop zones |
| `z-40` | Pane view overlays |
| `z-50` | Context menus, command palette, settings selects |
| `99999` | Portaled popovers (token usage, context badge, metrics pill) |
## Light Theme Notes
The light theme uses **warm neutrals** (not pure white/gray):
- Backgrounds: `#f9f9f7`, `#f0efed`, `#eae9e6` (warm off-white)
- Borders: `#d5d3cf`, `#e3e1dd` (warm gray)
- Text: `#1c1b19`, `#4d4b46`, `#6d6b65` (warm dark)
- Syntax highlighting: GitHub-inspired palette
Body transition: `background-color 0.2s ease, color 0.2s ease` for smooth theme switching.

View file

@ -0,0 +1,104 @@
---
name: ccc:explain-visible-context
description: Explains what "Visible Context" is — the 6 trackable token categories, what falls outside tracking, how it's displayed, and why it matters. Use when someone asks about visible context, token attribution, or context window usage.
---
Present the following explanation directly to the user. Output the full content below as your response — do not summarize, ask follow-up questions, or treat this as background context.
# Visible Context
## What It Is
"Visible Context" is the portion of Claude's context window that we can identify and measure. Every time Claude processes a turn, its context window fills with various pieces of information — your messages, file contents, tool outputs, thinking, and more. Visible Context tracks what we **can** attribute to a known source, so you can see where your tokens are going.
## What We Track (6 Categories)
### CLAUDE.md Files
Memory files that Claude loads automatically at the start of every session and after each compaction. These include:
- **Global** CLAUDE.md (`~/.claude/CLAUDE.md`) — your personal instructions across all projects
- **Project** CLAUDE.md (`.claude/CLAUDE.md` or `CLAUDE.md` at project root) — project-specific instructions
- **Directory** CLAUDE.md — instructions scoped to subdirectories (e.g., `src/renderer/CLAUDE.md`)
These are injected repeatedly (once per compaction phase), so their token cost accumulates. A 500-token CLAUDE.md file injected across 3 compaction phases costs ~1,500 tokens total.
### @-Mentioned Files
Files you reference with `@path/to/file` in your messages. When you mention a file, Claude Code injects the full file contents into the context. Large files consume significant tokens — a 1,000-line source file could use 5,000+ tokens per mention.
### Tool Outputs
Results returned from tool executions: file reads (`Read`), command output (`Bash`), search results (`Grep`, `Glob`), and others. Every tool result stays in the context window until compaction. A `Bash` command that prints 500 lines of output or a `Read` of a large file both count here.
### Thinking + Text Output
Claude's own output that consumes context:
- **Extended thinking** — Claude's internal reasoning (when thinking mode is active). This can be substantial for complex tasks.
- **Text output** — Claude's visible responses to you. Longer explanations and code blocks use more tokens.
### Task Coordination
Messages and operations from Claude Code's team/orchestration features:
- `SendMessage` — messages between teammates
- `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` — task management
- `TeamCreate`, `TeamDelete` — team lifecycle
Each coordination message adds to the context window of the receiving agent.
### User Messages
Your actual prompt text for each turn. This includes the raw text you type, but not the system-injected metadata around it.
## What We Don't Track
Visible Context does **not** cover everything in Claude's context window. The following are present but not attributable by our tracking:
- **Claude Code's system prompt** — the base instructions that tell Claude how to behave, use tools, format output, etc.
- **Tool descriptions** — the schema and documentation for each built-in tool (Read, Write, Edit, Bash, Grep, Glob, etc.)
- **MCP tool descriptions** — schemas for any MCP (Model Context Protocol) servers you have connected
- **Custom agent definitions** — instructions from `.claude/agents/` configurations
- **Skill descriptions** — the short descriptions of available skills that Claude sees so it knows what's available (visible via `/context` in Claude Code)
- **Internal system reminders**`<system-reminder>` injections that Claude Code adds for session state, git status, available skills, etc.
- **Conversation structure overhead** — the message formatting, role markers, and protocol framing around each message
These untracked items form a "base cost" that's always present. You can see what Claude Code injects via the `/context` command in Claude Code itself.
## How It's Displayed
### Per-Turn Popover (Context Badge)
Each AI group in the chat shows a small badge. Hovering reveals what was injected at that specific turn — which CLAUDE.md files, which @-mentioned files, which tool outputs contributed tokens.
### Token Usage Popover
The token count next to each AI group has an info icon. Hovering shows the standard input/output/cache breakdown, plus an expandable "Visible Context" section showing the percentage of total tokens attributable to each tracked category.
### Session Context Panel
A dedicated panel (toggle via the context badge or header button) that shows the full session-wide view:
- All tracked injections grouped by category
- Token estimates per injection
- Phase filtering (if compaction events split the session into phases)
- Total visible context as a percentage of total session tokens
## Compaction Phases
When Claude's context window fills up, Claude Code compacts the conversation — summarizing older messages to free space. Each compaction creates a new "phase." Visible Context tracks injections per phase because:
- CLAUDE.md files are re-injected after each compaction
- Previous tool outputs and file contents are summarized away
- The phase selector lets you see what's in context **right now** (current phase) vs. what was present earlier
## Why Visible Context Matters
Understanding where tokens go helps you:
- **Spot expensive injections** — a massive CLAUDE.md file or a frequently-mentioned large file could be using 20%+ of your context
- **Optimize CLAUDE.md** — keep memory files concise; every token is repeated across phases
- **Be strategic with @-mentions** — mentioning a 2,000-line file costs real context space
- **Understand compaction impact** — see how much context resets after compaction
- **Debug unexpected behavior** — if Claude seems to "forget" something, check whether it was compacted away

View file

@ -0,0 +1,179 @@
---
name: ccc:markdown-search
description: Markdown search logic — how in-session and cross-session search works. Use when working on SearchBar, search highlighting, searchHighlightUtils, markdownTextSearch, or SessionSearcher.
---
# Markdown Search Logic
How in-session and cross-session markdown search works end-to-end.
## Scope
Current in-session search intentionally covers:
- User message markdown text
- AI `lastOutput` text markdown
Current in-session search intentionally excludes:
- System items
- Tool result text blocks
- Thinking/subagent/internal display items
Primary source files:
- `src/renderer/components/search/SearchBar.tsx`
- `src/renderer/store/slices/conversationSlice.ts`
- `src/renderer/components/chat/ChatHistory.tsx`
- `src/renderer/components/chat/searchHighlightUtils.ts`
- `src/shared/utils/markdownTextSearch.ts`
- `src/main/services/discovery/SessionSearcher.ts`
## Core Data Model
`SearchMatch` (renderer store) in `src/renderer/store/types.ts`:
- `itemId`: chat group id (`user-*`, `ai-*`)
- `itemType`: `user | ai`
- `matchIndexInItem`: 0-based index inside one searchable item
- `globalIndex`: 0-based index across all matches
- `displayItemId`: optional (`lastOutput` for AI output)
Important distinction:
- `matchIndexInItem` is local to one item.
- `currentSearchIndex` is global position in the search result list.
## Pipeline Overview
### 1) Query input and initial match generation
`SearchBar` updates the query with tab-scoped conversation data:
- `setSearchQuery(query, conversation)` in `src/renderer/components/search/SearchBar.tsx`
`setSearchQuery` in `src/renderer/store/slices/conversationSlice.ts`:
- Scans conversation items
- Uses `findMarkdownSearchMatches` (shared parser logic) per searchable item
- Builds initial `searchMatches`, `searchResultCount`, `currentSearchIndex`
### 2) Rendering highlights
Search highlighting is rendered in markdown component trees through:
- `createSearchContext(...)` in `src/renderer/components/chat/searchHighlightUtils.ts`
- `highlightSearchInChildren(...)` in `src/renderer/components/chat/searchHighlightUtils.ts`
Each rendered highlight mark includes:
- `data-search-item-id`
- `data-search-match-index`
- `data-search-result` (`current` or `match`)
### 3) Canonicalization to rendered DOM (critical)
`ChatHistory` collects rendered `<mark>` elements in DOM order and calls:
- `syncSearchMatchesWithRendered(renderedMatches)` in `src/renderer/store/slices/conversationSlice.ts`
Why this exists:
- Real UI navigation must match visible marks exactly.
- Parser results can temporarily differ during render timing.
- DOM order is the final source of truth for nth navigation.
Safety guard:
- `ChatHistory` delays syncing when a transient empty mark snapshot appears, to avoid wiping results mid-render.
### 4) Next/prev navigation and scrolling
`nextSearchResult` / `previousSearchResult` in `src/renderer/store/slices/conversationSlice.ts`:
- Move `currentSearchIndex` with wrap-around
`ChatHistory` scroll effect:
- First tries exact selector:
- `mark[data-search-item-id="..."][data-search-match-index="..."]`
- If missing, falls back to the global nth rendered mark (same `currentSearchIndex`)
- Final fallback walks text nodes under `[data-search-content]` roots
## Shared Markdown Search Engine
`src/shared/utils/markdownTextSearch.ts` is used by both renderer and main process:
- `findMarkdownSearchMatches`
- `countMarkdownSearchMatches`
- `extractMarkdownPlainText`
Design principle:
- Search parser mirrors markdown render behavior (remark + gfm + HAST traversal)
- Matching is segment-based (no cross-node match)
## Cross-Session Search (Command Palette / IPC)
Main process search path:
- IPC handler: `src/main/ipc/search.ts`
- Engine: `src/main/services/discovery/SessionSearcher.ts`
`SessionSearcher` also uses shared markdown search utils, and returns:
- `groupId`
- `itemType`
- `matchIndexInItem`
- `matchStartOffset`
These are passed into tab navigation context so opening a search result can jump to the exact in-session match.
## Invariants to Keep
When changing markdown/search code, keep these invariants:
1. Parser and renderer must agree on searchable text boundaries.
2. `matchIndexInItem` semantics must stay stable per item.
3. `currentSearchIndex` must represent the global nth visible match.
4. `searchResultCount` must reflect actual rendered match count after canonicalization.
5. Search source scope must be explicit (no accidental inclusion of hidden/internal text).
## If You Add New Searchable Markdown Surfaces
If you make a new markdown surface searchable:
1. Ensure it uses search context + `highlightSearchInChildren`.
2. Ensure emitted marks include `data-search-item-id` and `data-search-match-index`.
3. Ensure the content is included in `setSearchQuery` source scanning.
4. Ensure parser collection logic in `src/shared/utils/markdownTextSearch.ts` still mirrors render behavior.
5. Add/adjust alignment tests.
## Debug Playbook
Enable debug logs:
- `localStorage.setItem('search-debug', '1')`
Useful logs:
- `[search] query` / `[search] sample` from `setSearchQuery`
- `[search] sync-rendered` from DOM canonicalization
- `[search] next` / `[search] prev` navigation logs
Quick checks when behavior is off:
1. Compare `searchResultCount` vs number of rendered marks.
2. Verify `currentSearchIndex` increments exactly once per click.
3. Check whether exact mark selector exists for current match.
4. Confirm the active tab conversation is the same one used for `setSearchQuery`.
5. Confirm virtualization is disabled during active search.
## Tests
Main tests relevant to this logic:
- `test/shared/utils/markdownTextSearch.test.ts`
- `test/shared/utils/markdownSearchRendererAlignment.test.ts`
The alignment test ensures parser match indexes and rendered mark indexes stay identical across representative markdown cases.

View file

@ -0,0 +1,232 @@
---
name: ccc:navigation-scroll
description: Navigation and scroll orchestration — tab navigation, error highlights, search scrolling, auto-scroll coordination, and common bug patterns. Use when working on useTabNavigationController, scroll restore, or navigation requests.
---
# Navigation & Scroll Orchestration
How tab navigation (error highlights, search scrolling, auto-scroll) works end-to-end.
## Architecture
### Navigation Request Model (Nonce-Based)
```typescript
// src/renderer/types/tabs.ts
interface TabNavigationRequest {
id: string; // crypto.randomUUID() — fresh nonce per click
kind: 'error' | 'search' | 'autoBottom';
highlight: 'red' | 'yellow' | 'none';
payload: ErrorNavigationPayload | SearchNavigationPayload | {};
source: 'notification' | 'triggerPreview' | 'commandPalette' | 'sessionOpen';
}
// Stored on Tab:
interface Tab {
pendingNavigation?: TabNavigationRequest; // Set by enqueue, cleared by consume
lastConsumedNavigationId?: string; // Tracks last processed request
}
```
### Store Actions (tabSlice.ts)
| Action | Purpose |
|--------|---------|
| `enqueueTabNavigation(tabId, request)` | Set `pendingNavigation` on a tab |
| `consumeTabNavigation(tabId, requestId)` | Clear `pendingNavigation`, record `lastConsumedNavigationId` |
### Navigation Sources
| Source | Slice | Creates |
|--------|-------|---------|
| Notification click / test trigger | `notificationSlice.navigateToError()` | `ErrorNavigationRequest` (red) |
| CommandPalette search result | `tabSlice.navigateToSession()` | `SearchNavigationRequest` (yellow) |
### Controller Hook: `useTabNavigationController`
**Location:** `src/renderer/hooks/useTabNavigationController.ts`
Phase state machine:
```
idle → pending → expanding → scrolling → highlighting → complete → idle
```
Key behaviors:
- **Active-tab-only:** Ignores `!isActiveTab` to prevent cross-tab races
- **Nonce dedup:** `activeRequestIdRef.current === pendingNavigation.id` prevents reprocessing
- **Failure debounce:** 500ms cooldown after failed navigation (`lastFailureAtRef`)
- **Abort support:** New navigation aborts in-progress one via `AbortController`
- **Highlight-first:** Highlight is set BEFORE scroll (best-effort scroll, guaranteed highlight)
### Scroll Precedence (ChatHistory.tsx)
Three scroll systems compete — navigation wins:
| System | Guard | Priority |
|--------|-------|----------|
| Navigation scroll | Controller's `executeNavigation` | Highest |
| Scroll restore (tab switch) | `!shouldDisableAutoScroll` | Medium |
| Auto-scroll to bottom | `disabled: shouldDisableAutoScroll` | Lowest |
`shouldDisableAutoScroll` is `true` during ANY navigation phase or when `pendingNavigation` exists.
## Key Files
| File | Role |
|------|------|
| `src/renderer/hooks/useTabNavigationController.ts` | Unified navigation controller |
| `src/renderer/hooks/navigation/utils.ts` | Shared helpers (scroll calc, element lookup, visibility) |
| `src/renderer/components/chat/ChatHistory.tsx` | Scroll restore + auto-scroll coordination |
| `src/renderer/store/slices/tabSlice.ts` | `enqueueTabNavigation`, `consumeTabNavigation`, `navigateToSession` |
| `src/renderer/store/slices/notificationSlice.ts` | `navigateToError` |
| `src/renderer/store/slices/sessionDetailSlice.ts` | `fetchSessionDetail` (sets `conversationLoading`) |
| `src/renderer/types/tabs.ts` | `TabNavigationRequest` types + factory helpers |
## Common Bug Patterns
### 1. Scroll Restore Overrides Navigation
**Symptom:** Scrolls to target, then snaps back to top/previous position.
**Root cause:** The scroll restore effect fires after `consumeTabNavigation` clears `pendingNavigation`. If the guard only checks `!pendingNavigation`, it triggers while navigation highlight is still active.
**Fix pattern:** Guard scroll restore with `!shouldDisableAutoScroll` instead of `!pendingNavigation`. The controller's `shouldDisableAutoScroll` covers the FULL lifecycle (pending → complete), not just while `pendingNavigation` exists.
**Additional:** Save scroll position when `shouldDisableAutoScroll` transitions true→false (navigation completed) to prevent stale `savedScrollTop` from being restored later.
```typescript
// ChatHistory.tsx — scroll restore effect
useEffect(() => {
const wasDisabled = prevShouldDisableRef.current;
prevShouldDisableRef.current = shouldDisableAutoScroll;
// Navigation just completed — save current position, skip restore
if (wasDisabled && !shouldDisableAutoScroll && scrollContainerRef.current) {
saveScrollPosition(scrollContainerRef.current.scrollTop);
return;
}
if (isThisTabActive && savedScrollTop !== undefined && !conversationLoading && !shouldDisableAutoScroll) {
// ... restore logic
}
}, [isThisTabActive, savedScrollTop, conversationLoading, shouldDisableAutoScroll, saveScrollPosition]);
```
### 2. Redundant `fetchSessionDetail` Unmounts ChatHistory
**Symptom:** Navigation doesn't scroll at all, or session "reloads" unnecessarily.
**Root cause:** `navigateToSession` or `navigateToError` calls `fetchSessionDetail` even when the session is already loaded in an existing tab. This sets `conversationLoading: true`, causing ChatHistory to unmount (show loading spinner) and remount — losing scroll container and controller state.
**Fix pattern:** Only call `fetchSessionDetail` for NEW tabs. For existing tabs, `setActiveTab` already handles the fetch when `sessionChanged` is true.
```typescript
// tabSlice.ts — navigateToSession
if (existingTab) {
state.setActiveTab(existingTab.id);
// NO fetchSessionDetail — setActiveTab handles it
} else {
state.openTab({ ... });
void state.fetchSessionDetail(projectId, sessionId); // Only for new tabs
}
```
### 3. Highlight Not Showing (Strict Post-Scroll Gates)
**Symptom:** Scrolls to correct location but no red/yellow highlight ring appears.
**Root cause:** `executeErrorNavigation` / `executeSearchNavigation` returns `false` after scroll due to strict gates:
- `userInterrupted` — any accidental wheel/touch event during smooth scroll
- `isElementVisibleInContainer` — element partially off-screen after centering (tall elements)
- Element not found within 600ms timeout
When `success = false`, `executeNavigation` clears all highlight state (`setHighlightedGroupId(null)`).
**Fix pattern:** Set highlight BEFORE scroll attempt. Make scroll best-effort. Always return `true` once target group is found.
```typescript
// In executeErrorNavigation:
// 1. Find target group
// 2. Expand group
// 3. SET HIGHLIGHT HERE (before scroll)
setHighlightedGroupId(targetGroupId);
setIsSearchHighlight(false);
if (toolUseId) setCurrentToolUseId(toolUseId);
// 4. Best-effort scroll (don't gate highlight on scroll outcome)
// 5. Return true (highlight already visible)
```
### 4. Test Trigger Shows No Highlight
**Symptom:** "Test trigger" creates an error with a timestamp that doesn't match any AI group. Navigation scrolls but nothing is highlighted.
**Root cause:** Same as #3. The error timestamp doesn't match, so `findAIGroupByTimestamp` falls back to closest/last group. But post-scroll gates prevent the highlight from being applied.
**Fix:** Same as #3 — highlight-first pattern.
### 5. `conversationLoading` Race During Tab Switch
**Symptom:** Navigation queued on tab, but when switching to that tab, loading state causes ChatHistory unmount. Navigation controller state is lost.
**Root cause:** `fetchSessionDetail` immediately sets `conversationLoading: true`. ChatHistory returns `<ChatHistoryLoadingState />`, unmounting the controller hook.
**Recovery:** The controller is designed to survive remount — when ChatHistory remounts with `pendingNavigation` still set and `conversationLoading: false`, the detection effect starts fresh navigation. BUT scroll restore can race with it (see #1).
## Debugging Checklist
When navigation isn't working:
1. **Check `pendingNavigation` exists on the tab** — is `enqueueTabNavigation` called?
2. **Check `isActiveTab` is true** — controller ignores inactive tabs
3. **Check `conversationLoading`** — if true, controller waits in `pending` phase
4. **Check `conversation` exists** — if null, controller waits
5. **Check timestamp matching** — does `findAIGroupByTimestamp` find the right group?
6. **Check element refs** — are `aiGroupRefs` / `chatItemRefs` populated?
7. **Check `shouldDisableAutoScroll`** — is scroll restore racing with navigation?
8. **Check for double `fetchSessionDetail`** — is ChatHistory unmounting unnecessarily?
9. **Check `phase` progression** — is it stuck in `pending` or failing at `scrolling`?
## Navigation Helper Functions (navigation/utils.ts)
| Function | Purpose |
|----------|---------|
| `findAIGroupByTimestamp(items, timestamp)` | Find AI group containing/closest to timestamp |
| `findChatItemByTimestamp(items, timestamp)` | Find any chat item by timestamp |
| `findAIGroupBySubagentId(items, subagentId)` | Find AI group by subagent ID |
| `calculateCenteredScrollTop(element, container, offset)` | Calculate scroll position to center element |
| `waitForElementStability(element, timeout, stableFrames)` | Wait for element size to stop changing |
| `waitForScrollEnd(container, timeout)` | Wait for smooth scroll to finish |
| `isElementVisibleInContainer(element, container, offset)` | Check if element is in viewport |
| `findCurrentSearchResultInContainer(container)` | Find `[data-search-result="current"]` element |
## Factory Helpers (tabs.ts)
```typescript
createErrorNavigationRequest({ errorId, errorTimestamp, toolUseId, lineNumber })
createSearchNavigationRequest({ query, messageTimestamp, matchedText })
isErrorPayload(request) // type guard
isSearchPayload(request) // type guard
```
## Tests
Related test files:
- `test/renderer/store/tabSlice.test.ts``enqueueTabNavigation` / `consumeTabNavigation`
- `test/renderer/store/notificationSlice.test.ts``navigateToError` behavior
- `test/renderer/hooks/navigationUtils.test.ts` — Navigation utility functions
- `test/renderer/hooks/useSearchContextNavigation.test.ts` — Search result finding
Test patterns:
```typescript
// Mock crypto for predictable nonces
vi.stubGlobal('crypto', { randomUUID: () => `test-uuid-${++counter}` });
// Verify navigation request shape
expect(tab.pendingNavigation?.kind).toBe('error');
expect(tab.pendingNavigation?.highlight).toBe('red');
// Verify nonce uniqueness on repeated clicks
store.getState().navigateToError(error);
const firstId = store.getState().openTabs[0].pendingNavigation?.id;
store.getState().navigateToError(error);
const secondId = store.getState().openTabs[0].pendingNavigation?.id;
expect(firstId).not.toBe(secondId);
```

50
.claude/rules/react.md Normal file
View file

@ -0,0 +1,50 @@
---
globs: ["src/renderer/**/*.tsx"]
---
# React Conventions
## Component Structure
- Components in `src/renderer/components/` organized by feature
- One component per file, PascalCase naming
- Colocate related hooks and utilities
## State Management (Zustand)
```typescript
// Slices pattern
projects: Project[]
selectedProjectId: string | null
projectsLoading: boolean
projectsError: string | null
```
Each domain slice includes:
- Data array or object
- Selected/active item ID
- Loading state
- Error state
## Hooks
- Custom hooks in `src/renderer/hooks/`
- Prefix with `use`: `useAutoScrollBottom`, `useTheme`
- Keep hooks focused and composable
## Component Organization
```
components/
├── chat/ # Chat display, items, viewers, SessionContextPanel
├── common/ # Shared components (badges, token display)
├── dashboard/ # Dashboard views
├── layout/ # Layout components (headers, shells)
├── notifications/ # Notification panels and badges
├── search/ # Search UI and results
├── settings/ # Settings pages and controls
│ ├── components/ # Reusable setting controls
│ ├── hooks/ # Settings-specific hooks
│ ├── sections/ # Setting sections
│ └── NotificationTriggerSettings/ # Trigger config UI
└── sidebar/ # Sidebar navigation
```
## Contexts
- `contexts/TabUIContext.tsx` - Per-tab UI state isolation

61
.claude/rules/tailwind.md Normal file
View file

@ -0,0 +1,61 @@
---
globs: ["**/*.css", "src/renderer/**/*.tsx"]
---
# Tailwind CSS Conventions
## Theme Architecture
Uses CSS custom properties for theme-aware colors defined in `src/renderer/index.css`.
### Core Surface Colors
```css
--color-surface: #141416 /* Main background */
--color-surface-raised: #27272a /* Elevated surfaces */
--color-surface-overlay: #27272a /* Overlays/modals */
--color-surface-sidebar: #0f0f11 /* Sidebar background */
```
### Border Colors
```css
--color-border: rgba(255, 255, 255, 0.05)
--color-border-subtle: rgba(255, 255, 255, 0.05)
--color-border-emphasis: rgba(255, 255, 255, 0.1)
```
### Text Colors
```css
--color-text: #fafafa /* Primary text */
--color-text-secondary: #a1a1aa /* Secondary text */
--color-text-muted: #71717a /* Muted text */
```
## Tailwind Usage
Use theme-aware classes that reference CSS variables:
```tsx
// Preferred - uses CSS variables for theme support
<div className="bg-surface text-text border-border">
<div className="bg-surface-raised text-text-secondary">
// Also available via claude-dark namespace
<div className="bg-claude-dark-bg text-claude-dark-text">
```
## Additional CSS Variable Categories
- Chat bubbles: `--chat-user-*`, `--chat-ai-*`, `--chat-system-*`
- Code blocks: `--code-*`, `--syntax-*`, `--inline-code-*`
- Diff viewer: `--diff-added-*`, `--diff-removed-*`
- Tool blocks: `--tool-call-*`, `--tool-result-*`
- Tool items: `--tool-item-name`, `--tool-item-summary`, `--tool-item-muted`, `--tool-item-hover-bg`
- Badges: `--badge-*`, `--tag-*`
- Search: `--highlight-*`
- Scrollbar: `--scrollbar-thumb`, `--scrollbar-thumb-hover`, `--scrollbar-thumb-active`
- Prose/Markdown: `--prose-heading`, `--prose-body`, `--prose-link`, `--prose-code-*`, `--prose-pre-*`
- Thinking blocks: `--thinking-bg`, `--thinking-border`, `--thinking-text`, `--thinking-content-*`
- Output blocks: `--output-bg`, `--output-border`, `--output-text`, `--output-content-border`
- Cards/Subagents: `--card-bg`, `--card-border`, `--card-header-*`, `--card-icon-muted`, `--card-separator`
- Highlights: `--skill-highlight-*`, `--path-highlight-*`
- UI elements: `--interruption-*`, `--warning-*`, `--plan-exit-*`, `--error-highlight-*`, `--kbd-*`, `--context-btn-*`
## Dark/Light Theme
Both themes supported via `:root` and `:root.light` in index.css.
Toggle via `useTheme` hook which adds/removes `light` class on root.

76
.claude/rules/testing.md Normal file
View file

@ -0,0 +1,76 @@
---
globs: ["test/**/*", "**/*.test.ts", "**/*.spec.ts"]
---
# Testing Conventions
## Test Framework
Uses Vitest with `happy-dom` environment. Config in `vitest.config.ts`.
## Test Commands
```bash
pnpm test # Run all vitest tests
pnpm test:watch # Watch mode
pnpm test:coverage # Coverage report
pnpm test:coverage:critical # Critical path coverage
pnpm test:chunks # Chunk building tests
pnpm test:semantic # Semantic step extraction
pnpm test:noise # Noise filtering tests
pnpm test:task-filtering # Task tool filtering
```
## Test Structure
```
test/
├── main/
│ ├── ipc/ # IPC handler tests
│ │ ├── configValidation.test.ts
│ │ └── guards.test.ts
│ ├── services/ # Service tests
│ │ ├── analysis/ (ChunkBuilder)
│ │ ├── discovery/ (ProjectPathResolver, SessionSearcher)
│ │ ├── infrastructure/ (FileWatcher)
│ │ └── parsing/ (MessageClassifier, SessionParser)
│ └── utils/ # Main process utilities
│ ├── jsonl.test.ts
│ ├── pathDecoder.test.ts
│ ├── pathValidation.test.ts
│ ├── regexValidation.test.ts
│ └── tokenizer.test.ts
├── renderer/
│ ├── hooks/ # Hook tests
│ │ ├── navigationUtils.test.ts
│ │ ├── useAutoScrollBottom.test.ts
│ │ ├── useSearchContextNavigation.test.ts
│ │ └── useVisibleAIGroup.test.ts
│ ├── store/ # Zustand store slices
│ │ ├── notificationSlice.test.ts
│ │ ├── paneSlice.test.ts
│ │ ├── pathResolution.test.ts
│ │ ├── sessionSlice.test.ts
│ │ ├── tabSlice.test.ts
│ │ └── tabUISlice.test.ts
│ └── utils/ # Renderer utilities
│ ├── claudeMdTracker.test.ts
│ ├── dateGrouping.test.ts
│ ├── formatters.test.ts
│ └── pathUtils.test.ts
├── shared/
│ └── utils/ # Shared utilities
│ ├── markdownSearchRendererAlignment.test.ts
│ ├── markdownTextSearch.test.ts
│ ├── modelParser.test.ts
│ └── tokenFormatting.test.ts
├── mocks/ # Test fixtures and mocks
└── setup.ts # Test setup/config
```
## Files to Test After Changes
- `services/analysis/ChunkBuilder.ts` - Chunk building logic
- `services/parsing/SessionParser.ts` - JSONL parsing
- `services/parsing/MessageClassifier.ts` - Message classification
- Store slices in `src/renderer/store/slices/`
- Utility functions in `*/utils/`
## Test Data
Test fixtures use real JSONL session data from `~/.claude/projects/`.

38
.claude/settings.json Normal file
View file

@ -0,0 +1,38 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read f; for p in pnpm-lock.yaml .env dist/ dist-electron/ node_modules/; do if [ -n \"$f\" ] && echo \"$f\" | grep -q \"$p\"; then echo \"Blocked: $f matches protected pattern '$p'\" >&2; exit 2; fi; done; exit 0; }"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' | { read file_path; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then pnpm eslint --fix \"$file_path\" 2>/dev/null || true; fi; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx|json|css)$'; then pnpm prettier --write \"$file_path\" 2>/dev/null || true; fi; }",
"timeout": 30
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo '[Post-compaction context reminder]\n- Package manager: pnpm only (not npm/yarn)\n- Path aliases: @main/*, @renderer/*, @shared/*, @preload/*\n- Electron 3-process: main/ (Node), preload/ (bridge), renderer/ (React), shared/ (cross-process)\n- isMeta: false = real user message, true = internal/system message\n- Chunk types: UserChunk, AIChunk, SystemChunk, CompactChunk\n- State: Zustand slices pattern (data, selectedId, loading, error)\n- Styling: Tailwind with CSS variables (bg-surface, text-text, border-border)\n- Naming: PascalCase services/components, camelCase utils, UPPER_SNAKE constants\n- Barrel exports: import from domain index.ts\n- New IPC: channel in preload/constants → handler in main/ipc → method in preload/index.ts\n- PostToolUse hook auto-runs eslint+prettier on edited files\n- PreToolUse hook blocks edits to pnpm-lock.yaml, .env, dist/, node_modules/'"
}
]
}
]
}
}

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*.{ts,tsx,js,jsx,json,md,yml,yaml}]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

47
.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# Dependencies
node_modules/
# Build output
dist/
dist-electron/
out/
release/
coverage/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# TypeScript
*.tsbuildinfo
.pnpm-store/
package-lock.json
notification_example/
temp/
.claude/*.local.json
.claude/agent-memory/*
eslint-fix/

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

25
.prettierignore Normal file
View file

@ -0,0 +1,25 @@
# Build outputs
dist/
dist-electron/
build/
out/
# Dependencies
node_modules/
pnpm-lock.yaml
# Generated files
*.min.js
*.min.css
# Config files that shouldn't be formatted
*.config.js
*.config.ts
# IDE
.idea/
.vscode/
# Misc
.DS_Store
*.log

28
.prettierrc.json Normal file
View file

@ -0,0 +1,28 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"jsxSingleQuote": false,
"bracketSameLine": false,
"plugins": ["prettier-plugin-tailwindcss"],
"overrides": [
{
"files": ["*.json", "*.jsonc"],
"options": {
"trailingComma": "none"
}
},
{
"files": "*.md",
"options": {
"proseWrap": "preserve"
}
}
]
}

27
CHANGELOG.md Normal file
View file

@ -0,0 +1,27 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog and this project follows Semantic Versioning.
## [Unreleased]
### Added
- Strict IPC input validation guards for project/session/subagent/search limits.
- `get-waterfall-data` IPC endpoint implementation.
- Cross-platform path normalization in renderer path resolvers.
- `onTodoChange` preload API event bridge.
- CI workflow for macOS/Windows (typecheck, lint, test, build).
- Release workflow for signed package builds.
- Open-source governance docs (`LICENSE`, `CONTRIBUTING`, `CODE_OF_CONDUCT`, `SECURITY`).
### Changed
- `readMentionedFile` preload API signature now requires `projectRoot`.
- Notification update event contract standardized to `{ total, unreadCount }`.
- Session pagination uses cached displayable-content detection for performance.
- File watcher error detection optimized for append-only updates.
### Fixed
- Lint violations in navigation and markdown/subagent UI components.
- Test mock drift causing runtime errors in test output.
- Multiple Windows path handling edge cases.

158
CLAUDE.md Normal file
View file

@ -0,0 +1,158 @@
# Claude Code Context
Electron app that visualizes Claude Code session execution
## Tech Stack
Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
## Commands
Always use pnpm (not npm/yarn) for this project.
- `pnpm install` - Install dependencies
- `pnpm dev` - Dev server with hot reload
- `pnpm build` - Production build
- `pnpm typecheck` - Type checking
- `pnpm lint:fix` - Lint and auto-fix
- `pnpm format` - Format code
- `pnpm test` - Run all vitest tests
- `pnpm test:watch` - Watch mode
- `pnpm test:coverage` - Coverage report
- `pnpm test:coverage:critical` - Critical path coverage
- `pnpm test:chunks` - Chunk building tests
- `pnpm test:semantic` - Semantic step extraction tests
- `pnpm test:noise` - Noise filtering tests
- `pnpm test:task-filtering` - Task tool filtering tests
## Path Aliases
Use path aliases for imports:
- `@main/*``src/main/*`
- `@renderer/*``src/renderer/*`
- `@shared/*``src/shared/*`
- `@preload/*``src/preload/*`
## Data Sources
~/.claude/projects/{encoded-path}/*.jsonl - Session files
~/.claude/todos/{sessionId}.json - Todo data
Path encoding: `/Users/name/project``-Users-name-project`
## Critical Concepts
### isMeta Flag
- `isMeta: false` = Real user message (creates new chunks)
- `isMeta: true` = Internal message (tool results, system-generated)
### Chunk Structure
Independent chunk types for timeline visualization:
- **UserChunk**: Single user message with metrics
- **AIChunk**: All assistant responses with tool executions and spawned subagents
- **SystemChunk**: Command output/system messages
- **CompactChunk**: System metadata/structural messages
Each chunk has: timestamp, duration, metrics (tokens, cost, tools)
### Task/Subagent Filtering
Task tool_use blocks are filtered when subagent exists
Keep orphaned Task calls (no matching subagent) for visibility.
### Agent Teams
Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team.
- **Process.team?** `{ teamName, memberName, memberColor }` — enriched by SubagentResolver from Task call inputs and `teammate_spawned` tool results
- **Teammate messages** arrive as `<teammate-message teammate_id="..." color="..." summary="...">content</teammate-message>` in user messages (isMeta: false). Detected by `isParsedTeammateMessage()` — excluded from UserChunks, rendered as `TeammateMessageItem` cards
- **Session ongoing detection** treats `SendMessage` shutdown_response (approve: true) and its tool_result as ending events, not ongoing activity
- **Display summary** counts distinct teammates (by name) separately from regular subagents
- **Team tools**: TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamDelete — have readable summaries in `toolSummaryHelpers.ts`
### Visible Context Tracking
Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field):
| Category | Type | Source |
|----------|------|--------|
| `claude-md` | `ClaudeMdContextInjection` | CLAUDE.md files (global, project, directory) |
| `mentioned-file` | `MentionedFileInjection` | User @-mentioned files |
| `tool-output` | `ToolOutputInjection` | Tool execution results (Read, Bash, etc.) |
| `thinking-text` | `ThinkingTextInjection` | Extended thinking + text output tokens |
| `team-coordination` | `TeamCoordinationInjection` | Team tools (SendMessage, TaskCreate, etc.) |
| `user-message` | `UserMessageInjection` | User prompt text per turn |
- **Types**: `src/renderer/types/contextInjection.ts``ContextInjection` union, `ContextStats`, `TokensByCategory`
- **Tracker**: `src/renderer/utils/contextTracker.ts``computeContextStats()`, `processSessionContextWithPhases()`
- **Context Phases**: Compaction events reset accumulated injections, tracked via `ContextPhaseInfo`
- **Display surfaces**: `ContextBadge` (per-turn popover), `TokenUsageDisplay` (hover breakdown), `SessionContextPanel` (full panel)
## Error Handling
- Main: try/catch, console.error, return safe defaults
- Renderer: error state in Zustand store
- IPC: parameter validation, graceful degradation
## Performance
- LRU Cache: Avoid re-parsing large JSONL files
- Streaming JSONL: Line-by-line processing
- Virtual Scrolling: For large session/message lists
- Debounced File Watching: 100ms debounce
## Troubleshooting
### Build Issues
```bash
rm -rf dist dist-electron node_modules
pnpm install
pnpm build
```
### Type Errors
```bash
pnpm typecheck
```
### Test Failures
Check for changes in message parsing or chunk building logic.
## TypeScript Conventions
### Naming
| Category | Convention | Example |
|----------|------------|---------|
| Services/Components | PascalCase | `ProjectScanner.ts` |
| Utilities | camelCase | `pathDecoder.ts` |
| Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` |
| Type Guards | isXxx | `isRealUserMessage()` |
| Builders | buildXxx | `buildChunks()` |
| Getters | getXxx | `getResponses()` |
### Type Guards
```typescript
// Message type guards (src/main/types/messages.ts)
isParsedRealUserMessage(msg) // isMeta: false, string content
isParsedInternalUserMessage(msg) // isMeta: true, array content
isAssistantMessage(msg) // type: "assistant"
// Chunk type guards
isUserChunk(chunk) // type: "user"
isAIChunk(chunk) // type: "ai"
isSystemChunk(chunk) // type: "system"
isCompactChunk(chunk) // type: "compact"
// Context injection type guards (component-scoped in ContextBadge.tsx, not exported)
isClaudeMdInjection(inj) // category: "claude-md"
isMentionedFileInjection(inj) // category: "mentioned-file"
isToolOutputInjection(inj) // category: "tool-output"
isThinkingTextInjection(inj) // category: "thinking-text"
isTeamCoordinationInjection(inj) // category: "team-coordination"
isUserMessageInjection(inj) // category: "user-message"
```
### Barrel Exports
`src/main/services/` and its domain subdirectories have barrel exports via index.ts:
```typescript
// Preferred
import { ChunkBuilder, ProjectScanner } from './services';
// Also valid
import { ChunkBuilder } from './services/analysis';
```
Note: renderer utils/hooks/types do NOT have barrel exports — import directly from files.
### Import Order
1. External packages
2. Path aliases (@main, @renderer, @shared)
3. Relative imports

19
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,19 @@
# Code of Conduct
This project follows the Contributor Covenant Code of Conduct.
## Our Standards
- Be respectful and constructive.
- Assume good intent and discuss ideas, not people.
- Give actionable feedback and accept feedback gracefully.
## Unacceptable Behavior
- Harassment, discrimination, or personal attacks.
- Trolling, insulting language, or sustained disruption.
- Publishing private information without explicit permission.
## Enforcement
Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior.
## Reporting
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.

41
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,41 @@
# Contributing
Thanks for contributing to Claude Code Context.
## Prerequisites
- Node.js 20+
- pnpm 10+
- macOS or Windows
## Setup
```bash
pnpm install
pnpm dev
```
## Quality Gates
Before opening a PR, run:
```bash
pnpm typecheck
pnpm lint
pnpm test
pnpm build
```
## Pull Request Guidelines
- Keep changes focused and small.
- Add/adjust tests for behavior changes.
- Update docs when changing public behavior or setup.
- Use clear PR titles and include a short validation checklist.
## Commit Style
- Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`).
- Include rationale in commit body for non-trivial changes.
## Reporting Bugs
Please include:
- OS version
- app version / commit hash
- repro steps
- expected vs actual behavior
- logs/screenshots when possible

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Claude Code Context contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# Claude Code Context
Desktop app for exploring Claude Code session context usage.
It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers.
## Features
- Repository/worktree-aware project grouping
- Session search with context snippets
- Structured conversation/chunk parsing from Claude JSONL logs
- Context usage inspection (CLAUDE.md + mentioned files + tool output)
- Native notifications with configurable trigger rules
- Real-time updates from Claude session/todo file changes
## Tech Stack
- Electron + electron-vite
- React + TypeScript + Zustand
- Tailwind CSS
- Vitest + ESLint
## Requirements
- Node.js 20+
- pnpm 10+
- macOS or Windows
## Getting Started
```bash
pnpm install
pnpm dev
```
## Data Source
The app reads Claude local data from:
- `~/.claude/projects/`
- `~/.claude/todos/`
## Scripts
```bash
pnpm dev # Run app in development
pnpm typecheck # TypeScript checks
pnpm lint # ESLint (no auto-fix)
pnpm test # Unit tests
pnpm build # Electron/Vite production build
pnpm check # Full local quality gate
pnpm dist:mac # Package macOS app (electron-builder)
pnpm dist:win # Package Windows app (electron-builder)
pnpm dist # Package both targets
```
## Packaging and Release
- Packaging is configured with `electron-builder.yml`.
- CI workflow (`.github/workflows/ci.yml`) runs typecheck/lint/test/build on macOS + Windows.
- Release workflow (`.github/workflows/release.yml`) builds distributables on tags (`v*`).
- Code signing/notarization uses GitHub secrets:
- `CSC_LINK`, `CSC_KEY_PASSWORD`
- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` (macOS notarization)
## Security Notes
- IPC handlers validate IDs/inputs and apply strict path containment checks.
- File reads for context injection are constrained to project root and `~/.claude`.
- Sensitive credential path patterns are blocked.
## Contributing
See:
- `CONTRIBUTING.md`
- `CODE_OF_CONDUCT.md`
- `SECURITY.md`
## License
MIT (`LICENSE`)

20
SECURITY.md Normal file
View file

@ -0,0 +1,20 @@
# Security Policy
## Supported Versions
Only the latest release is supported with security fixes.
## Reporting a Vulnerability
Please report vulnerabilities privately and do not open public issues for undisclosed security problems.
Include:
- affected version/commit
- vulnerability description
- impact assessment
- reproduction steps or proof of concept
If you do not have a private contact path yet, open a minimal GitHub issue asking for a secure reporting channel without disclosing technical details.
## Disclosure Process
- We will acknowledge reports as quickly as possible.
- We will validate, triage severity, and prepare a fix.
- We will coordinate a release and publish advisories when appropriate.

41
electron-builder.yml Normal file
View file

@ -0,0 +1,41 @@
appId: com.claudecode.context
productName: Claude Code Context
directories:
output: release
files:
- out/renderer/**
- dist-electron/**
- package.json
asar: true
extraMetadata:
main: dist-electron/main/index.js
mac:
category: public.app-category.developer-tools
target:
- dmg
- zip
hardenedRuntime: true
gatekeeperAssess: false
icon: resources/icons/mac/icon.icns
dmg:
sign: false
win:
target:
- nsis
icon: resources/icons/win/icon.ico
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
publish:
- provider: github
releaseType: draft

62
electron.vite.config.ts Normal file
View file

@ -0,0 +1,62 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@main': resolve(__dirname, 'src/main'),
'@shared': resolve(__dirname, 'src/shared')
}
},
build: {
outDir: 'dist-electron/main',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts')
}
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@preload': resolve(__dirname, 'src/preload'),
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main')
}
},
build: {
outDir: 'dist-electron/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts')
},
output: {
format: 'cjs',
entryFileNames: '[name].js'
}
}
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main')
}
},
plugins: [react()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html')
}
}
}
}
})

591
eslint.config.js Normal file
View file

@ -0,0 +1,591 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import tailwindcss from 'eslint-plugin-tailwindcss';
import sonarjs from 'eslint-plugin-sonarjs';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import importPlugin from 'eslint-plugin-import';
import security from 'eslint-plugin-security';
import boundaries from 'eslint-plugin-boundaries';
import eslintComments from '@eslint-community/eslint-plugin-eslint-comments';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import globals from 'globals';
export default defineConfig([
// Global ignores
globalIgnores([
'dist/**',
'dist-electron/**',
'build/**',
'node_modules/**',
'*.config.js',
'*.config.cjs',
'*.config.ts',
'out/**',
]),
// Base ESLint recommended rules
js.configs.recommended,
// TypeScript-ESLint recommended with type checking + stylistic
// Using recommended (not strict) for a balanced approach
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
// SonarJS - Code quality and bug detection rules
sonarjs.configs.recommended,
// Security - Catch common security mistakes in AI-generated code
security.configs.recommended,
// TypeScript parser options for type-aware linting
{
name: 'typescript-parser-options',
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// Import plugin configuration - Main/Preload (uses tsconfig.node.json)
{
name: 'import-plugin-main',
files: ['src/main/**/*.ts', 'src/preload/**/*.ts'],
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.node.json',
},
},
},
rules: {
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
'import/no-unresolved': 'error',
'import/no-default-export': 'warn',
},
},
// Import plugin configuration - Renderer (uses tsconfig.json)
{
name: 'import-plugin-renderer',
files: ['src/renderer/**/*.{ts,tsx}'],
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
},
},
rules: {
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
'import/no-unresolved': 'error',
'import/no-default-export': 'warn',
},
},
// Module boundaries - Enforce Electron three-process architecture
{
name: 'module-boundaries',
files: ['src/**/*.{js,jsx,ts,tsx}'],
plugins: {
boundaries: boundaries,
},
settings: {
'boundaries/elements': [
{ type: 'main', pattern: 'src/main/**', mode: 'folder' },
{ type: 'preload', pattern: 'src/preload/**', mode: 'folder' },
{ type: 'renderer', pattern: 'src/renderer/**', mode: 'folder' },
{ type: 'shared', pattern: 'src/shared/**', mode: 'folder' },
],
'boundaries/ignore': ['**/*.test.ts', '**/*.spec.ts'],
},
rules: {
// Enforce strict module boundaries for Electron architecture
'boundaries/element-types': [
'error',
{
default: 'disallow',
rules: [
// Renderer can only import from renderer and shared
{ from: 'renderer', allow: ['renderer', 'shared'] },
// Main process can only import from main and shared
{ from: 'main', allow: ['main', 'shared'] },
// Preload can only import from preload and shared
{ from: 'preload', allow: ['preload', 'shared'] },
// Shared can import from shared and main (for type re-exports)
{ from: 'shared', allow: ['shared', 'main'] },
],
},
],
// Prevent importing private modules
'boundaries/no-private': 'error',
},
},
// ESLint Comments
{
name: 'eslint-comments',
files: ['src/**/*.{js,jsx,ts,tsx}'],
plugins: {
'@eslint-community/eslint-comments': eslintComments,
},
rules: {
// Prevents blanket-disabling rules
'@eslint-community/eslint-comments/no-unlimited-disable': 'error',
// Require description for disable comments
'@eslint-community/eslint-comments/require-description': [
'error',
{ ignore: [] },
],
// Re-enable rules after disabling
'@eslint-community/eslint-comments/disable-enable-pair': 'error',
// No duplicate disable comments
'@eslint-community/eslint-comments/no-duplicate-disable': 'error',
// Unused disable comments
'@eslint-community/eslint-comments/no-unused-disable': 'error',
},
},
// Import sorting for all JS/TS files
{
name: 'import-sorting',
files: ['src/**/*.{js,jsx,ts,tsx}'],
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports (e.g., import './styles.css')
['^\\u0000'],
// Node.js builtins (fs, path, etc.)
['^node:'],
// React and related packages
['^react', '^react-dom'],
// External packages from node_modules
['^@?\\w'],
// Internal aliases (@/ paths)
['^@/'],
// Parent imports (../)
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
// Same-folder imports (./)
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// Type imports
['^.+\\u0000$'],
],
},
],
'simple-import-sort/exports': 'error',
},
},
// Main process (Electron Node.js)
{
name: 'electron-main',
files: ['src/main/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
// Allow console in main process for logging
'no-console': 'off',
},
},
// Preload script (Electron bridge)
{
name: 'electron-preload',
files: ['src/preload/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
},
},
},
// Renderer process (React + A11y + Tailwind)
{
name: 'renderer-react',
files: ['src/renderer/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
},
},
plugins: {
react: reactPlugin,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'jsx-a11y': jsxA11y,
tailwindcss: tailwindcss,
},
settings: {
react: {
version: 'detect',
},
tailwindcss: {
// Tailwind config path (relative to cwd)
config: 'tailwind.config.js',
// Allow custom classnames (e.g., from CSS modules)
callees: ['classnames', 'clsx', 'cn'],
},
},
rules: {
// React recommended rules
...reactPlugin.configs.recommended.rules,
// JSX runtime (React 17+) - no need to import React
...reactPlugin.configs['jsx-runtime'].rules,
// React Hooks rules
...reactHooks.configs.recommended.rules,
// Accessibility rules (recommended)
...jsxA11y.configs.recommended.rules,
// Tailwind CSS rules
...tailwindcss.configs.recommended.rules,
// React Refresh for HMR
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// Disable prop-types since we use TypeScript
'react/prop-types': 'off',
// A11y rule adjustments for this project
// Allow click handlers on divs when keyboard handlers also present
'jsx-a11y/click-events-have-key-events': 'warn',
'jsx-a11y/no-static-element-interactions': 'warn',
// Allow autofocus for search inputs in desktop apps
'jsx-a11y/no-autofocus': 'off',
// Tailwind CSS rule adjustments
// Warn on class order (Prettier plugin handles sorting)
'tailwindcss/classnames-order': 'off', // Prettier plugin handles this
// Warn on conflicting classes
'tailwindcss/no-contradicting-classname': 'error',
// Warn on custom classnames that don't exist
'tailwindcss/no-custom-classname': 'warn',
// === React-Specific Rules ===
// Consistent component definition
'react/function-component-definition': [
'error',
{
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
},
],
// Strengthen exhaustive-deps
'react-hooks/exhaustive-deps': 'error',
// Prevent prop spreading
'react/jsx-props-no-spreading': [
'warn',
{
exceptions: ['input', 'button', 'Input', 'Button', 'textarea', 'select'],
},
],
// Ensure key props
'react/jsx-key': [
'error',
{
checkFragmentShorthand: true,
checkKeyMustBeforeSpread: true,
},
],
// Prevent unnecessary fragments
'react/jsx-no-useless-fragment': 'warn',
// Self-closing components for consistency
'react/self-closing-comp': [
'error',
{
component: true,
html: true,
},
],
},
},
// Test files
{
name: 'test-files',
files: ['test/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
languageOptions: {
globals: {
...globals.node,
},
parserOptions: {
projectService: false,
project: './tsconfig.test.json',
},
},
rules: {
// Relax TypeScript strict rules for tests
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/unbound-method': 'off',
// Relax function/export rules for tests
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// Relax naming conventions for tests (allow describe, it, expect patterns)
'@typescript-eslint/naming-convention': 'off',
// Allow magic numbers in tests
'sonarjs/no-hardcoded-ip': 'off',
// Allow floating promises in tests (common with async test helpers)
'@typescript-eslint/no-floating-promises': 'off',
},
},
// Custom rule overrides for all TypeScript files
{
name: 'custom-rules',
files: ['src/**/*.{ts,tsx}'],
rules: {
// === Core JavaScript rules ===
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
// === TypeScript Import/Export rules ===
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/consistent-type-exports': [
'error',
{ fixMixedExportsWithInlineTypeSpecifier: true },
],
// === Unused variables ===
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
// === Relaxed strict rules for practical use ===
// Allow empty functions (useful for callbacks and stubs)
'@typescript-eslint/no-empty-function': 'off',
// Allow numbers/booleans in template literals (common pattern)
'@typescript-eslint/restrict-template-expressions': [
'error',
{
allowNumber: true,
allowBoolean: true,
allowNullish: false,
},
],
// Allow async functions without await (IPC handlers often need this)
'@typescript-eslint/require-await': 'off',
// Allow floating promises in event handlers (common in Electron)
'@typescript-eslint/no-floating-promises': [
'error',
{
ignoreVoid: true,
ignoreIIFE: true,
},
],
// Allow promises in places that don't expect them (event handlers)
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
arguments: false,
},
},
],
// Allow void expression in arrow functions shorthand
'@typescript-eslint/no-confusing-void-expression': [
'error',
{
ignoreArrowShorthand: true,
ignoreVoidOperator: true,
},
],
// Prefer nullish coalescing but don't error on logical or
'@typescript-eslint/prefer-nullish-coalescing': 'off',
// Allow inferrable types (style preference)
'@typescript-eslint/no-inferrable-types': 'off',
// === Anti-Hallucination Rules ===
// Explicit return types
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
},
],
// Explicit types for exported functions (minimum requirement)
'@typescript-eslint/explicit-module-boundary-types': 'warn',
// Prevent variable shadowing
'@typescript-eslint/no-shadow': 'error',
// === Naming Conventions ===
'@typescript-eslint/naming-convention': [
'warn',
// Imports can be camelCase or PascalCase (React, ReactDOM, App, etc.)
{
selector: 'import',
format: ['camelCase', 'PascalCase'],
},
// Default: variables and parameters in camelCase
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'allow',
},
// Static readonly class properties can be UPPER_CASE
{
selector: 'classProperty',
modifiers: ['static', 'readonly'],
format: ['camelCase', 'UPPER_CASE'],
},
// Variables: camelCase or UPPER_CASE for constants
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
leadingUnderscore: 'allow',
},
// Functions: camelCase (includes type guards like isXxx, builders like buildXxx)
{
selector: 'function',
format: ['camelCase', 'PascalCase'],
},
// Parameters: camelCase, allow leading underscore for unused
{
selector: 'parameter',
format: ['camelCase'],
leadingUnderscore: 'allow',
},
// Types and interfaces in PascalCase
{
selector: 'typeLike',
format: ['PascalCase'],
},
// Interfaces should NOT start with I (modern convention)
{
selector: 'interface',
format: ['PascalCase'],
custom: { regex: '^I[A-Z]', match: false },
},
// Enum members in PascalCase or UPPER_CASE
{
selector: 'enumMember',
format: ['PascalCase', 'UPPER_CASE'],
},
// Object literal properties: allow any format (for API compatibility)
{
selector: 'objectLiteralProperty',
format: null,
},
// Type properties: allow any format (for type definitions matching APIs)
{
selector: 'typeProperty',
format: null,
},
],
// === Import Restrictions ===
// Note: boundaries/element-types handles main/renderer separation
'no-restricted-imports': [
'error',
{
patterns: [
// Prevent deep relative imports - use @/ aliases
{
group: ['../**/..'],
message: 'Avoid deep relative imports, use @/ aliases',
},
],
},
],
// === Mutation Prevention ===
'no-param-reassign': [
'error',
{
props: true,
ignorePropertyModificationsFor: ['draft', 'acc', 'ctx', 'state', 'req', 'res'],
},
],
// === SonarJS rule adjustments ===
// Cognitive complexity - warn instead of error for gradual adoption
'sonarjs/cognitive-complexity': 'off',
// Allow some duplication in similar but not identical code
'sonarjs/no-duplicate-string': 'off',
// Relax for Electron IPC patterns (many similar switch cases)
'sonarjs/no-small-switch': 'off',
// Allow nested ternaries in JSX (common React pattern)
'sonarjs/no-nested-conditional': 'off',
// === Security rule adjustments (Code Protection) ===
// These catch common security mistakes
'security/detect-eval-with-expression': 'error',
// Disabled: This is a desktop file reader app - file system access is expected
'security/detect-non-literal-fs-filename': 'off',
// Disabled: Dynamic patterns are intentional in this app
'security/detect-non-literal-regexp': 'off',
// Disabled: Often false positives with typed code
'security/detect-object-injection': 'off',
'security/detect-child-process': 'warn',
'security/detect-non-literal-require': 'warn',
'security/detect-possible-timing-attacks': 'warn',
},
},
// === IMPORTANT: eslint-config-prettier MUST be LAST ===
// This disables all ESLint rules that conflict with Prettier
// Prettier handles formatting, ESLint handles code quality
eslintConfigPrettier,
]);

17
knip.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://unpkg.com/knip@next/schema.json",
"entry": [
"src/main/index.ts",
"src/preload/index.ts",
"src/renderer/main.tsx",
"electron.vite.config.ts"
],
"project": ["src/**/*.{ts,tsx}!"],
"ignore": ["tsconfig*.json"],
"paths": {
"@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"],
"@preload/*": ["./src/preload/*"],
"@shared/*": ["./src/shared/*"]
}
}

98
package.json Normal file
View file

@ -0,0 +1,98 @@
{
"name": "claude-code-context",
"type": "module",
"version": "0.1.0",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "MIT",
"author": "Claude Code Context contributors",
"homepage": "https://github.com/matt1398/claude-code-context",
"repository": {
"type": "git",
"url": "https://github.com/matt1398/claude-code-context.git"
},
"bugs": {
"url": "https://github.com/matt1398/claude-code-context/issues"
},
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"dist": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --mac --win",
"dist:mac": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --mac",
"dist:win": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --win",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-virtual": "^3.10.8",
"date-fns": "^3.6.0",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"zustand": "^4.5.0"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2",
"@tailwindcss/typography": "^0.5.19",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17",
"electron": "^40.3.0",
"electron-vite": "^2.3.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^5.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.6",
"eslint-plugin-tailwindcss": "^3.18.2",
"globals": "^17.2.0",
"happy-dom": "^17.4.6",
"knip": "^5.82.1",
"postcss": "^8.4.35",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^5.4.2",
"vitest": "^3.1.4"
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}

7228
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- electron
- esbuild

6
postcss.config.cjs Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

31
src/CLAUDE.md Normal file
View file

@ -0,0 +1,31 @@
# src/ Structure
Three-process Electron architecture:
## Processes
- `main/` - Node.js runtime (file system, IPC, lifecycle)
- `preload/` - Secure bridge (contextBridge API)
- `renderer/` - React/Chromium (UI, state, visualization)
- `shared/` - Cross-process types and utilities
## Import Pattern
Use barrel exports from domain folders:
```typescript
import { ChunkBuilder, ProjectScanner } from './services';
```
## IPC Communication
Exposed API via `window.electronAPI`, organized by domain:
| Domain | Methods | Examples |
|--------|---------|---------|
| Sessions | 10 | `getProjects()`, `getSessions()`, `getSessionsPaginated()`, `getSessionDetail()`, `getSessionMetrics()`, `getWaterfallData()`, `getSubagentDetail()`, `searchSessions()`, `getAppVersion()` |
| Repository | 2 | `getRepositoryGroups()`, `getWorktreeSessions()` |
| Validation | 2 | `validatePath()`, `validateMentions()` |
| CLAUDE.md | 3 | `readClaudeMdFiles()`, `readDirectoryClaudeMd()`, `readMentionedFile()` |
| Config | 16 | `config.get()`, `config.update()`, `config.addTrigger()`, `config.openInEditor()`, `config.pinSession()`, `config.unpinSession()`, etc. |
| Notifications | 9 | `notifications.get()`, `notifications.markRead()`, `notifications.onNew()`, etc. |
| Utilities | 7 | `openPath()`, `openExternal()`, `onFileChange()`, `onTodoChange()`, `getZoomFactor()`, `onZoomFactorChanged()` |
| Session | 1 | `session.scrollToLine()` |
Full API signatures in `src/preload/index.ts`, channel constants in `src/preload/constants/ipcChannels.ts`.

32
src/main/CLAUDE.md Normal file
View file

@ -0,0 +1,32 @@
# Main Process
Node.js runtime handling file system, IPC, and app lifecycle.
## Structure
- `index.ts` - App entry point, lifecycle management
- `ipc/` - IPC handlers organized by domain
- `services/` - Business logic by domain
- `types/` - Type definitions
- `utils/` - Utility functions
- `constants/` - Shared constants (messageTags, worktreePatterns)
## IPC Organization
Handlers in `ipc/` by domain:
- `projects.ts` - Project listing
- `sessions.ts` - Session operations
- `search.ts` - Search functionality
- `subagents.ts` - Subagent details
- `validation.ts` - Path validation
- `utility.ts` - Shell & file operations
- `config.ts` - Configuration
- `notifications.ts` - Notifications
## Adding IPC Handler
1. Add to domain file in `ipc/`
2. If new domain, create file and register in `handlers.ts`
3. Add type in `preload/index.ts`
4. Implement in appropriate service
## File Watching
FileWatcher service monitors session files with 100ms debounce.
Notifies renderer of changes via IPC events.

View file

@ -0,0 +1,46 @@
/**
* Message Tag Constants
*
* Centralized XML tag string literals used in message parsing and filtering.
*/
// =============================================================================
// System Output Tags
// =============================================================================
/** Local command stdout wrapper tag */
export const LOCAL_COMMAND_STDOUT_TAG = '<local-command-stdout>';
/** Local command stderr wrapper tag */
export const LOCAL_COMMAND_STDERR_TAG = '<local-command-stderr>';
/** Local command caveat wrapper tag */
const LOCAL_COMMAND_CAVEAT_TAG = '<local-command-caveat>';
/** System reminder wrapper tag */
const SYSTEM_REMINDER_TAG = '<system-reminder>';
// =============================================================================
// Empty Output Tags
// =============================================================================
/** Empty stdout output */
export const EMPTY_STDOUT = '<local-command-stdout></local-command-stdout>';
/** Empty stderr output */
export const EMPTY_STDERR = '<local-command-stderr></local-command-stderr>';
// =============================================================================
// Tag Arrays for Filtering
// =============================================================================
/** Tags that indicate system output (excludes from User chunks) */
export const SYSTEM_OUTPUT_TAGS = [
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
SYSTEM_REMINDER_TAG,
] as const;
/** Tags that indicate hard noise (messages filtered completely) */
export const HARD_NOISE_TAGS = [LOCAL_COMMAND_CAVEAT_TAG, SYSTEM_REMINDER_TAG] as const;

View file

@ -0,0 +1,44 @@
/**
* Worktree Pattern Constants
*
* Centralized worktree-related string literals to avoid duplication.
* These are used in GitIdentityResolver for detecting worktree sources and paths.
*/
// =============================================================================
// Directory Names
// =============================================================================
/** Standard git worktrees subdirectory */
export const WORKTREES_DIR = 'worktrees';
/** Workspaces directory (used by conductor) */
export const WORKSPACES_DIR = 'workspaces';
/** Tasks directory (used by auto-claude) */
export const TASKS_DIR = 'tasks';
// =============================================================================
// Worktree Source Identifiers
// =============================================================================
/** Cursor editor worktrees directory */
export const CURSOR_DIR = '.cursor';
/** Vibe Kanban worktree source */
export const VIBE_KANBAN_DIR = 'vibe-kanban';
/** Conductor worktree source */
export const CONDUCTOR_DIR = 'conductor';
/** Auto-Claude worktree source */
export const AUTO_CLAUDE_DIR = '.auto-claude';
/** 21st/1code worktree source */
export const TWENTYFIRST_DIR = '.21st';
/** Claude Desktop worktrees directory */
export const CLAUDE_WORKTREES_DIR = '.claude-worktrees';
/** ccswitch worktrees directory */
export const CCSWITCH_DIR = '.ccswitch';

295
src/main/index.ts Normal file
View file

@ -0,0 +1,295 @@
/**
* Main process entry point for Claude Code Context.
*
* Responsibilities:
* - Initialize Electron app and main window
* - Set up IPC handlers for data access
* - Initialize services (ProjectScanner, SessionParser, etc.)
* - Start file watcher for live updates
* - Manage application lifecycle
*/
import {
CACHE_CLEANUP_INTERVAL_MINUTES,
CACHE_TTL_MINUTES,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
DEV_SERVER_PORT,
getTrafficLightPositionForZoom,
MAX_CACHE_SESSIONS,
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
} from '@shared/constants';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow } from 'electron';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
// Icon path - works for both dev and production
const getIconPath = (): string => {
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
return join(process.cwd(), 'resources/icon.png');
}
return join(__dirname, '../../resources/icon.png');
};
const logger = createLogger('App');
import {
ChunkBuilder,
configManager,
DataCache,
FileWatcher,
NotificationManager,
ProjectScanner,
SessionParser,
SubagentResolver,
} from './services';
// =============================================================================
// Application State
// =============================================================================
let mainWindow: BrowserWindow | null = null;
// Service instances
let projectScanner: ProjectScanner;
let sessionParser: SessionParser;
let subagentResolver: SubagentResolver;
let chunkBuilder: ChunkBuilder;
let dataCache: DataCache;
let fileWatcher: FileWatcher;
let notificationManager: NotificationManager;
let cleanupInterval: NodeJS.Timeout | null = null;
/**
* Initializes all services.
*/
function initializeServices(): void {
logger.info('Initializing services...');
// Initialize services (paths are set automatically from environment)
projectScanner = new ProjectScanner();
sessionParser = new SessionParser(projectScanner);
subagentResolver = new SubagentResolver(projectScanner);
chunkBuilder = new ChunkBuilder();
const disableCache = process.env.CLAUDE_CONTEXT_DISABLE_CACHE === '1';
dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache);
logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`);
// Initialize IPC handlers
initializeIpcHandlers(projectScanner, sessionParser, subagentResolver, chunkBuilder, dataCache);
// Initialize notification manager using singleton pattern
// This ensures IPC handlers and FileWatcher use the same instance
// Note: mainWindow will be set later via setMainWindow() when window is created
notificationManager = NotificationManager.getInstance();
// Start file watcher with notification manager for error detection
fileWatcher = new FileWatcher(dataCache);
fileWatcher.setNotificationManager(notificationManager);
fileWatcher.start();
// Forward file change events to renderer
// Note: Error detection is handled internally by FileWatcher via NotificationManager
fileWatcher.on('file-change', (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
}
});
fileWatcher.on('todo-change', (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('todo-change', event);
}
});
// Start automatic cache cleanup
cleanupInterval = dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
logger.info('Services initialized successfully');
}
/**
* Shuts down all services.
*/
function shutdownServices(): void {
logger.info('Shutting down services...');
// Stop file watcher
if (fileWatcher) {
fileWatcher.stop();
}
// Stop cache cleanup
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
// Remove IPC handlers
removeIpcHandlers();
logger.info('Services shut down successfully');
}
/**
* Update native traffic-light position and notify renderer of the current zoom factor.
*/
function syncTrafficLightPosition(win: BrowserWindow): void {
const zoomFactor = win.webContents.getZoomFactor();
const position = getTrafficLightPositionForZoom(zoomFactor);
win.setWindowButtonPosition(position);
win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
}
/**
* Creates the main application window.
*/
function createWindow(): void {
mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
icon: getIconPath(),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
backgroundColor: '#1a1a1a',
titleBarStyle: 'hidden',
trafficLightPosition: getTrafficLightPositionForZoom(1),
title: 'Claude Code Context',
});
// Load the renderer
if (process.env.NODE_ENV === 'development') {
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
mainWindow.webContents.openDevTools();
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
// Set traffic light position + notify renderer on first load
mainWindow.webContents.on('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow);
}
});
// Sync traffic light position when zoom changes (Cmd+/-, Cmd+0)
// zoom-changed event doesn't fire in Electron 40, so we detect zoom keys directly.
// Also keeps zoom bounds within a practical readability range.
const MIN_ZOOM_LEVEL = -3; // ~70%
const MAX_ZOOM_LEVEL = 5;
const ZOOM_IN_KEYS = new Set(['+', '=']);
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!input.meta || input.type !== 'keyDown') return;
const currentLevel = mainWindow.webContents.getZoomLevel();
// Block zoom-out beyond minimum
if (ZOOM_OUT_KEYS.has(input.key) && currentLevel <= MIN_ZOOM_LEVEL) {
event.preventDefault();
return;
}
// Block zoom-in beyond maximum
if (ZOOM_IN_KEYS.has(input.key) && currentLevel >= MAX_ZOOM_LEVEL) {
event.preventDefault();
return;
}
// For zoom keys (including Cmd+0 reset), defer sync until zoom is applied
if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') {
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow);
}
}, 100);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
// Clear main window reference from notification manager
if (notificationManager) {
notificationManager.setMainWindow(null);
}
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
logger.error('Renderer process gone:', details.reason, details.exitCode);
// Could show an error dialog or attempt to reload the window
});
// Set main window reference for notification manager
if (notificationManager) {
notificationManager.setMainWindow(mainWindow);
}
logger.info('Main window created');
}
/**
* Application ready handler.
*/
void app.whenReady().then(() => {
logger.info('App ready, initializing...');
// Initialize services first
initializeServices();
// Apply configuration settings
const config = configManager.getConfig();
// Apply launch at login setting
app.setLoginItemSettings({
openAtLogin: config.general.launchAtLogin,
});
// Apply dock visibility and icon (macOS)
if (process.platform === 'darwin') {
if (!config.general.showDockIcon) {
app.dock?.hide();
}
// Set dock icon
app.dock?.setIcon(getIconPath());
}
// Then create window
createWindow();
// Listen for notification click events
notificationManager.on('notification-clicked', (_error) => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
/**
* All windows closed handler.
*/
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
/**
* Before quit handler - cleanup.
*/
app.on('before-quit', () => {
shutdownServices();
});

57
src/main/ipc/CLAUDE.md Normal file
View file

@ -0,0 +1,57 @@
# IPC Handlers
Domain-organized IPC request handlers for main process.
## Structure
```
ipc/
├── handlers.ts # Initialization and registration
├── config.ts # App configuration handlers
├── configValidation.ts # Config input validation/sanitization
├── guards.ts # IPC argument type guards
├── notifications.ts # Notification management
├── projects.ts # Project listing, repository grouping
├── search.ts # Session content search
├── sessions.ts # Session operations, pagination
├── subagents.ts # Subagent detail drill-down
├── utility.ts # Shell operations, file reading
└── validation.ts # Path validation, file mentioning
```
## Handler Pattern
Each domain module exports:
```typescript
// Setup with services
initialize{Domain}Handlers(services)
// Register with ipcMain
register{Domain}Handlers(ipcMain)
// Cleanup on app quit
remove{Domain}Handlers(ipcMain)
```
## Service Dependencies
`initializeIpcHandlers()` receives service instances:
- `ProjectScanner` - File system scanning
- `SessionParser` - JSONL parsing
- `SubagentResolver` - Subagent linking
- `ChunkBuilder` - Chunk analysis
- `DataCache` - Result caching
## Response Pattern
Config handlers use `IpcResult<T>` wrapper:
```typescript
return { success: true, data: result };
return { success: false, error: message };
```
Other handlers return data directly or `null` on error.
## Adding New Handler
1. Add to existing domain file or create new file
2. Call `initialize{Domain}Handlers()` if new domain
3. Add `register/remove{Domain}Handlers` in `handlers.ts`
4. Add channel constant in `preload/constants/ipcChannels.ts`
5. Add method to ElectronAPI in `preload/index.ts`
6. Implement service logic in `src/main/services/`

628
src/main/ipc/config.ts Normal file
View file

@ -0,0 +1,628 @@
/**
* IPC Handlers for App Configuration.
*
* Handlers:
* - config:get: Get full app configuration
* - config:update: Update a specific config section
* - config:addIgnoreRegex: Add an ignore pattern for notifications
* - config:removeIgnoreRegex: Remove an ignore pattern
* - config:addIgnoreRepository: Add a repository to ignore list
* - config:removeIgnoreRepository: Remove a repository from ignore list
* - config:snooze: Set snooze duration for notifications
* - config:clearSnooze: Clear the snooze timer
* - config:addTrigger: Add a new notification trigger
* - config:updateTrigger: Update an existing notification trigger
* - config:removeTrigger: Remove a notification trigger
* - config:getTriggers: Get all notification triggers
* - config:testTrigger: Test a trigger against historical session data
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
import {
type AppConfig,
ConfigManager,
type NotificationTrigger,
type TriggerContentType,
type TriggerMatchField,
type TriggerMode,
type TriggerTokenType,
} from '../services';
import { validateConfigUpdatePayload } from './configValidation';
import { validateTriggerId } from './guards';
import type { TriggerColor } from '@shared/constants/triggerColors';
const logger = createLogger('IPC:config');
// Get singleton instance
const configManager = ConfigManager.getInstance();
/**
* Response type for config operations
*/
interface ConfigResult<T = void> {
success: boolean;
data?: T;
error?: string;
}
/**
* Registers all config-related IPC handlers.
*/
export function registerConfigHandlers(ipcMain: IpcMain): void {
// Get full configuration
ipcMain.handle('config:get', handleGetConfig);
// Update configuration section
ipcMain.handle('config:update', handleUpdateConfig);
// Ignore regex pattern handlers
ipcMain.handle('config:addIgnoreRegex', handleAddIgnoreRegex);
ipcMain.handle('config:removeIgnoreRegex', handleRemoveIgnoreRegex);
// Ignore repository handlers
ipcMain.handle('config:addIgnoreRepository', handleAddIgnoreRepository);
ipcMain.handle('config:removeIgnoreRepository', handleRemoveIgnoreRepository);
// Snooze handlers
ipcMain.handle('config:snooze', handleSnooze);
ipcMain.handle('config:clearSnooze', handleClearSnooze);
// Trigger management handlers
ipcMain.handle('config:addTrigger', handleAddTrigger);
ipcMain.handle('config:updateTrigger', handleUpdateTrigger);
ipcMain.handle('config:removeTrigger', handleRemoveTrigger);
ipcMain.handle('config:getTriggers', handleGetTriggers);
ipcMain.handle('config:testTrigger', handleTestTrigger);
// Session pin handlers
ipcMain.handle('config:pinSession', handlePinSession);
ipcMain.handle('config:unpinSession', handleUnpinSession);
// Dialog handlers
ipcMain.handle('config:selectFolders', handleSelectFolders);
// Editor handlers
ipcMain.handle('config:openInEditor', handleOpenInEditor);
logger.info('Config handlers registered (including trigger management)');
}
// =============================================================================
// Handler Functions
// =============================================================================
/**
* Handler for 'config:get' IPC call.
* Returns the full app configuration.
*/
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<ConfigResult<AppConfig>> {
try {
const config = configManager.getConfig();
return { success: true, data: config };
} catch (error) {
logger.error('Error in config:get:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:update' IPC call.
* Updates a specific section of the configuration.
* Returns the full updated config.
*/
async function handleUpdateConfig(
_event: IpcMainInvokeEvent,
section: unknown,
data: unknown
): Promise<ConfigResult<AppConfig>> {
try {
const validation = validateConfigUpdatePayload(section, data);
if (!validation.valid) {
return { success: false, error: validation.error };
}
configManager.updateConfig(validation.section, validation.data);
const updatedConfig = configManager.getConfig();
return { success: true, data: updatedConfig };
} catch (error) {
logger.error('Error in config:update:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addIgnoreRegex' IPC call.
* Adds a regex pattern to the notification ignore list.
*/
async function handleAddIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<ConfigResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
}
// Validate that the pattern is a valid regex
try {
new RegExp(pattern);
} catch {
return { success: false, error: 'Invalid regex pattern' };
}
configManager.addIgnoreRegex(pattern);
return { success: true };
} catch (error) {
logger.error('Error in config:addIgnoreRegex:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeIgnoreRegex' IPC call.
* Removes a regex pattern from the notification ignore list.
*/
async function handleRemoveIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<ConfigResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
}
configManager.removeIgnoreRegex(pattern);
return { success: true };
} catch (error) {
logger.error('Error in config:removeIgnoreRegex:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addIgnoreRepository' IPC call.
* Adds a repository to the notification ignore list.
*/
async function handleAddIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<ConfigResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
}
configManager.addIgnoreRepository(repositoryId);
return { success: true };
} catch (error) {
logger.error('Error in config:addIgnoreRepository:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeIgnoreRepository' IPC call.
* Removes a repository from the notification ignore list.
*/
async function handleRemoveIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<ConfigResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
}
configManager.removeIgnoreRepository(repositoryId);
return { success: true };
} catch (error) {
logger.error('Error in config:removeIgnoreRepository:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:snooze' IPC call.
* Sets the snooze timer for notifications.
*/
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<ConfigResult> {
try {
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
return { success: false, error: 'Minutes must be a positive number' };
}
configManager.setSnooze(minutes);
return { success: true };
} catch (error) {
logger.error('Error in config:snooze:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:clearSnooze' IPC call.
* Clears the snooze timer.
*/
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<ConfigResult> {
try {
configManager.clearSnooze();
return { success: true };
} catch (error) {
logger.error('Error in config:clearSnooze:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addTrigger' - Adds a new notification trigger.
*/
async function handleAddTrigger(
_event: IpcMainInvokeEvent,
trigger: {
id: string;
name: string;
enabled: boolean;
contentType: string;
mode?: TriggerMode;
requireError?: boolean;
toolName?: string;
matchField?: string;
matchPattern?: string;
ignorePatterns?: string[];
tokenThreshold?: number;
tokenType?: TriggerTokenType;
repositoryIds?: string[];
color?: string;
}
): Promise<ConfigResult> {
try {
if (!trigger.id || !trigger.name || !trigger.contentType) {
return {
success: false,
error: 'Trigger must have id, name, and contentType',
};
}
configManager.addTrigger({
id: trigger.id,
name: trigger.name,
enabled: trigger.enabled,
contentType: trigger.contentType as TriggerContentType,
mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'),
requireError: trigger.requireError,
toolName: trigger.toolName,
matchField: trigger.matchField as TriggerMatchField | undefined,
matchPattern: trigger.matchPattern,
ignorePatterns: trigger.ignorePatterns,
tokenThreshold: trigger.tokenThreshold,
tokenType: trigger.tokenType,
repositoryIds: trigger.repositoryIds,
color: trigger.color as TriggerColor | undefined,
isBuiltin: false,
});
return { success: true };
} catch (error) {
logger.error('Error in config:addTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add trigger',
};
}
}
/**
* Handler for 'config:updateTrigger' - Updates an existing notification trigger.
*/
async function handleUpdateTrigger(
_event: IpcMainInvokeEvent,
triggerId: string,
updates: Partial<{
name: string;
enabled: boolean;
contentType: string;
requireError: boolean;
toolName: string;
matchField: string;
matchPattern: string;
ignorePatterns: string[];
mode: TriggerMode;
tokenThreshold: number;
tokenType: TriggerTokenType;
repositoryIds: string[];
color: string;
}>
): Promise<ConfigResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
return {
success: false,
error: validatedTriggerId.error ?? 'Trigger ID is required',
};
}
configManager.updateTrigger(validatedTriggerId.value!, updates as Partial<NotificationTrigger>);
return { success: true };
} catch (error) {
logger.error('Error in config:updateTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update trigger',
};
}
}
/**
* Handler for 'config:removeTrigger' - Removes a notification trigger.
*/
async function handleRemoveTrigger(
_event: IpcMainInvokeEvent,
triggerId: string
): Promise<ConfigResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
return {
success: false,
error: validatedTriggerId.error ?? 'Trigger ID is required',
};
}
configManager.removeTrigger(validatedTriggerId.value!);
return { success: true };
} catch (error) {
logger.error('Error in config:removeTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove trigger',
};
}
}
/**
* Handler for 'config:getTriggers' - Gets all notification triggers.
*/
async function handleGetTriggers(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<NotificationTrigger[]>> {
try {
const triggers = configManager.getTriggers();
return { success: true, data: triggers };
} catch (error) {
logger.error('Error in config:getTriggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get triggers',
};
}
}
/**
* Handler for 'config:testTrigger' - Tests a trigger against historical session data.
* Returns errors that would have been detected by the trigger.
*
* Safety: Results are truncated if:
* - More than 10,000 total matches found
* - More than 100 sessions scanned
* - Test runs longer than 30 seconds
*/
async function handleTestTrigger(
_event: IpcMainInvokeEvent,
trigger: NotificationTrigger
): Promise<
ConfigResult<{
totalCount: number;
errors: {
id: string;
sessionId: string;
projectId: string;
message: string;
timestamp: number;
source: string;
toolUseId?: string;
subagentId?: string;
lineNumber?: number;
context: { projectName: string };
}[];
/** True if results were truncated due to safety limits */
truncated?: boolean;
}>
> {
try {
const { errorDetector } = await import('../services');
const result = await errorDetector.testTrigger(trigger, 50);
// Map the DetectedError objects to the format expected by the renderer
// Include toolUseId, subagentId, and lineNumber for deep linking to exact error location
const errors = result.errors.map((error) => ({
id: error.id,
sessionId: error.sessionId,
projectId: error.projectId,
message: error.message,
timestamp: error.timestamp,
source: error.source,
toolUseId: error.toolUseId,
subagentId: error.subagentId,
lineNumber: error.lineNumber,
context: { projectName: error.context.projectName },
}));
return {
success: true,
data: { totalCount: result.totalCount, errors, truncated: result.truncated },
};
} catch (error) {
logger.error('Error in config:testTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to test trigger',
};
}
}
/**
* Handler for 'config:pinSession' - Pins a session for a project.
*/
async function handlePinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.pinSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:pinSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:unpinSession' - Unpins a session for a project.
*/
async function handleUnpinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.unpinSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:unpinSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:openInEditor' - Opens the config JSON file in an external editor.
* Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open.
*/
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<ConfigResult> {
try {
const configPath = configManager.getConfigPath();
// Try editors in priority order
const editors: string[] = [];
if (process.env.VISUAL) editors.push(process.env.VISUAL);
if (process.env.EDITOR) editors.push(process.env.EDITOR);
editors.push('cursor', 'code', 'subl', 'zed');
for (const editor of editors) {
try {
await new Promise<void>((resolve, reject) => {
const child = execFile(editor, [configPath], { timeout: 5000 });
// If the process spawns successfully, resolve after a short delay
// (editors typically fork and the parent exits quickly)
const timer = setTimeout(() => resolve(), 500);
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
return { success: true };
} catch {
// Editor not found, try next
continue;
}
}
// Fallback: open with system default
const { shell } = await import('electron');
const errorMessage = await shell.openPath(configPath);
if (errorMessage) {
return { success: false, error: errorMessage };
}
return { success: true };
} catch (error) {
logger.error('Error in config:openInEditor:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
* Allows users to select one or more folders for trigger project scope.
*/
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<ConfigResult<string[]>> {
try {
// Get the focused window for proper dialog parenting
const focusedWindow = BrowserWindow.getFocusedWindow();
// dialog.showOpenDialog accepts either (options) or (window, options)
const dialogOptions: Electron.OpenDialogOptions = {
properties: ['openDirectory', 'multiSelections'],
title: 'Select Project Folders',
buttonLabel: 'Select',
};
const result = focusedWindow
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled) {
return { success: true, data: [] };
}
return { success: true, data: result.filePaths };
} catch (error) {
logger.error('Error in config:selectFolders:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to open folder dialog',
};
}
}
// =============================================================================
// Cleanup
// =============================================================================
/**
* Removes all config-related IPC handlers.
* Should be called when shutting down.
*/
export function removeConfigHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('config:get');
ipcMain.removeHandler('config:update');
ipcMain.removeHandler('config:addIgnoreRegex');
ipcMain.removeHandler('config:removeIgnoreRegex');
ipcMain.removeHandler('config:addIgnoreRepository');
ipcMain.removeHandler('config:removeIgnoreRepository');
ipcMain.removeHandler('config:snooze');
ipcMain.removeHandler('config:clearSnooze');
ipcMain.removeHandler('config:addTrigger');
ipcMain.removeHandler('config:updateTrigger');
ipcMain.removeHandler('config:removeTrigger');
ipcMain.removeHandler('config:getTriggers');
ipcMain.removeHandler('config:testTrigger');
ipcMain.removeHandler('config:pinSession');
ipcMain.removeHandler('config:unpinSession');
ipcMain.removeHandler('config:selectFolders');
ipcMain.removeHandler('config:openInEditor');
logger.info('Config handlers removed');
}

View file

@ -0,0 +1,292 @@
/**
* Runtime validation for config:update IPC payloads.
* Prevents invalid/unknown data from mutating persisted config.
*/
import type {
AppConfig,
DisplayConfig,
GeneralConfig,
NotificationConfig,
NotificationTrigger,
} from '../services';
type ConfigSection = keyof AppConfig;
interface ValidationSuccess<K extends ConfigSection> {
valid: true;
section: K;
data: Partial<AppConfig[K]>;
}
interface ValidationFailure {
valid: false;
error: string;
}
export type ConfigUpdateValidationResult =
| ValidationSuccess<'notifications'>
| ValidationSuccess<'general'>
| ValidationSuccess<'display'>
| ValidationFailure;
const VALID_SECTIONS = new Set<ConfigSection>(['notifications', 'general', 'display']);
const MAX_SNOOZE_MINUTES = 24 * 60;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
if (!isPlainObject(trigger)) {
return false;
}
if (typeof trigger.id !== 'string' || trigger.id.trim().length === 0) {
return false;
}
if (typeof trigger.name !== 'string' || trigger.name.trim().length === 0) {
return false;
}
if (typeof trigger.enabled !== 'boolean') {
return false;
}
if (
trigger.contentType !== 'tool_result' &&
trigger.contentType !== 'tool_use' &&
trigger.contentType !== 'thinking' &&
trigger.contentType !== 'text'
) {
return false;
}
if (
trigger.mode !== 'error_status' &&
trigger.mode !== 'content_match' &&
trigger.mode !== 'token_threshold'
) {
return false;
}
return true;
}
function validateNotificationsSection(
data: unknown
): ValidationSuccess<'notifications'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'notifications update must be an object' };
}
const allowedKeys: (keyof NotificationConfig)[] = [
'enabled',
'soundEnabled',
'includeSubagentErrors',
'ignoredRegex',
'ignoredRepositories',
'snoozedUntil',
'snoozeMinutes',
'triggers',
];
const result: Partial<NotificationConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedKeys.includes(key as keyof NotificationConfig)) {
return {
valid: false,
error: `notifications.${key} is not supported via config:update`,
};
}
switch (key as keyof NotificationConfig) {
case 'enabled':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.enabled = value;
break;
case 'soundEnabled':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.soundEnabled = value;
break;
case 'includeSubagentErrors':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.includeSubagentErrors = value;
break;
case 'ignoredRegex':
if (!isStringArray(value)) {
return { valid: false, error: `notifications.${key} must be a string[]` };
}
result.ignoredRegex = value;
break;
case 'ignoredRepositories':
if (!isStringArray(value)) {
return { valid: false, error: `notifications.${key} must be a string[]` };
}
result.ignoredRepositories = value;
break;
case 'snoozedUntil':
if (value !== null && !isFiniteNumber(value)) {
return { valid: false, error: 'notifications.snoozedUntil must be a number or null' };
}
if (typeof value === 'number' && value < 0) {
return { valid: false, error: 'notifications.snoozedUntil must be >= 0' };
}
result.snoozedUntil = value;
break;
case 'snoozeMinutes':
if (!isFiniteNumber(value) || !Number.isInteger(value)) {
return { valid: false, error: 'notifications.snoozeMinutes must be an integer' };
}
if (value <= 0 || value > MAX_SNOOZE_MINUTES) {
return {
valid: false,
error: `notifications.snoozeMinutes must be between 1 and ${MAX_SNOOZE_MINUTES}`,
};
}
result.snoozeMinutes = value;
break;
case 'triggers':
if (!Array.isArray(value) || !value.every((trigger) => isValidTrigger(trigger))) {
return { valid: false, error: 'notifications.triggers must be a valid trigger[]' };
}
result.triggers = value;
break;
default:
return { valid: false, error: `Unsupported notifications key: ${key}` };
}
}
return {
valid: true,
section: 'notifications',
data: result,
};
}
function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'general update must be an object' };
}
const allowedKeys: (keyof GeneralConfig)[] = [
'launchAtLogin',
'showDockIcon',
'theme',
'defaultTab',
];
const result: Partial<GeneralConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedKeys.includes(key as keyof GeneralConfig)) {
return { valid: false, error: `general.${key} is not a valid setting` };
}
switch (key as keyof GeneralConfig) {
case 'launchAtLogin':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
}
result.launchAtLogin = value;
break;
case 'showDockIcon':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
}
result.showDockIcon = value;
break;
case 'theme':
if (value !== 'dark' && value !== 'light' && value !== 'system') {
return { valid: false, error: 'general.theme must be one of: dark, light, system' };
}
result.theme = value;
break;
case 'defaultTab':
if (value !== 'dashboard' && value !== 'last-session') {
return {
valid: false,
error: 'general.defaultTab must be one of: dashboard, last-session',
};
}
result.defaultTab = value;
break;
default:
return { valid: false, error: `Unsupported general key: ${key}` };
}
}
return {
valid: true,
section: 'general',
data: result,
};
}
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'display update must be an object' };
}
const allowedKeys: (keyof DisplayConfig)[] = [
'showTimestamps',
'compactMode',
'syntaxHighlighting',
];
const result: Partial<DisplayConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedKeys.includes(key as keyof DisplayConfig)) {
return { valid: false, error: `display.${key} is not a valid setting` };
}
if (typeof value !== 'boolean') {
return { valid: false, error: `display.${key} must be a boolean` };
}
result[key as keyof DisplayConfig] = value;
}
return {
valid: true,
section: 'display',
data: result,
};
}
export function validateConfigUpdatePayload(
section: unknown,
data: unknown
): ConfigUpdateValidationResult {
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return { valid: false, error: 'Section must be one of: notifications, general, display' };
}
switch (section as ConfigSection) {
case 'notifications':
return validateNotificationsSection(data);
case 'general':
return validateGeneralSection(data);
case 'display':
return validateDisplaySection(data);
default:
return { valid: false, error: 'Invalid section' };
}
}

148
src/main/ipc/guards.ts Normal file
View file

@ -0,0 +1,148 @@
/**
* IPC guard utilities for runtime validation and coercion.
*
* Main goals:
* - Reject malformed IDs and unbounded inputs at IPC boundaries
* - Keep validation logic consistent across handlers
*/
import { isValidProjectId } from '@main/utils/pathDecoder';
const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const MAX_QUERY_LENGTH = 512;
const MAX_RESULTS = 200;
const MAX_PAGE_LIMIT = 200;
interface ValidationResult<T> {
valid: boolean;
value?: T;
error?: string;
}
function validateString(
value: unknown,
fieldName: string,
maxLength: number = 256
): ValidationResult<string> {
if (typeof value !== 'string') {
return { valid: false, error: `${fieldName} must be a string` };
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return { valid: false, error: `${fieldName} cannot be empty` };
}
if (trimmed.length > maxLength) {
return { valid: false, error: `${fieldName} exceeds max length (${maxLength})` };
}
return { valid: true, value: trimmed };
}
export function validateProjectId(projectId: unknown): ValidationResult<string> {
const basic = validateString(projectId, 'projectId');
if (!basic.valid) {
return basic;
}
if (!isValidProjectId(basic.value!)) {
return { valid: false, error: 'projectId is not a valid encoded Claude project path' };
}
return { valid: true, value: basic.value };
}
export function validateSessionId(sessionId: unknown): ValidationResult<string> {
const basic = validateString(sessionId, 'sessionId', 128);
if (!basic.valid) {
return basic;
}
if (!SESSION_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'sessionId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateSubagentId(subagentId: unknown): ValidationResult<string> {
const basic = validateString(subagentId, 'subagentId', 128);
if (!basic.valid) {
return basic;
}
if (!SUBAGENT_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'subagentId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateNotificationId(notificationId: unknown): ValidationResult<string> {
const basic = validateString(notificationId, 'notificationId', 128);
if (!basic.valid) {
return basic;
}
if (!NOTIFICATION_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'notificationId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateTriggerId(triggerId: unknown): ValidationResult<string> {
const basic = validateString(triggerId, 'triggerId', 128);
if (!basic.valid) {
return basic;
}
if (!TRIGGER_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'triggerId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateSearchQuery(query: unknown): ValidationResult<string> {
if (typeof query !== 'string') {
return { valid: false, error: 'query must be a string' };
}
const trimmed = query.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'query cannot be empty' };
}
if (trimmed.length > MAX_QUERY_LENGTH) {
return { valid: false, error: `query exceeds max length (${MAX_QUERY_LENGTH})` };
}
return { valid: true, value: trimmed };
}
function coerceLimit(value: unknown, defaultValue: number, maxValue: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return defaultValue;
}
const normalized = Math.floor(value);
if (normalized <= 0) {
return defaultValue;
}
return Math.min(normalized, maxValue);
}
export function coerceSearchMaxResults(value: unknown, defaultValue: number = 50): number {
return coerceLimit(value, defaultValue, MAX_RESULTS);
}
export function coercePageLimit(value: unknown, defaultValue: number = 20): number {
return coerceLimit(value, defaultValue, MAX_PAGE_LIMIT);
}

93
src/main/ipc/handlers.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* IPC Handlers - Orchestrates domain-specific handler modules.
*
* This module initializes and registers all IPC handlers from domain modules:
* - projects.ts: Project listing and repository groups
* - sessions.ts: Session operations and pagination
* - search.ts: Session search functionality
* - subagents.ts: Subagent detail retrieval
* - validation.ts: Path validation and scroll handling
* - utility.ts: Shell operations and file reading
* - notifications.ts: Notification management
* - config.ts: App configuration
*/
import { createLogger } from '@shared/utils/logger';
import { ipcMain } from 'electron';
import { registerConfigHandlers, removeConfigHandlers } from './config';
const logger = createLogger('IPC:handlers');
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
import {
initializeProjectHandlers,
registerProjectHandlers,
removeProjectHandlers,
} from './projects';
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
import {
initializeSessionHandlers,
registerSessionHandlers,
removeSessionHandlers,
} from './sessions';
import {
initializeSubagentHandlers,
registerSubagentHandlers,
removeSubagentHandlers,
} from './subagents';
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
import { registerValidationHandlers, removeValidationHandlers } from './validation';
import type {
ChunkBuilder,
DataCache,
ProjectScanner,
SessionParser,
SubagentResolver,
} from '../services';
/**
* Initializes IPC handlers with service instances.
*/
export function initializeIpcHandlers(
scanner: ProjectScanner,
parser: SessionParser,
resolver: SubagentResolver,
builder: ChunkBuilder,
cache: DataCache
): void {
// Initialize domain handlers with their required services
initializeProjectHandlers(scanner);
initializeSessionHandlers(scanner, parser, resolver, builder, cache);
initializeSearchHandlers(scanner);
initializeSubagentHandlers(builder, cache, parser, resolver);
// Register all handlers
registerProjectHandlers(ipcMain);
registerSessionHandlers(ipcMain);
registerSearchHandlers(ipcMain);
registerSubagentHandlers(ipcMain);
registerValidationHandlers(ipcMain);
registerUtilityHandlers(ipcMain);
registerNotificationHandlers(ipcMain);
registerConfigHandlers(ipcMain);
logger.info('All handlers registered');
}
/**
* Removes all IPC handlers.
* Should be called when shutting down.
*/
export function removeIpcHandlers(): void {
removeProjectHandlers(ipcMain);
removeSessionHandlers(ipcMain);
removeSearchHandlers(ipcMain);
removeSubagentHandlers(ipcMain);
removeValidationHandlers(ipcMain);
removeUtilityHandlers(ipcMain);
removeNotificationHandlers(ipcMain);
removeConfigHandlers(ipcMain);
logger.info('All handlers removed');
}

View file

@ -0,0 +1,186 @@
/**
* IPC Handlers for Notification Operations.
*
* Handlers:
* - notifications:get: Get all notifications (paginated)
* - notifications:markRead: Mark notification as read
* - notifications:markAllRead: Mark all as read
* - notifications:delete: Delete a single notification
* - notifications:clear: Clear all notifications
* - notifications:getUnreadCount: Get unread count for badge
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import {
type GetNotificationsOptions,
type GetNotificationsResult,
NotificationManager,
} from '../services';
import { coercePageLimit, validateNotificationId } from './guards';
const logger = createLogger('IPC:notifications');
/**
* Registers all notification-related IPC handlers.
*
* @param ipcMain - The Electron IpcMain instance
*/
export function registerNotificationHandlers(ipcMain: IpcMain): void {
ipcMain.handle('notifications:get', handleGetNotifications);
ipcMain.handle('notifications:markRead', handleMarkRead);
ipcMain.handle('notifications:markAllRead', handleMarkAllRead);
ipcMain.handle('notifications:delete', handleDelete);
ipcMain.handle('notifications:clear', handleClear);
ipcMain.handle('notifications:getUnreadCount', handleGetUnreadCount);
logger.info('Notification handlers registered');
}
/**
* Removes all notification IPC handlers.
* Should be called when shutting down.
*/
export function removeNotificationHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('notifications:get');
ipcMain.removeHandler('notifications:markRead');
ipcMain.removeHandler('notifications:markAllRead');
ipcMain.removeHandler('notifications:delete');
ipcMain.removeHandler('notifications:clear');
ipcMain.removeHandler('notifications:getUnreadCount');
logger.info('Notification handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'notifications:get' IPC call.
* Gets all notifications with optional pagination and filtering.
*/
async function handleGetNotifications(
_event: IpcMainInvokeEvent,
options?: GetNotificationsOptions
): Promise<GetNotificationsResult> {
try {
const opts = options ?? {};
const safeOptions: GetNotificationsOptions = {
limit: coercePageLimit(opts.limit, 20),
offset:
typeof opts.offset === 'number' && Number.isFinite(opts.offset) && opts.offset >= 0
? Math.floor(opts.offset)
: 0,
};
const manager = NotificationManager.getInstance();
const result = await manager.getNotifications(safeOptions);
return result;
} catch (error) {
logger.error('Error in notifications:get:', getErrorMessage(error));
return {
notifications: [],
total: 0,
totalCount: 0,
unreadCount: 0,
hasMore: false,
};
}
}
/**
* Handler for 'notifications:markRead' IPC call.
* Marks a specific notification as read.
*/
async function handleMarkRead(
_event: IpcMainInvokeEvent,
notificationId: string
): Promise<boolean> {
try {
const validatedNotification = validateNotificationId(notificationId);
if (!validatedNotification.valid) {
logger.error(
`notifications:markRead rejected: ${validatedNotification.error ?? 'Invalid notificationId'}`
);
return false;
}
const manager = NotificationManager.getInstance();
const success = await manager.markRead(validatedNotification.value!);
return success;
} catch (error) {
logger.error(`Error in notifications:markRead for ${notificationId}:`, error);
return false;
}
}
/**
* Handler for 'notifications:markAllRead' IPC call.
* Marks all notifications as read.
*/
async function handleMarkAllRead(_event: IpcMainInvokeEvent): Promise<boolean> {
try {
const manager = NotificationManager.getInstance();
const success = await manager.markAllRead();
return success;
} catch (error) {
logger.error('Error in notifications:markAllRead:', error);
return false;
}
}
/**
* Handler for 'notifications:delete' IPC call.
* Deletes a single notification.
*/
async function handleDelete(_event: IpcMainInvokeEvent, notificationId: string): Promise<boolean> {
try {
const validatedNotification = validateNotificationId(notificationId);
if (!validatedNotification.valid) {
logger.error(
`notifications:delete rejected: ${validatedNotification.error ?? 'Invalid notificationId'}`
);
return false;
}
const manager = NotificationManager.getInstance();
const success = manager.deleteNotification(validatedNotification.value!);
return success;
} catch (error) {
logger.error(`Error in notifications:delete for ${notificationId}:`, error);
return false;
}
}
/**
* Handler for 'notifications:clear' IPC call.
* Clears all notifications.
*/
async function handleClear(_event: IpcMainInvokeEvent): Promise<boolean> {
try {
const manager = NotificationManager.getInstance();
const success = await manager.clearAll();
return success;
} catch (error) {
logger.error('Error in notifications:clear:', error);
return false;
}
}
/**
* Handler for 'notifications:getUnreadCount' IPC call.
* Gets the count of unread notifications for badge display.
*/
async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise<number> {
try {
const manager = NotificationManager.getInstance();
const count = await manager.getUnreadCount();
return count;
} catch (error) {
logger.error('Error in notifications:getUnreadCount:', error);
return 0;
}
}

109
src/main/ipc/projects.ts Normal file
View file

@ -0,0 +1,109 @@
/**
* IPC Handlers for Project Operations.
*
* Handlers:
* - get-projects: List all projects
* - get-repository-groups: List projects grouped by git repository
* - get-worktree-sessions: List sessions for a specific worktree
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import { type Project, type RepositoryGroup, type Session } from '../types';
import { validateProjectId } from './guards';
import type { ProjectScanner } from '../services';
const logger = createLogger('IPC:projects');
// Service instance - set via initialize
let projectScanner: ProjectScanner;
/**
* Initializes project handlers with service instance.
*/
export function initializeProjectHandlers(scanner: ProjectScanner): void {
projectScanner = scanner;
}
/**
* Registers all project-related IPC handlers.
*/
export function registerProjectHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-projects', handleGetProjects);
ipcMain.handle('get-repository-groups', handleGetRepositoryGroups);
ipcMain.handle('get-worktree-sessions', handleGetWorktreeSessions);
logger.info('Project handlers registered');
}
/**
* Removes all project IPC handlers.
*/
export function removeProjectHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-projects');
ipcMain.removeHandler('get-repository-groups');
ipcMain.removeHandler('get-worktree-sessions');
logger.info('Project handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'get-projects' IPC call.
* Lists all projects from ~/.claude/projects/
*/
async function handleGetProjects(_event: IpcMainInvokeEvent): Promise<Project[]> {
try {
const projects = await projectScanner.scan();
return projects;
} catch (error) {
logger.error('Error in get-projects:', error);
return [];
}
}
/**
* Handler for 'get-repository-groups' IPC call.
* Lists all projects grouped by git repository.
* Worktrees of the same repo are grouped together.
*/
async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<RepositoryGroup[]> {
try {
const groups = await projectScanner.scanWithWorktreeGrouping();
return groups;
} catch (error) {
logger.error('Error in get-repository-groups:', error);
return [];
}
}
/**
* Handler for 'get-worktree-sessions' IPC call.
* Lists all sessions for a specific worktree within a repository group.
*/
async function handleGetWorktreeSessions(
_event: IpcMainInvokeEvent,
worktreeId: string
): Promise<Session[]> {
try {
const validatedProject = validateProjectId(worktreeId);
if (!validatedProject.valid) {
logger.error(
`get-worktree-sessions rejected: ${validatedProject.error ?? 'Invalid worktreeId'}`
);
return [];
}
const sessions = await projectScanner.listWorktreeSessions(validatedProject.value!);
return sessions;
} catch (error) {
logger.error(`Error in get-worktree-sessions for ${worktreeId}:`, error);
return [];
}
}

82
src/main/ipc/search.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* IPC Handlers for Search Operations.
*
* Handlers:
* - search-sessions: Search sessions in a project
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import { type SearchSessionsResult } from '../types';
import { coerceSearchMaxResults, validateProjectId, validateSearchQuery } from './guards';
const logger = createLogger('IPC:search');
import type { ProjectScanner } from '../services';
// Service instance - set via initialize
let projectScanner: ProjectScanner;
/**
* Initializes search handlers with service instance.
*/
export function initializeSearchHandlers(scanner: ProjectScanner): void {
projectScanner = scanner;
}
/**
* Registers all search-related IPC handlers.
*/
export function registerSearchHandlers(ipcMain: IpcMain): void {
ipcMain.handle('search-sessions', handleSearchSessions);
logger.info('Search handlers registered');
}
/**
* Removes all search IPC handlers.
*/
export function removeSearchHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('search-sessions');
logger.info('Search handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'search-sessions' IPC call.
* Searches sessions in a project for a query string.
*/
async function handleSearchSessions(
_event: IpcMainInvokeEvent,
projectId: string,
query: string,
maxResults?: number
): Promise<SearchSessionsResult> {
try {
const validatedProject = validateProjectId(projectId);
const validatedQuery = validateSearchQuery(query);
if (!validatedProject.valid || !validatedQuery.valid) {
logger.error(
`search-sessions rejected: ${validatedProject.error ?? validatedQuery.error ?? 'Invalid inputs'}`
);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const safeMaxResults = coerceSearchMaxResults(maxResults, 50);
const result = await projectScanner.searchSessions(
validatedProject.value!,
validatedQuery.value!,
safeMaxResults
);
return result;
} catch (error) {
logger.error(`Error in search-sessions for project ${projectId}:`, error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}

305
src/main/ipc/sessions.ts Normal file
View file

@ -0,0 +1,305 @@
/**
* IPC Handlers for Session Operations.
*
* Handlers:
* - get-sessions: List sessions for a project
* - get-sessions-paginated: List sessions with cursor-based pagination
* - get-session-detail: Get full session detail with subagents
* - get-session-groups: Get conversation groups for a session
* - get-session-metrics: Get metrics for a session
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import { DataCache } from '../services';
import {
type ConversationGroup,
type PaginatedSessionsResult,
type Session,
type SessionDetail,
type SessionMetrics,
type SessionsPaginationOptions,
} from '../types';
import { coercePageLimit, validateProjectId, validateSessionId } from './guards';
import type { ChunkBuilder, ProjectScanner, SessionParser, SubagentResolver } from '../services';
import type { WaterfallData } from '@shared/types';
const logger = createLogger('IPC:sessions');
// Service instances - set via initialize
let projectScanner: ProjectScanner;
let sessionParser: SessionParser;
let subagentResolver: SubagentResolver;
let chunkBuilder: ChunkBuilder;
let dataCache: DataCache;
/**
* Initializes session handlers with service instances.
*/
export function initializeSessionHandlers(
scanner: ProjectScanner,
parser: SessionParser,
resolver: SubagentResolver,
builder: ChunkBuilder,
cache: DataCache
): void {
projectScanner = scanner;
sessionParser = parser;
subagentResolver = resolver;
chunkBuilder = builder;
dataCache = cache;
}
/**
* Registers all session-related IPC handlers.
*/
export function registerSessionHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-sessions', handleGetSessions);
ipcMain.handle('get-sessions-paginated', handleGetSessionsPaginated);
ipcMain.handle('get-session-detail', handleGetSessionDetail);
ipcMain.handle('get-session-groups', handleGetSessionGroups);
ipcMain.handle('get-session-metrics', handleGetSessionMetrics);
ipcMain.handle('get-waterfall-data', handleGetWaterfallData);
logger.info('Session handlers registered');
}
/**
* Removes all session IPC handlers.
*/
export function removeSessionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-sessions');
ipcMain.removeHandler('get-sessions-paginated');
ipcMain.removeHandler('get-session-detail');
ipcMain.removeHandler('get-session-groups');
ipcMain.removeHandler('get-session-metrics');
ipcMain.removeHandler('get-waterfall-data');
logger.info('Session handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'get-sessions' IPC call.
* Lists all sessions for a given project.
*/
async function handleGetSessions(
_event: IpcMainInvokeEvent,
projectId: string
): Promise<Session[]> {
try {
const validatedProject = validateProjectId(projectId);
if (!validatedProject.valid) {
logger.error(`get-sessions rejected: ${validatedProject.error ?? 'Invalid projectId'}`);
return [];
}
const sessions = await projectScanner.listSessions(validatedProject.value!);
return sessions;
} catch (error) {
logger.error(`Error in get-sessions for project ${projectId}:`, error);
return [];
}
}
/**
* Handler for 'get-sessions-paginated' IPC call.
* Lists sessions for a project with cursor-based pagination.
*/
async function handleGetSessionsPaginated(
_event: IpcMainInvokeEvent,
projectId: string,
cursor: string | null,
limit?: number,
options?: SessionsPaginationOptions
): Promise<PaginatedSessionsResult> {
try {
const validatedProject = validateProjectId(projectId);
if (!validatedProject.valid) {
logger.error(
`get-sessions-paginated rejected: ${validatedProject.error ?? 'Invalid projectId'}`
);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
const safeLimit = coercePageLimit(limit, 20);
const result = await projectScanner.listSessionsPaginated(
validatedProject.value!,
cursor,
safeLimit,
options
);
return result;
} catch (error) {
logger.error(`Error in get-sessions-paginated for project ${projectId}:`, error);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
}
/**
* Handler for 'get-session-detail' IPC call.
* Gets full session detail including parsed chunks and subagents.
* Uses cache to avoid re-parsing large files.
*/
async function handleGetSessionDetail(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<SessionDetail | null> {
try {
const validatedProject = validateProjectId(projectId);
const validatedSession = validateSessionId(sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`get-session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'Invalid parameters'}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
// Check cache first
let sessionDetail = dataCache.get(cacheKey);
if (sessionDetail) {
return sessionDetail;
}
// Get session metadata
const session = await projectScanner.getSession(safeProjectId, safeSessionId);
if (!session) {
logger.error(`Session not found: ${sessionId}`);
return null;
}
// Parse session messages
const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId);
// Resolve subagents
const subagents = await subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
// Build session detail with chunks
sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents);
// Cache the result
dataCache.set(cacheKey, sessionDetail);
return sessionDetail;
} catch (error) {
logger.error(`Error in get-session-detail for ${projectId}/${sessionId}:`, error);
return null;
}
}
/**
* Handler for 'get-session-groups' IPC call.
* Gets conversation groups for a session using the new buildGroups API.
* This is an alternative to chunks that provides a simpler, more natural grouping.
*/
async function handleGetSessionGroups(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConversationGroup[]> {
try {
const validatedProject = validateProjectId(projectId);
const validatedSession = validateSessionId(sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`get-session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'Invalid parameters'}`
);
return [];
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
// Parse session messages
const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId);
// Resolve subagents
const subagents = await subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
// Build conversation groups using the new API
const groups = chunkBuilder.buildGroups(parsedSession.messages, subagents);
return groups;
} catch (error) {
logger.error(`Error in get-session-groups for ${projectId}/${sessionId}:`, error);
return [];
}
}
/**
* Handler for 'get-session-metrics' IPC call.
* Gets metrics for a session without full detail.
*/
async function handleGetSessionMetrics(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<SessionMetrics | null> {
try {
const validatedProject = validateProjectId(projectId);
const validatedSession = validateSessionId(sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
// Try to get from cache first
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
const cached = dataCache.get(cacheKey);
if (cached) {
return cached.metrics;
}
// Parse session to get metrics
const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId);
return parsedSession.metrics;
} catch (error) {
logger.error(`Error in get-session-metrics for ${projectId}/${sessionId}:`, error);
return null;
}
}
/**
* Handler for 'get-waterfall-data' IPC call.
* Builds waterfall chart data for a session.
*/
async function handleGetWaterfallData(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<WaterfallData | null> {
try {
const detail = await handleGetSessionDetail(_event, projectId, sessionId);
if (!detail) {
return null;
}
return chunkBuilder.buildWaterfallData(detail.chunks, detail.processes);
} catch (error) {
logger.error(`Error in get-waterfall-data for ${projectId}/${sessionId}:`, error);
return null;
}
}

124
src/main/ipc/subagents.ts Normal file
View file

@ -0,0 +1,124 @@
/**
* IPC Handlers for Subagent Operations.
*
* Handlers:
* - get-subagent-detail: Get detailed information for a specific subagent
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import { type SubagentDetail } from '../types';
import { validateProjectId, validateSessionId, validateSubagentId } from './guards';
import type { ChunkBuilder, DataCache, SessionParser, SubagentResolver } from '../services';
const logger = createLogger('IPC:subagents');
// Service instances - set via initialize
let chunkBuilder: ChunkBuilder;
let dataCache: DataCache;
let sessionParser: SessionParser;
let subagentResolver: SubagentResolver;
/**
* Initializes subagent handlers with service instances.
*/
export function initializeSubagentHandlers(
builder: ChunkBuilder,
cache: DataCache,
parser: SessionParser,
resolver: SubagentResolver
): void {
chunkBuilder = builder;
dataCache = cache;
sessionParser = parser;
subagentResolver = resolver;
}
/**
* Registers all subagent-related IPC handlers.
*/
export function registerSubagentHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-subagent-detail', handleGetSubagentDetail);
logger.info('Subagent handlers registered');
}
/**
* Removes all subagent IPC handlers.
*/
export function removeSubagentHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-subagent-detail');
logger.info('Subagent handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'get-subagent-detail' IPC call.
* Gets detailed information for a specific subagent for drill-down modal.
*/
async function handleGetSubagentDetail(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string,
subagentId: string
): Promise<SubagentDetail | null> {
try {
const validatedProject = validateProjectId(projectId);
const validatedSession = validateSessionId(sessionId);
const validatedSubagent = validateSubagentId(subagentId);
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
logger.error(
`get-subagent-detail rejected: ${
validatedProject.error ??
validatedSession.error ??
validatedSubagent.error ??
'Invalid parameters'
}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const safeSubagentId = validatedSubagent.value!;
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
// Check cache first
let subagentDetail = dataCache.getSubagent(cacheKey);
if (subagentDetail) {
return subagentDetail;
}
// Build subagent detail
const builtDetail = await chunkBuilder.buildSubagentDetail(
safeProjectId,
safeSessionId,
safeSubagentId,
sessionParser,
subagentResolver
);
if (!builtDetail) {
logger.error(`Subagent not found: ${safeSubagentId}`);
return null;
}
subagentDetail = builtDetail;
// Cache the result
dataCache.setSubagent(cacheKey, subagentDetail);
return subagentDetail;
} catch (error) {
logger.error(`Error in get-subagent-detail for ${subagentId}:`, error);
return null;
}
}

230
src/main/ipc/utility.ts Normal file
View file

@ -0,0 +1,230 @@
/**
* IPC Handlers for Utility Operations.
*
* Handlers:
* - shell:openPath: Opens a folder or file in the system's default application
* - read-claude-md-files: Reads all global CLAUDE.md files for a project
* - read-directory-claude-md: Reads a specific directory's CLAUDE.md file
* - read-mentioned-file: Validates mentioned files for context injection
*/
import { createLogger } from '@shared/utils/logger';
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
import * as fs from 'fs';
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
const logger = createLogger('IPC:utility');
import { validateFilePath, validateOpenPath } from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer';
/**
* Registers all utility-related IPC handlers.
*/
export function registerUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-app-version', handleGetAppVersion);
ipcMain.handle('shell:openPath', handleShellOpenPath);
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
logger.info('Utility handlers registered');
}
/**
* Removes all utility IPC handlers.
*/
export function removeUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-app-version');
ipcMain.removeHandler('shell:openPath');
ipcMain.removeHandler('shell:openExternal');
ipcMain.removeHandler('read-claude-md-files');
ipcMain.removeHandler('read-directory-claude-md');
ipcMain.removeHandler('read-mentioned-file');
logger.info('Utility handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'get-app-version' IPC call.
* Returns the app version from package.json.
*/
function handleGetAppVersion(): string {
return app.getVersion();
}
/**
* Handler for 'shell:openExternal' IPC call.
* Opens a URL in the system's default browser.
*/
async function handleShellOpenExternal(
_event: IpcMainInvokeEvent,
url: string
): Promise<{ success: boolean; error?: string }> {
try {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return { success: false, error: 'Invalid URL' };
}
const protocol = parsedUrl.protocol.toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:' && protocol !== 'mailto:') {
logger.error(`shell:openExternal - invalid URL scheme: ${url}`);
return { success: false, error: 'Only http, https, and mailto URLs are allowed' };
}
await shell.openExternal(parsedUrl.toString());
return { success: true };
} catch (error) {
logger.error('Error in shell:openExternal:', error);
return { success: false, error: String(error) };
}
}
/**
* Handler for 'shell:openPath' IPC call.
* Opens a folder or file in the system's default application (Finder on macOS).
* Validates path security before opening.
*/
async function handleShellOpenPath(
_event: IpcMainInvokeEvent,
targetPath: string,
projectRoot?: string
): Promise<{ success: boolean; error?: string }> {
try {
// Validate path security
const validation = validateOpenPath(targetPath, projectRoot ?? null);
if (!validation.valid) {
logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`);
return { success: false, error: validation.error };
}
const safePath = validation.normalizedPath!;
// Check if path exists
if (!fs.existsSync(safePath)) {
logger.error(`shell:openPath - path does not exist: ${safePath}`);
return { success: false, error: 'Path does not exist' };
}
// Open in default application (Finder on macOS)
const errorMessage = await shell.openPath(safePath);
if (errorMessage) {
logger.error(`shell:openPath - failed: ${errorMessage}`);
return { success: false, error: errorMessage };
}
return { success: true };
} catch (error) {
logger.error('Error in shell:openPath:', error);
return { success: false, error: String(error) };
}
}
/**
* Handler for 'read-claude-md-files' IPC call.
* Reads all global CLAUDE.md files for a project.
*/
async function handleReadClaudeMdFiles(
_event: IpcMainInvokeEvent,
projectRoot: string
): Promise<Record<string, ClaudeMdFileInfo>> {
try {
const result = readAllClaudeMdFiles(projectRoot);
// Convert Map to object for IPC serialization
const files: Record<string, ClaudeMdFileInfo> = {};
result.files.forEach((info, key) => {
files[key] = info;
});
return files;
} catch (error) {
logger.error(`Error in read-claude-md-files:`, error);
return {};
}
}
/**
* Handler for 'read-directory-claude-md' IPC call.
* Reads a specific directory's CLAUDE.md file.
*/
async function handleReadDirectoryClaudeMd(
_event: IpcMainInvokeEvent,
dirPath: string
): Promise<ClaudeMdFileInfo> {
try {
const info = readDirectoryClaudeMd(dirPath);
return info;
} catch (error) {
logger.error(`Error in read-directory-claude-md:`, error);
return {
path: dirPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
}
/**
* Handler for 'read-mentioned-file' IPC call.
* Validates mentioned files for context injection.
* Returns file info if file exists, is a regular file, within allowed directories, and within token limits.
*
* Security: Validates path against allowed directories and sensitive file patterns.
*/
async function handleReadMentionedFile(
_event: IpcMainInvokeEvent,
absolutePath: string,
projectRoot: string,
maxTokens: number = 25000
): Promise<{ path: string; exists: boolean; charCount: number; estimatedTokens: number } | null> {
try {
// Validate path security
const validation = validateFilePath(absolutePath, projectRoot || null);
if (!validation.valid) {
return null;
}
const safePath = validation.normalizedPath!;
// Check if file exists
if (!fs.existsSync(safePath)) {
return null;
}
// Check if it's a file (not directory)
const stats = fs.statSync(safePath);
if (!stats.isFile()) {
return null;
}
// Read file content
const content = fs.readFileSync(safePath, 'utf8');
// Calculate tokens
const estimatedTokens = countTokens(content);
// Check token limit
if (estimatedTokens > maxTokens) {
return null;
}
return {
path: safePath,
exists: true,
charCount: content.length,
estimatedTokens,
};
} catch (error) {
logger.error(`Error in read-mentioned-file for ${absolutePath}:`, error);
return null;
}
}

145
src/main/ipc/validation.ts Normal file
View file

@ -0,0 +1,145 @@
/**
* IPC Handlers for Validation Operations.
*
* Handlers:
* - validate-path: Validate if a file/directory path exists relative to project
* - validate-mentions: Batch validate path mentions (@file references)
* - session:scrollToLine: Deep link handler for scrolling to a specific line in a session
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
const logger = createLogger('IPC:validation');
/**
* Registers all validation-related IPC handlers.
*/
export function registerValidationHandlers(ipcMain: IpcMain): void {
ipcMain.handle('validate-path', handleValidatePath);
ipcMain.handle('validate-mentions', handleValidateMentions);
ipcMain.handle('session:scrollToLine', handleScrollToLine);
logger.info('Validation handlers registered');
}
/**
* Removes all validation IPC handlers.
*/
export function removeValidationHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('validate-path');
ipcMain.removeHandler('validate-mentions');
ipcMain.removeHandler('session:scrollToLine');
logger.info('Validation handlers removed');
}
// =============================================================================
// Security Helpers
// =============================================================================
/**
* Checks if a path is contained within a base directory.
* Prevents path traversal attacks (e.g., ../../etc/passwd).
*/
function isPathContained(fullPath: string, basePath: string): boolean {
const normalizedFull = path.normalize(fullPath);
const normalizedBase = path.normalize(basePath);
// Ensure the full path starts with the base path followed by a separator
// or is exactly the base path
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'validate-path' IPC call.
* Validates if a file/directory path exists relative to project.
*/
async function handleValidatePath(
_event: IpcMainInvokeEvent,
relativePath: string,
projectPath: string
): Promise<{ exists: boolean; isDirectory?: boolean }> {
try {
const fullPath = path.join(projectPath, relativePath);
// Security: Ensure path doesn't escape project directory
if (!isPathContained(fullPath, projectPath)) {
logger.warn('validate-path blocked path traversal attempt:', relativePath);
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
return {
exists: true,
isDirectory: stats.isDirectory(),
};
} catch {
return { exists: false };
}
}
/**
* Handler for 'validate-mentions' IPC call.
* Batch validates path mentions (@file references).
* Slash commands do not need validation.
*/
async function handleValidateMentions(
_event: IpcMainInvokeEvent,
mentions: { type: 'path'; value: string }[],
projectPath: string
): Promise<Record<string, boolean>> {
const results = new Map<string, boolean>();
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
return Object.fromEntries(results);
}
/**
* Handler for 'session:scrollToLine' IPC call.
* Used for deep linking from notifications to specific lines in a session.
* The actual scrolling happens in the renderer; this handler validates and returns the data.
*/
async function handleScrollToLine(
_event: IpcMainInvokeEvent,
sessionId: string,
lineNumber: number
): Promise<{ success: boolean; sessionId: string; lineNumber: number }> {
try {
if (!sessionId) {
logger.error('session:scrollToLine called with empty sessionId');
return { success: false, sessionId: '', lineNumber: 0 };
}
if (typeof lineNumber !== 'number' || lineNumber < 0) {
logger.error('session:scrollToLine called with invalid lineNumber');
return { success: false, sessionId, lineNumber: 0 };
}
return { success: true, sessionId, lineNumber };
} catch (error) {
logger.error(`Error in session:scrollToLine:`, error);
return { success: false, sessionId: '', lineNumber: 0 };
}
}

View file

@ -0,0 +1,59 @@
# Services
Business logic organized by domain.
## Domains
- `analysis/` - Chunk building, semantic steps, tool execution
- `discovery/` - Project/session scanning, subagent resolution
- `error/` - Error detection, trigger checking
- `infrastructure/` - Cache, file watching, config, notifications
- `parsing/` - JSONL parsing, message classification
## Key Services
### Analysis
- **ChunkBuilder** - Orchestrates chunk building
- **ChunkFactory** - Creates chunk objects
- **ConversationGroupBuilder** - Builds conversation groups
- **ProcessLinker** - Links subagents to chunks
- **SemanticStepExtractor** - Extracts steps
- **SemanticStepGrouper** - Groups semantic steps
- **SubagentDetailBuilder** - Builds subagent detail views
- **ToolExecutionBuilder** - Builds tool execution tracking
- **ToolResultExtractor** - Extracts tool results
- **ToolSummaryFormatter** - Formats tool summaries
### Discovery
- **ProjectPathResolver** - Resolves project paths
- **ProjectScanner** - Scans ~/.claude/projects/
- **SessionContentFilter** - Filters session content
- **SessionSearcher** - Searches session content
- **SubagentLocator** - Locates subagent files
- **SubagentResolver** - Parses subagent files, detects parallel execution, enriches team metadata/colors
- **SubprojectRegistry** - Tracks subproject associations
- **WorktreeGrouper** - Groups projects by git worktree
### Parsing
- **SessionParser** - Parses JSONL files
- **MessageClassifier** - Categorizes messages (user, system, AI, noise)
- **ClaudeMdReader** - Reads CLAUDE.md configuration
- **GitIdentityResolver** - Resolves git identities
### Error
- **ErrorDetector** - Per-tool-use token counting, returns `DetectedError[]`
- **ErrorMessageBuilder** - Builds error notification messages
- **ErrorTriggerChecker** - Matches against notification triggers
- **ErrorTriggerTester** - Tests triggers against historical data
- **TriggerMatcher** - Pattern matching for triggers
### Infrastructure
- **DataCache** - LRU cache (50 entries, 10min TTL)
- **FileWatcher** - 100ms debounced file watching
- **ConfigManager** - App configuration
- **NotificationManager** - Notification handling
- **TriggerManager** - Notification trigger management
## Adding Service
1. Create in appropriate domain folder
2. Export from domain's index.ts
3. Re-export from services/index.ts

View file

@ -0,0 +1,443 @@
/**
* ChunkBuilder service - Builds visualization chunks from parsed session data.
*
* Responsibilities:
* - Group messages into chunks (user message + responses)
* - Attach subagents to chunks
* - Build waterfall chart data
* - Calculate chunk metrics
*
* This module orchestrates chunk building using specialized modules:
* - MessageClassifier: Classify messages into categories
* - ChunkFactory: Create individual chunk objects
* - ProcessLinker: Link subagent processes to chunks
* - SemanticStepExtractor: Extract semantic steps from AI chunks
* - SemanticStepGrouper: Group semantic steps for UI
* - ToolExecutionBuilder: Build tool execution tracking
* - SubagentDetailBuilder: Build subagent drill-down details
* - ConversationGroupBuilder: Alternative grouping strategy
*/
import {
type Chunk,
type ConversationGroup,
EMPTY_METRICS,
type EnhancedChunk,
isAIChunk,
isCompactChunk,
isSystemChunk,
isUserChunk,
type MessageCategory,
type ParsedMessage,
type Process,
type Session,
type SessionDetail,
type SessionMetrics,
type SubagentDetail,
type TokenUsage,
} from '@main/types';
import { calculateMetrics } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import type { WaterfallData, WaterfallItem } from '@shared/types';
const logger = createLogger('Service:ChunkBuilder');
import { classifyMessages } from '../parsing/MessageClassifier';
import {
buildAIChunkFromBuffer,
buildCompactChunk,
buildSystemChunk,
buildUserChunk,
} from './ChunkFactory';
import { buildGroups as buildConversationGroups } from './ConversationGroupBuilder';
import { buildSubagentDetail as buildSubagentDetailFn } from './SubagentDetailBuilder';
import type { SubagentResolver } from '../discovery/SubagentResolver';
import type { SessionParser } from '../parsing/SessionParser';
export class ChunkBuilder {
// ===========================================================================
// Chunk Building
// ===========================================================================
/**
* Build chunks from messages using 4-category classification.
* Produces independent UserChunks, AIChunks, and SystemChunks.
*
* Categories:
* - User: Genuine user input (creates UserChunk, renders RIGHT)
* - System: Command output <local-command-stdout> (creates SystemChunk, renders LEFT)
* - Hard Noise: Filtered out entirely (system metadata, caveats, reminders)
* - AI: All other messages grouped into AIChunks (renders LEFT)
*
* All chunk types are INDEPENDENT - no pairing between User and AI.
*/
buildChunks(messages: ParsedMessage[], subagents: Process[] = []): EnhancedChunk[] {
const chunks: EnhancedChunk[] = [];
// Filter to main thread messages (non-sidechain)
const mainMessages = messages.filter((m) => !m.isSidechain);
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
// Classify each message into categories using MessageClassifier
const classified = classifyMessages(mainMessages);
// Log classification summary
const categoryCounts = new Map<MessageCategory, number>();
for (const { category } of classified) {
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
}
logger.debug('Message classification:', Object.fromEntries(categoryCounts));
// Build chunks from classification - AI chunks are INDEPENDENT
let aiBuffer: ParsedMessage[] = [];
for (const { message, category } of classified) {
switch (category) {
case 'hardNoise':
// Skip - filtered out
break;
case 'compact':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildCompactChunk(message));
break;
case 'user':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildUserChunk(message));
break;
case 'system':
// Flush any buffered AI messages first
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
aiBuffer = [];
}
chunks.push(buildSystemChunk(message));
break;
case 'ai':
aiBuffer.push(message);
break;
}
}
// Flush remaining AI buffer
if (aiBuffer.length > 0) {
chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages));
}
// Log final chunk summary
const userChunkCount = chunks.filter(isUserChunk).length;
const aiChunkCount = chunks.filter(isAIChunk).length;
const systemChunkCount = chunks.filter(isSystemChunk).length;
const compactChunkCount = chunks.filter(isCompactChunk).length;
logger.debug(
`Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact`
);
return chunks;
}
// ===========================================================================
// Simplified Grouping Strategy (delegates to ConversationGroupBuilder)
// ===========================================================================
/**
* Build conversation groups using simplified grouping strategy.
* Groups one user message with all AI responses until the next user message.
*
* This is a cleaner alternative to buildChunks() that:
* - Uses simpler time-based grouping
* - Separates Task executions from regular tool executions
* - Links subagents more explicitly via TaskExecution
*/
buildGroups(messages: ParsedMessage[], subagents: Process[]): ConversationGroup[] {
return buildConversationGroups(messages, subagents);
}
// ===========================================================================
// Session Detail Building
// ===========================================================================
/**
* Build a complete SessionDetail from parsed data.
*/
buildSessionDetail(
session: Session,
messages: ParsedMessage[],
subagents: Process[]
): SessionDetail {
// Build chunks
const chunks = this.buildChunks(messages, subagents);
// Calculate overall metrics
const metrics = calculateMetrics(messages);
return {
session,
messages,
chunks,
processes: subagents,
metrics,
};
}
/**
* Build waterfall chart data from chunks and resolved processes.
*/
buildWaterfallData(chunks: Chunk[], processes: Process[]): WaterfallData {
const items: WaterfallItem[] = [];
for (const chunk of chunks) {
const baseChunkItem: WaterfallItem = {
id: chunk.id,
label: this.getChunkLabel(chunk),
startTime: chunk.startTime,
endTime: chunk.endTime,
durationMs: chunk.durationMs,
tokenUsage: this.toTokenUsage(chunk.metrics),
level: 0,
type: 'chunk',
isParallel: false,
};
items.push(baseChunkItem);
if (isAIChunk(chunk)) {
for (const toolExec of chunk.toolExecutions) {
const endTime = toolExec.endTime ?? toolExec.startTime;
items.push({
id: `tool-${toolExec.toolCall.id}`,
label: toolExec.toolCall.name,
startTime: toolExec.startTime,
endTime,
durationMs:
toolExec.durationMs ?? Math.max(endTime.getTime() - toolExec.startTime.getTime(), 0),
tokenUsage: {
input_tokens: 0,
output_tokens: 0,
},
level: 1,
type: 'tool',
isParallel: false,
parentId: chunk.id,
});
}
for (const process of chunk.processes) {
items.push({
id: `subagent-${process.id}`,
label: process.description || process.subagentType || process.id,
startTime: process.startTime,
endTime: process.endTime,
durationMs: process.durationMs,
tokenUsage: this.toTokenUsage(process.metrics),
level: 1,
type: 'subagent',
isParallel: process.isParallel,
parentId: chunk.id,
metadata: {
subagentType: process.subagentType,
messageCount: process.messages.length,
},
});
}
}
}
// Add any process that was not attached to an AI chunk (defensive fallback)
for (const process of processes) {
const itemId = `subagent-${process.id}`;
if (items.some((item) => item.id === itemId)) {
continue;
}
items.push({
id: itemId,
label: process.description || process.subagentType || process.id,
startTime: process.startTime,
endTime: process.endTime,
durationMs: process.durationMs,
tokenUsage: this.toTokenUsage(process.metrics),
level: 0,
type: 'subagent',
isParallel: process.isParallel,
metadata: {
subagentType: process.subagentType,
messageCount: process.messages.length,
},
});
}
const sortedItems = [...items];
sortedItems.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
if (sortedItems.length === 0) {
const now = new Date();
return {
items: [],
minTime: now,
maxTime: now,
totalDurationMs: 0,
};
}
const minTime = sortedItems.reduce(
(min, item) => (item.startTime.getTime() < min.getTime() ? item.startTime : min),
sortedItems[0].startTime
);
const maxTime = sortedItems.reduce(
(max, item) => (item.endTime.getTime() > max.getTime() ? item.endTime : max),
sortedItems[0].endTime
);
return {
items: sortedItems,
minTime,
maxTime,
totalDurationMs: Math.max(maxTime.getTime() - minTime.getTime(), 0),
};
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Get total metrics for all chunks.
*/
getTotalChunkMetrics(chunks: (Chunk | EnhancedChunk)[]): SessionMetrics {
if (chunks.length === 0) {
return { ...EMPTY_METRICS };
}
let durationMs = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let messageCount = 0;
for (const chunk of chunks) {
durationMs += chunk.durationMs;
inputTokens += chunk.metrics.inputTokens;
outputTokens += chunk.metrics.outputTokens;
cacheReadTokens += chunk.metrics.cacheReadTokens;
cacheCreationTokens += chunk.metrics.cacheCreationTokens;
messageCount += chunk.metrics.messageCount;
}
return {
durationMs,
totalTokens: inputTokens + outputTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
messageCount,
};
}
/**
* Find chunk containing a specific message UUID.
*/
findChunkByMessageId(
chunks: (Chunk | EnhancedChunk)[],
messageUuid: string
): Chunk | EnhancedChunk | undefined {
return chunks.find((c) => {
// UserChunk: check userMessage
if (isUserChunk(c)) {
return c.userMessage.uuid === messageUuid;
}
// AIChunk: check responses
if (isAIChunk(c)) {
return c.responses.some((r) => r.uuid === messageUuid);
}
return false;
});
}
/**
* Find chunk containing a specific subagent.
* Only AIChunks have processes.
*/
findChunkBySubagentId(
chunks: (Chunk | EnhancedChunk)[],
subagentId: string
): Chunk | EnhancedChunk | undefined {
return chunks.find((c) => {
if (isAIChunk(c)) {
return c.processes.some((s: Process) => s.id === subagentId);
}
return false;
});
}
private getChunkLabel(chunk: Chunk): string {
switch (chunk.chunkType) {
case 'user':
return 'User';
case 'ai':
return 'Assistant';
case 'system':
return 'System';
case 'compact':
return 'Compact';
default:
return 'Chunk';
}
}
private toTokenUsage(metrics: SessionMetrics): TokenUsage {
return {
input_tokens: metrics.inputTokens,
output_tokens: metrics.outputTokens,
cache_read_input_tokens: metrics.cacheReadTokens || undefined,
cache_creation_input_tokens: metrics.cacheCreationTokens || undefined,
};
}
// ===========================================================================
// Subagent Detail Building (for drill-down)
// ===========================================================================
/**
* Build detailed information for a specific subagent.
* Used for drill-down modal to show subagent's internal execution.
*
* @param projectId - Project ID
* @param sessionId - Parent session ID (currently unused, kept for API consistency)
* @param subagentId - Subagent ID to load
* @param sessionParser - SessionParser instance for parsing subagent file
* @param subagentResolver - SubagentResolver instance for nested subagents
* @returns SubagentDetail or null if not found
*/
async buildSubagentDetail(
projectId: string,
sessionId: string,
subagentId: string,
sessionParser: SessionParser,
subagentResolver: SubagentResolver
): Promise<SubagentDetail | null> {
// Delegate to the extracted module, passing buildChunks as a callback
return buildSubagentDetailFn(
projectId,
sessionId,
subagentId,
sessionParser,
subagentResolver,
(messages, subagents) => this.buildChunks(messages, subagents)
);
}
}

View file

@ -0,0 +1,203 @@
/**
* ChunkFactory service - Creates individual chunk objects from messages.
*
* Responsibilities:
* - Build UserChunk from user messages
* - Build SystemChunk from command output messages
* - Build CompactChunk from summary messages
* - Build AIChunk from buffered AI messages
* - Calculate timing and metrics for chunks
*/
import {
type EnhancedAIChunk,
type EnhancedCompactChunk,
type EnhancedSystemChunk,
type EnhancedUserChunk,
type ParsedMessage,
type Process,
} from '@main/types';
import { calculateStepContext } from '@main/utils/contextAccumulator';
import { calculateMetrics } from '@main/utils/jsonl';
import { fillTimelineGaps } from '@main/utils/timelineGapFilling';
import { linkProcessesToAIChunk } from './ProcessLinker';
import { extractSemanticStepsFromAIChunk } from './SemanticStepExtractor';
import { buildSemanticStepGroups } from './SemanticStepGrouper';
import { buildToolExecutions } from './ToolExecutionBuilder';
/**
* Generate a stable chunk ID based on message UUID.
* Using the message UUID ensures IDs are consistent across re-parses.
*/
function generateStableChunkId(prefix: string, message: ParsedMessage): string {
return `${prefix}-${message.uuid}`;
}
/**
* Build a UserChunk from a user message.
*/
export function buildUserChunk(message: ParsedMessage): EnhancedUserChunk {
const id = generateStableChunkId('user', message);
const metrics = calculateMetrics([message]);
return {
id,
chunkType: 'user',
userMessage: message,
startTime: message.timestamp,
endTime: message.timestamp,
durationMs: 0,
metrics,
rawMessages: [message],
};
}
/**
* Build a SystemChunk from a command output message.
*/
export function buildSystemChunk(message: ParsedMessage): EnhancedSystemChunk {
const id = generateStableChunkId('system', message);
const commandOutput = extractCommandOutput(message);
const metrics = calculateMetrics([message]);
return {
id,
chunkType: 'system',
message,
commandOutput,
startTime: message.timestamp,
endTime: message.timestamp,
durationMs: 0,
metrics,
rawMessages: [message],
};
}
/**
* Build a CompactChunk from a compact summary message.
*/
export function buildCompactChunk(message: ParsedMessage): EnhancedCompactChunk {
const id = generateStableChunkId('compact', message);
const metrics = calculateMetrics([message]);
return {
id,
chunkType: 'compact',
message,
startTime: message.timestamp,
endTime: message.timestamp,
durationMs: 0,
metrics,
rawMessages: [message],
};
}
/**
* Extract command output from <local-command-stdout> tag.
*/
function extractCommandOutput(message: ParsedMessage): string {
const content = typeof message.content === 'string' ? message.content : '';
const match = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/.exec(content);
const matchStderr = /<local-command-stderr>([\s\S]*?)<\/local-command-stderr>/.exec(content);
if (match) {
return match[1];
}
if (matchStderr) {
return matchStderr[1];
}
return content;
}
/**
* Build an AIChunk from buffered AI messages.
*/
export function buildAIChunkFromBuffer(
responses: ParsedMessage[],
subagents: Process[],
allMessages: ParsedMessage[]
): EnhancedAIChunk {
// Use first response message's UUID for stable ID
const id =
responses.length > 0 ? generateStableChunkId('ai', responses[0]) : `ai-empty-${Date.now()}`; // Fallback for edge case
const { startTime, endTime, durationMs } = calculateAIChunkTiming(responses);
const metrics = calculateMetrics(responses);
const toolExecutions = buildToolExecutions(responses);
// Collect sidechain messages for this time range
const sidechainMessages = collectSidechainMessages(allMessages, startTime, endTime);
const chunk: EnhancedAIChunk = {
id,
chunkType: 'ai',
responses,
startTime,
endTime,
durationMs,
metrics,
processes: [],
sidechainMessages,
toolExecutions,
semanticSteps: [],
rawMessages: responses,
};
// Link processes to this chunk
linkProcessesToAIChunk(chunk, subagents);
// Extract semantic steps using the extracted module
chunk.semanticSteps = extractSemanticStepsFromAIChunk(chunk);
chunk.semanticSteps = fillTimelineGaps({
steps: chunk.semanticSteps,
chunkStartTime: chunk.startTime,
chunkEndTime: chunk.endTime,
});
calculateStepContext(chunk.semanticSteps, chunk.rawMessages);
chunk.semanticStepGroups = buildSemanticStepGroups(chunk.semanticSteps);
return chunk;
}
/**
* Calculate timing for AI chunks (responses only, no user message).
*/
function calculateAIChunkTiming(responses: ParsedMessage[]): {
startTime: Date;
endTime: Date;
durationMs: number;
} {
if (responses.length === 0) {
const now = new Date();
return { startTime: now, endTime: now, durationMs: 0 };
}
const startTime = responses[0].timestamp;
let endTime = startTime;
for (const resp of responses) {
if (resp.timestamp > endTime) {
endTime = resp.timestamp;
}
}
return {
startTime,
endTime,
durationMs: endTime.getTime() - startTime.getTime(),
};
}
/**
* Collect sidechain messages in a time range.
*/
function collectSidechainMessages(
messages: ParsedMessage[],
startTime: Date,
endTime: Date | undefined
): ParsedMessage[] {
return messages.filter((m) => {
if (!m.isSidechain) return false;
if (m.timestamp < startTime) return false;
if (endTime && m.timestamp >= endTime) return false;
return true;
});
}

View file

@ -0,0 +1,213 @@
/**
* ConversationGroupBuilder - Alternative grouping strategy for conversation flow.
*
* Groups one user message with all AI responses until the next user message.
* This is a cleaner alternative to buildChunks() that:
* - Uses simpler time-based grouping
* - Separates Task executions from regular tool executions
* - Links subagents more explicitly via TaskExecution
*/
import {
type ConversationGroup,
isParsedUserChunkMessage,
type ParsedMessage,
type Process,
type TaskExecution,
type ToolCall,
type ToolExecution,
} from '@main/types';
import { calculateMetrics } from '@main/utils/jsonl';
/**
* Build conversation groups using simplified grouping strategy.
* Groups one user message with all AI responses until the next user message.
*/
export function buildGroups(messages: ParsedMessage[], subagents: Process[]): ConversationGroup[] {
const groups: ConversationGroup[] = [];
// Step 1: Filter to main thread only (not sidechain)
const mainMessages = messages.filter((m) => !m.isSidechain);
// Step 2: Find all REAL user messages (these start groups)
// Use isParsedUserChunkMessage to filter out noise
const userMessages = mainMessages.filter(isParsedUserChunkMessage);
// Step 3: For each user message, collect all AI responses until next user message
for (let i = 0; i < userMessages.length; i++) {
const userMsg = userMessages[i];
const nextUserMsg = userMessages[i + 1];
// Collect all messages between this user message and the next
const aiResponses = collectAIResponses(mainMessages, userMsg, nextUserMsg);
// Separate Task tool results from regular tool executions
const { taskExecutions, regularToolExecutions } = separateTaskExecutions(
aiResponses,
subagents
);
// Link subagents to this group
const groupSubagents = linkSubagentsToGroup(userMsg, nextUserMsg, subagents);
// Calculate metrics
const { startTime, endTime, durationMs } = calculateGroupTiming(userMsg, aiResponses);
const metrics = calculateMetrics([userMsg, ...aiResponses]);
groups.push({
id: `group-${i + 1}`,
type: 'user-ai-exchange',
userMessage: userMsg,
aiResponses,
processes: groupSubagents,
toolExecutions: regularToolExecutions,
taskExecutions,
startTime,
endTime,
durationMs,
metrics,
});
}
return groups;
}
/**
* Collect AI responses between a user message and the next user message.
* Simpler than collectResponses - just uses timestamp boundaries.
*/
function collectAIResponses(
messages: ParsedMessage[],
userMsg: ParsedMessage,
nextUserMsg: ParsedMessage | undefined
): ParsedMessage[] {
const responses: ParsedMessage[] = [];
const startTime = userMsg.timestamp;
const endTime = nextUserMsg?.timestamp;
for (const msg of messages) {
// Skip if before this user message
if (msg.timestamp <= startTime) continue;
// Skip if at or after next user message
if (endTime && msg.timestamp >= endTime) continue;
// Include ALL non-user messages (assistant + internal user messages)
if (msg.type === 'assistant' || (msg.type === 'user' && msg.isMeta === true)) {
responses.push(msg);
}
}
return responses;
}
/**
* Separate Task executions from regular tool executions.
* Task tools spawn subagents, so we track them separately to avoid duplication.
*/
function separateTaskExecutions(
responses: ParsedMessage[],
allSubagents: Process[]
): { taskExecutions: TaskExecution[]; regularToolExecutions: ToolExecution[] } {
const taskExecutions: TaskExecution[] = [];
const regularToolExecutions: ToolExecution[] = [];
// Build map of tool_use_id -> subagent for Task calls
const taskIdToSubagent = new Map<string, Process>();
for (const subagent of allSubagents) {
if (subagent.parentTaskId) {
taskIdToSubagent.set(subagent.parentTaskId, subagent);
}
}
// Collect all tool calls
const toolCalls = new Map<string, { call: ToolCall; timestamp: Date }>();
for (const msg of responses) {
if (msg.type === 'assistant') {
for (const toolCall of msg.toolCalls) {
toolCalls.set(toolCall.id, { call: toolCall, timestamp: msg.timestamp });
}
}
}
// Match with results
for (const msg of responses) {
if (msg.type === 'user' && msg.isMeta === true && msg.sourceToolUseID) {
const callInfo = toolCalls.get(msg.sourceToolUseID);
if (!callInfo) continue;
// Check if this is a Task call with a subagent
const subagent = taskIdToSubagent.get(msg.sourceToolUseID);
if (callInfo.call.name === 'Task' && subagent) {
// This is a Task execution
taskExecutions.push({
taskCall: callInfo.call,
taskCallTimestamp: callInfo.timestamp,
subagent,
toolResult: msg,
resultTimestamp: msg.timestamp,
durationMs: msg.timestamp.getTime() - callInfo.timestamp.getTime(),
});
} else {
// Regular tool execution
const result = msg.toolResults[0];
if (result) {
regularToolExecutions.push({
toolCall: callInfo.call,
result,
startTime: callInfo.timestamp,
endTime: msg.timestamp,
durationMs: msg.timestamp.getTime() - callInfo.timestamp.getTime(),
});
}
}
}
}
return { taskExecutions, regularToolExecutions };
}
/**
* Link subagents to a conversation group based on timing.
*/
function linkSubagentsToGroup(
userMsg: ParsedMessage,
nextUserMsg: ParsedMessage | undefined,
allSubagents: Process[]
): Process[] {
const groupSubagents: Process[] = [];
const startTime = userMsg.timestamp;
const endTime = nextUserMsg?.timestamp ?? new Date(Date.now() + 1000 * 60 * 60 * 24); // Far future if no next message
// Collect subagents that start within this group's time range
for (const subagent of allSubagents) {
if (subagent.startTime >= startTime && subagent.startTime < endTime) {
groupSubagents.push(subagent);
}
}
return groupSubagents;
}
/**
* Calculate group timing from user message and AI responses.
*/
function calculateGroupTiming(
userMsg: ParsedMessage,
aiResponses: ParsedMessage[]
): { startTime: Date; endTime: Date; durationMs: number } {
const startTime = userMsg.timestamp;
let endTime = startTime;
for (const resp of aiResponses) {
if (resp.timestamp > endTime) {
endTime = resp.timestamp;
}
}
return {
startTime,
endTime,
durationMs: endTime.getTime() - startTime.getTime(),
};
}

View file

@ -0,0 +1,61 @@
/**
* ProcessLinker service - Links subagent processes to AI chunks.
*
* Uses a two-tier linking strategy:
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
* that spawned them. This is reliable even when the response is still in progress.
* 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to
* checking if the subagent's startTime falls within the chunk's time range.
*/
import { type EnhancedAIChunk, type Process } from '@main/types';
/**
* Link processes to a single AI chunk.
*
* Uses a two-tier linking strategy:
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
* that spawned them. This is reliable even when the response is still in progress.
* 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to
* checking if the subagent's startTime falls within the chunk's time range.
*/
export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void {
// Build set of Task tool IDs from this chunk's responses
const chunkTaskIds = new Set<string>();
for (const response of chunk.responses) {
for (const toolCall of response.toolCalls) {
if (toolCall.isTask) {
chunkTaskIds.add(toolCall.id);
}
}
}
// Track which subagents have been linked
const linkedSubagentIds = new Set<string>();
// Primary linking: Match subagents to Task calls by parentTaskId
for (const subagent of subagents) {
if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) {
chunk.processes.push(subagent);
linkedSubagentIds.add(subagent.id);
}
}
// Fallback linking: For orphaned subagents, use timing-based matching
// This handles edge cases where parentTaskId might not be set
for (const subagent of subagents) {
if (linkedSubagentIds.has(subagent.id)) {
continue; // Already linked via parentTaskId
}
// Only use timing fallback if subagent has no parentTaskId
// (If it has parentTaskId but didn't match, it belongs to a different chunk)
if (!subagent.parentTaskId) {
if (subagent.startTime >= chunk.startTime && subagent.startTime <= chunk.endTime) {
chunk.processes.push(subagent);
}
}
}
chunk.processes.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}

View file

@ -0,0 +1,210 @@
/**
* SemanticStepExtractor - Extracts semantic steps from AI chunks.
*
* Semantic steps represent logical units of work within AI responses:
* - thinking: Claude's reasoning process
* - tool_call: Tool invocation
* - tool_result: Tool execution result
* - output: Text output from Claude
* - subagent: Nested agent execution
* - interruption: User interruption
*/
import { countContentTokens } from '@main/utils/tokenizer';
import type { AIChunk, EnhancedAIChunk, SemanticStep } from '@main/types';
/**
* Extract semantic steps from AI chunk responses.
* Semantic steps represent logical units of work within responses.
*
* Note: ALL tool calls are included, including Task tools with subagents.
* Task tools are filtered in the renderer's buildDisplayItems,
* but they are kept here for accurate context token tracking in aggregateToolOutputs.
*/
export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk): SemanticStep[] {
const steps: SemanticStep[] = [];
let stepIdCounter = 0;
// Note: Task tool calls are included in semantic steps for context token tracking.
// The renderer's buildDisplayItems filters Task tools with subagents.
// Process only AI responses (no user message in AIChunk)
for (const msg of chunk.responses) {
if (msg.type === 'assistant') {
// Extract from content blocks
const content = Array.isArray(msg.content) ? msg.content : [];
for (const block of content) {
if (block.type === 'thinking' && block.thinking) {
// Calculate tokens for thinking content (output from Claude)
const thinkingTokens = countContentTokens(block.thinking);
steps.push({
id: `${msg.uuid}-thinking-${stepIdCounter++}`,
type: 'thinking',
startTime: new Date(msg.timestamp),
durationMs: 0, // Estimated from token count
content: {
thinkingText: block.thinking,
tokenCount: thinkingTokens, // Pre-computed token count
},
tokens: {
input: 0,
output: thinkingTokens, // Thinking is output from Claude
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
if (block.type === 'tool_use' && block.id && block.name) {
// Include ALL tool calls in semantic steps, including Task tools with processes.
// Task tools with processes are filtered from DISPLAY in the renderer's buildDisplayItems,
// but they should be included here for accurate context token tracking.
// The renderer's aggregateToolOutputs will correctly count Task tool tokens
// as part of the main session's context consumption.
// Calculate tool call tokens directly from name + input
// This reflects what actually enters the context window
const callTokens = countContentTokens(block.name + JSON.stringify(block.input));
steps.push({
id: block.id,
type: 'tool_call',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
toolName: block.name,
toolInput: block.input,
sourceModel: msg.model,
},
tokens: {
input: callTokens,
output: 0,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
if (block.type === 'text' && block.text) {
// Calculate tokens for text output (Claude's generated text)
const textTokens = countContentTokens(block.text);
steps.push({
id: `${msg.uuid}-output-${stepIdCounter++}`,
type: 'output',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
outputText: block.text,
tokenCount: textTokens, // Pre-computed token count for consistency
},
tokens: {
input: 0, // Text output is generated by Claude, not input
output: textTokens,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
sourceMessageId: msg.uuid,
});
}
}
}
// Tool results from internal user messages
// Note: isMeta can be true or null in JSONL, so check for toolResults presence directly
if (msg.type === 'user' && msg.toolResults && msg.toolResults.length > 0) {
for (const result of msg.toolResults) {
steps.push({
id: result.toolUseId,
type: 'tool_result',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
toolResultContent:
typeof result.content === 'string' ? result.content : JSON.stringify(result.content),
isError: result.isError,
toolUseResult: msg.toolUseResult, // Enriched data from message
tokenCount: countContentTokens(result.content), // Pre-computed token count
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
}
}
// User interruption messages
// These are user messages with array content containing text like "[Request interrupted by user]"
if (msg.type === 'user' && Array.isArray(msg.content)) {
let foundInterruption = false;
for (const block of msg.content) {
if (block.type === 'text' && block.text) {
const textContent = block.text;
// Check for interruption patterns
if (
textContent.includes('[Request interrupted by user]') ||
textContent.includes('[Request interrupted by user for tool use]')
) {
steps.push({
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
type: 'interruption',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
interruptionText: textContent,
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
foundInterruption = true;
}
}
}
// User-rejected tool use (toolUseResult field is "User rejected tool use")
if (!foundInterruption && (msg.toolUseResult as unknown) === 'User rejected tool use') {
steps.push({
id: `${msg.uuid}-interruption-${stepIdCounter++}`,
type: 'interruption',
startTime: new Date(msg.timestamp),
durationMs: 0,
content: {
interruptionText: 'Request interrupted by user',
},
context: msg.agentId ? 'subagent' : 'main',
agentId: msg.agentId,
});
}
}
}
// Link processes as steps
for (const process of chunk.processes) {
steps.push({
id: process.id,
type: 'subagent',
startTime: process.startTime,
endTime: process.endTime,
durationMs: process.durationMs,
content: {
subagentId: process.id,
subagentDescription: process.description,
},
tokens: {
input: process.metrics.inputTokens,
output: process.metrics.outputTokens,
cached: process.metrics.cacheReadTokens,
},
isParallel: process.isParallel,
context: 'subagent',
agentId: process.id,
});
}
// Sort by startTime
return steps.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}

View file

@ -0,0 +1,114 @@
/**
* SemanticStepGrouper - Groups semantic steps for UI presentation.
*
* Groups steps by their source assistant message for collapsible UI.
* Steps from the same assistant message share the message UUID.
*/
import type { SemanticStep, SemanticStepGroup } from '@main/types';
/**
* Build semantic step groups from steps.
* Groups steps by their source assistant message for collapsible UI.
*/
export function buildSemanticStepGroups(steps: SemanticStep[]): SemanticStepGroup[] {
const groups: SemanticStepGroup[] = [];
let groupIdCounter = 0;
// Group steps by assistant message or standalone type
const stepsByGroup = new Map<string | null, SemanticStep[]>();
for (const step of steps) {
const messageId = extractMessageIdFromStep(step);
const existingSteps = stepsByGroup.get(messageId) ?? [];
existingSteps.push(step);
stepsByGroup.set(messageId, existingSteps);
}
// Build groups
for (const [messageId, groupSteps] of stepsByGroup) {
const startTime = groupSteps[0].startTime;
const endTimes = groupSteps
.map((s) => s.endTime ?? new Date(s.startTime.getTime() + s.durationMs))
.map((d) => d.getTime());
const endTime = new Date(Math.max(...endTimes));
const totalDuration = groupSteps.reduce((sum, s) => sum + s.durationMs, 0);
groups.push({
id: `group-${++groupIdCounter}`,
label: buildGroupLabel(groupSteps),
steps: groupSteps,
isGrouped: messageId !== null && groupSteps.length > 1,
sourceMessageId: messageId ?? undefined,
startTime,
endTime,
totalDuration,
});
}
// Sort by startTime
return groups.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
/**
* Extract the assistant message ID from a step, or null if standalone.
* Steps from the same assistant message share the message UUID.
* Subagents, tool results, and interruptions are standalone (null).
*/
function extractMessageIdFromStep(step: SemanticStep): string | null {
// Use sourceMessageId if available
if (step.sourceMessageId) {
return step.sourceMessageId;
}
// Standalone steps (not grouped)
if (step.type === 'subagent') return null;
if (step.type === 'tool_result') return null;
if (step.type === 'interruption') return null;
if (step.type === 'tool_call') return null; // Tool calls are standalone
return null;
}
/**
* Build a descriptive label for a group.
*/
function buildGroupLabel(steps: SemanticStep[]): string {
if (steps.length === 1) {
const step = steps[0];
switch (step.type) {
case 'thinking':
return 'Thinking';
case 'tool_call':
return `Tool: ${step.content.toolName ?? 'Unknown'}`;
case 'tool_result':
return `Result: ${step.content.isError ? 'Error' : 'Success'}`;
case 'subagent':
return step.content.subagentDescription ?? 'Subagent';
case 'output':
return 'Output';
case 'interruption':
return 'Interruption';
}
}
// Multiple steps grouped together
const hasThinking = steps.some((s) => s.type === 'thinking');
const hasOutput = steps.some((s) => s.type === 'output');
const toolCalls = steps.filter((s) => s.type === 'tool_call');
if (toolCalls.length > 0) {
return `Tools (${toolCalls.length})`;
}
if (hasThinking && hasOutput) {
return 'Assistant Response';
}
if (hasThinking) {
return 'Thinking';
}
if (hasOutput) {
return 'Output';
}
return `Response (${steps.length} steps)`;
}

View file

@ -0,0 +1,135 @@
/**
* SubagentDetailBuilder - Builds detailed information for subagent drill-down.
*
* Loads subagent JSONL files, resolves nested subagents, and builds
* complete SubagentDetail objects for the drill-down modal.
*/
import {
type EnhancedAIChunk,
type EnhancedChunk,
isEnhancedAIChunk,
type ParsedMessage,
type Process,
type SemanticStepGroup,
type SubagentDetail,
} from '@main/types';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Service:SubagentDetailBuilder');
import { buildSemanticStepGroups } from './SemanticStepGrouper';
import type { SubagentResolver } from '../discovery/SubagentResolver';
import type { SessionParser } from '../parsing/SessionParser';
/**
* Build detailed information for a specific subagent.
* Used for drill-down modal to show subagent's internal execution.
*
* @param projectId - Project ID
* @param _sessionId - Parent session ID (currently unused, kept for API consistency)
* @param subagentId - Subagent ID to load
* @param sessionParser - SessionParser instance for parsing subagent file
* @param subagentResolver - SubagentResolver instance for nested subagents
* @param buildChunksFn - Function to build chunks from messages and subagents
* @returns SubagentDetail or null if not found
*/
export async function buildSubagentDetail(
projectId: string,
_sessionId: string, // Unused but kept for API consistency
subagentId: string,
sessionParser: SessionParser,
subagentResolver: SubagentResolver,
buildChunksFn: (messages: ParsedMessage[], subagents: Process[]) => EnhancedChunk[]
): Promise<SubagentDetail | null> {
try {
const fs = await import('fs/promises');
const path = await import('path');
const os = await import('os');
// Construct path to subagent JSONL file
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const subagentPath = path.join(claudeDir, projectId, 'subagents', `agent-${subagentId}.jsonl`);
// Check if file exists
try {
await fs.access(subagentPath);
} catch {
logger.warn(`Subagent file not found: ${subagentPath}`);
return null;
}
// Parse subagent JSONL file
const parsedSession = await sessionParser.parseSessionFile(subagentPath);
// Resolve nested subagents within this subagent
const nestedSubagents = await subagentResolver.resolveSubagents(
projectId,
subagentId, // Use subagentId as sessionId for nested resolution
parsedSession.taskCalls
);
// Build chunks with semantic steps
const chunks = buildChunksFn(parsedSession.messages, nestedSubagents);
// Extract description (try to get from first user message)
let description = 'Subagent';
if (parsedSession.messages.length > 0) {
const firstUserMsg = parsedSession.messages.find(
(m) => m.type === 'user' && typeof m.content === 'string'
);
if (firstUserMsg && typeof firstUserMsg.content === 'string') {
description = firstUserMsg.content.substring(0, 100);
if (firstUserMsg.content.length > 100) {
description += '...';
}
}
}
// Calculate timing
const times = parsedSession.messages.map((m) => m.timestamp.getTime());
const startTime = new Date(Math.min(...times));
const endTime = new Date(Math.max(...times));
const duration = endTime.getTime() - startTime.getTime();
// Calculate thinking tokens
let thinkingTokens = 0;
for (const msg of parsedSession.messages) {
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'thinking' && block.thinking) {
thinkingTokens += countTokens(block.thinking);
}
}
}
}
// Build semantic step groups from AI chunks only (UserChunks don't have semanticSteps)
const allSemanticSteps = chunks
.filter((c): c is EnhancedAIChunk => isEnhancedAIChunk(c))
.flatMap((c) => c.semanticSteps);
const semanticStepGroups: SemanticStepGroup[] | undefined =
allSemanticSteps.length > 0 ? buildSemanticStepGroups(allSemanticSteps) : undefined;
return {
id: subagentId,
description,
chunks,
semanticStepGroups,
startTime,
endTime,
duration,
metrics: {
inputTokens: parsedSession.metrics.inputTokens,
outputTokens: parsedSession.metrics.outputTokens,
thinkingTokens,
messageCount: parsedSession.metrics.messageCount,
},
};
} catch (error) {
logger.error(`Error building subagent detail for ${subagentId}:`, error);
return null;
}
}

View file

@ -0,0 +1,82 @@
/**
* ToolExecutionBuilder - Builds tool execution tracking from messages.
*
* Matches tool calls with their results using:
* 1. sourceToolUseID for accurate internal user message matching
* 2. toolResults array fallback for other patterns
*/
import type { ParsedMessage, ToolCall, ToolExecution } from '@main/types';
/**
* Build tool execution tracking from messages.
* Enhanced to use sourceToolUseID for more accurate matching.
*/
export function buildToolExecutions(messages: ParsedMessage[]): ToolExecution[] {
const executions: ToolExecution[] = [];
const toolCallMap = new Map<string, { call: ToolCall; startTime: Date }>();
// First pass: collect all tool calls
for (const msg of messages) {
for (const toolCall of msg.toolCalls) {
toolCallMap.set(toolCall.id, {
call: toolCall,
startTime: msg.timestamp,
});
}
}
// Second pass: match with results and build executions
// Try sourceToolUseID first (most accurate), then fall back to toolResults array
for (const msg of messages) {
// Check if this message has a sourceToolUseID (internal user messages)
if (msg.sourceToolUseID) {
const callInfo = toolCallMap.get(msg.sourceToolUseID);
if (callInfo && msg.toolResults.length > 0) {
// Use the first tool result for this internal user message
const result = msg.toolResults[0];
executions.push({
toolCall: callInfo.call,
result,
startTime: callInfo.startTime,
endTime: msg.timestamp,
durationMs: msg.timestamp.getTime() - callInfo.startTime.getTime(),
});
}
}
// Also check toolResults array for any results not matched above
for (const result of msg.toolResults) {
// Skip if already matched via sourceToolUseID
const alreadyMatched = executions.some((e) => e.result?.toolUseId === result.toolUseId);
if (alreadyMatched) continue;
const callInfo = toolCallMap.get(result.toolUseId);
if (callInfo) {
executions.push({
toolCall: callInfo.call,
result,
startTime: callInfo.startTime,
endTime: msg.timestamp,
durationMs: msg.timestamp.getTime() - callInfo.startTime.getTime(),
});
}
}
}
// Add calls without results
for (const [id, callInfo] of toolCallMap) {
const hasResult = executions.some((e) => e.toolCall.id === id);
if (!hasResult) {
executions.push({
toolCall: callInfo.call,
startTime: callInfo.startTime,
});
}
}
// Sort by start time
executions.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
return executions;
}

View file

@ -0,0 +1,224 @@
/**
* ToolResultExtractor service - Extracts tool results from messages.
*
* Provides utilities for:
* - Building tool_use maps for linking results to calls
* - Building tool_result maps for token estimation
* - Estimating token counts from content
* - Extracting tool results from various message formats
*/
import { isToolResultContent, type ParsedMessage } from '@main/types';
import { countContentTokens } from '@main/utils/tokenizer';
// =============================================================================
// Types
// =============================================================================
/**
* Extracted tool result information for trigger matching.
*/
export interface ExtractedToolResult {
toolUseId: string;
isError: boolean;
content: string | unknown[];
toolName?: string;
}
/**
* Tool use information from assistant messages.
*/
export interface ToolUseInfo {
name: string;
input: Record<string, unknown>;
}
/**
* Tool result information for token estimation.
*/
export interface ToolResultInfo {
content: string | unknown[];
isError: boolean;
}
// =============================================================================
// Map Building
// =============================================================================
/**
* Builds a map of tool_use_id to tool_use content.
* This allows linking tool_results back to their tool_use calls to check tool names.
*/
export function buildToolUseMap(messages: ParsedMessage[]): Map<string, ToolUseInfo> {
const map = new Map<string, ToolUseInfo>();
for (const message of messages) {
if (message.type !== 'assistant') continue;
// Check content array for tool_use blocks
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use') {
const toolUse = block;
map.set(toolUse.id, {
name: toolUse.name,
input: toolUse.input || {},
});
}
}
}
// Also check toolCalls if present
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
map.set(toolCall.id, {
name: toolCall.name,
input: toolCall.input || {},
});
}
}
}
return map;
}
/**
* Builds a map of tool_use_id to tool_result content.
* Used for estimating output tokens per tool_use.
*/
export function buildToolResultMap(messages: ParsedMessage[]): Map<string, ToolResultInfo> {
const map = new Map<string, ToolResultInfo>();
for (const message of messages) {
// Check content array for tool_result blocks
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (isToolResultContent(block)) {
map.set(block.tool_use_id, {
content: block.content,
isError: block.is_error === true,
});
}
}
}
// Also check toolResults array if present
if (message.toolResults) {
for (const toolResult of message.toolResults) {
map.set(toolResult.toolUseId, {
content: toolResult.content,
isError: toolResult.isError === true,
});
}
}
// Also check toolUseResult if present (enriched data)
if (message.toolUseResult && message.sourceToolUseID) {
const content = extractContentFromToolUseResult(message.toolUseResult);
const isError =
message.toolUseResult.isError === true || message.toolUseResult.is_error === true;
map.set(message.sourceToolUseID, { content, isError });
}
}
return map;
}
// =============================================================================
// Token Estimation
// =============================================================================
/**
* Estimates token count from content using the shared tokenizer.
* Uses the same calculation as ChunkBuilder for consistency with UI display.
*/
export function estimateTokens(content: string | unknown[] | Record<string, unknown>): number {
if (typeof content === 'string') {
return countContentTokens(content);
}
// For objects/arrays, use countContentTokens which handles JSON.stringify
return countContentTokens(content as unknown[]);
}
// =============================================================================
// Tool Result Extraction
// =============================================================================
/**
* Extracts content string from toolUseResult.
*/
function extractContentFromToolUseResult(toolUseResult: Record<string, unknown>): string {
if (typeof toolUseResult.error === 'string') {
return toolUseResult.error;
}
if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim()) {
return toolUseResult.stderr;
}
if (typeof toolUseResult.content === 'string') {
return toolUseResult.content;
}
if (typeof toolUseResult.message === 'string') {
return toolUseResult.message;
}
return '';
}
/**
* Extracts tool results from a message.
* Handles multiple patterns of tool result storage.
*
* @param message - The parsed message to extract from
* @param findToolNameFn - Function to find tool name by tool use ID
*/
export function extractToolResults(
message: ParsedMessage,
findToolNameFn: (message: ParsedMessage, toolUseId: string) => string | null
): ExtractedToolResult[] {
const results: ExtractedToolResult[] = [];
// Pattern 1: Check toolResults array on ParsedMessage
if (message.toolResults && message.toolResults.length > 0) {
for (const toolResult of message.toolResults) {
results.push({
toolUseId: toolResult.toolUseId,
isError: toolResult.isError === true,
content: toolResult.content,
toolName: findToolNameFn(message, toolResult.toolUseId) ?? undefined,
});
}
}
// Pattern 2: Check toolUseResult field (enriched data from entry)
if (message.toolUseResult) {
const toolUseResult = message.toolUseResult;
const hasError = toolUseResult.isError === true || toolUseResult.is_error === true;
const toolUseId =
(typeof toolUseResult.toolUseId === 'string' ? toolUseResult.toolUseId : undefined) ??
message.sourceToolUseID;
if (toolUseId) {
results.push({
toolUseId,
isError: hasError,
content: extractContentFromToolUseResult(toolUseResult),
toolName: typeof toolUseResult.toolName === 'string' ? toolUseResult.toolName : undefined,
});
}
}
// Pattern 3: Check content blocks for tool_result
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (isToolResultContent(block)) {
results.push({
toolUseId: block.tool_use_id,
isError: block.is_error === true,
content: block.content,
toolName: findToolNameFn(message, block.tool_use_id) ?? undefined,
});
}
}
}
return results;
}

View file

@ -0,0 +1,113 @@
/**
* ToolSummaryFormatter service - Formats tool information for display.
*
* Provides utilities for:
* - Extracting filenames from paths
* - Truncating long strings
* - Formatting token counts
* - Generating human-readable tool summaries
*/
import { formatTokens } from '@shared/utils/tokenFormatting';
import * as path from 'path';
// Re-export for backwards compatibility
export { formatTokens };
// =============================================================================
// String Utilities
// =============================================================================
/**
* Extracts filename from a file path.
*/
function getFileName(filePath: string): string {
return path.basename(filePath) || filePath;
}
/**
* Truncates a string to a maximum length with ellipsis.
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + '...';
}
// =============================================================================
// Tool Summary Generation
// =============================================================================
/**
* Generates a human-readable summary for a tool call.
* Simplified version of LinkedToolItem's getToolSummary.
*/
export function getToolSummary(toolName: string, input: Record<string, unknown>): string {
switch (toolName) {
case 'Edit':
case 'Read':
case 'Write': {
const filePath = input.file_path as string | undefined;
if (filePath) return getFileName(filePath);
return toolName;
}
case 'Bash': {
const description = input.description as string | undefined;
const command = input.command as string | undefined;
if (description) return truncate(description, 50);
if (command) return truncate(command, 50);
return 'Bash';
}
case 'Grep':
case 'Glob': {
const pattern = input.pattern as string | undefined;
if (pattern) return `"${truncate(pattern, 30)}"`;
return toolName;
}
case 'Task': {
const description = input.description as string | undefined;
const prompt = input.prompt as string | undefined;
const subagentType = input.subagent_type as string | undefined;
const desc = description ?? prompt;
const typeStr = subagentType ? `${subagentType} - ` : '';
if (desc) return `${typeStr}${truncate(desc, 40)}`;
return subagentType ?? 'Task';
}
case 'Skill': {
const skill = input.skill as string | undefined;
if (skill) return skill;
return 'Skill';
}
case 'WebFetch': {
const url = input.url as string | undefined;
if (url) {
try {
const urlObj = new URL(url);
return truncate(urlObj.hostname + urlObj.pathname, 50);
} catch {
return truncate(url, 50);
}
}
return 'WebFetch';
}
case 'WebSearch': {
const query = input.query as string | undefined;
if (query) return `"${truncate(query, 40)}"`;
return 'WebSearch';
}
default: {
// Try common parameter names
const nameField = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
if (typeof nameField === 'string') {
return truncate(nameField, 50);
}
return toolName;
}
}
}

View file

@ -0,0 +1,26 @@
/**
* Analysis services - Chunk building and session analysis.
*
* Exports:
* - ChunkBuilder: Builds visualization chunks from parsed session data
* - ChunkFactory: Creates individual chunk objects
* - ProcessLinker: Links subagent processes to chunks
* - ConversationGroupBuilder: Alternative grouping strategy
* - SemanticStepExtractor: Extracts semantic steps from AI chunks
* - SemanticStepGrouper: Groups semantic steps for UI
* - ToolExecutionBuilder: Builds tool execution tracking
* - ToolResultExtractor: Extracts results from tool calls
* - ToolSummaryFormatter: Formats tool summaries
* - SubagentDetailBuilder: Builds subagent drill-down details
*/
export * from './ChunkBuilder';
export * from './ChunkFactory';
export * from './ConversationGroupBuilder';
export * from './ProcessLinker';
export * from './SemanticStepExtractor';
export * from './SemanticStepGrouper';
export * from './SubagentDetailBuilder';
export * from './ToolExecutionBuilder';
export * from './ToolResultExtractor';
export * from './ToolSummaryFormatter';

View file

@ -0,0 +1,118 @@
/**
* ProjectPathResolver - Resolves encoded project IDs to canonical filesystem paths.
*
* Resolution order:
* 1) cwd hint (if provided and absolute)
* 2) cwd extracted from session JSONL files (authoritative)
* 3) decodePath(projectId) fallback (lossy, best-effort)
*
* Results are memoized per projectId and can be invalidated by file watcher events.
*/
import { extractCwd } from '@main/utils/jsonl';
import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { subprojectRegistry } from './SubprojectRegistry';
const logger = createLogger('Discovery:ProjectPathResolver');
interface ResolveProjectPathOptions {
cwdHint?: string;
sessionPaths?: string[];
forceRefresh?: boolean;
}
export class ProjectPathResolver {
private readonly projectsDir: string;
private readonly projectPathCache = new Map<string, string>();
constructor(projectsDir?: string) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
}
/**
* Resolve a project ID to a canonical path.
*/
async resolveProjectPath(
projectId: string,
options?: ResolveProjectPathOptions
): Promise<string> {
const opts = options ?? {};
// Short-circuit for composite IDs: use the registry's cwd directly
const registryCwd = subprojectRegistry.getCwd(projectId);
if (registryCwd) {
this.projectPathCache.set(projectId, registryCwd);
return registryCwd;
}
if (!opts.forceRefresh) {
const cached = this.projectPathCache.get(projectId);
if (cached) {
return cached;
}
}
const cwdHint = opts.cwdHint?.trim();
if (cwdHint && path.isAbsolute(cwdHint)) {
this.projectPathCache.set(projectId, cwdHint);
return cwdHint;
}
const sessionPaths = opts.sessionPaths?.length
? opts.sessionPaths
: this.listSessionPaths(projectId);
for (const sessionPath of sessionPaths) {
try {
const cwd = await extractCwd(sessionPath);
if (cwd && path.isAbsolute(cwd)) {
this.projectPathCache.set(projectId, cwd);
return cwd;
}
} catch {
// Ignore unreadable or malformed files and continue to next candidate.
}
}
const decoded = decodePath(extractBaseDir(projectId));
this.projectPathCache.set(projectId, decoded);
return decoded;
}
/**
* Invalidate a single project's cached path.
*/
invalidateProject(projectId: string): void {
this.projectPathCache.delete(projectId);
}
/**
* Clear all cached project paths.
*/
clear(): void {
this.projectPathCache.clear();
}
private listSessionPaths(projectId: string): string[] {
const projectDir = path.join(this.projectsDir, extractBaseDir(projectId));
if (!fs.existsSync(projectDir)) {
return [];
}
try {
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.map((entry) => path.join(projectDir, entry.name));
} catch (error) {
logger.error(`Failed to read session files for ${projectId}:`, error);
return [];
}
}
}
export const projectPathResolver = new ProjectPathResolver();

View file

@ -0,0 +1,819 @@
/**
* ProjectScanner service - Scans ~/.claude/projects/ directory and lists all projects.
*
* Responsibilities:
* - Read project directories from ~/.claude/projects/
* - Decode directory names to original paths (with cwd fallback)
* - List session files for each project
* - Read task list data from ~/.claude/todos/
* - Return sorted list of projects by recent activity
*
* Delegates to specialized services:
* - SessionContentFilter: Noise detection and message filtering
* - WorktreeGrouper: Git repository grouping
* - SubagentLocator: Subagent file lookup
* - SessionSearcher: Search functionality
*/
import {
type PaginatedSessionsResult,
type Project,
type RepositoryGroup,
type SearchSessionsResult,
type Session,
type SessionCursor,
type SessionsPaginationOptions,
} from '@main/types';
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
import {
buildSessionPath,
buildSubagentsPath,
buildTodoPath,
extractBaseDir,
extractProjectName,
extractSessionId,
getProjectsBasePath,
getTodosBasePath,
isValidEncodedPath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { SessionContentFilter } from './SessionContentFilter';
import { subprojectRegistry } from './SubprojectRegistry';
const logger = createLogger('Discovery:ProjectScanner');
import { ProjectPathResolver } from './ProjectPathResolver';
import { SessionSearcher } from './SessionSearcher';
import { SubagentLocator } from './SubagentLocator';
import { WorktreeGrouper } from './WorktreeGrouper';
export class ProjectScanner {
private readonly projectsDir: string;
private readonly todosDir: string;
private readonly contentPresenceCache = new Map<
string,
{ mtimeMs: number; hasContent: boolean }
>();
private readonly sessionMetadataCache = new Map<
string,
{
mtimeMs: number;
metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
}
>();
// Delegated services
private readonly sessionContentFilter: typeof SessionContentFilter;
private readonly worktreeGrouper: WorktreeGrouper;
private readonly subagentLocator: SubagentLocator;
private readonly sessionSearcher: SessionSearcher;
private readonly projectPathResolver: ProjectPathResolver;
constructor(projectsDir?: string, todosDir?: string) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
this.todosDir = todosDir ?? getTodosBasePath();
// Initialize delegated services
this.sessionContentFilter = SessionContentFilter;
this.worktreeGrouper = new WorktreeGrouper(this.projectsDir);
this.subagentLocator = new SubagentLocator(this.projectsDir);
this.sessionSearcher = new SessionSearcher(this.projectsDir);
this.projectPathResolver = new ProjectPathResolver(this.projectsDir);
}
// ===========================================================================
// Project Scanning
// ===========================================================================
/**
* Scans the projects directory and returns a list of all projects.
* @returns Promise resolving to projects sorted by most recent activity
*/
async scan(): Promise<Project[]> {
try {
if (!fs.existsSync(this.projectsDir)) {
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
return [];
}
// Clear the subproject registry on full re-scan
subprojectRegistry.clear();
const entries = fs.readdirSync(this.projectsDir, { withFileTypes: true });
// Filter to only directories with valid encoding pattern
const projectDirs = entries.filter(
(entry) => entry.isDirectory() && isValidEncodedPath(entry.name)
);
// Process each project directory (may return multiple projects per dir)
const projectArrays = await Promise.all(projectDirs.map((dir) => this.scanProject(dir.name)));
// Flatten and sort by most recent
const validProjects = projectArrays.flat();
validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
return validProjects;
} catch (error) {
logger.error('Error scanning projects directory:', error);
return [];
}
}
// ===========================================================================
// Repository Grouping (Worktree Support)
// ===========================================================================
/**
* Scans projects and groups them by git repository.
* Projects belonging to the same git repository (main repo + worktrees)
* are grouped together under a single RepositoryGroup.
* Non-git projects are represented as single-worktree groups.
*
* Sessions are filtered to exclude noise-only sessions, so counts
* accurately reflect visible sessions in the UI.
*
* @returns Promise resolving to RepositoryGroups sorted by most recent activity
*/
async scanWithWorktreeGrouping(): Promise<RepositoryGroup[]> {
try {
// 1. Scan all projects using existing logic
const projects = await this.scan();
if (projects.length === 0) {
return [];
}
// 2. Delegate to WorktreeGrouper
return this.worktreeGrouper.groupByRepository(projects);
} catch (error) {
logger.error('Error scanning with worktree grouping:', error);
return [];
}
}
/**
* Lists sessions for a specific worktree within a repository group.
* This is a convenience method that delegates to listSessions since
* worktree.id is the same as project.id.
*
* @param worktreeId - The worktree ID (same as project ID)
*/
async listWorktreeSessions(worktreeId: string): Promise<Session[]> {
return this.listSessions(worktreeId);
}
// ===========================================================================
// Project Scanning (continued)
// ===========================================================================
/**
* Scans a single project directory and returns project metadata.
* If sessions have different cwd values, splits into multiple projects.
*/
private async scanProject(encodedName: string): Promise<Project[]> {
try {
const projectPath = path.join(this.projectsDir, encodedName);
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
// Get session files (.jsonl at root level)
const sessionFiles = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
);
if (sessionFiles.length === 0) {
return [];
}
// Collect file stats and cwd for each session
interface SessionInfo {
sessionId: string;
filePath: string;
mtimeMs: number;
birthtimeMs: number;
cwd: string | null;
}
const sessionInfos: SessionInfo[] = await Promise.all(
sessionFiles.map(async (file) => {
const filePath = path.join(projectPath, file.name);
const stats = fs.statSync(filePath);
let cwd: string | null = null;
try {
cwd = await extractCwd(filePath);
} catch {
// Ignore unreadable files
}
return {
sessionId: extractSessionId(file.name),
filePath,
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs,
cwd,
};
})
);
// Group sessions by cwd
const cwdGroups = new Map<string, SessionInfo[]>();
const baseName = extractProjectName(encodedName);
const decodedFallback = baseName; // Used when cwd is null
for (const info of sessionInfos) {
const key = info.cwd ?? `__decoded__${decodedFallback}`;
const group = cwdGroups.get(key) ?? [];
group.push(info);
cwdGroups.set(key, group);
}
// If only 1 unique cwd, return single project (current behavior)
if (cwdGroups.size <= 1) {
const allSessionIds = sessionInfos.map((s) => s.sessionId);
let mostRecentSession: number | undefined;
let createdAt = Date.now();
for (const info of sessionInfos) {
if (!mostRecentSession || info.mtimeMs > mostRecentSession) {
mostRecentSession = info.mtimeMs;
}
if (info.birthtimeMs < createdAt) {
createdAt = info.birthtimeMs;
}
}
const sessionPaths = sessionInfos.map((s) => s.filePath);
const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {
sessionPaths,
});
return [
{
id: encodedName,
path: actualPath,
name: baseName,
sessions: allSessionIds,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
},
];
}
// Multiple unique cwds: split into subprojects
const projects: Project[] = [];
// Find the "root" cwd (shortest path, or the one matching the decoded name)
const cwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));
const rootCwd = cwdKeys.reduce(
(shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest),
cwdKeys[0] ?? ''
);
for (const [cwdKey, sessions] of cwdGroups) {
const isDecodedFallback = cwdKey.startsWith('__decoded__');
const actualCwd = isDecodedFallback ? null : cwdKey;
// Register in subproject registry
const sessionIds = sessions.map((s) => s.sessionId);
const compositeId = subprojectRegistry.register(
encodedName,
actualCwd ?? decodedFallback,
sessionIds
);
// Compute timestamps
let mostRecentSession: number | undefined;
let createdAt = Date.now();
for (const info of sessions) {
if (!mostRecentSession || info.mtimeMs > mostRecentSession) {
mostRecentSession = info.mtimeMs;
}
if (info.birthtimeMs < createdAt) {
createdAt = info.birthtimeMs;
}
}
// Build display name
let displayName: string;
if (!actualCwd || actualCwd === rootCwd) {
displayName = baseName;
} else {
// Use last segment of cwd for disambiguation
const lastSegment = path.basename(actualCwd);
displayName = `${baseName} (${lastSegment})`;
}
projects.push({
id: compositeId,
path: actualCwd ?? decodedFallback,
name: displayName,
sessions: sessionIds,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
});
}
return projects;
} catch (error) {
logger.error(`Error scanning project ${encodedName}:`, error);
return [];
}
}
/**
* Gets details for a specific project by ID.
* Handles composite IDs by scanning the base directory and finding the matching subproject.
*/
async getProject(projectId: string): Promise<Project | null> {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
if (!fs.existsSync(projectPath)) {
return null;
}
// For composite IDs, scan and find the matching subproject
if (subprojectRegistry.isComposite(projectId)) {
const projects = await this.scanProject(baseDir);
return projects.find((p) => p.id === projectId) ?? null;
}
const projects = await this.scanProject(baseDir);
return projects.find((p) => p.id === projectId) ?? projects[0] ?? null;
}
// ===========================================================================
// Session Listing
// ===========================================================================
/**
* Lists all sessions for a given project with metadata.
* Filters out sessions that contain only noise messages.
*/
async listSessions(projectId: string): Promise<Session[]> {
try {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
return [];
}
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
// Filter to only sessions belonging to this subproject
if (sessionFilter) {
sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));
}
const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name));
const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths);
const sessions = await Promise.all(
sessionFiles.map(async (file) => {
const sessionId = extractSessionId(file.name);
const filePath = path.join(projectPath, file.name);
// Check if session has non-noise messages (delegated to SessionContentFilter)
const hasContent = await this.hasDisplayableContent(filePath);
if (!hasContent) {
return null; // Filter out noise-only sessions
}
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
})
);
// Filter out null results (noise-only sessions)
const validSessions = sessions.filter((s): s is Session => s !== null);
// Sort by created date (most recent first)
validSessions.sort((a, b) => b.createdAt - a.createdAt);
return validSessions;
} catch (error) {
logger.error(`Error listing sessions for project ${projectId}:`, error);
return [];
}
}
/**
* Lists sessions for a project with cursor-based pagination.
* Efficiently fetches only the sessions needed for the current page.
*
* @param projectId - The project ID to list sessions for
* @param cursor - Base64-encoded cursor from previous page (null for first page)
* @param limit - Number of sessions to return (default 20)
* @returns Paginated result with sessions, cursor, and metadata
*/
async listSessionsPaginated(
projectId: string,
cursor: string | null,
limit: number = 20,
options?: SessionsPaginationOptions
): Promise<PaginatedSessionsResult> {
try {
const includeTotalCount = options?.includeTotalCount ?? false;
const prefilterAll = options?.prefilterAll ?? false;
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
// Step 1: Get all session files with their timestamps (lightweight stat calls)
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
// Filter to only sessions belonging to this subproject
if (sessionFilter) {
sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));
}
// Get stats for all session files
interface SessionFileInfo {
name: string;
sessionId: string;
timestamp: number;
filePath: string;
mtimeMs: number;
}
const fileInfos: SessionFileInfo[] = [];
for (const file of sessionFiles) {
const filePath = path.join(projectPath, file.name);
try {
const stats = fs.statSync(filePath);
fileInfos.push({
name: file.name,
sessionId: extractSessionId(file.name),
timestamp: stats.mtimeMs,
filePath,
mtimeMs: stats.mtimeMs,
});
} catch {
// Skip files we can't stat
continue;
}
}
// Step 2: Sort by timestamp descending (most recent first)
fileInfos.sort((a, b) => {
if (b.timestamp !== a.timestamp) {
return b.timestamp - a.timestamp;
}
// Tie-breaker: sort by sessionId alphabetically
return a.sessionId.localeCompare(b.sessionId);
});
// Step 3: Optionally pre-filter all sessions for accurate total count
// This is slower but provides exact totalCount.
let validSessionIds: Set<string> | null = null;
let totalCount = 0;
if (prefilterAll) {
validSessionIds = new Set<string>();
for (const fileInfo of fileInfos) {
if (await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs)) {
validSessionIds.add(fileInfo.sessionId);
}
}
totalCount = validSessionIds.size;
}
// Step 4: Apply cursor filter to find starting position
let startIndex = 0;
if (cursor) {
try {
const decoded = JSON.parse(
Buffer.from(cursor, 'base64').toString('utf8')
) as SessionCursor;
startIndex = fileInfos.findIndex((info) => {
// Find the first item that comes AFTER the cursor
if (info.timestamp < decoded.timestamp) return true;
if (info.timestamp === decoded.timestamp && info.sessionId > decoded.sessionId)
return true;
return false;
});
// If cursor not found, start from beginning
if (startIndex === -1) startIndex = fileInfos.length;
} catch {
// Invalid cursor, start from beginning
startIndex = 0;
}
}
// Step 5: Fetch sessions for this page
const decodedPath = await this.resolveProjectPathForId(
projectId,
fileInfos.map((fileInfo) => fileInfo.filePath)
);
const sessions: Session[] = [];
let scannedCandidates = 0;
// Fast path: avoid pre-filtering everything. Scan until we have enough page items.
for (let i = startIndex; i < fileInfos.length; i++) {
const fileInfo = fileInfos[i];
if (!fileInfo) {
continue;
}
scannedCandidates++;
let hasContent: boolean;
if (validSessionIds) {
hasContent = validSessionIds.has(fileInfo.sessionId);
} else {
hasContent = await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs);
}
if (!hasContent) {
continue;
}
const session = await this.buildSessionMetadata(
projectId,
fileInfo.sessionId,
fileInfo.filePath,
decodedPath
);
sessions.push(session);
if (sessions.length >= limit + 1) {
break;
}
}
// Step 6: Build next cursor
let nextCursor: string | null = null;
const hasMore = sessions.length > limit || startIndex + scannedCandidates < fileInfos.length;
const pageSessions = hasMore ? sessions.slice(0, limit) : sessions;
// If total count wasn't precomputed, keep UI-safe lower bound
if (!includeTotalCount) {
// Lightweight mode: return a lower-bound count to avoid full scans.
totalCount = pageSessions.length + (hasMore ? 1 : 0);
}
if (pageSessions.length > 0 && hasMore) {
const lastSession = pageSessions[pageSessions.length - 1];
const lastFileInfo = fileInfos.find((f) => f.sessionId === lastSession.id);
if (lastFileInfo) {
const cursorData: SessionCursor = {
timestamp: lastFileInfo.timestamp,
sessionId: lastFileInfo.sessionId,
};
nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64');
}
}
return {
sessions: pageSessions,
nextCursor,
hasMore: nextCursor !== null,
totalCount,
};
} catch (error) {
logger.error(`Error listing paginated sessions for project ${projectId}:`, error);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
}
/**
* Build session metadata from a session file.
*/
private async buildSessionMetadata(
projectId: string,
sessionId: string,
filePath: string,
projectPath: string
): Promise<Session> {
const stats = fs.statSync(filePath);
const cachedMetadata = this.sessionMetadataCache.get(filePath);
const metadata =
cachedMetadata?.mtimeMs === stats.mtimeMs
? cachedMetadata.metadata
: await analyzeSessionFileMetadata(filePath);
if (cachedMetadata?.mtimeMs !== stats.mtimeMs) {
this.sessionMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata });
}
// Check for subagents (delegated to SubagentLocator)
const hasSubagents = this.subagentLocator.hasSubagentsSync(projectId, sessionId);
// Load task list data if exists
const todoData = await this.loadTodoData(sessionId);
return {
id: sessionId,
projectId,
projectPath,
todoData,
createdAt: Math.floor(stats.birthtimeMs),
firstMessage: metadata.firstUserMessage?.text,
messageTimestamp: metadata.firstUserMessage?.timestamp,
hasSubagents,
messageCount: metadata.messageCount,
isOngoing: metadata.isOngoing,
gitBranch: metadata.gitBranch ?? undefined,
};
}
/**
* Gets a single session's metadata.
*/
async getSession(projectId: string, sessionId: string): Promise<Session | null> {
const filePath = this.getSessionPath(projectId, sessionId);
if (!fs.existsSync(filePath)) {
return null;
}
const decodedPath = await this.resolveProjectPathForId(projectId);
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
}
// ===========================================================================
// Task List Data
// ===========================================================================
/**
* Loads task list data for a session from ~/.claude/todos/{sessionId}.json
*/
async loadTodoData(sessionId: string): Promise<unknown> {
try {
const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId);
if (!fs.existsSync(todoPath)) {
return undefined;
}
const content = fs.readFileSync(todoPath, 'utf8');
return JSON.parse(content) as unknown;
} catch (error) {
// Log but continue - task list data is non-critical
logger.debug(`Failed to load task list data for session ${sessionId}:`, error);
return undefined;
}
}
// ===========================================================================
// Path Helpers
// ===========================================================================
/**
* Gets the path to the session JSONL file.
*/
getSessionPath(projectId: string, sessionId: string): string {
return buildSessionPath(this.projectsDir, projectId, sessionId);
}
/**
* Gets the path to the subagents directory.
*/
getSubagentsPath(projectId: string, sessionId: string): string {
return buildSubagentsPath(this.projectsDir, projectId, sessionId);
}
/**
* Lists all session file paths for a project.
*/
async listSessionFiles(projectId: string): Promise<string[]> {
try {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
return [];
}
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
let files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
if (sessionFilter) {
files = files.filter((entry) => sessionFilter.has(extractSessionId(entry.name)));
}
return files.map((entry) => path.join(projectPath, entry.name));
} catch (error) {
logger.error(`Error listing session files for project ${projectId}:`, error);
return [];
}
}
// ===========================================================================
// Subagent Detection (delegated to SubagentLocator)
// ===========================================================================
/**
* Checks if a session has a subagents directory (async).
*/
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
return this.subagentLocator.hasSubagents(projectId, sessionId);
}
/**
* Checks if a session has subagent files (session-specific only).
* Only checks the NEW structure: {projectId}/{sessionId}/subagents/
* Verifies that at least one subagent file has non-empty content.
*/
hasSubagentsSync(projectId: string, sessionId: string): boolean {
return this.subagentLocator.hasSubagentsSync(projectId, sessionId);
}
/**
* Lists all subagent files for a session from both NEW and OLD structures.
* Returns NEW structure files first, then OLD structure files.
*/
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
return this.subagentLocator.listSubagentFiles(projectId, sessionId);
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Gets the base projects directory path.
*/
getProjectsDir(): string {
return this.projectsDir;
}
/**
* Gets the base todos directory path.
*/
getTodosDir(): string {
return this.todosDir;
}
/**
* Checks if the projects directory exists.
*/
projectsDirExists(): boolean {
return fs.existsSync(this.projectsDir);
}
// ===========================================================================
// Search (delegated to SessionSearcher)
// ===========================================================================
/**
* Searches sessions in a project for a query string.
* Filters out noise messages and returns matching content.
*
* @param projectId - The project ID to search in
* @param query - Search query string
* @param maxResults - Maximum number of results to return (default 50)
*/
async searchSessions(
projectId: string,
query: string,
maxResults: number = 50
): Promise<SearchSessionsResult> {
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
}
/**
* Resolves the project path for a given project ID.
* For composite IDs, uses the registry's cwd directly.
* For plain IDs, delegates to ProjectPathResolver.
*/
private async resolveProjectPathForId(
projectId: string,
sessionPaths?: string[]
): Promise<string> {
const registryCwd = subprojectRegistry.getCwd(projectId);
if (registryCwd) {
return registryCwd;
}
const baseDir = extractBaseDir(projectId);
return this.projectPathResolver.resolveProjectPath(baseDir, {
sessionPaths,
});
}
/**
* Checks whether a session file has non-noise displayable content.
* Uses mtime-based memoization to avoid expensive re-parsing on repeated requests.
*/
private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise<boolean> {
try {
const effectiveMtime = mtimeMs ?? fs.statSync(filePath).mtimeMs;
const cached = this.contentPresenceCache.get(filePath);
if (cached?.mtimeMs === effectiveMtime) {
return cached.hasContent;
}
const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(filePath);
this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent });
return hasContent;
} catch {
return false;
}
}
}

View file

@ -0,0 +1,249 @@
/**
* SessionContentFilter - Filters noise messages from sessions.
*
* Responsibilities:
* - Check if session files contain displayable content
* - Categorize messages as displayable or noise
* - Filter out system-generated and meta messages
*
* A session is displayable if it contains at least one:
* - Real user message (creates UserChunk)
* - System output message (creates SystemChunk)
* - Assistant message (creates AIChunk)
* - Compact boundary message (creates CompactChunk)
*
* Filtered out (hard noise):
* - system entries
* - summary entries
* - file-history-snapshot entries
* - queue-operation entries
* - user messages with ONLY <local-command-caveat> or <system-reminder>
* - synthetic assistant messages (model='<synthetic>')
*/
import { type ChatHistoryEntry, type ContentBlock } from '@main/types';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as readline from 'readline';
const logger = createLogger('Service:SessionContentFilter');
/**
* Hard noise tags - user messages with ONLY these tags are filtered out.
*/
const HARD_NOISE_TAGS = ['<local-command-caveat>', '<system-reminder>'];
/**
* Hard noise entry types - these types are always filtered out.
*/
const HARD_NOISE_TYPES = ['system', 'summary', 'file-history-snapshot', 'queue-operation'];
/**
* SessionContentFilter provides static methods for filtering noise messages.
*/
export class SessionContentFilter {
/**
* Checks if a session file contains any displayable conversation items.
* Returns true if the session has at least one message that would create
* a visible chunk (UserChunk, SystemChunk, AIChunk, or CompactChunk).
*
* Uses the same logic as ChunkBuilder to ensure consistency with ChatHistory:
* - Sessions that pass this check will have non-empty conversation.items
* - Sessions that fail will show "No conversation history" in ChatHistory
*
* @param filePath - Path to the session JSONL file
* @returns Promise resolving to true if session has displayable content
*/
static async hasNonNoiseMessages(filePath: string): Promise<boolean> {
if (!fs.existsSync(filePath)) {
return false;
}
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
try {
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as ChatHistoryEntry;
// Skip entries without uuid (queue-operation, etc.)
if (!entry.uuid) {
continue;
}
// Check if this entry would create a displayable chunk
// This aligns with ChunkBuilder.categorizeMessage() logic
if (SessionContentFilter.isDisplayableEntry(entry)) {
fileStream.destroy();
return true;
}
} catch {
// Skip malformed lines
continue;
}
}
} catch (error) {
logger.error(`Error checking displayable messages in ${filePath}:`, error);
}
// No displayable messages found
return false;
}
/**
* Checks if a JSONL entry would create a displayable chunk.
* Mirrors the logic in ChunkBuilder.categorizeMessage() and isParsed*Message() guards.
*
* @param entry - The parsed JSONL entry
* @returns true if the entry would create a displayable chunk
*/
static isDisplayableEntry(entry: ChatHistoryEntry): boolean {
const entryType = entry.type;
// Hard noise types - never displayed
if (HARD_NOISE_TYPES.includes(entryType)) {
return false;
}
// Sidechain messages are subagent messages - not part of main conversation
if ('isSidechain' in entry && entry.isSidechain === true) {
return false;
}
// Assistant messages - displayable (creates AIChunk)
// Filter synthetic messages (model='<synthetic>')
if (entryType === 'assistant') {
const assistantEntry = entry as { message?: { model?: string } };
return assistantEntry.message?.model !== '<synthetic>';
}
// User messages - check for real user input vs noise
if (entryType === 'user') {
return SessionContentFilter.isDisplayableUserEntry(entry);
}
return false;
}
/**
* Checks if a user entry is displayable.
*
* @param entry - The user entry to check
* @returns true if the user entry would create a displayable chunk
*/
private static isDisplayableUserEntry(entry: ChatHistoryEntry): boolean {
const userEntry = entry as {
message?: { content?: string | ContentBlock[] };
isMeta?: boolean;
};
const content = userEntry.message?.content;
const isMeta = userEntry.isMeta;
// Internal user messages (tool results) - part of AI response flow
// These ARE displayable as they're part of AIChunks
if (isMeta === true) {
return true;
}
// String content
if (typeof content === 'string') {
return SessionContentFilter.isDisplayableStringContent(content);
}
// Array content (newer format)
if (Array.isArray(content)) {
return SessionContentFilter.isDisplayableArrayContent(content);
}
return false;
}
/**
* Checks if string content is displayable.
*
* @param content - The string content to check
* @returns true if displayable
*/
private static isDisplayableStringContent(content: string): boolean {
const trimmed = content.trim();
// Check for hard noise tags - user messages with ONLY these tags
for (const tag of HARD_NOISE_TAGS) {
const openTag = tag;
const closeTag = tag.replace('<', '</');
if (trimmed.startsWith(openTag) && trimmed.endsWith(closeTag)) {
return false;
}
}
// System output (creates SystemChunk) - displayable
if (
trimmed.startsWith('<local-command-stdout>') ||
trimmed.startsWith('<local-command-stderr>')
) {
return true;
}
// Real user input (creates UserChunk) - displayable
if (trimmed.length > 0) {
return true;
}
return false;
}
/**
* Checks if array content is displayable.
*
* @param content - The array content to check
* @returns true if displayable
*/
private static isDisplayableArrayContent(content: ContentBlock[]): boolean {
// Check for tool_result blocks (part of AI response flow)
const hasToolResult = content.some((block: ContentBlock) => block.type === 'tool_result');
if (hasToolResult) {
return true;
}
// Check for text/image blocks (real user input)
const hasUserContent = content.some(
(block: ContentBlock) => block.type === 'text' || block.type === 'image'
);
if (hasUserContent) {
// Filter user interruption messages - but these are still displayable
if (
content.length === 1 &&
content[0].type === 'text' &&
typeof content[0].text === 'string' &&
content[0].text.startsWith('[Request interrupted by user')
) {
// Interruptions are part of AI flow, still displayable
return true;
}
// Check text blocks for hard noise tags
for (const block of content) {
if (block.type === 'text') {
const textBlock = block;
for (const tag of HARD_NOISE_TAGS) {
const closeTag = tag.replace('<', '</');
if (textBlock.text.startsWith(tag) && textBlock.text.trim().endsWith(closeTag)) {
return false;
}
}
}
}
return true;
}
return false;
}
}

View file

@ -0,0 +1,312 @@
/**
* SessionSearcher - Searches sessions for query strings.
*
* Responsibilities:
* - Search across sessions in a project
* - Search within a single session file
* - Restrict matching scope to User text + AI last text output
* - Extract context around each match occurrence
*/
import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder';
import {
isEnhancedAIChunk,
isUserChunk,
type ParsedMessage,
type SearchResult,
type SearchSessionsResult,
type SemanticStep,
} from '@main/types';
import { parseJsonlFile } from '@main/utils/jsonl';
import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { createLogger } from '@shared/utils/logger';
import {
extractMarkdownPlainText,
findMarkdownSearchMatches,
} from '@shared/utils/markdownTextSearch';
import * as fs from 'fs';
import * as path from 'path';
import { subprojectRegistry } from './SubprojectRegistry';
const logger = createLogger('Discovery:SessionSearcher');
interface SearchableEntry {
text: string;
groupId: string;
messageType: 'user' | 'assistant';
itemType: 'user' | 'ai';
timestamp: number;
messageUuid: string;
}
/**
* SessionSearcher provides methods for searching sessions.
*/
export class SessionSearcher {
private readonly projectsDir: string;
private readonly chunkBuilder: ChunkBuilder;
constructor(projectsDir: string) {
this.projectsDir = projectsDir;
this.chunkBuilder = new ChunkBuilder();
}
/**
* Searches sessions in a project for a query string.
* Filters out noise messages and returns matching content.
*
* @param projectId - The project ID to search in
* @param query - Search query string
* @param maxResults - Maximum number of results to return (default 50)
* @returns Search results with matches and metadata
*/
async searchSessions(
projectId: string,
query: string,
maxResults: number = 50
): Promise<SearchSessionsResult> {
const results: SearchResult[] = [];
let sessionsSearched = 0;
if (!query || query.trim().length === 0) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const normalizedQuery = query.toLowerCase().trim();
try {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
try {
await fs.promises.access(projectPath, fs.constants.R_OK);
} catch {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Get all session files
const entries = await fs.promises.readdir(projectPath, { withFileTypes: true });
const sessionFilesWithTime = await Promise.all(
entries
.filter((entry) => {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false;
// Filter to only sessions belonging to this subproject
if (sessionFilter) {
const sessionId = extractSessionId(entry.name);
return sessionFilter.has(sessionId);
}
return true;
})
.map(async (entry) => {
const filePath = path.join(projectPath, entry.name);
try {
const stats = await fs.promises.stat(filePath);
return { name: entry.name, filePath, mtimeMs: stats.mtimeMs };
} catch {
return null;
}
})
);
const sessionFiles = sessionFilesWithTime
.filter((entry): entry is { name: string; filePath: string; mtimeMs: number } => !!entry)
.sort((a, b) => b.mtimeMs - a.mtimeMs);
// Search each session file
for (const file of sessionFiles) {
if (results.length >= maxResults) break;
const sessionId = extractSessionId(file.name);
const filePath = file.filePath;
sessionsSearched++;
try {
const sessionResults = await this.searchSessionFile(
projectId,
sessionId,
filePath,
normalizedQuery,
maxResults - results.length
);
results.push(...sessionResults);
} catch {
// Skip files we can't read
continue;
}
}
return {
results,
totalMatches: results.length,
sessionsSearched,
query,
};
} catch (error) {
logger.error(`Error searching sessions for project ${projectId}:`, error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
}
/**
* Searches a single session file for a query string.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @param filePath - Path to the session file
* @param query - Normalized search query (lowercase)
* @param maxResults - Maximum number of results to return
* @returns Array of search results
*/
async searchSessionFile(
projectId: string,
sessionId: string,
filePath: string,
query: string,
maxResults: number
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
let sessionTitle: string | undefined;
const messages = await parseJsonlFile(filePath);
const chunks = this.chunkBuilder.buildChunks(messages, []);
for (const chunk of chunks) {
if (results.length >= maxResults) {
break;
}
if (isUserChunk(chunk)) {
const userText = this.extractUserSearchableText(chunk.userMessage);
if (!sessionTitle && userText) {
sessionTitle = userText.slice(0, 100);
}
if (!userText) {
continue;
}
const searchableEntry: SearchableEntry = {
text: userText,
groupId: chunk.id,
messageType: 'user',
itemType: 'user',
timestamp: chunk.userMessage.timestamp.getTime(),
messageUuid: chunk.userMessage.uuid,
};
this.collectMatchesForEntry(
searchableEntry,
query,
results,
maxResults,
projectId,
sessionId,
sessionTitle
);
continue;
}
if (isEnhancedAIChunk(chunk)) {
const lastOutputStep = this.findLastOutputTextStep(chunk.semanticSteps);
const outputText = lastOutputStep?.content.outputText;
if (!lastOutputStep || !outputText) {
continue;
}
const searchableEntry: SearchableEntry = {
text: outputText,
groupId: chunk.id,
messageType: 'assistant',
itemType: 'ai',
timestamp: lastOutputStep.startTime.getTime(),
messageUuid: lastOutputStep.sourceMessageId ?? chunk.responses[0]?.uuid ?? '',
};
this.collectMatchesForEntry(
searchableEntry,
query,
results,
maxResults,
projectId,
sessionId,
sessionTitle
);
}
}
return results;
}
private collectMatchesForEntry(
entry: SearchableEntry,
query: string,
results: SearchResult[],
maxResults: number,
projectId: string,
sessionId: string,
sessionTitle?: string
): void {
const mdMatches = findMarkdownSearchMatches(entry.text, query);
if (mdMatches.length === 0) return;
// Build plain text once for context snippet extraction
const plainText = extractMarkdownPlainText(entry.text);
const lowerPlain = plainText.toLowerCase();
for (const mdMatch of mdMatches) {
if (results.length >= maxResults) return;
// Find approximate position in plain text for context extraction
let pos = 0;
for (let i = 0; i < mdMatch.matchIndexInItem; i++) {
const idx = lowerPlain.indexOf(query, pos);
if (idx === -1) break;
pos = idx + query.length;
}
const matchPos = lowerPlain.indexOf(query, pos);
const effectivePos = matchPos >= 0 ? matchPos : 0;
const contextStart = Math.max(0, effectivePos - 50);
const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50);
const context = plainText.slice(contextStart, contextEnd);
const matchedText =
matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query;
results.push({
sessionId,
projectId,
sessionTitle: sessionTitle ?? 'Untitled Session',
matchedText,
context:
(contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''),
messageType: entry.messageType,
timestamp: entry.timestamp,
groupId: entry.groupId,
itemType: entry.itemType,
matchIndexInItem: mdMatch.matchIndexInItem,
matchStartOffset: effectivePos,
messageUuid: entry.messageUuid,
});
}
}
private extractUserSearchableText(message: ParsedMessage): string {
let rawText = '';
if (typeof message.content === 'string') {
rawText = message.content;
} else if (Array.isArray(message.content)) {
rawText = message.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('');
}
return sanitizeDisplayContent(rawText);
}
private findLastOutputTextStep(steps: SemanticStep[]): SemanticStep | null {
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
if (step.type === 'output' && step.content.outputText) {
return step;
}
}
return null;
}
}

View file

@ -0,0 +1,203 @@
/**
* SubagentLocator - Locates and manages subagent files.
*
* Responsibilities:
* - Check if sessions have subagent files
* - List subagent files for a session
* - Handle both NEW and OLD subagent directory structures:
* - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
* - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
* - Determine subagent ownership for OLD structure
*/
import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
const logger = createLogger('Discovery:SubagentLocator');
/**
* SubagentLocator provides methods for locating subagent files.
*/
export class SubagentLocator {
private readonly projectsDir: string;
constructor(projectsDir: string) {
this.projectsDir = projectsDir;
}
/**
* Checks if a session has subagent files (async).
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns Promise resolving to true if subagents exist
*/
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
return this.hasSubagentsSync(projectId, sessionId);
}
/**
* Checks if a session has subagent files (session-specific only).
* Only checks the NEW structure: {projectId}/{sessionId}/subagents/
* Verifies that at least one subagent file has non-empty content.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns true if subagents exist
*/
hasSubagentsSync(projectId: string, sessionId: string): boolean {
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (fs.existsSync(newSubagentsPath)) {
try {
const entries = fs.readdirSync(newSubagentsPath);
const subagentFiles = entries.filter(
(name) => name.startsWith('agent-') && name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const fileName of subagentFiles) {
const filePath = path.join(newSubagentsPath, fileName);
try {
const stats = fs.statSync(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = fs.readFileSync(filePath, 'utf8');
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
}
} catch {
// Ignore errors
}
}
return false;
}
/**
* Lists all subagent files for a session from both NEW and OLD structures.
* Returns NEW structure files first, then OLD structure files.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns Promise resolving to array of file paths
*/
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
const allFiles: string[] = [];
try {
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (fs.existsSync(newSubagentsPath)) {
const entries = fs.readdirSync(newSubagentsPath, { withFileTypes: true });
const newFiles = entries
.filter(
(entry) =>
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
)
.map((entry) => path.join(newSubagentsPath, entry.name));
allFiles.push(...newFiles);
}
} catch (error) {
logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
}
try {
// Scan OLD structure: {projectId}/agent-*.jsonl
// Must filter by sessionId since all sessions share the same project root
const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId);
allFiles.push(...oldFiles);
} catch (error) {
logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error);
}
return allFiles;
}
/**
* Gets subagent files from project root (OLD structure).
* Scans {projectId}/agent-*.jsonl files and filters by sessionId.
*
* In the OLD structure, all subagent files are in the project root,
* so we must read each file's first line to check if it belongs to the session.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns Promise resolving to array of file paths
*/
async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
try {
const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
if (!fs.existsSync(projectPath)) {
return [];
}
const files = fs.readdirSync(projectPath);
const agentFiles = files
.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'))
.map((f) => path.join(projectPath, f));
// Filter files by checking if their sessionId matches
const matchingFiles: string[] = [];
for (const filePath of agentFiles) {
if (await this.subagentBelongsToSession(filePath, sessionId)) {
matchingFiles.push(filePath);
}
}
return matchingFiles;
} catch (error) {
logger.error(`Error reading project root for subagent files:`, error);
return [];
}
}
/**
* Checks if a subagent file belongs to a specific session by reading its first line.
* Subagent files have a sessionId field that points to the parent session.
*
* @param filePath - Path to the subagent file
* @param sessionId - The session ID to check
* @returns Promise resolving to true if the subagent belongs to the session
*/
async subagentBelongsToSession(filePath: string, sessionId: string): Promise<boolean> {
try {
// Read just the first line to check sessionId
const content = fs.readFileSync(filePath, 'utf-8');
const firstNewline = content.indexOf('\n');
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
if (!firstLine.trim()) {
return false;
}
const entry = JSON.parse(firstLine) as { sessionId?: string };
return entry.sessionId === sessionId;
} catch (error) {
// If we can't read or parse the file, don't include it - log for debugging
logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error);
return false;
}
}
/**
* Gets the path to the subagents directory.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns Path to the subagents directory
*/
getSubagentsPath(projectId: string, sessionId: string): string {
return buildSubagentsPath(this.projectsDir, projectId, sessionId);
}
}

View file

@ -0,0 +1,547 @@
/**
* SubagentResolver service - Links Task calls to subagent files and detects parallelism.
*
* Responsibilities:
* - Find subagent JSONL files in {sessionId}/subagents/ directory
* - Parse each subagent file
* - Calculate start/end times and metrics
* - Detect parallel execution (100ms overlap threshold)
* - Link subagents to parent Task tool calls
*/
import { type ParsedMessage, type Process, type SessionMetrics, type ToolCall } from '@main/types';
import { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import { type ProjectScanner } from './ProjectScanner';
const logger = createLogger('Discovery:SubagentResolver');
/** Parallel detection window in milliseconds */
const PARALLEL_WINDOW_MS = 100;
export class SubagentResolver {
private projectScanner: ProjectScanner;
constructor(projectScanner: ProjectScanner) {
this.projectScanner = projectScanner;
}
// ===========================================================================
// Main Resolution
// ===========================================================================
/**
* Resolve all subagents for a session.
*/
async resolveSubagents(
projectId: string,
sessionId: string,
taskCalls: ToolCall[],
messages?: ParsedMessage[]
): Promise<Process[]> {
// Get subagent files
const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId);
if (subagentFiles.length === 0) {
return [];
}
// Parse all subagent files
const subagents = await Promise.all(
subagentFiles.map((filePath) => this.parseSubagentFile(filePath))
);
// Filter out failed parses
const validSubagents = subagents.filter((s): s is Process => s !== null);
// Link to Task calls using tool result data from parent session messages
this.linkToTaskCalls(validSubagents, taskCalls, messages ?? []);
// Propagate team metadata to continuation files via parentUuid chain
this.propagateTeamMetadata(validSubagents);
// Detect parallel execution
this.detectParallelExecution(validSubagents);
// Enrich team metadata colors from messages
if (messages) {
this.enrichTeamColors(validSubagents, messages);
}
// Sort by start time
validSubagents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
return validSubagents;
}
// ===========================================================================
// Subagent Parsing
// ===========================================================================
/**
* Parse a single subagent file.
*/
private async parseSubagentFile(filePath: string): Promise<Process | null> {
try {
const messages = await parseJsonlFile(filePath);
if (messages.length === 0) {
return null;
}
// Filter out warmup subagents - these are pre-warming agents spawned by Claude Code
// that have "Warmup" as the first user message and should not be displayed
if (this.isWarmupSubagent(messages)) {
return null;
}
// Extract agent ID from filename (agent-{id}.jsonl)
const filename = path.basename(filePath);
const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, '');
// Filter out compact files (context compaction artifacts, not real subagents)
if (agentId.startsWith('acompact')) {
return null;
}
// Calculate timing
const { startTime, endTime, durationMs } = this.calculateTiming(messages);
// Calculate metrics
const metrics = calculateMetrics(messages);
// Check if subagent is still in progress
const isOngoing = checkMessagesOngoing(messages);
return {
id: agentId,
filePath,
messages,
startTime,
endTime,
durationMs,
metrics,
isParallel: false, // Will be set by detectParallelExecution
isOngoing,
};
} catch (error) {
logger.error(`Error parsing subagent file ${filePath}:`, error);
return null;
}
}
/**
* Check if this is a warmup subagent that should be filtered out.
* Warmup subagents are pre-warming agents spawned by Claude Code that have:
* - First user message with content exactly "Warmup"
* - isSidechain: true (all subagents have this)
*/
private isWarmupSubagent(messages: ParsedMessage[]): boolean {
// Find the first user message
const firstUserMessage = messages.find((m) => m.type === 'user');
if (!firstUserMessage) {
return false;
}
// Check if content is exactly "Warmup" (string, not array)
return firstUserMessage.content === 'Warmup';
}
/**
* Extract the summary attribute from the first <teammate-message> tag in a subagent's messages.
* Returns the summary string if found, undefined otherwise.
* Used to match team member files to their spawning Task calls.
*/
private extractTeamMessageSummary(messages: ParsedMessage[]): string | undefined {
const firstUserMessage = messages.find((m) => m.type === 'user');
if (!firstUserMessage) return undefined;
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
const match = /<teammate-message[^>]*\bsummary="([^"]+)"/.exec(text);
return match?.[1];
}
/**
* Calculate timing from messages.
*/
private calculateTiming(messages: ParsedMessage[]): {
startTime: Date;
endTime: Date;
durationMs: number;
} {
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
if (timestamps.length === 0) {
const now = new Date();
return { startTime: now, endTime: now, durationMs: 0 };
}
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
return {
startTime: new Date(minTime),
endTime: new Date(maxTime),
durationMs: maxTime - minTime,
};
}
// ===========================================================================
// Task Call Linking
// ===========================================================================
/**
* Link subagents to their parent Task tool calls.
*
* Uses result-based matching: reads tool_result messages from the parent session
* to find agentId values, then matches subagent files by their ID. Falls back to
* positional matching (without wrap-around) for any remaining unmatched subagents.
*
* After matching, enriches subagents with Task call metadata (description, subagentType).
*/
private linkToTaskCalls(
subagents: Process[],
taskCalls: ToolCall[],
messages: ParsedMessage[]
): void {
// Filter to only Task calls
const taskCallsOnly = taskCalls.filter((tc) => tc.isTask);
if (taskCallsOnly.length === 0 || subagents.length === 0) {
return;
}
// Build a map: agentId → taskCallId from tool result messages
// Tool results for Task calls contain an agentId field linking to the subagent file
const agentIdToTaskId = new Map<string, string>();
for (const msg of messages) {
if (!msg.toolUseResult) continue;
const result = msg.toolUseResult;
// Check both camelCase (regular subagents) and snake_case (team spawns) field names
const agentId = (result.agentId ?? result.agent_id) as string | undefined;
if (!agentId) continue;
// Find the Task call ID from sourceToolUseID or toolResults[0].toolUseId
const taskCallId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId;
if (taskCallId) {
agentIdToTaskId.set(agentId, taskCallId);
}
}
// Build a lookup from task call ID → ToolCall for enrichment
const taskCallById = new Map(taskCallsOnly.map((tc) => [tc.id, tc]));
// Track which subagents and tasks got matched
const matchedSubagentIds = new Set<string>();
const matchedTaskIds = new Set<string>();
// Phase 1: Result-based matching (agentId from tool results)
// Works for regular subagents (Explore, etc.) where agentId = file UUID
for (const subagent of subagents) {
const taskCallId = agentIdToTaskId.get(subagent.id);
if (!taskCallId) continue;
const taskCall = taskCallById.get(taskCallId);
if (!taskCall) continue;
this.enrichSubagentFromTask(subagent, taskCall);
matchedSubagentIds.add(subagent.id);
matchedTaskIds.add(taskCallId);
}
// Phase 2: Description-based matching for team members
// Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them.
// Instead, match by comparing the Task description to the summary attribute in the
// subagent file's first <teammate-message> tag.
const teamTaskCalls = taskCallsOnly.filter(
(tc) => !matchedTaskIds.has(tc.id) && tc.input?.team_name && tc.input?.name
);
if (teamTaskCalls.length > 0) {
// Pre-extract summaries from unmatched subagent files
const subagentSummaries = new Map<string, string>();
for (const subagent of subagents) {
if (matchedSubagentIds.has(subagent.id)) continue;
const summary = this.extractTeamMessageSummary(subagent.messages);
if (summary) {
subagentSummaries.set(subagent.id, summary);
}
}
// Match each team Task call to the earliest subagent file with matching summary
for (const taskCall of teamTaskCalls) {
const description = taskCall.taskDescription;
if (!description) continue;
let bestMatch: Process | undefined;
for (const subagent of subagents) {
if (matchedSubagentIds.has(subagent.id)) continue;
if (subagentSummaries.get(subagent.id) !== description) continue;
if (!bestMatch || subagent.startTime < bestMatch.startTime) {
bestMatch = subagent;
}
}
if (bestMatch) {
this.enrichSubagentFromTask(bestMatch, taskCall);
matchedSubagentIds.add(bestMatch.id);
matchedTaskIds.add(taskCall.id);
}
}
}
// Phase 3: Positional fallback for remaining unmatched non-team subagents (no wrap-around)
const unmatchedSubagents = [...subagents]
.filter((s) => !matchedSubagentIds.has(s.id))
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
const unmatchedTasks = taskCallsOnly.filter(
(tc) => !matchedTaskIds.has(tc.id) && !(tc.input?.team_name && tc.input?.name)
);
for (let i = 0; i < unmatchedSubagents.length && i < unmatchedTasks.length; i++) {
this.enrichSubagentFromTask(unmatchedSubagents[i], unmatchedTasks[i]);
}
}
/**
* Enrich a subagent with metadata from its parent Task call.
* Intentionally mutates the subagent in place for consistency with other resolution methods.
*/
private enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void {
/* eslint-disable no-param-reassign -- Mutation is intentional; subagent is enriched in place */
subagent.parentTaskId = taskCall.id;
subagent.description = taskCall.taskDescription;
subagent.subagentType = taskCall.taskSubagentType;
// Extract team metadata from Task call input
const teamName = taskCall.input?.team_name as string | undefined;
const memberName = taskCall.input?.name as string | undefined;
if (teamName && memberName) {
subagent.team = { teamName, memberName, memberColor: '' };
}
/* eslint-enable no-param-reassign -- End of intentional mutation block */
}
/**
* Enrich team member subagents with color information from tool results.
* Teammate spawned results contain color information.
*/
private enrichTeamColors(subagents: Process[], messages: ParsedMessage[]): void {
for (const msg of messages) {
if (!msg.toolUseResult) continue;
// sourceToolUseID may be absent on teammate_spawned results;
// fall back to toolResults[0].toolUseId
const sourceId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId;
if (!sourceId) continue;
const result = msg.toolUseResult;
if (result.status === 'teammate_spawned' && result.color) {
// Set color on ALL subagents sharing this parentTaskId
// (primary file + continuation files from parentUuid chain propagation)
for (const subagent of subagents) {
if (subagent.parentTaskId === sourceId && subagent.team) {
subagent.team.memberColor = result.color as string;
}
}
}
}
}
/**
* Propagate team metadata to continuation files via parentUuid chain.
*
* Team members generate multiple JSONL files (one per activation/turn).
* Only the primary file is matched by linkToTaskCalls (Phase 2 description match).
* Continuation files (task assignments, shutdown responses) are linked to the
* same teammate by following the parentUuid chain: a continuation file's first
* message.parentUuid matches the last message.uuid of the previous file.
*/
private propagateTeamMetadata(subagents: Process[]): void {
// Build map: last message uuid → subagent (for chain lookups)
const lastUuidToSubagent = new Map<string, Process>();
for (const subagent of subagents) {
if (subagent.messages.length === 0) continue;
const lastMsg = subagent.messages[subagent.messages.length - 1];
if (lastMsg.uuid) {
lastUuidToSubagent.set(lastMsg.uuid, subagent);
}
}
// For each subagent without team metadata, follow parentUuid chain
// to find an ancestor with team metadata and propagate it
const maxDepth = 10;
for (const subagent of subagents) {
if (subagent.team) continue; // Already has team metadata
if (subagent.messages.length === 0) continue;
const firstMsg = subagent.messages[0];
if (!firstMsg.parentUuid) continue;
// Walk the chain upward
let ancestor: Process | undefined = lastUuidToSubagent.get(firstMsg.parentUuid);
let depth = 0;
while (ancestor && !ancestor.team && depth < maxDepth) {
if (ancestor.messages.length === 0) break;
const parentUuid = ancestor.messages[0].parentUuid;
if (!parentUuid) break;
ancestor = lastUuidToSubagent.get(parentUuid);
depth++;
}
if (ancestor?.team) {
subagent.team = { ...ancestor.team };
subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId;
subagent.description = subagent.description ?? ancestor.description;
subagent.subagentType = subagent.subagentType ?? ancestor.subagentType;
}
}
}
// ===========================================================================
// Parallel Detection
// ===========================================================================
/**
* Detect parallel execution among subagents.
* Subagents with start times within PARALLEL_WINDOW_MS are marked as parallel.
*/
private detectParallelExecution(subagents: Process[]): void {
if (subagents.length < 2) return;
// Sort by start time
const sorted = [...subagents].sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
// Group by start time buckets
const groups: Process[][] = [];
let currentGroup: Process[] = [];
let groupStartTime = 0;
for (const agent of sorted) {
const startMs = agent.startTime.getTime();
if (currentGroup.length === 0) {
// Start new group
currentGroup.push(agent);
groupStartTime = startMs;
} else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) {
// Add to current group
currentGroup.push(agent);
} else {
// Finalize current group and start new one
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [agent];
groupStartTime = startMs;
}
}
// Don't forget the last group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Mark agents in groups with multiple members as parallel
for (const group of groups) {
if (group.length > 1) {
for (const agent of group) {
agent.isParallel = true;
}
}
}
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Get subagent by ID.
*/
findSubagentById(subagents: Process[], id: string): Process | undefined {
return subagents.find((s) => s.id === id);
}
/**
* Get parallel subagent groups.
*/
getParallelGroups(subagents: Process[]): Process[][] {
const parallelAgents = subagents.filter((s) => s.isParallel);
if (parallelAgents.length === 0) return [];
// Group by start time
const sorted = [...parallelAgents].sort(
(a, b) => a.startTime.getTime() - b.startTime.getTime()
);
const groups: Process[][] = [];
let currentGroup: Process[] = [];
let groupStartTime = 0;
for (const agent of sorted) {
const startMs = agent.startTime.getTime();
if (currentGroup.length === 0) {
currentGroup.push(agent);
groupStartTime = startMs;
} else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) {
currentGroup.push(agent);
} else {
groups.push(currentGroup);
currentGroup = [agent];
groupStartTime = startMs;
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups.filter((g) => g.length > 1);
}
/**
* Calculate total metrics for all subagents.
*/
getTotalSubagentMetrics(subagents: Process[]): SessionMetrics {
if (subagents.length === 0) {
return {
durationMs: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 0,
};
}
let totalDuration = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let messageCount = 0;
for (const agent of subagents) {
totalDuration += agent.durationMs;
inputTokens += agent.metrics.inputTokens;
outputTokens += agent.metrics.outputTokens;
cacheReadTokens += agent.metrics.cacheReadTokens;
cacheCreationTokens += agent.metrics.cacheCreationTokens;
messageCount += agent.metrics.messageCount;
}
return {
durationMs: totalDuration,
totalTokens: inputTokens + outputTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
messageCount,
};
}
}

View file

@ -0,0 +1,98 @@
/**
* SubprojectRegistry - Maps composite project IDs to their split data.
*
* When multiple sessions in the same encoded directory have different `cwd` values,
* they are split into separate "subprojects". Each subproject gets a composite ID
* of the form `{encodedDir}::{sha256(cwd).slice(0,8)}`.
*
* This singleton registry tracks:
* - Which base directory a composite ID maps to
* - Which cwd each subproject represents
* - Which session IDs belong to each subproject
*/
import * as crypto from 'crypto';
interface SubprojectEntry {
baseDir: string;
cwd: string;
sessionIds: Set<string>;
}
class SubprojectRegistryImpl {
private readonly entries = new Map<string, SubprojectEntry>();
/**
* Register a subproject and return its composite ID.
*
* @param baseDir - The encoded directory name (e.g., "-Users-name-project")
* @param cwd - The actual working directory for this subproject
* @param sessionIds - Session IDs belonging to this subproject
* @returns Composite ID in the form `{baseDir}::{hash}`
*/
register(baseDir: string, cwd: string, sessionIds: string[]): string {
const hash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 8);
const compositeId = `${baseDir}::${hash}`;
this.entries.set(compositeId, {
baseDir,
cwd,
sessionIds: new Set(sessionIds),
});
return compositeId;
}
/**
* Extract the base directory from any project ID (composite or plain).
* For composite IDs (`{encoded}::{hash}`), returns the encoded part.
* For plain IDs, returns the ID as-is.
*/
getBaseDir(projectId: string): string {
const sep = projectId.indexOf('::');
if (sep !== -1) {
return projectId.slice(0, sep);
}
return projectId;
}
/**
* Check if a project ID is a composite (split) ID.
*/
isComposite(projectId: string): boolean {
return projectId.includes('::');
}
/**
* Get the session ID filter set for a composite project ID.
* Returns null for plain (non-composite) IDs.
*/
getSessionFilter(projectId: string): Set<string> | null {
const entry = this.entries.get(projectId);
return entry?.sessionIds ?? null;
}
/**
* Get the cwd for a composite project ID.
* Returns null for plain (non-composite) IDs.
*/
getCwd(projectId: string): string | null {
const entry = this.entries.get(projectId);
return entry?.cwd ?? null;
}
/**
* Get the full entry for a composite project ID.
*/
getEntry(projectId: string): SubprojectEntry | undefined {
return this.entries.get(projectId);
}
/**
* Clear all registered subprojects. Called at the start of a full re-scan.
*/
clear(): void {
this.entries.clear();
}
}
/** Module-level singleton */
export const subprojectRegistry = new SubprojectRegistryImpl();

View file

@ -0,0 +1,200 @@
/**
* WorktreeGrouper - Groups projects by git repository.
*
* Responsibilities:
* - Group projects that belong to the same git repository
* - Handle worktrees (main repo + worktrees grouped together)
* - Filter out empty worktrees (no visible sessions)
* - Sort worktrees by main first, then by most recent activity
*/
import {
type Project,
type RepositoryGroup,
type RepositoryIdentity,
type Worktree,
} from '@main/types';
import { extractBaseDir } from '@main/utils/pathDecoder';
import * as path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { SessionContentFilter } from './SessionContentFilter';
import { subprojectRegistry } from './SubprojectRegistry';
/**
* WorktreeGrouper provides methods for grouping projects by git repository.
*/
export class WorktreeGrouper {
private readonly projectsDir: string;
constructor(projectsDir: string) {
this.projectsDir = projectsDir;
}
/**
* Groups projects by git repository.
* Projects belonging to the same git repository (main repo + worktrees)
* are grouped together under a single RepositoryGroup.
* Non-git projects are represented as single-worktree groups.
*
* Sessions are filtered to exclude noise-only sessions, so counts
* accurately reflect visible sessions in the UI.
*
* @param projects - List of projects to group
* @returns Promise resolving to RepositoryGroups sorted by most recent activity
*/
async groupByRepository(projects: Project[]): Promise<RepositoryGroup[]> {
if (projects.length === 0) {
return [];
}
// 1. Resolve repository identity for each project
const projectIdentities = new Map<string, RepositoryIdentity | null>();
const projectBranches = new Map<string, string | null>();
await Promise.all(
projects.map(async (project) => {
const identity = await gitIdentityResolver.resolveIdentity(project.path);
projectIdentities.set(project.id, identity);
// Also get branch name for display
const branch = await gitIdentityResolver.getBranch(project.path);
projectBranches.set(project.id, branch);
})
);
// 2. Filter sessions for each project to only include non-noise sessions
const projectFilteredSessions = new Map<string, string[]>();
await Promise.all(
projects.map(async (project) => {
const baseDir = extractBaseDir(project.id);
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(project.id);
const filteredSessions: string[] = [];
for (const sessionId of project.sessions) {
// Skip sessions that don't belong to this subproject
if (sessionFilter && !sessionFilter.has(sessionId)) {
continue;
}
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath)) {
filteredSessions.push(sessionId);
}
}
projectFilteredSessions.set(project.id, filteredSessions);
})
);
// 3. Group projects by repository
const repoGroups = new Map<
string,
{
identity: RepositoryIdentity | null;
projects: Project[];
branches: Map<string, string | null>;
}
>();
for (const project of projects) {
const identity = projectIdentities.get(project.id) ?? null;
const branch = projectBranches.get(project.id) ?? null;
// Use repository ID if available, otherwise use project ID (for non-git projects)
const groupId = identity?.id ?? project.id;
if (!repoGroups.has(groupId)) {
repoGroups.set(groupId, {
identity,
projects: [],
branches: new Map(),
});
}
const group = repoGroups.get(groupId)!;
group.projects.push(project);
group.branches.set(project.id, branch);
}
// 4. Convert to RepositoryGroup[]
const repositoryGroups: RepositoryGroup[] = [];
for (const [groupId, group] of repoGroups) {
const worktrees: Worktree[] = group.projects.map((project) => {
const branch = group.branches.get(project.id) ?? null;
const isMainWorktree = !gitIdentityResolver.isWorktree(project.path);
// Use filtered sessions instead of raw sessions
const filteredSessions = projectFilteredSessions.get(project.id) ?? [];
// Detect worktree source for badge display
const source = gitIdentityResolver.detectWorktreeSource(project.path);
// Use source-aware display name generation
const displayName = gitIdentityResolver.getWorktreeDisplayName(
project.path,
source,
branch,
isMainWorktree
);
return {
id: project.id,
path: project.path,
name: displayName,
gitBranch: branch ?? undefined,
isMainWorktree,
source,
sessions: filteredSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
};
});
// Filter out worktrees with 0 visible sessions
const nonEmptyWorktrees = worktrees.filter((wt) => wt.sessions.length > 0);
// Skip this repository group if all worktrees are empty
if (nonEmptyWorktrees.length === 0) {
continue;
}
// Sort worktrees: main first, then by most recent activity
nonEmptyWorktrees.sort((a, b) => {
if (a.isMainWorktree && !b.isMainWorktree) return -1;
if (!a.isMainWorktree && b.isMainWorktree) return 1;
return (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0);
});
const totalSessions = nonEmptyWorktrees.reduce((sum, wt) => sum + wt.sessions.length, 0);
const mostRecentSession = Math.max(
...nonEmptyWorktrees.map((wt) => wt.mostRecentSession ?? 0)
);
repositoryGroups.push({
id: groupId,
identity: group.identity,
worktrees: nonEmptyWorktrees,
name: group.identity?.name ?? group.projects[0].name,
mostRecentSession: mostRecentSession > 0 ? mostRecentSession : undefined,
totalSessions,
});
}
// 5. Sort repository groups by most recent activity
repositoryGroups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
return repositoryGroups;
}
/**
* Lists sessions for a specific worktree.
* This is a convenience method that returns the worktree ID.
*
* @param worktreeId - The worktree ID (same as project ID)
* @returns The worktree ID for delegation to listSessions
*/
getWorktreeProjectId(worktreeId: string): string {
// Worktree ID is the same as project ID
return worktreeId;
}
}

View file

@ -0,0 +1,20 @@
/**
* Discovery services - Scanning and locating session data.
*
* Exports:
* - ProjectScanner: Scans ~/.claude/projects/ for projects and sessions
* - SessionSearcher: Searches session content
* - SessionContentFilter: Filters session content for display
* - SubagentLocator: Locates subagent JSONL files
* - SubagentResolver: Resolves and links subagents to Task calls
* - WorktreeGrouper: Groups projects by git worktree
*/
export * from './ProjectPathResolver';
export * from './ProjectScanner';
export * from './SessionContentFilter';
export * from './SessionSearcher';
export * from './SubagentLocator';
export * from './SubagentResolver';
export * from './SubprojectRegistry';
export * from './WorktreeGrouper';

View file

@ -0,0 +1,222 @@
/**
* ErrorDetector service - Detects errors from parsed JSONL messages.
*
* This is the main orchestrator that coordinates between specialized modules:
* - ToolSummaryFormatter: Formats tool information for display
* - TriggerMatcher: Pattern matching utilities
* - ToolResultExtractor: Extracts tool results from messages
* - ErrorMessageBuilder: Builds error messages and DetectedError objects
* - ErrorTriggerChecker: Checks different trigger types
* - ErrorTriggerTester: Testing functionality for trigger preview
*
* Detection criteria:
* - Uses configurable triggers from ConfigManager
* - Supports tool_result triggers with requireError, toolName, and matchPattern
* - Supports tool_use triggers for future expansion
* - Supports token_threshold triggers for monitoring context usage
*/
import { type ParsedMessage } from '@main/types';
import {
buildToolResultMap,
buildToolUseMap,
type ToolResultInfo,
type ToolUseInfo,
} from '../analysis/ToolResultExtractor';
import { ConfigManager, type NotificationTrigger } from '../infrastructure/ConfigManager';
import { type DetectedError } from './ErrorMessageBuilder';
import {
checkTokenThresholdTrigger,
checkToolResultTrigger,
checkToolUseTrigger,
matchesRepositoryScope,
preResolveRepositoryIds,
} from './ErrorTriggerChecker';
import { testTrigger as testTriggerImpl } from './ErrorTriggerTester';
// =============================================================================
// Error Detector Class
// =============================================================================
class ErrorDetector {
// ===========================================================================
// Main Detection Method
// ===========================================================================
/**
* Detects errors from an array of parsed messages using configurable triggers.
*
* @param messages - Array of ParsedMessage objects from a session
* @param sessionId - The session ID
* @param projectId - The project ID (encoded directory name)
* @param filePath - Path to the JSONL file
* @returns Array of DetectedError objects
*/
async detectErrors(
messages: ParsedMessage[],
sessionId: string,
projectId: string,
filePath: string
): Promise<DetectedError[]> {
const errors: DetectedError[] = [];
// Get enabled triggers from config
const configManager = ConfigManager.getInstance();
const triggers = configManager.getEnabledTriggers();
if (triggers.length === 0) {
return errors;
}
// Pre-resolve repository ID for this project to populate cache.
const cwdHint =
messages.find((message) => typeof message.cwd === 'string' && message.cwd.trim().length > 0)
?.cwd ?? undefined;
await preResolveRepositoryIds([{ projectId, cwdHint }]);
// Build tool_use map for linking results to calls
const toolUseMap = buildToolUseMap(messages);
// Build tool_result map for estimating output tokens
const toolResultMap = buildToolResultMap(messages);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const lineNumber = i + 1; // 1-based line numbers for JSONL
// Check each trigger against this message
for (const trigger of triggers) {
const triggerErrors = this.checkTrigger(
message,
trigger,
toolUseMap,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
// Add all detected errors (can be multiple for token_threshold mode)
errors.push(...triggerErrors);
}
}
return errors;
}
// ===========================================================================
// Trigger Checking (Router)
// ===========================================================================
/**
* Checks if a message matches a specific trigger.
* Routes to the appropriate trigger checker based on trigger configuration.
*
* @param message - The parsed message to check
* @param trigger - The trigger configuration
* @param toolUseMap - Map of tool_use_id to tool_use content for linking results to calls
* @param toolResultMap - Map of tool_use_id to tool_result content for token estimation
* @param sessionId - Session ID
* @param projectId - Project ID
* @param filePath - File path
* @param lineNumber - Line number in JSONL
* @returns Array of DetectedError (can be multiple for token_threshold mode)
*/
private checkTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolUseMap: Map<string, ToolUseInfo>,
toolResultMap: Map<string, ToolResultInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError[] {
// Check repository scope first - if repositoryIds is set, only trigger for matching repositories
if (!matchesRepositoryScope(projectId, trigger.repositoryIds)) {
return [];
}
// Use the mode directly (mode is now required in NotificationTrigger)
const effectiveMode = trigger.mode;
// Handle token_threshold mode - check each tool_use individually
if (effectiveMode === 'token_threshold') {
return checkTokenThresholdTrigger(
message,
trigger,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
}
// Handle tool_result triggers
if (trigger.contentType === 'tool_result') {
const error = checkToolResultTrigger(
message,
trigger,
toolUseMap,
sessionId,
projectId,
filePath,
lineNumber
);
return error ? [error] : [];
}
// Handle tool_use triggers (for future expansion)
if (trigger.contentType === 'tool_use') {
const error = checkToolUseTrigger(
message,
trigger,
sessionId,
projectId,
filePath,
lineNumber
);
return error ? [error] : [];
}
return [];
}
// ===========================================================================
// Trigger Testing (Preview Feature)
// ===========================================================================
/**
* Tests a trigger configuration against historical session data.
* Returns a list of errors that would have been detected.
*
* Safety features (handled by ErrorTriggerTester):
* - Limits returned errors to 50
* - Caps totalCount at 10,000 to prevent indefinite counting
* - Stops scanning after 100 sessions
* - Aborts after 30 seconds
*
* @param trigger - The trigger configuration to test
* @param limit - Maximum number of results to return (default 50)
*/
public async testTrigger(
trigger: NotificationTrigger,
limit: number = 50
): Promise<{
totalCount: number;
errors: DetectedError[];
/** True if results were truncated due to safety limits */
truncated?: boolean;
}> {
return testTriggerImpl(trigger, limit);
}
}
// =============================================================================
// Singleton Export
// =============================================================================
export const errorDetector = new ErrorDetector();

View file

@ -0,0 +1,185 @@
/**
* ErrorMessageBuilder service - Builds error messages and DetectedError objects.
*
* Provides utilities for:
* - Extracting error messages from tool results
* - Finding tool names by ID
* - Creating DetectedError objects
* - Truncating messages for display
*/
import { type ContentBlock, type ParsedMessage } from '@main/types';
import { randomUUID } from 'crypto';
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
import type { TriggerColor } from '@shared/constants/triggerColors';
// =============================================================================
// Types
// =============================================================================
/**
* Represents a detected error from a Claude Code session.
*/
export interface DetectedError {
/** UUID for unique identification */
id: string;
/** Unix timestamp when error was detected */
timestamp: number;
/** Session ID where error occurred */
sessionId: string;
/** Project ID (encoded directory name) */
projectId: string;
/** Path to the JSONL file */
filePath: string;
/** Source of the error - tool name or 'assistant' */
source: string;
/** Error message content */
message: string;
/** Line number in JSONL for deep linking */
lineNumber?: number;
/** Tool use ID for precise deep linking to the specific tool item */
toolUseId?: string;
/** Subagent ID when error originates from a subagent session */
subagentId?: string;
/** Trigger color key for notification dot and highlight */
triggerColor?: TriggerColor;
/** ID of the trigger that produced this notification */
triggerId?: string;
/** Human-readable name of the trigger that produced this notification */
triggerName?: string;
/** Additional context about the error */
context: {
/** Human-readable project name */
projectName: string;
/** Current working directory when error occurred */
cwd?: string;
};
}
/**
* Parameters for creating a DetectedError.
*/
export interface CreateDetectedErrorParams {
sessionId: string;
projectId: string;
filePath: string;
projectName: string;
lineNumber: number;
source: string;
message: string;
timestamp: Date;
cwd?: string;
toolUseId?: string;
subagentId?: string;
triggerColor?: TriggerColor;
triggerId?: string;
triggerName?: string;
}
// =============================================================================
// Error Message Extraction
// =============================================================================
/**
* Extracts error message from a tool result.
*/
export function extractErrorMessage(result: ExtractedToolResult): string {
if (typeof result.content === 'string') {
return result.content.trim() || 'Unknown error';
}
if (Array.isArray(result.content)) {
const texts: string[] = [];
for (const item of result.content) {
if (item && typeof item === 'object' && 'type' in item) {
const block = item as ContentBlock;
if (block.type === 'text' && 'text' in block) {
texts.push(block.text);
}
}
}
return texts.join('\n').trim() || 'Unknown error';
}
return 'Unknown error';
}
// =============================================================================
// Tool Name Lookup
// =============================================================================
/**
* Finds tool name from message's tool calls by tool use ID.
*/
function findToolName(message: ParsedMessage, toolUseId: string): string | null {
if (message.toolCalls) {
const toolCall = message.toolCalls.find((tc) => tc.id === toolUseId);
if (toolCall) {
return toolCall.name;
}
}
return null;
}
/**
* Finds tool name by searching tool_use_id in the message context.
*/
export function findToolNameByToolUseId(message: ParsedMessage, toolUseId: string): string | null {
// First check toolCalls
const fromToolCalls = findToolName(message, toolUseId);
if (fromToolCalls) return fromToolCalls;
// Check sourceToolUseID if this message is a tool result
if (message.sourceToolUseID === toolUseId && message.toolUseResult) {
if (typeof message.toolUseResult.toolName === 'string') {
return message.toolUseResult.toolName;
}
}
return null;
}
// =============================================================================
// Message Truncation
// =============================================================================
/**
* Truncates error message to a reasonable length for display.
*/
function truncateMessage(message: string, maxLength: number = 500): string {
if (message.length <= maxLength) {
return message;
}
return message.slice(0, maxLength) + '...';
}
// =============================================================================
// DetectedError Creation
// =============================================================================
/**
* Creates a DetectedError object with all required fields.
*/
export function createDetectedError(params: CreateDetectedErrorParams): DetectedError {
return {
id: randomUUID(),
timestamp: params.timestamp.getTime(),
sessionId: params.sessionId,
projectId: params.projectId,
filePath: params.filePath,
source: params.source,
message: truncateMessage(params.message),
lineNumber: params.lineNumber,
toolUseId: params.toolUseId,
subagentId: params.subagentId,
triggerColor: params.triggerColor,
triggerId: params.triggerId,
triggerName: params.triggerName,
context: {
projectName: params.projectName,
cwd: params.cwd,
},
};
}

View file

@ -0,0 +1,461 @@
/**
* ErrorTriggerChecker service - Checks different trigger types against messages.
*
* Provides utilities for:
* - Checking tool_result triggers
* - Checking tool_use triggers
* - Checking token threshold triggers
* - Validating project scope
*/
import { type ParsedMessage } from '@main/types';
import { extractProjectName } from '@main/utils/pathDecoder';
import {
estimateTokens,
extractToolResults,
type ToolResultInfo,
type ToolUseInfo,
} from '../analysis/ToolResultExtractor';
import { formatTokens, getToolSummary } from '../analysis/ToolSummaryFormatter';
import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { type NotificationTrigger } from '../infrastructure/ConfigManager';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import {
createDetectedError,
type DetectedError,
extractErrorMessage,
findToolNameByToolUseId,
} from './ErrorMessageBuilder';
import {
extractToolUseField,
getContentBlocks,
matchesIgnorePatterns,
matchesPattern,
} from './TriggerMatcher';
// =============================================================================
// Repository Scope Checking
// =============================================================================
// Cache for projectId -> repositoryId mapping to avoid repeated resolution
const repositoryIdCache = new Map<string, string | null>();
interface RepositoryScopeTarget {
projectId: string;
cwdHint?: string;
}
/**
* Resolves a projectId to its repositoryId using GitIdentityResolver.
* Results are cached for performance.
* @param projectId - The encoded project ID (e.g., "-Users-username-myproject")
* @returns Repository ID or null if not resolvable
*/
async function resolveRepositoryId(target: string | RepositoryScopeTarget): Promise<string | null> {
const projectId = typeof target === 'string' ? target : target.projectId;
const cwdHint = typeof target === 'string' ? undefined : target.cwdHint;
// Check cache first
if (repositoryIdCache.has(projectId)) {
return repositoryIdCache.get(projectId) ?? null;
}
const projectPath = await projectPathResolver.resolveProjectPath(projectId, { cwdHint });
// Resolve repository identity
const identity = await gitIdentityResolver.resolveIdentity(projectPath);
const repositoryId = identity?.id ?? null;
// Cache the result
repositoryIdCache.set(projectId, repositoryId);
return repositoryId;
}
/**
* Synchronous version of resolveRepositoryId using cached values only.
* If not cached, attempts synchronous resolution via path heuristics.
*/
function resolveRepositoryIdSync(projectId: string): string | null {
// Check cache first
if (repositoryIdCache.has(projectId)) {
return repositoryIdCache.get(projectId) ?? null;
}
// For sync context, we can't do async resolution
// The async version should be called during initialization
return null;
}
/**
* Checks if the project matches the trigger's repository scope.
* @param projectId - The encoded project ID (e.g., "-Users-username-myproject")
* @param repositoryIds - Optional list of repository group IDs to scope the trigger to
* @returns true if trigger should apply, false if it should be skipped
*/
export function matchesRepositoryScope(projectId: string, repositoryIds?: string[]): boolean {
// If no repository IDs specified, trigger applies to all repositories
if (!repositoryIds || repositoryIds.length === 0) {
return true;
}
// Get the repository ID for this project (from cache)
const repositoryId = resolveRepositoryIdSync(projectId);
// If we can't resolve the repository ID, don't match
if (!repositoryId) {
return false;
}
// Check if the repository ID matches any of the configured IDs
return repositoryIds.includes(repositoryId);
}
/**
* Pre-resolves repository IDs for a list of project IDs.
* Call this before checking triggers to populate the cache.
*/
export async function preResolveRepositoryIds(
targets: (string | RepositoryScopeTarget)[]
): Promise<void> {
const uniqueTargets = new Map<string, RepositoryScopeTarget>();
for (const target of targets) {
if (typeof target === 'string') {
if (!uniqueTargets.has(target)) {
uniqueTargets.set(target, { projectId: target });
}
continue;
}
const existing = uniqueTargets.get(target.projectId);
if (!existing) {
uniqueTargets.set(target.projectId, target);
continue;
}
// Prefer a target with cwd hint if one was provided.
if (!existing.cwdHint && target.cwdHint) {
uniqueTargets.set(target.projectId, target);
}
}
await Promise.all(
Array.from(uniqueTargets.values()).map((target) => resolveRepositoryId(target))
);
}
// =============================================================================
// Tool Result Trigger Checking
// =============================================================================
/**
* Checks if a tool_result matches a trigger.
*/
export function checkToolResultTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolUseMap: Map<string, ToolUseInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError | null {
const toolResults = extractToolResults(message, findToolNameByToolUseId);
for (const result of toolResults) {
// If requireError is true, only match when is_error is true
if (trigger.requireError) {
if (!result.isError) {
continue;
}
// Extract error message for ignore pattern checking
const errorMessage = extractErrorMessage(result);
// Check ignore patterns - if any match, skip this error
if (matchesIgnorePatterns(errorMessage, trigger.ignorePatterns)) {
continue;
}
// Create detected error
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: result.toolName ?? 'tool_result',
message: errorMessage,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: result.toolUseId,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
// Non-error tool_result triggers (if toolName is specified)
if (trigger.toolName) {
const toolUse = toolUseMap.get(result.toolUseId);
if (toolUse?.name !== trigger.toolName) {
continue;
}
// Match against content if matchField is 'content'
if (trigger.matchField === 'content' && trigger.matchPattern) {
const content =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
if (!matchesPattern(content, trigger.matchPattern)) {
continue;
}
if (matchesIgnorePatterns(content, trigger.ignorePatterns)) {
continue;
}
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: trigger.toolName,
message: `Tool result matched: ${content.slice(0, 200)}`,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: result.toolUseId,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
}
}
return null;
}
// =============================================================================
// Tool Use Trigger Checking
// =============================================================================
/**
* Checks if a tool_use matches a trigger.
*/
export function checkToolUseTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError | null {
if (message.type !== 'assistant') return null;
const contentBlocks = getContentBlocks(message);
for (const block of contentBlocks) {
if (block.type !== 'tool_use') continue;
const toolUse = block as {
type: 'tool_use';
id: string;
name: string;
input?: Record<string, unknown>;
};
// Check tool name if specified
if (trigger.toolName && toolUse.name !== trigger.toolName) {
continue;
}
// Extract the field to match based on matchField
// If no matchField specified (e.g., "Any Tool"), match against entire input JSON
const fieldValue = trigger.matchField
? extractToolUseField(toolUse, trigger.matchField)
: toolUse.input
? JSON.stringify(toolUse.input)
: null;
if (!fieldValue) continue;
// Check match pattern
if (trigger.matchPattern && !matchesPattern(fieldValue, trigger.matchPattern)) {
continue;
}
// Check ignore patterns
if (matchesIgnorePatterns(fieldValue, trigger.ignorePatterns)) {
continue;
}
// Match found!
return createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: toolUse.name,
message: `${trigger.matchField ?? 'tool_use'}: ${fieldValue.slice(0, 200)}`,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: toolUse.id,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
});
}
return null;
}
// =============================================================================
// Token Threshold Trigger Checking
// =============================================================================
/**
* Check if individual tool_use blocks exceed the token threshold.
* Returns an array of DetectedError for each tool_use that exceeds the threshold.
*
* Token calculation (matches context window impact):
* - Tool call tokens: estimated from name + JSON.stringify(input) (what enters context)
* - Tool result tokens: estimated from tool_result.content (what Claude reads)
* - Total = call + result
*/
export function checkTokenThresholdTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolResultMap: Map<string, ToolResultInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError[] {
const errors: DetectedError[] = [];
// Only check for token_threshold mode
if (trigger.mode !== 'token_threshold' || !trigger.tokenThreshold) {
return errors;
}
// Only check assistant messages that contain tool_use blocks
if (message.type !== 'assistant') {
return errors;
}
const tokenType = trigger.tokenType ?? 'total';
const threshold = trigger.tokenThreshold;
// Collect all tool_use blocks from message
const toolUseBlocks: { id: string; name: string; input: Record<string, unknown> }[] = [];
// Check content array for tool_use blocks
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use') {
const toolUse = block;
toolUseBlocks.push({
id: toolUse.id,
name: toolUse.name,
input: toolUse.input || {},
});
}
}
}
// Also check toolCalls array if present
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
// Avoid duplicates
if (!toolUseBlocks.some((t) => t.id === toolCall.id)) {
toolUseBlocks.push({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input || {},
});
}
}
}
if (toolUseBlocks.length === 0) {
return errors;
}
// Check each tool_use block individually
for (const toolUse of toolUseBlocks) {
// Check tool name filter if specified
if (trigger.toolName && toolUse.name !== trigger.toolName) {
continue;
}
// Calculate tool call tokens directly from name + input
// This reflects what actually enters the context window
const toolCallTokens = estimateTokens(toolUse.name + JSON.stringify(toolUse.input));
// Calculate tool result tokens (what Claude reads back)
let toolResultTokens = 0;
const toolResult = toolResultMap.get(toolUse.id);
if (toolResult) {
toolResultTokens = estimateTokens(toolResult.content);
}
// Calculate token count based on tokenType
// Note: 'input' here means tool CALL tokens (what enters context)
// 'output' here means tool RESULT tokens (what Claude reads)
let tokenCount = 0;
switch (tokenType) {
case 'input':
// Tool call tokens (name + input that enters context)
tokenCount = toolCallTokens;
break;
case 'output':
// Tool result tokens (what Claude reads - success message, file content for Read, etc.)
tokenCount = toolResultTokens;
break;
case 'total':
// Both: full context impact of the tool operation
tokenCount = toolCallTokens + toolResultTokens;
break;
}
// Check threshold
if (tokenCount <= threshold) {
continue;
}
// Build summary for the tool
const toolSummary = getToolSummary(toolUse.name, toolUse.input);
// Build message with tool info and token type for clarity
const tokenTypeLabel = tokenType === 'total' ? '' : ` ${tokenType}`;
const tokenMessage = `${toolUse.name} - ${toolSummary} : ~${formatTokens(tokenCount)}${tokenTypeLabel} tokens`;
// Check ignore patterns
if (matchesIgnorePatterns(tokenMessage, trigger.ignorePatterns)) {
continue;
}
errors.push(
createDetectedError({
sessionId,
projectId,
filePath,
projectName: extractProjectName(projectId),
lineNumber,
source: toolUse.name,
message: tokenMessage,
timestamp: message.timestamp,
cwd: message.cwd,
toolUseId: toolUse.id,
triggerColor: trigger.color,
triggerId: trigger.id,
triggerName: trigger.name,
})
);
}
return errors;
}

View file

@ -0,0 +1,329 @@
/**
* ErrorTriggerTester service - Testing functionality for trigger preview.
*
* Provides utilities for:
* - Testing trigger configurations against historical session data
* - Running single trigger detection for preview functionality
*/
import { type ParsedMessage } from '@main/types';
import { parseJsonlFile } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import {
buildToolResultMap,
buildToolUseMap,
type ToolResultInfo,
type ToolUseInfo,
} from '../analysis/ToolResultExtractor';
import { ProjectScanner } from '../discovery/ProjectScanner';
import { type NotificationTrigger } from '../infrastructure/ConfigManager';
const logger = createLogger('Service:ErrorTriggerTester');
import { type DetectedError } from './ErrorMessageBuilder';
import {
checkTokenThresholdTrigger,
checkToolResultTrigger,
checkToolUseTrigger,
matchesRepositoryScope,
preResolveRepositoryIds,
} from './ErrorTriggerChecker';
// =============================================================================
// Trigger Testing (Preview Feature)
// =============================================================================
/**
* Safety limits to prevent resource exhaustion from faulty triggers.
*
* Strategy: Stop as soon as we find enough results, not after scanning N sessions.
* This allows finding rare patterns (like .env) while still being fast for common patterns.
*/
const TEST_LIMITS = {
/** Maximum number of errors to return (primary stop condition) */
MAX_ERRORS: 50,
/** Maximum totalCount to track (prevents indefinite counting) */
MAX_TOTAL_COUNT: 10_000,
/** Maximum time in ms before aborting (30 seconds) - main safety limit */
TIMEOUT_MS: 30_000,
} as const;
/**
* State object used during trigger testing to track progress and limits.
*/
interface TestState {
errors: DetectedError[];
totalCount: number;
sessionsScanned: number;
truncated: boolean;
startTime: number;
effectiveLimit: number;
}
/**
* Checks if the test should stop due to hitting safety limits.
* Returns a reason string if should stop, null if should continue.
*
* Stop conditions (in order of priority):
* 1. Found enough errors (effectiveLimit) - success, no warning
* 2. Timeout (30s) - safety limit
* 3. Total count limit (10k) - prevent counting forever
*/
function shouldStopTest(state: TestState): string | null {
// Primary stop condition: found enough errors
if (state.errors.length >= state.effectiveLimit) {
return null; // Stop but don't log - we have enough errors (success case)
}
// Safety limits
if (Date.now() - state.startTime > TEST_LIMITS.TIMEOUT_MS) {
return 'Trigger test timed out after 30 seconds';
}
if (state.totalCount >= TEST_LIMITS.MAX_TOTAL_COUNT) {
return 'Trigger test stopped after reaching count limit';
}
return null;
}
/**
* Tests a trigger configuration against historical session data.
* Returns a list of errors that would have been detected.
*
* Strategy: Scan sessions until we find enough results or hit safety limits.
* This allows finding rare patterns while staying fast for common patterns.
*
* Stop conditions:
* - Found enough errors (limit) - primary success condition
* - Timeout (30s) - safety limit
* - Total count reached (10k) - prevents infinite counting
*
* @param trigger - The trigger configuration to test
* @param limit - Maximum number of results to return (default 50, capped at MAX_ERRORS)
*/
export async function testTrigger(
trigger: NotificationTrigger,
limit: number = TEST_LIMITS.MAX_ERRORS
): Promise<{
totalCount: number;
errors: DetectedError[];
/** True if results were truncated due to safety limits */
truncated?: boolean;
}> {
const projectScanner = new ProjectScanner();
const state: TestState = {
errors: [],
totalCount: 0,
sessionsScanned: 0,
truncated: false,
startTime: Date.now(),
effectiveLimit: Math.min(limit, TEST_LIMITS.MAX_ERRORS),
};
try {
// Get list of all projects
const projects = await projectScanner.scan();
// Process each project to find session files
for (const project of projects) {
// Check safety limits before processing project
const stopReason = shouldStopTest(state);
if (stopReason) {
logger.warn(stopReason);
state.truncated = true;
break;
}
// Early exit if we have enough errors (no truncation warning needed)
if (state.errors.length >= state.effectiveLimit) break;
const sessionFiles = await projectScanner.listSessionFiles(project.id);
// Pre-resolve repository ID for this project.
await preResolveRepositoryIds([{ projectId: project.id, cwdHint: project.path }]);
// Process each session file (most recent first)
const shouldBreakOuter = await processSessionFiles(
sessionFiles,
trigger,
project.id,
state,
parseJsonlFile
);
if (shouldBreakOuter) break;
}
return { totalCount: state.totalCount, errors: state.errors, truncated: state.truncated };
} catch (error) {
logger.error('Error testing trigger:', error);
return { totalCount: 0, errors: [] };
}
}
/**
* Processes session files for a single project.
* Returns true if outer loop should break, false otherwise.
*/
async function processSessionFiles(
sessionFiles: string[],
trigger: NotificationTrigger,
projectId: string,
state: TestState,
parseFile: (path: string) => Promise<ParsedMessage[]>
): Promise<boolean> {
for (const filePath of sessionFiles) {
// Check safety limits
const stopReason = shouldStopTest(state);
if (stopReason) {
logger.warn(stopReason);
state.truncated = true;
return true; // Break outer loop
}
// Early exit if we have enough errors
if (state.errors.length >= state.effectiveLimit) return false;
try {
state.sessionsScanned++;
// Parse session file
const messages = await parseFile(filePath);
// Extract sessionId from file path
const filename = path.basename(filePath);
const sessionId = filename.replace(/\.jsonl$/, '');
// Test the trigger against each message
const sessionErrors = detectErrorsWithTrigger(
messages,
trigger,
sessionId,
projectId,
filePath
);
// Update totalCount but cap it
const newTotal = state.totalCount + sessionErrors.length;
if (newTotal >= TEST_LIMITS.MAX_TOTAL_COUNT) {
state.totalCount = TEST_LIMITS.MAX_TOTAL_COUNT;
state.truncated = true;
} else {
state.totalCount = newTotal;
}
// Add errors up to limit
for (const error of sessionErrors) {
if (state.errors.length >= state.effectiveLimit) break;
state.errors.push(error);
}
} catch (error) {
// Skip files that can't be parsed
logger.error(`Error parsing session file ${filePath}:`, error);
continue;
}
}
return false; // Don't break outer loop
}
/**
* Detects errors from messages using a single trigger.
* Used by testTrigger for preview functionality.
*/
function detectErrorsWithTrigger(
messages: ParsedMessage[],
trigger: NotificationTrigger,
sessionId: string,
projectId: string,
filePath: string
): DetectedError[] {
const errors: DetectedError[] = [];
// Build tool_use map for linking results to calls
const toolUseMap = buildToolUseMap(messages);
// Build tool_result map for estimating output tokens
const toolResultMap = buildToolResultMap(messages);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const lineNumber = i + 1; // 1-based line numbers for JSONL
const triggerErrors = checkTrigger(
message,
trigger,
toolUseMap,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
// Add all detected errors (can be multiple for token_threshold mode)
errors.push(...triggerErrors);
}
return errors;
}
/**
* Checks if a message matches a specific trigger.
* Internal helper for detectErrorsWithTrigger.
*/
function checkTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolUseMap: Map<string, ToolUseInfo>,
toolResultMap: Map<string, ToolResultInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError[] {
// Check repository scope first - if repositoryIds is set, only trigger for matching repositories
if (!matchesRepositoryScope(projectId, trigger.repositoryIds)) {
return [];
}
// Use the mode directly (mode is now required in NotificationTrigger)
const effectiveMode = trigger.mode;
// Handle token_threshold mode - check each tool_use individually
if (effectiveMode === 'token_threshold') {
return checkTokenThresholdTrigger(
message,
trigger,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
}
// Handle tool_result triggers
if (trigger.contentType === 'tool_result') {
const error = checkToolResultTrigger(
message,
trigger,
toolUseMap,
sessionId,
projectId,
filePath,
lineNumber
);
return error ? [error] : [];
}
// Handle tool_use triggers (for future expansion)
if (trigger.contentType === 'tool_use') {
const error = checkToolUseTrigger(message, trigger, sessionId, projectId, filePath, lineNumber);
return error ? [error] : [];
}
return [];
}

View file

@ -0,0 +1,82 @@
/**
* TriggerMatcher service - Pattern matching utilities for trigger checking.
*
* Provides utilities for:
* - Regex pattern matching (with ReDoS protection)
* - Ignore pattern checking
* - Extracting fields from tool_use blocks
* - Getting content blocks from messages
*/
import { type ContentBlock, type ParsedMessage } from '@main/types';
import { createSafeRegExp } from '@main/utils/regexValidation';
// =============================================================================
// Pattern Matching
// =============================================================================
/**
* Checks if content matches a pattern.
* Uses validated regex to prevent ReDoS attacks.
*/
export function matchesPattern(content: string, pattern: string): boolean {
const regex = createSafeRegExp(pattern, 'i');
if (!regex) {
// Pattern is invalid or potentially dangerous, reject match
return false;
}
return regex.test(content);
}
/**
* Checks if content matches any of the ignore patterns.
* Uses validated regex to prevent ReDoS attacks.
*/
export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean {
if (!ignorePatterns || ignorePatterns.length === 0) {
return false;
}
for (const pattern of ignorePatterns) {
const regex = createSafeRegExp(pattern, 'i');
if (regex?.test(content)) {
return true;
}
// Invalid or potentially dangerous patterns are skipped
}
return false;
}
// =============================================================================
// Field Extraction
// =============================================================================
/**
* Extracts the specified field from a tool_use block.
*/
export function extractToolUseField(
toolUse: { name: string; input?: Record<string, unknown> },
matchField?: string
): string | null {
if (!matchField || !toolUse.input) return null;
const value = toolUse.input[matchField];
if (typeof value === 'string') {
return value;
}
if (value !== undefined) {
return JSON.stringify(value);
}
return null;
}
/**
* Gets content blocks from a message, handling both array and object formats.
*/
export function getContentBlocks(message: ParsedMessage): ContentBlock[] {
if (Array.isArray(message.content)) {
return message.content;
}
return [];
}

View file

@ -0,0 +1,16 @@
/**
* Error services - Error detection and notification triggers.
*
* Exports:
* - ErrorDetector: Detects errors in parsed messages
* - ErrorTriggerChecker: Checks messages against notification triggers
* - ErrorTriggerTester: Tests triggers against historical data
* - ErrorMessageBuilder: Builds error notification messages
* - TriggerMatcher: Matches content against trigger patterns
*/
export * from './ErrorDetector';
export * from './ErrorMessageBuilder';
export * from './ErrorTriggerChecker';
export * from './ErrorTriggerTester';
export * from './TriggerMatcher';

View file

@ -0,0 +1,16 @@
/**
* Services barrel export - Re-exports all services for backward compatibility.
*
* Domain organization:
* - analysis/: Chunk building and session analysis
* - discovery/: Scanning and locating session data
* - error/: Error detection and notification triggers
* - infrastructure/: Core application infrastructure
* - parsing/: Parsing JSONL and configuration files
*/
export * from './analysis';
export * from './discovery';
export * from './error';
export * from './infrastructure';
export * from './parsing';

View file

@ -0,0 +1,681 @@
/**
* ConfigManager service - Manages app configuration stored at ~/.claude/claude-code-context-config.json.
*
* Responsibilities:
* - Load configuration from disk on initialization
* - Provide default values for all configuration fields
* - Save configuration changes to disk
* - Manage notification settings (ignore patterns, projects, snooze)
* - Handle JSON parse errors gracefully
*/
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
import type { TriggerColor } from '@shared/constants/triggerColors';
const logger = createLogger('Service:ConfigManager');
const CONFIG_DIR = path.join(os.homedir(), '.claude');
const CONFIG_FILENAME = 'claude-code-context-config.json';
const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
// ===========================================================================
// Types
// ===========================================================================
export interface NotificationConfig {
enabled: boolean;
soundEnabled: boolean;
ignoredRegex: string[];
ignoredRepositories: string[]; // Repository group IDs to ignore
snoozedUntil: number | null; // Unix timestamp (ms) when snooze ends
snoozeMinutes: number; // Default snooze duration
/** Whether to include errors from subagent sessions */
includeSubagentErrors: boolean;
/** Notification triggers - define when to generate notifications */
triggers: NotificationTrigger[];
}
/**
* Content types that can trigger notifications.
*/
export type TriggerContentType = 'tool_result' | 'tool_use' | 'thinking' | 'text';
/**
* Known tool names that can be filtered for tool_use triggers.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used for type derivation only
const KNOWN_TOOL_NAMES = [
'Bash',
'Task',
'TodoWrite',
'Read',
'Write',
'Edit',
'Grep',
'Glob',
'WebFetch',
'WebSearch',
'LSP',
'Skill',
'NotebookEdit',
'AskUserQuestion',
'KillShell',
'TaskOutput',
] as const;
/**
* Tool names that can be filtered for tool_use triggers.
* Accepts known tool names or any custom tool name.
*/
export type TriggerToolName = (typeof KNOWN_TOOL_NAMES)[number] | (string & Record<never, never>);
/**
* Match fields available for different content types and tools.
*/
export type MatchFieldForToolResult = 'content';
export type MatchFieldForBash = 'command' | 'description';
export type MatchFieldForTask = 'description' | 'prompt' | 'subagent_type';
export type MatchFieldForRead = 'file_path';
export type MatchFieldForWrite = 'file_path' | 'content';
export type MatchFieldForEdit = 'file_path' | 'old_string' | 'new_string';
export type MatchFieldForGlob = 'pattern' | 'path';
export type MatchFieldForGrep = 'pattern' | 'path' | 'glob';
export type MatchFieldForWebFetch = 'url' | 'prompt';
export type MatchFieldForWebSearch = 'query';
export type MatchFieldForSkill = 'skill' | 'args';
export type MatchFieldForThinking = 'thinking';
export type MatchFieldForText = 'text';
/**
* Combined type for all possible match fields.
*/
export type TriggerMatchField =
| MatchFieldForToolResult
| MatchFieldForBash
| MatchFieldForTask
| MatchFieldForRead
| MatchFieldForWrite
| MatchFieldForEdit
| MatchFieldForGlob
| MatchFieldForGrep
| MatchFieldForWebFetch
| MatchFieldForWebSearch
| MatchFieldForSkill
| MatchFieldForThinking
| MatchFieldForText;
/**
* Trigger mode determines how the trigger evaluates conditions.
* - 'error_status': Triggers when is_error is true (simple boolean check)
* - 'content_match': Triggers when content matches a regex pattern
* - 'token_threshold': Triggers when token count exceeds threshold
*/
export type TriggerMode = 'error_status' | 'content_match' | 'token_threshold';
/**
* Token type for threshold triggers.
*/
export type TriggerTokenType = 'input' | 'output' | 'total';
/**
* Notification trigger configuration.
* Defines when notifications should be generated.
*/
export interface NotificationTrigger {
/** Unique identifier for this trigger */
id: string;
/** Human-readable name for this trigger */
name: string;
/** Whether this trigger is enabled */
enabled: boolean;
/** Content type to match */
contentType: TriggerContentType;
/** For tool_use/tool_result: specific tool name to match */
toolName?: TriggerToolName;
/** Whether this is a built-in trigger (cannot be deleted) */
isBuiltin?: boolean;
/** Regex patterns to IGNORE (skip notification if content matches any of these) */
ignorePatterns?: string[];
// === Discriminated Union Mode ===
/** Trigger evaluation mode */
mode: TriggerMode;
// === Mode: error_status ===
/** For error_status mode: always triggers on is_error=true */
requireError?: boolean;
// === Mode: content_match ===
/** For content_match mode: field to match against */
matchField?: TriggerMatchField;
/** For content_match mode: regex pattern to match */
matchPattern?: string;
// === Mode: token_threshold ===
/** For token_threshold mode: minimum token count to trigger */
tokenThreshold?: number;
/** For token_threshold mode: which token type to check */
tokenType?: TriggerTokenType;
// === Repository Scope ===
/** If set, this trigger only applies to these repository group IDs */
repositoryIds?: string[];
// === Display ===
/** Color for notification dot and navigation highlight (preset key or hex string) */
color?: TriggerColor;
}
export interface GeneralConfig {
launchAtLogin: boolean;
showDockIcon: boolean;
theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session';
}
export interface DisplayConfig {
showTimestamps: boolean;
compactMode: boolean;
syntaxHighlighting: boolean;
}
export interface SessionsConfig {
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
}
export interface AppConfig {
notifications: NotificationConfig;
general: GeneralConfig;
display: DisplayConfig;
sessions: SessionsConfig;
}
// Config section keys for type-safe updates
export type ConfigSection = keyof AppConfig;
// ===========================================================================
// Default Configuration
// ===========================================================================
// Default regex patterns for common non-actionable notifications
const DEFAULT_IGNORED_REGEX = ["The user doesn't want to proceed with this tool use\\."];
const DEFAULT_CONFIG: AppConfig = {
notifications: {
enabled: true,
soundEnabled: true,
ignoredRegex: [...DEFAULT_IGNORED_REGEX],
ignoredRepositories: [],
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
triggers: DEFAULT_TRIGGERS,
},
general: {
launchAtLogin: false,
showDockIcon: true,
theme: 'dark',
defaultTab: 'dashboard',
},
display: {
showTimestamps: true,
compactMode: false,
syntaxHighlighting: true,
},
sessions: {
pinnedSessions: {},
},
};
// ===========================================================================
// ConfigManager Class
// ===========================================================================
export class ConfigManager {
private config: AppConfig;
private readonly configPath: string;
private static instance: ConfigManager | null = null;
private triggerManager: TriggerManager;
constructor(configPath?: string) {
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
this.config = this.loadConfig();
this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>
this.saveConfig()
);
}
// ===========================================================================
// Singleton Pattern
// ===========================================================================
/**
* Gets the singleton instance of ConfigManager.
*/
static getInstance(): ConfigManager {
ConfigManager.instance ??= new ConfigManager();
return ConfigManager.instance;
}
/**
* Resets the singleton instance (useful for testing).
*/
static resetInstance(): void {
ConfigManager.instance = null;
}
// ===========================================================================
// Config Loading & Saving
// ===========================================================================
/**
* Loads configuration from disk.
* Returns default config if file doesn't exist or is invalid.
*/
private loadConfig(): AppConfig {
try {
if (!fs.existsSync(this.configPath)) {
logger.info('No config file found, using defaults');
return this.deepClone(DEFAULT_CONFIG);
}
const content = fs.readFileSync(this.configPath, 'utf8');
const parsed = JSON.parse(content) as Partial<AppConfig>;
// Merge with defaults to ensure all fields exist
return this.mergeWithDefaults(parsed);
} catch (error) {
logger.error('Error loading config, using defaults:', error);
return this.deepClone(DEFAULT_CONFIG);
}
}
/**
* Saves the current configuration to disk.
*/
private saveConfig(): void {
try {
this.persistConfig(this.config);
logger.info('Config saved');
} catch (error) {
logger.error('Error saving config:', error);
}
}
/**
* Persists configuration to the canonical path.
*/
private persistConfig(config: AppConfig): void {
const configDir = path.dirname(this.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const content = JSON.stringify(config, null, 2);
fs.writeFileSync(this.configPath, content, 'utf8');
}
/**
* Merges loaded config with defaults to ensure all fields exist.
* Special handling for triggers array to preserve existing triggers
* and add any missing builtin triggers.
*/
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
const loadedTriggers = loadedNotifications.triggers ?? [];
// Merge triggers: preserve existing triggers, add missing builtin ones
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
return {
notifications: {
...DEFAULT_CONFIG.notifications,
...loadedNotifications,
triggers: mergedTriggers,
},
general: {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
},
display: {
...DEFAULT_CONFIG.display,
...(loaded.display ?? {}),
},
sessions: {
...DEFAULT_CONFIG.sessions,
...(loaded.sessions ?? {}),
},
};
}
/**
* Deep clones an object.
*/
private deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T;
}
// ===========================================================================
// Config Access
// ===========================================================================
/**
* Gets the full configuration object.
*/
getConfig(): AppConfig {
return this.deepClone(this.config);
}
/**
* Gets the configuration file path.
*/
getConfigPath(): string {
return this.configPath;
}
// ===========================================================================
// Config Updates
// ===========================================================================
/**
* Updates a section of the configuration.
* @param section - The config section to update ('notifications', 'general', 'display')
* @param data - Partial data to merge into the section
*/
updateConfig<K extends ConfigSection>(section: K, data: Partial<AppConfig[K]>): AppConfig {
this.config[section] = {
...this.config[section],
...data,
};
this.saveConfig();
return this.getConfig();
}
// ===========================================================================
// Notification Ignore Regex Management
// ===========================================================================
/**
* Adds a regex pattern to the ignore list.
* Validates pattern for safety to prevent ReDoS attacks.
* @param pattern - Regex pattern string to add
* @returns Updated config
*/
addIgnoreRegex(pattern: string): AppConfig {
if (!pattern || pattern.trim().length === 0) {
return this.getConfig();
}
const trimmedPattern = pattern.trim();
// Validate regex pattern (includes ReDoS protection)
const validation = validateRegexPattern(trimmedPattern);
if (!validation.valid) {
logger.error(`ConfigManager: Invalid regex pattern: ${validation.error ?? 'Unknown error'}`);
return this.getConfig();
}
// Check for duplicates
if (this.config.notifications.ignoredRegex.includes(trimmedPattern)) {
return this.getConfig();
}
this.config.notifications.ignoredRegex.push(trimmedPattern);
this.saveConfig();
return this.getConfig();
}
/**
* Removes a regex pattern from the ignore list.
* @param pattern - Regex pattern string to remove
* @returns Updated config
*/
removeIgnoreRegex(pattern: string): AppConfig {
const index = this.config.notifications.ignoredRegex.indexOf(pattern);
if (index !== -1) {
this.config.notifications.ignoredRegex.splice(index, 1);
this.saveConfig();
}
return this.getConfig();
}
// ===========================================================================
// Notification Ignore Repository Management
// ===========================================================================
/**
* Adds a repository to the ignore list.
* @param repositoryId - Repository group ID to add
* @returns Updated config
*/
addIgnoreRepository(repositoryId: string): AppConfig {
if (!repositoryId || repositoryId.trim().length === 0) {
return this.getConfig();
}
const trimmedRepositoryId = repositoryId.trim();
// Check for duplicates
if (this.config.notifications.ignoredRepositories.includes(trimmedRepositoryId)) {
return this.getConfig();
}
this.config.notifications.ignoredRepositories.push(trimmedRepositoryId);
this.saveConfig();
return this.getConfig();
}
/**
* Removes a repository from the ignore list.
* @param repositoryId - Repository group ID to remove
* @returns Updated config
*/
removeIgnoreRepository(repositoryId: string): AppConfig {
const index = this.config.notifications.ignoredRepositories.indexOf(repositoryId);
if (index !== -1) {
this.config.notifications.ignoredRepositories.splice(index, 1);
this.saveConfig();
}
return this.getConfig();
}
// ===========================================================================
// Trigger Management (delegated to TriggerManager)
// ===========================================================================
/**
* Adds a new notification trigger.
* @param trigger - The trigger configuration to add
* @returns Updated config
*/
addTrigger(trigger: NotificationTrigger): AppConfig {
this.config.notifications.triggers = this.triggerManager.add(trigger);
return this.deepClone(this.config);
}
/**
* Updates an existing notification trigger.
* @param triggerId - ID of the trigger to update
* @param updates - Partial trigger configuration to apply
* @returns Updated config
*/
updateTrigger(triggerId: string, updates: Partial<NotificationTrigger>): AppConfig {
this.config.notifications.triggers = this.triggerManager.update(triggerId, updates);
return this.deepClone(this.config);
}
/**
* Removes a notification trigger.
* Built-in triggers cannot be removed.
* @param triggerId - ID of the trigger to remove
* @returns Updated config
*/
removeTrigger(triggerId: string): AppConfig {
this.config.notifications.triggers = this.triggerManager.remove(triggerId);
return this.deepClone(this.config);
}
/**
* Gets all notification triggers.
* @returns Array of notification triggers
*/
getTriggers(): NotificationTrigger[] {
return this.triggerManager.getAll();
}
/**
* Gets enabled notification triggers only.
* @returns Array of enabled notification triggers
*/
getEnabledTriggers(): NotificationTrigger[] {
return this.triggerManager.getEnabled();
}
// ===========================================================================
// Snooze Management
// ===========================================================================
/**
* Sets the snooze period for notifications.
* Alias: snooze()
* @param minutes - Number of minutes to snooze (uses config default if not provided)
* @returns Updated config
*/
setSnooze(minutes?: number): AppConfig {
const snoozeMinutes = minutes ?? this.config.notifications.snoozeMinutes;
const snoozedUntil = Date.now() + snoozeMinutes * 60 * 1000;
this.config.notifications.snoozedUntil = snoozedUntil;
this.saveConfig();
logger.info(
`ConfigManager: Notifications snoozed until ${new Date(snoozedUntil).toISOString()}`
);
return this.getConfig();
}
/**
* Alias for setSnooze() for convenience.
*/
snooze(minutes?: number): AppConfig {
return this.setSnooze(minutes);
}
/**
* Clears the snooze period, re-enabling notifications.
* @returns Updated config
*/
clearSnooze(): AppConfig {
this.config.notifications.snoozedUntil = null;
this.saveConfig();
logger.info('Snooze cleared');
return this.getConfig();
}
/**
* Checks if notifications are currently snoozed.
* Automatically clears expired snooze.
* @returns true if currently snoozed, false otherwise
*/
isSnoozed(): boolean {
const snoozedUntil = this.config.notifications.snoozedUntil;
if (snoozedUntil === null) {
return false;
}
// Check if snooze has expired
if (Date.now() >= snoozedUntil) {
// Auto-clear expired snooze
this.config.notifications.snoozedUntil = null;
this.saveConfig();
return false;
}
return true;
}
// ===========================================================================
// Session Pin Management
// ===========================================================================
/**
* Pins a session for a project.
* @param projectId - The project ID
* @param sessionId - The session ID to pin
*/
pinSession(projectId: string, sessionId: string): void {
const pins = this.config.sessions.pinnedSessions[projectId] ?? [];
// Check for duplicates
if (pins.some((p) => p.sessionId === sessionId)) {
return;
}
// Prepend (most recently pinned first)
this.config.sessions.pinnedSessions[projectId] = [{ sessionId, pinnedAt: Date.now() }, ...pins];
this.saveConfig();
}
/**
* Unpins a session for a project.
* @param projectId - The project ID
* @param sessionId - The session ID to unpin
*/
unpinSession(projectId: string, sessionId: string): void {
const pins = this.config.sessions.pinnedSessions[projectId];
if (!pins) return;
this.config.sessions.pinnedSessions[projectId] = pins.filter((p) => p.sessionId !== sessionId);
// Clean up empty arrays
if (this.config.sessions.pinnedSessions[projectId].length === 0) {
delete this.config.sessions.pinnedSessions[projectId];
}
this.saveConfig();
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Resets configuration to defaults.
* @returns Updated config
*/
resetToDefaults(): AppConfig {
this.config = this.deepClone(DEFAULT_CONFIG);
this.triggerManager.setTriggers(this.config.notifications.triggers);
this.saveConfig();
logger.info('Config reset to defaults');
return this.getConfig();
}
/**
* Reloads configuration from disk.
* Useful if config was modified externally.
* @returns Updated config
*/
reload(): AppConfig {
this.config = this.loadConfig();
this.triggerManager.setTriggers(this.config.notifications.triggers);
logger.info('Config reloaded from disk');
return this.getConfig();
}
}
// ===========================================================================
// Singleton Export
// ===========================================================================
/** Singleton instance for convenience */
export const configManager = ConfigManager.getInstance();

View file

@ -0,0 +1,356 @@
/**
* DataCache service - LRU cache for parsed session data.
*
* Responsibilities:
* - Cache parsed SessionDetail objects to avoid re-parsing
* - LRU eviction policy with configurable max size
* - TTL-based expiration
* - Provide cache invalidation for file changes
*/
import { type SessionDetail, type SubagentDetail } from '@main/types';
import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Service:DataCache');
interface CacheEntry<T> {
value: T;
timestamp: number;
version: number; // Cache schema version
}
// Union type for cached values
type CachedValue = SessionDetail | SubagentDetail;
export class DataCache {
private cache: Map<string, CacheEntry<CachedValue>>;
private maxSize: number;
private ttl: number; // Time-to-live in milliseconds
private enabled: boolean; // Whether caching is enabled
private static readonly CURRENT_VERSION = 2; // Increment when cache structure changes
constructor(maxSize: number = 50, ttlMinutes: number = 10, enabled: boolean = true) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttl = ttlMinutes * 60 * 1000;
this.enabled = enabled;
}
/**
* Enable or disable caching.
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
// Clear cache when disabling
this.cache.clear();
}
}
/**
* Check if caching is enabled.
*/
isEnabled(): boolean {
return this.enabled;
}
// ===========================================================================
// Cache Operations
// ===========================================================================
/**
* Gets a cached session detail.
* @param key - Cache key in format "projectId/sessionId"
* @returns The cached SessionDetail, or undefined if not found or expired
*/
get(key: string): SessionDetail | undefined {
if (!this.enabled) {
return undefined;
}
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
// Check if entry version is outdated
if (entry.version !== DataCache.CURRENT_VERSION) {
logger.info(`DataCache: Invalidating outdated cache entry (v${entry.version}): ${key}`);
this.cache.delete(key);
return undefined;
}
// Check if entry has expired
const now = Date.now();
if (now - entry.timestamp > this.ttl) {
this.cache.delete(key);
return undefined;
}
// Move to end (mark as recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value as SessionDetail;
}
/**
* Gets a cached subagent detail.
* @param key - Cache key in format "subagent-projectId-sessionId-subagentId"
* @returns The cached SubagentDetail, or undefined if not found or expired
*/
getSubagent(key: string): SubagentDetail | undefined {
if (!this.enabled) {
return undefined;
}
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
// Check if entry version is outdated
if (entry.version !== DataCache.CURRENT_VERSION) {
logger.info(
`DataCache: Invalidating outdated subagent cache entry (v${entry.version}): ${key}`
);
this.cache.delete(key);
return undefined;
}
// Check if entry has expired
const now = Date.now();
if (now - entry.timestamp > this.ttl) {
this.cache.delete(key);
return undefined;
}
// Move to end (mark as recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value as SubagentDetail;
}
/**
* Internal method to set a value in the cache.
* Handles LRU eviction and cache entry creation.
*/
private setInternal(key: string, value: CachedValue): void {
if (!this.enabled) {
return;
}
// If at max size, remove least recently used (first entry)
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
value,
timestamp: Date.now(),
version: DataCache.CURRENT_VERSION,
});
}
/**
* Sets a value in the cache.
* @param key - Cache key in format "projectId/sessionId"
* @param value - The SessionDetail to cache
*/
set(key: string, value: SessionDetail): void {
this.setInternal(key, value);
}
/**
* Sets a subagent detail value in the cache.
* @param key - Cache key in format "subagent-projectId-sessionId-subagentId"
* @param value - The SubagentDetail to cache
*/
setSubagent(key: string, value: SubagentDetail): void {
this.setInternal(key, value);
}
/**
* Checks if a key exists in the cache and is not expired.
* @param key - Cache key to check
* @returns true if key exists and is valid, false otherwise
*/
has(key: string): boolean {
return this.get(key) !== undefined;
}
// ===========================================================================
// Key Building
// ===========================================================================
/**
* Build a cache key from project and session IDs.
*/
static buildKey(projectId: string, sessionId: string): string {
return `${projectId}/${sessionId}`;
}
/**
* Parse a cache key into project and session IDs.
*/
static parseKey(key: string): { projectId: string; sessionId: string } | null {
const parts = key.split('/');
if (parts.length !== 2) return null;
return { projectId: parts[0], sessionId: parts[1] };
}
// ===========================================================================
// Invalidation
// ===========================================================================
/**
* Invalidates a specific cache entry.
* @param key - Cache key to invalidate
*/
invalidate(key: string): void {
this.cache.delete(key);
}
/**
* Invalidates a cache entry by project and session IDs.
*/
invalidateSession(projectId: string, sessionId: string): void {
this.invalidate(DataCache.buildKey(projectId, sessionId));
this.invalidateSubagentSession(projectId, sessionId);
}
/**
* Invalidates all cached subagent details for a session.
*/
invalidateSubagentSession(projectId: string, sessionId: string): void {
const prefix = `subagent-${projectId}-${sessionId}-`;
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
}
/**
* Invalidates all cache entries for a project.
* @param projectId - The project ID
*/
invalidateProject(projectId: string): void {
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
if (key.startsWith(`${projectId}/`)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
}
/**
* Clears the entire cache.
*/
clear(): void {
this.cache.clear();
}
// ===========================================================================
// Cache Management
// ===========================================================================
/**
* Gets current cache size.
* @returns Number of entries in the cache
*/
size(): number {
return this.cache.size;
}
/**
* Gets cache statistics.
* @returns Object with cache stats
*/
stats(): {
size: number;
maxSize: number;
ttlMinutes: number;
keys: string[];
} {
return {
size: this.cache.size,
maxSize: this.maxSize,
ttlMinutes: this.ttl / 60000,
keys: Array.from(this.cache.keys()),
};
}
/**
* Removes expired and outdated entries from the cache.
* Should be called periodically to prevent memory bloat.
*/
cleanExpired(): number {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
// Remove if expired OR outdated version
if (now - entry.timestamp > this.ttl || entry.version !== DataCache.CURRENT_VERSION) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
if (keysToDelete.length > 0) {
logger.info(`DataCache: Cleaned ${keysToDelete.length} expired/outdated entries`);
}
return keysToDelete.length;
}
/**
* Starts automatic cleanup of expired entries.
* @param intervalMinutes - How often to run cleanup (default: 5 minutes)
* @returns Timer handle that can be used to stop cleanup
*/
startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout {
const intervalMs = intervalMinutes * 60 * 1000;
return setInterval(() => {
this.cleanExpired();
}, intervalMs);
}
/**
* Gets all cached session IDs for a project.
*/
getProjectSessionIds(projectId: string): string[] {
const sessionIds: string[] = [];
for (const key of this.cache.keys()) {
if (key.startsWith(`${projectId}/`)) {
const parsed = DataCache.parseKey(key);
if (parsed) {
sessionIds.push(parsed.sessionId);
}
}
}
return sessionIds;
}
}

View file

@ -0,0 +1,697 @@
/**
* FileWatcher service - Watches for changes in Claude Code project files.
*
* Responsibilities:
* - Watch ~/.claude/projects/ directory for session changes
* - Watch ~/.claude/todos/ directory for todo changes
* - Detect new/modified/deleted files
* - Emit events to notify renderer process
* - Invalidate cache entries when files change
* - Detect errors in changed session files and notify NotificationManager
*/
import { type FileChangeEvent, type ParsedMessage } from '@main/types';
import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';
import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { errorDetector } from '../error/ErrorDetector';
import { ConfigManager } from './ConfigManager';
import { type DataCache } from './DataCache';
import { type NotificationManager } from './NotificationManager';
const logger = createLogger('Service:FileWatcher');
/** Debounce window for file change events */
const DEBOUNCE_MS = 100;
/** Retry delay when watched directories are unavailable or watcher errors occur */
const WATCHER_RETRY_MS = 2000;
/** Interval for periodic catch-up scan to detect missed fs.watch events */
const CATCH_UP_INTERVAL_MS = 30_000;
/** Only catch-up scan files modified within this window */
const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
interface AppendedParseResult {
messages: ParsedMessage[];
parsedLineCount: number;
consumedBytes: number;
}
interface ActiveSessionFile {
projectId: string;
sessionId: string;
subagentId?: string;
}
export class FileWatcher extends EventEmitter {
private projectsWatcher: fs.FSWatcher | null = null;
private todosWatcher: fs.FSWatcher | null = null;
private retryTimer: NodeJS.Timeout | null = null;
private projectsPath: string;
private todosPath: string;
private dataCache: DataCache;
private notificationManager: NotificationManager | null = null;
private isWatching: boolean = false;
private debounceTimers = new Map<string, NodeJS.Timeout>();
/** Track last processed line count per file for incremental error detection */
private lastProcessedLineCount = new Map<string, number>();
/** Track last processed file size in bytes for append-only parsing optimization */
private lastProcessedSize = new Map<string, number>();
/** Active session files tracked for periodic catch-up scan */
private activeSessionFiles = new Map<string, ActiveSessionFile>();
/** Timer for periodic catch-up scan */
private catchUpTimer: NodeJS.Timeout | null = null;
/** Files currently being processed (concurrency guard) */
private processingInProgress = new Set<string>();
/** Files that need reprocessing after current processing completes */
private pendingReprocess = new Set<string>();
constructor(dataCache: DataCache, projectsPath?: string, todosPath?: string) {
super();
this.projectsPath = projectsPath ?? getProjectsBasePath();
this.todosPath = todosPath ?? getTodosBasePath();
this.dataCache = dataCache;
}
/**
* Sets the NotificationManager for error detection integration.
* Must be called before start() to enable error notifications.
*/
setNotificationManager(manager: NotificationManager): void {
this.notificationManager = manager;
}
// ===========================================================================
// Watcher Control
// ===========================================================================
/**
* Starts watching the projects and todos directories.
*/
start(): void {
if (this.isWatching) {
logger.warn('Already watching');
return;
}
this.isWatching = true;
this.ensureWatchers();
this.startCatchUpTimer();
}
/**
* Stops all watchers.
*/
stop(): void {
this.isWatching = false;
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
if (this.projectsWatcher) {
this.projectsWatcher.close();
this.projectsWatcher = null;
}
if (this.todosWatcher) {
this.todosWatcher.close();
this.todosWatcher = null;
}
// Clear any pending debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
// Clear catch-up timer
if (this.catchUpTimer) {
clearInterval(this.catchUpTimer);
this.catchUpTimer = null;
}
// Clear error detection tracking
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
this.processingInProgress.clear();
this.pendingReprocess.clear();
logger.info('Stopped watching');
}
/**
* Starts the projects directory watcher.
*/
private startProjectsWatcher(): void {
if (this.projectsWatcher) {
return;
}
try {
if (!fs.existsSync(this.projectsPath)) {
logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`);
this.scheduleWatcherRetry();
return;
}
this.projectsWatcher = fs.watch(
this.projectsPath,
{ recursive: true },
(eventType, filename) => {
if (filename) {
this.handleProjectsChange(eventType, filename);
}
}
);
this.attachWatcherRecovery(this.projectsWatcher, 'projects');
logger.info(`FileWatcher: Started watching projects at ${this.projectsPath}`);
} catch (error) {
logger.error('Error starting projects watcher:', error);
this.projectsWatcher = null;
this.scheduleWatcherRetry();
}
}
/**
* Starts the todos directory watcher.
*/
private startTodosWatcher(): void {
if (this.todosWatcher) {
return;
}
try {
if (!fs.existsSync(this.todosPath)) {
// Todos directory may not exist yet - that's OK
this.scheduleWatcherRetry();
return;
}
this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => {
if (filename) {
this.handleTodosChange(eventType, filename);
}
});
this.attachWatcherRecovery(this.todosWatcher, 'todos');
logger.info(`FileWatcher: Started watching todos at ${this.todosPath}`);
} catch (error) {
logger.error('Error starting todos watcher:', error);
this.todosWatcher = null;
this.scheduleWatcherRetry();
}
}
private ensureWatchers(): void {
if (!this.isWatching) {
return;
}
this.startProjectsWatcher();
this.startTodosWatcher();
if (!this.projectsWatcher || !this.todosWatcher) {
this.scheduleWatcherRetry();
}
}
private scheduleWatcherRetry(): void {
if (!this.isWatching || this.retryTimer) {
return;
}
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.ensureWatchers();
}, WATCHER_RETRY_MS);
}
private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void {
watcher.on('error', (error) => {
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
if (watcherType === 'projects') {
this.projectsWatcher = null;
} else {
this.todosWatcher = null;
}
this.scheduleWatcherRetry();
});
watcher.on('close', () => {
if (!this.isWatching) {
return;
}
if (watcherType === 'projects') {
this.projectsWatcher = null;
} else {
this.todosWatcher = null;
}
this.scheduleWatcherRetry();
});
}
// ===========================================================================
// Event Handling
// ===========================================================================
/**
* Handles file change events in the projects directory.
*/
private handleProjectsChange(eventType: string, filename: string): void {
try {
// Ignore non-JSONL files
if (!filename.endsWith('.jsonl')) {
return;
}
// Debounce rapid changes to the same file
this.debounce(filename, () => this.processProjectsChange(eventType, filename));
} catch (error) {
logger.error('Error handling projects change:', error);
}
}
/**
* Process a debounced projects change.
*/
private processProjectsChange(eventType: string, filename: string): void {
const parts = filename.split(path.sep);
const projectId = parts[0];
if (!projectId) return;
const fullPath = path.join(this.projectsPath, filename);
const fileExists = fs.existsSync(fullPath);
// Determine change type
let changeType: FileChangeEvent['type'];
if (eventType === 'rename') {
changeType = fileExists ? 'add' : 'unlink';
} else {
changeType = 'change';
}
// Parse session ID and check if it's a subagent
let sessionId: string | undefined;
let isSubagent = false;
// Session file at project root: projectId/sessionId.jsonl
if (parts.length === 2) {
sessionId = path.basename(parts[1], '.jsonl');
}
// Subagent file: projectId/sessionId/subagents/agent-hash.jsonl
else if (parts.length === 4 && parts[2] === 'subagents') {
sessionId = parts[1];
isSubagent = true;
}
if (sessionId) {
// Invalidate cache
this.dataCache.invalidateSession(projectId, sessionId);
projectPathResolver.invalidateProject(projectId);
if (changeType === 'unlink') {
this.clearErrorTracking(fullPath);
}
// Emit event
const event: FileChangeEvent = {
type: changeType,
path: fullPath,
projectId,
sessionId,
isSubagent,
};
this.emit('file-change', event);
logger.info(
`FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${filename}`
);
// Detect errors in changed session files (not deleted files)
if (changeType !== 'unlink' && this.notificationManager) {
if (isSubagent) {
// Only process subagent files if config allows
const config = ConfigManager.getInstance().getConfig();
if (config.notifications.includeSubagentErrors) {
const subagentFilename = path.basename(parts[3], '.jsonl');
const subagentId = subagentFilename.replace(/^agent-/, '');
this.activeSessionFiles.set(fullPath, { projectId, sessionId, subagentId });
this.detectErrorsInSessionFile(projectId, sessionId, fullPath, subagentId).catch(
(err) => {
logger.error('Error detecting errors in subagent file:', err);
}
);
}
} else {
this.activeSessionFiles.set(fullPath, { projectId, sessionId });
this.detectErrorsInSessionFile(projectId, sessionId, fullPath).catch((err) => {
logger.error('Error detecting errors in session file:', err);
});
}
}
}
}
// ===========================================================================
// Error Detection
// ===========================================================================
/**
* Detects errors in a session file and sends notifications.
* Uses incremental processing to only check new lines since last check.
*/
private async detectErrorsInSessionFile(
projectId: string,
sessionId: string,
filePath: string,
subagentId?: string
): Promise<void> {
if (!this.notificationManager) {
return;
}
// Concurrency guard: if already processing this file, mark for reprocessing
if (this.processingInProgress.has(filePath)) {
this.pendingReprocess.add(filePath);
return;
}
this.processingInProgress.add(filePath);
try {
// Get the last processed line count for this file
const lastLineCount = this.lastProcessedLineCount.get(filePath) ?? 0;
const lastSize = this.lastProcessedSize.get(filePath) ?? 0;
const fileStats = await fs.promises.stat(filePath);
const currentSize = fileStats.size;
// Fast path: no size change means no new data
if (currentSize === lastSize && lastLineCount > 0) {
return;
}
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
let newMessages: ParsedMessage[] = [];
let currentLineCount: number;
let processedSize: number;
if (canUseIncrementalAppend) {
const appended = await this.parseAppendedMessages(filePath, lastSize);
newMessages = appended.messages;
currentLineCount = lastLineCount + appended.parsedLineCount;
processedSize = lastSize + appended.consumedBytes;
} else {
// Fallback for first-read, truncation, or rewrite scenarios
const messages = await parseJsonlFile(filePath);
currentLineCount = messages.length;
newMessages = messages.slice(lastLineCount);
// Re-stat after full parse to capture bytes written during the parse
const postParseStats = await fs.promises.stat(filePath);
processedSize = postParseStats.size;
}
// If no new lines, skip processing
if (currentLineCount <= lastLineCount) {
this.lastProcessedSize.set(filePath, processedSize);
return;
}
// Detect errors in new messages
// Note: We pass the offset-adjusted line numbers to errorDetector
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
// Adjust line numbers to account for the offset and annotate subagent errors
for (const error of errors) {
if (error.lineNumber !== undefined) {
error.lineNumber = error.lineNumber + lastLineCount;
}
if (subagentId) {
error.subagentId = subagentId;
}
}
// Notify for each detected error
for (const error of errors) {
await this.notificationManager.addError(error);
}
// Update the last processed line count
this.lastProcessedLineCount.set(filePath, currentLineCount);
this.lastProcessedSize.set(filePath, processedSize);
if (errors.length > 0) {
logger.info(`FileWatcher: Detected ${errors.length} errors in ${filePath}`);
}
} catch (err) {
logger.error(`FileWatcher: Error processing session file for errors: ${filePath}`, err);
} finally {
this.processingInProgress.delete(filePath);
// If a reprocess was requested while we were processing, run again
if (this.pendingReprocess.has(filePath)) {
this.pendingReprocess.delete(filePath);
this.detectErrorsInSessionFile(projectId, sessionId, filePath, subagentId).catch((e) => {
logger.error('Error during reprocessing of session file:', e);
});
}
}
}
/**
* Clears the error detection tracking for a specific file.
* Call this when a file is deleted or to force re-processing.
*/
clearErrorTracking(filePath: string): void {
this.lastProcessedLineCount.delete(filePath);
this.lastProcessedSize.delete(filePath);
this.activeSessionFiles.delete(filePath);
}
/**
* Clears all error detection tracking.
*/
clearAllErrorTracking(): void {
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
}
/**
* Parse only newly appended JSONL lines from the given byte offset.
*/
private async parseAppendedMessages(
filePath: string,
startOffset: number
): Promise<AppendedParseResult> {
const parsedMessages: ParsedMessage[] = [];
const stream = fs.createReadStream(filePath, { start: startOffset, encoding: 'utf8' });
let buffer = '';
let consumedBytes = 0;
let parsedLineCount = 0;
for await (const chunk of stream) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const rawLine of lines) {
consumedBytes += Buffer.byteLength(`${rawLine}\n`, 'utf8');
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (!line.trim()) {
continue;
}
try {
const parsed = parseJsonlLine(line);
if (parsed) {
parsedMessages.push(parsed);
parsedLineCount++;
}
} catch {
// Ignore malformed appended lines; full parse path will recover on next rewrite.
}
}
}
// Handle final line without trailing newline
if (buffer.trim()) {
try {
const parsed = parseJsonlLine(buffer);
if (parsed) {
parsedMessages.push(parsed);
parsedLineCount++;
consumedBytes += Buffer.byteLength(buffer, 'utf8');
}
} catch {
// Keep offset pinned until this trailing partial becomes a complete line.
}
}
return {
messages: parsedMessages,
parsedLineCount,
consumedBytes,
};
}
/**
* Handles file change events in the todos directory.
*/
private handleTodosChange(eventType: string, filename: string): void {
try {
// Only handle JSON files
if (!filename.endsWith('.json')) {
return;
}
// Debounce rapid changes
this.debounce(`todos/${filename}`, () => this.processTodosChange(eventType, filename));
} catch (error) {
logger.error('Error handling todos change:', error);
}
}
/**
* Process a debounced todos change.
*/
private processTodosChange(eventType: string, filename: string): void {
// Session ID is the filename without extension
const sessionId = path.basename(filename, '.json');
const fullPath = path.join(this.todosPath, filename);
const fileExists = fs.existsSync(fullPath);
// Determine change type
let changeType: FileChangeEvent['type'];
if (eventType === 'rename') {
changeType = fileExists ? 'add' : 'unlink';
} else {
changeType = 'change';
}
// Emit event (we don't have projectId for todos)
const event: FileChangeEvent = {
type: changeType,
path: fullPath,
sessionId,
isSubagent: false,
};
this.emit('todo-change', event);
logger.info(`FileWatcher: ${changeType} todo - ${filename}`);
}
// ===========================================================================
// Catch-Up Scan
// ===========================================================================
/**
* Starts the periodic catch-up timer to detect file growth missed by fs.watch.
* FSEvents on macOS can coalesce, delay, or drop events. This timer polls
* tracked active session files every CATCH_UP_INTERVAL_MS to detect unprocessed growth.
*/
private startCatchUpTimer(): void {
if (this.catchUpTimer) {
return;
}
this.catchUpTimer = setInterval(() => {
this.runCatchUpScan().catch((err) => {
logger.error('Error during catch-up scan:', err);
});
}, CATCH_UP_INTERVAL_MS);
}
/**
* Scans active session files for unprocessed growth.
* Only checks files modified within the last hour.
*/
private async runCatchUpScan(): Promise<void> {
if (!this.notificationManager || this.activeSessionFiles.size === 0) {
return;
}
const now = Date.now();
for (const [filePath, info] of this.activeSessionFiles) {
try {
const stats = await fs.promises.stat(filePath);
// Skip files not modified recently
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
this.activeSessionFiles.delete(filePath);
continue;
}
const lastSize = this.lastProcessedSize.get(filePath) ?? 0;
if (stats.size > lastSize) {
logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`);
await this.detectErrorsInSessionFile(
info.projectId,
info.sessionId,
filePath,
info.subagentId
);
}
} catch (err) {
// File may have been deleted between iterations
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
this.activeSessionFiles.delete(filePath);
this.clearErrorTracking(filePath);
} else {
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
}
}
}
}
// ===========================================================================
// Debouncing
// ===========================================================================
/**
* Debounce a function call for a specific key.
*/
private debounce(key: string, fn: () => void): void {
// Clear existing timer for this key
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set new timer
const timer = setTimeout(() => {
this.debounceTimers.delete(key);
fn();
}, DEBOUNCE_MS);
this.debounceTimers.set(key, timer);
}
// ===========================================================================
// Status
// ===========================================================================
/**
* Returns whether the watcher is currently active.
*/
isActive(): boolean {
return this.isWatching;
}
/**
* Returns watched paths.
*/
getWatchedPaths(): { projects: string; todos: string } {
return {
projects: this.projectsPath,
todos: this.todosPath,
};
}
}

View file

@ -0,0 +1,657 @@
/**
* NotificationManager service - Manages native macOS notifications and error history.
*
* Responsibilities:
* - Store error history at ~/.claude/claude-code-context-notifications.json (max 100 entries)
* - Show native macOS notifications using Electron's Notification API
* - Implement throttling (5 seconds per unique error hash)
* - Respect config.notifications.enabled and snoozedUntil
* - Filter errors matching ignoredRegex patterns
* - Filter errors from ignoredProjects
* - Auto-prune notifications over 100 on startup
* - Emit IPC events to renderer: notification:new, notification:updated
*/
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { type DetectedError } from '../error/ErrorMessageBuilder';
const logger = createLogger('Service:NotificationManager');
import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { ConfigManager } from './ConfigManager';
// Re-export DetectedError for backward compatibility
export type { DetectedError };
/**
* Stored notification with read status.
*/
export interface StoredNotification extends DetectedError {
/** Whether the notification has been read */
isRead: boolean;
/** When the notification was created (may differ from error timestamp) */
createdAt: number;
}
/**
* Pagination options for getNotifications.
*/
export interface GetNotificationsOptions {
/** Number of notifications to return */
limit?: number;
/** Number of notifications to skip */
offset?: number;
}
/**
* Result of getNotifications call.
*/
export interface GetNotificationsResult {
/** Notifications for this page */
notifications: StoredNotification[];
/** Total number of notifications */
total: number;
/** Total count (alias for IPC compatibility) */
totalCount: number;
/** Number of unread notifications */
unreadCount: number;
/** Whether there are more notifications to load */
hasMore: boolean;
}
// =============================================================================
// Constants
// =============================================================================
/** Maximum number of notifications to store */
const MAX_NOTIFICATIONS = 100;
/** Throttle window in milliseconds (5 seconds) */
const THROTTLE_MS = 5000;
/** Path to notifications storage file */
const NOTIFICATIONS_PATH = path.join(
os.homedir(),
'.claude',
'claude-code-context-notifications.json'
);
// =============================================================================
// NotificationManager Class
// =============================================================================
export class NotificationManager extends EventEmitter {
private static instance: NotificationManager | null = null;
private notifications: StoredNotification[] = [];
private configManager: ConfigManager;
private mainWindow: BrowserWindow | null = null;
private throttleMap = new Map<string, number>();
private isInitialized: boolean = false;
constructor(configManager?: ConfigManager) {
super();
this.configManager = configManager ?? ConfigManager.getInstance();
}
// ===========================================================================
// Singleton Pattern
// ===========================================================================
/**
* Gets the singleton instance of NotificationManager.
*/
static getInstance(): NotificationManager {
if (!NotificationManager.instance) {
NotificationManager.instance = new NotificationManager();
NotificationManager.instance.initialize();
}
return NotificationManager.instance;
}
/**
* Resets the singleton instance (useful for testing).
*/
static resetInstance(): void {
NotificationManager.instance = null;
}
/**
* Sets the singleton instance (useful for dependency injection).
*/
static setInstance(instance: NotificationManager): void {
NotificationManager.instance = instance;
}
// ===========================================================================
// Initialization
// ===========================================================================
/**
* Initializes the notification manager.
* Loads existing notifications and prunes if needed.
*/
initialize(): void {
if (this.isInitialized) {
return;
}
this.loadNotifications();
this.pruneNotifications();
this.isInitialized = true;
logger.info(`NotificationManager: Initialized with ${this.notifications.length} notifications`);
}
/**
* Sets the main window reference for sending IPC events.
*/
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
// ===========================================================================
// Persistence
// ===========================================================================
/**
* Loads notifications from disk.
*/
private loadNotifications(): void {
try {
if (fs.existsSync(NOTIFICATIONS_PATH)) {
const data = fs.readFileSync(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
}
}
} catch (error) {
logger.error('Error loading notifications:', error);
this.notifications = [];
}
}
/**
* Saves notifications to disk.
*/
private saveNotifications(): void {
try {
// Ensure directory exists
const dir = path.dirname(NOTIFICATIONS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(this.notifications, null, 2), 'utf8');
} catch (error) {
logger.error('Error saving notifications:', error);
}
}
/**
* Prunes notifications to MAX_NOTIFICATIONS entries.
* Removes oldest notifications first.
*/
private pruneNotifications(): void {
if (this.notifications.length > MAX_NOTIFICATIONS) {
// Sort by createdAt descending (newest first)
this.notifications.sort((a, b) => b.createdAt - a.createdAt);
// Keep only the newest MAX_NOTIFICATIONS
const removed = this.notifications.length - MAX_NOTIFICATIONS;
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS);
this.saveNotifications();
logger.info(`NotificationManager: Pruned ${removed} old notifications`);
}
}
// ===========================================================================
// Error Filtering
// ===========================================================================
/**
* Generates a unique hash for throttling based on projectId + message.
*/
private generateErrorHash(error: DetectedError): string {
return `${error.projectId}:${error.message}`;
}
/**
* Checks if an error should be throttled.
*/
private isThrottled(error: DetectedError): boolean {
const hash = this.generateErrorHash(error);
const lastSeen = this.throttleMap.get(hash);
if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) {
return true;
}
// Update throttle map
this.throttleMap.set(hash, Date.now());
// Clean up old entries periodically
this.cleanupThrottleMap();
return false;
}
/**
* Cleans up old entries from the throttle map.
*/
private cleanupThrottleMap(): void {
const now = Date.now();
const expiredThreshold = now - THROTTLE_MS * 2;
const keysToDelete: string[] = [];
this.throttleMap.forEach((timestamp, hash) => {
if (timestamp < expiredThreshold) {
keysToDelete.push(hash);
}
});
for (const key of keysToDelete) {
this.throttleMap.delete(key);
}
}
/**
* Checks if notifications are currently enabled based on config.
*/
private areNotificationsEnabled(): boolean {
const config = this.configManager.getConfig();
// Check if notifications are globally disabled
if (!config.notifications.enabled) {
return false;
}
// Check if notifications are snoozed
if (config.notifications.snoozedUntil) {
if (Date.now() < config.notifications.snoozedUntil) {
return false;
} else {
// Snooze has expired, clear it
this.configManager.clearSnooze();
}
}
return true;
}
/**
* Checks if an error matches any ignored regex patterns.
*/
private matchesIgnoredRegex(error: DetectedError): boolean {
const config = this.configManager.getConfig();
const patterns = config.notifications.ignoredRegex;
if (!patterns || patterns.length === 0) {
return false;
}
for (const pattern of patterns) {
try {
const regex = new RegExp(pattern, 'i');
if (regex.test(error.message)) {
return true;
}
} catch {
// Invalid regex pattern, skip
logger.warn(`NotificationManager: Invalid regex pattern: ${pattern}`);
}
}
return false;
}
/**
* Checks if the error is from an ignored repository.
* Resolves the project path to a repository ID and checks against ignored list.
*/
private async isFromIgnoredRepository(error: DetectedError): Promise<boolean> {
const config = this.configManager.getConfig();
const ignoredRepositories = config.notifications.ignoredRepositories;
if (!ignoredRepositories || ignoredRepositories.length === 0) {
return false;
}
// Resolve project ID to repository ID using canonical path resolution.
const projectPath = await projectPathResolver.resolveProjectPath(error.projectId, {
cwdHint: error.context.cwd,
});
const identity = await gitIdentityResolver.resolveIdentity(projectPath);
if (!identity) {
return false;
}
return ignoredRepositories.includes(identity.id);
}
/**
* Determines if an error should generate a notification.
*/
private async shouldNotify(error: DetectedError): Promise<boolean> {
// Check if notifications are enabled
if (!this.areNotificationsEnabled()) {
return false;
}
// Check if error is from an ignored repository
if (await this.isFromIgnoredRepository(error)) {
return false;
}
// Check if error matches an ignored regex
if (this.matchesIgnoredRegex(error)) {
return false;
}
// Check throttling (for native toast dedup only — storage is unconditional)
if (this.isThrottled(error)) {
return false;
}
return true;
}
// ===========================================================================
// Native Notifications
// ===========================================================================
/**
* Shows a native macOS notification for an error.
*/
private showNativeNotification(error: DetectedError): void {
// Check if Notification is supported
if (!Notification.isSupported()) {
logger.warn('Native notifications not supported');
return;
}
const config = this.configManager.getConfig();
const notification = new Notification({
title: 'Claude Code Error',
subtitle: error.context.projectName,
body: error.message.slice(0, 200),
sound: config.notifications.soundEnabled ? 'default' : undefined,
});
notification.on('click', () => {
// Focus app window
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.show();
this.mainWindow.focus();
// Send deep link to renderer
this.mainWindow.webContents.send('notification:clicked', error);
}
// Emit event for other listeners
this.emit('notification-clicked', error);
});
notification.show();
}
// ===========================================================================
// IPC Event Emission
// ===========================================================================
/**
* Emits a notification:new event to the renderer.
*/
private emitNewNotification(notification: StoredNotification): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('notification:new', notification);
}
this.emit('notification-new', notification);
}
/**
* Emits a notification:updated event to the renderer.
*/
private emitNotificationUpdated(): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('notification:updated', {
total: this.notifications.length,
unreadCount: this.getUnreadCountSync(),
});
}
this.emit('notification-updated', {
total: this.notifications.length,
unreadCount: this.getUnreadCountSync(),
});
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Adds an error and shows a notification if enabled.
* @param error - The detected error to add
* @returns The stored notification, or null if filtered/throttled
*/
async addError(error: DetectedError): Promise<StoredNotification | null> {
// Deduplicate by toolUseId: the same tool call can appear in both the
// subagent JSONL file and the parent session JSONL (as a progress event).
// Keep the subagent-annotated version (with subagentId) when possible.
if (error.toolUseId) {
const existingIndex = this.notifications.findIndex((n) => n.toolUseId === error.toolUseId);
if (existingIndex !== -1) {
const existing = this.notifications[existingIndex];
if (!existing.subagentId && error.subagentId) {
// Replace: prefer the subagent-annotated version
this.notifications.splice(existingIndex, 1);
} else {
// Already have a (better or equal) version — skip
return null;
}
}
}
const storedNotification: StoredNotification = {
...error,
isRead: false,
createdAt: Date.now(),
};
// Add to the beginning of the list (newest first)
this.notifications.unshift(storedNotification);
// Prune if needed
this.pruneNotifications();
// Save to disk
this.saveNotifications();
// Emit new notification event
this.emitNewNotification(storedNotification);
// Emit authoritative counters (total/unread) so renderer badge stays in sync.
this.emitNotificationUpdated();
// Show native notification if enabled and not filtered
if (await this.shouldNotify(error)) {
this.showNativeNotification(error);
}
return storedNotification;
}
/**
* Gets a paginated list of notifications.
* @param options - Pagination options
* @returns Paginated notifications result
*/
async getNotifications(options?: GetNotificationsOptions): Promise<GetNotificationsResult> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
// Notifications are already sorted newest first
const notifications = this.notifications.slice(offset, offset + limit);
const total = this.notifications.length;
const hasMore = offset + notifications.length < total;
return {
notifications,
total,
totalCount: total,
unreadCount: this.getUnreadCountSync(),
hasMore,
};
}
/**
* Marks a notification as read.
* @param id - The notification ID to mark as read
* @returns true if found and marked, false otherwise
*/
async markRead(id: string): Promise<boolean> {
const notification = this.notifications.find((n) => n.id === id);
if (!notification) {
return false;
}
if (!notification.isRead) {
notification.isRead = true;
this.saveNotifications();
this.emitNotificationUpdated();
}
return true;
}
/**
* Marks all notifications as read.
* @returns true on success
*/
async markAllRead(): Promise<boolean> {
let changed = false;
for (const notification of this.notifications) {
if (!notification.isRead) {
notification.isRead = true;
changed = true;
}
}
if (changed) {
this.saveNotifications();
this.emitNotificationUpdated();
}
return true;
}
/**
* Clears all notifications.
*/
clear(): void {
this.notifications = [];
this.saveNotifications();
this.emitNotificationUpdated();
}
/**
* Clears all notifications (async version for IPC).
* @returns true on success
*/
async clearAll(): Promise<boolean> {
this.clear();
return true;
}
/**
* Gets the count of unread notifications.
* @returns Number of unread notifications (Promise for IPC compatibility)
*/
async getUnreadCount(): Promise<number> {
return this.notifications.filter((n) => !n.isRead).length;
}
/**
* Gets the count of unread notifications (sync version).
* @returns Number of unread notifications
*/
getUnreadCountSync(): number {
return this.notifications.filter((n) => !n.isRead).length;
}
/**
* Gets a specific notification by ID.
* @param id - The notification ID
* @returns The notification or undefined if not found
*/
getNotification(id: string): StoredNotification | undefined {
return this.notifications.find((n) => n.id === id);
}
/**
* Deletes a specific notification.
* @param id - The notification ID to delete
* @returns true if found and deleted, false otherwise
*/
deleteNotification(id: string): boolean {
const index = this.notifications.findIndex((n) => n.id === id);
if (index === -1) {
return false;
}
this.notifications.splice(index, 1);
this.saveNotifications();
this.emitNotificationUpdated();
return true;
}
// ===========================================================================
// Stats
// ===========================================================================
/**
* Gets statistics about notifications.
*/
getStats(): {
total: number;
unread: number;
byProject: Record<string, number>;
bySource: Record<string, number>;
} {
const byProject: Record<string, number> = {};
const bySource: Record<string, number> = {};
for (const notification of this.notifications) {
const projectName = notification.context.projectName;
byProject[projectName] = (byProject[projectName] || 0) + 1;
bySource[notification.source] = (bySource[notification.source] || 0) + 1;
}
return {
total: this.notifications.length,
unread: this.getUnreadCountSync(),
byProject,
bySource,
};
}
}

View file

@ -0,0 +1,318 @@
/**
* TriggerManager - Manages notification triggers.
*
* Handles CRUD operations for notification triggers including:
* - Adding, updating, and removing triggers
* - Validating trigger configurations (with ReDoS protection)
* - Managing builtin vs custom triggers
*/
import { validateRegexPattern } from '@main/utils/regexValidation';
import type { NotificationTrigger } from './ConfigManager';
// ===========================================================================
// Types
// ===========================================================================
export interface TriggerValidationResult {
valid: boolean;
errors: string[];
}
// ===========================================================================
// Default Triggers
// ===========================================================================
/**
* Default built-in notification triggers.
*/
export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
{
id: 'builtin-tool-result-error',
name: 'Tool Result Error',
enabled: true,
contentType: 'tool_result',
mode: 'error_status',
requireError: true,
ignorePatterns: [
"The user doesn't want to proceed with this tool use\\.",
'\\[Request interrupted by user for tool use\\]',
],
isBuiltin: true,
color: 'red',
},
{
id: 'builtin-bash-command',
name: '.env File Access Alert',
enabled: true,
contentType: 'tool_use',
toolName: 'Bash',
mode: 'content_match',
matchField: 'command',
matchPattern: '/.env',
isBuiltin: true,
color: 'red',
},
{
id: 'builtin-high-token-usage',
name: 'High Token Usage',
enabled: true,
contentType: 'tool_result',
mode: 'token_threshold',
tokenThreshold: 8000,
tokenType: 'total',
color: 'orange',
isBuiltin: true,
},
];
// ===========================================================================
// TriggerManager Class
// ===========================================================================
export class TriggerManager {
private triggers: NotificationTrigger[];
private readonly onSave: () => void;
constructor(triggers: NotificationTrigger[], onSave: () => void) {
this.triggers = triggers;
this.onSave = onSave;
}
// ===========================================================================
// CRUD Operations
// ===========================================================================
/**
* Gets all notification triggers.
*/
getAll(): NotificationTrigger[] {
return this.deepClone(this.triggers);
}
/**
* Gets enabled notification triggers only.
*/
getEnabled(): NotificationTrigger[] {
return this.deepClone(this.triggers.filter((t) => t.enabled));
}
/**
* Gets a trigger by ID.
*/
getById(triggerId: string): NotificationTrigger | undefined {
const trigger = this.triggers.find((t) => t.id === triggerId);
return trigger ? this.deepClone(trigger) : undefined;
}
/**
* Adds a new notification trigger.
* @throws Error if trigger with same ID already exists
*/
add(trigger: NotificationTrigger): NotificationTrigger[] {
// Check if trigger with same ID already exists
if (this.triggers.some((t) => t.id === trigger.id)) {
throw new Error(`Trigger with ID "${trigger.id}" already exists`);
}
// Validate trigger
const validation = this.validate(trigger);
if (!validation.valid) {
throw new Error(`Invalid trigger: ${validation.errors.join(', ')}`);
}
this.triggers = [...this.triggers, trigger];
this.onSave();
return this.getAll();
}
/**
* Updates an existing notification trigger.
* @throws Error if trigger not found
*/
update(triggerId: string, updates: Partial<NotificationTrigger>): NotificationTrigger[] {
const index = this.triggers.findIndex((t) => t.id === triggerId);
if (index === -1) {
throw new Error(`Trigger with ID "${triggerId}" not found`);
}
// Extract allowedUpdates without isBuiltin (which cannot be changed)
const allowedUpdates = Object.fromEntries(
Object.entries(updates).filter(([key]) => key !== 'isBuiltin')
) as Partial<NotificationTrigger>;
const updated = { ...this.triggers[index], ...allowedUpdates };
// Ensure mode is set (for backward compatibility with old triggers)
if (!updated.mode) {
updated.mode = this.inferMode(updated);
}
// Validate updated trigger
const validation = this.validate(updated);
if (!validation.valid) {
throw new Error(`Invalid trigger update: ${validation.errors.join(', ')}`);
}
this.triggers = this.triggers.map((t, i) => (i === index ? updated : t));
this.onSave();
return this.getAll();
}
/**
* Infers trigger mode from trigger properties for backward compatibility.
*/
private inferMode(
trigger: Partial<NotificationTrigger>
): 'error_status' | 'content_match' | 'token_threshold' {
if (trigger.requireError) return 'error_status';
if (trigger.matchPattern || trigger.matchField) return 'content_match';
if (trigger.tokenThreshold !== undefined) return 'token_threshold';
return 'error_status'; // default fallback
}
/**
* Removes a notification trigger.
* Built-in triggers cannot be removed.
* @throws Error if trigger not found or is builtin
*/
remove(triggerId: string): NotificationTrigger[] {
const trigger = this.triggers.find((t) => t.id === triggerId);
if (!trigger) {
throw new Error(`Trigger with ID "${triggerId}" not found`);
}
if (trigger.isBuiltin) {
throw new Error('Cannot remove built-in triggers. Disable them instead.');
}
this.triggers = this.triggers.filter((t) => t.id !== triggerId);
this.onSave();
return this.getAll();
}
// ===========================================================================
// Validation
// ===========================================================================
/**
* Validates a trigger configuration.
*/
validate(trigger: NotificationTrigger): TriggerValidationResult {
const errors: string[] = [];
// Required fields
if (!trigger.id || trigger.id.trim() === '') {
errors.push('Trigger ID is required');
}
if (!trigger.name || trigger.name.trim() === '') {
errors.push('Trigger name is required');
}
if (!trigger.contentType) {
errors.push('Content type is required');
}
if (!trigger.mode) {
errors.push('Trigger mode is required');
}
// Mode-specific validation
if (trigger.mode === 'content_match') {
// matchField is required unless it's tool_use with "Any Tool" (no toolName)
// In that case, we match against the entire JSON input
if (!trigger.matchField && !(trigger.contentType === 'tool_use' && !trigger.toolName)) {
errors.push('Match field is required for content_match mode');
}
// Validate regex pattern if provided (with ReDoS protection)
if (trigger.matchPattern) {
const validation = validateRegexPattern(trigger.matchPattern);
if (!validation.valid) {
errors.push(validation.error ?? 'Invalid regex pattern');
}
}
}
if (trigger.mode === 'token_threshold') {
if (trigger.tokenThreshold === undefined || trigger.tokenThreshold < 0) {
errors.push('Token threshold must be a non-negative number');
}
if (!trigger.tokenType) {
errors.push('Token type is required for token_threshold mode');
}
}
// Validate ignore patterns (with ReDoS protection)
if (trigger.ignorePatterns) {
for (const pattern of trigger.ignorePatterns) {
const validation = validateRegexPattern(pattern);
if (!validation.valid) {
errors.push(
`Invalid ignore pattern "${pattern}": ${validation.error ?? 'Unknown error'}`
);
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
// ===========================================================================
// Trigger Merging
// ===========================================================================
/**
* Merges loaded triggers with default triggers.
* - Preserves all existing triggers (including user-modified builtin triggers)
* - Adds any missing builtin triggers from defaults
* - Removes deprecated builtin triggers that are no longer in defaults
*/
static mergeTriggers(
loaded: NotificationTrigger[],
defaults: NotificationTrigger[] = DEFAULT_TRIGGERS
): NotificationTrigger[] {
// Get IDs of current builtin triggers
const builtinIds = new Set(defaults.filter((t) => t.isBuiltin).map((t) => t.id));
// Filter out deprecated builtin triggers (builtin triggers not in current defaults)
const filtered = loaded.filter((t) => !t.isBuiltin || builtinIds.has(t.id));
// Add any missing builtin triggers from defaults
for (const defaultTrigger of defaults) {
if (defaultTrigger.isBuiltin) {
const existsInFiltered = filtered.some((t) => t.id === defaultTrigger.id);
if (!existsInFiltered) {
filtered.push(defaultTrigger);
}
}
}
return filtered;
}
// ===========================================================================
// Internal Methods
// ===========================================================================
/**
* Updates the internal triggers array.
* Used by ConfigManager when loading config.
*/
setTriggers(triggers: NotificationTrigger[]): void {
this.triggers = triggers;
}
/**
* Deep clones an object.
*/
private deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T;
}
}

View file

@ -0,0 +1,16 @@
/**
* Infrastructure services - Core application infrastructure.
*
* Exports:
* - DataCache: LRU cache with TTL for parsed session data
* - FileWatcher: Watches for file changes with debouncing
* - ConfigManager: App configuration management
* - TriggerManager: Notification trigger management (used internally by ConfigManager)
* - NotificationManager: Notification handling and persistence
*/
export * from './ConfigManager';
export * from './DataCache';
export * from './FileWatcher';
export * from './NotificationManager';
export * from './TriggerManager';

View file

@ -0,0 +1,293 @@
/**
* ClaudeMdReader service - Reads CLAUDE.md files and calculates token counts.
*
* Responsibilities:
* - Read CLAUDE.md files from various locations
* - Calculate character counts and estimate token counts
* - Handle file not found gracefully
* - Support tilde (~) expansion to home directory
*/
import { encodePath } from '@main/utils/pathDecoder';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
const logger = createLogger('Service:ClaudeMdReader');
// ===========================================================================
// Types
// ===========================================================================
export interface ClaudeMdFileInfo {
path: string;
exists: boolean;
charCount: number;
estimatedTokens: number; // charCount / 4
}
export interface ClaudeMdReadResult {
files: Map<string, ClaudeMdFileInfo>;
}
// ===========================================================================
// Helper Functions
// ===========================================================================
/**
* Expands tilde (~) in a path to the actual home directory.
* @param filePath - Path that may contain ~
* @returns Expanded path with ~ replaced by home directory
*/
function expandTilde(filePath: string): string {
if (filePath.startsWith('~')) {
const homeDir = app.getPath('home');
return path.join(homeDir, filePath.slice(1));
}
return filePath;
}
// ===========================================================================
// Main Functions
// ===========================================================================
/**
* Reads a single CLAUDE.md file and returns its info.
* @param filePath - Path to the CLAUDE.md file (supports ~ expansion)
* @returns ClaudeMdFileInfo with file details
*/
function readClaudeMdFile(filePath: string): ClaudeMdFileInfo {
const expandedPath = expandTilde(filePath);
try {
if (!fs.existsSync(expandedPath)) {
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
const content = fs.readFileSync(expandedPath, 'utf8');
const charCount = content.length;
const estimatedTokens = countTokens(content);
return {
path: expandedPath,
exists: true,
charCount,
estimatedTokens,
};
} catch (error) {
// Handle permission denied, file not readable, etc.
logger.error(`Error reading CLAUDE.md file at ${expandedPath}:`, error);
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
}
/**
* Reads all .md files in a directory and returns combined info.
* Used for project rules directory.
* @param dirPath - Path to the directory (supports ~ expansion)
* @returns ClaudeMdFileInfo with combined stats from all .md files
*/
function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
const expandedPath = expandTilde(dirPath);
try {
if (!fs.existsSync(expandedPath)) {
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
const stats = fs.statSync(expandedPath);
if (!stats.isDirectory()) {
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
const mdFiles = collectMdFiles(expandedPath);
if (mdFiles.length === 0) {
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
let totalCharCount = 0;
const allContent: string[] = [];
for (const filePath of mdFiles) {
try {
const content = fs.readFileSync(filePath, 'utf8');
totalCharCount += content.length;
allContent.push(content);
} catch {
// Skip files we can't read
continue;
}
}
// Count tokens on combined content for accuracy
const estimatedTokens = countTokens(allContent.join('\n'));
return {
path: expandedPath,
exists: true,
charCount: totalCharCount,
estimatedTokens,
};
} catch (error) {
logger.error(`Error reading directory ${expandedPath}:`, error);
return {
path: expandedPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
}
/**
* Recursively collect all .md files in a directory tree.
*/
function collectMdFiles(dir: string): string[] {
const mdFiles: string[] = [];
try {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
try {
const stats = fs.statSync(fullPath);
if (stats.isFile() && entry.endsWith('.md')) {
mdFiles.push(fullPath);
} else if (stats.isDirectory()) {
mdFiles.push(...collectMdFiles(fullPath));
}
} catch {
continue;
}
}
} catch {
// Directory not readable
}
return mdFiles;
}
/**
* Returns the platform-specific enterprise CLAUDE.md path.
*/
function getEnterprisePath(): string {
switch (process.platform) {
case 'win32':
return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md';
case 'darwin':
return '/Library/Application Support/ClaudeCode/CLAUDE.md';
default:
return '/etc/claude-code/CLAUDE.md';
}
}
/**
* Reads auto memory MEMORY.md file for a project.
* Only reads the first 200 lines, matching Claude Code behavior.
*/
function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo {
const expandedRoot = expandTilde(projectRoot);
const encoded = encodePath(expandedRoot);
const homeDir = app.getPath('home');
const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md');
try {
if (!fs.existsSync(memoryPath)) {
return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 };
}
const content = fs.readFileSync(memoryPath, 'utf8');
// Only first 200 lines, matching Claude Code behavior
const lines = content.split('\n');
const truncated = lines.slice(0, 200).join('\n');
const charCount = truncated.length;
const estimatedTokens = countTokens(truncated);
return { path: memoryPath, exists: true, charCount, estimatedTokens };
} catch (error) {
logger.error(`Error reading auto memory at ${memoryPath}:`, error);
return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 };
}
}
/**
* Reads all potential CLAUDE.md locations for a project.
* @param projectRoot - The root directory of the project
* @returns ClaudeMdReadResult with Map of path -> ClaudeMdFileInfo
*/
export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult {
const files = new Map<string, ClaudeMdFileInfo>();
const expandedProjectRoot = expandTilde(projectRoot);
// 1. Enterprise CLAUDE.md (platform-specific path)
const enterprisePath = getEnterprisePath();
files.set('enterprise', readClaudeMdFile(enterprisePath));
// 2. User memory: ~/.claude/CLAUDE.md
const userMemoryPath = '~/.claude/CLAUDE.md';
files.set('user', readClaudeMdFile(userMemoryPath));
// 3. Project memory: ${projectRoot}/CLAUDE.md
const projectMemoryPath = path.join(expandedProjectRoot, 'CLAUDE.md');
files.set('project', readClaudeMdFile(projectMemoryPath));
// 4. Project memory alt: ${projectRoot}/.claude/CLAUDE.md
const projectMemoryAltPath = path.join(expandedProjectRoot, '.claude', 'CLAUDE.md');
files.set('project-alt', readClaudeMdFile(projectMemoryAltPath));
// 5. Project rules: ${projectRoot}/.claude/rules/*.md
const projectRulesPath = path.join(expandedProjectRoot, '.claude', 'rules');
files.set('project-rules', readDirectoryMdFiles(projectRulesPath));
// 6. Project local: ${projectRoot}/CLAUDE.local.md
const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md');
files.set('project-local', readClaudeMdFile(projectLocalPath));
// 7. User rules: ~/.claude/rules/**/*.md
const homeDir = app.getPath('home');
const userRulesPath = path.join(homeDir, '.claude', 'rules');
files.set('user-rules', readDirectoryMdFiles(userRulesPath));
// 8. Auto memory: ~/.claude/projects/<encoded>/memory/MEMORY.md
files.set('auto-memory', readAutoMemoryFile(projectRoot));
return { files };
}
/**
* Reads a specific directory's CLAUDE.md file.
* Used for directory-specific CLAUDE.md detected from file reads.
* @param dirPath - Path to the directory (supports ~ expansion)
* @returns ClaudeMdFileInfo for the CLAUDE.md file in that directory
*/
export function readDirectoryClaudeMd(dirPath: string): ClaudeMdFileInfo {
const expandedDirPath = expandTilde(dirPath);
const claudeMdPath = path.join(expandedDirPath, 'CLAUDE.md');
return readClaudeMdFile(claudeMdPath);
}

View file

@ -0,0 +1,674 @@
/**
* GitIdentityResolver service - Resolves git repository identity from project paths.
*
* Responsibilities:
* - Detect if a path is inside a git worktree vs main repository
* - Extract the main repository path from worktree's .git file
* - Get git remote URL for repository identity
* - Build consistent repository identity across all worktrees
*
* Git worktree detection:
* - Main repo: .git is a directory
* - Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/<name>"
*/
import {
AUTO_CLAUDE_DIR,
CCSWITCH_DIR,
CLAUDE_WORKTREES_DIR,
CONDUCTOR_DIR,
CURSOR_DIR,
TASKS_DIR,
TWENTYFIRST_DIR,
VIBE_KANBAN_DIR,
WORKSPACES_DIR,
WORKTREES_DIR,
} from '@main/constants/worktreePatterns';
import { type RepositoryIdentity, type WorktreeSource } from '@main/types';
import { createLogger } from '@shared/utils/logger';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const logger = createLogger('Service:GitIdentityResolver');
class GitIdentityResolver {
/**
* Resolve repository identity from a project path.
*
* Algorithm:
* 1. Check if path/.git exists on filesystem
* 2. If .git is a file (worktree), read gitdir to find main repo
* 3. If .git is a directory (main repo), use it directly
* 4. Extract remote URL from .git/config
* 5. Build RepositoryIdentity with consistent ID
* 6. FALLBACK: If path doesn't exist, use heuristics based on path patterns
*
* @param projectPath - The filesystem path to check
* @returns RepositoryIdentity or null if not a git repo
*/
async resolveIdentity(projectPath: string): Promise<RepositoryIdentity | null> {
try {
const gitPath = path.join(projectPath, '.git');
// First, try filesystem-based resolution
if (fs.existsSync(gitPath)) {
const stats = fs.statSync(gitPath);
let mainGitDir: string;
if (stats.isFile()) {
// This is a worktree - parse the .git file to find main repo
const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent);
if (!gitDirMatch) {
logger.warn(`Invalid .git file format at ${gitPath}`);
return this.resolveIdentityFromPath(projectPath);
}
let worktreeGitDir = gitDirMatch[1].trim();
// Handle relative paths in gitdir (resolve relative to the .git file location)
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(projectPath, worktreeGitDir);
}
mainGitDir = this.extractMainGitDir(worktreeGitDir);
} else if (stats.isDirectory()) {
mainGitDir = gitPath;
} else {
return this.resolveIdentityFromPath(projectPath);
}
// Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders)
// This ensures all worktrees of the same repo get the same ID
try {
mainGitDir = fs.realpathSync(mainGitDir);
} catch {
// If realpath fails (e.g., path doesn't exist), use as-is
}
// Extract remote URL from config
const remoteUrl = this.getRemoteUrl(mainGitDir);
// Generate consistent repository ID based on the CANONICAL main git directory
const repoId = this.generateRepoId(remoteUrl, mainGitDir);
// Extract repository name from path or remote URL
const repoName = this.extractRepoName(remoteUrl, mainGitDir);
return {
id: repoId,
remoteUrl: remoteUrl ?? undefined,
mainGitDir,
name: repoName,
};
}
// Fallback: path doesn't exist, use heuristic resolution
return this.resolveIdentityFromPath(projectPath);
} catch (error) {
logger.error(`Error resolving git identity for ${projectPath}:`, error);
// Try fallback even on error
return this.resolveIdentityFromPath(projectPath);
}
}
/**
* Fallback: Resolve repository identity from path patterns when filesystem is unavailable.
* Uses heuristics to detect common worktree path patterns.
*
* Patterns supported:
* - /.cursor/worktrees/{repo}/{worktree-name}
* - /vibe-kanban/worktrees/{issue-branch}/{repo}
* - /T/vibe-kanban/worktrees/{issue-branch}/{repo}
* - Regular paths: use last component as repo name
*/
private resolveIdentityFromPath(projectPath: string): RepositoryIdentity | null {
const repoName = this.extractRepoNameFromPath(projectPath);
if (!repoName) {
return null;
}
// Generate ID from full path (since no remote URL, avoids colliding same-named repos)
const repoId = this.generateRepoId(null, projectPath);
return {
id: repoId,
remoteUrl: undefined,
mainGitDir: repoName, // Use repo name as placeholder
name: repoName,
};
}
/**
* Extract repository name from path using heuristics.
* Works for both existing and deleted worktrees based on path patterns.
*
* Patterns:
* - /.cursor/worktrees/{repo}/{worktree} repo
* - /vibe-kanban/worktrees/{issue-branch}/{repo} repo (last component)
* - /conductor/workspaces/{repo}/{subpath} repo
* - /.auto-claude/worktrees/tasks/{task-id} parent repo (2 levels up from .auto-claude)
* - /.21st/worktrees/{id}/{name} parent repo
* - /.claude-worktrees/{repo}/{name} repo
* - /.ccswitch/worktrees/{repo}/{name} repo
* - Default: last path component
*/
private extractRepoNameFromPath(projectPath: string): string | null {
const parts = projectPath.split(path.sep).filter(Boolean);
if (parts.length === 0) {
return null;
}
// Pattern 1: /.cursor/worktrees/{repo}/{worktree-name}
const cursorWorktreeIdx = parts.indexOf(CURSOR_DIR);
if (cursorWorktreeIdx >= 0 && parts[cursorWorktreeIdx + 1] === WORKTREES_DIR) {
if (parts[cursorWorktreeIdx + 2]) {
return parts[cursorWorktreeIdx + 2];
}
}
// Pattern 2: /vibe-kanban/worktrees/{issue-branch}/{repo}
const vibeKanbanIdx = parts.indexOf(VIBE_KANBAN_DIR);
if (vibeKanbanIdx >= 0 && parts[vibeKanbanIdx + 1] === WORKTREES_DIR) {
// The repo name is the LAST component (after issue-branch)
return parts[parts.length - 1];
}
// Pattern 3: /conductor/workspaces/{repo}/{subpath}
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
if (conductorIdx >= 0 && parts[conductorIdx + 1] === WORKSPACES_DIR) {
if (parts[conductorIdx + 2]) {
return parts[conductorIdx + 2];
}
}
// Pattern 4: /.auto-claude/worktrees/tasks/{task-id}
// Repo is typically the directory containing .auto-claude
const autoClaudeIdx = parts.indexOf(AUTO_CLAUDE_DIR);
if (autoClaudeIdx > 0 && parts[autoClaudeIdx + 1] === WORKTREES_DIR) {
return parts[autoClaudeIdx - 1]; // Parent directory is the repo
}
// Pattern 5: /.21st/worktrees/{id}/{name}
const twentyFirstIdx = parts.indexOf(TWENTYFIRST_DIR);
if (twentyFirstIdx > 0 && parts[twentyFirstIdx + 1] === WORKTREES_DIR) {
return parts[twentyFirstIdx - 1]; // Parent directory is the repo
}
// Pattern 6: /.claude-worktrees/{repo}/{name}
const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR);
if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 1]) {
return parts[claudeWorktreesIdx + 1];
}
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
if (parts[ccswitchIdx + 2]) {
return parts[ccswitchIdx + 2];
}
}
// Default: use the last component
return parts[parts.length - 1];
}
/**
* Determine if a path is a worktree (vs main repo).
* Worktrees have a .git file, main repos have a .git directory.
* Uses path heuristics if filesystem is not available (for deleted worktrees).
*/
isWorktree(projectPath: string): boolean {
// First, try path-based heuristics (works for deleted worktrees)
const parts = projectPath.split(path.sep).filter(Boolean);
// Check for known worktree patterns - these are ALWAYS worktrees
if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(CLAUDE_WORKTREES_DIR)) {
return true;
}
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
// Subpaths in conductor/workspaces are worktrees
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
if (conductorIdx >= 0 && parts.length > conductorIdx + 3) {
return true; // Has subpath after workspaces/{repo}
}
}
// Fallback: check filesystem if available
try {
const gitPath = path.join(projectPath, '.git');
if (fs.existsSync(gitPath)) {
const stats = fs.statSync(gitPath);
return stats.isFile();
}
} catch {
// Ignore errors - filesystem might not be available
}
return false;
}
/**
* Extract the main .git directory path from a worktree's gitdir.
*
* @param worktreeGitDir - Path like "/path/to/main/.git/worktrees/<name>"
* @returns Path to main .git directory like "/path/to/main/.git"
*/
private extractMainGitDir(worktreeGitDir: string): string {
// worktreeGitDir is typically: /path/to/main/.git/worktrees/<worktree-name>
// We need to go up two levels to get to .git
const parts = worktreeGitDir.split(path.sep);
const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR);
if (worktreesIndex > 0) {
// Return everything up to and including .git
return parts.slice(0, worktreesIndex).join(path.sep);
}
// Fallback: try to find .git in the path
const gitIndex = worktreeGitDir.lastIndexOf('.git');
if (gitIndex > 0) {
return worktreeGitDir.substring(0, gitIndex + 4); // +4 for ".git"
}
// Last resort: return as-is
return worktreeGitDir;
}
/**
* Get git remote URL from a repository's config file.
*
* @param gitDir - Path to the .git directory
* @returns Remote URL or null if not found
*/
private getRemoteUrl(gitDir: string): string | null {
try {
const configPath = path.join(gitDir, 'config');
if (!fs.existsSync(configPath)) {
return null;
}
const configContent = fs.readFileSync(configPath, 'utf-8');
// Parse git config to find [remote "origin"] section
const lines = configContent.split(/\r?\n/);
let inOriginRemote = false;
for (const line of lines) {
const trimmed = line.trim();
// Check for remote "origin" section
if (/^\[remote\s+"origin"\]$/.exec(trimmed)) {
inOriginRemote = true;
continue;
}
// Check for new section (exit origin remote)
if (trimmed.startsWith('[') && inOriginRemote) {
break;
}
// Look for url = ... in origin remote section
if (inOriginRemote && trimmed.startsWith('url')) {
const urlMatch = /^url\s*=\s*(\S[^\r\n]*)$/.exec(trimmed);
if (urlMatch) {
return urlMatch[1].trim();
}
}
}
return null;
} catch (error) {
logger.error(`Error reading git config at ${gitDir}:`, error);
return null;
}
}
/**
* Generate consistent repository ID.
* Uses the LOCAL DIRECTORY NAME as the primary identifier to ensure consistent grouping
* across filesystem-based and path-based resolution.
*
* IMPORTANT: We prioritize local directory name over remote URL repo name because:
* 1. Path-based resolution (for deleted worktrees) can only use directory names
* 2. Users may clone repos with different local names than remote names
* 3. We need consistent grouping regardless of whether filesystem exists
*
* @param _remoteUrl - Git remote URL (unused, kept for API compatibility)
* @param mainGitDirOrName - Path to main .git directory, or repo name for path-based resolution
* @returns Consistent hash-based ID
*/
private generateRepoId(remoteUrl: string | null, mainGitDirOrName: string): string {
// When a remote URL is available, use directory name for grouping
// (worktrees of the same repo have same dir name but different paths)
// When NO remote URL, use the full path to avoid colliding repos with same name
let identity: string;
if (mainGitDirOrName.includes(path.sep) || mainGitDirOrName.includes('/')) {
if (remoteUrl) {
// Has remote → use dir name (allows worktree grouping)
const parentDir = path.dirname(mainGitDirOrName);
identity = path.basename(parentDir);
} else {
// No remote → use full path to distinguish same-named repos.
// For filesystem-based calls, mainGitDirOrName is like /path/.git → strip .git
// For fallback calls, mainGitDirOrName is the project path itself
identity = mainGitDirOrName.endsWith('.git')
? path.dirname(mainGitDirOrName)
: mainGitDirOrName;
}
} else {
// It's already just a name (from path-based resolution fallback)
identity = mainGitDirOrName;
}
// Normalize and generate hash
const normalized = identity.toLowerCase().trim();
// Generate SHA-256 hash and take first 12 characters
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
return hash.substring(0, 12);
}
/**
* Extract repository name from git directory path.
* Always uses the LOCAL directory name for consistency with path-based resolution.
*
* @param _remoteUrl - Git remote URL (unused, kept for API compatibility)
* @param mainGitDir - Path to main .git directory
* @returns Repository name for display
*/
private extractRepoName(_remoteUrl: string | null, mainGitDir: string): string {
// Always use local directory name for consistency
// /Users/username/projectname/.git -> projectname
// /Users/username/projectname/.git -> projectname
const parentDir = path.dirname(mainGitDir);
return path.basename(parentDir);
}
/**
* Get the git branch for a worktree.
*
* @param projectPath - The filesystem path to check
* @returns Branch name or null
*/
async getBranch(projectPath: string): Promise<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) {
return null;
}
const stats = fs.statSync(gitPath);
let headPath: string;
if (stats.isFile()) {
// Worktree - read .git file to find the HEAD location
const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent);
if (!gitDirMatch) {
return null;
}
headPath = path.join(gitDirMatch[1], 'HEAD');
} else {
// Main repo
headPath = path.join(gitPath, 'HEAD');
}
if (!fs.existsSync(headPath)) {
return null;
}
const headContent = fs.readFileSync(headPath, 'utf-8').trim();
// Check if HEAD is a symbolic ref (branch)
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent);
if (refMatch) {
return refMatch[1];
}
// HEAD is detached (commit hash)
return 'detached HEAD';
} catch (error) {
logger.error(`Error reading git branch for ${projectPath}:`, error);
return null;
}
}
/**
* Detect the worktree source based on path patterns.
* This method works purely on path patterns and does NOT require filesystem access,
* ensuring detection works even for deleted worktrees.
*
* Supported patterns:
* - vibe-kanban: /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
* - conductor: /Users/.../conductor/workspaces/{repo}/{workspace}
* - auto-claude: /Users/.../.auto-claude/worktrees/tasks/{task-id}
* - 21st: /Users/.../.21st/worktrees/{id}/{name}
* - claude-desktop: /Users/.../.claude-worktrees/{repo}/{name}
* - ccswitch: /Users/.../.ccswitch/worktrees/{repo}/{name}
* - git: Standard git worktree (fallback if none of the above match)
* - unknown: Non-git or undetectable
*
* @param projectPath - The filesystem path to check
* @returns WorktreeSource identifier
*/
detectWorktreeSource(projectPath: string): WorktreeSource {
const parts = projectPath.split(path.sep).filter(Boolean);
// Pattern: vibe-kanban
// /tmp/vibe-kanban/worktrees/{issue-branch}/{repo}
// /private/var/folders/.../vibe-kanban/worktrees/{issue-branch}/{repo}
if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) {
return 'vibe-kanban';
}
// Pattern: conductor
// /Users/.../conductor/workspaces/{repo}/{workspace}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
return 'conductor';
}
// Pattern: auto-claude
// /Users/.../.auto-claude/worktrees/tasks/{task-id}
if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) {
return 'auto-claude';
}
// Pattern: 21st (1code)
// /Users/.../.21st/worktrees/{id}/{name}
if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) {
return '21st';
}
// Pattern: claude-desktop
// /Users/.../.claude-worktrees/{repo}/{name}
if (parts.includes(CLAUDE_WORKTREES_DIR)) {
return 'claude-desktop';
}
// Pattern: ccswitch
// /Users/.../.ccswitch/worktrees/{repo}/{name}
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return 'ccswitch';
}
// Check if it's a standard git repo (only if filesystem exists)
// For deleted repos, we'll return 'git' as fallback since we can't verify
try {
const gitPath = path.join(projectPath, '.git');
if (fs.existsSync(gitPath)) {
return 'git';
}
} catch {
// Ignore errors - filesystem might not be available
}
// Default to 'git' for paths that don't match known patterns
// This is a reasonable default since most worktrees come from git
return 'git';
}
/**
* Get the display name for a worktree based on its source.
* Extracts the meaningful identifier from the path based on the pattern.
*
* @param projectPath - The filesystem path
* @param source - The detected worktree source
* @param branch - The git branch (if available)
* @param isMainWorktree - Whether this is the main worktree
* @returns Display name for the worktree
*/
getWorktreeDisplayName(
projectPath: string,
source: WorktreeSource,
branch: string | null,
isMainWorktree: boolean
): string {
const parts = projectPath.split(path.sep).filter(Boolean);
switch (source) {
case 'vibe-kanban': {
// Pattern: vibe-kanban/worktrees/{issue-branch}/{repo}
// Display: {issue-branch} (e.g., "92a6-kanban-extension")
const worktreesIdx = parts.indexOf(WORKTREES_DIR);
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
return parts[worktreesIdx + 1];
}
break;
}
case 'conductor': {
// Pattern: conductor/workspaces/{repo}/{workspace}
// Display: {workspace} (e.g., "san-francisco")
const workspacesIdx = parts.indexOf(WORKSPACES_DIR);
if (workspacesIdx >= 0 && parts[workspacesIdx + 2]) {
return parts[workspacesIdx + 2];
}
break;
}
case 'auto-claude': {
// Pattern: .auto-claude/worktrees/tasks/{task-id}
// Display: {task-id} (e.g., "002-hjell")
const tasksIdx = parts.indexOf(TASKS_DIR);
if (tasksIdx >= 0 && parts[tasksIdx + 1]) {
return parts[tasksIdx + 1];
}
// Fallback: last component
return parts[parts.length - 1];
}
case '21st': {
// Pattern: .21st/worktrees/{id}/{name with [bracket-id]}
// e.g., "mkp2f9a3y7x1s2nr 3b06478 [colonial-swordfish-fcad5f]"
// Display: Extract from brackets (e.g., "colonial-swordfish-fcad5f")
const lastPart = parts[parts.length - 1];
// Extract content from square brackets using indexOf to avoid regex backtracking
const bracketStart = lastPart.indexOf('[');
const bracketEnd = lastPart.indexOf(']', bracketStart);
if (bracketStart !== -1 && bracketEnd !== -1 && bracketEnd > bracketStart + 1) {
return lastPart.slice(bracketStart + 1, bracketEnd);
}
// Fallback: use the last part as-is
return lastPart;
}
case 'claude-desktop': {
// Pattern: .claude-worktrees/{repo}/{name}
// Display: {name} (e.g., "keen-sinoussi")
const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR);
if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 2]) {
return parts[claudeWorktreesIdx + 2];
}
break;
}
case 'ccswitch': {
// Pattern: .ccswitch/worktrees/{repo}/{name}
// Display: {name} (e.g., "just-explain-my-repo-briefly")
const ccswitchWorktreesIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchWorktreesIdx >= 0) {
const worktreesIdx = parts.indexOf(WORKTREES_DIR, ccswitchWorktreesIdx);
if (worktreesIdx >= 0 && parts[worktreesIdx + 2]) {
return parts[worktreesIdx + 2];
}
}
break;
}
case 'git':
// Standard git worktree - use branch or path-based name
if (isMainWorktree) {
return branch ?? 'main';
}
// For non-main git worktrees, try to get the worktree name from .git file
return this.getGitWorktreeName(projectPath) ?? branch ?? parts[parts.length - 1];
case 'unknown':
default:
// Non-git project - use last path component
return parts[parts.length - 1] ?? 'unknown';
}
// Fallback for any case that didn't return
return branch ?? parts[parts.length - 1] ?? 'unknown';
}
/**
* Get the worktree name from git's internal tracking.
* Reads .git file to find the worktree name in .git/worktrees/{name}
*
* @param projectPath - The filesystem path
* @returns Worktree name or null
*/
private getGitWorktreeName(projectPath: string): string | null {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) return null;
const stats = fs.statSync(gitPath);
if (!stats.isFile()) return null;
const content = fs.readFileSync(gitPath, 'utf-8');
const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content);
if (!match) return null;
// gitdir: /main/.git/worktrees/my-worktree-name
const gitdirParts = match[1].trim().split(path.sep);
const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR);
if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) {
return gitdirParts[worktreesIdx + 1];
}
return null;
} catch {
return null;
}
}
}
// Export singleton instance
export const gitIdentityResolver = new GitIdentityResolver();

View file

@ -0,0 +1,65 @@
/**
* MessageClassifier service - Classifies messages into categories for chunk building.
*
* Categories:
* - User: Genuine user input (creates UserChunk, renders RIGHT)
* - System: Command output <local-command-stdout> (creates SystemChunk, renders LEFT)
* - Compact: Summary messages from conversation compaction
* - Hard Noise: Filtered out entirely (system metadata, caveats, reminders)
* - AI: All other messages grouped into AIChunks (renders LEFT)
*/
import {
isParsedCompactMessage,
isParsedHardNoiseMessage,
isParsedSystemChunkMessage,
isParsedUserChunkMessage,
type MessageCategory,
type ParsedMessage,
} from '@main/types';
/**
* Result of classifying a message.
*/
export interface ClassifiedMessage {
message: ParsedMessage;
category: MessageCategory;
}
/**
* Classify all messages into categories.
*/
export function classifyMessages(messages: ParsedMessage[]): ClassifiedMessage[] {
return messages.map((message) => ({
message,
category: categorizeMessage(message),
}));
}
/**
* Categorize a single message into one of five categories.
*/
function categorizeMessage(message: ParsedMessage): MessageCategory {
// Check hard noise first (filtered out)
if (isParsedHardNoiseMessage(message)) {
return 'hardNoise';
}
// Check compact summary (before system/user to catch it early)
if (isParsedCompactMessage(message)) {
return 'compact';
}
// Check system (command output)
if (isParsedSystemChunkMessage(message)) {
return 'system';
}
// Check user (real user input)
if (isParsedUserChunkMessage(message)) {
return 'user';
}
// Everything else is AI (assistant messages, tool results, etc.)
return 'ai';
}

View file

@ -0,0 +1,378 @@
/**
* SessionParser service - Parses Claude Code session JSONL files.
*
* Responsibilities:
* - Parse JSONL files into structured messages
* - Extract all message metadata
* - Identify tool calls and tool results
* - Calculate session metrics
*/
import {
isParsedInternalUserMessage,
isParsedRealUserMessage,
type ParsedMessage,
type SessionMetrics,
type ToolCall,
type ToolResult,
} from '@main/types';
import {
calculateMetrics,
extractTextContent,
getTaskCalls,
parseJsonlFile,
} from '@main/utils/jsonl';
import * as path from 'path';
import { type ProjectScanner } from '../discovery/ProjectScanner';
/**
* Result of parsing a session file.
*/
export interface ParsedSession {
/** All parsed messages */
messages: ParsedMessage[];
/** Session metrics */
metrics: SessionMetrics;
/** All Task calls found in the session */
taskCalls: ToolCall[];
/** Messages grouped by type */
byType: {
user: ParsedMessage[]; // All user messages
realUser: ParsedMessage[]; // Only real user messages (not tool results)
internalUser: ParsedMessage[]; // Only tool result messages
assistant: ParsedMessage[];
system: ParsedMessage[];
other: ParsedMessage[];
};
/** Sidechain messages */
sidechainMessages: ParsedMessage[];
/** Main thread messages (non-sidechain) */
mainMessages: ParsedMessage[];
}
export class SessionParser {
private projectScanner: ProjectScanner;
constructor(projectScanner: ProjectScanner) {
this.projectScanner = projectScanner;
}
// ===========================================================================
// Core Parsing
// ===========================================================================
/**
* Parse a session JSONL file and return structured data.
*/
async parseSession(projectId: string, sessionId: string): Promise<ParsedSession> {
const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId);
return this.parseSessionFile(sessionPath);
}
/**
* Parse a JSONL file at the given path.
*/
async parseSessionFile(filePath: string): Promise<ParsedSession> {
const messages = await parseJsonlFile(filePath);
return this.processMessages(messages);
}
/**
* Process parsed messages into structured data.
*/
private processMessages(messages: ParsedMessage[]): ParsedSession {
// Group by type
const byType = {
user: messages.filter((m) => m.type === 'user'),
realUser: messages.filter(isParsedRealUserMessage),
internalUser: messages.filter(isParsedInternalUserMessage),
assistant: messages.filter((m) => m.type === 'assistant'),
system: messages.filter((m) => m.type === 'system'),
other: messages.filter(
(m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system'
),
};
// Separate sidechain and main messages
const sidechainMessages = messages.filter((m) => m.isSidechain);
const mainMessages = messages.filter((m) => !m.isSidechain);
// Calculate metrics
const metrics = calculateMetrics(messages);
// Extract all Task calls
const taskCalls = getTaskCalls(messages);
return {
messages,
metrics,
taskCalls,
byType,
sidechainMessages,
mainMessages,
};
}
// ===========================================================================
// Message Queries
// ===========================================================================
/**
* Get user messages from a parsed session.
*/
getUserMessages(session: ParsedSession): ParsedMessage[] {
return session.byType.user;
}
/**
* Get assistant messages from a parsed session.
*/
getAssistantMessages(session: ParsedSession): ParsedMessage[] {
return session.byType.assistant;
}
/**
* Get messages in a time range.
*/
getMessagesInRange(messages: ParsedMessage[], startTime: Date, endTime: Date): ParsedMessage[] {
return messages.filter((m) => m.timestamp >= startTime && m.timestamp <= endTime);
}
/**
* Get responses to a specific user message.
* Finds all assistant messages that follow the user message until the next user message.
*/
getResponses(messages: ParsedMessage[], userMessageUuid: string): ParsedMessage[] {
const userMsgIndex = messages.findIndex((m) => m.uuid === userMessageUuid);
if (userMsgIndex === -1) return [];
const responses: ParsedMessage[] = [];
for (let i = userMsgIndex + 1; i < messages.length; i++) {
const msg = messages[i];
// Stop at next user message
if (msg.type === 'user') break;
// Include assistant responses
if (msg.type === 'assistant') {
responses.push(msg);
}
}
return responses;
}
// ===========================================================================
// Tool Call Analysis
// ===========================================================================
/**
* Get all Task (subagent) calls from messages.
*/
getTaskCalls(messages: ParsedMessage[]): ToolCall[] {
return getTaskCalls(messages);
}
/**
* Get all tool calls of a specific type.
*/
getToolCallsByName(messages: ParsedMessage[], toolName: string): ToolCall[] {
return messages.flatMap((m) => m.toolCalls.filter((tc) => tc.name === toolName));
}
/**
* Find the tool result for a specific tool call.
*/
findToolResult(
messages: ParsedMessage[],
toolCallId: string
): { message: ParsedMessage; result: ToolResult } | null {
for (const msg of messages) {
const result = msg.toolResults.find((tr) => tr.toolUseId === toolCallId);
if (result) {
return { message: msg, result };
}
}
return null;
}
// ===========================================================================
// Timing Analysis
// ===========================================================================
/**
* Get the time range of messages.
*/
getTimeRange(messages: ParsedMessage[]): { start: Date; end: Date; durationMs: number } {
if (messages.length === 0) {
const now = new Date();
return { start: now, end: now, durationMs: 0 };
}
const timestamps = messages.map((m) => m.timestamp.getTime());
let min = timestamps[0];
let max = timestamps[0];
for (let i = 1; i < timestamps.length; i++) {
if (timestamps[i] < min) min = timestamps[i];
if (timestamps[i] > max) max = timestamps[i];
}
const start = new Date(min);
const end = new Date(max);
return {
start,
end,
durationMs: end.getTime() - start.getTime(),
};
}
/**
* Calculate metrics for a subset of messages.
*/
calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
return calculateMetrics(messages);
}
// ===========================================================================
// Text Extraction
// ===========================================================================
/**
* Extract text content from a message.
*/
extractText(message: ParsedMessage): string {
return extractTextContent(message);
}
/**
* Get a preview of a message (first N characters).
*/
getMessagePreview(message: ParsedMessage, maxLength: number = 100): string {
const text = extractTextContent(message);
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// ===========================================================================
// Message Threading
// ===========================================================================
/**
* Build a parent-child message tree.
*/
buildMessageTree(messages: ParsedMessage[]): Map<string, ParsedMessage[]> {
const tree = new Map<string, ParsedMessage[]>();
for (const msg of messages) {
const parentId = msg.parentUuid ?? 'root';
if (!tree.has(parentId)) {
tree.set(parentId, []);
}
tree.get(parentId)!.push(msg);
}
return tree;
}
/**
* Get child messages of a specific message.
*/
getChildMessages(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] {
return messages.filter((m) => m.parentUuid === parentUuid);
}
/**
* Get the conversation thread for a message (ancestors + descendants).
*/
getThread(messages: ParsedMessage[], messageUuid: string): ParsedMessage[] {
const thread: ParsedMessage[] = [];
const messageMap = new Map(messages.map((m) => [m.uuid, m]));
// Get ancestors
let current = messageMap.get(messageUuid);
const ancestors: ParsedMessage[] = [];
while (current) {
ancestors.unshift(current);
current = current.parentUuid ? messageMap.get(current.parentUuid) : undefined;
}
thread.push(...ancestors);
// Get descendants
const descendants = this.getDescendants(messages, messageUuid);
// Add descendants that aren't already in thread
for (const desc of descendants) {
if (!thread.find((m) => m.uuid === desc.uuid)) {
thread.push(desc);
}
}
return thread;
}
/**
* Get all descendants of a message.
*/
private getDescendants(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] {
const result: ParsedMessage[] = [];
const children = messages.filter((m) => m.parentUuid === parentUuid);
for (const child of children) {
result.push(child);
result.push(...this.getDescendants(messages, child.uuid));
}
return result;
}
// ===========================================================================
// Subagent File Parsing
// ===========================================================================
/**
* Parse a subagent JSONL file.
*/
async parseSubagentFile(filePath: string): Promise<{
messages: ParsedMessage[];
metrics: SessionMetrics;
}> {
const messages = await parseJsonlFile(filePath);
const metrics = calculateMetrics(messages);
return { messages, metrics };
}
/**
* Parse all subagent files for a session.
*/
async parseAllSubagents(
projectId: string,
sessionId: string
): Promise<
Map<
string,
{
filePath: string;
messages: ParsedMessage[];
metrics: SessionMetrics;
}
>
> {
const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId);
const results = new Map();
for (const filePath of subagentFiles) {
// Extract agent ID from filename (agent-{id}.jsonl)
const filename = path.basename(filePath);
const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, '');
const { messages, metrics } = await this.parseSubagentFile(filePath);
results.set(agentId, { filePath, messages, metrics });
}
return results;
}
}

View file

@ -0,0 +1,14 @@
/**
* Parsing services - Parsing JSONL and configuration files.
*
* Exports:
* - SessionParser: Parses JSONL session files
* - MessageClassifier: Classifies messages into categories
* - ClaudeMdReader: Reads CLAUDE.md configuration files
* - GitIdentityResolver: Resolves git identities from sessions
*/
export * from './ClaudeMdReader';
export * from './GitIdentityResolver';
export * from './MessageClassifier';
export * from './SessionParser';

Some files were not shown because too many files have changed in this diff Show more