From 1290c111c46a30be666920db7d60a0ac5b7cd0f1 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 18:24:44 +0200 Subject: [PATCH 1/3] fix: remove CLAUDE_CODE_OAUTH_TOKEN injection that caused persistent 401 errors The app was reading OAuth tokens from ~/.claude/.credentials.json and injecting them as CLAUDE_CODE_OAUTH_TOKEN into spawned CLI processes. These tokens are almost always stale because Claude Code refreshes tokens in-memory but rarely writes back to the credential store. When CLAUDE_CODE_OAUTH_TOKEN is set in the environment, the CLI uses it directly instead of going through its own auth/refresh flow, causing every API call to fail with 401 "OAuth token has expired". Remove all credential-file reading logic and let the CLI handle its own authentication. ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN (explicitly set by user) are still passed through. Co-Authored-By: Claude Opus 4.6 --- .../services/team/TeamProvisioningService.ts | 126 ++---------------- 1 file changed, 12 insertions(+), 114 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4a1dceee..73140601 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -20,12 +20,11 @@ import { import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { createLogger } from '@shared/utils/logger'; -import { execFile, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { promisify } from 'util'; import { atomicWriteAsync } from './atomicWrite'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; @@ -63,7 +62,6 @@ const PROBE_CACHE_TTL_MS = 60_000; const PREFLIGHT_TIMEOUT_MS = 30000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; -const KEYCHAIN_TIMEOUT_MS = 5000; const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; @@ -73,8 +71,6 @@ const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else' const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const; const PREFLIGHT_EXPECTED = 'PONG'; -const execFileAsync = promisify(execFile); - type TeamsBaseLocation = 'configured' | 'default'; type ValidConfigProbeResult = @@ -170,12 +166,7 @@ interface ProvisioningRun { type LeadActivityState = 'active' | 'idle' | 'offline'; -type ProvisioningAuthSource = - | 'anthropic_api_key' - | 'anthropic_auth_token' - | 'claude_code_oauth_token_env' - | 'claude_code_oauth_token_credentials' - | 'none'; +type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none'; interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; @@ -945,8 +936,7 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText: return ( 'Claude CLI reports it is not authenticated ("Please run /login"). ' + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' + - 'For automation/headless use, prefer `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN`, ' + - 'or set `ANTHROPIC_API_KEY` for `-p` mode.' + 'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.' ); } return trimmed.slice(-4000); @@ -1101,7 +1091,7 @@ export class TeamProvisioningService { // authenticate through a mechanism we don't know about (e.g. a // managed apiKeyHelper, SSO, or a future auth flow). warnings.push( - 'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN). ' + + 'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN). ' + 'Attempting preflight check to verify if CLI can authenticate on its own.' ); } @@ -1185,7 +1175,7 @@ export class TeamProvisioningService { const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', { error: 'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' + - 'to authenticate, or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.', + 'to authenticate, or set ANTHROPIC_API_KEY and try again.', cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), }); run.onProgress(progress); @@ -1235,7 +1225,7 @@ export class TeamProvisioningService { return; } - // Respawn + // Respawn with saved context — CLI handles its own auth refresh. let child: ReturnType; try { child = spawnCli(ctx.claudePath, ctx.args, { @@ -3278,105 +3268,14 @@ export class TeamProvisioningService { return { env, authSource: 'anthropic_auth_token' }; } - // 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`) - if ( - typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' && - env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0 - ) { - return { env, authSource: 'claude_code_oauth_token_env' }; - } - - // 4. Try reading OAuth token from platform credential storage. - // macOS: Keychain (service "Claude Code-credentials") - // Linux: ~/.claude/.credentials.json - // Note: keychain tokens may be stale — Claude Code refreshes in-memory - // but does not always write back. We still try as best-effort. - const oauthToken = await this.readOAuthTokenFromStorage(home); - if (oauthToken) { - env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken; - return { env, authSource: 'claude_code_oauth_token_credentials' }; - } - + // 3. No explicit API key — let the CLI handle its own OAuth auth. + // Claude CLI reads credentials from its own storage and refreshes + // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the + // credentials file causes 401 errors because the stored token is + // often stale (CLI refreshes in-memory but rarely writes back). return { env, authSource: 'none' }; } - /** - * Attempts to read the OAuth access token from platform-specific storage. - * - * On macOS: reads from the encrypted Keychain (service "Claude Code-credentials"). - * On Linux: reads from ~/.claude/.credentials.json. - * - * Warning: the token retrieved here may be expired. Claude Code refreshes - * tokens in-memory but does not always persist the refreshed value back to - * the credential store. A subsequent preflight check (`claude -p "ping"`) - * will detect if the token is actually usable. - */ - private async readOAuthTokenFromStorage(home: string): Promise { - const claudeBasePath = getClaudeBasePath(); - if (process.platform === 'darwin') { - const keychainToken = await this.readOAuthTokenFromKeychain(); - if (keychainToken) { - return keychainToken; - } - // Fallback: ~/.claude/.credentials.json (or overridden Claude root) - return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home); - } - return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home); - } - - private async readOAuthTokenFromKeychain(): Promise { - try { - const { stdout } = await execFileAsync( - 'security', - ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], - { timeout: KEYCHAIN_TIMEOUT_MS } - ); - const parsed = JSON.parse(stdout.trim()) as unknown; - return this.extractOAuthAccessToken(parsed); - } catch { - return null; - } - } - - private async readOAuthTokenFromCredentialsFile( - claudeBasePath: string, - homeFallback: string - ): Promise { - // Preferred: current Claude root (supports claudeRootPath override) - const primaryPath = path.join(claudeBasePath, '.credentials.json'); - // Back-compat: legacy location under HOME - const legacyPath = path.join(homeFallback, '.claude', '.credentials.json'); - try { - const raw = await fs.promises.readFile(primaryPath, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - return this.extractOAuthAccessToken(parsed); - } catch { - try { - const raw = await fs.promises.readFile(legacyPath, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - return this.extractOAuthAccessToken(parsed); - } catch { - return null; - } - } - } - - private extractOAuthAccessToken(parsed: unknown): string | null { - if (!parsed || typeof parsed !== 'object') { - return null; - } - const root = parsed as { claudeAiOauth?: unknown }; - if (!root.claudeAiOauth || typeof root.claudeAiOauth !== 'object') { - return null; - } - const oauth = root.claudeAiOauth as { accessToken?: unknown }; - if (typeof oauth.accessToken !== 'string') { - return null; - } - const token = oauth.accessToken.trim(); - return token.length > 0 ? token : null; - } - /** * Immediately update projectPath in config.json at launch start, before CLI spawn. * Ensures TeamDetailView shows the correct project path even if provisioning @@ -4076,8 +3975,7 @@ export class TeamProvisioningService { const hint = isAuthFailure ? 'Claude CLI `-p` mode is not authenticated. ' + 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + - 'For automation/headless use, set ANTHROPIC_API_KEY or run `claude setup-token` ' + - 'and export CLAUDE_CODE_OAUTH_TOKEN.' + + 'For automation/headless use, set ANTHROPIC_API_KEY.' + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; From 12e5e95bca5ec9583f037f6d2737643e977007e3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 18:26:28 +0200 Subject: [PATCH 2/3] chore: simplify Claude configuration by removing hooks and enabling all permissions The previous configuration had complex hooks for file protection and auto-formatting which were causing issues. This simplifies the configuration to enable all permissions directly and removes all hook logic, making the setup more straightforward and reliable. --- .claude/settings.json | 48 ++++++++++++------------------------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index bf6eae03..1c1162bc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,38 +1,16 @@ { - "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; }" - } - ] - } + "alwaysThinkingEnabled": true, + "permissions": { + "allow": [ + "Edit", + "Bash", + "ReadFile(*)", + "Web Search", + "WebSearch", + "WebFetch", + "Fetch" ], - "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/'" - } - ] - } - ] + "deny": [], + "ask": [] } -} +} \ No newline at end of file From d2d07e4a2fe07453f3d86909f51190699a618838 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 18:55:08 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/infrastructure/FileWatcher.ts | 19 ++ .../services/team/TeamProvisioningService.ts | 34 +--- .../infrastructure/FileWatcher.test.ts | 189 +++++++++++++++++- 3 files changed, 207 insertions(+), 35 deletions(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 11fca601..308a7a5e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter { private pendingReprocess = new Set(); /** Flag to prevent reuse after disposal */ private disposed = false; + /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */ + private readonly instanceCreatedAt = Date.now(); constructor( dataCache: DataCache, @@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter { return; } + const isFirstRead = lastLineCount === 0 && lastSize === 0; const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; let newMessages: ParsedMessage[] = []; let currentLineCount: number; @@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter { return; } + // On first read (after app restart), establish baseline without detecting errors + // for files that existed BEFORE this FileWatcher started. This prevents flooding + // notifications with historical errors from old sessions. + // Files created AFTER startup are new sessions — detect errors normally. + if (isFirstRead) { + const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt; + if (isPreExistingFile) { + this.lastProcessedLineCount.set(filePath, currentLineCount); + this.lastProcessedSize.set(filePath, processedSize); + logger.info( + `FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)` + ); + 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); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 73140601..74da9ae6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1086,20 +1086,10 @@ export class TeamProvisioningService { const warnings: string[] = []; - if (authSource === 'none') { - // No explicit auth found. Still attempt preflight — the CLI may - // authenticate through a mechanism we don't know about (e.g. a - // managed apiKeyHelper, SSO, or a future auth flow). - warnings.push( - 'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN). ' + - 'Attempting preflight check to verify if CLI can authenticate on its own.' - ); - } - - if (authSource === 'anthropic_auth_token') { - warnings.push( - 'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.' - ); + if (authSource === 'anthropic_api_key') { + logger.info('Auth: using explicit ANTHROPIC_API_KEY'); + } else if (authSource === 'anthropic_auth_token') { + logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY'); } const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); @@ -1463,13 +1453,7 @@ export class TeamProvisioningService { const prompt = buildProvisioningPrompt(request); let child: ReturnType; - const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); - if (authSource === 'none') { - logger.warn( - 'No explicit auth env var found for `-p` mode. ' + - 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' - ); - } + const { env: shellEnv } = await this.buildProvisioningEnv(); const spawnArgs = [ '--input-format', 'stream-json', @@ -1769,13 +1753,7 @@ export class TeamProvisioningService { const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks); let child: ReturnType; - const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); - if (authSource === 'none') { - logger.warn( - 'No explicit auth env var found for `-p` mode (launch). ' + - 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' - ); - } + const { env: shellEnv } = await this.buildProvisioningEnv(); const launchArgs = [ '--input-format', 'stream-json', diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 003df76a..d6e69e20 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -429,12 +429,6 @@ describe('FileWatcher', () => { const detectPromise = new Promise((resolve) => { detectResolve = resolve; }); - vi.mocked(errorDetector.detectErrors).mockImplementation( - () => - new Promise((resolve) => { - detectPromise.then(() => resolve([])); - }) - ); const watcherAny = watcher as unknown as { detectErrorsInSessionFile: ( @@ -444,9 +438,27 @@ describe('FileWatcher', () => { ) => Promise; processingInProgress: Set; pendingReprocess: Set; + instanceCreatedAt: number; }; + // Ensure watcher treats the file as pre-existing so first call baselines + watcherAny.instanceCreatedAt = Date.now() + 60_000; - // Start first call (will block on detectErrors) + // First call establishes baseline (skips error detection on first read) + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // Append new data so subsequent calls have new lines to process + fs.appendFileSync(filePath, jsonlLine('u2', 'world')); + + // Now make detectErrors slow to simulate long processing + vi.mocked(errorDetector.detectErrors).mockImplementation( + () => + new Promise((resolve) => { + detectPromise.then(() => resolve([])); + }) + ); + + // Start call that will block on detectErrors (not first read anymore) const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); // Wait a tick so the first call enters the processing block and reaches detectErrors @@ -512,7 +524,10 @@ describe('FileWatcher', () => { ) => Promise; lastProcessedSize: Map; lastProcessedLineCount: Map; + instanceCreatedAt: number; }; + // Treat file as new (created after watcher) so it goes through the full parse path + watcherAny.instanceCreatedAt = 0; // First call - fallback path (no lastProcessedLineCount) await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); @@ -527,6 +542,166 @@ describe('FileWatcher', () => { }); }); + // =========================================================================== + // First-Read Baseline Tests (prevents old session error flooding) + // =========================================================================== + + describe('first-read baseline behavior', () => { + it('establishes baseline without detecting errors for pre-existing files', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-baseline-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + // Write a file with multiple lines (simulating an existing session with errors) + fs.writeFileSync( + filePath, + jsonlLine('u1', 'hello') + jsonlLine('u2', 'world') + jsonlLine('u3', 'error line'), + 'utf8' + ); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Simulate watcher starting well after the file was created + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + lastProcessedSize: Map; + instanceCreatedAt: number; + }; + watcherAny.instanceCreatedAt = Date.now() + 60_000; // watcher "started" in the future + + vi.mocked(errorDetector.detectErrors).mockClear(); + + // First read should establish baseline, NOT detect errors + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // errorDetector.detectErrors should NOT have been called + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + + // Baseline tracking should be established + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); + + // notificationManager.addError should NOT have been called + expect(notificationManager.addError).not.toHaveBeenCalled(); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('detects errors only in new data after baseline is established', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-post-baseline-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + // Initial content (old session data) + fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world'), 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Simulate watcher starting well after the file was created + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + instanceCreatedAt: number; + }; + watcherAny.instanceCreatedAt = Date.now() + 60_000; + + vi.mocked(errorDetector.detectErrors).mockClear(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + // First read: baseline only + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); + + // Append new data + fs.appendFileSync(filePath, jsonlLine('u3', 'new error')); + + // Second read: should detect errors in new data only + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); + // Verify only the new message was passed to detectErrors + const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; + expect(callArgs[0]).toHaveLength(1); // only 1 new message + + // Tracking should now reflect all 3 lines + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('detects errors immediately for files created after watcher startup', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-newfile-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + vi.mocked(errorDetector.detectErrors).mockClear(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + // instanceCreatedAt is already set to "now" by the constructor, + // and the file created below will have birthtimeMs >= instanceCreatedAt, + // so it will be treated as a new file (no baseline skip) + const filePath = path.join(projectDir, 'session-new.jsonl'); + fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'error'), 'utf8'); + + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + }; + + // First read of a NEW file should detect errors (not baseline-skip) + await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0]; + expect(callArgs[0]).toHaveLength(2); // all messages scanned + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + }); + // =========================================================================== // Timer Lifecycle Tests // ===========================================================================