Guidance for working on ClaudeDeck — a minimalist Windows tray dashboard for Claude Code sessions, written in PowerShell (no Node/Python). Shell is PowerShell on Windows.
The Bash tool here is a POSIX bash on Windows — every powershell -File example in this
file uses .\ paths that bash strips before powershell.exe sees them (.\tools\build.ps1
becomes .toolsbuild.ps1 → "file does not exist"). Two rules:
- Use forward slashes in any command run through bash:
powershell -File ./tools/build-setup.ps1. - Never mix PowerShell cmdlets into a bash pipeline (
... | Select-Objectfails with "command not found"); either keep the pipeline pure POSIX (head,grep) or wrap the whole thing inpowershell -Command "...". - Avoid inline
powershell -Commandfor anything non-trivial. Bash expands$_inside double quotes (it becameextglob.CommandLineonce), and escaping WQL filters like"Name='powershell.exe'"through two quoting layers is a lost cause. For anything with$_, pipelines or nested quotes, write a helper.ps1and run it with-File(e.g.tools/kill-app.ps1stops the running tray/view/stats before a redeploy).
ClaudeDeck has no server and no daemon doing the tracking — it piggybacks on Claude Code hooks. The flow is one-directional: hooks write state files, the UI reads them.
Claude Code --(hooks: UserPromptSubmit/Stop/Notification/SessionEnd)--> session-tracker.ps1
|
writes per-session state ~/.claude/sessions/state/<session_id>.json
appends event log ~/.claude/sessions/stats/events.jsonl
|
session-tray.ps1 (always running) ----reads state----> tray menu of live sessions
session-view.ps1 (large overlay) ----reads state----> full-screen session list
session-stats.ps1 (dashboard) ----reads events---> activity / focus-time charts
Scripts under scripts/ are split into entry points (run directly / by a launcher) and
dot-sourced modules (libraries loaded into an entry point's scope; never run on their own).
Entry points:
- session-tracker.ps1 — the only thing hooks call. Takes
-Event prompt|stop|notify|end, reads the hook's JSON payload from stdin, and writes the session's state file (+ appends toevents.jsonl). It must never throw into Claude Code: every path ends inexit 0and errors are swallowed. It also reads the session transcript to compute context-window tokens. Deliberately standalone — it does NOT dot-source the shared library, so a bug there can never break a hook. - session-tray.ps1 — the long-lived tray icon (
NotifyIcon). Lists active sessions, lets you click one to focus its VS Code/Cursor window, and owns all settings toggles (DND, transparency, size, auto-update) which it persists as flag/text files. Hosts the global hotkey (Win+Alt+C) that opens the large view. Single-instance via a process scan. Dot-sourcessession-common.ps1+session-pomodoro.ps1. - session-view.ps1 — the large always-on-top overlay, auto-popped on
Stop(unlessdnd.flag). Same per-session data as the tray, bigger. Readsopacity.txt/size.txtlive. Dot-sourcessession-common.ps1+session-ui-interop.ps1+session-ui-icons.ps1+session-ui-repo.ps1. The file still owns the form build, layout, row rendering, animations and the focus-nudge "Matrix" effect (one cohesive WinForms window). - session-stats.ps1 — the statistics dashboard. Reads
events.jsonl, pairs prompt→stop events into "turns" to derive focus time, and draws today/week cards, a 14-day activity chart, and top projects. Supports-Printfor a headless text summary (use this to test the math). Dot-sourcessession-common.ps1. - session-recap.ps1 — the weekly recap popup, auto-opened by the tray every Friday at 17:00
(and from the tray's "Weekly recap" menu). Gathers the work week's user requests (Mon 00:00 →
now) per project by reading the Claude Code transcripts under
~/.claude/projects/<dir>/*.jsonl, pairs each project with its completed (ticked-done) tasks fromobjectives.json— pending tasks are skipped as noise — and asks an LLM for a short per-project synthesis. The provider is pluggable via.env(Get-CDEnv):LLM_PROVIDER=ollama(native/api/generate) oropenai(any OpenAI-compatible/v1/chat/completionsendpoint). LLM calls run in a background runspace, polled by a UI timer so cards fill in as they arrive; if Ollama is off or unreachable it falls back to listing the raw requests.-Printdumps the gathered data headlessly (test the parsing without UI/LLM);-Days Noverrides the window. Dot-sourcessession-common.ps1. - session-update.ps1 — optional self-updater. Single channel: GitHub Releases
(the latest release's tag is the canonical version, its attached
ClaudeDeck-Setup.cmdasset is the payload — so a push tomainwithout a release can never serve stale bits).-Checkqueriesreleases/latest(User-Agent header required by the API), compares its tag with the installedversion.txt(strictly greater ⇒ offer the update — a local dev build ahead of the release never nags) and writesupdate.json(incl.assetUrl/notes).-Applydownloads the asset to TEMP, copies itself out of the install dir, and relaunches in-Bootstrap(detached + hidden), returning immediately so the UI never blocks.-Bootstrapis the worker: it waits for every ClaudeDeck process to exit (polling until the logo/font handles release — the real failure mode of the old 700ms-sleep design), extracts the installer's silent PS payload from the.cmd(after the#@CDINSTALLER@#marker — no console, nopause), runs it, writesupdate-result.json(ok/version/error) for the restarted tray to surface as a balloon, and restarts the tray itself if the install failed. NB: it must NOT useStart-Process -Wait(that blocks on the relaunched tray forever); it uses-PassThru+$p.WaitForExit(). Standalone (a leaf, run as a child process). - session-workspaces.ps1 — favorite-workspace save/restore. Dual-mode: run directly with
-Save/-Restore/-List, or dot-sourced. Run as a hidden child process by the deck, NOT dot-sourced into it (itsparam()block + action logic would leak — see its header). - *.vbs (
start-tray,show-view,show-stats,show-recap) — thin launchers that run the matching.ps1viapowershell -WindowStyle Hiddenso there's no console flash. Shortcuts and the tray menu always go through these, never the.ps1directly.
Dot-sourced modules (pure definitions; param-less and side-effect-light so they're safe to
load into a live WinForms scope — see session-common.ps1's header for the rules):
- session-common.ps1 — the shared library every UI script loads: the data-layout paths
(
Get-CDRoot/Get-CDPath), UTF-8-no-BOM writes (Get-CDUtf8/Write-CDText), the flag toggle (Toggle-Flag), the per-project colour/badge helpers (Get-ProjectColor/Get-Initials/Get-TextOn/Hue2Rgb), the.envreader (Get-CDEnv, with built-in defaults), and the self-updater bridge (Get-LocalVersion/Invoke-Updater/Get-UpdateInfo). Single source of truth for things that used to be copy-pasted across the deck, tray and stats. - session-pomodoro.ps1 — the whole Pomodoro engine + the foreground-activity classifier,
loaded by the tray. Runs its own 1s timer, publishes
pomodoro.json, consumespomodoro-cmd.txt, and exposesGet-PomoCategory/Play-PomoChime(also used by the tray's focus nudge). The deck only renderspomodoro.jsonand writes control tokens. - session-ui-interop.ps1 — the deck's native types:
[WinFocus](find/raise IDE windows, global mouse polling, taskbar flash),[VDesk](follow the active virtual desktop) and[NoActivateForm](a Form that never steals keyboard focus). PureAdd-Type. - session-ui-icons.ps1 — Material icon font loading + glyph/badge rendering for the deck
(
$script:MAT,New-IconFont,Get-IconChar,Set-IconLabel,New-MatIcon,New-Badge). - session-ui-repo.ps1 — the row context-menu helpers: resolve a session's git remote to a
web URL + host-aware deep links (
Get-RepoWebUrl/Get-RepoMenuLabel/Get-RepoSubLinks) andOpen-Terminal.
Dot-sourcing runs a file in the caller's scope, so $script: state and $PSScriptRoot
stay shared — that's why the modules can define functions the entry point's controls/timers
use. All files live flat under scripts/ (the installer and build-setup.ps1 copy/embed
Get-ChildItem -File non-recursively, so a subfolder would NOT ship).
All runtime state lives here, not in the repo. The UI and tracker communicate only through these files:
state/<session_id>.json— one per session:project,last_prompt,status(running/waiting/done),updated,ctx_tokens. Overwritten each event; pruned after 24h.stats/events.jsonl— append-only history ({ ts, ev, id, project, ctx }per line). This is what makes trends/durations possible; trimmed to 90 days by the stats view. When cloud sync is on, each machine appends to its ownevents-<HOST>.jsonlin the sync folder instead (so two PCs never collide on an append); the stats view reads everyevents*.jsonland merges them (Get-CDEventLogs). The localevents.jsonlkeeps being read as legacy/pre-sync history.objectives.json—{ items: { "<project>": [ { text, done, desc }, ... ] } }: the manually- typed per-project TODO list. Each task has atext(title), adoneflag, and an optionaldesc— a rich-text note stored as RTF (empty string when none; pure-ASCII so it survives PS 5.1 encoding). The deck's group header shows the list as a ONE-LINE scrollable todo (mouse wheel cycles tasks, a click toggles the shown task's done, the pencil opens the full add/edit/ delete/reorder editor — the per-task Add/Edit dialog (Edit-TaskDetailinsession-view.ps1) is a resizable window with the title plus aRichTextBoxnote + Bold/Italic/Bullet toolbar); the weekly recap reads only the ticked-done tasks'textas "completed". A legacy single"<text>"string is still read as one undone task — seeConvertTo-CDTasks/Format-CDObjectiveinsession-common.ps1, the shared normaliser both surfaces use.- Flag files (presence = on):
dnd.flag,closeoutside.flag,autoupdate.flag. - Value files:
opacity.txt(20–100),size.txt(scale),update.json(the last-Checkresult:current/latest/available/assetUrl/notes),update-result.json(written by the updater's-Bootstrapworker after an install —ok/version/error— read once by the restarted tray to show a success/failure balloon, then deleted),version.txt,update-attempted.txt(the last version the tray tried to auto-install — withautoupdate.flagon, a found update is installed without a click; this marker caps each version at one automatic attempt so a failing release degrades to the manual "Install update" entry instead of an install/restart loop),recap-shown.txt(Monday-date tag of the last shown weekly recap, so it fires once/week),sync.txt(the cloud-sync folder path, when configured — see below). .env— user-editable settings (seeded once from.env.example, never overwritten): the weekly-recap LLM config —LLM_PROVIDER(ollama|openai),LLM_URL/LLM_MODEL/LLM_API_KEYfor the OpenAI-compatible path, the legacyOLLAMA_URL/OLLAMA_MODELfor the native path (honoured as fallbacks), plusLLM_ENABLED/LLM_TIMEOUTandRECAP_INCLUDE_OUTCOMES/RECAP_OUTCOME_CHARS. Read viaGet-CDEnv. Saved recaps land inrecaps/recap-<Monday>.md.
Cloud sync (optional, opt-in). Set a folder kept in sync across machines (Google Drive /
OneDrive / Synology Drive) via the deck's gear menu ("Cloud sync folder…") — the path is stored
in sync.txt. Only the portable user data follows you: objectives.json (one shared file)
and the activity history (per-machine events-<HOST>.jsonl, conflict-free). Machine-local things
(live state/, flags, opacity/size, version) never sync. The mechanism lives in session-common.ps1
(Get-CDSyncDir / Get-CDObjectivesPath / Get-CDEventWritePath / Get-CDEventLogs / Set-CDSyncDir);
the tracker mirrors the event-path logic inline (Get-EventLogPath) since it can't dot-source the lib.
Every helper falls back to the local copy when the folder is unset or temporarily unreachable (drive
not mounted yet), so nothing ever breaks. objectives.json is single-file last-write-wins (rare
two-PC-at-once edits can lose one side); events are per-host so they never collide.
install.ps1 merges these into ~/.claude/settings.json (idempotently — it matches on a marker
string, never duplicates): UserPromptSubmit→tracker prompt, Stop→tracker stop and the
view auto-popup, Notification→tracker notify, SessionEnd→tracker end.
These patterns are deliberate across the codebase — match them:
- Always write UTF-8 without BOM via
[System.IO.File]::WriteAllText(..., $utf8NoBom);Set-Content/Get-Content -Rawmangle accents on PS 5.1. - No non-ASCII literals in source. Build glyphs from code points (
[char]0x2014for an em dash, etc.) so files survive any encoding. ConvertFrom-Jsonunwraps single-element arrays into bare objects — re-wrap with@(...)before serializing, or Claude Code rejectssettings.json("Expected array, received object").- The tracker must never fail loudly.
$ErrorActionPreference = 'SilentlyContinue'+exit 0.
The tray and view run from %USERPROFILE%\.claude\sessions\, not from scripts/ in
this repo. Editing a script in the repo has no effect on the running app until it's
redeployed. Each script is a hidden powershell.exe process launched via wscript.
Apply this systematically whenever you modify any script under scripts/:
-
Kill the running app (tray, large view, and stats window; the single-instance guard otherwise makes a fresh process
exit 0):Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -like '*session-tray.ps1*' -or $_.CommandLine -like '*session-view.ps1*' -or $_.CommandLine -like '*session-stats.ps1*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
-
Run the edited script straight from the repo (fastest iteration — scripts read state from
~/.claude/sessions/state/andstats/via hardcoded paths, so this reflects your edits live):powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\session-view.ps1 # large view powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\session-tray.ps1 # tray powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\session-stats.ps1 # stats window powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\session-stats.ps1 -Print # stats math, headless
Running in a visible terminal (no
-WindowStyle Hidden) surfaces PowerShell errors — use it for debugging.-Printis the fastest way to sanity-check stats computations. The tracker can't be run usefully on its own — it needs a hook JSON payload on stdin. -
Validate the real deployment (only when the change looks good, not every iteration):
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
install.ps1is idempotent and non-destructive: it stops the tray, copies scripts to~/.claude/sessions/, recreates shortcuts, backs upsettings.json, and restarts the tray.
If you changed anything under scripts/ or install.ps1, regenerate the single-file
installer and verify the embedded copies match:
powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\build-setup.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\verify-bundle.ps1 # expect "--- ALL MATCH ---"ClaudeDeck-Setup.cmd is generated (base64-embedded scripts + a copy of install.ps1's
logic) — never edit it by hand. The version number comes from the latest git tag.
The git tag is the single source of truth for the version. Default to a patch bump
(0.0.+1) — e.g. v0.1.1 → v0.1.2 — unless the user asks for a different one. The
self-updater reads GitHub Releases (releases/latest), so the final gh release create
(with the .cmd attached as an asset) is mandatory, not optional — without it, pushing
to main ships nothing to update clients (the tag drives version.txt, the published release
is what -Check compares against and -Apply downloads):
git commit ... # land your changes first
git tag v0.1.2 # patch bump from the latest tag
powershell -File .\tools\build-setup.ps1 # writes scripts/version.txt + regenerates the .cmd from the tag
powershell -File .\tools\verify-bundle.ps1 # expect "--- ALL MATCH ---"
git add scripts/version.txt ClaudeDeck-Setup.cmd
git commit -m "Release v0.1.2: regenerate version.txt and installer from tag"
git tag -f v0.1.2 # move the tag onto the release commit
git push origin main && git push origin v0.1.2
gh release create v0.1.2 ClaudeDeck-Setup.cmd --title "ClaudeDeck v0.1.2" --notes "..." # REQUIRED: the updater's channelAll user-facing text and comments are in English. Keep it that way.