Skip to content

Personal Atlas Cockpit (PAC) — Architecture & Conventions

Reference playbook for PAC v0.2.1 (22 May 2026 cut). Read this before resuming any PAC work, especially before touching the main process, IPC surface, or sidebar/layout code.

PAC lives at C:\ssClawy\personal-atlas-cockpit\app\.


What PAC is

A personal mission-control Electron app for Sush's MSFT Solution Engineer role. Tray + popover for at-a-glance, full window for deep work. Sush only. No distribution. No org-wide ambition. Different surface from anz-clawpilot.

Stack: Electron 33 + Vite 5 + React 18 + TypeScript, built via electron-vite. Renderer = static SPA. Main = Node.js with IPC bridge. No SQLite yet (60s in-memory cache); v0.3+ candidate.


The internal-first pivot story (why v0.2.1 matters)

v0.2 tried MSAL device-code auth (client ID 14d82eec-204b-4c2f-b7e8-296a70dab67e, the MS Graph PowerShell SDK well-known). Blocked twice by MSFT Corp Conditional Access — first the device-code consent screen, then the admin-consent wall.

v0.2.1 rips MSAL out and adopts two CA-pre-approved internal patterns:

  1. WorkIQ MCPworkiq.cmd mcp spawned as JSON-RPC subprocess for all M365 data. Mirrors Frontier SE. Sush's WorkIQ auth is already CA-pre-approved at first run; PAC inherits it. All Graph queries go through WorkIQ. No direct Graph SDK in PAC.
  2. Copilot CLI --acp — spawned as Atlas chat sidebar. Same memory, same skills, same auth as Sush's terminal Atlas. Read-only tool allow-list (read, grep, glob).

This pattern is now the standard. Rule #4 (internal-first) applies to everything PAC builds going forward. Before any new external integration, scan MSFT-internal repos/MCPs first (msx-mcp, mcp-gateway, ai-sales-kit, etc.). See msft-gold-explore-queue.md.


Process model

┌────────────────────────────────────────────────────────┐
│ MAIN (Node)                                            │
│                                                        │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ workiq.ts   │  │ atlas-chat.ts│  │ loader.ts    │  │
│  │ JSON-RPC    │  │ JSON-RPC     │  │ delegates to │  │
│  │ to workiq.cmd│  │ to copilot   │  │ workiq.ask() │  │
│  │ (subprocess)│  │ --acp        │  │              │  │
│  └─────────────┘  └──────────────┘  └──────────────┘  │
│        ▲                ▲                  ▲           │
│        │                │                  │           │
│        │   ipcMain.handle('workiq.ask', ...)           │
│        │   ipcMain.handle('chat:send', ...)            │
│        │                │                  │           │
└────────┼────────────────┼──────────────────┼───────────┘
         │                │                  │
         │  preload.ts contextBridge exposes window.pac
         │                │                  │
┌────────┼────────────────┼──────────────────┼───────────┐
│ RENDERER (Chromium)                                    │
│  React app — TitleBar / TabBar / Home / AtlasChat      │
│  Hooks: useDashboard(), useAtlasChat()                 │
└────────────────────────────────────────────────────────┘

Key rules: - Every spawn() in main must scrub ELECTRON_RUN_AS_NODE, ELECTRON_NO_ASAR, ELECTRON_NO_ATTACH_CONSOLE. Sush's user-scope env has ELECTRON_RUN_AS_NODE=1 set globally (Clawpilot artifact) — without scrubbing, every child Electron process boots as plain Node and require('electron') returns the binary path string. See scripts/run-electron-vite.cjs for the canonical wrapper. - Single-instance lock at top of src/main/index.ts. Multiple instances would step on the tray icon + global shortcut. - bootstrapped flag gates cleanup — second-instance handler shouldn't destroy the running tray.


WorkIQ MCP wiring (Frontier SE pattern)

Source of truth: src/main/workiq.ts.

Spawn chain: workiq.cmd (Sush's npm-global) → npx workiq → bundled binary. First success wins.

Handshake order (must be strict): 1. Spawn with { shell: true, env: scrubbed }. 2. Send initialize JSON-RPC. 3. Wait for response. 4. Send notifications/initialized. 5. Send tools/list — expect accept_eula, ask_work_iq, get_debug_link.

Single-flight queue. Multiple renderer asks serialize through a Promise chain. WorkIQ tolerates only one in-flight tool call at a time. Skipping the queue breaks the JSON-RPC stream (interleaved responses, parser desync).

Prompt convention. All Graph queries phrased as structured-output natural language with explicit JSON schema in the prompt. Example: "List my calendar events for TODAY in the Pacific/Auckland timezone. For each, return a JSON array of objects with these exact keys: id, subject, startISO, endISO, startHHMM, durationMin, isCustomer, organizerName." WorkIQ returns plain JSON; loader parses and validates.

Round-trip latency. 24–38 seconds typical. Cache aggressively. Default cache: 60s in-memory; bump to 15min for loader.fetchDashboard() snapshot in v0.2.2.


ACP (Atlas chat) wiring

Source of truth: src/main/atlas-chat.ts.

Spawn: copilot --acp --effort high --allow-tool=read,grep,glob --add-dir <userData>.

Why --effort high: Atlas in sidebar is doing real work alongside Sush — research, code review, drafting. Cost is fine; quality matters more.

Why read-only tools by default: Sidebar is always-on. Allowing bash or edit by default would let an autonomous tool-call modify Sush's filesystem mid-session. Per-conversation opt-in for bash is a v0.2.2 candidate (gate behind explicit user toggle + Rule #3 disclosure).

Event stream. ACP emits JSON-RPC chunks on stdout — agentMessageStreamChunk, agentMessageToolCall, agentTurnDone. atlas-chat.ts parses these and emits IPC events chat:chunk, chat:tool-call, chat:turn-done, chat:error to renderer.

Rule #2 enforcement. chat:send is the only way to start a turn, and it's gated by ipcMain.handle() — no main-process auto-invocation. QA gate Rule #2: chat:send is user-triggered IPC only enforces this.

Rule #3 (signature) does not apply to sidebar replies that stay in the chat UI (Sush is reading them directly). It applies if a sidebar exchange ever results in a Teams send or email — at which point chat:send flow doesn't send anything externally anyway, the user does it themselves.


Layout contract (the 22 May regression and its fix)

Permanent invariant. AtlasChat sidebar is position: fixed; top: 0; right: 0; bottom: 0 with width: 40px (collapsed) or 320px (expanded). It is NOT in document flow. It is NOT nested inside any flex wrapper inside Home.tsx.

window-root reserves space for it via two utility classes: - .has-sidebar-collapsed { padding-right: 40px } - .has-sidebar-expanded { padding-right: 320px }

Home.tsx polls localStorage['pac.sidebar.expanded'] every 250ms to keep the class in sync with the sidebar's own state.

What broke (22 May 2026). First sidebar integration nested AtlasChat inside <div className="window-body"> with display: flex; flex-direction: row. That collapsed the Home dashboard column to zero width — visible TitleBar/TabBar/StatusBar, then completely black body for several boot iterations. Boot logs clean, WorkIQ returned real data, ErrorBoundary never fired. Pure CSS layout corruption that took multiple iterations to diagnose because nothing was throwing an error.

Detective work. Used Win32 EnumWindows + IsWindowVisible to confirm the window existed at expected bounds. Multi-monitor positioning was a red herring (Sush's 3-display setup put the window on whatever screen the cursor was on at create time). Once forced onto primary at (40,40) 1320x880 via MoveWindow, the dashboard rendered immediately after the position: fixed refactor — proving the bug was layout-only.

3 new QA gates lock the contract: - AtlasChat is position:fixed (not in-flow) - window-root reserves sidebar padding - Home.tsx does not nest content in window-body wrapper

Growing-guardrail rule (per Sush's PERMANENT POLICY) — every prod bug becomes a QA gate before the fix ships. The QA suite only grows.


Build + boot + debug

Build: npm run build (electron-vite). Sizes: ~312 KB JS, ~66 KB CSS. Ceilings: 600 KB JS, 100 KB CSS.

Start: npm run start (electron-vite preview). Or via cmd.exe wrapper for env injection — PowerShell $env:VAR doesn't propagate through Start-Process reliably.

Env flags: - PAC_DEVTOOLS=1 — auto-open detached DevTools on HOME window. - PAC_OPEN_HOME=1 — auto-show HOME 600ms after bootstrap (skip the tray click step for test convenience).

Force-show after multi-monitor confusion. If the window ends up on a non-primary display or behind another foreground window, use Set-Process-Id <pid> enumeration + Win32 MoveWindow + SetForegroundWindow to relocate to (40,40) 1320x880 on primary. See scripts/move-pac-to-primary.ps1 if scripted.

Kill PAC's electron only. Use Stop-Process -Id <pid> with the specific PID from Get-Process electron | where Path -like "*personal-atlas-cockpit*". NEVER Stop-Process -Name electron — that kills Clawpilot's own electron + Copilot CLI processes too.


QA suite — current gates (24, all green at v0.2.1 cut)

node test-pac-qa.cjs (full mode includes build + typecheck) or node test-pac-qa.cjs --quick (static checks only, <1s).

Categories: - Rule #2 enforcement — no main-process auto-send anywhere (sendMail, chat:send from main). - Internal-first — no MSAL/Graph SDK deps, no auth.ts/graph.ts, no MSAL imports. - Env-scrub disciplineworkiq.ts + atlas-chat.ts + run-electron-vite.cjs all scrub ELECTRON_RUN_AS_NODE. - MCP handshake order — workiq.ts performs initializenotifications/initializedtools/list in correct order. - Single-flight queue — workiq.ts serializes asks. - ACP defaults — read-only tool allow-list, --acp + --effort + --add-dir flags wired. - Layout regression — 3 gates from 22 May fix (see above). - Zen-PAC chrome — no backdrop-filter, no glow, no gradient borders. - Voice grammar — Atlas voice uses var(--amber) not hardcoded hex; light-mode tokens present. - Signature strings — Atlas Teams relay signature intact (P3 prep).

Growing-guardrail rule applies. Every new bug → new gate before fix ships. Never delete gates.


Pick-up-here

Status at v0.2.2 cut (22 May 2026): - ✅ All 40 QA gates green (full suite incl. typecheck + build). - ✅ WorkIQ MCP wired end-to-end, real M365 data flowing. - ✅ Real signals on Home: Atlas voice composed from real meetings, Risk Radar from portfolio.json + recent signals, Pipeline pill from hot risks, TODO panel with CRUD. - ✅ Persistence layer (src/main/userdata.ts) — atomic writes (tmp → rename) for todo.json, portfolio.json, chat-history.json in app.getPath('userData'). - ✅ ACP eager init: pill goes READY at boot, not on first send. Chat history reloads on app open (last 100 messages). - ✅ Sidebar resize handle: 6px drag strip at left edge of expanded sidebar, clamped 280–600px, persists via localStorage['pac.sidebar.width'], writes --pac-sidebar-width CSS var. window-root.has-sidebar-expanded reads the var so HOME content adjusts in lockstep. - ⏳ End-to-end ACP fresh stream not visually verified in v0.2.2 ship session (UI automation in Electron is brittle — UIAutomation FindAll(Edit) returns 0; SendKeys click-and-type unreliable). Boot log + persisted history prove the wiring. Sush's first manual prompt is the live test.

v0.2.2 architecture additions: - Persistence pattern — All user data is flat JSON in app.getPath('userData') = C:\Users\ssutheesh\AppData\Roaming\personal-atlas-cockpit\. Three files: todo.json, portfolio.json, chat-history.json. Writes are atomic via writeJsonAtomic(file, value) → temp file → fs.rename() so an ungraceful shutdown can never half-write. No SQLite, no schema, no migrations — flat JSON is enough for one user at file sizes <100 KB. Move to SQLite only when a real query/index requirement appears. - Derived signalsloader.ts exposes deriveRisks(portfolio, meetings, emails, chats) (matches portfolio domains against incoming traffic + stage) and composeAtlasVoice(meetings) / composeDelta(risks, emails) (deterministic string composers — never call out to an LLM for these). Falls back to fixture strings if inputs are empty. - Resize handle contractAtlasChat.tsx owns width state. Width state simultaneously: (a) sets <aside style={width}>, (b) persists to localStorage['pac.sidebar.width'], (c) writes document.documentElement.style.setProperty('--pac-sidebar-width', '${w}px'). theme.css rule .window-root.has-sidebar-expanded { padding-right: var(--pac-sidebar-width, 320px) } keeps HOME in lockstep. Never inline-style HOME's padding — always go through the CSS var. - Chat history persistenceatlas-chat-hook.ts calls window.atlas.chatHistory.get() on mount to reload, and set() on a 500ms debounce after each chunk. Capped at last 100 messages (oldest dropped). Clear-all also clears the persisted store. - ACP eager initmain/index.ts calls atlasChat.init() once after IPC handlers are wired (no waiting for first chat:send). Session ID logged at boot. Gate #28 (main/index.ts triggers atlas-chat eager init) prevents regression.

Gotcha catalogue (v0.2.2 additions): - --no-banner invalid in copilot CLI ≥1.0.40. Killed the ACP subprocess with code 1 + "unknown option". Removed from atlas-chat.ts; gate #40 blocks re-introduction. - Stale-electron spawn loop. Killing electron PIDs while a pac-boot shellId is still alive causes electron-vite preview to respawn them. Always stop_powershell the shell session first, then PID-kill any leftovers. - UIAutomation can't see Electron renderer DOM. FindAll(ControlType.Edit) returns 0 elements — Chromium doesn't expose its widgets to MSAA in this build. For live UI automation use Playwright against a debug-port-enabled boot, or live-test manually. - Git not initialised yet. ~~PAC is on-disk only. Git repo creation is v0.5 scope (electron-updater + GitHub Releases). BUILD-LOG + session-journal are the audit trail for v0.2.x.~~ Resolved 22 May 2026 ~16:00 NZST. git init ran retroactively at C:\ssClawy\personal-atlas-cockpit\app\ (default branch main), baseline commit 3a26d5c captures full v0.2.2 ship state. .gitignore extended with *.tsbuildinfo. No remote yet — GitHub Releases + electron-updater stays v0.5 scope. Every phase from v0.3 onwards commits per pac-phases.md SOP step 11.

v0.2.2 verification update (22 May 2026, second pass): - ✅ ACP live-stream now verified end-to-end — both via headless scripts/smoke-acp.cjs (3 chunks, 96 chars, end_turn, Sonnet 4.6, ~7.7s) AND via live PAC sidebar (real BNZ-prep reply + model-check reply rendered with markdown). Atlas chat is fully live. - ✅ TODO CRUD verified — add (5→6) · toggle done (file write confirmed) · delete (6→5) all hit disk at %APPDATA%\personal-atlas-cockpit\todo.json via atomic write. Surface returns/expects plain TodoItem[] (NOT {items:[]}). - ✅ Sidebar resize bounds verified — 100→280 · 320→320 · 450→450 · 800→600 · bogus→320 · null→320. Clamping correct in both directions. - ✅ Chat history round-trip verified — all 3 messages restore across PAC restart (mixed role:'atlas' + role:'assistant' payload). Screenshot pac-v022-home-history-loaded.png. - 🐞 Chat-history role contract bug found + fixed. atlas-chat-hook.ts writer persists role:'atlas' (canonical per preload/index.ts + main/userdata.ts type defs: 'user' | 'atlas') but reader was filtering 'user' || 'assistant' only — assistant turns silently dropped on reload. Fix: reader now accepts 'user' OR 'assistant' OR 'atlas', normalizes non-'user''assistant' for in-memory state. Backwards-compatible with any pre-fix file.

v0.2.2 gotcha catalogue (additions from second pass): - Windows DPR=1.5 + native screenshots. Sush's display is 150% scaled. Win32 GetWindowRect returns virtualized CSS pixels (e.g., 1640×940) but the actual framebuffer is physical (2460×1410). Native CopyFromScreen(width=1640) only captures the top-left third of an Electron window. Use Chrome DevTools Protocol Page.captureScreenshot instead — it captures via the renderer (handles DPR natively) and works regardless of OS scaling. - CDP hook for PAC QA. Set env var PAC_REMOTE_DEBUG=<port> to enable chrome://inspect-style debugging. main/index.ts checks at startup and calls app.commandLine.appendSwitch('remote-debugging-port', port). List tabs at http://127.0.0.1:<port>/json; HOME tab url ends #home, popover #popover. Reusable WS runners live at ~/.copilot/session-state/dd7ae685.../files/cdp-eval.js (Runtime.evaluate) and cdp-shot.js (Page.captureScreenshot). Hook is harmless when env unset; gated, not removed. - Three TodoItem type defs must stay syncedsrc/preload/index.ts:49, src/main/userdata.ts:19, src/renderer/src/data/types.ts:125. All three now include done?: boolean. Consolidate to a shared types file in v0.3. - React event-delegation gotcha. Synthetic native mousedown on .ac-resize-handle doesn't trigger React's onMouseDown (delegation root mismatch). For programmatic chat input use the native value setter + input event + form submit — that path works.

v0.3 candidates (next phase): 1. Wire pulse to real WorkIQ signal stream (recent file edits + chat messages + email arrivals). 2. Wire unattendedMeetings from meetings query (where attendees.length > 1 AND status === 'done' AND no reply sent). 3. Wire repos from git log + GitHub MCP (5 most-recent Sush-touched repos with last-commit message). 4. Wire captureQueue from ~/.copilot/capture.md (one-line idea inbox). 5. Windows autostart via app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true }) (start to tray, not foreground). 6. New QA gates: each wired surface = 1 gate, autostart = 1 gate.

Cross-refs: - learning-docs/docs/reference/msft-gold-explore-queue.md — internal-first install order. - learning-docs/docs/reference/deployment-playbook.md — universal pre-push checklist. - learning-docs/docs/reference/parallel-git-rules.md — never git add ., always explicit paths. - C:\ssClawy\personal-atlas-cockpit\app\BUILD-LOG.md — turn-by-turn build history. - C:\Users\ssutheesh\.copilot\pac-phases.md — phase queue with strict ordering.


Added 22 May 2026 by Atlas (Claude Opus 4.7 xhigh) during v0.2.1 ship. Last revision: 22 May 2026 PM (v0.2.2 second pass — ACP live verified, TODO CRUD verified, resize bounds verified, chat-history role contract bug fixed + history round-trip verified, CDP-for-screenshots pattern + DPR pitfall + 3-TodoItem-defs gotcha logged).


v0.3 — Four real surfaces + Windows autostart + chrome/tray/data-load fixes (22 May 2026 PM)

Shipped as one session, ~3hr. v0.3 scope (4 surfaces + autostart) shipped clean; 5 user-surfaced bugs absorbed in-session with permanent QA gates per growing-guardrail rule. QA 40 → 56 full / 51 quick.

Real-surface wiring pattern

Three signal sources now feed the dashboard: 1. WorkIQ MCP for M365 signals (meetings, emails, Teams, files) — slow (~30-70s per query), serialised through one binary. 2. Local IPC for filesystem signals (main/repos.ts for git scan, main/capture.ts for ~/.copilot/capture.md) — fast (<200ms). 3. Userdata JSON for persisted state (todos, portfolio, chat history) — instant.

Each source has its own fetcher in loader.ts. tryFetchRealSurfaces orchestrates a Promise.all over all of them, then merges into the fixture base.

Critical pattern — two-stage render with onUpdate callback

Bug class: WorkIQ serial latency stalls the entire dashboard. Original getDashboardData() awaited Promise.all of 5 WorkIQ queries before resolving. Measured 66s + 46s + 60s + 26s = ~3+ min on cold boot. setData never fired → {data && ...} rendered empty body.

Solution: getDashboardData(force, onUpdate?) signature with two-stage render: - Stage 1 (sync-fast, ~50ms): loadFixtureBase() returns fixture + persisted todos. Caller awaits, setData(fixture) fires immediately. - Stage 2 (background): tryFetchRealSurfaces() runs in void .then() chain. When real data lands, calls onUpdate(enriched) so caller can setData(enriched) again.

` s // loader.ts export async function getDashboardData( force = false, onUpdate?: (data: DashboardData) => void ): Promise { const fixtureData = await loadFixtureBase() cache = { at: now, data: fixtureData }

if (onUpdate && window.atlas?.workiq) { void tryFetchRealSurfaces().then((real) => { if (Object.keys(real).length === 0) return const enriched = { ...fixtureData, ...real } cache = { at: Date.now(), data: enriched } onUpdate(enriched) }).catch(() => {}) }

return fixtureData }

// Home.tsx getDashboardData(false, (enriched) => { if (alive) setData(enriched) }) .then(d => { if (alive) setData(d) }) `

Per-surface timeout: withTimeout<T>(p, ms, fallback) caps each WorkIQ fetch at 20s. One slow query can't hold up the whole merge.

Why this matters generally: Any slow data source (network, MCP, large query) should follow this pattern. Never make the user stare at "LAST SYNC --:--". Fixture-first is correct UX.

QA gates: withTimeout exists · every WorkIQ fetch wrapped · getDashboardData accepts onUpdate · Home.tsx passes callback in both initial + workiq-online-refresh paths.

Taskbar identity pattern (Windows)

Windows binds taskbar identity from AppUserModelId. If not set, the OS shell binds late and apps group under "Electron" — taskbar shows the electron-vite log title instead of the app.

Critical ordering: app.setAppUserModelId('com.atlas.cockpit') MUST be called BEFORE app.whenReady(). Calling it after has no effect — the shell has already bound.

Three required pieces: 1. app.setAppUserModelId('com.atlas.cockpit') (line 4 of main/index.ts, before whenReady) 2. title: 'Atlas Cockpit' in BrowserWindow constructor (both main + popover) 3. icon: resolveIcon() in BrowserWindow constructor (custom 256px .ico)

resolveIcon() pattern — multi-candidate fallback handles dev/preview/packaged paths: s function resolveIcon(): NativeImage | undefined { const candidates = [ join(app.getAppPath(), 'build', 'atlas.ico'), join(app.getAppPath(), 'resources', 'icon.ico'), join(process.resourcesPath, 'atlas.ico') ] for (const p of candidates) { if (!existsSync(p)) continue const img = nativeImage.createFromPath(p) if (!img.isEmpty()) return img } return undefined } // then: { ...(icon ? { icon } : {}) } — conditional spread avoids undefined

QA gates: setAppUserModelId set before whenReady · BrowserWindow has title + icon · resolveIcon defined in both window builders.

Tray icon resolution pattern

Bug class: tray.ts hard-coded one path; if asset missing, embedded base64 fallback decoded to near-invisible blob → tray icon "exists" but is transparent.

Pattern: 4-candidate fallback chain via app.getAppPath() + existsSync + isEmpty() validation + force-resize to 16×16 (Windows tray's expected pixel size).

s function buildIcon(): NativeImage { const candidates = [ join(app.getAppPath(), 'resources', 'tray-icon@2x.png'), // hi-DPI first join(app.getAppPath(), 'resources', 'tray-icon.png'), join(app.getAppPath(), 'resources', 'icon.ico'), join(app.getAppPath(), 'build', 'atlas.ico') ] for (const p of candidates) { if (!existsSync(p)) continue const img = nativeImage.createFromPath(p) if (img.isEmpty()) continue return img.resize({ width: 16, height: 16 }) } // last resort: amber-A PNG fallback (not transparent) return nativeImage.createFromBuffer(Buffer.from(FALLBACK_PNG_BASE64, 'base64')) }

QA gate locks the asset — not just the code path: 1. File exists at resources/tray-icon.png OR tray-icon@2x.png 2. PNG magic bytes valid (89 50 4E 47 0D 0A 1A 0A) — guards against corrupted writes 3. File size ≥100 bytes — catches near-empty/transparent PNGs (the original bug's fallback was a 50-byte decode) 4. tray.ts references the expected fallback chain in code

Win11 default-hidden tray gotcha: new tray icons are hidden in the overflow flyout until user pins. Document this in any onboarding — code is correct but icon "appears missing".

Dev-mode launcher infrastructure (v0.3-v0.4 era, pre-packaging)

Until v0.5 ships the MSI, we need a clean way for Sush to launch the dev/preview build from Desktop or taskbar without seeing a console window flash.

Pattern: .cmd (sets envs + runs node script) wrapped in .vbs (hidden launcher) wrapped in .lnk (Desktop shortcut with custom icon).

cmd REM launch-atlas-cockpit.cmd @echo off cd /d "C:\ssClawy\personal-atlas-cockpit\app" set PAC_OPEN_HOME=1 set PAC_DISABLE_AUTOSTART=1 node scripts/run-electron-vite.cjs preview

bs ' launch-atlas-cockpit.vbs — hidden launcher (no console flash) Set WshShell = CreateObject("WScript.Shell") WshShell.Run """C:\ssClawy\personal-atlas-cockpit\app\launch-atlas-cockpit.cmd""", 0, False

`powershell

create-shortcut.ps1

= New-Object -ComObject WScript.Shell = .CreateShortcut("C:\Users\ssutheesh\OneDrive - Microsoft\Desktop\Atlas Cockpit.lnk") .TargetPath = "wscript.exe" .Arguments = """C:\ssClawy\personal-atlas-cockpit\app\launch-atlas-cockpit.vbs""" .IconLocation = "C:\ssClawy\personal-atlas-cockpit\app\build\atlas.ico" .Save() `

Win11 taskbar pinning is non-programmatic — must right-click the running app → Pin to taskbar. Best automation can do is create Desktop + Start Menu .lnks.

Multi-monitor cursor-positioning trap

PAC main window uses screen.getCursorScreenPoint() to position itself near the cursor (intended UX: pop up where you're looking). On a 3-monitor setup, the window often landed on the far monitor — Sush couldn't see it.

Current workaround (v0.3): PowerShell SetWindowPos at boot via launcher script forces primary monitor (60,40 size 1500x1000).

Proper fix (v0.4 scope): screen.getDisplayMatching(cursorBounds) + clamp inside main-window.ts. Ensure x + width <= display.bounds.right and y + height <= display.bounds.bottom AND not negative.

v0.3 file inventory

Created: - src/main/repos.ts (~190 lines) — git scanner + gh wrapper - src/main/capture.ts (~85 lines) — capture.md reader - build/atlas.ico, resources/icon.ico, resources/tray-icon{,@1x,@2x}.png — icon assets - launch-atlas-cockpit.{cmd,vbs} — dev-mode launcher

Modified: - src/main/index.ts — AppUserModelId + 4 v0.3 IPC handlers + 3 chrome IPC handlers + Windows autostart - src/main/main-window.ts, popover-window.tsresolveIcon() + title + icon - src/main/tray.ts — full rewrite (4-candidate fallback) - src/preload/index.tsatlas.repos, atlas.capture, atlas.win namespaces - src/renderer/src/data/loader.ts — 4 new fetchers + derivePulse + deriveUnattended + withTimeout + two-stage getDashboardData(force, onUpdate) + loadFixtureBase - src/renderer/src/home/Home.tsx — passes onUpdate callback in both paths - src/renderer/src/home/components/Chrome.tsx — TitleBar traffic-lights wired - test-pac-qa.cjs — +10 new gates (5 chrome + 1 tray + 4 data-load)

v0.3 lessons

  • Growing-guardrail rule applied 5× in one session. Every Sush-reported bug became a permanent QA gate BEFORE the fix shipped. Suite never shrinks.
  • WorkIQ MCP is serial — assume one query at a time, expect 30-70s each. Design loaders for this constraint, not against it.
  • Fixture-first is correct UX. Never block the dashboard waiting for real data. Stage 1 fixture + Stage 2 callback merge.
  • Win11 default-hides new tray icons. Document for onboarding.
  • AppUserModelId ordering matters. Set before whenReady or shell binds late.
  • Multi-monitor + cursor-positioning is fragile. Always clamp window position to display bounds.

v0.4 candidates (next phase)

  1. Popover wired to loader.ts via same onUpdate callback (popover currently fixture-only — intentional v0.3 deferral)
  2. Hourly heartbeat (60min) composes hour-summary → taskbar pulse if urgent → opt-in Teams self-ping (Rule #2 enforced — default off)
  3. Heartbeat log to ~/.copilot/atlas-heartbeat.log
  4. Multi-monitor window-position clamp in main-window.ts
  5. 3 new QA gates: popover wired to loader · heartbeat respects Rule #2 · heartbeat skips DND

Updated 22 May 2026 PM by Atlas (Claude Opus 4.7 xhigh) during v0.3 ship — added 4 real surfaces + Windows autostart + 5 bug fixes with permanent QA gates (chrome/tray/data-load patterns documented).

v0.4 — Popover real data + hourly Atlas heartbeat (shipped 22 May 2026)

Popover two-stage parity

Popover.tsx useEffect now mirrors Home.tsx exactly:

invalidateCache();
getDashboardData(false, (enriched) => setData(enriched));
const off = workiq.onStatusChange((status) => {
  if (status === 'online') {
    invalidateCache();
    getDashboardData(false, (enriched) => setData(enriched));
  }
});
return () => off();

Was single-stage getDashboardData() before — popover only ever rendered fixture, never received WorkIQ enrichment. Now both Home + popover share identical loader behaviour. Visual contract unchanged (existing DashboardData shape).

Heartbeat module — src/main/heartbeat.ts

Public API: - startHeartbeat(mainWindow) — schedules setInterval(HEARTBEAT_INTERVAL_MS) after setTimeout(FIRST_TICK_DEFER_MS) (boot-storm avoidance: first tick at +60 min, not at boot). - stopHeartbeat() — clears both timers. Called in before-quit. - runHeartbeatNow() — fires one tick immediately (IPC heartbeat:runNow, debug-only). - getHeartbeatStatus(){ running, lastTickISO, lastRecord, nextEtaISO }. - getHeartbeatLogPath()os.homedir()/.copilot/atlas-heartbeat.log.

Constants (QA-locked): - HEARTBEAT_INTERVAL_MS = 60 * 60_000 — 60 min floor. - FIRST_TICK_DEFER_MS = HEARTBEAT_INTERVAL_MS — no fire-at-boot. - DND_PRESENCE_TOKENS = ['donotdisturb','presenting','inameeting','inacall','busy'] — case-folded substring match on WorkIQ presence response. - LOG_MAX_BYTES = 10 * 1024 * 1024 — rotates to .bak.<ts> when exceeded.

Tick flow: 1. collectSignals() — parallel WorkIQ pulls: fetchPresence, fetchUnreadEmailCount, fetchMeetingsStartingSoon, fetchHotRiskCustomers. Each prompt is JSON-only with tolerant extractFirstJson<T> regex parser. 2. composeSummary(signals, prev) — deterministic plain-English string. Compares against previousHotRisks Set + previousUnreadCount to surface "movement" (new urgent emails, freshly-flagged risks). 3. JSONL append to log: { tsISO, summary, urgent, signalCounts, action }. One record per tick. 4. DND guard: if presence matches any DND_PRESENCE_TOKENS → skip pulse + send, log action: 'skipped_dnd'. 5. Urgent + main window unfocused → mainWindow.flashFrame(true) taskbar pulse. 6. maybeSendTeamsSelfPing() — checks optInTeamsSelfPing pref + urgency + DND. Currently ALWAYS returns 'noop' — no live send path until v0.5/v0.6 wires Teams send through MSX or WorkIQ. The Rule #3 signature constant ATLAS_SIGNATURE_TEAMS = "↳ Atlas here (Sush's AI co-founder) — Sush didn't draft this one. Ping back as normal." is in the source + QA-gated, so the disclosure footer is locked the moment someone wires the real send.

JSONL log format

Each line at ~/.copilot/atlas-heartbeat.log:

{"tsISO":"2026-05-22T17:45:00.000+12:00","summary":"+3 unread emails since 16:45 (1 urgent · Yahya/BNZ). 1 hot risk movement: ASB Q2 commit slipped to next week.","urgent":true,"signalCounts":{"unread":3,"meetings":0,"hotRisks":1},"action":"flashed"}

action values: flashed · pulsed_only · noop · skipped_dnd · error.

Rule #2 + Rule #3 enforcement

  • Rule #2: DEFAULT_HEARTBEAT_PREFS.optInTeamsSelfPing = false — QA gate v0.4-rule2-default-off enforces.
  • Rule #2: zero sendChatMessage/sendTeamsMessage/send_chat_message literal occurrences in heartbeat.ts excluding comments — QA gate v0.4-rule2-no-live-send enforces line-by-line.
  • Rule #3: literal strings "Atlas here (Sush's AI co-founder)" + "Sush didn't draft this one" both present in source — QA gate v0.4-rule3-signature enforces.

IPC surface (preload)

atlas.heartbeat = {
  getStatus(): Promise<HeartbeatStatus>;
  logPath(): Promise<string>;
  runNow(): Promise<HeartbeatRecord | { action: 'error', error: string }>;
  prefs: {
    get(): Promise<HeartbeatPrefs>;
    set(patch: Partial<HeartbeatPrefs>): Promise<HeartbeatPrefs>;
  };
};

Boot/shutdown wiring in main/index.ts

  • startHeartbeat(mainWindow) runs at end of bootstrap, gated by process.env.PAC_DISABLE_HEARTBEAT !== '1'. The env-gate is the smoke-test escape hatch (used for v0.4 QA boots).
  • stopHeartbeat() in app.on('before-quit') handler.

QA gates (11 new in v0.4)

Gate Catches
popover-uses-twostage-callback regression to single-stage getDashboardData() in Popover.tsx
popover-subscribes-workiq missing workiq.onStatusChange subscription in popover
heartbeat-module-exists someone deletes src/main/heartbeat.ts
heartbeat-exports-public-api renamed/removed public functions
heartbeat-interval-floor-60min accidental sub-hour interval (e.g. test value left in)
heartbeat-first-tick-deferred someone fires tick() at boot instead of via setTimeout
heartbeat-jsonl-log-path log path drifts from ~/.copilot/atlas-heartbeat.log
heartbeat-dnd-guard DND token check removed
v0.4-rule2-default-off optInTeamsSelfPing default flipped to true
v0.4-rule2-no-live-send live send* call added without bypass
v0.4-rule3-signature signature footer literal removed
main-wires-heartbeat bootstrap stops calling startHeartbeat
version-min-0.4 version regressed below 0.4.0

Honest gaps (v0.4)

  • No live JSONL write screenshot — interval floor QA-locked at 60 min; live tick would require source patching. Ship gate-only coverage per v0.2.2 precedent.
  • Teams self-ping path is a no-op stub. Wire-up deferred to v0.5/v0.6.
  • No live render-process attach to verify window.atlas.heartbeat.runNow() round-trips end-to-end. IPC handlers + preload types both QA-gated.

v0.4 section added 22 May 2026 by Atlas (Claude Opus 4.7 xhigh) during PAC v0.4 ship — popover real data parity + hourly Atlas heartbeat with Rule #2/#3 enforcement.


v0.4.1 + v0.4.2 — disk-persisted real cache + zero-mock-data contract (22 May 2026 PM)

The directive (load-bearing)

"tbh i dont ever want to see mock data anymore — either you wipe it clean and only show cached real data from now onwards or dont show anything going forward." — Sush, 22 May 2026

This is now an architectural constraint, not a preference. Mock data is banned in user-visible rendering paths. Every future feature that touches dashboard rendering must comply.

Three sources of mock data (only one was obvious)

  1. Bundled JSON fixture (fixtures/dashboard.json) — the obvious one. Deleted in v0.4.1.
  2. Seeded defaults in userdata.tsDEFAULT_TODOS (5 items) + DEFAULT_PORTFOLIO.customers (4 fake banks) returned by readJson(file, fallback) on first launch when no userdata JSON exists. Killed in v0.4.2 — both now empty arrays.
  3. Hardcoded JSX copy — literal strings inside component bodies that bypass the data layer entirely ("BNZ security lead", "Tonight 18:47 · Yahya decision", "Atlas can draft 2 prep packs — ASB ANZ replatform read + Spark pricing deck"). Most insidious because they survive even after data sources are clean. Killed in v0.4.2.

The 3-stage loader contract (v0.4.1)

getDashboardData(force?, onUpdate?) →
  Stage 0 — disk read    : await atlas.cache.get() → { lastSyncISO, data } | null
  Stage 1 — paint        : resolve immediately with disk cache OR emptyDashboard()
  Stage 2 — enrich+save  : background tryFetchRealSurfaces()
                            → onUpdate(enriched)
                            → atlas.cache.save(enriched) — best-effort, non-blocking

Subsequent cold boots render real data INSTANTLY because the disk cache holds the last successful enrichment. The fixture-rendering window (30–180s on every cold boot in v0.3/v0.4) is gone — replaced by either real cached data or an honest "ATLAS IS FETCHING" banner.

Disk cache file

  • Path: ~/.copilot/pac-cache.json (not %APPDATA%/AtlasCockpit/) — keeps it co-located with Atlas tooling.
  • Shape: { lastSyncISO: string, data: DashboardData }
  • Write strategy: atomic tmp+rename (${file}.tmp.${pid}.${now} → rename). Survives ungraceful shutdown. Cleanup on rename failure.
  • Read strategy: tolerate missing/corrupt — never throw, never block boot. Renderer falls back to emptyDashboard().

Empty-state UX (dual mode)

  • First launch (getLastSyncISO() == null): banner reads "~ ATLAS IS FETCHING · FIRST LAUNCH" with 30–180s explanation. StatusBar: LAST SYNC NEVER · FIRST LAUNCH.
  • Synced but empty (getLastSyncISO() != null AND isEmptyState()): banner reads "~ NO SIGNALS RIGHT NOW · workiq answered — there's nothing on the page today". StatusBar: LAST SYNC FRI, 22 MAY 17:17 (formatted NZ).
  • Both states footer: "no mock data will ever be shown. — sush directive, 22 may 2026."

Permanent QA enforcement (v0.4.2 — institutional memory)

A line-by-line scanner walks every .ts/.tsx under src/ and bans 13 literal mock copy strings ("BNZ security lead", "Yahya reply asking for the security team", "Tonight 18:47 · Yahya decision", "Atlas can draft 2 prep packs", etc.). Line comments are stripped before scan so rule documentation can mention banned strings. New mock copy never ships because the gate blocks the commit.

What this means for future phases

Every new dashboard surface MUST: 1. Provide an EMPTY_* shape that compiles against its type (non-nullable fields default to safe empty values). 2. Skip rendering decorative copy unless real data backs it. 3. Source all per-customer logic from portfolio.json (or real M365-derived signals), never from if (customer === 'BNZ') branches. 4. Persist via atlas.cache.save(...) if it's part of dashboard data.

The QA gate no hardcoded mock copy strings anywhere in src is forever — any new violation will fail CI before merge.


v0.5 — installer + electron-updater + MSX MCP (22 May 2026)

Three substreams, two locked dormant by design. Pattern documented for v0.6+.

5a · electron-builder NSIS installer

Config (package.json build block): - appId: "com.atlas.cockpit", productName: "Atlas Cockpit" - asar: true (perf + light tamper-resistance) - files: ["out/**/*", "package.json", "resources/**/*"] (electron-vite outputs to out/) - win.target: [{ target: "nsis", arch: ["x64"] }] (electron-builder defaults to host arch if invoked without --x64/--arm64 flag — be explicit when cross-building) - nsis: { oneClick: true, perMachine: false, allowToChangeInstallationDirectory: false } - directories.buildResources: "build-resources" (icon.ico lives here; resources/ ships in asar via files glob) - publish: { provider: "github", owner: "ssutheesh", repo: "personal-atlas-cockpit", private: true, publishAutoUpdate: false }

Env-scrub wrapper (scripts/run-electron-builder.cjs): same pattern as run-electron-vite.cjs. Deletes ELECTRON_RUN_AS_NODE, ELECTRON_NO_ASAR, ELECTRON_NO_ATTACH_CONSOLE before spawning the builder. Without this, electron-builder's internal electron spawn for native-module rebuild boots as plain Node and crashes (Sush's machine has ELECTRON_RUN_AS_NODE=1 at User scope).

Build commands: - npm run pack → unpacked dir (debug, no installer) - npm run dist → full installer to dist-installer/

Artifact: ~85 MB unsigned NSIS exe. Signing TBD (signtool with self-signed cert is fine for personal use; production needs a real codesigning cert).

5b · electron-updater (Rule #2 default-locked)

Dormancy guard (src/main/updater.ts): \\\ s const enabled = (app.isPackaged || process.env.PAC_ENABLE_UPDATER === '1') && process.env.PAC_DISABLE_UPDATER !== '1'\\ In dev → dormant. In QA → dormant (PAC_DISABLE_UPDATER=1 set by test runner). In production packaged → live. Always overridable both ways.

Rule #2 enforcement — two layers: 1. Code: autoDownload = false, autoInstallOnAppQuit = false, no scheduler around applyUpdateAndRestart. User must click RESTART NOW in UpdateBanner. 2. QA gates: regex-banned setTimeout/setInterval near applyUpdateAndRestart. Required: isPackaged guard, env guards, autoDownload = false, autoInstallOnAppQuit = false.

Lifecycle: - initUpdater({ broadcast }) in app.whenReady — wires events, schedules first check after 5min boot delay, 24h recurring interval. - stopUpdater() in before-quit — clears intervals, removes listeners. - 4 IPCs: updater:check, updater:download, updater:applyRestart, updater:getStatus (+ updater:status-change broadcast event). - UpdateBanner.tsx renders nothing when status.enabled === false. Three button states: DOWNLOAD (available) · RESTART NOW (downloaded) · LATER (always dismissable).

Lazy require of electron-updater — wrapped in try/require because winreg/builder-util are heavy and pointless in dev where the updater is dormant anyway.

5c · MSX MCP subprocess (graceful degrade)

Mirrors workiq.ts exactly — same env-scrub, same JSON-RPC enqueue serialisation, same handshake order: 1. initialize (capabilities, protocol version) 2. notifications/initialized 3. tools/list (cached) 4. tools/call (queued; stdio is single-stream)

Discovery order: 1. process.env.MSX_MCP_PATH (explicit override) 2. %APPDATA%/npm/msx-mcp.cmd 3. %APPDATA%/npm/msx-se-mcp.cmd

If none exist, isInstalled() returns false and the subprocess is never spawned (main/index.ts gates msx.init() on this).

Read-only enforcement (pickPipelineTool): tool name must match BOTH regexes: - Verb: /^(read|list|get|query|search|fetch|find)/i - Subject: /(pipeline|opportunit|opps|deals)/i

A tool like createOpportunity or updateDeal is structurally unpickable. Locked by QA gate that scans the regex literals.

Graceful degrade contract: every error path in getPipeline() returns []. Never throws. Loader guard: if (Array.isArray(msxPipeline) && msxPipeline.length > 0) merged.pipeline = msxPipeline — empty MSX response does NOT overwrite. composeDelta derived fallback always runs so the titlebar pill always renders.

Pattern: dormant-by-default for new background services

Heartbeat (v0.4) and updater (v0.5) both follow this pattern: - Produce the full surface area (subprocess, IPCs, renderer banner). - Lock runtime behaviour off behind a guard (env flag or app.isPackaged). - Document the enable path in the journal entry + this doc. - QA gates lock the dormancy regex.

Why: lets new infrastructure land without changing user-visible behaviour. Activation becomes a small follow-up phase, not a coupled mega-ship.

Honest gaps still open after v0.5

  • No GitHub remote for PAC → first real updater check-for-update not yet exercised end-to-end. Code path covered by QA gates + boot log; functional verification deferred.
  • MSX MCP binary not installed locally → real pipeline data not yet rendered. Will auto-populate on install with zero code change.
  • Installer built arm64-only this session (host arch). x64 retry = one flag.
  • NSIS only, no MSI. Acceptable for personal-use installer.

Files (v0.5)

  • scripts/run-electron-builder.cjs (NEW · env-scrub wrapper)
  • src/main/updater.ts (NEW · ~280 LOC)
  • src/main/msx.ts (NEW · ~360 LOC)
  • src/renderer/src/shared/UpdateBanner.tsx (NEW · ~4 KB)
  • build-resources/icon.ico (NEW · copied)
  • package.json (version 0.4.2 → 0.5.0, +dist/pack scripts, +full build block)
  • src/preload/index.ts (+3 type exports, +updater + msx APIs)
  • src/main/index.ts (+imports, +6 IPCs, +init/destroy)
  • src/renderer/src/data/loader.ts (+msxPipelineP in parallel fetch, +array-length guard)
  • src/renderer/src/home/Home.tsx + popover/Popover.tsx (+UpdateBanner mount)
  • test-pac-qa.cjs (+25 v0.5 gates → 113/113 green)

Deps added (v0.5)

  • electron-builder@26.8.1 (devDep)
  • electron-updater@6.8.3 (dep)

Bundle (v0.5)

  • JS 334.16 KB / CSS 67.72 KB (ceiling 600/100 — very comfortable)