CoODL
June 14, 2026 · View on GitHub
Repeated mistakes and their fixes. Written so future-me (any Claude session) does not re-discover these by touching the stove.
Read this FIRST at the start of any CoODL session. Add to it whenever a lesson is learned twice. If a topic accumulates and pushes this file near the 200-line limit, extract it into a sibling docs/dev/<TOPIC>.md and cross-reference from here.
Companion docs
docs/dev/BUILD-AND-TOOLING.md— line-count audit, PowerShell / heredoc quirks, commit-message workflow, CI runner gotchas, screenshot workflow, UI-Automation snippets, the release checklist.docs/dev/VELOPACK.md(v1.10.0+) — Velopack packaging + auto-update CI pipeline: localvpk packcommands, thePublishSingleFile=falseandfetch-depth: 0invariants, the--tokengotcha, delta-package generation viavpk download github, post-ship validation strategy, code-signing future plans.docs/dev/REFACTOR-AND-XAML.md— NSubstitute / fixture gotchas, Avalonia compiled-binding pitfalls (string->Color,TappedvsPointerPressed,IsHitTestVisibleon child images/overlays), WMI scanner Electron quirk, close-to-tray process state, clipboard bitmap capture.docs/MIGRATION.md— user-facing v1.9.x -> v1.10.0 migration guide (portable .exe -> Setup.exe + auto-update).
Hard rules (repeated here for emphasis)
These come from the project owner and are non-negotiable:
- Every file ≤ 200 lines (.cs, .axaml, .md, .ts, etc.). Only exception is
package.json. When a file grows past 200, extract via OOP — split, don't trim. Never remove comments, code or whitespace to hit the limit. - 100% test pass rate before any push. Build must be 0 warnings, 0 errors.
- Never suppress warnings with
NoWarn,#pragma, or[SuppressMessage]. Fix the root cause. - Never strip logging. Diagnostic/info/warning/error logging stays permanently once added. Logging is never "temporary".
- Always
dotnet run+ visually verify before committing a visual change. Fresh screenshot →docs/screenshots/→ ROADMAP updated referencing it. - Plan before implementing. Present options, wait for the user to pick, then build. No unilateral "I'll just build it" moves.
- SOLID / MVVM / event-driven throughout. ViewModels never touch system APIs directly — everything goes through an
I<Service>abstraction. - Read
docs/first. At the start of any CoODL session, read this file and the two companion docs before touching code. Saves re-discovering things.
CoODL architecture invariants
MainWindowViewModeldepends only onI<Service>abstractions. No direct WMI / filesystem / registry calls.- Core layer (
ClaudeDesktopLauncher.Core) has zero Avalonia references. Keep UI out of Core. The only exception is that Core services can return raw bytes (e.g.byte[]?for thumbnails) and the UI layer materialises them into Avalonia types. - Every service has an
I<Service>interface inServices/Interfaces/and a concrete implementation inServices/. - List VMs own reconciliation — they mutate their
ObservableCollectionin place, never reassign. Preserves Avalonia binding identity so row state (edit-in-progress text) survives refreshes. - Reconciliation is identity-preserving: match by stable key (slot number for slots, PID for externals), update in place, add missing, remove gone. Never clear-and-repopulate.
- Pure functions / testable logic separated from IO-orchestration services. Example:
SeedCacheValidators(pure validation) extracted fromFileSlotSeedCache(IO orchestration). - Scanner→Classifier→ViewModel pipeline. Scanner returns raw
ClaudeProcessInfo. Classifier returns typedSlotProcessInfo/ExternalProcessInfo/ null. VMs filter + reconcile. IThumbnailableViewModelis the shared surface across bothClaudeInstanceViewModelandExternalInstanceViewModel, lettingThumbnailRefreshertreat both uniformly without a common base class (v1.9.1+).- Per-row callbacks live as
Action<T>? OnSomethingproperties on the list VM, wired viaSlotCallbackBinder.Bind(slots) +SlotCallbackBinder.BindExternal(externals). Keeps row VMs free of service dependencies. - Velopack auto-update (v1.10.0+):
VelopackApp.Build().Run()MUST run as the first line ofProgram.Main, before Avalonia boots. Velopack'sUpdateManageris a concrete type wrapped by ourIAutoUpdateServiceadapter for testability. Update orchestration lives inMainWindowUpdateViewModel(sub-VM pattern) +UpdateOrchestrator(state machine) soMainWindowViewModelstays under the 200-line cap. Full design rationale indocs/dev/VELOPACK.md. - Self-heal at startup (v1.10.2+): where an external system (Velopack, Claude Desktop, the Windows Shell) has a known bug that leaves our app in a broken state, we prefer shipping a local heal-on-startup check over waiting for an upstream fix. The pattern: interface + two injectable probes (production resolver + a mock for testing), dev-build guard so
dotnet runnever attempts the heal, defensivetry/catchat the top of the heal method so a broken probe can't block startup, and an enum return type so callers (and tests) can assert which branch ran.IShortcutHealeris the reference implementation.
NTFS junctions and symlinks inside AppData\Local do not support directory enumeration
Investigated 2026-04-22 during the shared extension store design. Do not re-investigate — this is a confirmed Windows OS restriction, not a bug we can fix.
GetFileSystemEntries(FindFirstFileW) andGetChildItemon a junction or directory symlink located inside%LOCALAPPDATA%(but outside%LOCALAPPDATA%\Temp) always fail with "Could not find a part of the path", even thoughTest-PathandGetFileAttributesWsucceed on the same path.- Confirmed on the developer machine (Windows 11, Developer Mode ON). Not caused by: AppContainer SIDs, spaces in path names,
\\?\extended prefix, or junction vs symlink reparse tag. A junction FROMDocumentsorAppData\RoamingTOAppData\Localworks fine — the restriction is specifically on junctions whose SOURCE is insideAppData\Local(non-Temp). - Consequence for extension store:
ClaudeSlot{N}\Claude Extensions\is always insideAppData\Local, so any junction/symlink placed there to redirect to a shared store will be unreadable by Claude's Node.js process (which uses the same Win32FindFirstFileWAPI). The entire junction-based extension deduplication approach is blocked. - The backlog item "Shared extension store" has been closed as not feasible via junctions/symlinks. See ROADMAP for the updated note.
Diagnostic honesty — lessons from the 2026-04-20 update-failure investigation
- "Transient" is a conclusion, not an opening hypothesis. Cheap diagnostics FIRST: read the relevant log, run
handle64.exe, check process trees. A retry without evidence wastes the user's time. Two consecutive failures with identical symptoms = reproducible bug, not flake. - Dev builds can impersonate the installed version. A
dotnet runproduces an .exe showing the same version footer as the Velopack build. ONLY check viaGet-Process ClaudeDesktopLauncher | Select-Object PathandGetLastWriteTimeon the installed exe. Kill stray dev builds before diagnostic work. - Velopack silently relaunches the old version on apply failure. When apply fails, Velopack logs
[ERROR] Apply error:tovelopack.logand relaunches the original exe. No C# exception is thrown. The only signal is the log. Detection pattern lives inIUpdateApplyFailureDetector(v1.10.4+). - "Continue" isn't always the right response to a stuck investigation. Stop after the second identical failure and write a plan. Signs to stop: repeated same-symptom failures; speculating instead of measuring; running low on tokens while still flailing.
Avalonia.Controls.WebView - type name resolution
Added in v1.10.7. Package: Avalonia.Controls.WebView 12.0.0. NativeWebView is in the Avalonia.Controls namespace. WebViewNavigationCompletedEventArgs also in Avalonia.Controls. Set UserDataFolder via reflection inside EnvironmentRequested handler (avoids needing WindowsWebView2EnvironmentRequestedEventArgs type name). No AppBuilder extension needed — just add the package and use NativeWebView in XAML.
Memory / mental model
- The user runs the CoODL launcher on Windows (Dell laptop is Linux but not the target).
- ClaudeSlot{N} data directories live at
%LOCALAPPDATA%\ClaudeSlot{N}\. - Seed cache is at
%APPDATA%\ClaudeDesktopLauncher\seed\. - Logs are at
%APPDATA%\ClaudeDesktopLauncher\logs\launcher-YYYY-MM-DD.log. - User's Windows GitHub Desktop is the shiftkey fork v3.4.12-linux1.
- AutoHotkey v2 script at
%USERPROFILE%\Documents\AutoHotkey\Continue.ahk. - The user has a multi-monitor setup. Primary monitor is 2560x1440. Prefer
Windows-MCP:Clicktool for automated clicks (handles DPI correctly).
Avalonia.Controls.WebView focus crash - global exception handler
Added in v1.10.17. The Avalonia.Controls.WebView 12.0.0 package has a known crash where CoreWebView2Controller.MoveFocus throws System.ArgumentException (HRESULT 0x80070057) when the underlying WebView2 is in a transient state during window activation. The exception bubbles from NativeWebView.OnGotFocus -> WindowBase.HandleActivated -> Win32 WndProc and kills the process because no Avalonia layer catches it.
Fix: App.OnFrameworkInitializationCompleted calls HookGlobalExceptionHandlers() which subscribes to Dispatcher.UIThread.UnhandledException, logs the exception, and sets e.Handled = true. The launcher survives the crash and the user keeps working. Every swallowed exception is logged so real bugs remain discoverable in %APPDATA%\ClaudeDesktopLauncher\logs\.
Diagnostic pattern: when the launcher dies abruptly with no stack in the launcher log, check the Windows Application event log for ClaudeDesktopLauncher.exe faulting application entries - the .NET Runtime usually logs the unhandled exception there before the process exits.
WndProc-synchronous exceptions bypass Dispatcher.UnhandledException
Discovered in v1.10.18 after v1.10.17 shipped with what turned out to be an ineffective fix. Dispatcher.UIThread.UnhandledException only fires for exceptions thrown during Invoke/Post operations on the dispatcher. Exceptions thrown synchronously from inside a Win32 WndProc callback (focus events, paint events, etc.) bypass the dispatcher entirely - they propagate up through native code to the Win32 message pump and kill the process before any managed handler can intercept them.
Concrete case: the WebView2 CoreWebView2Controller.MoveFocus ArgumentException crash originates from NativeWebView.OnGotFocus, which is called synchronously by WindowBase.HandleActivated from WindowImpl.AppWndProc. The Dispatcher.UnhandledException hook from v1.10.17 was registered but never fired for this crash, and v1.10.17 continued to crash identically.
Correct fix: prevent the exception at the source. v1.10.18 sets Focusable="False" on the NativeWebView so the OnGotFocus -> MoveFocus path is never entered. Mouse interaction still works because pointer events don't trigger focus.
Diagnostic signature: when a crash logged in the Windows Application event log shows "The process was terminated due to an unhandled exception" with a stack trace that ends in WindowImpl.AppWndProc or any HandleXxx method on WindowBase, this is a WndProc-synchronous exception. Don't try to catch it - find what triggers it and prevent the trigger.
Cross-platform build for Windows + Linux from one codebase (v1.10.19)
Three layered mechanisms keep the source tree single-targeted while producing two artefacts. Picking the right one per situation is the trick:
csproj <PackageReference Condition="'$(OS)' == 'Windows_NT'"> for packages that have no Linux story at all (Avalonia.Controls.WebView, Velopack, System.Management, System.Drawing.Common). The package is just absent on Linux builds, and any code referencing types from those packages must be excluded from the Linux compile.
csproj <Compile Remove="..." Condition="'$(OS)' != 'Windows_NT'"> for source files that reference Windows-only types (Win32WindowHider, WmiClaudeProcessScanner, etc.). The file simply isn't part of the Linux build. The DI graph routes around the absence via per-platform registration branches in App.axaml.cs.
#if WINDOWS preprocessor blocks for files that are mostly cross-platform but have one or two Windows-only references — Program.cs (Velopack hook) and the call site of RegisterWindowsServices in App.axaml.cs. The WINDOWS constant is set by <DefineConstants Condition="'$(OS)' == 'Windows_NT'">$(DefineConstants);WINDOWS</DefineConstants> in the main csproj. Sparingly used; the other two mechanisms are cleaner where they fit.
XAML can't be conditionally compiled the way C# can. Avalonia.Controls.WebView's <NativeWebView> element won't resolve on Linux because the package isn't there. Solution: keep the XAML cross-platform with a placeholder (<ContentControl x:Name="UsagePanelHost"/>), wire any Windows-only control creation up in code-behind, and split the code-behind into a cross-platform partial class plus a <Compile Remove>'d Windows-only partial class. The cross-platform partial declares an unimplemented partial method like partial void InitializeUsagePanel(); which the Windows partial implements; on Linux the call site compiles away to nothing.
SystemProcessService had [SupportedOSPlatform("windows")] despite using cross-platform System.Diagnostics.Process APIs. Removed in v1.10.19. MainWindowHandle returns IntPtr.Zero on Linux processes which is harmless; the call works it just always returns no-window. Don't add platform attributes just because the class was written on Windows; only add them when the APIs actually demand it.
/proc/PID/stat: anchor on the LAST ), not the first space (v1.10.20)
The Linux /proc/PID/stat line is space-separated, but field 2 (comm) is the executable name in parentheses and may contain spaces or even nested parens if a process renamed itself via prctl(PR_SET_NAME). A naive line.Split(' ') then parts[3] for the parent PID will break catastrophically for any such process — and silently, since most processes have well-behaved names. The fix in ProcfsParser.ParseStat is to find the last ) in the line, then split the remainder. Field indices after that are stable: [0] = state, [1] = ppid, [19] = starttime in clock ticks.
Convert clock ticks to wall-clock seconds with sysconf(_SC_CLK_TCK) — universally 100 on Linux. Then unix_seconds = btime + (starttime / 100) where btime comes from the top of /proc/stat. Don't read /proc/stat per-process; cache the btime after first read since it doesn't change for the launcher's lifetime.
For the cmdline blob: /proc/PID/cmdline is NUL-separated argv, often with a trailing NUL. UTF-8 decode then Replace('\0', ' ').TrimEnd() gives a Windows-WMI-equivalent space-separated string. Don't try to be clever about quoting; the launcher's downstream classifier only cares about flag presence (--user-data-dir=, --type=), not about reconstructing the original argv.
When walking /proc, filter directory names to all-digit strings (IsPidDirectoryName) — /proc contains sys, bus, self, thread-self, cpuinfo, etc. that are not processes. And tolerate every per-PID file read failing: processes can exit mid-scan (FileNotFoundException, DirectoryNotFoundException), and kernel threads/other users' processes may give UnauthorizedAccessException. Swallow all four exceptions per-entry, never abort the whole scan.