A running log of "this wasted time, don't do it again" moments.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~1 hour
The MutationObserver injection script used document.querySelector(SELECTORS.messageListContainer) where messageListContainer was a comma-joined list of 13 CSS selectors. The browser returns the first element in DOM order that matches ANY selector in the list — this is not the same as the first selector that matches.
[role="list"][aria-label*="chat" i] was intended to match the message thread, but it matched <UL aria-label="Chat participants" role="list"> (the participants popup/menu) which appears earlier in the DOM. The observer watched the wrong element and no message mutations ever fired.
Never use document.querySelector(joined) for priority-ordered fallback selectors. Add a messageListContainerList array to TEAMS_SELECTORS and iterate it in the injection script with a for loop, stopping at the first match. This guarantees priority order is respected regardless of DOM element position.
When a selector has fallback tiers, iterate them in JS, one call each. Do not join and use querySelector / querySelectorAll with a comma-separated string if order matters. The browser's selector engine does not honour your list order — it honours document order.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~30 minutes
Diagnostic console.log() calls added inside the injection script string (passed to webContents.executeJavaScript()) printed to the Teams page's DevTools console, not the main process stdout. The logs appeared to produce nothing, making debugging very confusing.
Add webContents.on('console-message', (_e, level, message) => { ... }) in the monitor's start() method. Filter for your prefix ([HiveAgent]) and pipe to console.log() in the main process. This bridges the renderer's console to the terminal.
Any console.log inside a string passed to executeJavaScript runs in the renderer/web context. It is invisible to the main process unless you wire up console-message event forwarding.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~20 minutes (debugging false "not found" retries)
The Teams SPA loads at teams.microsoft.com and renders the shell (title bar, nav, search). The [role="log"] message container only exists in the DOM once the user has navigated to a chat or channel. The first did-finish-load fires before any chat is open, so all injection retries fail.
The did-navigate-in-page handler fires when the user opens a chat. This resets observerInjected and schedules a 600ms retry. By that point the chat view is rendering and [role="log"] is in the DOM. Injection succeeds there.
The "failed" retries on page load are expected and harmless. They should not trigger alerts.
| Role | Confirmed selector |
|---|---|
| Message list container | [role="log"] (ARIA live region, no tid/label/class) |
| Message item | [data-tid="chat-pane-message"] |
| Message body | .fui-ChatMessage__body |
| Sender name | [data-tid="message-author-name"] (also .fui-ChatMessage__author) |
| Timestamp | time[datetime] |
[CIRCULAR IMPORTS] SidebarScanner.forPlatform() with static subclass imports causes "before initialization" crash
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~20 minutes
Adding static imports of the four scanner subclasses (TeamsSidebarScanner, etc.) directly into sidebar-scanner.ts (the abstract base class they extend) created a module circular dependency. Vite bundles the graph statically, resulting in ReferenceError: Cannot access 'SidebarScanner' before initialization at app startup.
The original dynamic require('./scanners') pattern was there to break this cycle at load time, but Vite's static bundler can't handle dynamic requires.
Created scanner-factory.ts — a separate module that imports all four subclasses and exports createScannerForPlatform(platform). It imports SidebarScanner as a type only (no runtime circular reference). sentinel-instance.ts uses the factory. sidebar-scanner.ts is a clean abstract base class with no subclass references.
Never import subclasses into their base class. If you need a factory/registry pattern, put it in a sibling module that imports both. Use import type for type-only references to break cycles.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~15 minutes
The Sentinel's did-navigate handler checked every navigation URL against LOGIN_REDIRECT_PATTERNS. The Microsoft SSO handshake always routes through login.microsoftonline.com briefly even for already-authenticated sessions. The handler fired auth-expired immediately on startup, stopping the Sentinel before it could scan.
Added authCheckEnabled = false flag. A 20-second setTimeout sets it to true after startup, allowing the SSO handshake to complete. Only navigations after that grace period trigger auth-expired.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~5 minutes
When legacy monitor files were moved to src/main/browser/monitors/_legacy/ (SA.07), their relative imports (./base-monitor, ./teams-selectors, etc.) still pointed to the parent directory. TypeScript reported errors because the files were in a subdirectory but the imports weren't updated.
Add "src/main/browser/monitors/_legacy" to the exclude array in tsconfig.json. Legacy/archival code should never be compiled. This is the correct pattern for any _legacy/ or _archive/ directory.
Any directory prefixed with _legacy or _archive must be added to tsconfig.json's exclude list immediately when created.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) Time lost: ~5 minutes
In action-orchestrator.ts, inside the case 'drafted' block, the code checked if (action.status === 'sent') after calling executeSend(). TypeScript correctly flagged this as an impossible comparison — action.status is narrowed to 'drafted' by the switch case, and 'sent' is incompatible.
Return a boolean from executeSend() and check the return value, not the action's status property. The action object parameter doesn't mutate when queue.updateStatus() is called — you have to capture the result explicitly.
Never check action.status in a switch/case block after the case has already narrowed the type. Use return values from async operations to propagate success/failure.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2)
Modern web apps (Teams CKEditor, Slack Quill, Gmail, Outlook) all use contenteditable div elements, not standard <input> or <textarea>. Three techniques were considered:
webContents.sendInputEvent()— unreliable, doesn't trigger framework event listeners correctlyelement.value = text— only works on standard inputs, silently fails on contenteditabledocument.execCommand('insertText', false, text)— works correctly on all contenteditable implementations
Always use document.execCommand('insertText', false, text) via executeJavaScript when injecting text into compose boxes in web apps. Never use sendInputEvent for typing. Never assign .value on contenteditable elements.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2)
When designing action-orchestrator.ts, there was a temptation to fall back from API sending to browser DOM sending if the API call failed. This would create confusion about what actually happened — the user might think the message was sent when it wasn't, or vice versa.
If hasApiSendCapability() returns true and sendViaApi() fails, report failure only. Never fall back from API sending to browser DOM sending. If you're going to mix methods, you can't trust either audit trail. Pick one and stick to it per action.
Date: 2026-04-01
Sprint: 29
Time lost: Significant — the app rendered completely unstyled for multiple sessions
The initial commit (e89c3a5) scaffolded the Electron + Svelte renderer with pages that used Tailwind utility classes (Approvals, Rules, Platforms, etc.). However, src/renderer/app.css — the file that contains the three critical Tailwind directives — was never created or committed.
main.ts imports it on line 1:
import './app.css';Without the file existing and containing:
@tailwind base;
@tailwind components;
@tailwind utilities;Vite's PostCSS pipeline never runs Tailwind at all. Every className="bg-slate-800 text-white flex …" in every component silently does nothing. The app renders with zero layout, zero colour, zero spacing.
Dashboard.svelte appeared styled because it uses scoped <style> CSS (not Tailwind), which is why that page looked fine while everything else was broken.
Create src/renderer/app.css with the Tailwind entrypoint + global dark-mode resets:
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Dark-mode reset */
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
background-color: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}The .clinerules now explicitly documents:
Dashboard.svelteuses scoped<style>CSS — do not use Tailwind there- All other pages (Approvals, Rules, new pages) use Tailwind utility classes
- Does the new page use Tailwind classes? →
app.cssmust exist and be imported inmain.ts - Is
app.cssin git? Rungit status src/renderer/app.cssbefore assuming Tailwind works - When Tailwind classes appear to do nothing: check
app.cssexists first, before debugging component logic
Cline scaffolded the renderer pages with Tailwind classes but did not create the CSS entrypoint file. Because main.ts imports a non-existent file, Vite silently skips it in dev mode (no hard error), making the failure invisible until the app is visually inspected.
Always create app.css as part of any Tailwind project scaffold before writing a single utility class.
Every IPC channel exposed in preload.ts must have a matching type in renderer/env.d.ts. Forgetting one causes silent failures where window.agent.foo exists at runtime but TypeScript doesn't see it, or vice versa. Treat them as a single file that happens to live in two places.
The Dashboard page was built with <style> scoped CSS before Tailwind was added to the project. All new pages (Approvals, Rules, Platforms, Settings) use Tailwind. Do NOT add Tailwind classes to Dashboard.svelte — it will conflict. If Dashboard needs restyling, migrate the whole page to Tailwind in one go.
Calling safeStorage.encryptString() before the Electron app is ready throws. All credential operations must happen inside or after the app.whenReady() callback. The credentialStore handles this internally — never call safeStorage directly.
The Camoufox Firefox fork is too large for the Electron ASAR archive. It's downloaded post-install via scripts/setup-camoufox.ts to ~/.hive-agent/camoufox/. The forge.config.ts ignore array already excludes Playwright's local browsers from packaging.
The renderer uses Svelte 5 syntax ($props(), $state(), $derived()). Do NOT use Svelte 4 patterns (export let, $:, createEventDispatcher). The setup wizard was built with Svelte 5 from the start — follow its patterns.
The Electron app was calling /api/agent/auth, /api/agent/context, /api/agent/llm, and /api/agent/action — none of which existed in the EventHive SvelteKit codebase. Sprint 30 (run in the EventHive repo) builds auth and context, and extends the existing tool_data CRUD API with agent key auth instead of creating a separate action endpoint.
The original design proxied LLM requests through EventHive's /api/agent/llm. This added latency, a server dependency, and a whole endpoint to build. The Sprint 30 pivot calls Anthropic directly from the Electron main process — the user provides their own API key during setup (stored in keytar). This is consistent with how Sprint 7's Risk Assessment Wizard handles AI.
Instead of a dedicated /api/agent/action endpoint, agent writes go through the same /api/tools/[toolSlug]/data routes that the tool UI uses. An Authorization: Bearer ha_k_* header triggers agent-key auth instead of session-cookie auth. An _agent metadata block in the request body is stripped before the tool_data write and captured in agent_audit_log instead.
The MockMonitor (src/main/browser/monitors/mock-monitor.ts) replays saved messages from ~/.hive-agent/mock-messages.json through the full LLM → rules → approval pipeline. This decouples intelligence layer development from Camoufox scraping. Always test with mock first before testing with real Teams.
All Teams web app selectors live in src/main/browser/monitors/teams-selectors.ts. Never hardcode selectors in monitor logic. When Teams updates its DOM (which it does regularly), you only need to update one file. The file includes a "Last verified" date — update it whenever you confirm selectors still work.
Date: 2026-04-01
Sprint: 30
When teams.microsoft.com is loaded in an Electron BrowserWindow without a UA override, Microsoft detects the Electron/X.Y.Z string in the user-agent and displays a hard block:
"Classic Teams is no longer available. Use Teams on the web or download the latest version of the app."
This is not a session problem or an auth problem — it fires before login even appears.
Fix: Set a modern Chrome user-agent on the named session before the window loads any URL:
session.fromPartition('persist:teams').setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
);This must be done before win.loadURL(). Setting it after the window is created but before the URL loads also works via win.webContents.setUserAgent(), but the session-level approach is cleaner because it persists across page navigations.
The same issue will affect any Microsoft 365 property (Outlook web, SharePoint) loaded in Electron — always spoof the UA.
npm install appdmg fails because appdmg depends on canvas (a native Node addon) and electron-forge intercepts node-gyp with its Electron-targeted version, causing the native build to fail.
Fix: Skip MakerDMG entirely and use hdiutil (built into macOS) directly via npm run make:dmg.
Do NOT attempt npm install appdmg — it will fail with gyp ERR! not ok.
Renderer → preload.ts (contextBridge) → main process (ipcMain.handle)
Main → renderer: mainWindow.webContents.send(channel, data)
Renderer listens: window.agent.*.onSomething(callback)
Secrets → credentialStore.save(key, value) → safeStorage.encryptString → hex file in ~/.hive-agent/secrets/
Config → credentialStore.saveConfig(obj) → JSON at ~/.hive-agent/config.json (non-secret only)
PlatformMonitor.onMessage(msg)
→ MonitorManager.emit(msg)
→ IPC 'monitor:new-message' → Dashboard activity feed
→ LLMProcessor.process(msg) → Anthropic API → PendingAction | null
→ RulesStore.findMatch(action) → auto-approve or surface in UI
→ ActionExecutor.execute(action) → PATCH tool_data API with agent key
→ Local audit log + server audit log
Date: 2026-04-02 Sprint: Sentinel (Sprint 2) — post-sprint bug fix Symptoms: Teams window shows "sign in" again; no log activity after navigating to a different channel or clicking inside Teams.
The Teams web client is a React SPA. When the user switches channels, did-navigate-in-page fires, injectObserver() is called, but the script returns 'already-injected' because window.__hiveAgentObserver is still set from the previous channel. That observer is attached to a detached DOM container — the old channel's message list. It no longer receives any MutationObserver callbacks for new messages.
Separately, when injectObserver() runs too early after navigation (Teams hasn't rendered the new channel list yet), the script returns 'no-container'. The original code just logged a warning and stopped — no retry, so monitoring silently died until the next full page load.
Two changes in teams-monitor.ts:
-
did-navigate-in-pagehandler — Before scheduling re-injection, execute a cleanup script that callswindow.__hiveAgentObserver.disconnect()and nullifies it. Add a 600ms delay before re-injection to let Teams finish rendering the new channel. -
injectObserver()'no-container'branch — Instead of logging and giving up, callscheduleInjectRetry()which retries at 600ms → 1500ms → 3000ms. Gives up after 3 attempts with a warning.
Any did-navigate-in-page handler that re-injects JavaScript into a SPA must explicitly clean up the previous instance of any singleton (window.__hiveAgentFoo) before re-injecting. Never rely on the 'already-injected' guard as the sole check after navigation.
Date: 2026-04-02
Sprint: Sentinel (Sprint 2) — post-sprint bug fix
Symptoms: sentinelManager.sync() runs at startup but finds no sessions; Teams Sentinel never starts even though Teams is authenticated.
SA.07 (the integration migration task) was never completed. The old sign-in flow (teams:signin-done IPC + keytar teams_connected = 'true') was never bridged to platformRegistry. sentinelManager.sync() calls platformRegistry.getMonitorableSessions(), which returned an empty array because no Teams session had ever been added to the registry.
Additionally, getMonitorableSessions() required at least one channel with monitored === true. Sessions added by the bridge have no channels (monitor-all mode). This also silently prevented the Sentinel from starting.
Three-part bridge in index.ts (pending SA.07 full migration):
-
ensureTeamsSentinel()— finds or creates ateamssession inplatformRegistryand setsisAuthenticated: true. Called fromteams:signin-done,platform:disconnect(the inverse:removeTeamsSentinel()), and the startup IIFE. -
getMonitorableSessions()inplatform-registry.ts— updated to also include sessions withchannels.length === 0(monitor-all mode).SentinelInstancealready handles this: whenmonitoredChannelIds.size === 0, all unread channels are passed through. -
Startup bridge — when
teamsConnected === 'true'at app start,ensureTeamsSentinel()populates the registry beforesentinelManager.sync()runs (which is called immediately afterwhenReady()).
If an authentication flow writes to keytar/credential store, it must also update platformRegistry so the Sentinel layer knows about it. The two stores are separate and neither automatically syncs with the other. Until SA.07 is complete, ensureTeamsSentinel() / removeTeamsSentinel() are the bridge.
Date: 2026-04-02
Sprint: Sentinel (Sprint 2) — post-sprint bug fix
Symptoms: [TeamsMonitor] Observer injection gave up after retries — all 3 retries fail with 'no-container', no messages ever captured.
Calling BrowserWindow.hide() on the Teams monitoring window causes Chromium to suspend the page's rendering pipeline. The Teams React SPA stops rendering new DOM nodes, so the message list container ([data-tid="message-list"] etc.) is never added to the DOM. injectObserver() returns 'no-container' on every attempt — not because the selectors are wrong, but because there is genuinely nothing to attach to.
backgroundThrottling left at its default (true) compounds this: even if some rendering happens, Chromium throttles JS timers and repaints in hidden background windows.
Two changes in teams-monitor.ts:
-
hideWindow()no longer callswin.hide()— instead it callswin.setOpacity(0)andwin.setBounds({ x: -9999, y: -9999, width: 1280, height: 800 }). The window moves off-screen so the user can't see it, but Chromium continues rendering it as a visible window.showWindow()restores opacity to 1 and moves it back on-screen. -
backgroundThrottling: falseadded towebPreferences. Prevents Chromium throttling JS timers and repaints when the window is in the background.
When all retries are exhausted, scheduleInjectRetry() now dumps the first 25 [data-tid], [role="list|log"], [class*="message"] elements to the console. This immediately shows which selector to add to TEAMS_SELECTORS.messageListContainer for the next Teams DOM update.
Never call BrowserWindow.hide() on a window whose DOM you need to keep rendering for scraping/monitoring. Use off-screen positioning + setOpacity(0) instead. Always set backgroundThrottling: false on monitoring windows.
Debugging Teams (or any platform) DOM selectors by doing full HTML dumps, analysing one clue at a time, adjusting a selector, re-dumping, and repeating is extremely slow and expensive. We had dozens of unknowns about how Teams structures its DOM — message containers, sidebar items, unread badges, ARIA roles, data-tid attributes — and each round-trip was a full dump + AI analysis cycle.
Built a comprehensive DOM X-Ray toolkit (src/main/browser/dom-xray.ts) that runs inside the Electron browser panel's webContents context and produces a structured report in one shot:
-
Run X-Ray — Single
page.evaluate()that scans the entire DOM and extracts:- All
data-*attributes (grouped by attribute name, with value frequencies) - ARIA landscape (every
[role]element, counts, samples) - Repeating patterns (sibling elements with identical tag+class signatures → likely list items)
- Unread indicators (badge elements, bold text, notification dots)
- Interactive elements (buttons, inputs, links, textareas)
- Message candidates (auto-detected containers with author/body/timestamp sub-elements)
- Sidebar candidates (nav/list structures with 5+ items)
- Raw DOM tree snapshot (top 5 levels)
- All
-
Record Mutations — Attaches a MutationObserver for N seconds and captures every added/removed/changed node. Summarises by frequency to show which DOM regions change when a message arrives or channel is switched.
-
Test Selector — Quick validation of any CSS selector against the live page, showing matched elements with their attributes.
-
Open DevTools — One-click Chrome DevTools on the browser panel tab.
Reports auto-save as JSON to ~/.hive-agent/dom-reports/ for offline analysis or sharing with AI.
Dashboard sidebar → "🔬 X-Ray" tab. Requires a platform to be open in the Browser Panel first.
When working with unknown platform DOMs, always start with an X-Ray scan to map the full landscape before writing any selectors. One X-Ray report replaces dozens of manual dump-and-guess cycles.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2)
The [role="log"] selector was the primary message list container in teams-selectors.ts and TIERED_SELECTORS.messageListContainer.primary. The X-Ray scan revealed it has 0 children in the current New Teams (teams.cloud.microsoft). The MutationObserver was attached to an empty element, so no messages were ever detected.
Microsoft moved the message rendering from a role="log" ARIA container to [data-tid="message-pane-list-runway"] which contains 10+ direct <div> children (each wrapping a [data-tid="chat-pane-item"]).
Updated teams-selectors.ts:
- Primary container:
[data-tid="message-pane-list-runway"](confirmed 10 items) - Secondary:
[data-tid="message-pane-layout"](parent wrapper) - Removed
[role="log"]from the container list entirely - Primary message item:
[data-tid="chat-pane-item"](14x, full wrapper) - Author:
[data-tid="message-author-name"](10x, confirmed) - Sidebar tree:
[data-tid="simple-collab-dnd-rail"]with[role="treeitem"]children
Never assume ARIA roles contain the actual rendered content. Always verify with an X-Ray scan. Platform DOM documentation lives in dev-docs/platforms/TEAMS.md.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2)
The pollQueue() method ran:
executeJavaScript('JSON.stringify(window.__hiveAgentQueue || []); window.__hiveAgentQueue = [];')JavaScript evaluates both statements but executeJavaScript returns the completion value of the last expression: window.__hiveAgentQueue = [] → []. The JSON.stringify(...) result was computed and discarded. The poll always returned an empty array, so no messages ever reached handleRawMessage.
Wrap in an IIFE that captures the result before clearing:
executeJavaScript('(function(){ var q = JSON.stringify(window.__hiveAgentQueue || []); window.__hiveAgentQueue = []; return q; })()')When executeJavaScript must both read and mutate a page variable, always wrap in an IIFE that returns the value you need. Multi-statement scripts return the last expression's value, not the first.
Date: 2026-04-02 Sprint: Sentinel (Sprint 2)
handleRawMessage had if (!raw.body || raw.body.length < 10) return; — this silently dropped messages like "whatever" (8 chars), "sorry" (5 chars), "ok" (2 chars), "yes" (3 chars). These are legitimate human messages that should reach the LLM processor.
Lowered to raw.body.length < 2. Anything 2+ characters is a valid message. System messages and noise should be filtered by content pattern, not by length.
Don't use body length as a proxy for "is this a real message". Even single-word replies ("ok", "yes", "no", "done") are meaningful in a business chat context.
Date: 2026-04-02 Sprint: Intelligence Layer (Sprint 3)
We needed to support Anthropic, OpenAI, local models (Ollama/LM Studio), and Groq — each with different APIs. Instead of writing four separate provider implementations, we use a single OpenAICompatibleProvider class that speaks the OpenAI chat completions format. Anthropic is handled via a thin AnthropicNativeProvider wrapper.
Default to the OpenAI-compatible chat completions format (/v1/chat/completions) for any new LLM provider. Most providers (Groq, Ollama, LM Studio, vLLM, OpenRouter) already speak this format. Only create a native provider class if the API is fundamentally incompatible (like Anthropic's messages API).
Date: 2026-04-02 Sprint: Intelligence Layer (Sprint 3)
Local models and smaller cloud models often claim to support tool use but produce malformed JSON or hallucinate tool names. Instead of discovering this at runtime (crashing the pipeline), we run a 3-test probe sequence at configuration time: tool use → structured JSON → basic classification. Each test has a strict pass/fail check. The result assigns a tier (1/2/3) that the reasoning pipeline uses to select the right prompt strategy.
Always probe LLM capabilities at setup time, not at runtime. Store the result in config so the pipeline adapts its strategy without retrying failed patterns on every message. Re-probe when the user changes model or provider.
Date: 2026-04-02 Sprint: Intelligence Layer (Sprint 3)
When adding new types to env.d.ts for the LLM provider config, initial attempt used import type { ... } which converted the file from an ambient script to a module, breaking the global Window augmentation. All Svelte components lost access to window.agent types.
env.d.ts must remain an ambient script file (no import/export). Duplicate types there as standalone interfaces. Keep them in sync with the source modules manually.