diff --git a/.github/workflows/rowboat-build.yml b/.github/workflows/rowboat-build.yml
deleted file mode 100644
index 270e6263..00000000
--- a/.github/workflows/rowboat-build.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Rowboat Build
-
-on:
- pull_request:
-
-jobs:
- build-rowboat-nextjs:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v6
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- cache-dependency-path: 'apps/rowboat/package-lock.json'
- node-version: '20'
- cache: 'npm'
-
- - name: Install dependencies
- run: npm ci
- working-directory: apps/rowboat
-
- - name: Build Rowboat
- run: npm run build
- working-directory: apps/rowboat
-
- build-rowboatx:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v6
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- cache-dependency-path: 'apps/rowboat/package-lock.json'
- node-version: '24'
- cache: 'npm'
-
- - name: Install dependencies
- run: npm ci
- working-directory: apps/cli
-
- - name: Build Rowboat
- run: npm run build
- working-directory: apps/cli
\ No newline at end of file
diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml
deleted file mode 100644
index c411ab68..00000000
--- a/.github/workflows/x-publish.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Publish to npm
-
-on: workflow_dispatch
-
-permissions:
- id-token: write # Required for OIDC
- contents: read
-
-jobs:
- publish:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repo
- uses: actions/checkout@v6
-
- - name: Set up Node
- uses: actions/setup-node@v6
- with:
- node-version: 24
- registry-url: https://registry.npmjs.org/
-
- - name: Update npm
- run: npm install -g npm@latest
-
- - name: Install deps
- run: npm ci
- working-directory: apps/cli
-
- # optional: run tests
- # - run: npm test
-
- - name: Build
- run: npm run build
- working-directory: apps/cli
-
- - name: Pack
- run: npm pack
- working-directory: apps/cli
-
- - name: Publish to npm
- run: npm publish --access public
- working-directory: apps/cli
\ No newline at end of file
diff --git a/.github/workflows/x-tests.yml b/.github/workflows/x-tests.yml
index 9e8b1c7d..c08c4095 100644
--- a/.github/workflows/x-tests.yml
+++ b/.github/workflows/x-tests.yml
@@ -34,6 +34,12 @@ jobs:
- name: Run apps/x Vitest suites
run: npm test
+ # Typechecks with the dev tsconfigs, which include test files —
+ # the build tsconfigs exclude them and vitest strips types without
+ # checking, so this is the only gate that sees type errors in tests.
+ - name: Typecheck apps/x packages
+ run: npm run typecheck
+
apps-x-electron-package:
name: apps/x Electron package smoke test
runs-on: ubuntu-latest
diff --git a/CLAUDE.md b/CLAUDE.md
index 5e762a33..a5fe24c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -111,7 +111,7 @@ Long-form docs for specific features. Read the relevant file before making chang
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
| Calls (video mode) — one hands-free call engine with four presets (voice / video / share screen / practice coaching), device-derived surfaces (full-screen ⇄ floating popout), frame pipeline, prompts catalog | `apps/x/VIDEO_MODE.md` |
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
-| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
+| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
## Common Tasks
@@ -139,6 +139,7 @@ Long-form docs for specific features. Read the relevant file before making chang
### Verify compilation
```bash
cd apps/x && npm run deps && npm run lint
+cd apps/x && npm run typecheck # dev tsconfigs — the only gate that typechecks *.test.ts
```
## Tech Stack
diff --git a/README.md b/README.md
index 934ab3b4..8d46398b 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
Rowboat
@@ -26,14 +30,14 @@
-Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects.
+Rowboat indexes your work into a living knowledge graph and uses that to get work done on your machine. It includes work surfaces for collaborating with AI: email client, notes, browser, code mode, meeting note taker, and workspaces for different projects.
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
@@ -51,10 +55,10 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do
Brain
-Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
+Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
-
+
@@ -63,35 +67,35 @@ Rowboat indexes email, meetings, slack and assistant conversations into a living
The built-in email client sorts emails into important and everything else. Rowboat automatically drafts responses for important email using all the work context.
-
+
Background agents
-You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
+You can set up background agents that run on events like new email or on schedule like every day at 8am. They can connect to tools, search the web, use the browser and write code using Claude Code or Codex.
-
+
Built-in Browser
-Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access.
+Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because its isolated from your main browser, you can log in only to the accounts that want the assistant to access.
-
+
Meeting Notes
-A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
+A local meeting note-taker that taps into mic & speaker, produces live transcript and summarizes the meeting in a markdown file and updates the knowledge graph.
-
+
@@ -100,25 +104,25 @@ A local meeting note-taker that taps into mic & speaker, produces live transcrip
Code mode lets you spin up parallel coding agents with Claude Code or Codex, and have Rowboat drive them with all the work context where needed.
-
+
Apps
-You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people.
+You can bulild your own work surfaces inside Rowboat — they get acess to all the tools and integrations, and you can share them with other people.
-
+
Integrations
-Includes one-click integrations to most popular products.
+Includes one-click integrations to most popular products.
-
+
diff --git a/apps/cli/todo.md b/apps/cli/todo.md
index 9c97afd3..3ca4d45c 100644
--- a/apps/cli/todo.md
+++ b/apps/cli/todo.md
@@ -12,4 +12,4 @@ o file logging
rowboat agent
---
-- create agent
\ No newline at end of file
+- create agent
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 8442b158..3178f741 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -55,6 +55,6 @@
"chatgpt",
"claude"
]
- }
+ }
}
-
\ No newline at end of file
+
diff --git a/apps/docs/docs/development/contribution-guide.mdx b/apps/docs/docs/development/contribution-guide.mdx
index 1089d7e0..8fbf2a1d 100644
--- a/apps/docs/docs/development/contribution-guide.mdx
+++ b/apps/docs/docs/development/contribution-guide.mdx
@@ -52,4 +52,4 @@ Rowboat is open-source and we welcome contributions of all kinds — bug reports
## Getting Help
-If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked.
\ No newline at end of file
+If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked.
diff --git a/apps/docs/docs/development/roadmap.mdx b/apps/docs/docs/development/roadmap.mdx
index 6248e567..8202b2b2 100644
--- a/apps/docs/docs/development/roadmap.mdx
+++ b/apps/docs/docs/development/roadmap.mdx
@@ -4,4 +4,4 @@ icon: "road"
# Roadmap
-Explore the future development plans and upcoming features for Rowboat.
\ No newline at end of file
+Explore the future development plans and upcoming features for Rowboat.
diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md
index 919511ca..a2bfa3ae 100644
--- a/apps/x/ANALYTICS.md
+++ b/apps/x/ANALYTICS.md
@@ -41,9 +41,9 @@ Every `llm_usage` emit point in the codebase:
| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line |
|---|---|---|---|---|
-| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) |
+| `copilot_chat` | (none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | `packages/core/src/runtime/turns/bridges/real-usage-reporter.ts` (`reportModelUsage`); legacy runs (code-mode carve-out) still emit from `packages/core/src/runtime/legacy/engine.ts` (`streamLlm` finish-step) |
| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` |
-| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` |
+| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/runtime/tools/domains/parsing.ts:179` |
| `live_note_agent` | `routing` | no | Pass 1 routing classifier (`generateObject`) | `packages/core/src/knowledge/live-note/routing.ts:93` |
| `live_note_agent` | `manual` | yes | Pass 2 agent run — user clicked Run / called the `run-live-note-agent` tool | `packages/core/src/knowledge/live-note/runner.ts:140` (createRun, `subUseCase: trigger`) |
| `live_note_agent` | `cron` | yes | Pass 2 agent run — cron expression matched | same call site |
@@ -53,7 +53,6 @@ Every `llm_usage` emit point in the codebase:
| `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) |
| `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) |
| `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) |
-| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) |
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
diff --git a/apps/x/GRANOLA_PARITY.md b/apps/x/GRANOLA_PARITY.md
new file mode 100644
index 00000000..53af3bd9
--- /dev/null
+++ b/apps/x/GRANOLA_PARITY.md
@@ -0,0 +1,153 @@
+# Granola Parity — Research & Gap Analysis
+
+Goal: make Rowboat's meeting feature work exactly like Granola (granola.ai). We copy Granola's behavior — no new invention. This doc is the source of truth: how Granola works, what Rowboat has today (with file:line pointers), the gaps, and the parity plan.
+
+Research basis: Granola's official docs (docs.granola.ai), granola.ai blog/security/jobs pages, third-party reviews and reverse-engineering writeups, plus a full audit of this codebase. Claims that are inferred (not directly documented by Granola) are marked **[inference]**.
+
+---
+
+## Part 1 — How Granola works
+
+Granola is an Electron app (confirmed by their own job postings: "Granola is an Electron app with deep OS integrations", React/TypeScript UI + native OS helpers). No bot ever joins the call — it captures audio locally on the device, so nothing is visible to other meeting participants.
+
+### 1.1 Always-running app (launch at login)
+
+- The product model is an **always-running, menu-bar-resident app**. Granola must be running for detection/notifications to work ("You must have Granola open... for transcription to run" — their troubleshooting docs).
+- A literal "open at login" toggle is **not publicly documented**. **[inference]** A clone should register as a login item (Electron `app.setLoginItemSettings`), default on, with a setting to turn it off — the entire detection value prop collapses if the app isn't running.
+- The app keeps running when the window closes (menu bar presence remains).
+
+### 1.2 Menu bar icon
+
+- Granola "sits in your Mac's menu bar". Documented affordance: **click the menu bar icon to start recording / open the app**.
+- Recording status is NOT primarily shown in the menu bar. The documented indicators are:
+ - Inside the note: "green dancing bars" at the bottom while capturing.
+ - When another app has focus: a **floating always-on-top "live meeting" pill** on the right side of the screen.
+- Exact dropdown contents (upcoming meetings list, icon state change while recording) are not documented. The "Coming up" list lives on the home screen, not the tray.
+
+### 1.3 Meeting detection — two signals, never auto-record
+
+Granola detects meetings via **calendar events + microphone-in-use detection**, and it **never records without a user action** ("Granola only starts transcribing when you open a note for that meeting").
+
+**Signal A — Calendar:**
+- Google / Microsoft calendar sync. Pre-creates an auto-titled note per event with 2+ attendees; filters declined events, OOO, Focus Time.
+- **In-app popup (Granola-drawn, not macOS Notification Center) 1 minute before** any scheduled call with 2+ attendees. One click on it **opens the meeting URL AND starts transcription** at once.
+- **Armed auto-start:** if you have the upcoming meeting's note open before it starts, recording starts automatically at the scheduled time.
+
+**Signal B — Mic-in-use (ad-hoc calls, incl. browser meetings):**
+- "Granola notices when your microphone is in use and offers to start taking notes."
+- It's app-aware: notification title is "**Huddle detected**" (Slack), "**Call detected**" (FaceTime/WhatsApp), "**Meeting detected**" (anything else, including browsers). Buttons: "Take Notes". Ad-hoc popups have a dashed left border; calendar ones a solid colored bar.
+- If the ad-hoc call starts within **15 minutes** of a scheduled event, the popup adopts that event's name (merged).
+- **[inference]** Mechanism: poll CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` on input devices for "mic in use", plus enumerate running processes against a known meeting-app list to get the app-specific title. There's no public macOS API for "process X is using the mic", so it's a heuristic combo. Browser meetings degrade to the generic "Meeting detected" title; calendar linkage supplies identity.
+- Notifications are configurable in Settings (off entirely, or per-application).
+- Related preference: "**Reposition Granola for meetings**" — window auto-repositions/resizes alongside the call when a meeting starts (toggleable, default on).
+
+### 1.4 Audio capture
+
+- Capture = **default microphone + system audio output**, at the OS level. Works with any app or browser (Chrome/Safari/Firefox/Edge) with no extension.
+- Cannot isolate per-app audio — it's the combined system stream; uses the OS default sound devices.
+- The two streams stay separate through transcription → transcript UI shows **grey bubbles (left) = system audio ("Them"), green bubbles (right) = your mic ("Me")**. No true live diarization on desktop — just Me/Them.
+- **macOS permissions: exactly two** — Microphone, and Screen & System Audio Recording (macOS bundles system-audio capture under screen recording). No Accessibility permission.
+- **[inference]** System audio mechanism: ScreenCaptureKit audio loopback (macOS 13+ baseline, they require 13+ / recommend 14.2+), possibly CoreAudio process taps on 14.4+. No virtual audio driver (setup has no driver install step).
+- **Audio is never stored** — streamed to the ASR provider in real time and discarded.
+
+### 1.5 Transcription
+
+- Cloud, real-time streaming ASR. Subprocessors named on their security page: **Deepgram and AssemblyAI** for ASR; **OpenAI and Anthropic** for note enhancement.
+- Live transcript accrues during the call, hidden behind a waveform-icon toggle in the note.
+
+### 1.6 Meeting end & post-meeting flow
+
+- **Auto-stop conditions** (documented): (a) call-end heuristic — transcript inactivity + whether the call software is still in use + scheduled end time for calendar-linked meetings; (b) **15 minutes of silence**; (c) computer sleeps; (d) manual stop. Note: "on macOS, meeting auto-end detection requires admin rights" — without it, manual stop only.
+- **On stop, enhancement runs automatically** — no click needed. Enhanced notes are ready in seconds (~30s max reported).
+- A "**notes ready**" notification fires when enhancement completes — that's the re-entry point back into the app if you switched away. The note itself was already open (recording ran inside it), so the enhanced version replaces the raw view in place. There's no documented "force-focus the window" behavior; the notification does the redirecting.
+- Extras: auto-drafted follow-up emails (toggleable), pre-meeting briefs.
+
+### 1.7 Notes UI
+
+**During the meeting:** a plain notepad — the user types rough bullets; transcription runs invisibly. Green dancing bars at the bottom = capturing. Waveform button (next to the per-note "Ask anything" chat bar) opens the live transcript side-by-side. Mid-meeting you can ask the chat to catch you up.
+
+**After the meeting (enhanced notes):**
+- Enhancement merges exactly three inputs: **transcript + your raw notes + calendar metadata**, through a template.
+- Signature affordance: **your words render black, AI-generated text grey**.
+- Per-line provenance: magnifying-glass icon on a line reveals where it came from in the transcript.
+- Templates: built-ins (1:1, standup, sales discovery) + custom; re-enhance with a different template (✨), regenerate (🔁), edit raw notes and re-enhance.
+- Chat: per-note "Ask anything" bar, global chat (⌘J), cross-meeting chat over folders; edit-by-asking.
+- Home screen: "**Coming up**" strip (next ~5 meetings) + reverse-chronological past notes list; folders; recurring meetings grouped by recurring-event ID + title; shareable links.
+
+---
+
+## Part 2 — What Rowboat has today (audit of main)
+
+Two separate feature families exist in `apps/x`; only the second is Granola-adjacent:
+- **"Calls" (video mode)** — live voice/video chat *with the Rowboat AI* (`VIDEO_MODE.md`). Not meeting capture.
+- **"Meetings"** — real meeting capture → transcript → AI notes. Working pipeline, but **calendar-triggered and manual-click only**.
+
+### What EXISTS (and is solid)
+
+| Area | Status | Where |
+|---|---|---|
+| Mic + system-audio capture, 2-channel | ✅ | `apps/renderer/src/hooks/useMeetingTranscription.ts:282-299` — mic `getUserMedia` (ch 0) + `getDisplayMedia({audio})` loopback (ch 1), merged to 16 kHz PCM (`:424-485`); loopback auto-approved in `apps/main/src/main.ts:216-226` |
+| Realtime ASR | ✅ | Deepgram realtime WS, `nova-3`, multichannel + diarize (`useMeetingTranscription.ts:9-21`); proxy or raw key (`:253-280`) |
+| Speaker labels | ✅ | ch 0 → "You", ch 1 → diarized `Speaker N` (`:335-347`) |
+| Transcript storage | ✅ | Markdown + frontmatter + fenced transcript block, `knowledge/Meetings/rowboat//.md` (`:93-136`, `:487-510`) |
+| AI meeting notes on stop | ✅ | `packages/core/src/knowledge/summarize_meeting.ts` — LLM summary, attendee-name resolution from calendar; orchestrated in `App.tsx:5601-5661`; notes prepended above transcript |
+| Auto-stop heuristics | ✅ (partial) | silence RMS backstop, calendar-end gating, system-track ended/muted — `useMeetingTranscription.ts:378-549` |
+| Calendar sync | ✅ | Google OAuth via `googleapis`, `packages/core/src/knowledge/sync_calendar.ts`, per-event JSON in `~/.rowboat/calendar_sync/` |
+| Pre-meeting notification | ✅ (system toast) | `packages/core/src/knowledge/notify_calendar_meetings.ts` — polls every 30s, notifies ~1 min before, deep-links `rowboat://action?type=join-and-take-meeting-notes` |
+| Meetings screen | ✅ | `apps/renderer/src/components/meetings-view.tsx` — "Coming up" (+Join / Take-notes buttons, inline prep) + past-notes table |
+| Transcript rendering | ✅ | `apps/renderer/src/extensions/transcript-block.tsx` (TipTap, colored speakers, collapsible) |
+| Meeting prep briefs | ✅ | `meeting_prep_scheduler.ts`, `meeting_prep_brief.ts` (Granola has this too) |
+| Permissions flows | ✅ | mic `ipc.ts:2090-2106`; screen recording check/open-settings `ipc.ts:2003-2018`; Info.plist strings `forge.config.cjs:201-204`; entitlements OK |
+
+### What is MISSING (the entire "ambient" layer)
+
+| # | Gap | Detail |
+|---|---|---|
+| 1 | **Launch at login** | Zero `setLoginItemSettings` / auto-launch code anywhere. |
+| 2 | **Menu bar (tray) icon** | Zero `Tray` usage. No background presence: on macOS closing the window leaves only the Dock; no way to start recording without the full window. |
+| 3 | **Meeting detection** | No mic-in-use detection, no process detection (Zoom/Teams/Slack/FaceTime), no browser awareness. Only calendar (time-based). Ad-hoc calls are invisible. |
+| 4 | **Auto/one-click start UX** | Capture starts only via explicit button click or clicking the calendar toast. No "Meeting detected — Take notes?" popup, no armed auto-start when a note is open at meeting start time. |
+| 5 | **Headless capture** | Capture depends on the renderer window holding a live `getDisplayMedia` stream. No native audio helper → can't capture while the app is "in the background" the way Granola does. |
+| 6 | **Meeting-end redirect** | On stop, the note refreshes in place, but there's **no "notes ready" notification** and no bring-the-user-back moment if they're in another app. |
+| 7 | **Notes UI polish (Granola signatures)** | No black-vs-grey authorship distinction (we replace, they merge raw notes + transcript), no during-meeting notepad-first flow (we show the transcript note), no per-line provenance, no templates/re-enhance, no floating "recording" pill. |
+
+### Honest assessment
+
+Rowboat has built the *second half* of Granola well — what happens once you're recording, and after the meeting ends. It has essentially none of the *first half* — noticing a meeting is happening and quietly being there without the user opening the app. That first half is exactly items 1–5 above, and it's where all the work is.
+
+---
+
+## Part 3 — Parity plan (copy Granola, no new stuff)
+
+Ordered so each phase is shippable and testable on its own.
+
+### Phase 1 — Resident app: login item + tray
+- `app.setLoginItemSettings({ openAtLogin: true })`, default on, toggle in Settings. When launched at login: no window, tray only **[inference — Granola undocumented, but implied]**.
+- Electron `Tray` with template icon; menu: "Start recording", "Open Rowboat", recording status line, Quit. Keep app alive on window close (already macOS default; add tray so it's reachable).
+- Acceptance: reboot → icon in menu bar, no window; click tray → start an ad-hoc meeting note.
+
+### Phase 2 — Meeting detection + "Take notes?" popup
+- Native signal (small Swift helper or node addon, polled from main): mic-in-use via CoreAudio `kAudioDevicePropertyDeviceIsRunningSomewhere` + running-process match against a known list (zoom.us, Teams, Slack, FaceTime, browsers…).
+- Granola-style app-drawn popup (small always-on-top BrowserWindow, like the existing video popout): "Huddle detected / Call detected / Meeting detected — [Take Notes]". Merge with a calendar event if within 15 min. Never auto-record.
+- Upgrade the existing 1-min-before calendar toast to the same popup style; one click = open meeting URL + start capture (deep-link plumbing already exists in `deeplink.ts`).
+- Armed auto-start: meeting note open before start time → auto-start at start time.
+- Per-app notification settings.
+- Acceptance: start a Meet call in Chrome with no calendar event → popup appears; click → recording.
+
+### Phase 3 — Meeting end → notes ready redirect
+- Keep existing auto-stop heuristics; add the missing "call software no longer active" signal from the Phase-2 process watcher; keep 15-min silence backstop (ours is stricter — align to Granola's 15 min).
+- On summary completion: fire a **"Your meeting notes are ready" notification**; clicking focuses the app on the note (deep link exists). This is the "redirect when we cut the call" the feature needs.
+- Acceptance: leave the call → recording stops on its own → notification within ~30s → click lands on finished notes.
+
+### Phase 4 — Notes UI parity
+- During meeting: notepad-first note (user types; transcript behind a waveform toggle); green capture indicator in-note; floating "live meeting" pill when app unfocused (reuse the video-popout window machinery).
+- After meeting: enhancement merges **raw notes + transcript + calendar event** (today we only use the transcript); render user text black / AI text grey; ✨ re-enhance + 🔁 regenerate; keep transcript below as today.
+- Home: Meetings view already ≈ Granola's home (Coming up + past list) — minor polish only.
+
+### Phase 5 (only if needed for true parity) — Headless capture
+- Native mic + ScreenCaptureKit system-audio capture in main/helper process so recording doesn't require the renderer window. Biggest lift; Phases 1–4 deliver the Granola experience with the window opening on record start, which is acceptable Granola-like behavior (their note opens on start too).
+
+### Key implementation notes
+- Permissions stay exactly two (mic + screen-recording) — we already handle both.
+- Deepgram nova-3 multichannel already matches Granola's Me/Them model — no ASR change needed.
+- Reuse: popup ← video popout window pattern; deep links ← `deeplink.ts`; detection loop ← same main-process init pattern as `notify_calendar_meetings.ts`.
diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md
index d8a157d7..85f16786 100644
--- a/apps/x/LIVE_NOTE.md
+++ b/apps/x/LIVE_NOTE.md
@@ -326,7 +326,7 @@ Three branches by `trigger`:
### 5. Live Note skill (Copilot-facing)
- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills.
-- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant.
+- **File**: `packages/core/src/runtime/assembly/skills/live-note/skill.ts`. Exported `skill` constant.
- **Schema interpolation**: at module load, `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` is interpolated into the "Canonical Schema" section. Edits to `LiveNoteSchema` propagate automatically.
- **Output**: markdown, injected into the Copilot system prompt when `loadSkill('live-note')` fires.
- **Invoked by**: Copilot's `loadSkill` builtin tool. Registration in `skills/index.ts`.
@@ -334,7 +334,7 @@ Three branches by `trigger`:
### 6. Copilot trigger paragraph
- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded.
-- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph).
+- **File**: `packages/core/src/runtime/assembly/copilot/instructions.ts` (look for the "Live Notes" paragraph).
- **Strong signals (load + act without asking)**: cadence words ("every morning / daily / hourly…"), living-document verbs ("keep a running summary of…", "maintain a digest of…"), watch/monitor verbs, pin-live framings ("always show the latest X here"), direct ("track / follow X"), event-conditional ("whenever a relevant email comes in…").
- **Medium signals (load + answer the one-off + offer)**: time-decaying questions ("what's the weather?", "USD/INR right now?", "service X status?"), note-anchored snapshots ("show me my schedule here"), recurring artifacts ("morning briefing", "weekly review", "Acme dashboard"), topic-following / catch-up.
- **Anti-signals (do NOT make live)**: definitional questions, one-off lookups, manual document editing.
@@ -343,7 +343,7 @@ Three branches by `trigger`:
### 7. `run-live-note-agent` tool — `context` parameter description
- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run.
-- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition).
+- **File**: `packages/core/src/runtime/tools/catalog.ts` (the `run-live-note-agent` tool definition).
- **Inputs**: `filePath` (workspace-relative; the tool strips the `knowledge/` prefix internally), optional `context`.
- **Output**: flows into `runLiveNoteAgent(..., 'manual')` → `buildMessage` → appended as `**Context:**` in the agent message.
- **Key use case**: backfill a newly-made-live note so its body isn't empty on day 1.
@@ -394,10 +394,10 @@ Conventions:
| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
-| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |
-| Skill registration | `packages/core/src/application/assistant/skills/index.ts` |
-| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` |
-| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` |
+| Copilot skill | `packages/core/src/runtime/assembly/skills/live-note/skill.ts` |
+| Skill registration | `packages/core/src/runtime/assembly/skills/index.ts` |
+| Copilot trigger paragraph | `packages/core/src/runtime/assembly/copilot/instructions.ts` |
+| `run-live-note-agent` builtin tool | `packages/core/src/runtime/tools/catalog.ts` |
| Editor toolbar (Radio button → panel) | `apps/renderer/src/components/editor-toolbar.tsx` |
| Live Note panel (single-view editor) | `apps/renderer/src/components/live-note-sidebar.tsx` |
| Status hook (`useLiveNoteAgentStatus`) | `apps/renderer/src/hooks/use-live-note-agent-status.ts` |
diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md
index 653521cb..999a7f56 100644
--- a/apps/x/VIDEO_MODE.md
+++ b/apps/x/VIDEO_MODE.md
@@ -108,7 +108,7 @@ is live) to the outgoing message as `UserImagePart`s and sets
(`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike
file attachments (path references read via the `LLMParse` tool), image
parts go to the model as real multimodal image parts.
-- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013):
+- `packages/core/src/runtime/assembly/message-encoding.ts` `convertFromMessages`:
emits a context line (frame counts + time span), then labeled groups —
a `"Webcam frames (oldest to newest):"` text part before camera images and
a `"Screen-share frames (oldest to newest):"` text part before screen
@@ -176,11 +176,11 @@ Push-to-talk is disabled while a call owns the mic.
| Prompt | Where |
|--------|-------|
-| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) |
-| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) |
-| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) |
-| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) |
-| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
+| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/runtime/assembly/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `runtime/assembly/compose-instructions.ts`) |
+| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) |
+| "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`runtime/assembly/skills/app-navigation/skill.ts`) |
+| Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/runtime/assembly/message-encoding.ts` (`convertFromMessages`) |
+| `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/runtime/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` |
Voice input/output prompt sections (`# Voice Input`, `# Voice Output`) are
reused untouched — calls set `voiceInput` per utterance and force
diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs
index 548e037c..696084f3 100644
--- a/apps/x/apps/main/bundle.mjs
+++ b/apps/x/apps/main/bundle.mjs
@@ -91,6 +91,26 @@ if (!fs.existsSync(stagedBinary)) {
}
console.log('✅ node-pty staged in .package/node_modules');
+// Compile the mic-monitor helper (ambient meeting detection) on macOS.
+// Best-effort: without swiftc — or on other platforms — the app still works,
+// ad-hoc meeting detection just stays off (main checks the binary exists).
+if (process.platform === 'darwin') {
+ const swiftSrc = path.join(here, 'native', 'mic-monitor.swift');
+ const helperOut = path.join(here, '.package', 'dist', 'mic-monitor');
+ const upToDate = fs.existsSync(helperOut) &&
+ fs.statSync(helperOut).mtimeMs >= fs.statSync(swiftSrc).mtimeMs;
+ if (upToDate) {
+ console.log('✅ mic-monitor helper up to date');
+ } else {
+ try {
+ execSync(`swiftc -O "${swiftSrc}" -o "${helperOut}"`, { stdio: 'inherit' });
+ console.log('✅ mic-monitor helper compiled');
+ } catch {
+ console.warn('⚠️ mic-monitor helper not built (swiftc unavailable?) — meeting detection disabled');
+ }
+ }
+}
+
// Bundle the vendored agent-slack CLI into a single self-contained script next
// to main.cjs. It runs as a child process (process.execPath with
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
diff --git a/apps/x/apps/main/native/mic-monitor.swift b/apps/x/apps/main/native/mic-monitor.swift
new file mode 100644
index 00000000..239de7ee
--- /dev/null
+++ b/apps/x/apps/main/native/mic-monitor.swift
@@ -0,0 +1,180 @@
+// mic-monitor: prints a JSON line whenever the set of processes using the
+// microphone changes.
+//
+// This is the ambient meeting-detection signal (Granola-style): when another
+// app (Zoom, Meet in a browser, Slack huddle, FaceTime…) opens the
+// microphone, we report it. No audio is captured, so this requires no
+// microphone permission (TCC) — it's device state, not content.
+//
+// Two signals, best available wins:
+// - macOS 14.4+: per-process audio objects (kAudioHardwarePropertyProcessObjectList
+// + kAudioProcessPropertyIsRunningInput) give the processes that own the
+// mic. Each owner is resolved to its bundle ID (CoreAudio) and executable
+// path (libproc) HERE, natively — the consumer must not need to shell out
+// (child-process spawns from Electron's main process fail with EBADF
+// while capture is active).
+// - Fallback: kAudioDevicePropertyDeviceIsRunningSomewhere on the default
+// input device — mic in use by *someone*, no attribution.
+//
+// Protocol: one JSON object per line on stdout, emitted on every state
+// change (and once at startup):
+// {"micInUse":true,"owners":[{"pid":123,"bundleId":"com.google.Chrome.helper","path":"/Applications/..."}]}
+// ("owners" is empty when attribution is unavailable.)
+// The process exits when stdin closes, so it can never outlive the app.
+//
+// Compiled by apps/main/bundle.mjs (best-effort, macOS only) to
+// .package/dist/mic-monitor.
+
+import Foundation
+import CoreAudio
+import Darwin
+
+func defaultInputDeviceID() -> AudioDeviceID? {
+ var deviceID = AudioDeviceID(0)
+ var size = UInt32(MemoryLayout.size)
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ let status = AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID)
+ guard status == noErr, deviceID != kAudioObjectUnknown else { return nil }
+ return deviceID
+}
+
+func isRunningSomewhere(_ device: AudioDeviceID) -> Bool {
+ var running = UInt32(0)
+ var size = UInt32(MemoryLayout.size)
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &running)
+ return status == noErr && running != 0
+}
+
+func audioProcessObjectIDs() -> [AudioObjectID] {
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyProcessObjectList,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var size = UInt32(0)
+ let sysID = AudioObjectID(kAudioObjectSystemObject)
+ guard AudioObjectGetPropertyDataSize(sysID, &addr, 0, nil, &size) == noErr, size > 0 else {
+ return []
+ }
+ var ids = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout.size)
+ guard AudioObjectGetPropertyData(sysID, &addr, 0, nil, &size, &ids) == noErr else { return [] }
+ return ids
+}
+
+func processIsRunningInput(_ object: AudioObjectID) -> Bool {
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioProcessPropertyIsRunningInput,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var value = UInt32(0)
+ var size = UInt32(MemoryLayout.size)
+ let status = AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value)
+ return status == noErr && value != 0
+}
+
+func processPID(_ object: AudioObjectID) -> Int32? {
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioProcessPropertyPID,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var pid = pid_t(-1)
+ var size = UInt32(MemoryLayout.size)
+ guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &pid) == noErr, pid >= 0 else {
+ return nil
+ }
+ return Int32(pid)
+}
+
+func processBundleID(_ object: AudioObjectID) -> String? {
+ var addr = AudioObjectPropertyAddress(
+ mSelector: kAudioProcessPropertyBundleID,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var value: Unmanaged? = nil
+ var size = UInt32(MemoryLayout?>.size)
+ guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr,
+ let cf = value else { return nil }
+ let str = cf.takeRetainedValue() as String
+ return str.isEmpty ? nil : str
+}
+
+func processPath(_ pid: Int32) -> String? {
+ var buffer = [CChar](repeating: 0, count: 4096)
+ let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
+ guard length > 0 else { return nil }
+ return String(cString: buffer)
+}
+
+struct MicOwner: Equatable {
+ let pid: Int32
+ let bundleId: String
+ let path: String
+}
+
+/// Processes currently capturing from any input device (macOS 14.4+;
+/// returns [] where unsupported and the device-level fallback takes over).
+func micOwners() -> [MicOwner] {
+ var owners: [MicOwner] = []
+ for object in audioProcessObjectIDs() where processIsRunningInput(object) {
+ guard let pid = processPID(object) else { continue }
+ owners.append(MicOwner(
+ pid: pid,
+ bundleId: processBundleID(object) ?? "",
+ path: processPath(pid) ?? ""))
+ }
+ return owners.sorted { $0.pid < $1.pid }
+}
+
+func jsonEscape(_ s: String) -> String {
+ var out = ""
+ for ch in s.unicodeScalars {
+ switch ch {
+ case "\"": out += "\\\""
+ case "\\": out += "\\\\"
+ case "\n": out += "\\n"
+ case "\r": out += "\\r"
+ case "\t": out += "\\t"
+ default:
+ if ch.value < 0x20 {
+ out += String(format: "\\u%04x", ch.value)
+ } else {
+ out.unicodeScalars.append(ch)
+ }
+ }
+ }
+ return out
+}
+
+// Exit when the parent closes our stdin (app quit/crash) — never orphan.
+Thread {
+ while readLine(strippingNewline: false) != nil {}
+ exit(0)
+}.start()
+
+setbuf(stdout, nil)
+
+var lastInUse: Bool? = nil
+var lastOwners: [MicOwner] = []
+while true {
+ let owners = micOwners()
+ // Re-resolve the default device every poll: the user can switch input
+ // devices (AirPods in/out) mid-session.
+ let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false
+ let inUse = deviceInUse || !owners.isEmpty
+ if inUse != lastInUse || owners != lastOwners {
+ lastInUse = inUse
+ lastOwners = owners
+ let ownerJson = owners.map { o in
+ "{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}"
+ }.joined(separator: ",")
+ print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}")
+ }
+ Thread.sleep(forTimeInterval: 1.0)
+}
diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts
index 5119b6ba..256fe365 100644
--- a/apps/x/apps/main/src/composio-handler.ts
+++ b/apps/x/apps/main/src/composio-handler.ts
@@ -2,7 +2,7 @@ import { shell, BrowserWindow } from 'electron';
import { createAuthServer } from './auth-server.js';
import * as composioClient from '@x/core/dist/composio/client.js';
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
-import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
+import { invalidateCopilotInstructionsCache } from '@x/core/dist/runtime/assembly/copilot/instructions.js';
import { CURATED_TOOLKIT_SLUGS } from '@x/shared/dist/composio.js';
import type { LocalConnectedAccount, Toolkit } from '@x/core/dist/composio/types.js';
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 7dff8d1b..8df7e6d2 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -12,8 +12,8 @@ import { watcher as watcherCore, workspace } from '@x/core';
import { WorkDir } from '@x/core/dist/config/config.js';
import { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
-import * as runsCore from '@x/core/dist/runs/runs.js';
-import { bus } from '@x/core/dist/runs/bus.js';
+import * as runsCore from '@x/core/dist/runtime/legacy/runs.js';
+import { bus } from '@x/core/dist/runtime/legacy/bus.js';
import { serviceBus } from '@x/core/dist/services/service_bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
@@ -29,7 +29,9 @@ let caffeinateBlockerId: number | null = null;
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
-import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
+import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
+import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js';
+import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
@@ -53,7 +55,7 @@ import * as codeGit from '@x/core/dist/code-mode/git/service.js';
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
-import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
+import { invalidateCopilotInstructionsCache } from '@x/core/dist/runtime/assembly/copilot/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { IChannelsConfigRepo } from '@x/core/dist/channels/repo.js';
@@ -64,6 +66,20 @@ import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
+import { saveAppSettings } from '@x/core/dist/config/app_settings.js';
+import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js';
+import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js';
+import { consumePendingToggleMeetingNotes, setTrayRecordingState } from './tray.js';
+import { closeMeetingPopup, getMeetingPopupPayload, handleMeetingPopupAction } from './meeting-popup.js';
+
+// Ambient meeting detection must ignore Rowboat's own mic use: meeting
+// capture and assistant voice/video calls both hold the mic. Either being
+// active suppresses "Meeting detected" prompts.
+let meetingRecordingActive = false;
+let voiceCallActive = false;
+function updateSelfCaptureState() {
+ setSelfCaptureActive(meetingRecordingActive || voiceCallActive);
+}
import * as composioHandler from './composio-handler.js';
import * as appsIndexer from '@x/core/dist/apps/indexer.js';
import * as appsServer from '@x/core/dist/apps/server.js';
@@ -79,6 +95,9 @@ import * as appsPublisher from '@x/core/dist/apps/publisher.js';
// D18 install previews awaiting confirmation, keyed by app name.
const appInstallPreviews = new Map>>();
+// Last-seen app-set fingerprint; a change invalidates the copilot
+// instructions cache (they embed the installed-apps list).
+let lastAppsFingerprint: string | null = null;
import { consumePendingDeepLink } from './deeplink.js';
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
@@ -95,7 +114,9 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
-import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } from '@x/core/dist/knowledge/sync_gmail.js';
+import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, archiveCategoryThreads, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance, setThreadCategory } from '@x/core/dist/knowledge/sync_gmail.js';
+import { loadEmailInstructions, saveEmailInstructions } from '@x/core/dist/knowledge/email_instructions.js';
+import { getEmailLabels, syncCustomLabelsFromInstructions } from '@x/core/dist/knowledge/email_labels.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
@@ -488,24 +509,14 @@ let debounceTimer: ReturnType | null = null;
* Emit knowledge commit event to all renderer windows
*/
function emitKnowledgeCommitEvent(): void {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('knowledge:didCommit', {});
- }
- }
+ broadcastToWindows('knowledge:didCommit', {});
}
/**
* Emit workspace change event to all renderer windows
*/
function emitWorkspaceChangeEvent(event: z.infer): void {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('workspace:didChange', event);
- }
- }
+ broadcastToWindows('workspace:didChange', event);
}
/**
@@ -590,11 +601,12 @@ function handleWorkspaceChange(event: z.infer): void {
+// The one renderer fan-out: send a payload to every live window on a channel.
+// All broadcast feeds (runs, services, sessions, turns, code runs, agent
+// status) go through here.
+function broadcastToWindows(channel: string, payload: unknown): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('runs:events', event);
+ win.webContents.send(channel, payload);
}
}
}
+function emitRunEvent(event: z.infer): void {
+ broadcastToWindows('runs:events', event);
+}
+
function emitServiceEvent(event: z.infer): void {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('services:events', event);
- }
- }
+ broadcastToWindows('services:events', event);
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
// Native connection status (e.g. Google) is baked into the Copilot system
// prompt, so any OAuth state change must rebuild it.
invalidateCopilotInstructionsCache();
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('oauth:didConnect', event);
- }
- }
+ broadcastToWindows('oauth:didConnect', event);
}
async function requireCodeSession(sessionId: string): Promise {
@@ -669,12 +678,7 @@ export async function startCodeSessionStatusWatcher(): Promise {
const tracker = container.resolve('codeSessionStatusTracker');
await tracker.start();
codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('codeSession:status', { sessionId, status });
- }
- }
+ broadcastToWindows('codeSession:status', { sessionId, status });
});
}
@@ -690,12 +694,7 @@ export async function startRunsWatcher(): Promise {
// New runtime: session bus → renderer windows (session-design.md §10).
function emitSessionEvent(event: SessionBusEvent): void {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('sessions:events', event);
- }
- }
+ broadcastToWindows('sessions:events', event);
}
// Mobile channels: status changes (QR pairing, connect/disconnect) → renderer.
@@ -703,12 +702,7 @@ let channelsWatcher: (() => void) | null = null;
export function startChannelsWatcher(): void {
if (channelsWatcher) return;
channelsWatcher = subscribeChannelsStatus((status) => {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('channels:status', status);
- }
- }
+ broadcastToWindows('channels:status', status);
});
}
@@ -721,6 +715,55 @@ export function startSessionsWatcher(): void {
sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event));
}
+// Turn event spine → renderer windows: durable events of every turn the
+// runtime executes (session chat, headless background/knowledge runners,
+// spawned sub-agents), tagged with sessionId and the event's file offset so
+// consumers can join a live turn against a sessions:getTurn snapshot without
+// gaps or duplicates. Durable events are broadcast to every window;
+// text/reasoning deltas are high-volume and ephemeral, so they are sent only
+// to windows that subscribed to that turn via turns:subscribe.
+const turnDeltaSubs = new Map>();
+
+export function subscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void {
+ let turnIds = turnDeltaSubs.get(sender);
+ if (!turnIds) {
+ turnIds = new Set();
+ turnDeltaSubs.set(sender, turnIds);
+ sender.once('destroyed', () => turnDeltaSubs.delete(sender));
+ }
+ turnIds.add(turnId);
+}
+
+export function unsubscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void {
+ const turnIds = turnDeltaSubs.get(sender);
+ if (!turnIds) {
+ return;
+ }
+ turnIds.delete(turnId);
+ if (turnIds.size === 0) {
+ turnDeltaSubs.delete(sender);
+ }
+}
+
+let turnEventsWatcher: (() => void) | null = null;
+export function startTurnEventsWatcher(): void {
+ if (turnEventsWatcher) {
+ return;
+ }
+ const hub = container.resolve('turnEventBus');
+ turnEventsWatcher = hub.subscribeAll((event) => {
+ if (isDurableTurnEvent(event.event)) {
+ broadcastToWindows('turns:events', event);
+ return;
+ }
+ for (const [sender, turnIds] of turnDeltaSubs) {
+ if (turnIds.has(event.turnId) && !sender.isDestroyed()) {
+ sender.send('turns:events', event);
+ }
+ }
+ });
+}
+
// Ephemeral code-run stream: CodeRunFeed → all renderer windows. A direct
// tool→renderer side-channel that bypasses the turn runtime; the durable
// record is the settle-time code-run-events-batch tool progress.
@@ -731,12 +774,7 @@ export function startCodeRunFeedWatcher(): void {
}
const feed = container.resolve('codeRunFeed');
codeRunFeedWatcher = feed.subscribe((event) => {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('codeRun:events', event);
- }
- }
+ broadcastToWindows('codeRun:events', event);
});
}
@@ -767,12 +805,7 @@ let liveNoteAgentWatcher: (() => void) | null = null;
export function startLiveNoteAgentWatcher(): void {
if (liveNoteAgentWatcher) return;
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('live-note-agent:events', event);
- }
- }
+ broadcastToWindows('live-note-agent:events', event);
});
}
@@ -780,12 +813,7 @@ let backgroundTaskAgentWatcher: (() => void) | null = null;
export function startBackgroundTaskAgentWatcher(): void {
if (backgroundTaskAgentWatcher) return;
backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => {
- const windows = BrowserWindow.getAllWindows();
- for (const win of windows) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('bg-task-agent:events', event);
- }
- }
+ broadcastToWindows('bg-task-agent:events', event);
});
}
@@ -854,6 +882,59 @@ export function setupIpcHandlers() {
},
'updater:quitAndInstall': async () => {
quitAndInstallUpdate();
+ 'app:consumePendingTrayCommand': async () => {
+ return { toggleMeetingNotes: consumePendingToggleMeetingNotes() };
+ },
+ 'app:getLoginItemSettings': async () => {
+ // Dev builds never register a login item (it would point at the dev
+ // Electron binary), so report off.
+ if (!app.isPackaged) return { openAtLogin: false };
+ return { openAtLogin: app.getLoginItemSettings().openAtLogin };
+ },
+ 'app:setLoginItemSettings': async (_event, args) => {
+ if (app.isPackaged) {
+ app.setLoginItemSettings({
+ openAtLogin: args.openAtLogin,
+ ...(process.platform === 'win32' ? { args: ['--hidden'] } : {}),
+ });
+ // The user has expressed an explicit choice — never re-apply the
+ // first-run default over it.
+ saveAppSettings({ loginItemRegistered: true });
+ }
+ return { success: true as const };
+ },
+ 'meeting:setRecordingState': async (_event, args) => {
+ setTrayRecordingState(args.recording);
+ meetingRecordingActive = args.recording;
+ updateSelfCaptureState();
+ // Recording started through another path — a lingering "Take Notes?"
+ // popup is stale now.
+ if (args.recording) closeMeetingPopup();
+ return { success: true as const };
+ },
+ 'voice:setCallActive': async (_event, args) => {
+ voiceCallActive = args.active;
+ updateSelfCaptureState();
+ return { success: true as const };
+ },
+ 'meeting:notifyNotesReady': async (_event, args) => {
+ // Granola-style re-entry point: the note refreshed in place, but the
+ // user has usually switched back to the meeting app — the notification
+ // brings them back. Suppressed while the app is focused.
+ void notifyIfEnabled('meeting_notes_ready', {
+ title: 'Meeting notes ready',
+ message: `Your notes for "${args.title}" are ready.`,
+ link: `rowboat://open?type=file&path=${encodeURIComponent(args.notePath)}`,
+ actionLabel: 'Open notes',
+ onlyWhenBackground: true,
+ });
+ return { success: true as const };
+ },
+ 'meetingDetect:getPayload': async () => {
+ return { payload: getMeetingPopupPayload() };
+ },
+ 'meetingDetect:action': async (_event, args) => {
+ handleMeetingPopupAction(args.action);
return {};
},
'analytics:bootstrap': async () => {
@@ -897,7 +978,7 @@ export function setupIpcHandlers() {
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
},
'gmail:getEverythingElse': async (_event, args) => {
- return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
+ return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit, category: args.category });
},
'gmail:triggerSync': async () => {
triggerGmailSync();
@@ -931,6 +1012,33 @@ export function setupIpcHandlers() {
const result = setThreadImportance(args.threadId, args.importance);
return { ok: result.success, previous: result.previous, error: result.error };
},
+ 'gmail:setCategory': async (_event, args) => {
+ const result = setThreadCategory(args.threadId, args.category);
+ return { ok: result.success, error: result.error };
+ },
+ 'gmail:archiveCategory': async (_event, args) => {
+ return archiveCategoryThreads(args.category);
+ },
+ 'gmail:getEmailInstructions': async () => {
+ return { instructions: loadEmailInstructions() };
+ },
+ 'gmail:setEmailInstructions': async (_event, args) => {
+ const saved = saveEmailInstructions(args.instructions);
+ if (!saved.ok) return saved;
+ // Extract any custom labels the instructions define so they become
+ // valid classifier outputs immediately. Extraction failure shouldn't
+ // fail the save — the instructions themselves are already persisted
+ // and still steer classification as free text.
+ try {
+ await syncCustomLabelsFromInstructions(args.instructions);
+ } catch (err) {
+ console.warn('[EmailLabels] custom label extraction failed:', err);
+ }
+ return saved;
+ },
+ 'gmail:getEmailLabels': async () => {
+ return { labels: getEmailLabels().map(({ id, name, kind }) => ({ id, name, kind })) };
+ },
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
@@ -1051,6 +1159,14 @@ export function setupIpcHandlers() {
await container.resolve('sessions').deleteSession(args.sessionId);
return { success: true };
},
+ 'turns:subscribe': async (event, args) => {
+ subscribeTurnDeltas(event.sender, args.turnId);
+ return { success: true };
+ },
+ 'turns:unsubscribe': async (event, args) => {
+ unsubscribeTurnDeltas(event.sender, args.turnId);
+ return { success: true };
+ },
'sessions:downloadLog': async (event, args) => {
// Concatenate the session's turn logs into one JSONL for debugging.
const sessions = container.resolve('sessions');
@@ -1630,6 +1746,15 @@ export function setupIpcHandlers() {
for (const app of apps) {
if (app.agentSlugs.length) await appsAgents.syncAppAgents(app);
}
+ // The copilot instructions embed the installed-apps list. This handler
+ // is the one place that sees every change to the app set (installs,
+ // deletes, copilot-created folders — the renderer polls it), so refresh
+ // the instructions cache when the set actually changes.
+ const fingerprint = JSON.stringify(apps.map((a) => [a.folder, a.manifest?.name, a.manifest?.description, a.hasDist]));
+ if (fingerprint !== lastAppsFingerprint) {
+ lastAppsFingerprint = fingerprint;
+ invalidateCopilotInstructionsCache();
+ }
return {
serverRunning: status.running,
...(status.error ? { serverError: status.error } : {}),
@@ -1932,7 +2057,10 @@ export function setupIpcHandlers() {
},
// Search handler
'search:query': async (_event, args) => {
- return search(args.query, args.limit, args.types);
+ await sessionsIndexReady;
+ const sessions = container.resolve('sessions').listSessions()
+ .map((s) => ({ sessionId: s.sessionId, title: s.title }));
+ return search(args.query, args.limit, args.types, sessions);
},
// Inline task schedule classification
'export:note': async (event, args) => {
@@ -2188,6 +2316,9 @@ export function setupIpcHandlers() {
if (main.isMinimized()) main.restore();
main.show();
main.focus();
+ // The user is typically in another app (e.g. just left a meeting) —
+ // a plain focus() won't take the foreground from it.
+ app.focus({ steal: true });
}
return {};
},
diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts
index b862782b..6ac902de 100644
--- a/apps/x/apps/main/src/main.ts
+++ b/apps/x/apps/main/src/main.ts
@@ -1,8 +1,9 @@
-import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
+import { app, BrowserWindow, desktopCapturer, dialog, protocol, net, shell, session, safeStorage, type Session } from "electron";
import path from "node:path";
+import os from "node:os";
import {
setupIpcHandlers,
- startRunsWatcher, startSessionsWatcher, markSessionsIndexReady,
+ startRunsWatcher, startSessionsWatcher, startTurnEventsWatcher, markSessionsIndexReady,
startCodeRunFeedWatcher,
startChannelsWatcher,
startCodeSessionStatusWatcher,
@@ -23,7 +24,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
-import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
@@ -36,7 +36,7 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
-import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
+import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/assembly/skills/watcher.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
@@ -52,7 +52,7 @@ import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
-import type { ISessions } from "@x/core/dist/sessions/index.js";
+import type { ISessions } from "@x/core/dist/runtime/sessions/index.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
@@ -64,6 +64,11 @@ import {
setMainWindowForDeepLinks,
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
+import { startModelsDevRefresh } from "@x/core/dist/models/models-dev.js";
+import { loadAppSettings, saveAppSettings } from "@x/core/dist/config/app_settings.js";
+import { init as initMeetingDetection } from "@x/core/dist/meetings/detector.js";
+import { createAppTray, hasTray, isRecordingActive, markPendingToggleMeetingNotes } from "./tray.js";
+import { initMeetingPopup, showMeetingPopup } from "./meeting-popup.js";
// Captured as early as possible so it reflects actual process start. Used to
// gate grace-eligible notifications (e.g. the burst of background-task
@@ -73,6 +78,24 @@ const APP_LAUNCHED_AT = Date.now();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
+// fs.watch failures (EMFILE fd exhaustion, ENOSPC watch limits) surface as
+// uncaught exceptions from Node's watcher internals, bypassing chokidar's
+// 'error' handlers. Watching is a degradable feature — log and keep running.
+// Everything else keeps Electron's default behavior (native error dialog),
+// which we replicate since registering any listener suppresses it.
+process.on('uncaughtException', (err) => {
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
+ if ((code === 'EMFILE' || code === 'ENOSPC') && (err?.stack ?? '').includes('FSWatcher')) {
+ console.error('[Main] file watcher error (non-fatal):', err);
+ return;
+ }
+ console.error('[Main] uncaught exception:', err);
+ dialog.showErrorBox(
+ 'A JavaScript error occurred in the main process',
+ err?.stack ?? String(err),
+ );
+});
+
// run this as early in the main process as possible
if (started) app.quit();
@@ -257,7 +280,49 @@ function setupZoomShortcuts(win: BrowserWindow) {
});
}
-function createWindow() {
+// Resident-app plumbing (Granola-style): the main window may exist hidden
+// (launched at login) or not at all (closed on macOS) while the app keeps
+// running from the tray. showApp() is the single "bring the app up" path
+// used by the tray, the Dock, and pending tray commands.
+let mainWindow: BrowserWindow | null = null;
+
+function showApp(): void {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ if (!mainWindow.isVisible()) mainWindow.maximize();
+ mainWindow.show();
+ mainWindow.focus();
+ } else {
+ createWindow();
+ }
+ // The user is usually in another app (a meeting!) when this runs — a plain
+ // focus() won't take the foreground from it.
+ app.focus({ steal: true });
+}
+
+/**
+ * Was this process launched by the OS at login (rather than by the user)?
+ * Used to start with the window hidden so login launches are invisible.
+ *
+ * - Windows: our login item registers with an explicit --hidden arg.
+ * - macOS: wasOpenedAtLogin when available. On macOS 13+ (SMAppService)
+ * Electron doesn't reliably populate it (electron#37244), so fall back to
+ * a heuristic: a packaged launch while registered as a login item within
+ * two minutes of boot is treated as a login launch.
+ */
+function wasLaunchedAtLogin(): boolean {
+ if (process.argv.includes("--hidden")) return true;
+ if (process.platform !== "darwin" || !app.isPackaged) return false;
+ try {
+ const settings = app.getLoginItemSettings();
+ if (settings.wasOpenedAtLogin) return true;
+ return settings.openAtLogin && os.uptime() < 120;
+ } catch {
+ return false;
+ }
+}
+
+function createWindow(options: { startHidden?: boolean } = {}) {
const win = new BrowserWindow({
width: 1280,
height: 800,
@@ -283,11 +348,18 @@ function createWindow() {
configureSessionPermissions(session.defaultSession);
configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
+ mainWindow = win;
setMainWindowForDeepLinks(win);
- win.on("closed", () => setMainWindowForDeepLinks(null));
+ win.on("closed", () => {
+ if (mainWindow === win) mainWindow = null;
+ setMainWindowForDeepLinks(null);
+ });
- // Show window when content is ready to prevent blank screen
+ // Show window when content is ready to prevent blank screen.
+ // Launched-at-login starts stay hidden: the app is reachable from the
+ // tray/Dock, and showApp() maximizes on first reveal.
win.once("ready-to-show", () => {
+ if (options.startHidden) return;
win.maximize();
win.show();
});
@@ -374,6 +446,12 @@ app.whenReady().then(async () => {
// Initialize all config files before UI can access them
await initConfigs();
+ // Warm the models.dev catalog cache (single writer; refreshed every 24h
+ // while the app runs). Every consumer — catalog listings, the reasoning
+ // capability gate — reads the on-disk cache only. Best-effort: failures
+ // leave any existing cache in use and never block boot.
+ startModelsDevRefresh();
+
// PostHog identify() is idempotent — call it on every startup so existing
// signed-in installs (and every cold start of v0.3.4+) get re-identified.
// Otherwise main-process events stay anonymous until the user re-signs-in.
@@ -405,7 +483,79 @@ app.whenReady().then(async () => {
console.error('[Apps] Failed to start:', error);
});
- createWindow();
+ // Resident app (Granola-style): register as an OS login item once, on the
+ // first packaged run. After that the OS registry is the source of truth —
+ // the Settings toggle writes it directly, and disabling the login item in
+ // System Settings sticks because we never re-register on boot.
+ if (app.isPackaged && !loadAppSettings().loginItemRegistered) {
+ try {
+ app.setLoginItemSettings({
+ openAtLogin: true,
+ ...(process.platform === "win32" ? { args: ["--hidden"] } : {}),
+ });
+ saveAppSettings({ loginItemRegistered: true });
+ } catch (error) {
+ console.error("[LoginItem] Failed to register login item:", error);
+ }
+ }
+
+ createWindow({ startHidden: wasLaunchedAtLogin() });
+
+ // Menu bar icon: open the app / start-stop meeting notes without the
+ // window. If the renderer isn't ready to receive the toggle (window closed
+ // or still loading), park it as a pending command the renderer drains on
+ // mount — same pull pattern as pending deep links.
+ createAppTray({
+ openApp: showApp,
+ toggleMeetingNotes: () => {
+ const hadWindow = mainWindow !== null && !mainWindow.isDestroyed();
+ showApp();
+ const win = mainWindow;
+ if (!hadWindow || !win || win.webContents.isLoading()) {
+ markPendingToggleMeetingNotes();
+ return;
+ }
+ win.webContents.send("app:toggleMeetingNotes", null);
+ },
+ });
+
+ // Ambient meeting detection (Granola-style): the mic-monitor helper +
+ // running-app scan produce "Meeting detected" events; the popup asks
+ // before anything records. Clicking "Take Notes" routes into the same
+ // renderer flow as the calendar notification.
+ initMeetingPopup({
+ onTakeNotes: (meeting) => {
+ showApp();
+ // The user may have started recording between popup and click —
+ // sending the take-notes flow then would toggle it OFF.
+ if (isRecordingActive()) return;
+ const payload = {
+ event: meeting.calendarEvent ?? { summary: meeting.noteTitle },
+ openMeeting: false,
+ source: "detected",
+ };
+ const win = mainWindow;
+ if (!win || win.isDestroyed()) return;
+ if (win.webContents.isLoading()) {
+ win.webContents.once("did-finish-load", () => {
+ if (!win.isDestroyed()) win.webContents.send("app:takeMeetingNotes", payload);
+ });
+ return;
+ }
+ win.webContents.send("app:takeMeetingNotes", payload);
+ },
+ });
+ initMeetingDetection({
+ helperPath: path.join(__dirname, "mic-monitor"),
+ onDetected: (meeting) => showMeetingPopup(meeting),
+ // Call ended while recording (meeting app released the mic) — the
+ // renderer stops capture and generates notes, same as a manual stop.
+ onExternalCallEnded: () => {
+ const win = mainWindow;
+ if (!win || win.isDestroyed() || win.webContents.isLoading()) return;
+ win.webContents.send("meeting:externalCallEnded", null);
+ },
+ });
// Start workspace watcher as a main-process service
// Watcher runs independently and catches ALL filesystem changes:
@@ -447,6 +597,9 @@ app.whenReady().then(async () => {
markSessionsIndexReady();
}
startSessionsWatcher();
+ // Turn event spine: durable events of every turn (session, headless,
+ // sub-agent) → renderer, for turnId-keyed live views.
+ startTurnEventsWatcher();
startCodeRunFeedWatcher();
// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
@@ -505,9 +658,6 @@ app.whenReady().then(async () => {
// start knowledge graph builder
initGraphBuilder();
- // start email labeling service
- initEmailLabeling();
-
// start note tagging service
initNoteTagging();
@@ -530,14 +680,16 @@ app.whenReady().then(async () => {
initChromeSync();
app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow();
- }
+ // Reveal the hidden/closed main window (login launches start hidden).
+ showApp();
});
});
app.on("window-all-closed", () => {
- if (process.platform !== "darwin") {
+ // Resident app: with a tray present, keep running with no windows so
+ // meeting detection/notifications stay alive (Granola-style). Without a
+ // tray (creation failed), fall back to the platform-default quit.
+ if (process.platform !== "darwin" && !hasTray()) {
app.quit();
}
});
diff --git a/apps/x/apps/main/src/meeting-popup.ts b/apps/x/apps/main/src/meeting-popup.ts
new file mode 100644
index 00000000..5c5f7d53
--- /dev/null
+++ b/apps/x/apps/main/src/meeting-popup.ts
@@ -0,0 +1,160 @@
+import { app, BrowserWindow, screen } from "electron";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js";
+
+/**
+ * The "Meeting detected — Take Notes?" popup: a small frameless panel in the
+ * top-left corner of the display the user is on. A macOS panel (NSPanel) at
+ * screen-saver level with fullscreen-auxiliary behavior, so it floats over
+ * whatever the user is looking at — including a fullscreen Meet/Zoom — and
+ * appears without stealing focus. Auto-dismisses after a while and never
+ * records anything by itself: clicking "Take Notes" hands off to the main
+ * window's existing take-meeting-notes flow.
+ */
+
+// The window IS the card: exact card dimensions, card background, native
+// rounded corners + shadow. No transparency — macOS panels don't honor
+// `transparent: true` reliably and paint a grey backing slab instead.
+const POPUP_WIDTH = 376;
+const POPUP_HEIGHT = 48;
+// The popup renderer owns the real 45s countdown (it pauses on hover and
+// draws the progress line); this is only a crash-safety net so a wedged
+// renderer can't leave the popup on screen forever.
+const FALLBACK_DISMISS_MS = 180_000;
+
+// Display names, Granola-style ("Chrome", not "Google Chrome").
+const SHORT_APP_NAMES: Record = {
+ "Google Chrome": "Chrome",
+ "Microsoft Edge": "Edge",
+ "Brave Browser": "Brave",
+ "Microsoft Teams": "Teams",
+};
+
+export interface MeetingPopupPayload {
+ title: string;
+ message: string;
+ hasCalendarEvent: boolean;
+}
+
+let popupWin: BrowserWindow | null = null;
+let currentPayload: MeetingPopupPayload | null = null;
+let currentMeeting: DetectedMeeting | null = null;
+let dismissTimer: NodeJS.Timeout | null = null;
+let onTakeNotes: ((meeting: DetectedMeeting) => void) | null = null;
+
+/** Main registers the take-notes handoff once at startup. */
+export function initMeetingPopup(handlers: { onTakeNotes: (meeting: DetectedMeeting) => void }): void {
+ onTakeNotes = handlers.onTakeNotes;
+}
+
+export function getMeetingPopupPayload(): MeetingPopupPayload | null {
+ return currentPayload;
+}
+
+export function handleMeetingPopupAction(action: "take-notes" | "dismiss"): void {
+ const meeting = currentMeeting;
+ closeMeetingPopup();
+ if (action === "take-notes" && meeting) {
+ onTakeNotes?.(meeting);
+ }
+}
+
+export function closeMeetingPopup(): void {
+ if (dismissTimer) {
+ clearTimeout(dismissTimer);
+ dismissTimer = null;
+ }
+ currentPayload = null;
+ currentMeeting = null;
+ if (popupWin && !popupWin.isDestroyed()) popupWin.destroy();
+ popupWin = null;
+}
+
+export function showMeetingPopup(meeting: DetectedMeeting): void {
+ const eventSummary =
+ typeof meeting.calendarEvent?.summary === "string"
+ ? (meeting.calendarEvent.summary as string).trim()
+ : "";
+ const appLabel = SHORT_APP_NAMES[meeting.appName] ?? meeting.appName;
+ const payload: MeetingPopupPayload = {
+ // Lean two-liner: "Meeting detected" / "Chrome" — or the event name
+ // over the platform when a calendar event is happening now.
+ title: eventSummary ? eventSummary : meeting.title,
+ message: appLabel,
+ hasCalendarEvent: Boolean(meeting.calendarEvent),
+ };
+
+ // Replace any popup that's still up (stale detection loses to fresh).
+ closeMeetingPopup();
+ currentPayload = payload;
+ currentMeeting = meeting;
+
+ // Top-left of the display the user is actually on (cursor display), not
+ // necessarily the primary one.
+ const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
+ const workArea = display.workArea;
+ const popupDir = path.dirname(fileURLToPath(import.meta.url));
+ const preloadPath = app.isPackaged
+ ? path.join(popupDir, "../preload/dist/preload.js")
+ : path.join(popupDir, "../../../preload/dist/preload.js");
+
+ const win = new BrowserWindow({
+ width: POPUP_WIDTH,
+ height: POPUP_HEIGHT,
+ x: workArea.x + 24,
+ // Sit a bit clear of the menu bar rather than hugging it.
+ y: workArea.y + 44,
+ // NSPanel (macOS): non-activating, and — unlike a regular window with
+ // visibleOnFullScreen — can float over fullscreen Spaces without
+ // turning Rowboat into an "agent" app that loses its Dock icon.
+ ...(process.platform === "darwin" ? { type: "panel" as const } : {}),
+ frame: false,
+ resizable: false,
+ alwaysOnTop: true,
+ skipTaskbar: true,
+ show: false,
+ backgroundColor: "#1d1d1d",
+ hasShadow: true,
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: true,
+ preload: preloadPath,
+ },
+ });
+ // Screen-saver level + fullscreen-auxiliary: visible over fullscreen
+ // meeting apps on every workspace — the whole point of the popup is to be
+ // seen while the user is IN the meeting, wherever that is.
+ win.setAlwaysOnTop(true, "screen-saver");
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+ // visibleOnFullScreen flips the app's activation policy to "accessory":
+ // the Dock icon disappears and the app can no longer take foreground
+ // focus — clicking "Take Notes" then hands focus to whatever is next in
+ // the window stack instead of Rowboat. Restore the policy immediately:
+ // the popup keeps its fullscreen-auxiliary collection behavior (it's a
+ // non-activating panel, same trick as Zoom's floating controls), while
+ // Rowboat stays a regular app.
+ if (process.platform === "darwin") {
+ void app.dock?.show();
+ }
+ win.webContents.once("did-finish-load", () => {
+ if (win.isDestroyed()) return;
+ win.webContents.send("meetingDetect:payload", payload);
+ // showInactive: the user is in a meeting app right now — appearing
+ // must not steal focus from it.
+ win.showInactive();
+ });
+ win.on("closed", () => {
+ if (popupWin === win) popupWin = null;
+ });
+ popupWin = win;
+
+ if (app.isPackaged) {
+ win.loadURL("app://-/index.html#meeting-detected");
+ } else {
+ win.loadURL("http://localhost:5173/#meeting-detected");
+ }
+
+ dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS);
+}
diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts
index 738d861a..45a5207d 100644
--- a/apps/x/apps/main/src/test-agent.ts
+++ b/apps/x/apps/main/src/test-agent.ts
@@ -1,5 +1,5 @@
-import * as runsCore from '@x/core/dist/runs/runs.js';
-import { bus } from '@x/core/dist/runs/bus.js';
+import * as runsCore from '@x/core/dist/runtime/legacy/runs.js';
+import { bus } from '@x/core/dist/runtime/legacy/bus.js';
async function main() {
const { id } = await runsCore.createRun({
diff --git a/apps/x/apps/main/src/tray.ts b/apps/x/apps/main/src/tray.ts
new file mode 100644
index 00000000..2d55081d
--- /dev/null
+++ b/apps/x/apps/main/src/tray.ts
@@ -0,0 +1,167 @@
+import { app, Menu, Tray, nativeImage } from "electron";
+
+/**
+ * Menu bar / system tray presence (Granola-style resident app).
+ *
+ * The icon is the app glyph pre-rendered as a macOS "template" image
+ * (pure black + alpha, derived from icons/icon.png: alpha = pixel
+ * luminance, so the white sail becomes an opaque black shape and the
+ * black rounded square becomes transparent). Embedded as base64 so the
+ * tray never depends on asset paths that differ between dev and
+ * packaged layouts. Template rendering makes macOS tint it correctly
+ * in light/dark menu bars and while highlighted.
+ */
+const TRAY_ICON_18 =
+ "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAABRElEQVR4nKySMS9EQRSF7773REEUlFpRiEg0Oj1RqTQqCiLRKNV+gkLvF5CoJRqJPyBEqyMURJB9zzl5Z3bvTuZtdjdu8mVm7sw9c+fOzWw0a4HMO3Ib3DJRaV0NK5QrqHLBM2AB/IAvOgrrn0EJ2pqvgg2N82AarIBX7jcJtSQyCXbALliKzryDJ83LIiEQnrAFTsBcOOxg3CN4C4FFQmQCnINN+b/BmPZDvTi/0T597SwS4dtvwTK4VPrj1mu5zl9oXQWBMDKTfXAPruSfBcfgwOqi03j5A1h0vo5QJ0W3Dr9GOwN7eiYzPASn/owXSgmEBmTfsLhT4NnqHvqMg2IrozkvewHXyvpIIj2xqYyajJncWf2bPuvGjFL76+ADbFu3WW0YIdaHvcYfWrOoLt4GeVquC3+t228jCfmzVb/b/sX+AAAA//+fzjrgAAAABklEQVQDAHsTRGNUkus5AAAAAElFTkSuQmCC";
+const TRAY_ICON_36 =
+ "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC3klEQVR4nOyYu4sUQRDGa3fm8DgxUxB8ICqKqBj4QBOFMxMDRQUfgS/0D1A0MRQMTA1MxBcngpFmBiocgokgCopo4PnCSE193e74FV11XTtMz+zs9sLB3Qc/drenp/ubrqruuUtpmimlaaYZb6gBmvK9DbKiDoNUU+bIxEClEoqvRIxkOYbBcrAJ7ALzwATlFiVWyDQULYE1F2wE28E2sBYsJr8I58Fjc18UQw2hLYOOgJ1gHxgFSwru+SsmXsnvjjzq1ZA1wgOuBMfBQXJhUbUF7c8akraJWIZ4yVsyEIfhjBgZket6TRPa5mkmbd/AZ9PWk6GGmXAhuABOgTnSPikmmoH79F7+/Rr8IV+BtQ3xJFq2HJpLYkqNJNS5EkXbiVYb65kZt1XXkIZoAbhKLmGtkdRMFNrXMmOA+4zn2qdUtTGqma1gDKwAv2XgtOD+MkOaV5w/XAS/ijqmXZjZA+6QT9ph00erLDGTFpnitkn5fCRmbBpUGtLYHgU3pe0teAKegnVgL7kqI+lrjwlrKh+ue1Si0NPwICfBFXAX3CaXiP9MP16V3eSqbXOFKb32BawmF/ZCNQvaeDDOlWVgvRgbFzOJgSd5QO5YuEyd+5OaUnRzHBMzwTM0lIRD5FcjofBpnZDPIzZ1jnz12Qdk8ZGxBnykgv2nyhDLnthl0oOVjb0AG8itlOYnPxg/4C1wjALJrCp7/agykjfF/b+CI9QZNq2ww+BH1UBNiiPNkYfgHflQtuT7NfCe/KoHFfMFTZN6PthBblU4bLwq+ymwEeYVa4WsXspnW8Y/C75TSSJbDeIlf1Q+eUe/T24PK01kq9gv+fyAb8Aq8IncK+xPudZVkcQKmY6zhfzBeYBc/nQVKlXskJ0Wc4fAc6oRKlWMkOkKLCUXphPgBvmqqz1Yv9K95SL4AK73aiaWIRYfDYvInVO1w0QDMKTqywwr5k5dq5pCGsTf9n1p9h9WVfoPAAD//0eu+ckAAAAGSURBVAMAjdCu7L3gD4wAAAAASUVORK5CYII=";
+
+interface TrayActions {
+ openApp: () => void;
+ toggleMeetingNotes: () => void;
+}
+
+let tray: Tray | null = null;
+let actions: TrayActions | null = null;
+let recording = false;
+
+// Tray commands issued while the renderer wasn't ready to receive them
+// (window closed or still loading). Drained by the renderer on mount via
+// app:consumePendingTrayCommand — same pull pattern as pending deep links.
+let pendingToggleMeetingNotes = false;
+
+export function markPendingToggleMeetingNotes(): void {
+ pendingToggleMeetingNotes = true;
+}
+
+export function consumePendingToggleMeetingNotes(): boolean {
+ const value = pendingToggleMeetingNotes;
+ pendingToggleMeetingNotes = false;
+ return value;
+}
+
+function buildTrayIcon() {
+ const icon = nativeImage.createEmpty();
+ icon.addRepresentation({
+ scaleFactor: 1,
+ buffer: Buffer.from(TRAY_ICON_18, "base64"),
+ });
+ icon.addRepresentation({
+ scaleFactor: 2,
+ buffer: Buffer.from(TRAY_ICON_36, "base64"),
+ });
+ icon.setTemplateImage(true);
+ return icon;
+}
+
+export function createAppTray(trayActions: TrayActions): void {
+ if (tray) return;
+ actions = trayActions;
+
+ try {
+ tray = new Tray(buildTrayIcon());
+ } catch (error) {
+ // Tray support can be missing (some Linux environments). The app just
+ // behaves as before: no resident presence.
+ console.error("[Tray] Failed to create tray:", error);
+ return;
+ }
+
+ rebuildMenu();
+
+ // macOS opens the context menu on any click. On Windows/Linux a plain
+ // left-click should open the app; the menu stays on right-click.
+ if (process.platform !== "darwin") {
+ tray.on("click", () => actions?.openApp());
+ }
+}
+
+export function hasTray(): boolean {
+ return tray !== null;
+}
+
+export function isRecordingActive(): boolean {
+ return recording;
+}
+
+export function setTrayRecordingState(isRecording: boolean): void {
+ if (recording === isRecording) return;
+ recording = isRecording;
+ rebuildMenu();
+ if (isRecording) startWaveAnimation();
+ else stopWaveAnimation();
+}
+
+// --- Recording indicator: animated mini-waveform beside the tray icon ---
+// macOS renders tray titles to the right of the icon. Braille cells give
+// 1-dot-wide bars (two bars per character, four height steps each) — a slim
+// waveform, an unmissable "Rowboat is capturing this meeting" signal.
+
+const WAVE_FRAME_MS = 300;
+const WAVE_BAR_COUNT = 5;
+// Dot bits for a bar of height 1–4 (index 0–3), built bottom-up. Two bars
+// per braille cell (left column: dots 7,3,2,1 — right column: dots 8,6,5,4)
+// keeps the columns tightly packed; the sine wave keeps every bar ≥1 dot so
+// no column ever reads as missing.
+const WAVE_LEFT_BITS = [0x40, 0x44, 0x46, 0x47];
+const WAVE_RIGHT_BITS = [0x80, 0xa0, 0xb0, 0xb8];
+// Radians per bar / per frame: together they make the crest travel smoothly
+// leftward across the five bars.
+const WAVE_SPATIAL_STEP = 1.1;
+const WAVE_PHASE_STEP = 0.9;
+
+let waveTimer: NodeJS.Timeout | null = null;
+let wavePhase = 0;
+
+function waveString(phase: number): string {
+ const levels: number[] = [];
+ for (let i = 0; i < WAVE_BAR_COUNT; i++) {
+ const level = Math.round(1.5 + 1.5 * Math.sin(phase + i * WAVE_SPATIAL_STEP));
+ levels.push(Math.min(3, Math.max(0, level)));
+ }
+ let out = "";
+ for (let i = 0; i < levels.length; i += 2) {
+ const left = WAVE_LEFT_BITS[levels[i]];
+ const right = levels[i + 1] !== undefined ? WAVE_RIGHT_BITS[levels[i + 1]] : 0;
+ out += String.fromCharCode(0x2800 + left + right);
+ }
+ return out;
+}
+
+function startWaveAnimation(): void {
+ if (!tray || process.platform !== "darwin") return;
+ stopWaveAnimation();
+ waveTimer = setInterval(() => {
+ if (!tray) return;
+ wavePhase += WAVE_PHASE_STEP;
+ tray.setTitle(` ${waveString(wavePhase)}`, { fontType: "monospaced" });
+ }, WAVE_FRAME_MS);
+}
+
+function stopWaveAnimation(): void {
+ if (waveTimer) {
+ clearInterval(waveTimer);
+ waveTimer = null;
+ }
+ if (tray && process.platform === "darwin") tray.setTitle("");
+}
+
+function rebuildMenu(): void {
+ if (!tray) return;
+ const menu = Menu.buildFromTemplate([
+ { label: "Open Rowboat", click: () => actions?.openApp() },
+ recording
+ ? {
+ label: "Stop recording and generate notes",
+ click: () => actions?.toggleMeetingNotes(),
+ }
+ : {
+ label: "Start meeting notes",
+ click: () => actions?.toggleMeetingNotes(),
+ },
+ { type: "separator" },
+ { label: "Quit Rowboat", click: () => app.quit() },
+ ]);
+ tray.setContextMenu(menu);
+ tray.setToolTip(recording ? "Rowboat — recording meeting" : "Rowboat");
+}
diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json
index 0a8c9f9f..15b53e2d 100644
--- a/apps/x/apps/renderer/package.json
+++ b/apps/x/apps/renderer/package.json
@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "typecheck": "tsc -b",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
@@ -48,7 +49,7 @@
"@x/shared": "workspace:*",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
- "ai": "^5.0.117",
+ "ai": "^7.0.22",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css
index b83e7dd7..6a7b644a 100644
--- a/apps/x/apps/renderer/src/App.css
+++ b/apps/x/apps/renderer/src/App.css
@@ -460,12 +460,24 @@
font-size: 13px;
}
+/* The bold lead (summary or subject) may shrink — with ellipsis — but only
+ after the snippet span (huge shrink factor below) has fully collapsed.
+ Without this, a long summary plus the never-shrinking category chip
+ overflows the clipped row and the chip is what gets cut off. */
.gmail-row-content strong {
- flex-shrink: 0;
+ flex-shrink: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
color: inherit;
font-weight: 400;
}
+/* Snippet gives way first (chips carry .gmail-row-chip and keep shrink 0). */
+.gmail-row-content > span:not(.gmail-row-chip) {
+ flex-shrink: 1000;
+}
+
.gmail-row-date {
justify-self: end;
color: var(--gm-text-faint);
@@ -473,6 +485,114 @@
font-variant-numeric: tabular-nums;
}
+/* Category / waiting-age pill at the right edge of the content column. */
+.gmail-row-chip {
+ flex-shrink: 0;
+ margin-left: auto;
+ padding: 1px 8px;
+ border: 1px solid var(--gm-border);
+ border-radius: 999px;
+ color: var(--gm-text-faint);
+ font-size: 11px;
+ line-height: 16px;
+ white-space: nowrap;
+}
+
+/* Two chips on one row (category + status): only the first takes the auto
+ margin; the second sits right next to it, so the pair clusters at the
+ right edge instead of spreading across the free space. */
+.gmail-row-chip + .gmail-row-chip {
+ margin-left: 6px;
+}
+
+.gmail-row-chip-waiting {
+ border-color: transparent;
+ background: var(--gm-bg-pill-hover);
+ color: var(--gm-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.gmail-row-chip-ready {
+ border-color: transparent;
+ background: var(--gm-bg-row-selected);
+ color: var(--gm-accent);
+}
+
+/* Shown in place of the "Needs you" section when it's empty. */
+.gmail-caughtup {
+ padding: 18px 24px;
+ color: var(--gm-text-faint);
+ font-size: 13px;
+}
+
+/* Category filter pills under the "Everything else" header. */
+.gmail-category-pills {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 24px;
+ border-bottom: 1px solid var(--gm-border);
+}
+
+.gmail-category-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 10px;
+ border: 1px solid var(--gm-border);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--gm-text-muted);
+ font-family: inherit;
+ font-size: 12px;
+ line-height: 18px;
+ cursor: pointer;
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
+}
+
+.gmail-category-pill:hover {
+ background: var(--gm-bg-pill-hover);
+ color: var(--gm-text-strong);
+}
+
+.gmail-category-pill-active {
+ background: var(--gm-bg-row-selected);
+ border-color: var(--gm-accent);
+ color: var(--gm-text-strong);
+}
+
+.gmail-category-pill-count {
+ color: var(--gm-text-faint);
+ font-variant-numeric: tabular-nums;
+}
+
+.gmail-category-pill-archive {
+ margin-left: auto;
+ border-color: transparent;
+ background: var(--gm-bg-pill-hover);
+ color: var(--gm-text-strong);
+}
+
+.gmail-category-pill-archive:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+/* The category chip in the thread-detail toolbar is a button (dropdown trigger). */
+.gmail-category-chip {
+ flex-shrink: 0;
+ background: transparent;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background 120ms ease, color 120ms ease;
+}
+
+.gmail-category-chip:hover {
+ background: var(--gm-bg-pill-hover);
+ color: var(--gm-text-strong);
+}
+
.gmail-detail {
display: flex;
min-width: 0;
@@ -1240,10 +1360,14 @@
}
.graph-view {
- background-color: var(--background);
+ background-color: #f8f8f9;
user-select: none;
}
+.dark .graph-view {
+ background-color: #0b0b0d;
+}
+
.graph-view::before {
content: '';
position: absolute;
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 4bfec2cd..f61d8200 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -2,7 +2,7 @@ import * as React from 'react'
import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react'
import { workspace } from '@x/shared';
import { RunEvent } from '@x/shared/src/runs.js';
-import type { LanguageModelUsage, ToolUIPart } from 'ai';
+import type { ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
@@ -51,6 +51,7 @@ import {
import {
Message,
MessageContent,
+ MessageCopyButton,
MessageResponse,
} from '@/components/ai-elements/message';
import {
@@ -115,9 +116,11 @@ import {
isErrorMessage,
isToolCall,
isToolGroup,
+ isTurnUsageMessage,
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
+ REASONING_EFFORT_LABELS,
toToolState,
} from '@/lib/chat-conversation'
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
@@ -134,6 +137,7 @@ import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
import * as analytics from '@/lib/analytics'
import { playAckCue } from '@/lib/call-sounds'
import { useTheme } from '@/contexts/theme-context'
+import { TokenUsageMenu } from '@/components/token-usage-menu'
type DirEntry = z.infer
type RunEventType = z.infer
@@ -455,7 +459,17 @@ const buildBgTaskSetupPrompt = (description: string) =>
const buildBgTaskEditPrompt = (slug: string) =>
`Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.`
-const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => {
+// The renderer displays our internal (flat) usage shape that arrives over IPC,
+// not the AI SDK's restructured LanguageModelUsage (nested token details).
+type UsageSummary = {
+ inputTokens?: number
+ outputTokens?: number
+ totalTokens?: number
+ reasoningTokens?: number
+ cachedInputTokens?: number
+}
+
+const normalizeUsage = (usage?: UsageSummary | null): UsageSummary | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
if (!hasNumbers) return null
@@ -957,7 +971,7 @@ function App() {
return
}
}, [conversation])
- const [, setModelUsage] = useState(null)
+ const [, setModelUsage] = useState(null)
const [runId, setRunId] = useState(null)
// New runtime: the active session's chat data + actions. All logic lives in
// SessionChatStore (tested headlessly); the hook is a thin subscription.
@@ -1070,6 +1084,13 @@ function App() {
// floating popout; camera on → full-screen call; camera off → popout
// (mascot pill). Handlers live below the voice/submit plumbing they drive.
const video = useVideoMode()
+ // Assistant calls hold the mic — tell main so ambient meeting detection
+ // doesn't mistake our own capture for an external meeting.
+ useEffect(() => {
+ void window.ipc
+ .invoke('voice:setCallActive', { active: video.state !== 'idle' })
+ .catch(() => { /* detection may be unavailable */ })
+ }, [video.state])
const [inCall, setInCall] = useState(false)
const inCallRef = useRef(false)
// User explicitly shrank the full-screen call to the floating pill.
@@ -1088,6 +1109,24 @@ function App() {
handleToggleMeetingRef.current?.()
})
+ // Keep the tray menu in sync with meeting capture ("Start meeting notes"
+ // vs "Stop recording & generate notes").
+ useEffect(() => {
+ void window.ipc
+ .invoke('meeting:setRecordingState', { recording: meetingTranscription.state === 'recording' })
+ .catch(() => { /* tray may be unavailable */ })
+ }, [meetingTranscription.state])
+
+ // Main detected the meeting app released the mic (call ended) — stop and
+ // generate notes, exactly like a manual stop. Listener only exists while
+ // recording, so a stale signal can never toggle a new recording ON.
+ useEffect(() => {
+ if (meetingTranscription.state !== 'recording') return
+ return window.ipc.on('meeting:externalCallEnded', () => {
+ handleToggleMeetingRef.current?.()
+ })
+ }, [meetingTranscription.state])
+
// Check if voice is available on mount and when OAuth state changes
const refreshVoiceAvailability = useCallback(() => {
Promise.all([
@@ -1428,6 +1467,9 @@ function App() {
const chatViewStateByTabRef = useRef(chatViewStateByTab)
const chatDraftsRef = useRef(new Map())
const selectedModelByTabRef = useRef(new Map())
+ // Reasoning effort is per-tab, next-turn intent like the model selection —
+ // but unlike model it is never frozen on a run; it applies turn by turn.
+ const reasoningEffortByTabRef = useRef(new Map())
// Work directory is per-chat. Keyed by tab id; null/absent means none set.
const [workDirByTab, setWorkDirByTab] = useState>({})
const workDirByTabRef = useRef(workDirByTab)
@@ -1597,6 +1639,7 @@ function App() {
runId,
conversation,
currentAssistantMessage,
+ sessionUsage: {},
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
allPermissionRequests: new Map(allPermissionRequests),
permissionResponses: new Map(permissionResponses),
@@ -2969,6 +3012,7 @@ function App() {
// Per-message turn config. Composition inputs land in the system prompt
// via the agent resolver; keep them session-sticky where possible so the
// provider prefix cache survives across turns.
+ const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId)
const sendConfig = {
agent: {
agentId,
@@ -2986,6 +3030,7 @@ function App() {
},
},
autoPermission: (permissionMode ?? 'manual') === 'auto',
+ ...(reasoningEffort ? { reasoningEffort } : {}),
}
const userMessageContextFor = (middlePane: Awaited>) => ({
currentDateTime: new Date().toISOString(),
@@ -3141,7 +3186,6 @@ function App() {
toolCallId: string,
subflow: string[],
response: 'approve' | 'deny',
- scope?: 'once' | 'session' | 'always',
) => {
if (!runId) return
@@ -3150,7 +3194,6 @@ function App() {
await sessionChat.respondToPermission(
toolCallId,
response === 'approve' ? 'allow' : 'deny',
- scope ? { scope } : undefined,
)
} catch (error) {
console.error('Failed to authorize permission:', error)
@@ -3333,6 +3376,7 @@ function App() {
})
chatDraftsRef.current.delete(tabId)
selectedModelByTabRef.current.delete(tabId)
+ reasoningEffortByTabRef.current.delete(tabId)
chatScrollTopByTabRef.current.delete(tabId)
setWorkDirByTab((prev) => {
if (!(tabId in prev)) return prev
@@ -4677,11 +4721,23 @@ function App() {
return () => observer.disconnect()
}, [])
+ // Tray menu "Start/Stop meeting notes": same toggle as the Meetings header
+ // button. Also drains a toggle parked while the window was closed/loading
+ // (mirrors the pending deep-link pull above).
+ useEffect(() => {
+ void window.ipc.invoke('app:consumePendingTrayCommand', null).then(({ toggleMeetingNotes }) => {
+ if (toggleMeetingNotes) handleToggleMeetingRef.current?.()
+ })
+ return window.ipc.on('app:toggleMeetingNotes', () => {
+ handleToggleMeetingRef.current?.()
+ })
+ }, [])
+
// Triggered by main when the user clicks a calendar-meeting notification.
// Reuses the same flow as the in-app "Join meeting & take notes" button.
// When `openMeeting` is true, also opens the meeting URL in the system browser.
useEffect(() => {
- return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
+ return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, source }) => {
const e = event as {
summary?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
@@ -4705,7 +4761,7 @@ function App() {
location: e.location,
htmlLink: e.htmlLink,
conferenceLink,
- source: 'calendar-sync',
+ source: source ?? 'calendar-sync',
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
})
@@ -4780,6 +4836,7 @@ function App() {
case 'knowledge': void navigateToView({ type: 'knowledge-view' }); break
case 'workspace': void navigateToView({ type: 'workspace' }); break
case 'code': void navigateToView({ type: 'code' }); break
+ case 'apps': openAppsView(); break
}
}
@@ -5635,6 +5692,14 @@ function App() {
})
// Refresh the file view
await handleVoiceNoteCreated(notePath)
+ // Notes are done — bring Rowboat to the foreground on the
+ // finished note (the post-call "redirect"). The notification
+ // below is background-only, so it only fires if the focus
+ // grab didn't take.
+ void window.ipc.invoke('app:focusMainWindow', null).catch(() => {})
+ void window.ipc
+ .invoke('meeting:notifyNotesReady', { notePath, title: noteTitle })
+ .catch(() => { /* notification is best-effort */ })
}
}
} catch (err) {
@@ -5906,14 +5971,17 @@ function App() {
{item.content && (
-
-
- {item.content}
-
-
+