diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml
index e68ce4f4..6566f105 100644
--- a/.github/workflows/electron-build.yml
+++ b/.github/workflows/electron-build.yml
@@ -16,14 +16,14 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
- uses: pnpm/action-setup@v6
+ uses: pnpm/action-setup@v4
with:
- version: 10
+ version: 9
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: 24.15.0
+ node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@@ -39,17 +39,17 @@ jobs:
node -e "
const fs = require('fs');
const version = '${{ steps.version.outputs.version }}';
-
+
// Update apps/x/package.json
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
rootPackage.version = version;
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
-
+
// Update apps/x/apps/main/package.json
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
mainPackage.version = version;
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
-
+
console.log('Updated version to:', version);
"
@@ -61,25 +61,25 @@ jobs:
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
-
+
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
-
+
# Decode and import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
-
+
# Allow codesign to access the keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
-
+
# Add keychain to search list
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
-
+
# Verify certificate was imported
security find-identity -v "$KEYCHAIN_PATH"
-
+
# Clean up certificate file
rm -f $RUNNER_TEMP/certificate.p12
@@ -111,7 +111,6 @@ jobs:
with:
name: distributables
path: apps/x/apps/main/out/make/*
- if-no-files-found: error
retention-days: 30
build-linux:
@@ -122,14 +121,14 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
- uses: pnpm/action-setup@v6
+ uses: pnpm/action-setup@v4
with:
- version: 10
+ version: 9
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: 24.15.0
+ node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@@ -145,17 +144,17 @@ jobs:
node -e "
const fs = require('fs');
const version = '${{ steps.version.outputs.version }}';
-
+
// Update apps/x/package.json
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
rootPackage.version = version;
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
-
+
// Update apps/x/apps/main/package.json
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
mainPackage.version = version;
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
-
+
console.log('Updated version to:', version);
"
@@ -163,25 +162,12 @@ jobs:
run: pnpm install --frozen-lockfile
working-directory: apps/x
- - name: Build node-pty native binary for Linux
- working-directory: apps/x
- run: |
- # node-pty ships prebuilt binaries only for darwin/win32; compile the
- # linux-x64 binary so bundle.mjs can stage it into the package. Without
- # this the Linux app crashes on launch (missing prebuilds/linux-x64/pty.node).
- PTY="node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty"
- cd "$PTY"
- npx node-gyp rebuild
- mkdir -p prebuilds/linux-x64
- cp build/Release/pty.node prebuilds/linux-x64/
-
- name: Build electron app
env:
VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}
VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ROWBOAT_SKIP_PACMAN: '1' # Arch Linux package is built locally only, never in CI
- run: npx electron-forge publish --arch=x64 --platform=linux
+ run: npx electron-forge publish --arch=x64,arm64 --platform=linux
working-directory: apps/x/apps/main
- name: Upload workflow artifacts
@@ -189,7 +175,6 @@ jobs:
with:
name: distributables-linux
path: apps/x/apps/main/out/make/*
- if-no-files-found: error
retention-days: 30
build-windows:
@@ -200,14 +185,14 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
- uses: pnpm/action-setup@v6
+ uses: pnpm/action-setup@v4
with:
- version: 10
+ version: 9
- name: Setup Node.js
uses: actions/setup-node@v6
with:
- node-version: 24.15.0
+ node-version: 24
cache: 'pnpm'
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
@@ -225,17 +210,17 @@ jobs:
node -e "
const fs = require('fs');
const version = '${{ steps.version.outputs.version }}';
-
+
// Update apps/x/package.json
const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8'));
rootPackage.version = version;
fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n');
-
+
// Update apps/x/apps/main/package.json
const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8'));
mainPackage.version = version;
fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n');
-
+
console.log('Updated version to:', version);
"
@@ -256,5 +241,4 @@ jobs:
with:
name: distributables-windows
path: apps/x/apps/main/out/make/*
- if-no-files-found: error
retention-days: 30
diff --git a/.github/workflows/rowboat-build.yml b/.github/workflows/rowboat-build.yml
new file mode 100644
index 00000000..270e6263
--- /dev/null
+++ b/.github/workflows/rowboat-build.yml
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 00000000..c411ab68
--- /dev/null
+++ b/.github/workflows/x-publish.yml
@@ -0,0 +1,43 @@
+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
deleted file mode 100644
index c08c4095..00000000
--- a/.github/workflows/x-tests.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: rowboat
-
-on:
- pull_request:
- push:
- branches: [main]
- workflow_dispatch:
-
-jobs:
- apps-x-vitest:
- name: apps/x Vitest suites
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: apps/x
- steps:
- - uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v4
- with:
- version: 10
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: pnpm
- cache-dependency-path: apps/x/pnpm-lock.yaml
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # Builds @x/shared (core/renderer tests import its dist), then runs
- # the shared, core, and renderer vitest suites in order.
- - 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
- defaults:
- run:
- working-directory: apps/x
- steps:
- - uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v4
- with:
- version: 10
-
- - uses: actions/setup-node@v4
- with:
- node-version: 24.15.0
- cache: pnpm
- cache-dependency-path: apps/x/pnpm-lock.yaml
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Package Electron app without signing
- env:
- ROWBOAT_SKIP_CODE_SIGNING: "1"
- run: npm run package
- working-directory: apps/x/apps/main
diff --git a/.gitignore b/.gitignore
index c9eee447..086ea0b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,3 @@
data/
.venv/
.claude/
-
-# Local-only meeting-prep planning doc
-/PLAN.md
diff --git a/CLAUDE.md b/CLAUDE.md
index a5fe24c8..75a0f6a5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,9 +109,7 @@ Long-form docs for specific features. Read the relevant file before making chang
| Feature | Doc |
|---------|-----|
| 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 | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
## Common Tasks
@@ -139,7 +137,6 @@ 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 9e002cd1..361b87a0 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,9 @@
-
+
-Rowboat
- A desktop AI coworker with a memory of your work and built-in surfaces to act on it.
-
@@ -28,105 +25,28 @@
+# Rowboat
+**Open-source AI coworker that turns work into a knowledge graph and acts on it**
+
-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 connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine.
+You can do things like:
+- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
+- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
+- Track a person, company or topic through live notes
+- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
+- Record voice memos that automatically capture and update key takeaways in the graph
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
-
-
-
-
-
-
-
- Demo - apps to code · Demo - knowledge graph
-
-
-
⭐ If you find Rowboat useful, please star the repo. It helps more people find it.
----
-## Overview
+## Demo
+[](https://www.youtube.com/watch?v=7xTpciZCfpw)
-
-
-
-Brain
-Rowboat indexes email, meetings, slack and assistant conversations into a living Obsidian-style backlinked knowledge graph.
-
-
-
-
-
-
-
-Email
-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.
-
-
-
-
-
-
-
-
-Built-in Browser
-Rowboat includes a browser that lets you and assistant collaborate on web tasks. Because it's 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.
-
-
-
-
-
-
-
-Code Mode
-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 build your own work surfaces inside Rowboat — they get access to all the tools and integrations, and you can share them with other people.
-
-
-
-
-
-
-
-Integrations
-Includes one-click integrations to most popular products.
-
-
-
-
-
-
-
+[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
---
@@ -161,6 +81,23 @@ All API key files use the same format:
}
```
+## What it does
+
+Rowboat is a **local-first AI coworker** that can:
+- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments)
+- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc)
+- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
+
+Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
+
+## Integrations
+
+Rowboat builds memory from the work you already do, including:
+- **Gmail** (email)
+- **Google Calendar**
+- **Rowboat meeting notes** or **Fireflies**
+
+It also contains a library of product integrations through Composio.dev
## How it’s different
@@ -174,6 +111,24 @@ Rowboat maintains **long-lived knowledge** instead:
The result is memory that compounds, rather than retrieval that starts cold every time.
+## What you can do with it
+
+- **Meeting prep** from prior decisions, threads, and open questions
+- **Email drafting** grounded in history and commitments
+- **Docs & decks** generated from your ongoing context (including PDF slides)
+- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
+- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
+
+## Live notes
+
+Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note.
+
+- Track a competitor or market topic across X, Reddit, and the news
+- Monitor a person, project, or deal across web or your communications
+- Keep a running summary of any subject you care about
+
+Everything is written back into your local Markdown vault. You control what runs and when.
+
## Bring your own model
Rowboat works with the model setup you prefer:
diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts
index b2a5a94f..cd16f05e 100644
--- a/apps/cli/src/application/lib/command-executor.ts
+++ b/apps/cli/src/application/lib/command-executor.ts
@@ -4,13 +4,7 @@ import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/securit
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
-// Order matters: longer separators (`||`, `&&`) must precede their single-char
-// prefixes (`|`, `&`) so the leftmost-longest match consumes the right token.
-// `&` (background), backtick / `$(` (command substitution), and `(` `)`
-// (subshell) are also command separators — without them, `echo hi & rm /x`,
-// `echo \`rm /x\``, and `echo $(rm /x)` slip past isBlocked() with only
-// `echo` in the allowlist.
-const COMMAND_SPLIT_REGEX = /(?:\|\||&&|&|;|\||\n|`|\$\(|\(|\))/;
+const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
diff --git a/apps/cli/todo.md b/apps/cli/todo.md
index 3ca4d45c..9c97afd3 100644
--- a/apps/cli/todo.md
+++ b/apps/cli/todo.md
@@ -12,4 +12,4 @@ o file logging
rowboat agent
---
-- create agent
+- create agent
\ No newline at end of file
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 3178f741..8442b158 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 8fbf2a1d..1089d7e0 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.
+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
diff --git a/apps/docs/docs/development/roadmap.mdx b/apps/docs/docs/development/roadmap.mdx
index 8202b2b2..6248e567 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.
+Explore the future development plans and upcoming features for Rowboat.
\ No newline at end of file
diff --git a/apps/x/.gitignore b/apps/x/.gitignore
index 2c5f63fd..db195fb4 100644
--- a/apps/x/.gitignore
+++ b/apps/x/.gitignore
@@ -1,3 +1,2 @@
node_modules/
test-fixtures/
-*.tsbuildinfo
diff --git a/apps/x/.pnpm-store/v11/index.db b/apps/x/.pnpm-store/v11/index.db
deleted file mode 100644
index 7f9770bd..00000000
Binary files a/apps/x/.pnpm-store/v11/index.db and /dev/null differ
diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md
index 5bc0f456..572e9a6f 100644
--- a/apps/x/ANALYTICS.md
+++ b/apps/x/ANALYTICS.md
@@ -16,15 +16,13 @@
## Event catalog
-All PostHog events include `app_version` and `platform: 'desktop'` automatically. Main-process events add them in `packages/core/src/analytics/posthog.ts`; renderer events get them from the `analytics:bootstrap` IPC payload via `posthog.register` (plus an initialization-time `before_send` hook for `app_version`). `platform` guards against the legacy web dashboard's autocapture (`apps/rowboat`, unidentified by design) muddying desktop dashboards if it ever shares the project.
-
### `llm_usage`
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
| Property | Type | Notes |
|---|---|---|
-| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` |
+| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
| `model` | string | e.g. `claude-sonnet-4-6` |
@@ -41,9 +39,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 (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` | (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` | `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/runtime/tools/domains/parsing.ts:179` |
+| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` |
| `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,10 +51,10 @@ 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) |
-| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) |
##### `live_note_agent` sub-use-case shape
@@ -91,106 +89,9 @@ All in `apps/renderer/src/lib/analytics.ts`:
- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }`
- `oauth_connected` / `oauth_disconnected` — `{ provider }`
- `voice_input_started` — no properties
-- `call_started` — `{ preset: 'voice' | 'video' | 'share' | 'practice' }` — a hands-free call began (see `apps/x/VIDEO_MODE.md`)
-- `call_turn_latency` — `{ endpoint_to_submit_ms, submit_to_speak_ms, speak_to_audio_ms, total_ms }` — voice-to-voice latency breakdown for one call turn (utterance accepted → submitted → first TTS speak → audio playing)
- `search_executed` — `{ types: string[] }`
- `note_exported` — `{ format }`
-### Client auto-update funnel
-
-The desktop client's own updates — distinct from the in-app apps feature, which owns `app_updated`:
-
-- `update_prompted` — renderer (`apps/renderer/src/lib/analytics.ts`): the "Update available" card was shown for a staged update
-- `update_restarted` — main (`apps/main/src/updater.ts`), `{ from, to? }`: the user clicked restart-to-update (`to` may be missing when the update feed doesn't report the release name)
-- `update_failed` — main (`apps/main/src/updater.ts`), `{ message }`: the auto-updater errored (includes network errors for now)
-- `client_updated` — main (`apps/main/src/ipc.ts`), `{ from, to }`: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire)
-### `view_opened` — feature-importance funnel
-
-One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire.
-
-This is the top of every feature funnel: unique users on `view = 'email'` ÷ all users = how many people even open email. First visit to a key view also sets a one-shot person property (`has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code`) for cohort building.
-
-### Feature action events
-
-All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers); the emit sites are in the components named below. Events marked **(main)** are captured in `apps/main/src/ipc.ts` via `capture()` because the operation runs there.
-
-**Email** (`components/email-view.tsx`):
-
-- `email_thread_opened` — a thread was expanded in the list
-- `email_compose_opened` — `{ mode: 'new' | 'reply' | 'replyAll' | 'forward' | 'draft' }` — a composer was opened
-- `email_sent` — `{ mode, has_attachments, ai_assisted }` — `ai_assisted` is true when Write-with-AI produced a draft in that composer
-- `email_ai_draft_generated` — `{ mode: 'generate' | 'rewrite' }` — the Write/Edit-with-AI bar completed
-- `email_archived` / `email_trashed` — one thread archived / moved to trash
-- `email_marked_unread` — explicit mark-as-unread (marking *read* fires automatically on open, so it's deliberately not tracked)
-- `email_importance_changed` — `{ importance: 'important' | 'other' }` — user corrected the importance verdict
-- `email_category_changed` — `{ category }` — user re-filed a thread
-- `email_category_archived` — `{ category }` — bulk "archive all in category"
-- `email_searched` — a search query executed (debounced, one per settled query)
-- `email_instructions_saved` — standing email-agent instructions saved
-- `email_sync_triggered` — manual refresh button
-
-**Meetings** (`App.tsx`, `components/meetings-view.tsx`):
-
-- `meeting_recording_started` — `{ has_calendar_event }` — transcription actually began (all entry points: meetings view, home, sidebar, popup funnel through one call site)
-- `meeting_recording_stopped` — `{ duration_seconds }`
-- `meeting_popup_action` — `{ action: 'take-notes' | 'dismiss' }` **(main)** — the "meeting detected" popup window runs without PostHog, so the action is captured in its IPC handler
-- `meeting_note_opened` — a past meeting note opened from the meetings list
-
-**Calls** (`App.tsx`):
-
-- `call_started` — (pre-existing, above) fires on every call-button press that starts a call
-- `call_ended` — `{ duration_seconds }`
-
-**Background agents** (`components/bg-tasks-view.tsx`, `components/apps/app-detail.tsx`):
-
-- `bg_agent_created` — `{ method: 'manual' | 'coding' | 'copilot', has_triggers }` — `copilot` means the user submitted the "describe it" form (the agent is then created by Copilot in chat)
-- `bg_agent_updated` — instructions/triggers/model saved on an existing agent
-- `bg_agent_toggled` — `{ active }`
-- `bg_agent_run_clicked` — manual Run now
-- `bg_agent_stopped` — manual stop of a run
-- `bg_agent_deleted`
-
-**Live notes** (`components/live-note-sidebar.tsx`, `components/live-notes-view.tsx`):
-
-- `live_note_saved` — live config created or edited via the panel
-- `live_note_toggled` — `{ active }`
-- `live_note_run_clicked` — manual Run
-- `live_note_stopped` — in-flight run stopped
-- `live_note_deleted` — live config removed from the note
-- `live_note_edit_with_copilot_clicked`
-
-**Search** (`components/search-dialog.tsx`):
-
-- `search_opened` — the palette opened
-- `search_executed` — (pre-existing, above)
-- `search_result_selected` — `{ type: 'knowledge' | 'chat' }`
-
-**Apps** — all **(main)**, in `apps/main/src/ipc.ts` (pre-existing except `app_rolled_back`): `app_created`, `app_installed`, `app_uninstalled`, `app_updated`, `app_rolled_back`, `app_published`, `app_starred`, `app_deleted`. Plus renderer-side `app_opened` — `{ folder }` — an installed app's UI was opened (`components/apps/app-frame.tsx`).
-
-**Code mode** — both **(main/core)**:
-
-- `code_session_created` — `{ mode: 'direct' | 'rowboat', agent }` — captured in the `codeSession:create` IPC handler. This is the direct-vs-rowboat session split.
-- `code_session_message_sent` — `{ mode, agent }` — one per direct-drive message (`packages/core/src/code-mode/sessions/service.ts`). Direct turns bypass the agent runtime and emit no `llm_usage`, so this is the only usage-depth signal for direct mode; Rowboat-mode depth comes from `llm_usage where use_case = code_session`.
-
-**Billing** (`components/billing-error-dialog.tsx`):
-
-- `billing_error_shown` — `{ kind: 'subscription_required' | 'out_of_credits' | 'subscription_inactive' }` — the paywall dialog appeared
-- `billing_upgrade_clicked` — `{ kind }` — the upgrade CTA was clicked (shown → clicked = paywall conversion)
-
-**Failures** — success events all have a failure sibling where the operation can fail after the click:
-
-- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`)
-- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`)
-- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks
-
-**Misc**:
-
-- `note_created` — new note from the sidebar/knowledge actions (`App.tsx`)
-- `note_edited` — a note's autosave wrote changed content; deduped to one event per note per app session (so it counts "notes touched", not keystroke bursts)
-- `settings_opened` — `{ tab }` — settings dialog opened (tab = the initial tab)
-- `settings_tab_changed` — `{ tab }`
-- `onboarding_completed` — the onboarding flow finished (`App.tsx`)
-
## Person properties
Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
@@ -200,14 +101,10 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
| `plan`, `status` | main on identify | Subscription state |
| `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production |
-| `platform` | both processes (init + identify) | Always `desktop` from this app; segments desktop users from any other surface |
-| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event |
| `signed_in` | renderer | `true` while rowboat OAuth is connected |
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
| `total_notes` | renderer (init) | Workspace size signal |
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
-| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view |
-| `has_created_bg_agent` | renderer | One-shot: user set up a background agent |
## How to add a new event
diff --git a/apps/x/CODE_MODE_ENGINES_PLAN.md b/apps/x/CODE_MODE_ENGINES_PLAN.md
deleted file mode 100644
index ce7a4958..00000000
--- a/apps/x/CODE_MODE_ENGINES_PLAN.md
+++ /dev/null
@@ -1,271 +0,0 @@
-# Code Mode — Managed Engine Provisioning Plan
-
-Branch: `feat/code-mode-managed-engines` (off `dev` @ `8ce24ebb`)
-
-## 1. Problem & Goal
-
-Code mode runs two coding agents — **Claude Code** and **Codex** — by spawning their
-ACP adapters, which in turn spawn a heavy **native engine binary** (~205 MB claude,
-~194 MB codex). We need code mode to work in **packaged releases with ~99% reliability
-for both agents**, without shipping a ~400 MB installer.
-
-### Current state (HEAD = revert of #614)
-- Packaged builds **do not stage** the ACP adapters, and `forge.config.cjs`
- `ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the
- adapter via `require.resolve(...)` then spawns it — which **throws
- `Cannot find module '@agentclientprotocol/...'`**.
-- **Net: packaged code mode is broken in every release.** It only works in `dev`
- because pnpm symlinks exist. There is no 400 MB bloat today — but no function either.
-
-### Why the two prior approaches are insufficient
-- **Bundle engines (A):** +~400 MB per installer (one claude + one codex native binary
- per OS/arch). Works offline, but installer is huge.
-- **Drive from user's local install (B)** — what #614 settled on and was reverted:
- requires the user to have **both** CLIs installed **and** logged in, correct version,
- on the right PATH. Depends on the user's machine → **structurally cannot hit 99%**
- (version skew, GUI-launch PATH stripping, missing installs). This is why #614 was
- reverted.
-
-## 2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor)
-
-Split the problem into **engine** vs **auth**, treat them differently — exactly what
-Conductor (conductor.build) does:
-
-1. **Engine = owned by the app.** We provision **version-pinned** engine binaries into
- **app-support** (`~/.rowboat/engines///`), download-on-demand on first
- use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH.
- → no version skew, no PATH quirks → this is what delivers the 99%.
-2. **Auth = reused from the user.** The engines read existing credentials
- (`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts`
- already inspects these.
-
-### Empirical proof from Conductor on this machine
-- DMG is **123 MB** — far smaller than the ~400 MB of engines → engines are **not** in
- the installer; they're downloaded after install.
-- Layout observed at `~/Library/Application Support/com.conductor.app/`:
- ```
- agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64)
- agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64)
- bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version)
- bin/codex -> agent-binaries/codex/0.138.0/codex
- agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...}
- ```
-- So: versioned dirs + stable symlink + sha256 `.meta` ledger + download. We mirror this.
-
-## 3. Concrete facts that make this clean (verified)
-
-### Adapters honor an external engine via env var (no code change in adapters needed)
-- **Claude** — `@agentclientprotocol/claude-agent-acp@0.39.0`,
- `dist/acp-agent.js:39`: `if (process.env.CLAUDE_CODE_EXECUTABLE) return it;`
- and line 1552 `pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? ...`.
- If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE".
-- **Codex** — `@agentclientprotocol/codex-acp@0.0.44`,
- `dist/index.js:20900`: `const codexPath = process.env["CODEX_PATH"] ?? "codex";`
- → `spawn(codexPath, ["app-server"])`.
-- **Implication:** provisioning + setting these two env vars is the *entire* engine story.
-
-### Our engine packages are already single self-contained binaries
-- `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156` → contains one `claude`
- executable (+ LICENSE/README).
-- `@openai/codex@0.128.0-darwin-arm64` → `vendor//codex/codex` native binary
- **plus a bundled `rg` (ripgrep) at `vendor//path/rg`** — see Risk R1.
-
-### Pinned versions + platform package names (read from the installed adapter trees)
-- **Claude:** adapter `claude-agent-acp@0.39.0` → engine `@anthropic-ai/claude-agent-sdk@0.3.156`.
- Platform optional deps `@anthropic-ai/claude-agent-sdk-@0.3.156`:
- `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-x64-musl`,
- `linux-arm64-musl`, `win32-x64`, `win32-arm64`.
-- **Codex:** adapter `codex-acp@0.0.44` → `@openai/codex@^0.128.0` (pnpm-**patched**, see R2).
- Platform deps aliased `@openai/codex-` → `npm:@openai/codex@0.128.0-`:
- `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`.
-
-## 4. Distribution source — npm platform packages, adapter-pinned (DECIDED)
-
-We fetch the **per-platform engine packages from the npm registry at the exact versions
-our ACP adapters depend on**, extract the native binary, and provision it into
-`~/.rowboat/engines/...`. No self-host, no curl installer, no fallback.
-
-- **Tarball URL:** `https://registry.npmjs.org//-/-.tgz`
- - claude: `@anthropic-ai/claude-agent-sdk-@0.3.156` → extract the `claude` binary.
- - codex: `@openai/codex@0.128.0-` → extract `vendor//codex/codex`
- **and keep `vendor//path/rg`** (see R1).
-- **Why adapter-pinned npm (not curl installer, not release bucket, not self-host):**
- - The binary is the **exact version our adapter was built/tested against** → ACP
- handshake guaranteed → the key to ~99% reliability.
- - npm registry is highly available, immutable per-version, and the packument provides
- `dist.integrity` (sha512) + `dist.shasum` (sha1) → integrity verification with **zero
- infra**.
- - The official `curl | bash` installer is for end-users: it installs globally to
- `~/.local/bin` and **auto-updates in the background** — exactly what we must avoid. We
- want an isolated, pinned, app-managed copy that never moves under the adapter.
- (Conductor likewise fetches the raw binary rather than running the installer.)
-- **Versions are read from our lockfile at build time** and embedded in
- `engine-manifest.json`, so the manifest can never drift from the adapters we ship.
-
-### Reference: how this relates to Conductor / official installers
-- Conductor self-hosts the same kind of native binaries on `storage.conductor.build`
- (raw/`.gz`/`.zst` + `{url,gzipUrl,zstdUrl,sha256}` manifest), pairing the adapters with
- recent **standalone CLI** versions (claude 2.1.x, codex 0.138). That proves recent
- versions work — but we deliberately pin to the **adapter's own** engine version for
- determinism.
-- Official Claude releases also publish a GPG-signed `manifest.json` (SHA256/platform) at
- `downloads.claude.ai/claude-code-releases//`. We don't use it now (npm is simpler
- and adapter-matched), but it's the upgrade path if we ever want the latest CLI line.
-- If we later want control/availability/compression, we can mirror these npm tarballs to
- our own bucket — **runtime stays identical, only manifest URLs change.**
-
-### Locked decisions
-- **Source: npm platform packages, adapter-pinned versions (user).**
-- **No self-host (user).**
-- **Always provision; no fallback** to a user's pre-installed `claude`/`codex` (user).
- Reverted path B is fully retired.
-- **Codex pnpm patch — IRRELEVANT (user):** it targets the JS launcher; we point
- `CODEX_PATH` at the native binary, so it does not apply. (Risk R2 removed.)
-
-## 5. Design
-
-### 5.1 Build-time: generate an engine manifest + stage adapter JS
-
-**(a) Engine manifest (`engine-manifest.json`, embedded in the app).**
-A build script reads the installed adapter dependency trees and emits, per agent:
-```jsonc
-{
- "claude": {
- "version": "0.3.156",
- "platforms": {
- "darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
- "tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz",
- "integrity": "sha512-...",
- "binRelPath": "claude" },
- "...": {}
- }
- },
- "codex": {
- "version": "0.128.0",
- "platforms": {
- "darwin-arm64": { "pkg": "@openai/codex-darwin-arm64",
- "tarball": "https://registry.npmjs.org/...",
- "integrity": "sha512-...",
- "binRelPath": "vendor/aarch64-apple-darwin/codex/codex",
- "extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] }
- }
- }
-}
-```
-- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the
- manifest is always in sync with the adapters we ship. (Generating it at build time
- sidesteps hardcoding fragile package names.)
-- `binRelPath` / `extraPaths` capture where the executable (and codex's `rg`) live inside
- each tarball.
-
-**(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).**
-The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on
-disk in packaged builds so `agents.ts` can resolve + spawn them. In `forge.config.cjs`
-`generateAssets`, stage the two adapters and their **non-native** production dependency
-closure into `.package/acp/node_modules` (npm-style nested layout), and **exempt
-`.package` from the `node_modules` ignore rule**. We **drop** #614's native-engine staging
-entirely — engines come from provisioning, not the bundle.
-
-### 5.2 Runtime: engine provisioner (new module in `packages/core`)
-
-New file: `packages/core/src/code-mode/acp/engine-provisioner.ts`.
-
-```
-ensureEngine(agent): Promise<{ executablePath: string }>
- 1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported.
- 2. dir = ~/.rowboat/engines///
- 3. If dir exists AND .meta/-.json sha256 matches → return binPath.
- 4. Else acquire a cross-process lock (avoid double download), then:
- a. Download tarball to a temp file, streaming, with progress events.
- b. Verify integrity (sha512/sha256 from manifest).
- c. Extract into a temp dir, then atomic rename into / (tar gzip).
- d. chmod +x the binary (and codex's rg) on unix.
- e. Write .meta/-.json {sha256, size, downloaded_at_unix_ms}.
- 5. Return absolute path to the engine executable (binRelPath joined to dir).
-```
-- **Progress + cancellation** surfaced over IPC for the first-run "Downloading engine…" UI.
-- **Offline / failure** → typed error with a clear, actionable message (not a hang).
-- **Resumability / atomicity:** download to temp, verify, then rename; never leave a
- half-extracted version dir that passes the existence check.
-
-### 5.3 Wire provisioning into launch
-
-In `agents.ts` `getAgentLaunchSpec()` (currently sets only `CLAUDE_CODE_EXECUTABLE`):
-- Make it (or its caller in `client.ts` / `manager.ts`) `await ensureEngine(agent)` first,
- then set:
- - claude → `env.CLAUDE_CODE_EXECUTABLE = `
- - codex → `env.CODEX_PATH = ` (and ensure its `rg` sibling resolves;
- keep the vendor dir layout intact so codex finds `../path/rg`).
-- Keep `ELECTRON_RUN_AS_NODE=1` for the adapter spawn (unchanged).
-- The provisioner replaces both the bundled-engine dependency *and* the reverted
- "resolve user's local install" path. (Optionally: if a healthy provisioned engine is
- absent but a compatible local install exists, we *may* fall back to it — but the default
- and reliable path is the provisioned engine. Decide in review; default = provisioned only.)
-
-### 5.4 Status & UX (`status.ts` + renderer)
-- `checkCodeModeAgentStatus()` becomes: engine = provisioned? (instead of "installed on
- PATH?"); auth = existing credentials present? (unchanged logic).
-- First-run flow: user picks agent → if engine missing, show "Downloading engine
- (~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant.
-- Clear error states: download failed / offline / unsupported platform / auth missing.
-
-## 6. File-by-file change plan
-
-| File | Change |
-|---|---|
-| `apps/main/forge.config.cjs` | Stage adapter JS closure into `.package/acp/node_modules`; exempt `.package` from node_modules ignore; generate + copy `engine-manifest.json`. (No native engines staged.) |
-| `apps/main/scripts/gen-engine-manifest.mjs` *(new)* | Build-time: read lockfile/packuments → emit `engine-manifest.json` (versions, tarball URLs, integrity, bin paths). |
-| `packages/core/src/code-mode/acp/engine-provisioner.ts` *(new)* | `ensureEngine()` — download, verify, extract, lock, progress, `.meta` ledger. |
-| `packages/core/src/code-mode/acp/agents.ts` | Resolve adapter from staged `.package/acp` first (fallback to node_modules in dev); `await ensureEngine`; set `CLAUDE_CODE_EXECUTABLE`/`CODEX_PATH` to provisioned binaries. |
-| `packages/core/src/code-mode/acp/client.ts` / `manager.ts` | Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. |
-| `packages/core/src/code-mode/status.ts` | Engine status = provisioned (not PATH); keep auth checks. |
-| `apps/main/src/ipc.ts` + preload + renderer | IPC for provisioning progress + first-run download UI + error states. |
-| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP `initialize`; offline → clear error not a hang. |
-
-## 7. Edge cases & risks
-
-- **R1 — Codex needs `rg`.** The codex platform package bundles ripgrep at
- `vendor//path/rg`. We must extract/keep the vendor layout so codex finds it
- (don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir.
-- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we
- point `CODEX_PATH` at the native binary, so it does not apply.
-- **R3 — Platform/arch matrix.** Manifest must cover darwin x64/arm64, linux x64/arm64
- (+musl for claude), win32 x64/arm64. Windows engine is `claude.exe`/`codex.exe`.
-- **R4 — Integrity & supply chain.** Always verify the manifest integrity hash before
- chmod/exec. Treat a hash mismatch as a hard failure.
-- **R5 — Disk + upgrades.** Versioned dirs accumulate. Add a cleanup that keeps only the
- active pinned version per agent.
-- **R6 — First-run network.** Required once per agent; cached forever after. Must be a
- clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson).
-- **R7 — code signing / Gatekeeper (macOS).** Downloaded native binaries aren't covered by
- our app's signature. Verify they run under Gatekeeper (they're already
- signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs
- them fine from app-support — confirm we do too.
-- **R8 — `engine-manifest` staleness.** Manifest must regenerate whenever the adapter/engine
- versions change; tie generation into the build so it can't drift.
-
-## 8. Phasing
-
-1. **P1 — Packaging fix (unblocks dev→packaged parity):** stage adapter JS into `.package`,
- resolver checks staged path first. Verify packaged code mode can at least *spawn* the
- adapter. (Independent of provisioning; the part of #614 worth keeping.)
-2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both
- engines provision + boot from `~/.rowboat/engines` on macOS arm64.
-3. **P3 — UX + status:** first-run download UI, status panel, error states.
-4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke.
-5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install
- fallback.
-
-## 9. Decisions & remaining questions
-
-### Resolved (locked)
-1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl
- installer, not release bucket).
-2. **Always provision; no local-install fallback.**
-3. **Codex pnpm patch irrelevant.**
-
-### Still open (can decide during implementation)
-- **Provision timing:** on first code-mode *use* (lazy) vs first app launch (eager,
- background download). Plan assumes **lazy + cached**.
-- **Version-dir cleanup:** keep only the active pinned version per agent (R5).
-- **Verify R1** (codex `rg`) and **R7** (Gatekeeper on downloaded macOS binaries) during P2.
diff --git a/apps/x/GRANOLA_PARITY.md b/apps/x/GRANOLA_PARITY.md
deleted file mode 100644
index 53af3bd9..00000000
--- a/apps/x/GRANOLA_PARITY.md
+++ /dev/null
@@ -1,153 +0,0 @@
-# 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 85f16786..2fc43786 100644
--- a/apps/x/LIVE_NOTE.md
+++ b/apps/x/LIVE_NOTE.md
@@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t
Two paths, both producing identical on-disk YAML:
1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick.
-2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
+2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
@@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message:
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
The agent's system prompt tells it to:
-1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
-2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites.
+1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
+2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
4. Never modify YAML frontmatter — that's owned by the user and the runtime.
5. End with a 1-2 sentence summary stored as `lastRunSummary`.
@@ -115,7 +115,7 @@ Backend (main process)
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
└─ Builtin tool │ │
run-live-note-agent ────┘ ▼
- file-readText / -edit
+ workspace-readFile / -edit
│
▼
body region(s) rewritten on disk
@@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' |
`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context).
-This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
+This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
### Run flow (`runLiveNoteAgent`)
@@ -249,20 +249,22 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
- Then content organized by sub-topic under H2 headings, freshest/most-important first.
- Tightness over decoration.
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
-- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
+- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
---
-## Default Note Policy
+## Daily-Note Template & Migrations
-Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block.
+`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals.
-**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start:
+**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`:
-- File missing → mark processed and do nothing.
-- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
-- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
+- File missing → fresh write at canonical version.
+- File at-or-above canonical → no-op.
+- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body).
+
+The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced.
---
@@ -316,7 +318,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd
- **Purpose**: the user message seeded into each agent run.
- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`).
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
-- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
+- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
Three branches by `trigger`:
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.
@@ -326,7 +328,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/runtime/assembly/skills/live-note/skill.ts`. Exported `skill` constant.
+- **File**: `packages/core/src/application/assistant/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 +336,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/runtime/assembly/copilot/instructions.ts` (look for the "Live Notes" paragraph).
+- **File**: `packages/core/src/application/assistant/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 +345,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/runtime/tools/catalog.ts` (the `run-live-note-agent` tool definition).
+- **File**: `packages/core/src/application/lib/builtin-tools.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.
@@ -391,13 +393,13 @@ Conventions:
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` |
| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` |
| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` |
-| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
+| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_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/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` |
+| 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` |
| 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/MINI_APPS_PLAN.md b/apps/x/MINI_APPS_PLAN.md
deleted file mode 100644
index 72a82a29..00000000
--- a/apps/x/MINI_APPS_PLAN.md
+++ /dev/null
@@ -1,307 +0,0 @@
-# Mini Apps — Implementation Plan
-
-> Status: **Phase 1 (UI + rendering, hand-coded apps)** in progress.
-> Branch: `feature/mini-apps`. Working dir: `apps/x`.
-
-## 1. What we're building (agreed design)
-
-A **Mini App** is a user-created, personal app that lives inside Rowboat under a
-**"Mini Apps"** sidebar tab, shown as cards; click a card to open and use it like
-a standalone app.
-
-The settled mental model:
-
-> A Mini App = **custom UI code** (a single self-contained HTML file: React +
-> Tailwind, no build step, runs in a sandboxed iframe) + an **agent "backend"**
-> that produces fresh **data** on a trigger + an optional **state store** + a
-> **scoped bridge** that lets the UI call real **Composio** actions.
-
-Key decisions (the "why" behind the architecture):
-
-- **Opens to a finished, populated screen.** The agent runs *before* open (on a
- trigger); the UI just renders the latest result. Maps onto the existing
- background-agents engine.
-- **Frontend/backend split.** Code = frontend, written once. Agent = backend,
- produces fresh `data.json` each run. The UI is *not* regenerated per run.
- Cheaper, faster, and the UI can't break on refresh.
-- **A data contract** ties the three pieces together: at creation the builder
- produces a matched triple — UI code + data schema + agent instructions.
-- **Fully custom UI per app** (generated code), not a fixed block kit.
-- **Real interactivity** via a **scoped bridge** to Composio (each app declares
- the integrations it may touch; scope drives both enforcement and the auth
- prompt).
-- **Built by whatever engine is selected in the chat box** — Code Mode
- (Claude Code / Codex) if the user has it, else the Copilot.
-- **Living apps:** refined by chatting; breakage is first-class (app surfaces
- errors → "fix with copilot").
-- **State is an optional provided primitive** (per-app persistent store the agent
- and UI can read/write). Stateless apps ignore it.
-- **Personal/local only** for now, but each app is a **self-contained folder**
- so it can become portable later.
-
-### Framework for the apps
-
-Single self-contained `index.html` rendered in a sandboxed iframe.
-
-> **Learning (Phase 1):** remote CDNs do NOT work inside the sandboxed,
-> opaque-origin `srcdoc` iframe (React/Tailwind/Babel from unpkg/cdn.tailwindcss
-> failed → blank screen). Apps must be **fully self-contained, no network**.
-> Phase 1 therefore ships **vanilla HTML + CSS + JS** (no build, no transpile).
-> The React+Tailwind LLM-friendly target still stands for generation — but via a
-> **locally-bundled** runtime (esbuild-at-save, libs injected into the HTML),
-> never remote CDNs. The `window.rowboat` bridge is unchanged either way.
-
-The UI talks to the host through a `window.rowboat` **bridge** (the product
-surface *and* the security boundary):
-
-```
-rowboat.getData(): Promise // latest agent output
-rowboat.onData(cb): void // re-render on refresh
-rowboat.getState() / setState(patch) // per-app persistent store
-rowboat.callAction(scope, action, args) // scoped Composio call
-rowboat.ready() // handshake: "send me data"
-```
-
-## 2. Phased roadmap
-
-1. **Surface + one real app (UI-first — THIS PHASE).** "Mini Apps" sidebar tab,
- card grid, click to open. One **hardcoded** app (Twitter client) rendered in a
- sandboxed iframe from a self-contained HTML file, reading static data through a
- minimal bridge. Proves the *experience* and the *rendering path*. No agent,
- no storage, no real Composio yet.
-2. **The scoped bridge.** Real interactive buttons → real Composio actions,
- enforced against a per-app manifest scope; auth prompt on demand.
-3. **Generation.** Copilot/Code-Mode generates the UI+schema+agent triple from a
- chat description. Rides the existing chat mode selector + `code-with-agents`.
-4. **Living apps + breakage recovery.** Conversational edits; error surfacing.
-5. **Scale & polish.** Context-overload windowing (e.g. 100 tweets); app
- notifications (reuse `notify-user`).
-6. **(V2)** Drag an app into the main workspace (macOS-style).
-
-## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT)
-
-Move built-in apps out of source into `~/.rowboat/apps//`, served as static
-assets, with an optional background agent producing `data.json`. Sets up the
-copilot builder path (it will write the same folder layout). Team-agreed.
-
-### On-disk layout (one folder per app)
-```
-~/.rowboat/apps//
- manifest.json # id, title, description, source, scope[], active, lastRun,
- # entry (default "dist/index.html"), agent (optional bg-task slug)
- dist/ # static assets served via app://miniapp//...
- index.html # the app (self-contained; bridge shim inlined)
- data.json # latest agent output (read by the host, pushed to the app)
-```
-
-### Serving — `app://miniapp//` (option A)
-- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host
- `miniapp`: map `app://miniapp//` → `~/.rowboat/apps//dist/`
- (path-traversal guarded; default `index.html`). `app://` is already registered
- privileged (standard/secure/fetch/cors) so apps get a **real origin** —
- remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin.
-- The iframe loads via `src="app://miniapp//index.html"` (not `srcDoc`).
- Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms
- allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin,
- still isolated from the renderer (different host).
-
-### Data path
-- Built-in/static `data` is seeded to `data.json`. A future background agent
- (reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule.
-- App keeps using `rowboat.onData`; the **host** now sources that data by reading
- `~/.rowboat/apps//data.json` (via IPC) and posting it on `ready`. Composio
- still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls
- live via Composio.)
-
-### Steps
-1. **Shared schema** — `packages/shared/src/mini-app.ts`: `MiniAppManifest` zod +
- type. Export it. New IPC channels in `shared/src/ipc.ts`:
- - `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent.
- - `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`).
- - `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`).
-2. **Main** — `apps/main/src/mini-apps-handler.ts`: `seedApps` (write
- manifest/dist/data to `~/.rowboat/apps//` only if absent), `listApps`
- (read manifests), `getAppData` (read data.json). Register handlers in
- `ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host.
-3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds
- `toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed`
- → `list` → render cards from manifests. `MiniAppFrame`: take the manifest,
- load `src=app://miniapp//`, on `ready` fetch `mini-apps:get-data`
- and post it; RPC scope from `manifest.scope`.
-4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`),
- then the others; confirm connect + live PRs still work.
-
-### Out of scope here (next: copilot builder, tomorrow)
-Copilot writing a new app folder + an associated background agent; the agent
-browsing via embedded browser for social feeds; copilot verifying Composio wiring
-by actually calling tools before finalizing.
-
-## 2c. Remaining work (after on-disk move)
-
-Done so far: surface + runtime (Phase 1), real scoped Composio bridge (Phase 2),
-apps on disk served via `app://miniapp` (Phase 2.5).
-
-**Next — Copilot builder (demo target).**
-- Copilot creates an app folder in `~/.rowboat/apps//` via the `mini-apps:seed`
- install primitive (writes `manifest.json` + `dist/index.html`).
-- Copilot must **verify wiring by actually calling Composio tools** and inspecting
- the returned data before finalizing — never speculate the shape.
-- Give generated apps the bridge contract — prefer **serving a canonical shim**
- from the protocol (e.g. `app://miniapp/__bridge__.js`) over inlining.
-
-**Background-agent data pipeline (deterministic).**
-- Reuse the existing bg-tasks engine; link via `manifest.agent`.
-- The agent does NOT write files. It **returns structured data validated against
- the app's data schema**; the bg-task **runner (code) atomically writes the
- app's `data.json`** (temp→rename, keep last-good on failure). Path / atomicity
- / validation / fallback all live in code — only the *content* is LLM-driven.
-- Social feeds (Twitter/LinkedIn/Reddit): the agent browses via the embedded
- browser, curates, returns data → runner writes `data.json`.
-
-**Later (roadmap).**
-- Living apps + breakage recovery (edit by chat; surface errors → fix-with-copilot).
-- Scale/polish: context-overload windowing; app notifications; design-consistency
- / component templates (team-deferred).
-- V2: drag an app into the main workspace.
-
-## 2d. Mini App builder skill — spec
-
-A Copilot skill (`build-mini-app`, in `packages/core/src/application/assistant/
-skills/`) that turns a chat request into an installed app under `~/.rowboat/
-apps//`. Copilot orchestrates; the actual code-writing is delegated by the
-chat's active engine (the chip), but the on-disk artifact is identical either way.
-
-**Trigger + intent gate** (like live-note/background-task signal tiers):
-- **Strong (build directly):** "make/build/create an app · mini app · dashboard
- for …", "turn this into an app".
-- **Medium (CONFIRM FIRST):** requests that could be a one-off answer or a
- recurring app — e.g. "show me my open PRs", "track competitor launches". Ask:
- "Want this as a Mini App you can reopen, or just a one-time answer?" Build only
- on yes. (Building installs a folder + maybe a bg agent + an OAuth prompt — too
- heavy to trigger on a casual question.)
-- **Anti (do NOT build):** clearly one-off lookups/questions → just answer.
-
-**Flow:**
-1. **Scope the app** — derive `id` (slug), `title`, `description`, `source`, the
- Composio `scope[]`, and whether it's **agent-backed** (scheduled data) or
- **live** (calls Composio on use).
-2. **Verify wiring BEFORE building** (mandatory, engine-agnostic) — ensure the
- toolkits are connected (prompt OAuth if not), then actually call the needed
- tools (`composio-search-tools` → `composio-execute-tool`), inspect the REAL
- returned data, and derive the **data schema from actual responses** — never
- guess the shape.
-3. **Pick the writer (branch):**
- - **Code Mode active** → create the folder + a manifest skeleton, then
- `code_agent_run` with `cwd = ~/.rowboat/apps//` to author
- `dist/index.html` against the verified schema + bridge contract; it can
- iterate/test on-device.
- - **No Code Mode** → Copilot writes `dist/index.html` itself (from the app
- template + bridge shim) and installs via `mini-apps:seed`.
-4. **Bridge contract** — generated app references the canonical shim
- (`app://miniapp/__bridge__.js`) and uses `window.rowboat`
- (`getData/onData`, `isConnected/connect`, `searchTools/callAction`).
-5. **Data pipeline (if agent-backed)** — create a background task (existing
- bg-tasks engine), set `manifest.agent` to its slug. The agent **returns
- schema-validated data**; the **runner writes `data.json` deterministically**
- (temp→rename, last-good on failure). App reads it via `onData`.
-6. **Finalize** — write `manifest.json` (incl. `scope`, `agent?`), ensure
- `dist/index.html`, install → app appears in the gallery (`mini-apps:list`).
- Confirm end-to-end (open it; data loads).
-
-**Infra this needs (repo side):**
-- Serve the canonical bridge shim from the protocol: `app://miniapp/__bridge__.js`
- (move the shim out of per-app inlining into served infra).
-- bg-tasks runner: when a task is app-linked, validate its structured output
- against the app schema and write that app's `data.json` (deterministic write
- mode) instead of `index.md`.
-- A `build-mini-app` skill registered in the skills catalog.
-
-## 3. Phase 1 — detailed implementation
-
-UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet.
-We *do* mirror the eventual shapes so later phases slot in.
-
-### 3.1 New files (renderer)
-
-| File | Purpose |
-|------|---------|
-| `apps/renderer/src/mini-apps/types.ts` | `MiniApp` type (id, name, description, icon, accent, scope, html, data). |
-| `apps/renderer/src/mini-apps/registry.ts` | Hardcoded list of `MiniApp`s for Phase 1. |
-| `apps/renderer/src/mini-apps/apps/twitter-client.ts` | The sample app: self-contained HTML (React+Tailwind+Babel CDN) + static `data`. |
-| `apps/renderer/src/components/mini-apps-view.tsx` | Card grid; internal `selectedAppId` state; renders grid or open app. |
-| `apps/renderer/src/components/mini-app-frame.tsx` | Sandboxed iframe (`srcdoc`) + `window.rowboat` postMessage bridge (data wired; actions stubbed → toast/log). |
-
-The bridge message protocol (host ↔ iframe), defined once and shared:
-- iframe → host: `{ type: 'rowboat:mini-app:ready' }`,
- `{ type: 'rowboat:mini-app:action', id, scope, action, args }`,
- `{ type: 'rowboat:mini-app:setState', patch }`
-- host → iframe: `{ type: 'rowboat:mini-app:data', data }`,
- `{ type: 'rowboat:mini-app:state', state }`,
- `{ type: 'rowboat:mini-app:action-result', id, ok, result?, error? }`
-
-### 3.2 Wiring into `App.tsx` (mirror the `bg-tasks` view exactly)
-
-Add a first-class `apps` view. Edit sites (all mirror an existing view):
-
-1. **Tab path const** (~L198): add `const APPS_TAB_PATH = '__rowboat_mini_apps__'`.
-2. **Tab predicate** (~L374): `const isAppsTabPath = (path) => path === APPS_TAB_PATH`.
-3. **ViewState union** (~L636): add `| { type: 'apps' }`.
-4. **`parseDeepLink`** (~L713): add `case 'apps': return { type: 'apps' }`.
-5. **State flag** (~L825): `const [isAppsOpen, setIsAppsOpen] = useState(false)`.
-6. **Tab title** (~L1253): `if (isAppsTabPath(tab.path)) return 'Mini Apps'`.
-7. **Tab activation → flag** (~L3269, ~L3443): set `isAppsOpen` from tab path.
-8. **Closing-tab guard** (~L3376): add `&& !isAppsTabPath(closingTab.path)`.
-9. **`currentViewState`** (~L3770): `if (isAppsOpen) return { type: 'apps' }`
- (place alongside bg-tasks; add to deps array).
-10. **`ensureAppsFileTab`** (~L3853): mirror `ensureBgTasksFileTab`.
-11. **`openAppsView`** (~L3953): mirror `openBgTasksView`; reset all other flags,
- `setIsAppsOpen(true)`, `ensureAppsFileTab()`.
-12. **`applyViewState` switch** (~L4195): add `case 'apps':` mirroring `bg-tasks`.
-13. **Add `setIsAppsOpen(false)`** to every *other* view's reset block (the
- shared multi-`set...(false)` lines) so opening another view clears apps and
- closing it doesn't fall back to apps. Use targeted edits per block.
-14. **Derived booleans** that OR all view flags (isFullScreenChat,
- rightPaneAvailable, inFileView, rightPaneContext, viewOpen, keyboard guards):
- add `|| isAppsOpen` / `&& !isAppsOpen` consistently (search `isBgTasksOpen`).
-15. **Tab-path mapping** (~L4668): add `: isAppsOpen ? APPS_TAB_PATH`.
-16. **Render branch** (~L5894): add `) : isAppsOpen ? ( `.
-
-### 3.3 Sidebar entry (`sidebar-content.tsx`)
-
-- Add `onOpenApps?: () => void` prop (~L163) and destructure (~L412).
-- Add a `SidebarMenuButton` with `isActive={activeNav === 'apps'}` and a
- `LayoutGrid` (lucide) icon, label "Mini Apps", in the lower nav group near
- "Background agents" (~L830).
-- Thread `activeNav === 'apps'` from the parent (App.tsx passes `activeNav` based
- on `isAppsOpen`, mirroring how `'agents'` is derived ~L5651).
-- Wire `onOpenApps={openAppsView}` at the `SidebarContent`/home call sites
- (~L5657, ~L5832).
-
-### 3.4 The sample app (twitter-client)
-
-Self-contained HTML string. React + ReactDOM + Babel-standalone + Tailwind, all
-via CDN. Renders three sections — **To read / To repost / To respond** (reply
-pre-drafted) — from data delivered via `rowboat.onData`. Buttons call
-`rowboat.callAction('twitter', ...)`; in Phase 1 the host just toasts/logs and
-returns a fake ok. Static `data` lives next to the HTML.
-
-> Note: CDN scripts require network + `allow-scripts` in the sandbox. The frame
-> uses `sandbox="allow-scripts allow-popups allow-forms allow-modals"` — **no**
-> `allow-same-origin` (keeps the app from reaching host cookies/storage; the
-> bridge is the only channel). Verify CDN loads under this sandbox during testing;
-> if blocked, fall back to bundling the libs locally as a renderer asset.
-
-### 3.5 Verify
-
-- `cd apps/x && npm run deps && npm run lint` compiles clean.
-- `npm run dev`: "Mini Apps" appears in the sidebar; opens the card grid; clicking
- the Twitter card renders the app full-screen in the pane; sections populate from
- data; clicking a button shows the stubbed action result; tabs/back/forward and
- switching to other views behave like every other view.
-
-## 4. Out of scope for Phase 1 (later phases)
-
-`~/.rowboat/apps//` storage + IPC; the agent backend (reuse bg-tasks
-engine); real Composio execution through the bridge; per-app scope enforcement &
-auth prompting; generation via Copilot/Code-Mode; persistent state store;
-conversational editing & error recovery; the V2 workspace drag.
diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md
deleted file mode 100644
index 999a7f56..00000000
--- a/apps/x/VIDEO_MODE.md
+++ /dev/null
@@ -1,253 +0,0 @@
-# Calls (Video Mode) — Deep Dive
-
-Calls let the user talk to the assistant hands-free while it *sees* them
-(webcam) and their screen (screen share). There is ONE call engine —
-continuous listening, auto-submitted utterances, forced read-aloud TTS, frame
-capture — entered through four presets that differ only in starting devices.
-This doc covers the product flow, the technical pipeline, and the LLM prompt
-surface with exact pointers.
-
-## Product flow
-
-The composer has a **call split-button** (`chat-input-with-mentions.tsx`).
-The main click is the "work together" default — preset `share`: screen
-sharing ON, camera OFF, floating pill, so the user keeps working while the
-assistant watches along (the button tooltip discloses the screen share). The
-chevron menu holds the deviations. While a call is live the button turns red
-and ends it.
-
-| Preset | Starting devices | First surface |
-|--------|------------------|---------------|
-| `share` — main click | screen on, camera off | floating pill |
-| `voice` — "Voice call" | camera off, screen off | floating mascot pill |
-| `video` — "Video call" | camera on | full-screen call |
-| `practice` — "Practice session" | camera on, + coaching persona | full-screen call |
-
-**One surface rule** (`callSurface` in `App.tsx`): full screen and screen
-sharing are mutually exclusive in both directions — a full-screen call covers
-the screen, so sharing it would show the call itself.
-
-- sharing → floating popout, always (pill = working)
-- not sharing → full screen unless `callMinimized` (full screen = facing
- each other)
-- expanding the pill auto-STOPS any share; minimizing the full-screen call
- auto-STARTS one (the pill exists to work together) — presenting from full
- screen likewise collapses to the pill
-- the camera toggle never changes the surface: turning it on from the pill
- puts your video IN the pill; expanding is its own explicit action
-
-**Screen-share consent** is three-layered: a toast the moment any share
-starts ("Your screen is being shared… [Stop sharing]"), a persistent
-"Sharing screen" badge on the pill, and macOS's purple recording indicator.
-If the auto-share fails (Screen Recording permission not granted) the call
-starts anyway as a voice call, with a toast linking to System Settings.
-Practice/coaching is always an explicit choice — expanding to full screen
-never turns the coach on.
-
-In-call controls (identical bar on both surfaces): mic mute, camera toggle
-(silhouette avatar while off, no webcam frames captured), screen share
-toggle, mascot ⇄ "R" letter avatar, end call. **Mute is a full input
-pause**, not just audio — mic audio stops reaching Deepgram
-(`useVoiceMode.setPaused`, OR'd with the automatic thinking/speaking pause)
-AND camera/screen frame capture stops (`useVideoMode.setCapturePaused`;
-`collectFrames()` returns nothing while muted, so typed messages carry no
-frames either), letting the user talk to someone in the room without the
-assistant listening in. Devices stay acquired for instant unmute (camera
-light and macOS share indicator stay on — the pill's share badge switches to
-"Sharing paused"), the status chip shows "Muted" instead of "Listening",
-and assistant output is unaffected (in-flight speech keeps playing; Stop
-handles that). Mute resets to off at call start/end. While the assistant is thinking or speaking, a
-red **Stop** button appears on the mascot tile — it silences TTS instantly,
-skips queued voice segments, and aborts the run if it's still generating
-(stopping a run from anywhere, including the composer, also silences TTS). Captions of the in-progress utterance and the
-assistant's spoken line run along the bottom. Typing in the composer still
-works mid-call; frames ride along with typed messages too.
-
-Outside calls the composer keeps exactly one voice affordance: the **mic
-button** (push-to-talk dictation, untouched). Spoken responses exist only
-inside calls (forced full read-aloud, off on hang-up). The old video
-dropdown, talking-head toggle, read-aloud headphones toggle, and summary/full
-TTS dropdown are all retired — a per-message "read aloud" action on assistant
-messages is the planned replacement for text-in/voice-out.
-
-The call button is disabled unless both voice input (Deepgram) and voice
-output (TTS) are configured. `call_started` (with `preset`) is captured in
-PostHog — the adoption metric for this feature.
-
-**Popout mechanics**: a small always-on-top frameless window (camera tile
-when on + mascot tile, live caption, control bar) floating over every app —
-including Rowboat. Control-bar actions round-trip `video:popoutAction` →
-main → `video:popout-action` → app window, which owns the mic/camera/capture;
-`expand` also refocuses the app window (handled in main).
-
-## Frame pipeline
-
-`apps/renderer/src/hooks/useVideoMode.ts` runs one capture pipe per source
-(stream → offscreen `` → canvas JPEG → ring buffer):
-
-- Cadence: 1 fps (`CAPTURE_INTERVAL_MS`, line 20); ring buffer ~2 min.
-- Webcam: 512px wide, JPEG q0.65, max **12 frames/message** (lines 21, 31).
-- Screen: 1280px wide (text legibility), JPEG q0.7, max **4 frames/message**
- (lines 24, 32).
-- `collectFrames()` drains frames buffered since the last send, evenly
- sampled down to the caps, always keeping the newest; grabs one final frame
- at the moment of send. Falls back to the single latest frame for
- rapid-fire messages.
-
-`App.tsx` `handlePromptSubmit` attaches the drained frames (whenever a call
-is live) to the outgoing message as `UserImagePart`s and sets
-`composition.videoMode` when the camera or screen is active, plus
-`composition.coachMode` during a practice session. Frames also become
-`isVideoFrame` display attachments (filmstrip in the transcript —
-`chat-message-attachments.tsx`; history hydration in
-`lib/run-to-conversation.ts`).
-
-## Message schema & model encoding
-
-- `packages/shared/src/message.ts:51` — `UserImagePart`: inline base64
- (`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/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
- images — so the model never confuses the user with their screen.
-- Frames stay inline in history (no pruning) deliberately: pruning would
- bust provider prefix caching every turn and cost more than it saves.
-- The auto-permission classifier stringifies + truncates content to ~3KB per
- message, so inline base64 can't blow up its prompt.
-
-## Hands-free voice loop
-
-`apps/renderer/src/hooks/useVoiceMode.ts`:
-
-- `startContinuous(onUtterance)` (line 404): push-to-talk params but with
- `endpointing=1800` (line 25) so thinking pauses don't cut the user off,
- plus `utterance_end_ms=2000` (line 38) as a second end-of-speech signal.
- **Gotcha:** Deepgram's `speech_final` usually arrives on a result with an
- EMPTY transcript — empty finals must reach the endpoint check or
- utterances never complete (see the NOTE in `ws.onmessage`).
-- `setPaused(true)` (line 414) while the assistant thinks/speaks: drops mic
- audio (so TTS is never transcribed back), discards half-heard buffer,
- sends Deepgram KeepAlives every 5s. `App.tsx` drives this from
- `activeIsProcessing || tts.state !== 'idle'`.
-- Mid-call socket drops reconnect after 1s; the offline audio backlog is
- capped (~30s).
-
-Call lifecycle lives in `App.tsx` `startCall(preset)` / `endCall()`:
-entering a call saves/forces TTS settings, cancels any push-to-talk
-recording, and starts the continuous loop; ending restores everything.
-Push-to-talk is disabled while a call owns the mic.
-
-## Popout window
-
-- The popout window keeps the Dock icon alive: it uses
- `setVisibleOnAllWorkspaces(true)` WITHOUT `visibleOnFullScreen` — that flag
- turns the app into a macOS "agent" app and hides its Dock icon while the
- window exists (looks like Rowboat vanished). Trade-off: the popout doesn't
- hover over other apps' fullscreen Spaces.
-- Shown iff the derived `callSurface === 'popout'` (effect in `App.tsx`).
- Renderer asks `video:setPopout {show}`; main creates a frameless,
- `alwaysOnTop` ('floating'), all-workspaces BrowserWindow at the top-right
- of the primary display, loading the renderer bundle with `#video-popout`
- (`apps/renderer/src/main.tsx` branches on the hash →
- `components/video-popout.tsx`).
-- Call state streams over the `video:popout-state` push channel; main caches
- the last payload and replays it on popout load. Shown with
- `showInactive()` so it never steals focus.
-- The popout captures its **own** camera preview (MediaStreams can't cross
- windows) and synthesizes the mascot mouth level (no audio in that window).
-- `video:popoutAction` relays control-bar actions to the app window, matched
- only by real app-window URLs — `getAllWindows()` also contains hidden
- utility windows (PDF export) that must not be shown or messaged.
-
-## Permissions
-
-- Camera: `voice:ensureCameraAccess` settles the macOS TCC prompt before
- `getUserMedia` (same pattern as the mic). `NSCameraUsageDescription` is in
- `forge.config.cjs` `extendInfo`.
-- Screen: `getDisplayMedia` is auto-approved with the primary screen by
- `setDisplayMediaRequestHandler` in `main.ts` (no picker);
- `meeting:checkScreenPermission` registers the app in macOS Screen
- Recording settings on first use.
-
-## LLM prompts catalog
-
-| 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/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
-`voiceOutput: 'full'`.
-
-## Driving the app on a call
-
-The assistant can drive the Rowboat UI itself via the extended
-`app-navigation` builtin ("app driver"): `open-view` (any main view),
-`read-view` (returns the emails / background agents / chat-history data the
-view renders — and the renderer simultaneously navigates there so the user
-watches it happen), and `open-item` (a specific email thread, note,
-background agent, or past chat, deep-linked on screen). Data comes from the
-same core functions the UI's IPC handlers use (`listImportantThreads` /
-`searchThreads`, background-task `listTasks`, the sessions container) — no
-OCR of screen frames. The renderer applies results via
-`applyAppNavigation` in App.tsx, fed from BOTH event paths: the legacy
-`runs:events` ref-poll AND a watcher over the session-chat conversation (the
-turn runtime does not emit legacy run events — miss this and navigation
-silently no-ops while the tool reports success). Session switches seed the
-watcher so replaying history never navigates. During a call, visible
-navigations also collapse the full-screen call to the pill and focus the app
-window (`app:focusMainWindow`) so the user actually sees the screen change.
-Card labels live in `lib/chat-conversation.ts`. The call prompt and the
-`app-navigation` skill teach the show-while-telling pattern: read-view →
-speak the highlights → open-item when the user picks one.
-
-## Latency
-
-Voice-to-voice latency (user stops talking → assistant audio) is engineered
-at four points; the `call_turn_latency` PostHog event measures the real
-distribution (utterance → submit → first speak → audio playing):
-
-- **Smart endpointing** (`useVoiceMode.ts`): Deepgram endpoints at 600ms and
- the client decides — a transcript ending in terminal punctuation fires
- immediately (~600ms after last word); a mid-thought trail holds another
- 1.2s (resumed speech cancels the hold). Complete sentences turn around
- ~1.2s faster than the old fixed 1800ms endpoint.
-- **Streaming TTS** (`voice:synthesizeStreamStart` → `voice:tts-chunk` →
- MediaSource playback in `useVoiceTTS.ts`): the first segment of an idle
- queue plays from the first MP3 chunk instead of after the full body
- (ElevenLabs `/stream`, flash model). Follow-up segments keep the gapless
- full-body prefetch path. Falls back to non-streaming on any failure.
-- **Early clause speech** (`turn-view.ts` `applyOverlay`): a still-open
- `` block ≥60 chars emits its last complete clause immediately, so
- speech starts while the rest of the sentence generates.
-- **Acknowledgment cue** (`lib/call-sounds.ts`): a soft blip the instant an
- utterance is accepted — perceived latency matters as much as measured.
-
-## Cost notes
-
-Webcam frames ≈ 250–350 tokens each (≤12/message ≈ 3–4k); screen frames ≈
-1.5–2k tokens each (≤4/message ≈ 6–8k). History keeps frames inline, so long
-sessions grow but stay prefix-cached. First lever if cost bites: drop to one
-screen frame per message unless the screen changed.
-
-## Known limitations
-
-- Turn-taking is strict — no barge-in (would need echo cancellation against
- TTS output).
-- Frame sampling, not video: motion between frames is invisible (the prompt
- tells the model not to claim otherwise).
-- Vocal-delivery feedback is limited: Deepgram reduces speech to text, so
- "energy" coaching leans on visual cues.
-- Screen share always captures the primary display (no window/display
- picker yet).
-- The full-screen call covers the chat; there's no in-call transcript drawer.
-- The "attach camera frames to typed chat without a call" combination (the
- old video+chat mode) was cut in the call-model simplification; if analytics
- show demand, it should return as an attachment chip, not a mode.
diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs
index cca7fdc4..9ae77e0e 100644
--- a/apps/x/apps/main/bundle.mjs
+++ b/apps/x/apps/main/bundle.mjs
@@ -10,17 +10,11 @@
*/
import * as esbuild from 'esbuild';
-import { readFile } from 'node:fs/promises';
-import { execSync } from 'node:child_process';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
// The banner defines __import_meta_url at the top of the bundle,
// and we use define to replace all import.meta.url references with it.
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
-const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
await esbuild.build({
entryPoints: ['./dist/main.js'],
@@ -28,11 +22,7 @@ await esbuild.build({
platform: 'node',
target: 'node20',
outfile: './.package/dist/main.cjs',
- // electron is provided by the runtime. node-pty is a NATIVE module: it can't
- // be inlined (its loader requires .node binaries + a spawn-helper relative to
- // its own package dir), so it stays external and is copied into
- // .package/node_modules below, where require() from dist/main.cjs finds it.
- external: ['electron', 'node-pty'],
+ external: ['electron'], // Provided by Electron runtime
// Use CommonJS format - many dependencies use require() which doesn't work
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
format: 'cjs',
@@ -46,108 +36,7 @@ await esbuild.build({
// Empty strings disable analytics gracefully.
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
- 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
},
});
-// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
-// package dir and copy only what's needed at runtime (compiled JS + prebuilt
-// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
-// the bit, and a non-executable helper makes every PTY spawn fail.
-const here = path.dirname(fileURLToPath(import.meta.url));
-const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
-const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
-fs.rmSync(ptyDest, { recursive: true, force: true });
-fs.mkdirSync(ptyDest, { recursive: true });
-for (const item of ['package.json', 'lib', 'prebuilds']) {
- fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
-}
-const prebuildsDir = path.join(ptyDest, 'prebuilds');
-for (const dir of fs.readdirSync(prebuildsDir)) {
- const helper = path.join(prebuildsDir, dir, 'spawn-helper');
- if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
-}
-
-// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any
-// host whose prebuild is absent (notably Linux) the staged package has no loadable
-// pty.node and the app crashes on launch. Compile the native module for the host
-// platform+arch if needed and stage it under prebuilds/-/, where
-// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp
-// step (the CI workflow's explicit build is the fast path; this is the safety net).
-const hostTriple = `${process.platform}-${process.arch}`;
-const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node');
-if (!fs.existsSync(stagedBinary)) {
- const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node');
- if (!fs.existsSync(builtBinary)) {
- console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`);
- execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' });
- }
- if (!fs.existsSync(builtBinary)) {
- throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`);
- }
- fs.mkdirSync(path.dirname(stagedBinary), { recursive: true });
- fs.copyFileSync(builtBinary, stagedBinary);
- console.log(`✅ node-pty: staged ${hostTriple}/pty.node`);
-}
-console.log('✅ node-pty staged in .package/node_modules');
-
-// electron-chrome-extensions injects a preload script into browser tabs to
-// implement the chrome.* extension APIs. It resolves that file at runtime:
-// via require.resolve when node_modules is present (dev), falling back to a
-// file next to the running bundle (packaged app, where node_modules is
-// gone). Stage it next to main.cjs for the packaged case.
-const crxPreloadSrc = fs.realpathSync(
- path.join(here, 'node_modules', 'electron-chrome-extensions', 'dist', 'chrome-extension-api.preload.js'),
-);
-fs.copyFileSync(crxPreloadSrc, path.join(here, '.package', 'dist', 'chrome-extension-api.preload.js'));
-console.log('✅ electron-chrome-extensions preload staged');
-
-// 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
-// be inlined into main.cjs. Bundling here means the packaged app needs neither
-// node_modules nor a global npm install.
-const agentSlackPkg = JSON.parse(
- await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
-);
-await esbuild.build({
- entryPoints: ['./node_modules/agent-slack/dist/index.js'],
- bundle: true,
- platform: 'node',
- target: 'node22',
- outfile: './.package/dist/agent-slack.cjs',
- format: 'cjs',
- banner: { js: cjsBanner },
- define: {
- 'import.meta.url': '__import_meta_url',
- // Without this constant the CLI's --version walks up the directory tree
- // for a package.json and would find Rowboat's instead of agent-slack's.
- 'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
- },
- // The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
- // back to node:sqlite; keep it external so the probe fails at runtime the
- // same way it does under plain node.
- external: ['bun:sqlite'],
-});
-
-console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);
+console.log('✅ Main process bundled to .package/dist-bundle/main.js');
diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs
index 581206a8..ad639a86 100644
--- a/apps/x/apps/main/forge.config.cjs
+++ b/apps/x/apps/main/forge.config.cjs
@@ -5,190 +5,6 @@
const path = require('path');
const pkg = require('./package.json');
-// The Arch Linux (pacman) package is meant only for local builds on an Arch host
-// with makepkg. It already self-skips elsewhere (maker-pacman checks for makepkg),
-// but CI sets ROWBOAT_SKIP_PACMAN=1 to disable it explicitly — GitHub runners are
-// Ubuntu and shouldn't attempt to ship an Arch package.
-const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1';
-const SKIP_CODE_SIGNING = process.env.ROWBOAT_SKIP_CODE_SIGNING === '1';
-
-// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full
-// production dependency closure into the packaged app.
-//
-// Why this is needed: code mode spawns each adapter as a SEPARATE `node `
-// process and locates it at runtime via require.resolve — so it must ship as a real
-// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge
-// strips the workspace node_modules (see `ignore` below). Without this, packaged
-// builds throw `Cannot find module '@agentclientprotocol/...'`.
-//
-// Why we reconstruct the tree instead of copying node_modules: pnpm's store is a
-// symlink farm that legitimately holds multiple versions of the same package (e.g.
-// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style
-// node_modules — dereferencing symlinks — that resolves correctly regardless of pnpm
-// layout. We HOIST every package to the top-level node_modules and only nest a package
-// under its requirer on a genuine version conflict. Hoisting (vs. always nesting) keeps
-// the tree shallow: without it, transitive chains like codex-acp → open → wsl-utils →
-// is-wsl → is-inside-container → is-docker nest 5+ deep and produce ~260-char paths that
-// break the Windows Squirrel/nuget maker's MAX_PATH limit. Node resolution stays correct
-// because the top-level node_modules is an ancestor of every staged file, so a hoisted
-// package resolves for all requirers and a conflicting version shadows it via nesting.
-// verifyAcpStaging() below asserts this held for every dependency edge.
-//
-// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped
-// as platform-specific packages). Those are PROVISIONED on demand into
-// ~/.rowboat/engines/// and the adapters are pointed at them via
-// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping
-// them keeps each OS installer ~400 MB smaller while code mode stays fully functional.
-// Shared by stageAcpAdapters and verifyAcpStaging so staging and verification use
-// identical resolution semantics.
-const ACP_ADAPTERS = [
- '@agentclientprotocol/claude-agent-acp',
- '@agentclientprotocol/codex-acp',
-];
-
-// The native engines, shipped as platform packages. Provisioned on demand
-// (see header comment), so they're excluded from staging.
-const isAcpNativeEngine = (key) =>
- /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude
- /^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex
-
-// Resolve a dependency's real directory by walking node_modules the way Node does,
-// looking for the package DIRECTORY. We deliberately do NOT use
-// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
-// map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would
-// silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks.
-// Returns null for deps not installed for this OS (platform-optional binaries).
-const acpRealDirOf = (key, fromDir) => {
- const fs = require('fs');
- let dir = fromDir;
- for (;;) {
- const cand = path.join(dir, 'node_modules', ...key.split('/'));
- if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand);
- const parent = path.dirname(dir);
- if (parent === dir) return null;
- dir = parent;
- }
-};
-
-function stageAcpAdapters(mainDir, destNodeModules) {
- const fs = require('fs');
-
- let copied = 0;
- const skippedEngines = new Set();
- // srcRealDir -> the staged directory whose content represents it. Lets
- // verifyAcpStaging map every source package to where it landed.
- const placements = new Map();
- // package key -> version placed at the TOP-LEVEL node_modules. We hoist every
- // package to the top level and only nest a package under its requirer when a
- // DIFFERENT version is already hoisted there. See the header comment for why
- // (a shallow tree stays under Windows' MAX_PATH).
- const rootHoisted = new Map();
- const install = (srcDir, key, parentNM, chain) => {
- if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy
- const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
- const version = pj.version;
- const hoisted = rootHoisted.get(key);
- let destNM;
- if (hoisted === undefined) {
- destNM = destNodeModules; // first sighting → hoist to the top level
- rootHoisted.set(key, version);
- } else if (hoisted === version) {
- // identical version already hoisted at root → reuse it (its subtree is
- // already staged); just record where this srcDir resolves to.
- placements.set(srcDir, path.join(destNodeModules, ...key.split('/')));
- return;
- } else {
- destNM = parentNM; // genuine version conflict → nest under requirer
- }
- const destDir = path.join(destNM, ...key.split('/'));
- placements.set(srcDir, destDir);
- if (fs.existsSync(destDir)) return; // already placed at this exact location
- fs.mkdirSync(path.dirname(destDir), { recursive: true });
- fs.cpSync(srcDir, destDir, {
- recursive: true,
- dereference: true,
- filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion
- });
- copied++;
- const deps = { ...pj.dependencies, ...pj.optionalDependencies };
- const nextChain = new Set(chain).add(srcDir);
- for (const depKey of Object.keys(deps)) {
- if (isAcpNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
- const depDir = acpRealDirOf(depKey, srcDir);
- if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
- }
- };
-
- for (const key of ACP_ADAPTERS) {
- const srcDir = acpRealDirOf(key, mainDir);
- if (!srcDir) {
- throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`);
- }
- install(srcDir, key, destNodeModules, new Set());
- }
- if (skippedEngines.size) {
- console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`);
- }
- return { copied, placements };
-}
-
-// Fail the build LOUDLY if hoisting misplaced anything. Re-walk the source dependency
-// closure and assert that every (package → dependency) edge resolves, in the STAGED
-// tree, to the SAME version it resolves to in the SOURCE pnpm tree. This converts a
-// silent runtime "Cannot find module" (or a wrong-version resolution from a botched
-// hoist) into an immediate build failure. Expectations are derived from the source
-// tree — nothing is hardcoded — so it keeps working as the dependency set changes.
-function verifyAcpStaging(mainDir, placements) {
- const fs = require('fs');
- const versionAt = (dir) =>
- JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
- // Resolve `key`'s staged version as seen from `fromStagedDir`, via Node's own
- // upward node_modules walk. Reads package.json directly (not require.resolve, whose
- // `${key}/package.json` subpath some exports maps block).
- const stagedVersionOf = (key, fromStagedDir) => {
- let dir = fromStagedDir;
- for (;;) {
- const cand = path.join(dir, 'node_modules', ...key.split('/'));
- if (fs.existsSync(path.join(cand, 'package.json'))) return versionAt(cand);
- const parent = path.dirname(dir);
- if (parent === dir) return null;
- dir = parent;
- }
- };
- const errors = [];
- const visited = new Set();
- const walk = (srcDir) => {
- if (visited.has(srcDir)) return;
- visited.add(srcDir);
- const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8'));
- const stagedDir = placements.get(srcDir);
- if (!stagedDir) { errors.push(`not staged: ${pj.name}@${pj.version}`); return; }
- const deps = { ...pj.dependencies, ...pj.optionalDependencies };
- for (const depKey of Object.keys(deps)) {
- if (isAcpNativeEngine(depKey)) continue;
- const depSrc = acpRealDirOf(depKey, srcDir);
- if (!depSrc) continue; // platform-optional / not installed for this OS
- const want = versionAt(depSrc);
- const got = stagedVersionOf(depKey, stagedDir);
- if (got === null) {
- errors.push(`${pj.name} → ${depKey}: unresolved in staged tree (expected ${want})`);
- } else if (got !== want) {
- errors.push(`${pj.name} → ${depKey}: staged resolves ${got}, source resolves ${want}`);
- }
- walk(depSrc);
- }
- };
- for (const key of ACP_ADAPTERS) {
- const srcDir = acpRealDirOf(key, mainDir);
- if (srcDir) walk(srcDir);
- }
- if (errors.length) {
- throw new Error(
- `ACP staging verification failed — the staged tree resolves differently than source:\n - ${errors.join('\n - ')}`
- );
- }
-}
-
module.exports = {
packagerConfig: {
executableName: 'rowboat',
@@ -200,39 +16,30 @@ module.exports = {
],
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
- NSCameraUsageDescription: 'Rowboat uses your camera in video chat mode so the assistant can see you and give feedback (e.g. pitch practice).',
},
- ...(SKIP_CODE_SIGNING ? {} : {
- osxSign: {
- batchCodesignCalls: true,
- optionsForFile: () => ({
- entitlements: path.join(__dirname, 'entitlements.plist'),
- 'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
- }),
- },
- osxNotarize: {
- appleId: process.env.APPLE_ID,
- appleIdPassword: process.env.APPLE_PASSWORD,
- teamId: process.env.APPLE_TEAM_ID
- },
- }),
- // Since we bundle the main process with esbuild, we don't need the workspace
- // node_modules. These settings prevent Forge's dependency walker (flora-colossus)
- // from trying to analyze/copy node_modules, which fails with pnpm's symlinked
- // workspaces.
+ osxSign: {
+ batchCodesignCalls: true,
+ optionsForFile: () => ({
+ entitlements: path.join(__dirname, 'entitlements.plist'),
+ 'entitlements-inherit': path.join(__dirname, 'entitlements.plist'),
+ }),
+ },
+ osxNotarize: {
+ appleId: process.env.APPLE_ID,
+ appleIdPassword: process.env.APPLE_PASSWORD,
+ teamId: process.env.APPLE_TEAM_ID
+ },
+ // Since we bundle everything with esbuild, we don't need node_modules at all.
+ // These settings prevent Forge's dependency walker (flora-colossus) from trying
+ // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
prune: false,
- // Strip the workspace src/node_modules (paths are ANCHORED to the app root), BUT
- // always keep everything under `.package/` — that's our staged output: the
- // bundled main process, the ACP adapters + their dependency closure (staged by
- // the generateAssets hook), and the native node-pty module (staged into
- // .package/node_modules by bundle.mjs). Without the `.package` exemption the
- // node_modules rule would strip those and code mode / the embedded terminal
- // would break in packaged builds.
- ignore: (p) => {
- if (p === '/.package' || p.startsWith('/.package/')) return false;
- return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
- .some((re) => re.test(p));
- },
+ ignore: [
+ /src\//,
+ /node_modules\//,
+ /.gitignore/,
+ /bundle\.mjs/,
+ /tsconfig.json/,
+ ],
},
makers: [
{
@@ -249,17 +56,6 @@ module.exports = {
description: 'AI coworker with memory',
name: `Rowboat-win32-${arch}`,
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
- setupIcon: path.join(__dirname, 'icons/icon.ico'),
- // The animation is Squirrel's ONLY install UI — without this
- // users stare at Squirrel's unbranded default mid-install.
- loadingGif: path.join(__dirname, 'icons/install-loading.gif'),
- // Add/Remove Programs icon. Must be a remote URL (Squirrel
- // limitation); defaults to the Atom feather otherwise.
- iconUrl: 'https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/x/apps/main/icons/icon.ico',
- // Skip the machine-wide MSI deployment stub — it lands on the
- // GitHub release page next to setup.exe and users grab the
- // wrong one (it neither launches the app nor auto-updates).
- noMsi: true,
})
},
{
@@ -270,9 +66,7 @@ module.exports = {
bin: "rowboat",
description: 'AI coworker with memory',
maintainer: 'rowboatlabs',
- homepage: 'https://rowboatlabs.com',
- icon: path.join(__dirname, 'icons/icon.png'),
- mimeType: ['x-scheme-handler/rowboat'],
+ homepage: 'https://rowboatlabs.com'
}
})
},
@@ -283,28 +77,10 @@ module.exports = {
name: `Rowboat-linux`,
bin: "rowboat",
description: 'AI coworker with memory',
- homepage: 'https://rowboatlabs.com',
- icon: path.join(__dirname, 'icons/icon.png'),
- mimeType: ['x-scheme-handler/rowboat'],
+ homepage: 'https://rowboatlabs.com'
}
}
},
- // Arch Linux package — local-only; disabled in CI via ROWBOAT_SKIP_PACMAN.
- ...(SKIP_PACMAN ? [] : [{
- name: require.resolve('./makers/maker-pacman.cjs'),
- platforms: ['linux'],
- config: {
- name: 'rowboat',
- bin: 'rowboat',
- executableName: 'rowboat',
- description: 'AI coworker with memory',
- maintainer: 'rowboatlabs',
- homepage: 'https://rowboatlabs.com',
- license: 'Apache',
- icon: path.join(__dirname, 'icons/icon.png'),
- mimeType: ['x-scheme-handler/rowboat'],
- }
- }]),
{
name: '@electron-forge/maker-zip',
platform: ["darwin", "win32", "linux"],
@@ -397,18 +173,7 @@ module.exports = {
fs.mkdirSync(rendererDest, { recursive: true });
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
- // Stage the ACP coding-adapters (+ their JS dependency closure, minus native
- // engines) into .package/acp/node_modules. They are spawned as separate node
- // processes at runtime and Forge strips the workspace node_modules, so they
- // must be copied in explicitly. See stageAcpAdapters() above for the why.
- console.log('Staging ACP adapters...');
- const acpDest = path.join(packageDir, 'acp', 'node_modules');
- const { copied: staged, placements } = stageAcpAdapters(__dirname, acpDest);
- // Assert the hoisted tree resolves identically to source before shipping it.
- verifyAcpStaging(__dirname, placements);
- console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules (resolution verified)`);
-
console.log('✅ All assets staged in .package/');
},
}
-};
+};
\ No newline at end of file
diff --git a/apps/x/apps/main/icons/gen-install-loading.sh b/apps/x/apps/main/icons/gen-install-loading.sh
deleted file mode 100644
index 9b807fad..00000000
--- a/apps/x/apps/main/icons/gen-install-loading.sh
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/usr/bin/env bash
-# One-off dev tool, run manually (requires ImageMagick) — NOT part of any
-# build step. Its output, install-loading.gif, is committed and wired into
-# forge.config.cjs as Squirrel.Windows' `loadingGif` (the installer's only
-# UI). Re-run only if the icon or branding changes.
-#
-# Generates the Squirrel install animation: icon + title + operation label.
-# Timeline approximates a typical 20-25s install, then holds at "Almost done"
-# for the slow-machine tail. Frame every 0.5s. No progress bar — Squirrel gives
-# no real progress signal, so a bar would just be a fake timeline.
-set -euo pipefail
-
-ICON=$(dirname "$0")/icon.png
-# DejaVu Sans by name on systems that have it installed; set FONT to a
-# DejaVuSans.ttf path on systems that don't (e.g. Windows/macOS).
-FONT="${FONT:-DejaVu-Sans}"
-OUT_DIR=$(mktemp -d)
-mkdir -p "$OUT_DIR"
-rm -f "$OUT_DIR"/frame-*.png
-
-FRAMES=110 # 55s total at 0.5s/frame
-
-for i in $(seq 0 $((FRAMES - 1))); do
- label=$(awk -v i="$i" 'BEGIN {
- t = i * 0.5
- if (t < 14) print "Installing"; else if (t < 21) print "Creating shortcuts"; else print "Almost done"
- }')
-
- # Animated trailing dots (ellipsis), cycling every 2s
- ndots=$((i % 4))
- dots=""
- for _ in $(seq 1 $ndots); do dots=". $dots"; done
-
- magick -size 480x320 xc:'#252525' \
- \( "$ICON" -resize 84x84 \) -gravity center -geometry +0-72 -composite \
- -gravity center -font "$FONT" -pointsize 21 -fill '#e8e8e8' -annotate +0+8 'Installing Rowboat' \
- -gravity center -font "$FONT" -pointsize 14 -fill '#9a9a9a' -annotate +0+78 "$label" \
- -gravity center -font "$FONT" -pointsize 14 -fill '#6f6f6f' -annotate +0+100 "$dots" \
- "$OUT_DIR/frame-$(printf '%03d' "$i").png"
-done
-
-magick -delay 50 -loop 0 "$OUT_DIR"/frame-*.png -layers Optimize "$(dirname "$0")/install-loading.gif"
-echo "frames: $FRAMES"
-ls -la "$(dirname "$0")/install-loading.gif"
diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico
deleted file mode 100644
index 0e5ac870..00000000
Binary files a/apps/x/apps/main/icons/icon.ico and /dev/null differ
diff --git a/apps/x/apps/main/icons/install-loading.gif b/apps/x/apps/main/icons/install-loading.gif
deleted file mode 100644
index f673439e..00000000
Binary files a/apps/x/apps/main/icons/install-loading.gif and /dev/null differ
diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs
deleted file mode 100644
index 4cae1da9..00000000
--- a/apps/x/apps/main/makers/maker-pacman.cjs
+++ /dev/null
@@ -1,134 +0,0 @@
-// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages
-// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host).
-//
-// CJS on purpose: forge.config.cjs require()s us.
-
-const path = require('path');
-const fs = require('fs');
-const { execSync } = require('child_process');
-
-const MakerBase = require('@electron-forge/maker-base').default;
-
-const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' };
-
-class MakerPacman extends MakerBase {
- name = 'pacman';
- defaultPlatforms = ['linux'];
-
- isSupportedOnCurrentPlatform() {
- if (process.platform !== 'linux') return false;
- try {
- execSync('command -v makepkg', { stdio: 'ignore' });
- return true;
- } catch {
- return false;
- }
- }
-
- async make({ dir, makeDir, targetArch, packageJSON, appName }) {
- const pkgArch = ARCH_MAP[targetArch] || targetArch;
-
- const cfg = this.config || {};
- const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase();
- // pacman pkgver disallows '-'; map prerelease tags through.
- const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_');
- const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"');
- const maintainer = cfg.maintainer || 'unknown';
- const homepage = cfg.homepage || packageJSON.homepage || '';
- const license = cfg.license || 'custom';
- const bin = cfg.bin || pkgName;
- const execName = cfg.executableName || appName || pkgName;
- const mimeTypes = cfg.mimeType || [];
- const depends = cfg.depends || [];
- const iconSrc = cfg.icon;
-
- const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch));
- await this.ensureDirectory(outDir);
-
- // Clean prior contents so makepkg starts fresh each run.
- for (const f of fs.readdirSync(outDir)) {
- fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
- }
-
- // Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs).
- fs.writeFileSync(
- path.join(outDir, bin),
- `#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`,
- { mode: 0o755 },
- );
-
- const desktop = [
- '[Desktop Entry]',
- `Name=${appName || pkgName}`,
- `Comment=${pkgDesc}`,
- `Exec=${bin} %U`,
- `Icon=${pkgName}`,
- 'Type=Application',
- 'Categories=Utility;',
- 'Terminal=false',
- mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null,
- '',
- ].filter(Boolean).join('\n');
- fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop);
-
- const sources = [bin, `${pkgName}.desktop`];
- let iconInstall = '';
- if (iconSrc && fs.existsSync(iconSrc)) {
- fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png'));
- sources.push('icon.png');
- iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`;
- }
-
- const sumsLine = sources.map(() => "'SKIP'").join(' ');
- const sourceLine = sources.map((s) => `'${s}'`).join(' ');
- const dependsLine = depends.map((d) => `'${d}'`).join(' ');
- // Embed the packager output dir as a bash-safe literal.
- const appDirEscaped = dir.replace(/'/g, `'\\''`);
-
- const pkgbuild = `# Maintainer: ${maintainer}
-# Auto-generated by maker-pacman.cjs — do not edit by hand.
-pkgname=${pkgName}
-pkgver=${pkgVersion}
-pkgrel=1
-pkgdesc="${pkgDesc}"
-arch=('${pkgArch}')
-url="${homepage}"
-license=('${license}')
-depends=(${dependsLine})
-options=('!strip' '!debug')
-source=(${sourceLine})
-sha256sums=(${sumsLine})
-
-_appdir='${appDirEscaped}'
-
-package() {
- install -dm755 "$pkgdir/opt/$pkgname"
- cp -a "$_appdir/." "$pkgdir/opt/$pkgname/"
-
- # Electron's sandbox helper needs setuid root for the namespace sandbox.
- if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then
- chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
- fi
-
- install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}"
- install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop"
-${iconInstall}
-}
-`;
- fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild);
-
- execSync('makepkg -f --noconfirm --nodeps', {
- cwd: outDir,
- stdio: 'inherit',
- env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch },
- });
-
- return fs
- .readdirSync(outDir)
- .filter((f) => f.endsWith('.pkg.tar.zst'))
- .map((f) => path.join(outDir, f));
- }
-}
-
-module.exports = MakerPacman;
-module.exports.default = MakerPacman;
diff --git a/apps/x/apps/main/native/mic-monitor.swift b/apps/x/apps/main/native/mic-monitor.swift
deleted file mode 100644
index 239de7ee..00000000
--- a/apps/x/apps/main/native/mic-monitor.swift
+++ /dev/null
@@ -1,180 +0,0 @@
-// 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/package.json b/apps/x/apps/main/package.json
index 2a6c5018..74cb1598 100644
--- a/apps/x/apps/main/package.json
+++ b/apps/x/apps/main/package.json
@@ -13,25 +13,20 @@
"make": "electron-forge make"
},
"dependencies": {
- "@agentclientprotocol/claude-agent-acp": "^0.55.0",
- "@agentclientprotocol/codex-acp": "^1.1.0",
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
- "agent-slack": "0.9.3",
"chokidar": "^4.0.3",
- "electron-chrome-extensions": "^4.9.0",
"electron-squirrel-startup": "^1.0.1",
"html-to-docx": "^1.8.0",
"mammoth": "^1.11.0",
- "node-pty": "^1.1.0",
"papaparse": "^5.5.3",
"pdf-parse": "^2.4.5",
+ "update-electron-app": "^3.1.2",
"xlsx": "^0.18.5",
"zod": "^4.2.1"
},
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
- "@electron-forge/maker-base": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.11.1",
diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts
index b1e93d67..ad184451 100644
--- a/apps/x/apps/main/src/auth-server.ts
+++ b/apps/x/apps/main/src/auth-server.ts
@@ -2,8 +2,7 @@ import { createServer, Server } from 'http';
import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback';
-export const DEFAULT_PORT = 8080;
-export const PORT_RANGE_SIZE = 10;
+const DEFAULT_PORT = 8080;
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
@@ -20,46 +19,14 @@ export interface AuthServerResult {
port: number;
}
-interface CallbackHandlingOpts {
- callbackPath: string;
- /** Invoked when the provider redirects back with an `error` param. */
- onError?: (error: string) => void;
- /**
- * Gatekeeper run BEFORE the error/callback handling. Return a message to
- * reject the request with a polite close-this-tab error page — without
- * invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the
- * browser tab of a cancelled sign-in attempt carrying an old `state`)
- * without disturbing the live flow.
- */
- validateCallback?: (url: URL) => string | null;
-}
-
-function renderErrorPage(res: import('http').ServerResponse, message: string): void {
- res.writeHead(200, { 'Content-Type': 'text/html' });
- res.end(`
-
-
-
- OAuth Error
-
-
-
- Authorization Failed
- ${escapeHtml(message)}
- You can close this window.
-
-
-
- `);
-}
-
-function tryBindPort(
- port: number,
- onCallback: (callbackUrl: URL) => void | Promise,
- opts: CallbackHandlingOpts,
+/**
+ * Create a local HTTP server to handle OAuth callback
+ * Listens on http://localhost:8080/oauth/callback
+ * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
+ */
+export function createAuthServer(
+ port: number = DEFAULT_PORT,
+ onCallback: (callbackUrl: URL) => void | Promise
): Promise {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@@ -70,25 +37,30 @@ function tryBindPort(
}
const url = new URL(req.url, `http://localhost:${port}`);
-
- if (url.pathname === opts.callbackPath) {
- // Gatekeeper first: stale/foreign requests must not reach onError or
- // onCallback (a stale tab's redirect must never settle a live flow).
- const rejection = opts.validateCallback?.(url) ?? null;
- if (rejection) {
- renderErrorPage(res, rejection);
- return;
- }
-
+
+ if (url.pathname === OAUTH_CALLBACK_PATH) {
const error = url.searchParams.get('error');
if (error) {
- // Surface the provider error (e.g. access_denied when the user
- // cancels consent) so the caller can settle its flow instead of
- // waiting for the timeout. Callers that don't opt in keep the old
- // behaviour: the error page renders and the flow times out.
- opts.onError?.(error);
- renderErrorPage(res, `Error: ${error}`);
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+
+ OAuth Error
+
+
+
+ Authorization Failed
+ Error: ${escapeHtml(error)}
+ You can close this window.
+
+
+
+ `);
return;
}
@@ -124,10 +96,8 @@ function tryBindPort(
});
server.on('error', (err: NodeJS.ErrnoException) => {
- server.close();
- if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
- // Signal caller to try next port
- reject(Object.assign(new Error(err.code), { code: err.code }));
+ if (err.code === 'EADDRINUSE') {
+ reject(new Error(`Port ${port} is already in use`));
} else {
reject(err);
}
@@ -135,62 +105,3 @@ function tryBindPort(
});
}
-/**
- * Create a local HTTP server to handle OAuth callback.
- *
- * Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
- * thrown if it cannot be bound. This is the right behaviour for any provider
- * whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
- * callers must keep using the exact port they've handed to the provider.
- *
- * Opt into `{ fallback: true }` only when the caller is prepared to use the
- * port returned in `AuthServerResult` (i.e. the redirect URI is built from the
- * actual bound port, not hard-coded). With fallback enabled, scans `port`
- * through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
- * both EADDRINUSE and EACCES (the latter is common on Windows when
- * Hyper-V/WSL2 reserve the port).
- *
- * `callbackPath` overrides the served path for providers whose registered
- * redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is
- * invoked when the provider redirects back with an `error` param (e.g.
- * access_denied), letting the caller settle instead of waiting for timeout.
- * `validateCallback` runs before both — see CallbackHandlingOpts.
- */
-export async function createAuthServer(
- port: number = DEFAULT_PORT,
- onCallback: (callbackUrl: URL) => void | Promise,
- opts: { fallback?: boolean } & Partial = {},
-): Promise {
- const fallback = opts.fallback === true;
- const handlingOpts: CallbackHandlingOpts = {
- callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH,
- onError: opts.onError,
- validateCallback: opts.validateCallback,
- };
- const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
-
- for (let p = port; p <= limit; p++) {
- try {
- return await tryBindPort(p, onCallback, handlingOpts);
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
- console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
- continue;
- }
- if (!fallback) {
- const reason = code === 'EACCES' || code === 'EADDRINUSE'
- ? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
- : (err instanceof Error ? err.message : String(err));
- throw new Error(reason);
- }
- throw new Error(
- `No available port found in range ${port}–${limit}. Free a port in that range and try again.`
- );
- }
- }
-
- // Unreachable — loop always returns or throws — but satisfies TypeScript
- throw new Error(`No available port found in range ${port}–${limit}.`);
-}
-
diff --git a/apps/x/apps/main/src/browser/extensions.ts b/apps/x/apps/main/src/browser/extensions.ts
deleted file mode 100644
index a521c86c..00000000
--- a/apps/x/apps/main/src/browser/extensions.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import path from 'node:path';
-import fs from 'node:fs/promises';
-import { session, type BrowserWindow, type Session, type WebContents } from 'electron';
-import { ElectronChromeExtensions } from 'electron-chrome-extensions';
-import { WorkDir } from '@x/core/dist/config/config.js';
-import { browserViewManager } from './view.js';
-
-/**
- * Chrome extension support for the embedded browser, via
- * electron-chrome-extensions (GPL-3.0 / Patron dual-licensed — see the
- * library's LICENSE.md before shipping this in a release build).
- *
- * Extensions are loaded unpacked from ~/.rowboat/extensions// — either
- * a directory containing manifest.json directly, or (Chrome Web Store
- * unpacked layout) a directory whose single versioned subdirectory contains
- * it. There is no install UI; drop a folder there and restart the app.
- *
- * Known limits:
- * - The browser session's client-hint spoofing uses Electron's webRequest
- * API, which prevents extensions' chrome.webRequest listeners from firing
- * (Electron allows one consumer per session). Blockers that rely on
- * webRequest (uBlock MV2) won't block; content-script-based extensions
- * (Dark Reader, password managers) work.
- * - declarativeNetRequest is not implemented by Electron.
- */
-
-const EXTENSIONS_DIR = path.join(WorkDir, 'extensions');
-
-let extensions: ElectronChromeExtensions | null = null;
-
-/**
- * Called once at startup (before any browser use). Binds the extension
- * system to the browser session when the BrowserViewManager creates it, and
- * mirrors the manager's tab lifecycle into chrome.tabs.
- */
-export function setupBrowserExtensions(): void {
- browserViewManager.on('session-created', (browserSession: Session) => {
- initExtensions(browserSession);
- });
- browserViewManager.on('tab-created', (wc: WebContents, win: BrowserWindow) => {
- try {
- extensions?.addTab(wc, win);
- } catch (error) {
- console.error('[Extensions] addTab failed:', error);
- }
- });
- browserViewManager.on('tab-selected', (wc: WebContents) => {
- try {
- extensions?.selectTab(wc);
- } catch (error) {
- console.error('[Extensions] selectTab failed:', error);
- }
- });
-
- // Serves crx:// (extension action icons) to the app renderer, which hosts
- // the element on the default session; the element's
- // partition attribute routes state queries to the browser session.
- ElectronChromeExtensions.handleCRXProtocol(session.defaultSession);
-}
-
-function initExtensions(browserSession: Session): void {
- if (extensions) return;
-
- extensions = new ElectronChromeExtensions({
- license: 'GPL-3.0',
- session: browserSession,
- async createTab(details) {
- const result = await browserViewManager.newTab(details.url);
- if (!result.ok || !result.tabId) {
- throw new Error(result.error ?? 'Failed to create tab');
- }
- const wc = browserViewManager.getTabWebContents(result.tabId);
- const win = browserViewManager.getWindow();
- if (!wc || !win) throw new Error('Browser window is not available');
- return [wc, win];
- },
- selectTab(wc) {
- const tabId = browserViewManager.getTabIdForWebContents(wc);
- if (tabId) browserViewManager.switchTab(tabId);
- },
- removeTab(wc) {
- const tabId = browserViewManager.getTabIdForWebContents(wc);
- if (tabId) browserViewManager.closeTab(tabId);
- },
- });
-
- void loadUnpackedExtensions(browserSession);
-}
-
-async function resolveExtensionRoot(dir: string): Promise {
- const hasManifest = async (candidate: string) =>
- fs.access(path.join(candidate, 'manifest.json')).then(() => true, () => false);
-
- if (await hasManifest(dir)) return dir;
-
- // Chrome Web Store unpacked layout: //manifest.json. Pick the
- // lexically-latest version directory.
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
- const versionDirs = entries
- .filter((entry) => entry.isDirectory())
- .map((entry) => entry.name)
- .sort()
- .reverse();
- for (const name of versionDirs) {
- const candidate = path.join(dir, name);
- if (await hasManifest(candidate)) return candidate;
- }
- return null;
-}
-
-async function loadUnpackedExtensions(browserSession: Session): Promise {
- await fs.mkdir(EXTENSIONS_DIR, { recursive: true });
- const entries = await fs.readdir(EXTENSIONS_DIR, { withFileTypes: true }).catch(() => []);
-
- for (const entry of entries) {
- if (!entry.isDirectory()) continue;
- const root = await resolveExtensionRoot(path.join(EXTENSIONS_DIR, entry.name));
- if (!root) {
- console.warn(`[Extensions] No manifest.json under ${path.join(EXTENSIONS_DIR, entry.name)} — skipped`);
- continue;
- }
- try {
- const extension = await browserSession.extensions.loadExtension(root);
- console.log(`[Extensions] Loaded ${extension.name}@${extension.version} (${root})`);
- } catch (error) {
- console.error(`[Extensions] Failed to load ${root}:`, error);
- }
- }
-}
diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts
index 4bdafe9b..fa3b1ac1 100644
--- a/apps/x/apps/main/src/browser/ipc.ts
+++ b/apps/x/apps/main/src/browser/ipc.ts
@@ -1,6 +1,6 @@
import { BrowserWindow } from 'electron';
import { ipc } from '@x/shared';
-import { browserViewManager, type BrowserState, type DisplayMediaRequest, type HttpAuthRequest } from './view.js';
+import { browserViewManager, type BrowserState } from './view.js';
type IPCChannels = ipc.IPCChannels;
@@ -20,8 +20,6 @@ type BrowserHandlers = {
'browser:forward': InvokeHandler<'browser:forward'>;
'browser:reload': InvokeHandler<'browser:reload'>;
'browser:getState': InvokeHandler<'browser:getState'>;
- 'browser:httpAuthResponse': InvokeHandler<'browser:httpAuthResponse'>;
- 'browser:displayMediaResponse': InvokeHandler<'browser:displayMediaResponse'>;
};
/**
@@ -64,12 +62,6 @@ export const browserIpcHandlers: BrowserHandlers = {
'browser:getState': async () => {
return browserViewManager.getState();
},
- 'browser:httpAuthResponse': async (_event, args) => {
- return browserViewManager.respondToHttpAuth(args);
- },
- 'browser:displayMediaResponse': async (_event, args) => {
- return browserViewManager.respondToDisplayMedia(args);
- },
};
/**
@@ -78,34 +70,12 @@ export const browserIpcHandlers: BrowserHandlers = {
* window is created so the manager has a window to attach to.
*/
export function setupBrowserEventForwarding(): void {
- // Only send to app windows, never to OAuth/SSO popup windows created by
- // page window.open() — those render untrusted web content, and browsing
- // state / auth-challenge metadata must not cross into them.
- const broadcast = (channel: string, payload: unknown) => {
- for (const win of BrowserWindow.getAllWindows()) {
- if (win.isDestroyed() || !win.webContents) continue;
- if (browserViewManager.isPopupWindow(win)) continue;
- win.webContents.send(channel, payload);
- }
- };
-
browserViewManager.on('state-updated', (state: BrowserState) => {
- broadcast('browser:didUpdateState', state);
- });
-
- browserViewManager.on('http-auth-request', (request: HttpAuthRequest) => {
- broadcast('browser:httpAuthRequest', request);
- });
-
- browserViewManager.on('http-auth-resolved', (requestId: string) => {
- broadcast('browser:httpAuthResolved', { requestId });
- });
-
- browserViewManager.on('display-media-request', (request: DisplayMediaRequest) => {
- broadcast('browser:displayMediaRequest', request);
- });
-
- browserViewManager.on('display-media-resolved', (requestId: string) => {
- broadcast('browser:displayMediaResolved', { requestId });
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('browser:didUpdateState', state);
+ }
+ }
});
}
diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts
index 11a3a5bc..90b7d849 100644
--- a/apps/x/apps/main/src/browser/view.ts
+++ b/apps/x/apps/main/src/browser/view.ts
@@ -1,13 +1,11 @@
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
-import { BrowserWindow, WebContentsView, desktopCapturer, session, shell, type Session, type WebContents } from 'electron';
+import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
import type {
BrowserPageElement,
BrowserPageSnapshot,
BrowserState,
BrowserTabState,
- DisplayMediaRequest,
- HttpAuthRequest,
} from '@x/shared/dist/browser-control.js';
import { normalizeNavigationTarget } from './navigation.js';
import {
@@ -22,7 +20,7 @@ import {
type RawBrowserPageSnapshot,
} from './page-scripts.js';
-export type { BrowserPageSnapshot, BrowserState, BrowserTabState, DisplayMediaRequest, HttpAuthRequest };
+export type { BrowserPageSnapshot, BrowserState, BrowserTabState };
/**
* Embedded browser pane implementation.
@@ -38,35 +36,13 @@ export type { BrowserPageSnapshot, BrowserState, BrowserTabState, DisplayMediaRe
export const BROWSER_PARTITION = 'persist:rowboat-browser';
-// Spoof a real Chrome UA so OAuth servers don't reject the embedded browser.
-// The Chrome major version is derived from the running Chromium at startup:
-// pinning a fixed version goes stale as Electron upgrades, and Chromium keeps
-// emitting Sec-CH-UA client hints with the *real* version — a UA/client-hint
-// version mismatch is a classic bot-detection signal (Google sign-in,
-// Cloudflare). Minor version is frozen at 0.0.0, exactly like real Chrome's
-// reduced UA. The platform token matches the actual OS for the same reason.
-function getChromeMajorVersion(): number {
- const major = Number.parseInt(process.versions.chrome ?? '', 10);
- return Number.isFinite(major) && major > 0 ? major : 130;
-}
-
-function buildChromeUserAgent(): string {
- const platformToken =
- process.platform === 'darwin'
- ? 'Macintosh; Intel Mac OS X 10_15_7'
- : process.platform === 'win32'
- ? 'Windows NT 10.0; Win64; x64'
- : 'X11; Linux x86_64';
- return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${getChromeMajorVersion()}.0.0.0 Safari/537.36`;
-}
-
-const SPOOF_UA = buildChromeUserAgent();
+// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
+// that sniff the UA looking for "real browser" shapes.
+const SPOOF_UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const HOME_URL = 'https://www.google.com';
const NAVIGATION_TIMEOUT_MS = 10000;
-const HTTP_AUTH_TIMEOUT_MS = 120000;
-const DISPLAY_MEDIA_TIMEOUT_MS = 120000;
-const DISPLAY_MEDIA_THUMBNAIL_SIZE = { width: 320, height: 180 };
const POST_ACTION_IDLE_MS = 400;
const POST_ACTION_MAX_ELEMENTS = 25;
const POST_ACTION_MAX_TEXT_LENGTH = 4000;
@@ -92,21 +68,6 @@ type CachedSnapshot = {
elements: Array<{ index: number; selector: string }>;
};
-type PendingHttpAuth = {
- callback: (username?: string, password?: string) => void;
- timer: NodeJS.Timeout;
- // The webContents that raised the challenge, so its teardown can cancel it.
- webContents: WebContents;
-};
-
-type PendingDisplayMedia = {
- callback: (streams: Electron.Streams) => void;
- timer: NodeJS.Timeout;
- // The picker answers with a source id; the native callback needs the full
- // DesktopCapturerSource, so keep the gathered sources until resolution.
- sources: Map;
-};
-
const EMPTY_STATE: BrowserState = {
activeTabId: null,
tabs: [],
@@ -148,13 +109,6 @@ export class BrowserViewManager extends EventEmitter {
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
private snapshotCache = new Map();
- private pendingHttpAuth = new Map();
- private pendingDisplayMedia = new Map();
- // Child windows created by page window.open() (OAuth/SSO popups). Tracked so
- // they can be closed when the host window goes away — otherwise an orphaned
- // popup keeps BrowserWindow.getAllWindows() non-empty and, on macOS, blocks
- // the app from reopening via the Dock (see main.ts 'activate' handler).
- private popupWindows = new Set();
private cleanupWindowListeners: (() => void) | null = null;
attach(window: BrowserWindow): void {
@@ -183,7 +137,6 @@ export class BrowserViewManager extends EventEmitter {
if (this.window !== window) return;
const tabs = [...this.tabs.values()];
- const popups = [...this.popupWindows];
this.cleanupWindowListeners = null;
this.window = null;
this.browserSession = null;
@@ -197,17 +150,6 @@ export class BrowserViewManager extends EventEmitter {
this.attachedTabId = null;
this.visible = false;
this.snapshotCache.clear();
- for (const requestId of [...this.pendingHttpAuth.keys()]) {
- this.finishHttpAuth(requestId);
- }
- for (const requestId of [...this.pendingDisplayMedia.keys()]) {
- this.finishDisplayMedia(requestId);
- }
- // Close any OAuth/SSO popups so they don't outlive the app window.
- for (const popup of popups) {
- if (!popup.isDestroyed()) popup.close();
- }
- this.popupWindows.clear();
};
hostWebContents.on('did-start-loading', handleDidStartLoading);
@@ -229,138 +171,10 @@ export class BrowserViewManager extends EventEmitter {
if (this.browserSession) return this.browserSession;
const browserSession = session.fromPartition(BROWSER_PARTITION);
browserSession.setUserAgent(SPOOF_UA);
-
- // Electron's Sec-CH-UA client hints only carry the "Chromium" brand;
- // real Chrome also sends "Google Chrome". Some sign-in flows (notably
- // Google's) distinguish the two, so rewrite the brand list to match what
- // Chrome sends. Both the low-entropy header (`sec-ch-ua`, major versions)
- // and the high-entropy one (`sec-ch-ua-full-version-list`, requested via
- // Accept-CH and carrying full versions) must be rewritten together — a
- // header that claims "Google Chrome" alongside one that doesn't is a
- // stronger bot signal than the original. Only headers Chromium already
- // attached are rewritten — none are added. (navigator.userAgentData JS
- // brands still report only Chromium; there is no reliable hook to spoof
- // that under sandbox+contextIsolation, and header-based detection is the
- // common case.)
- const chromeMajor = getChromeMajorVersion();
- const chromeFull = process.versions.chrome ?? `${chromeMajor}.0.0.0`;
- const brandLists: Record = {
- 'sec-ch-ua': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "Not-A.Brand";v="99"`,
- 'sec-ch-ua-full-version-list': `"Chromium";v="${chromeFull}", "Google Chrome";v="${chromeFull}", "Not-A.Brand";v="99.0.0.0"`,
- };
- browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
- const requestHeaders = details.requestHeaders;
- for (const name of Object.keys(requestHeaders)) {
- const replacement = brandLists[name.toLowerCase()];
- if (replacement !== undefined) {
- requestHeaders[name] = replacement;
- }
- }
- callback({ requestHeaders });
- });
-
- // getDisplayMedia() from pages (e.g. Google Meet "Present now"). When the
- // browser pane is on screen, forward a source picker to the renderer and
- // resolve with the user's choice. Registered here (not in main.ts's
- // configureSessionPermissions) so the picker plumbing lives with the rest
- // of the pane's request/response wiring; the app's own session keeps its
- // auto-approve loopback handler for meeting transcription.
- browserSession.setDisplayMediaRequestHandler((_request, callback) => {
- this.handleDisplayMediaRequest(callback).catch(() => callback({}));
- });
-
this.browserSession = browserSession;
- // Synchronous on purpose: the extension system (browser/extensions.ts)
- // must bind to the session — registering its tab-API preload — before the
- // first tab's WebContentsView is constructed right after this returns.
- this.emit('session-created', browserSession);
return browserSession;
}
- /**
- * Resolve a page's getDisplayMedia() request. With the pane visible, gather
- * shareable sources and ask the renderer to show a picker (denied after a
- * timeout if unanswered). With the pane hidden, deny outright: nobody can
- * answer a picker, and auto-granting would hand a live screen capture to an
- * arbitrary page without consent. `visible` is also false whenever the
- * renderer hides the view behind a blocking overlay — including this
- * picker's own dialog — so a page re-requesting mid-picker lands here and
- * must be denied, not silently granted.
- */
- private async handleDisplayMediaRequest(callback: (streams: Electron.Streams) => void): Promise {
- if (!this.visible || !this.window) {
- callback({});
- return;
- }
-
- const sources = await desktopCapturer.getSources({
- types: ['screen', 'window'],
- thumbnailSize: DISPLAY_MEDIA_THUMBNAIL_SIZE,
- fetchWindowIcons: true,
- });
- if (sources.length === 0) {
- callback({});
- return;
- }
-
- const requestId = randomUUID();
- const timer = setTimeout(() => {
- this.finishDisplayMedia(requestId);
- }, DISPLAY_MEDIA_TIMEOUT_MS);
- this.pendingDisplayMedia.set(requestId, {
- callback,
- timer,
- sources: new Map(sources.map((source) => [source.id, source])),
- });
-
- const request: DisplayMediaRequest = {
- requestId,
- sources: sources.map((source) => ({
- id: source.id,
- name: source.name,
- kind: source.id.startsWith('screen:') ? 'screen' as const : 'window' as const,
- thumbnailDataUrl: source.thumbnail.isEmpty() ? '' : source.thumbnail.toDataURL(),
- // appIcon is typed non-null but is absent for screen sources.
- appIconDataUrl: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : null,
- })),
- };
- this.emit('display-media-request', request);
- }
-
- /**
- * Resolve a pending display-media request. `sourceId === undefined` (or an
- * id no longer in the gathered set) denies it. Always notifies the renderer
- * so a picker it may still be showing (e.g. after a timeout) is pruned.
- */
- private finishDisplayMedia(requestId: string, sourceId?: string, audio?: boolean): boolean {
- const pending = this.pendingDisplayMedia.get(requestId);
- if (!pending) return false;
- this.pendingDisplayMedia.delete(requestId);
- clearTimeout(pending.timer);
- const source = sourceId != null ? pending.sources.get(sourceId) : undefined;
- try {
- if (!source) {
- pending.callback({});
- } else if (audio) {
- pending.callback({ video: source, audio: 'loopback' });
- } else {
- pending.callback({ video: source });
- }
- } catch {
- // The requesting webContents may already be destroyed.
- }
- this.emit('display-media-resolved', requestId);
- return true;
- }
-
- respondToDisplayMedia(input: {
- requestId: string;
- sourceId?: string;
- audio?: boolean;
- }): { ok: boolean } {
- return { ok: this.finishDisplayMedia(input.requestId, input.sourceId, input.audio) };
- }
-
private emitState(): void {
this.emit('state-updated', this.snapshotState());
}
@@ -382,34 +196,17 @@ export class BrowserViewManager extends EventEmitter {
return /^https?:\/\//i.test(url) || url === 'about:blank';
}
- /**
- * webPreferences shared by browser tabs and OAuth popups. Kept in one place
- * so the security-sensitive popup surface can never drift from tabs.
- */
- private browserWebPreferences(): Electron.WebPreferences {
- return {
- session: this.getSession(),
- contextIsolation: true,
- sandbox: true,
- nodeIntegration: false,
- // Chromium's built-in PDFium viewer, so PDFs render inline instead
- // of showing a blank page.
- plugins: true,
- // Remove the WebAuthn API from the embedded browser only. Electron ships
- // the API but not Chrome's authenticator UI (Touch ID sheet, QR/phone
- // hybrid), so passkey challenges hang forever on "Verifying it's you...".
- // With the API absent, sites feature-detect it and fall back to
- // password/other verification. Scoped here (not app-wide) so the app's
- // own renderer keeps WebAuthn.
- disableBlinkFeatures: 'WebAuth',
- };
- }
-
private createView(): WebContentsView {
const view = new WebContentsView({
- webPreferences: this.browserWebPreferences(),
+ webPreferences: {
+ session: this.getSession(),
+ contextIsolation: true,
+ sandbox: true,
+ nodeIntegration: false,
+ },
});
+ view.webContents.setUserAgent(SPOOF_UA);
return view;
}
@@ -469,143 +266,14 @@ export class BrowserViewManager extends EventEmitter {
});
wc.on('page-title-updated', this.emitState.bind(this));
- this.wireWindowPolicy(wc);
- }
-
- /**
- * Window-open, popup, and HTTP-auth wiring shared by tabs and popups.
- */
- private wireWindowPolicy(wc: WebContents): void {
- wc.setWindowOpenHandler((details) => this.handleWindowOpen(details));
- wc.on('did-create-window', (child) => this.wirePopupWindow(child));
- this.wireHttpAuth(wc);
- }
-
- /**
- * Shared window.open / target=_blank policy for tabs and popups.
- *
- * An open that hands a handle back to the opener must become a real child
- * window so window.opener / postMessage survive — this is how OAuth/SSO
- * popups (Google, Microsoft, Plaid, ...) return their result; denying them
- * also makes sites report "popup blocked". Those are: a sized popup
- * (disposition 'new-window'), a *named* window.open(url, 'name') (non-empty
- * frameName), or a scripted blank window the opener will populate
- * (about:blank). A nameless target=_blank link (foreground-tab, empty
- * frameName) has no opener contract and opens as a tab, matching browser
- * behavior. Non-web schemes go to the system handler.
- *
- * Residual gap: a nameless, featureless window.open(url) is indistinguishable
- * from a _blank link (both foreground-tab + empty frameName) and opens as a
- * tab, losing its opener — rare for OAuth, which virtually always names or
- * sizes its popup.
- */
- private handleWindowOpen(details: Electron.HandlerDetails): Electron.WindowOpenHandlerResponse {
- const { url, disposition, frameName } = details;
-
- if (this.isEmbeddedTabUrl(url)) {
- const needsOpener =
- disposition === 'new-window' || frameName !== '' || url === 'about:blank';
- if (needsOpener) {
- return {
- action: 'allow',
- overrideBrowserWindowOptions: {
- autoHideMenuBar: true,
- webPreferences: this.browserWebPreferences(),
- },
- };
- }
- void this.newTab(url);
- return { action: 'deny' };
- }
-
- void shell.openExternal(url);
- return { action: 'deny' };
- }
-
- private wirePopupWindow(child: BrowserWindow): void {
- this.popupWindows.add(child);
- child.once('closed', () => this.popupWindows.delete(child));
- this.wireWindowPolicy(child.webContents);
- }
-
- /** True if `win` is an OAuth/SSO popup created by page window.open(). */
- isPopupWindow(win: BrowserWindow): boolean {
- return this.popupWindows.has(win);
- }
-
- /**
- * HTTP basic/proxy auth. Chromium's default is to cancel the challenge, so
- * 401-protected sites and authenticating proxies dead-end. When the browser
- * pane is on screen to answer, forward the challenge to it as a credential
- * prompt (cancelled after a timeout if unanswered). When the pane is closed
- * — e.g. agent-driven navigation — don't preventDefault, so Chromium cancels
- * immediately and the 401 page is readable rather than hanging.
- */
- private wireHttpAuth(wc: WebContents): void {
- wc.on('login', (event, _details, authInfo, callback) => {
- if (!this.visible || !this.window) return;
- event.preventDefault();
-
- const requestId = randomUUID();
- const timer = setTimeout(() => {
- this.finishHttpAuth(requestId);
- }, HTTP_AUTH_TIMEOUT_MS);
- this.pendingHttpAuth.set(requestId, { callback, timer, webContents: wc });
- // If the challenging contents dies before an answer, resolve now so the
- // native callback and timer don't leak (backstop for paths other than
- // destroyTab, which cancels explicitly before removeAllListeners()).
- wc.once('destroyed', () => this.finishHttpAuth(requestId));
-
- const request: HttpAuthRequest = {
- requestId,
- host: authInfo.host,
- isProxy: authInfo.isProxy,
- ...(authInfo.realm ? { realm: authInfo.realm } : {}),
- };
- this.emit('http-auth-request', request);
- });
- }
-
- /**
- * Resolve a pending auth challenge. `username === undefined` cancels it; an
- * empty-string username is a valid submission (token-style Basic auth).
- * Always notifies the renderer so a dialog it may still be showing (e.g.
- * after a timeout or tab close) is pruned.
- */
- private finishHttpAuth(requestId: string, username?: string, password?: string): boolean {
- const pending = this.pendingHttpAuth.get(requestId);
- if (!pending) return false;
- this.pendingHttpAuth.delete(requestId);
- clearTimeout(pending.timer);
- try {
- if (username == null) {
- pending.callback();
+ wc.setWindowOpenHandler(({ url }) => {
+ if (this.isEmbeddedTabUrl(url)) {
+ void this.newTab(url);
} else {
- pending.callback(username, password ?? '');
+ void shell.openExternal(url);
}
- } catch {
- // The challenged webContents may already be destroyed.
- }
- this.emit('http-auth-resolved', requestId);
- return true;
- }
-
- private cancelHttpAuthForWebContents(wc: WebContents): void {
- const ids: string[] = [];
- for (const [requestId, pending] of this.pendingHttpAuth) {
- if (pending.webContents === wc) ids.push(requestId);
- }
- for (const requestId of ids) {
- this.finishHttpAuth(requestId);
- }
- }
-
- respondToHttpAuth(input: {
- requestId: string;
- username?: string;
- password?: string;
- }): { ok: boolean } {
- return { ok: this.finishHttpAuth(input.requestId, input.username, input.password) };
+ return { action: 'deny' };
+ });
}
private snapshotTabState(tab: BrowserTab): BrowserTabState {
@@ -673,10 +341,6 @@ export class BrowserViewManager extends EventEmitter {
this.invalidateSnapshot(tabId);
this.syncAttachedView();
this.emitState();
- // Register with the extension system (chrome.tabs) before the initial
- // load below so extensions observe the tab's first navigation.
- this.emit('tab-created', tab.view.webContents, this.window);
- this.emit('tab-selected', tab.view.webContents);
const targetUrl =
initialUrl === 'about:blank'
@@ -700,10 +364,6 @@ export class BrowserViewManager extends EventEmitter {
private destroyTab(tab: BrowserTab): void {
this.invalidateSnapshot(tab.id);
- // Cancel any auth challenge this tab raised before we drop its listeners,
- // so the native callback + timer don't leak and the renderer prunes its
- // dialog (removeAllListeners() below would kill the 'destroyed' backstop).
- this.cancelHttpAuthForWebContents(tab.view.webContents);
tab.view.webContents.removeAllListeners();
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();
@@ -850,13 +510,11 @@ export class BrowserViewManager extends EventEmitter {
}
switchTab(tabId: string): { ok: boolean } {
- const tab = this.tabs.get(tabId);
- if (!tab) return { ok: false };
+ if (!this.tabs.has(tabId)) return { ok: false };
if (this.activeTabId === tabId) return { ok: true };
this.activeTabId = tabId;
this.syncAttachedView();
this.emitState();
- this.emit('tab-selected', tab.view.webContents);
return { ok: true };
}
@@ -1167,24 +825,6 @@ export class BrowserViewManager extends EventEmitter {
return this.snapshotState();
}
- // Accessors for the extension system's chrome.tabs bridge
- // (browser/extensions.ts), which speaks in WebContents while the manager
- // speaks in tab ids.
- getWindow(): BrowserWindow | null {
- return this.window;
- }
-
- getTabWebContents(tabId: string): WebContents | null {
- return this.getTab(tabId)?.view.webContents ?? null;
- }
-
- getTabIdForWebContents(wc: WebContents): string | null {
- for (const tab of this.tabs.values()) {
- if (tab.view.webContents === wc) return tab.id;
- }
- return null;
- }
-
private snapshotState(): BrowserState {
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
return {
diff --git a/apps/x/apps/main/src/chatgpt-signin.ts b/apps/x/apps/main/src/chatgpt-signin.ts
deleted file mode 100644
index 81e789d6..00000000
--- a/apps/x/apps/main/src/chatgpt-signin.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-import { shell } from 'electron';
-import type { Server } from 'http';
-import { createAuthServer } from './auth-server.js';
-import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
-import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
-import {
- CHATGPT_AUTHORIZE_URL,
- CHATGPT_CALLBACK_PATH,
- CHATGPT_CALLBACK_PORT,
- CHATGPT_CLIENT_ID,
- CHATGPT_EXTRA_AUTHORIZE_PARAMS,
- CHATGPT_REDIRECT_URI,
- CHATGPT_SCOPES,
-} from '@x/core/dist/auth/chatgpt-constants.js';
-
-// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client
-// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and
-// all token-endpoint traffic + storage live in core; this module owns the
-// system browser, the loopback callback server on 127.0.0.1:1455, and flow
-// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI
-// for the Codex client id, so there is no scan-to-next-port fallback.
-
-export type ChatGPTSignInResult = {
- signedIn: boolean;
- email?: string;
- accountId?: string;
- /** True when the attempt was cancelled (Cancel button or superseded). */
- cancelled?: boolean;
- error?: string;
-};
-
-/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */
-const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
-
-type ActiveAttempt = {
- promise: Promise;
- /**
- * Settle the attempt with a cancelled outcome. Resolves once the loopback
- * server has fully closed (listener + keep-alive connections), so a
- * follow-up attempt can rebind 1455 immediately.
- */
- cancel: (reason: string) => Promise;
-};
-
-let activeAttempt: ActiveAttempt | null = null;
-
-/**
- * Start a sign-in attempt. If one is already pending it is stale by
- * definition — the user is clicking Sign In again precisely because no
- * browser flow is visibly in progress (e.g. they closed the tab and hit
- * Cancel) — so cancel it and start FRESH (new PKCE verifier/state, new
- * loopback server, new browser window). Awaiting the cancel preserves the
- * one-server-at-a-time invariant: 1455 is fully released before rebinding.
- */
-export async function signInWithChatGPT(): Promise {
- if (activeAttempt) {
- const stale = activeAttempt;
- activeAttempt = null;
- console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one');
- await stale.cancel('Superseded by a new sign-in attempt.');
- }
-
- const attempt = startAttempt();
- activeAttempt = attempt;
- void attempt.promise.finally(() => {
- if (activeAttempt === attempt) activeAttempt = null;
- });
- return attempt.promise;
-}
-
-/**
- * Abort the pending attempt (renderer Cancel button): stops the loopback
- * server, clears pending state, settles the in-flight signIn promise with a
- * cancelled outcome. No-op when nothing is pending. Never touches stored
- * tokens — after cancel, chatgpt:getStatus reports signed-out (unless an
- * earlier sign-in already completed).
- */
-export async function cancelChatGPTSignIn(): Promise {
- const attempt = activeAttempt;
- if (!attempt) return;
- activeAttempt = null;
- await attempt.cancel('Sign-in cancelled.');
-}
-
-/**
- * One sign-in attempt. The returned promise always RESOLVES (never rejects),
- * and every exit path — success, denial, timeout, port busy, exchange
- * failure, cancellation — tears down the loopback server and the timeout
- * exactly once via the settle-once `finish`.
- */
-function startAttempt(): ActiveAttempt {
- let settle!: (result: ChatGPTSignInResult) => void;
- const promise = new Promise((resolve) => {
- settle = resolve;
- });
-
- let settled = false;
- let server: Server | null = null;
- let timeoutHandle: NodeJS.Timeout | null = null;
- let serverClosed: Promise | null = null;
-
- // Close the listening socket AND any keep-alive connections (the browser
- // holds one open after the callback response) so 1455 frees immediately.
- const closeServer = (): Promise => {
- if (serverClosed) return serverClosed;
- const s = server;
- server = null;
- serverClosed = !s
- ? Promise.resolve()
- : new Promise((resolve) => {
- s.close(() => resolve());
- s.closeAllConnections();
- });
- return serverClosed;
- };
-
- const finish = (result: ChatGPTSignInResult): Promise => {
- if (settled) return serverClosed ?? Promise.resolve();
- settled = true;
- if (timeoutHandle) clearTimeout(timeoutHandle);
- const closed = closeServer();
- if (!result.signedIn) {
- console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`);
- }
- settle(result);
- return closed;
- };
-
- const cancel = (reason: string): Promise =>
- finish({ signedIn: false, cancelled: true, error: reason });
-
- void run();
- return { promise, cancel };
-
- async function run(): Promise {
- console.log('[ChatGPTAuth] Starting sign-in flow...');
- try {
- const { verifier, challenge } = await oauthClient.generatePKCE();
- const state = oauthClient.generateState();
- if (settled) return; // cancelled while generating PKCE — nothing bound yet
-
- // Guard against duplicate callbacks (browser may send multiple requests).
- let callbackHandled = false;
- const onCallback = async (callbackUrl: URL) => {
- if (settled || callbackHandled) return;
- callbackHandled = true;
- try {
- // State already verified by validateCallback below.
- const code = callbackUrl.searchParams.get('code');
- if (!code) {
- void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' });
- return;
- }
- // Exchange + persistence live in core (never log token values).
- await exchangeChatGPTCode(code, verifier);
- const status = await getChatGPTStatus();
- console.log('[ChatGPTAuth] Sign-in complete');
- void finish({ ...status });
- } catch (error) {
- console.error('[ChatGPTAuth] Token exchange failed:', error);
- void finish({
- signedIn: false,
- error: error instanceof Error ? error.message : 'Token exchange failed',
- });
- }
- };
-
- // Bind the loopback server FIRST so a busy port fails fast, before any
- // browser tab opens. Fixed port — createAuthServer's no-fallback error
- // message tells the user to free the port.
- let boundServer: Server;
- try {
- ({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, {
- callbackPath: CHATGPT_CALLBACK_PATH,
- onError: (error) => {
- void finish({
- signedIn: false,
- error: error === 'access_denied'
- ? 'Sign-in was cancelled in the browser.'
- : `Sign-in failed: ${error}`,
- });
- },
- // Stale callbacks — a tab left over from an earlier, cancelled
- // attempt carries that attempt's `state` — get a polite
- // close-this-tab page and never reach onError/onCallback, so they
- // can neither complete sign-in nor settle the live attempt.
- validateCallback: (url) => {
- if (settled) {
- return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.';
- }
- if (url.searchParams.get('state') !== state) {
- return 'This sign-in link has expired. Close this tab and try again from Rowboat.';
- }
- return null;
- },
- }));
- } catch (error) {
- void finish({
- signedIn: false,
- error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server',
- });
- return;
- }
-
- if (settled) {
- // Cancelled while the bind was in flight — release the port we just
- // grabbed (finish() already ran with no server to close).
- boundServer.closeAllConnections();
- boundServer.close();
- return;
- }
- server = boundServer;
-
- timeoutHandle = setTimeout(() => {
- void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' });
- }, SIGN_IN_TIMEOUT_MS);
-
- const authUrl = new URL(CHATGPT_AUTHORIZE_URL);
- authUrl.search = new URLSearchParams({
- response_type: 'code',
- client_id: CHATGPT_CLIENT_ID,
- redirect_uri: CHATGPT_REDIRECT_URI,
- scope: CHATGPT_SCOPES.join(' '),
- code_challenge: challenge,
- code_challenge_method: 'S256',
- state,
- ...CHATGPT_EXTRA_AUTHORIZE_PARAMS,
- }).toString();
-
- try {
- // System browser: shares the user's existing ChatGPT session cookies.
- await shell.openExternal(authUrl.toString());
- } catch (error) {
- void finish({
- signedIn: false,
- error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser',
- });
- }
- } catch (error) {
- console.error('[ChatGPTAuth] Sign-in flow error:', error);
- void finish({
- signedIn: false,
- error: error instanceof Error ? error.message : 'Sign-in failed',
- });
- }
- }
-}
diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts
index 256fe365..8fc4b754 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/runtime/assembly/copilot/instructions.js';
+import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/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';
@@ -315,50 +315,3 @@ export async function listToolkits() {
totalItems: filtered.length,
};
}
-
-/**
- * Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be
- * connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin.
- */
-export async function executeTool(
- toolkitSlug: string,
- toolSlug: string,
- args?: Record,
-): Promise<{ successful: boolean; data?: unknown; error?: string }> {
- const account = composioAccountsRepo.getAccount(toolkitSlug);
- if (!account || account.status !== 'ACTIVE') {
- return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` };
- }
- try {
- const result = await composioClient.executeAction(toolSlug, {
- connected_account_id: account.id,
- user_id: 'rowboat-user',
- version: 'latest',
- arguments: args ?? {},
- });
- return { successful: result.successful, data: result.data, error: result.error ?? undefined };
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message);
- return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` };
- }
-}
-
-/**
- * Search Composio tools within a toolkit so a Mini App can discover the right
- * tool slug + input schema at runtime (how generated apps will wire actions).
- */
-export async function searchToolsInToolkit(
- toolkitSlug: string,
- query: string,
-): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> {
- try {
- const { items } = await composioClient.searchTools(query, [toolkitSlug]);
- return {
- tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })),
- };
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- return { tools: [], error: message };
- }
-}
diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts
index 68877975..aaaaa3bc 100644
--- a/apps/x/apps/main/src/deeplink.ts
+++ b/apps/x/apps/main/src/deeplink.ts
@@ -39,8 +39,6 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
export function dispatchUrl(url: string): void {
if (parseAction(url)) {
void dispatchAction(url);
- } else if (parsePickerCompletion(url)) {
- void dispatchPickerCompletion(url);
} else if (parseOAuthCompletion(url)) {
void dispatchOAuthCompletion(url);
} else {
@@ -160,41 +158,6 @@ async function dispatchOAuthCompletion(url: string): Promise {
await completeRowboatGoogleConnect(parsed.state);
}
-// --- Managed OAuth-redirect Picker completion ---
-
-interface PickerCompletion {
- state: string;
-}
-
-/**
- * Match rowboat://oauth/google/picker/done?session=. Distinct from the
- * connect completion above (oauth/google/done) by the extra `picker` segment.
- */
-function parsePickerCompletion(url: string): PickerCompletion | null {
- if (!url.startsWith(URL_PREFIX)) return null;
- const rest = url.slice(URL_PREFIX.length);
- const queryIdx = rest.indexOf("?");
- const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
- const parts = path.split("/").filter(Boolean);
- if (parts.length !== 4) return null;
- if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null;
- const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
- const state = params.get("session");
- return state ? { state } : null;
-}
-
-async function dispatchPickerCompletion(url: string): Promise {
- const parsed = parsePickerCompletion(url);
- if (!parsed) return;
-
- const win = mainWindowRef;
- if (win && !win.isDestroyed()) focusWindow(win);
-
- // Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps.
- const { completeManagedGooglePick } = await import("./google-picker-managed.js");
- await completeManagedGooglePick(parsed.state);
-}
-
function focusWindow(win: BrowserWindow): void {
if (win.isMinimized()) win.restore();
win.show();
diff --git a/apps/x/apps/main/src/google-picker-managed.ts b/apps/x/apps/main/src/google-picker-managed.ts
deleted file mode 100644
index a9790ad9..00000000
--- a/apps/x/apps/main/src/google-picker-managed.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { shell, BrowserWindow } from 'electron';
-import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
-import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
-import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js';
-import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js';
-
-// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on
-// the Rowboat backend with the COMPANY Google client — the desktop never holds
-// a client_id/secret or an API key. The desktop just opens the start URL, waits
-// for the deep link, claims the picked file ids, and downloads them with the
-// user's EXISTING managed Google token (which already holds drive.file from the
-// main connect). No Picker API key, appId, ngrok, or local OAuth.
-//
-// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing
-// managed Google-connect (start URL → park under session → deep-link back):
-//
-// GET ${webappUrl}/oauth/google/picker/start
-// Runs Google OAuth with the company client, scope=drive.file ONLY,
-// trigger_onepick=true, prompt=consent. Tied to the logged-in web
-// session (cookies), exactly like /oauth/google/start.
-//
-// GET ${webappUrl}/oauth/google/picker/callback
-// Google returns `picked_file_ids` (+ code). Park the ids under a
-// one-shot `session` ticket, then deep-link the desktop:
-// rowboat://oauth/google/picker/done?session=
-// (No need to exchange the code: the file is granted to the company
-// client, so the desktop's existing managed token can read it.)
-//
-// POST ${API_URL}/v1/google-oauth/claim-picked body { session }
-// Authenticated with the user's Rowboat bearer. Returns
-// { fileIds: string[], tokens: { access_token, ... } } — a fresh
-// drive.file token minted during the picker's own authorization.
-
-export interface ManagedPickResult {
- path: string;
- doc: GoogleDocListItem;
-}
-
-interface PendingPick {
- targetFolder: string;
- resolve: (result: ManagedPickResult | null) => void;
- reject: (error: Error) => void;
- timer: NodeJS.Timeout;
-}
-
-// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep
-// link can't carry our targetFolder, so we stash it here for completion.
-let pending: PendingPick | null = null;
-const TIMEOUT_MS = 10 * 60 * 1000;
-
-function clearPending(): void {
- if (pending) {
- clearTimeout(pending.timer);
- pending = null;
- }
-}
-
-function focusApp(): void {
- const win = BrowserWindow.getAllWindows()[0];
- if (win) {
- if (win.isMinimized()) win.restore();
- win.focus();
- }
-}
-
-/**
- * Open the managed picker in the browser and resolve once the deep link comes
- * back with the user's selection (or null on cancel/timeout). The actual import
- * happens in completeManagedGooglePick, fired by the deep-link dispatcher.
- */
-export async function startManagedGooglePick(targetFolder: string): Promise {
- // Supersede any abandoned flow so a stale deep link can't resolve this one.
- if (pending) {
- const stale = pending;
- clearPending();
- stale.resolve(null);
- }
-
- const webappUrl = await getWebappUrl();
- return await new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- if (pending) {
- clearPending();
- resolve(null);
- }
- }, TIMEOUT_MS);
- pending = { targetFolder, resolve, reject, timer };
- void shell.openExternal(`${webappUrl}/oauth/google/picker/start`);
- });
-}
-
-/**
- * Deep-link handler for rowboat://oauth/google/picker/done?session=.
- * Claims the picked file ids from the backend and imports the first one with
- * the existing managed token, resolving the promise startManagedGooglePick
- * returned.
- */
-export async function completeManagedGooglePick(session: string): Promise {
- const current = pending;
- if (!current) {
- console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)');
- return;
- }
- clearPending();
- focusApp();
-
- try {
- const { fileIds, accessToken } = await claimPickedFilesViaBackend(session);
- if (fileIds.length === 0 || !accessToken) {
- current.resolve(null);
- return;
- }
- // Download with the picker's own fresh drive.file token (the main
- // connection doesn't carry drive.file).
- const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken);
- current.resolve(result);
- } catch (error) {
- current.reject(error instanceof Error ? error : new Error(String(error)));
- }
-}
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 2532755a..a667b512 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -1,130 +1,52 @@
-import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app, screen, powerSaveBlocker } from 'electron';
+import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
import { ipc } from '@x/shared';
import path from 'node:path';
import os from 'node:os';
-import { fileURLToPath } from 'node:url';
import {
connectProvider,
disconnectProvider,
listProviders,
} from './oauth-handler.js';
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/runtime/legacy/runs.js';
-import { bus } from '@x/core/dist/runtime/legacy/bus.js';
+import * as runsCore from '@x/core/dist/runs/runs.js';
+import { bus } from '@x/core/dist/runs/bus.js';
import { serviceBus } from '@x/core/dist/services/service_bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
-import { execFile } from 'node:child_process';
+import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import z from 'zod';
-const execFileAsync = promisify(execFile);
-
-// Active powerSaveBlocker id while Caffeinate is toggled on; null when off.
-let caffeinateBlockerId: number | null = null;
-
+const execAsync = promisify(exec);
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 { 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';
-import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
+import { testModelConnection } from '@x/core/dist/models/models.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
-import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
-import { listCodexModels } from '@x/core/dist/models/codex.js';
-import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
-import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
-import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
-import type { CodeRunFeed } from '@x/core/dist/code-mode/feed.js';
-import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
-import { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js';
-import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
-import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
-import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
-import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
-import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
-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/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';
-import { applyChannelsConfig, getChannelsStatus, logoutWhatsApp, subscribeChannelsStatus } from '@x/core/dist/channels/service.js';
-import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
-import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
-import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
-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';
-import * as appsAgents from '@x/core/dist/apps/agents.js';
-import { capture } from '@x/core/dist/analytics/posthog.js';
-import { recordAppVersion, isVersionUpgrade } from '@x/core/dist/config/app_version.js';
-import { getUpdaterStatus, checkForUpdates, quitAndInstallUpdate } from './updater.js';
-import * as githubAuth from '@x/core/dist/apps/github-auth.js';
-import * as appsStars from '@x/core/dist/apps/stars.js';
-import * as appsInstaller from '@x/core/dist/apps/installer.js';
-import { registryClient } from '@x/core/dist/apps/registry.js';
-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';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js';
-import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js';
-import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js';
-import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js';
import { versionHistory, voice } from '@x/core';
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
-import { claimReferralCode, getCreditsState, maybeActivateCredit, subscribeCreditActivations } from '@x/core/dist/billing/credits.js';
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, 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';
-import { startManagedGooglePick } from './google-picker-managed.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@@ -135,200 +57,6 @@ import {
deleteLiveNote,
listLiveNotes,
} from '@x/core/dist/knowledge/live-note/fileops.js';
-import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js';
-import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js';
-import {
- fetchTask,
- patchTask,
- createTask,
- deleteTask,
- listTasks,
- readRunIds as readTaskRunIds,
-} from '@x/core/dist/background-tasks/fileops.js';
-
-type SlackHomeChannel = {
- id: string;
- name: string;
- workspaceUrl?: string;
- workspaceName?: string;
-};
-
-type SlackHomeMessage = {
- id: string;
- workspaceName?: string;
- workspaceUrl?: string;
- channelId?: string;
- channelName?: string;
- author?: string;
- text: string;
- ts: string;
- url?: string;
-};
-
-function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
- const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
- return (parsed.workspaces || []).map((w) => ({
- url: w.workspace_url || '',
- name: w.workspace_name || '',
- }));
-}
-
-type SlackAuthResult = {
- ok: boolean;
- workspaces: Array<{ url: string; name: string }>;
- error?: string;
- errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
-};
-
-// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
-// Shared by the plain and the quit-Slack-first import handlers.
-async function importDesktopAndReadWorkspaces(): Promise {
- const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
- if (!imported.ok) {
- return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
- }
- const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
- if (!whoami.ok) {
- return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
- }
- const workspaces = parseWhoamiWorkspaces(whoami.data);
- if (workspaces.length === 0) {
- return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
- }
- return { ok: true, workspaces };
-}
-
-// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
-// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
-// Slack open. taskkill exits non-zero when nothing matches — that's fine.
-async function quitSlackIfWindows(): Promise {
- if (process.platform !== 'win32') return;
- try {
- await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
- } catch {
- // No running Slack process to kill — nothing to do.
- }
- // Give Windows a moment to release the file handles before we copy them.
- await new Promise(resolve => setTimeout(resolve, 800));
-}
-
-function extractArrayPayload(parsed: unknown): unknown[] {
- if (Array.isArray(parsed)) return parsed;
- if (parsed && typeof parsed === 'object') {
- const obj = parsed as Record;
- for (const key of ['messages', 'channels', 'items', 'results', 'data']) {
- if (Array.isArray(obj[key])) return obj[key] as unknown[];
- }
- }
- return [];
-}
-
-function slackMessageText(message: Record): string {
- const value = message.text ?? message.body ?? message.content;
- return typeof value === 'string' ? value.trim() : '';
-}
-
-function slackMessageAuthor(message: Record): string | undefined {
- const value = message.username ?? message.user ?? message.author;
- return typeof value === 'string' ? value : undefined;
-}
-
-function extractSlackUserName(raw: unknown): string | null {
- if (!raw || typeof raw !== 'object') return null;
- const obj = raw as Record;
- const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record : undefined;
- const user = obj.user && typeof obj.user === 'object' ? obj.user as Record : undefined;
- const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record : undefined;
-
- const candidates = [
- profile?.display_name,
- profile?.real_name,
- userProfile?.display_name,
- userProfile?.real_name,
- obj.display_name,
- obj.displayName,
- obj.real_name,
- obj.realName,
- user?.display_name,
- user?.displayName,
- user?.real_name,
- user?.realName,
- obj.name,
- user?.name,
- ];
-
- for (const candidate of candidates) {
- if (typeof candidate === 'string' && candidate.trim()) {
- return candidate.trim();
- }
- }
-
- return null;
-}
-
-async function resolveSlackUserName(
- userId: string,
- workspaceUrl: string | undefined,
- cache: Map,
-): Promise {
- const key = `${workspaceUrl ?? ''}:${userId}`;
- if (cache.has(key)) return cache.get(key) ?? null;
-
- const args = ['user', 'get', userId];
- if (workspaceUrl) {
- args.push('--workspace', workspaceUrl);
- }
-
- const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
- if (result.ok) {
- const name = extractSlackUserName(result.data ?? {});
- if (name) {
- cache.set(key, name);
- return name;
- }
- } else {
- console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
- }
-
- cache.set(key, userId);
- return null;
-}
-
-async function resolveSlackMessageText(
- text: string,
- workspaceUrl: string | undefined,
- cache: Map,
-): Promise {
- const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g));
- if (matches.length === 0) return text;
-
- let resolved = text;
- for (const match of matches) {
- const userId = match[1] ?? match[3];
- if (!userId) continue;
- const fallback = match[2] ?? match[0];
- const name = await resolveSlackUserName(userId, workspaceUrl, cache);
- resolved = resolved.replaceAll(match[0], name ?? fallback);
- }
- return resolved;
-}
-
-async function resolveSlackAuthor(
- author: string | undefined,
- workspaceUrl: string | undefined,
- cache: Map,
-): Promise {
- if (!author) return undefined;
- if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author;
- return await resolveSlackUserName(author, workspaceUrl, cache) ?? author;
-}
-
-function slackMessageUrl(message: Record, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined {
- const direct = message.permalink ?? message.url;
- if (typeof direct === 'string' && direct) return direct;
- if (!workspaceUrl || !channelId) return undefined;
- return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`;
-}
import { browserIpcHandlers } from './browser/ipc.js';
/**
@@ -427,37 +155,9 @@ type InvokeHandlers = {
[K in InvokeChannels]: InvokeHandler;
};
-// In-flight streaming TTS requests, keyed by renderer-chosen requestId.
-const activeTtsStreams = new Map();
-
-// Video-mode popout window (shown for the whole duration of a screen share,
-// floating over every app including Rowboat itself) and the last call state
-// pushed by the main window — replayed to the popout when it finishes loading.
-let videoPopoutWin: BrowserWindow | null = null;
-let lastVideoPopoutState: {
- ttsState: 'idle' | 'synthesizing' | 'speaking';
- status: 'listening' | 'thinking' | 'speaking' | null;
- cameraOn: boolean;
- micMuted: boolean;
- screenSharing: boolean;
- interimText: string | null;
-} | null = null;
-
-// Match only real app windows — getAllWindows() can also contain the popout
-// itself and hidden utility windows (e.g. PDF-export renderers), which must
-// not be shown, focused, or sent app events.
-function findMainAppWindow(): BrowserWindow | undefined {
- return BrowserWindow.getAllWindows().find((w) => {
- if (w === videoPopoutWin || w.isDestroyed()) return false;
- const url = w.webContents.getURL();
- const isAppWindow = url.startsWith('app://') || url.startsWith('http://localhost');
- return isAppWindow && !url.includes('#video-popout');
- });
-}
-
/**
* Register all IPC handlers with type safety and runtime validation
- *
+ *
* This function ensures:
* 1. All invoke channels have handlers (exhaustiveness checking)
* 2. Handler signatures match channel definitions
@@ -513,14 +213,24 @@ let debounceTimer: ReturnType | null = null;
* Emit knowledge commit event to all renderer windows
*/
function emitKnowledgeCommitEvent(): void {
- broadcastToWindows('knowledge:didCommit', {});
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('knowledge:didCommit', {});
+ }
+ }
}
/**
* Emit workspace change event to all renderer windows
*/
function emitWorkspaceChangeEvent(event: z.infer): void {
- broadcastToWindows('workspace:didChange', event);
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('workspace:didChange', event);
+ }
+ }
}
/**
@@ -576,25 +286,7 @@ function queueChange(relPath: string): void {
/**
* Handle workspace change event from core watcher
*/
-function touchesKnowledge(event: z.infer): boolean {
- const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/');
- switch (event.type) {
- case 'created':
- case 'changed':
- case 'deleted':
- return hit(event.path);
- case 'moved':
- return hit(event.from) || hit(event.to);
- case 'bulkChanged':
- return !event.paths || event.paths.some(hit);
- default:
- return false;
- }
-}
-
function handleWorkspaceChange(event: z.infer): void {
- // Any knowledge-base change drops the cached index so the next read rebuilds.
- if (touchesKnowledge(event)) invalidateKnowledgeIndex();
// Debounce 'changed' events, emit others immediately
if (event.type === 'changed' && event.path) {
queueChange(event.path);
@@ -605,12 +297,11 @@ function handleWorkspaceChange(event: z.infer): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
- win.webContents.send(channel, payload);
+ win.webContents.send('runs:events', event);
}
}
}
-function emitRunEvent(event: z.infer): void {
- broadcastToWindows('runs:events', event);
-}
-
function emitServiceEvent(event: z.infer): void {
- broadcastToWindows('services:events', event);
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('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();
- broadcastToWindows('oauth:didConnect', event);
- // Google connect (both BYOK and rowboat-mode paths funnel through here) is
- // the "connected Gmail" first-time reward.
- if (event.provider === 'google' && event.success) {
- void maybeActivateCredit('first_gmail_connected');
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('oauth:didConnect', event);
+ }
}
}
-async function requireCodeSession(sessionId: string): Promise {
- const repo = container.resolve('codeSessionsRepo');
- const session = await repo.get(sessionId);
- if (!session) {
- throw new Error(`Unknown code session: ${sessionId}`);
- }
- return session;
-}
-
-let codeSessionStatusWatcher: (() => void) | null = null;
-export async function startCodeSessionStatusWatcher(): Promise {
- if (codeSessionStatusWatcher) {
- return;
- }
- const tracker = container.resolve('codeSessionStatusTracker');
- await tracker.start();
- codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => {
- broadcastToWindows('codeSession:status', { sessionId, status });
- });
-}
-
let runsWatcher: (() => void) | null = null;
export async function startRunsWatcher(): Promise {
if (runsWatcher) {
@@ -701,105 +366,6 @@ export async function startRunsWatcher(): Promise {
});
}
-// New runtime: session bus → renderer windows (session-design.md §10).
-function emitSessionEvent(event: SessionBusEvent): void {
- broadcastToWindows('sessions:events', event);
-}
-
-// Mobile channels: status changes (QR pairing, connect/disconnect) → renderer.
-let channelsWatcher: (() => void) | null = null;
-export function startChannelsWatcher(): void {
- if (channelsWatcher) return;
- channelsWatcher = subscribeChannelsStatus((status) => {
- broadcastToWindows('channels:status', status);
- });
-}
-
-let sessionsWatcher: (() => void) | null = null;
-export function startSessionsWatcher(): void {
- if (sessionsWatcher) {
- return;
- }
- const sessionBus = container.resolve('sessionBus');
- 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.
-let codeRunFeedWatcher: (() => void) | null = null;
-export function startCodeRunFeedWatcher(): void {
- if (codeRunFeedWatcher) {
- return;
- }
- const feed = container.resolve('codeRunFeed');
- codeRunFeedWatcher = feed.subscribe((event) => {
- broadcastToWindows('codeRun:events', event);
- });
-}
-
-// The renderer window is created before the session-index startup scan
-// finishes, so an early sessions:list could observe a partially built index
-// (the scan runs oldest-first — exactly the newest chats would be missing).
-// sessions:list awaits this deferred; main.ts resolves it when the scan
-// settles (success or failure, so the list never hangs).
-let resolveSessionsIndexReady: () => void;
-const sessionsIndexReady = new Promise((resolve) => {
- resolveSessionsIndexReady = resolve;
-});
-export function markSessionsIndexReady(): void {
- resolveSessionsIndexReady();
-}
-
let servicesWatcher: (() => void) | null = null;
export async function startServicesWatcher(): Promise {
if (servicesWatcher) {
@@ -814,15 +380,12 @@ let liveNoteAgentWatcher: (() => void) | null = null;
export function startLiveNoteAgentWatcher(): void {
if (liveNoteAgentWatcher) return;
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
- broadcastToWindows('live-note-agent:events', event);
- });
-}
-
-let backgroundTaskAgentWatcher: (() => void) | null = null;
-export function startBackgroundTaskAgentWatcher(): void {
- if (backgroundTaskAgentWatcher) return;
- backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => {
- broadcastToWindows('bg-task-agent:events', event);
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('live-note-agent:events', event);
+ }
+ }
});
}
@@ -844,10 +407,6 @@ export function stopServicesWatcher(): void {
// Handler Implementations
// ============================================================================
-// app:consumeUpdateInfo returns `updatedFrom` at most once per app run, so a
-// renderer reload doesn't re-show the "updated to vX" card.
-let updateNoticeConsumed = false;
-
/**
* Register all IPC handlers
* Add new handlers here as you add channels to IPCChannels
@@ -856,17 +415,6 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
- // Relay backend-confirmed credit grants (first-time-action rewards) to all
- // windows so the UI can update balances and celebrate.
- subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event));
-
- // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
- // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
- // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
- // for full historical coverage of people you've actually emailed.
- warmContactIndex();
- warmSentContacts();
-
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@@ -875,90 +423,10 @@ export function setupIpcHandlers() {
'app:consumePendingDeepLink': async () => {
return { url: consumePendingDeepLink() };
},
- 'app:consumeUpdateInfo': async () => {
- const version = app.getVersion();
- if (updateNoticeConsumed) return { version, updatedFrom: null };
- updateNoticeConsumed = true;
- const changedFrom = recordAppVersion(version);
- // Downgrades still restamp (so the next upgrade reports correctly) but
- // don't toast "Updated to vX" or count as a client update.
- const updatedFrom = changedFrom && isVersionUpgrade(changedFrom, version) ? changedFrom : null;
- // 'app_updated' is taken by the in-app apps feature; this is the client itself.
- if (updatedFrom) capture('client_updated', { from: updatedFrom, to: version });
- return { version, updatedFrom };
- },
- 'updater:getStatus': async () => {
- return getUpdaterStatus();
- },
- 'updater:check': async () => {
- return checkForUpdates();
- },
- 'updater:quitAndInstall': async () => {
- quitAndInstallUpdate();
- return {};
- },
- '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) => {
- // Captured here because the popup window runs without PostHog.
- capture('meeting_popup_action', { action: args.action });
- handleMeetingPopupAction(args.action);
- return {};
- },
'analytics:bootstrap': async () => {
return {
installationId: getInstallationId(),
apiUrl: API_URL,
- appVersion: app.getVersion(),
};
},
'workspace:getRoot': async () => {
@@ -991,107 +459,6 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts);
},
- 'gmail:getImportant': async (_event, args) => {
- return listImportantThreads({ cursor: args.cursor, limit: args.limit });
- },
- 'gmail:getEverythingElse': async (_event, args) => {
- return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit, category: args.category });
- },
- 'gmail:triggerSync': async () => {
- triggerGmailSync();
- return {};
- },
- 'gmail:sendReply': async (_event, args) => {
- const result = await sendThreadReply(args);
- if (!result.error) {
- void maybeActivateCredit('first_email_sent');
- }
- return result;
- },
- 'gmail:saveDraft': async (_event, args) => {
- return saveThreadDraft(args);
- },
- 'gmail:deleteDraft': async (_event, args) => {
- return deleteThreadDraft(args.draftId);
- },
- 'gmail:getDrafts': async () => {
- return listDraftThreads();
- },
- 'gmail:search': async (_event, args) => {
- return searchThreads(args.query, { limit: args.limit });
- },
- 'gmail:getConnectionStatus': async () => {
- return getGmailConnectionStatus();
- },
- 'gmail:getAccountEmail': async () => {
- return { email: await getAccountEmail() };
- },
- 'gmail:getAccountName': async () => {
- return { name: await getAccountName() };
- },
- 'gmail:setImportance': async (_event, args) => {
- 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);
- },
- 'gmail:trashThread': async (_event, args) => {
- return trashThread(args.threadId);
- },
- 'gmail:markThreadRead': async (_event, args) => {
- return markThreadRead(args.threadId, args.read);
- },
- 'gmail:downloadAttachment': async (_event, args) => {
- return downloadAttachment(args);
- },
- 'gmail:saveMessageHeight': async (_event, args) => {
- saveMessageBodyHeight(args.threadId, args.messageId, args.height);
- return {};
- },
- 'gmail:searchContacts': async (_event, args) => {
- const query = args?.query ?? '';
- const limit = args?.limit;
- const excludeEmails = args?.excludeEmails;
-
- // Primary source: people you've actually sent mail to (Gmail SENT label,
- // cached + refreshed via the Gmail API). Fallback: local-snapshot index
- // — used only when the SENT index hasn't been populated yet (very first
- // launch, before the background sync finishes).
- const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
- if (sent.length > 0) {
- return { contacts: sent };
- }
- const fallback = await searchGmailContacts(query, { limit, excludeEmails });
- return { contacts: fallback };
- },
'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor);
},
@@ -1102,17 +469,12 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
- return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
+ return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
return { success: true };
},
- 'codeRun:resolvePermission': async (_event, args) => {
- const registry = container.resolve('codePermissionRegistry');
- registry.resolve(args.requestId, args.decision);
- return { success: true };
- },
'runs:provideHumanInput': async (_event, args) => {
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
return { success: true };
@@ -1127,175 +489,24 @@ export function setupIpcHandlers() {
'runs:list': async (_event, args) => {
return runsCore.listRuns(args.cursor);
},
- 'runs:listByWorkDir': async (_event, args) => {
- return runsCore.listRunsByWorkDir(args.dir);
- },
'runs:delete': async (_event, args) => {
await runsCore.deleteRun(args.runId);
return { success: true };
},
- // ── New runtime: sessions + turns ─────────────────────────
- // Thin pass-throughs to the sessions service. sendMessage returns the
- // turnId immediately; the turn advances in the background and the
- // renderer reconciles via the sessions:events feed. Input-routing calls
- // settle with that advance's outcome (the renderer fire-and-forgets).
- 'sessions:create': async (_event, args) => {
- const sessionId = await container.resolve('sessions').createSession(args);
- return { sessionId };
- },
- 'sessions:list': async () => {
- await sessionsIndexReady;
- return { sessions: container.resolve('sessions').listSessions() };
- },
- 'sessions:get': async (_event, args) => {
- return container.resolve('sessions').getSession(args.sessionId);
- },
- 'sessions:getTurn': async (_event, args) => {
- return container.resolve('sessions').getTurn(args.turnId);
- },
- 'sessions:sendMessage': async (_event, args) => {
- return container.resolve('sessions').sendMessage(args.sessionId, args.input, args.config);
- },
- 'sessions:respondToPermission': async (_event, args) => {
- await container.resolve('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata);
- return { success: true };
- },
- 'sessions:respondToAskHuman': async (_event, args) => {
- await container.resolve('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer);
- return { success: true };
- },
- 'sessions:stopTurn': async (_event, args) => {
- await container.resolve('sessions').stopTurn(args.turnId, args.reason);
- return { success: true };
- },
- 'sessions:resumeTurn': async (_event, args) => {
- await container.resolve('sessions').resumeTurn(args.sessionId);
- return { success: true };
- },
- 'sessions:setTitle': async (_event, args) => {
- await container.resolve('sessions').setTitle(args.sessionId, args.title);
- return { success: true };
- },
- 'sessions:delete': async (_event, args) => {
- 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');
- const state = await sessions.getSession(args.sessionId);
- const win = BrowserWindow.fromWebContents(event.sender);
- const result = await dialog.showSaveDialog(win!, {
- defaultPath: `${args.sessionId}.jsonl.log`,
- filters: [
- { name: 'Chat Log', extensions: ['log'] },
- { name: 'JSONL', extensions: ['jsonl'] },
- { name: 'All Files', extensions: ['*'] },
- ],
- });
- if (result.canceled || !result.filePath) {
- return { success: false };
- }
- try {
- const lines: string[] = [];
- for (const ref of state.turns) {
- const turn = await sessions.getTurn(ref.turnId);
- for (const turnEvent of turn.events) {
- lines.push(JSON.stringify(turnEvent));
- }
- }
- await fs.writeFile(result.filePath, lines.join('\n') + '\n');
- return { success: true };
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Failed to download chat log';
- return { success: false, error: message };
- }
- },
- 'runs:downloadLog': async (event, args) => {
- const runFileName = `${args.runId}.jsonl`;
- if (path.basename(runFileName) !== runFileName) {
- return { success: false, error: 'Invalid run id' };
- }
-
- const sourcePath = path.join(WorkDir, 'runs', runFileName);
- const win = BrowserWindow.fromWebContents(event.sender);
- const result = await dialog.showSaveDialog(win!, {
- defaultPath: `${runFileName}.log`,
- filters: [
- { name: 'Chat Log', extensions: ['log'] },
- { name: 'JSONL', extensions: ['jsonl'] },
- { name: 'All Files', extensions: ['*'] },
- ],
- });
-
- if (result.canceled || !result.filePath) {
- return { success: false };
- }
-
- try {
- await fs.copyFile(sourcePath, result.filePath);
- return { success: true };
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Failed to download chat log';
- return { success: false, error: message };
- }
- },
'models:list': async () => {
- const base = (await isSignedIn())
- ? await listGatewayModels()
- : await listOnboardingModels();
- // ChatGPT-subscription (codex) models are additive and independent of
- // Rowboat sign-in; their failure must never break the main list.
- try {
- const chatgpt = await getChatGPTStatus();
- if (chatgpt.signedIn) {
- const codex = await listCodexModels();
- return { providers: [...base.providers, ...codex.providers] };
- }
- } catch (error) {
- console.warn('[Codex] Listing subscription models failed:', error);
+ if (await isSignedIn()) {
+ return await listGatewayModels();
}
- return base;
+ return await listOnboardingModels();
},
'models:test': async (_event, args) => {
return await testModelConnection(args.provider, args.model);
},
- 'models:listForProvider': async (_event, args) => {
- try {
- const models = await listModelsForProvider(args.provider);
- return { success: true, models };
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Failed to list models';
- return { success: false, error: message };
- }
- },
- 'llm:getDefaultModel': async () => {
- return await getDefaultModelAndProvider();
- },
- 'llm:generate': async (_event, args) => {
- console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
- const result = await generateOneShot(args);
- console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
- return result;
- },
'models:saveConfig': async (_event, args) => {
const repo = container.resolve('modelConfigRepo');
await repo.setConfig(args);
return { success: true };
},
- 'models:updateConfig': async (_event, args) => {
- const repo = container.resolve('modelConfigRepo');
- await repo.updateConfig(args);
- return { success: true };
- },
'oauth:connect': async (_event, args) => {
const credentials = args.clientId && args.clientSecret
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }
@@ -1313,32 +524,6 @@ export function setupIpcHandlers() {
const config = await repo.getClientFacingConfig();
return { config };
},
- 'chatgpt:getStatus': async () => {
- return await getChatGPTStatus();
- },
- 'chatgpt:signIn': async () => {
- const result = await signInWithChatGPT();
- if (result.signedIn) {
- // Model lists gate on sign-in state (composer picker, models:list) —
- // push the change so they refresh without polling.
- broadcastToWindows('chatgpt:statusChanged', { signedIn: true });
- }
- return result;
- },
- 'chatgpt:cancelSignIn': async () => {
- await cancelChatGPTSignIn();
- return { success: true };
- },
- 'chatgpt:signOut': async () => {
- try {
- await signOutChatGPT();
- broadcastToWindows('chatgpt:statusChanged', { signedIn: false });
- return { success: true };
- } catch (error) {
- console.error('[ChatGPTAuth] Sign-out failed:', error);
- return { success: false };
- }
- },
'account:getRowboat': async () => {
const signedIn = await isSignedIn();
if (!signedIn) {
@@ -1359,153 +544,6 @@ export function setupIpcHandlers() {
const config = await repo.getConfig();
return { enabled: config.enabled };
},
- 'codeMode:getConfig': async () => {
- const repo = container.resolve('codeModeConfigRepo');
- const config = await repo.getConfig();
- return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
- },
- 'codeMode:setConfig': async (_event, args) => {
- const repo = container.resolve('codeModeConfigRepo');
- await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
- invalidateCopilotInstructionsCache();
- return { success: true };
- },
- 'codeMode:checkAgentStatus': async () => {
- return await checkCodeModeAgentStatus();
- },
- 'codeMode:provisionEngine': async (_event, args) => {
- // Download + install the agent's engine, streaming progress back to the
- // requesting window so Settings can show a live bar. 'check' is instant — skip it.
- try {
- await ensureEngine(args.agent, {
- onProgress: (p) => {
- if (p.phase === 'check') return;
- _event.sender.send('codeMode:engineProgress', {
- agent: args.agent,
- phase: p.phase,
- receivedBytes: p.receivedBytes,
- totalBytes: p.totalBytes,
- });
- },
- });
- return { success: true };
- } catch (e) {
- return { success: false, error: e instanceof Error ? e.message : String(e) };
- }
- },
- 'codeProject:add': async (_event, args) => {
- const repo = container.resolve('codeProjectsRepo');
- const project = await repo.add(args.path);
- const git = await codeGit.repoInfo(project.path);
- return { project, git };
- },
- 'codeProject:remove': async (_event, args) => {
- const repo = container.resolve('codeProjectsRepo');
- await repo.remove(args.projectId);
- return { success: true };
- },
- 'codeProject:list': async () => {
- const repo = container.resolve('codeProjectsRepo');
- const projects = await repo.list();
- return {
- projects: await Promise.all(projects.map(async (project) => ({
- project,
- git: await codeGit.repoInfo(project.path),
- }))),
- };
- },
- 'codeSession:create': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- const session = await service.create(args);
- capture('code_session_created', { mode: session.mode, agent: session.agent });
- return { session };
- },
- 'codeSession:list': async () => {
- const repo = container.resolve('codeSessionsRepo');
- const tracker = container.resolve('codeSessionStatusTracker');
- return { sessions: await repo.list(), statuses: tracker.getStatuses() };
- },
- 'codeSession:update': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- return { session: await service.update(args.sessionId, args.patch) };
- },
- 'codeMode:listModelOptions': async (_event, args) => {
- const manager = container.resolve('codeModeManager');
- return manager.listModelOptions(args.agent);
- },
- 'codeSession:delete': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- disposeTerminal(args.sessionId);
- await service.delete(args.sessionId, {
- removeWorktree: args.removeWorktree,
- deleteBranch: args.deleteBranch,
- });
- return { success: true };
- },
- 'codeSession:sendMessage': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- // Intentionally not awaited: the turn can run for minutes and streams over
- // runs:events. sendMessage validates synchronously enough that busy/unknown
- // errors are reported via the run's error events instead.
- const resultPromise = service.sendMessage(args.sessionId, args.text);
- // Surface immediate rejections (busy session, unknown id) to the caller.
- const result = await Promise.race([
- resultPromise,
- new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)),
- ]);
- resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err));
- return result;
- },
- 'codeSession:stop': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- await service.stop(args.sessionId);
- return { success: true };
- },
- 'codeSession:gitStatus': async (_event, args) => {
- const session = await requireCodeSession(args.sessionId);
- const info = await codeGit.repoInfo(session.cwd);
- if (!info.isGitRepo) {
- return { isRepo: false, branch: null, hasCommits: false, files: [] };
- }
- let files = await codeGit.status(session.cwd);
- if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) {
- const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch);
- const byPath = new Map(branchFiles.map((file) => [file.path, file]));
- for (const file of files) {
- if (!byPath.has(file.path)) byPath.set(file.path, file);
- }
- files = [...byPath.values()];
- }
- return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
- },
- 'codeSession:fileDiff': async (_event, args) => {
- const session = await requireCodeSession(args.sessionId);
- return codeGit.fileDiff(session.cwd, args.path, {
- baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null,
- });
- },
- 'codeSession:readdir': async (_event, args) => {
- const session = await requireCodeSession(args.sessionId);
- return { entries: await readProjectDir(session.cwd, args.relPath) };
- },
- 'codeSession:readFile': async (_event, args) => {
- const session = await requireCodeSession(args.sessionId);
- return readProjectFile(session.cwd, args.relPath);
- },
- 'codeSession:mergeBack': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- return service.mergeBack(args.sessionId);
- },
- 'codeSession:cleanupWorktree': async (_event, args) => {
- const service = container.resolve('codeSessionService');
- try {
- await service.cleanupWorktree(args.sessionId, args.deleteBranch);
- return { success: true };
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Failed to clean up worktree';
- return { success: false, error: message };
- }
- },
'granola:setConfig': async (_event, args) => {
const repo = container.resolve('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });
@@ -1517,45 +555,6 @@ export function setupIpcHandlers() {
return { success: true };
},
- // ── Caffeinate (keep system awake, like macOS `caffeinate`) ──
- 'power:getCaffeinate': async () => {
- return { enabled: caffeinateBlockerId !== null && powerSaveBlocker.isStarted(caffeinateBlockerId) };
- },
- 'power:setCaffeinate': async (_event, args) => {
- if (args.enabled) {
- if (caffeinateBlockerId === null || !powerSaveBlocker.isStarted(caffeinateBlockerId)) {
- caffeinateBlockerId = powerSaveBlocker.start('prevent-app-suspension');
- }
- } else if (caffeinateBlockerId !== null) {
- if (powerSaveBlocker.isStarted(caffeinateBlockerId)) {
- powerSaveBlocker.stop(caffeinateBlockerId);
- }
- caffeinateBlockerId = null;
- }
- const enabled = caffeinateBlockerId !== null;
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send('power:caffeinateChanged', { enabled });
- }
- }
- return { enabled };
- },
- // ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
- 'channels:getConfig': async () => {
- return container.resolve('channelsConfigRepo').getConfig();
- },
- 'channels:setConfig': async (_event, args) => {
- await container.resolve('channelsConfigRepo').setConfig(args);
- await applyChannelsConfig(args);
- return { success: true };
- },
- 'channels:getStatus': async () => {
- return getChannelsStatus();
- },
- 'channels:whatsappLogout': async () => {
- await logoutWhatsApp();
- return { success: true };
- },
'slack:getConfig': async () => {
const repo = container.resolve('slackConfigRepo');
const config = await repo.getConfig();
@@ -1564,192 +563,22 @@ export function setupIpcHandlers() {
'slack:setConfig': async (_event, args) => {
const repo = container.resolve('slackConfigRepo');
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
- // Connecting/disconnecting Slack changes the Copilot's routing (native
- // `slack` skill vs. Composio), so rebuild its cached instructions.
- invalidateCopilotInstructionsCache();
return { success: true };
},
- 'slack:cliStatus': async () => {
- return await getAgentSlackCliStatus();
- },
- 'slack:knowledgeStatus': async () => {
- return {
- cli: await getAgentSlackCliStatus(),
- sources: getSlackKnowledgeSyncStatus(),
- };
- },
'slack:listWorkspaces': async () => {
- const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
- if (!result.ok) {
- return { workspaces: [], error: result.message, errorKind: result.kind };
- }
- const workspaces = parseWhoamiWorkspaces(result.data);
- return { workspaces };
- },
- 'slack:importDesktopAuth': async () => {
- // Pull xoxc token(s) + cookie from the running/installed Slack desktop
- // app into agent-slack's credential store, then read back the workspaces.
- return await importDesktopAndReadWorkspaces();
- },
- 'slack:quitAndImportDesktop': async () => {
- // Windows-only convenience: kill Slack (which locks its Cookies DB) then
- // run the normal desktop import in one click.
- await quitSlackIfWindows();
- return await importDesktopAndReadWorkspaces();
- },
- 'slack:parseCurlAuth': async (_event, args) => {
- // Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
- // request from a signed-in Slack web tab; parse-curl reads it from stdin
- // and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
- const curl = (args.curl ?? '').trim();
- if (!curl) {
- return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
- }
- const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
- if (!imported.ok) {
- return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
- }
- const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
- if (!whoami.ok) {
- return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
- }
- const workspaces = parseWhoamiWorkspaces(whoami.data);
- if (workspaces.length === 0) {
- return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
- }
- return { ok: true, workspaces };
- },
- 'slack:listChannels': async (_event, args) => {
- const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
- if (!result.ok) {
- return { channels: [], error: result.message };
- }
- const rawChannels = extractArrayPayload(result.data) as Array<{
- id?: string;
- name?: string;
- is_private?: boolean;
- isPrivate?: boolean;
- is_member?: boolean;
- isMember?: boolean;
- }>;
- const channels = rawChannels.map((ch) => ({
- id: ch.id || ch.name || '',
- name: ch.name || ch.id || '',
- isPrivate: ch.is_private ?? ch.isPrivate,
- isMember: ch.is_member ?? ch.isMember,
- })).filter((ch) => ch.id && ch.name);
- return { channels };
- },
- 'slack:getRecentMessages': async (_event, args) => {
- const repo = container.resolve('slackConfigRepo');
- const config = await repo.getConfig();
- if (!config.enabled || config.workspaces.length === 0) {
- return { enabled: false, messages: [] };
- }
-
- const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
- const messages: SlackHomeMessage[] = [];
- const userNameCache = new Map();
-
try {
- const knowledgeConfig = knowledgeSourcesRepo.getConfig();
- const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled);
- let channels: SlackHomeChannel[] = (slackSource?.scopes ?? [])
- .filter(scope => scope.type === 'channel')
- .map(scope => ({
- id: scope.id,
- name: scope.name ?? scope.id,
- workspaceUrl: scope.workspaceUrl,
- workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name,
- }));
-
- if (channels.length === 0) {
- for (const workspace of config.workspaces) {
- const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
- if (!channelList.ok) {
- throw new AgentSlackRunError(channelList.kind, channelList.message);
- }
- const rawChannels = extractArrayPayload(channelList.data);
- for (const raw of rawChannels) {
- if (!raw || typeof raw !== 'object') continue;
- const channel = raw as Record;
- const id = typeof channel.id === 'string' ? channel.id : undefined;
- const name = typeof channel.name === 'string' ? channel.name : id;
- const isMember = channel.is_member ?? channel.isMember;
- if (!id || !name || isMember === false) continue;
- channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name });
- }
- }
- }
-
- channels = channels.slice(0, 8);
-
- for (const channel of channels) {
- const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500'];
- if (channel.workspaceUrl) {
- commandArgs.push('--workspace', channel.workspaceUrl);
- }
- const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
- if (!messageList.ok) {
- console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
- continue;
- }
- const rawMessages = extractArrayPayload(messageList.data);
- for (const raw of rawMessages) {
- if (!raw || typeof raw !== 'object') continue;
- const message = raw as Record;
- const ts = typeof message.ts === 'string' ? message.ts : undefined;
- const text = slackMessageText(message);
- if (!ts || !text) continue;
- const channelId = typeof message.channel_id === 'string'
- ? message.channel_id
- : typeof message.channel === 'string'
- ? message.channel
- : channel.id;
- const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
- const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
- messages.push({
- id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
- workspaceName: channel.workspaceName,
- workspaceUrl: channel.workspaceUrl,
- channelId,
- channelName: channel.name,
- author: resolvedAuthor,
- text: resolvedText,
- ts,
- url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
- });
- }
- }
-
- const rankedIds = await rankSlackHomeMessages(messages, limit);
- const byId = new Map(messages.map(message => [message.id, message]));
- const rankedMessages = rankedIds
- .map(id => byId.get(id))
- .filter((message): message is SlackHomeMessage => Boolean(message));
- return { enabled: true, messages: rankedMessages };
+ const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
+ const parsed = JSON.parse(stdout);
+ const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
+ url: w.workspace_url || '',
+ name: w.workspace_name || '',
+ }));
+ return { workspaces };
} catch (err: unknown) {
- const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
- const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
- return { enabled: true, messages: [], error: message, errorKind };
+ const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
+ return { workspaces: [], error: message };
}
},
- 'knowledgeSources:getConfig': async () => {
- return knowledgeSourcesRepo.getConfig();
- },
- 'knowledgeSources:upsert': async (_event, args) => {
- const config = knowledgeSourcesRepo.upsertSource(args);
- if (args.provider === 'slack') {
- // The Copilot prompt lists the selected Slack channels, so refresh it
- // whenever the channel selection changes.
- invalidateCopilotInstructionsCache();
- triggerSlackKnowledgeSync();
- void syncSlackKnowledgeSources().catch(error => {
- console.error('[SlackKnowledge] Immediate sync after settings update failed:', error);
- });
- }
- return config;
- },
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet
const complete = isOnboardingComplete();
@@ -1785,197 +614,9 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
- 'composio:execute-tool': async (_event, args) => {
- return composioHandler.executeTool(args.toolkitSlug, args.toolSlug, args.arguments);
- },
- 'composio:search-tools': async (_event, args) => {
- return composioHandler.searchToolsInToolkit(args.toolkitSlug, args.query);
- },
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
},
- // Rowboat Apps handlers (spec §13)
- 'apps:serverStatus': async () => {
- return appsServer.getServerStatus();
- },
- 'apps:list': async () => {
- const status = appsServer.getServerStatus();
- const apps = await appsIndexer.listApps();
- // Keep bundled agents materialized (idempotent; disabled by default).
- 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();
- }
- // The copilot builds apps by writing the folder directly — apps:create is
- // never on that path — so the first-app reward triggers off observed
- // state instead: a valid non-installed app means the user built one.
- // Cheap on repeat polls (maybeActivateCredit short-circuits once claimed).
- if (apps.some((a) => a.kind === 'local' && a.status === 'ok')) {
- void maybeActivateCredit('first_app_built');
- }
- return {
- serverRunning: status.running,
- ...(status.error ? { serverError: status.error } : {}),
- apps,
- };
- },
- 'apps:get': async (_event, args) => {
- const app = await appsIndexer.getApp(args.folder);
- if (!app) throw new Error(`no such app: ${args.folder}`);
- const readme = await appsIndexer.readAppReadme(args.folder);
- return {
- app,
- ...(readme ? { readme } : {}),
- rollbackAvailable: await appsIndexer.rollbackAvailable(args.folder),
- };
- },
- 'apps:create': async (_event, args) => {
- const app = await appsIndexer.createApp(args);
- capture('app_created', { folder: app.folder });
- void maybeActivateCredit('first_app_built');
- return { app };
- },
- 'apps:delete': async (_event, args) => {
- await appsIndexer.deleteApp(args.folder);
- // Remove app-owned bg-tasks too — orphaned app---- tasks firing
- // against a deleted app was a painful prototype failure mode.
- await appsAgents.deleteAppAgents(args.folder);
- capture('app_deleted', { folder: args.folder });
- return { ok: true as const };
- },
- 'apps:setTheme': async (_event, args) => {
- appsServer.setAppsTheme(args.theme);
- return { ok: true as const };
- },
- // GitHub auth (device flow) — publishing only
- // Catalog + install/update (spec §12–13)
- 'apps:catalogIndex': async (_event, args) => {
- return registryClient.refreshIndex(args.force);
- },
- 'apps:catalogSearch': async (_event, args) => {
- return { records: await registryClient.search(args.query) };
- },
- 'apps:catalogStars': async (_event, args) => {
- const [stars, starred] = await Promise.all([
- appsStars.repoStars(args.repos),
- appsStars.starredStatus(args.repos),
- ]);
- return { stars, starred };
- },
- 'apps:star': async (_event, args) => {
- const result = await appsStars.setStar(args.repo, args.star);
- capture('app_starred', { repo: args.repo, star: args.star });
- return result;
- },
- 'apps:catalogDetail': async (_event, args) => {
- const record = await registryClient.resolve(args.name);
- if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
- let manifest;
- try { manifest = await registryClient.latestManifest(record); } catch { /* best effort */ }
- let readme: string | undefined;
- try {
- const res = await fetch(`https://raw.githubusercontent.com/${record.repo}/HEAD/README.md`);
- if (res.ok) readme = await res.text();
- } catch { /* best effort */ }
- const installed = (await appsIndexer.listApps()).find((a) => a.install?.name === args.name);
- return {
- record,
- ...(manifest ? { manifest } : {}),
- ...(readme ? { readme } : {}),
- ...(installed ? { installedFolder: installed.folder } : {}),
- };
- },
- 'apps:install': async (_event, args) => {
- const record = await registryClient.resolve(args.name);
- if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
- if (!args.confirmed) {
- const preview = await appsInstaller.previewInstall(record);
- appInstallPreviews.set(args.name, preview);
- return preview;
- }
- // D18: the confirmed phase checks the bundle against what was previewed.
- const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record);
- const result = await appsInstaller.installFromRegistry(record, preview);
- appInstallPreviews.delete(args.name);
- // Materialize bundled agents NOW, not on the next apps:list poll — the
- // renderer's post-install enable dialog patches these tasks immediately.
- if (result.app) await appsAgents.syncAppAgents(result.app);
- capture('app_installed', { name: args.name });
- return result;
- },
- 'apps:installFromUrl': async (_event, args) => {
- if (!args.confirmed) {
- return appsInstaller.previewUrlInstall(args.url);
- }
- const result = await appsInstaller.confirmUrlInstall(args.url);
- if (result.app) await appsAgents.syncAppAgents(result.app);
- capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder });
- return result;
- },
- 'apps:uninstall': async (_event, args) => {
- await appsInstaller.uninstallApp(args.folder);
- capture('app_uninstalled', { folder: args.folder });
- return { ok: true as const };
- },
- 'apps:checkUpdate': async (_event, args) => {
- return appsInstaller.checkUpdate(args.folder);
- },
- 'apps:update': async (_event, args) => {
- const before = (await appsIndexer.getApp(args.folder))?.manifest?.version;
- const app = await appsInstaller.updateApp(args.folder, {
- confirmOverwriteModified: args.confirmOverwriteModified,
- confirmNewCapabilities: args.confirmNewCapabilities,
- });
- capture('app_updated', { from: before, to: app.manifest?.version });
- return { app };
- },
- 'apps:rollback': async (_event, args) => {
- const app = await appsInstaller.rollbackApp(args.folder);
- capture('app_rolled_back', { folder: args.folder });
- return { app };
- },
- 'apps:publish': async (event, args) => {
- const win = BrowserWindow.fromWebContents(event.sender);
- const result = await appsPublisher.publishApp(args.folder, (step, detail) => {
- win?.webContents.send('apps:progress', { folder: args.folder, step, detail });
- });
- capture('app_published', { firstPublish: true });
- return result;
- },
- 'apps:publishUpdate': async (_event, args) => {
- const result = await appsPublisher.publishUpdate(args.folder, args.increment);
- capture('app_published', { version: result.version, firstPublish: false });
- return result;
- },
- 'apps:registerExisting': async (_event, args) => {
- return appsPublisher.registerExisting(args.name, args.repo);
- },
- 'githubAuth:start': async () => {
- const result = await githubAuth.startDeviceFlow();
- // Surface the code and open GitHub's verification page externally (§10).
- void shell.openExternal(result.verificationUri);
- return result;
- },
- 'githubAuth:poll': async () => {
- const result = await githubAuth.pollDeviceFlow();
- console.log(`[GitHubAuth] poll result → ${result.status}`);
- return result;
- },
- 'githubAuth:status': async () => {
- return githubAuth.getAuthStatus();
- },
- 'githubAuth:signOut': async () => {
- await githubAuth.clearAuth();
- return { ok: true as const };
- },
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve('agentScheduleRepo');
@@ -2015,11 +656,6 @@ export function setupIpcHandlers() {
const error = await shell.openPath(filePath);
return { error: error || undefined };
},
- 'shell:showItemInFolder': async (_event, args) => {
- const filePath = resolveShellPath(args.path);
- shell.showItemInFolder(filePath);
- return { success: true };
- },
'shell:readFileBase64': async (_event, args) => {
const filePath = resolveShellPath(args.path);
const stat = await fs.stat(filePath);
@@ -2053,30 +689,6 @@ export function setupIpcHandlers() {
}
return { path: result.filePaths[0] ?? null };
},
- 'terminal:ensure': async (_event, args) => {
- return ensureTerminal(args.id, args.cwd, args.cols, args.rows);
- },
- 'terminal:input': async (_event, args) => {
- writeTerminal(args.id, args.data);
- return { success: true };
- },
- 'terminal:resize': async (_event, args) => {
- resizeTerminal(args.id, args.cols, args.rows);
- return { success: true };
- },
- 'terminal:dispose': async (_event, args) => {
- disposeTerminal(args.id);
- return { success: true };
- },
- 'dialog:openFiles': async (event, args) => {
- const win = BrowserWindow.fromWebContents(event.sender);
- const result = await dialog.showOpenDialog(win!, {
- title: args.title ?? 'Attach files',
- ...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
- properties: ['openFile', 'multiSelections'],
- });
- return { paths: result.canceled ? [] : result.filePaths };
- },
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
@@ -2090,46 +702,9 @@ export function setupIpcHandlers() {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
- 'google-docs:getStatus': async () => {
- return getGoogleDocsConnectionStatus();
- },
- 'google-docs:import': async (_event, args) => {
- console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
- try {
- const result = await importGoogleDoc(args.fileId, args.targetFolder);
- console.log(`[GoogleDocs] import OK -> ${result.path}`);
- return result;
- } catch (err) {
- console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err);
- throw err;
- }
- },
- // Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the
- // pick with the company Google client; the desktop opens the start URL,
- // waits for the deep link, and imports the picked doc with the existing
- // managed token. No API key, appId, or local credentials.
- 'google-docs:pickViaManaged': async (_event, args) => {
- console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`);
- const result = await startManagedGooglePick(args.targetFolder);
- if (!result) return null;
- console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`);
- return result;
- },
- 'google-docs:refreshSnapshot': async (_event, args) => {
- return syncGoogleDocDown(args.path);
- },
- 'google-docs:sync': async (_event, args) => {
- return syncGoogleDocUp(args.path, { force: args.force });
- },
- 'google-docs:getLink': async (_event, args) => {
- return { link: await getGoogleDocLink(args.path) };
- },
// Search handler
'search:query': async (_event, args) => {
- 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);
+ return search(args.query, args.limit, args.types);
},
// Inline task schedule classification
'export:note': async (event, args) => {
@@ -2214,16 +789,8 @@ export function setupIpcHandlers() {
},
'meeting:summarize': async (_event, args) => {
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
- if (notes && notes.trim()) {
- void maybeActivateCredit('first_meeting_note');
- }
return { notes };
},
- 'meeting-prep:resolve': async (_event, args) => {
- const result = await resolveMeetingPrep(args.attendees);
- const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null;
- return { ...result, prepNote };
- },
'inline-task:classifySchedule': async (_event, args) => {
const schedule = await classifySchedule(args.instruction);
return { schedule };
@@ -2237,179 +804,6 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
- 'voice:synthesizeStreamStart': async (event, args) => {
- const { requestId, text } = args;
- const sender = event.sender;
- const controller = new AbortController();
- activeTtsStreams.set(requestId, controller);
- // Fire-and-forget: chunks are pushed to the renderer as they arrive so
- // playback can begin immediately; the invoke returns once started.
- void voice
- .synthesizeSpeechStream(
- text,
- (chunk) => {
- if (!sender.isDestroyed()) {
- sender.send('voice:tts-chunk', {
- requestId,
- chunkBase64: chunk.toString('base64'),
- done: false,
- });
- }
- },
- controller.signal,
- )
- .then(() => {
- if (!sender.isDestroyed()) {
- sender.send('voice:tts-chunk', { requestId, done: true });
- }
- })
- .catch((err: unknown) => {
- if (!sender.isDestroyed() && !controller.signal.aborted) {
- sender.send('voice:tts-chunk', {
- requestId,
- done: true,
- error: err instanceof Error ? err.message : String(err),
- });
- }
- })
- .finally(() => {
- activeTtsStreams.delete(requestId);
- });
- return { ok: true };
- },
- 'voice:synthesizeStreamCancel': async (_event, args) => {
- activeTtsStreams.get(args.requestId)?.abort();
- activeTtsStreams.delete(args.requestId);
- return {};
- },
- 'voice:ensureMicAccess': async () => {
- if (process.platform !== 'darwin') return { granted: true };
- const status = systemPreferences.getMediaAccessStatus('microphone');
- console.log('[voice] Microphone permission status:', status);
- if (status === 'granted') return { granted: true };
- // 'not-determined' shows the native TCC prompt and resolves once the
- // user responds; 'denied'/'restricted' resolve false without prompting.
- // Awaiting this here means the triggering mic click proceeds to
- // getUserMedia only after permission is settled — fixing the first
- // click silently failing while the prompt was still up.
- try {
- const granted = await systemPreferences.askForMediaAccess('microphone');
- console.log('[voice] Microphone permission after prompt:', granted);
- return { granted };
- } catch {
- return { granted: false };
- }
- },
- 'voice:ensureCameraAccess': async () => {
- if (process.platform !== 'darwin') return { granted: true };
- const status = systemPreferences.getMediaAccessStatus('camera');
- console.log('[video] Camera permission status:', status);
- if (status === 'granted') return { granted: true };
- // Same flow as the microphone: settle the native TCC prompt before the
- // renderer's getUserMedia so the first video click doesn't silently fail.
- try {
- const granted = await systemPreferences.askForMediaAccess('camera');
- console.log('[video] Camera permission after prompt:', granted);
- return { granted };
- } catch {
- return { granted: false };
- }
- },
- 'video:setPopout': async (_event, args) => {
- if (!args.show) {
- if (videoPopoutWin && !videoPopoutWin.isDestroyed()) videoPopoutWin.destroy();
- videoPopoutWin = null;
- return {};
- }
- if (videoPopoutWin && !videoPopoutWin.isDestroyed()) return {};
-
- const workArea = screen.getPrimaryDisplay().workArea;
- const width = 340;
- const height = 184;
- const ipcDir = path.dirname(fileURLToPath(import.meta.url));
- const preloadPath = app.isPackaged
- ? path.join(ipcDir, '../preload/dist/preload.js')
- : path.join(ipcDir, '../../../preload/dist/preload.js');
- const win = new BrowserWindow({
- width,
- height,
- x: workArea.x + workArea.width - width - 24,
- y: workArea.y + 24,
- frame: false,
- resizable: false,
- alwaysOnTop: true,
- skipTaskbar: true,
- show: false,
- hasShadow: true,
- backgroundColor: '#171717',
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- sandbox: true,
- preload: preloadPath,
- },
- });
- // Float above other apps on every workspace. Deliberately NOT
- // `visibleOnFullScreen: true`: on macOS that flag hides the app's Dock
- // icon for as long as such a window exists (the app becomes an
- // "agent" app), which reads as Rowboat having vanished. The trade-off
- // is the popout won't hover over other apps' fullscreen Spaces.
- win.setAlwaysOnTop(true, 'floating');
- win.setVisibleOnAllWorkspaces(true);
- win.webContents.once('did-finish-load', () => {
- if (lastVideoPopoutState) {
- win.webContents.send('video:popout-state', lastVideoPopoutState);
- }
- // showInactive: appearing must not steal focus from the app the user
- // switched to — that would immediately re-hide the popout.
- if (!win.isDestroyed()) win.showInactive();
- });
- win.on('closed', () => {
- if (videoPopoutWin === win) videoPopoutWin = null;
- });
- videoPopoutWin = win;
- if (app.isPackaged) {
- win.loadURL('app://-/index.html#video-popout');
- } else {
- win.loadURL('http://localhost:5173/#video-popout');
- }
- return {};
- },
- 'video:popoutState': async (_event, args) => {
- lastVideoPopoutState = args;
- if (videoPopoutWin && !videoPopoutWin.isDestroyed()) {
- videoPopoutWin.webContents.send('video:popout-state', args);
- }
- return {};
- },
- 'app:focusMainWindow': async () => {
- const main = findMainAppWindow();
- if (main) {
- 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 {};
- },
- 'video:getPopoutState': async () => {
- return { state: lastVideoPopoutState };
- },
- 'video:popoutAction': async (_event, args) => {
- // Relay a popout control-bar action to the app window, which owns the
- // call (mic, camera, screen capture) and executes it there. 'expand'
- // additionally brings the app window back to the foreground.
- const main = findMainAppWindow();
- if (args.action === 'expand' && main) {
- if (main.isMinimized()) main.restore();
- main.show();
- main.focus();
- }
- main?.webContents.send('video:popout-action', args);
- return {};
- },
// Live-note handlers
'live-note:run': async (_event, args) => {
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
@@ -2472,93 +866,10 @@ export function setupIpcHandlers() {
const notes = await listLiveNotes();
return { notes };
},
- // Bg-task handlers
- 'bg-task:run': async (_event, args) => {
- const result = await runBackgroundTask(args.slug, 'manual', args.context);
- return {
- success: !result.error,
- runId: result.runId,
- summary: result.summary,
- error: result.error,
- };
- },
- 'bg-task:get': async (_event, args) => {
- try {
- const task = await fetchTask(args.slug);
- return { success: true, task };
- } catch (err) {
- return { success: false, error: err instanceof Error ? err.message : String(err) };
- }
- },
- 'bg-task:patch': async (_event, args) => {
- try {
- const task = await patchTask(args.slug, args.partial);
- return { success: true, task };
- } catch (err) {
- return { success: false, error: err instanceof Error ? err.message : String(err) };
- }
- },
- 'bg-task:create': async (_event, args) => {
- try {
- const { slug } = await createTask({
- name: args.name,
- instructions: args.instructions,
- ...(args.triggers ? { triggers: args.triggers } : {}),
- ...(args.projectId ? { projectId: args.projectId } : {}),
- ...(args.model ? { model: args.model } : {}),
- ...(args.provider ? { provider: args.provider } : {}),
- });
- void maybeActivateCredit('first_bg_agent');
- return { success: true, slug };
- } catch (err) {
- return { success: false, error: err instanceof Error ? err.message : String(err) };
- }
- },
- 'bg-task:delete': async (_event, args) => {
- try {
- await deleteTask(args.slug);
- return { success: true };
- } catch (err) {
- return { success: false, error: err instanceof Error ? err.message : String(err) };
- }
- },
- 'bg-task:stop': async (_event, args) => {
- try {
- const task = await fetchTask(args.slug);
- if (!task?.lastRunId) {
- return { success: false, error: 'No active run for this task' };
- }
- await runsCore.stop(task.lastRunId, false);
- return { success: true };
- } catch (err) {
- return { success: false, error: err instanceof Error ? err.message : String(err) };
- }
- },
- 'bg-task:list': async (_event, args) => {
- return listTasks(args);
- },
- 'bg-task:listRunIds': async (_event, args) => {
- const runIds = await readTaskRunIds(args.slug, args.limit);
- return { runIds };
- },
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();
},
- // First-time-action credit rewards
- 'credits:getState': async () => {
- return await getCreditsState();
- },
- 'referral:claim': async (_event, args) => {
- return await claimReferralCode(args.code);
- },
- 'notifications:getSettings': async () => {
- return loadNotificationSettings();
- },
- 'notifications:setSettings': async (_event, args) => {
- saveNotificationSettings(args);
- return { success: true };
- },
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
});
diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts
index 322f66eb..accc971e 100644
--- a/apps/x/apps/main/src/main.ts
+++ b/apps/x/apps/main/src/main.ts
@@ -1,62 +1,44 @@
-import { app, BrowserWindow, desktopCapturer, dialog, protocol, net, shell, session, safeStorage, type Session } from "electron";
+import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
import path from "node:path";
-import os from "node:os";
import {
setupIpcHandlers,
- startRunsWatcher, startSessionsWatcher, startTurnEventsWatcher, markSessionsIndexReady,
- startCodeRunFeedWatcher,
- startChannelsWatcher,
- startCodeSessionStatusWatcher,
+ startRunsWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
- startBackgroundTaskAgentWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
stopWorkspaceWatcher
} from "./ipc.js";
-import { disposeAllTerminals } from "./terminal.js";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path";
-import { initUpdater } from "./updater.js";
+import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
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";
-import { init as initChannels } from "@x/core/dist/channels/service.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
-import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
-import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
-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/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";
-import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
+import { init as initLiveNoteEventProcessor } from "@x/core/dist/knowledge/live-note/events.js";
+import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
-import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
-import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
-import { execFileSync } from "node:child_process";
+import { execSync, exec, execFileSync } from "node:child_process";
+import { promisify } from "node:util";
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/runtime/sessions/index.js";
+import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
-import { setupBrowserExtensions } from "./browser/extensions.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
import {
@@ -65,45 +47,18 @@ import {
extractDeepLinkFromArgv,
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
-// completions a reopen replays) — see ElectronNotificationService.
-const APP_LAUNCHED_AT = Date.now();
+const execAsync = promisify(exec);
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();
// Single-instance lock: route a second launch (e.g. clicking a rowboat:// link)
// back into the existing process via the 'second-instance' event.
-if (app.isPackaged && !app.requestSingleInstanceLock()) {
+if (!app.requestSingleInstanceLock()) {
console.error('[Main] Another Rowboat instance is already running; exiting this process.');
app.quit();
process.exit(0);
@@ -228,34 +183,18 @@ protocol.registerSchemesAsPrivileged([
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
-// Granted to the embedded browser partition on top of the base set.
-// `notifications` lets sites (WhatsApp Web, Gmail, Slack, ...) show native OS
-// notifications via the HTML5 Notification API — Electron renders these
-// through the system notification center once the permission resolves to
-// granted. Background Web Push is still unavailable (Electron has no FCM),
-// so notifications only fire while the site is loaded in a tab. The app's
-// own renderer keeps the base set; it notifies through the main-process
-// notification service instead.
-const BROWSER_EXTRA_PERMISSIONS = ["notifications"] as const;
-
-function configureSessionPermissions(targetSession: Session, extraPermissions: readonly string[] = []): void {
- const allowed = new Set([...ALLOWED_SESSION_PERMISSIONS, ...extraPermissions]);
-
+function configureSessionPermissions(targetSession: Session): void {
targetSession.setPermissionCheckHandler((_webContents, permission) => {
- return allowed.has(permission);
+ return ALLOWED_SESSION_PERMISSIONS.has(permission);
});
targetSession.setPermissionRequestHandler((_webContents, permission, callback) => {
- callback(allowed.has(permission));
+ callback(ALLOWED_SESSION_PERMISSIONS.has(permission));
});
-}
-// Auto-approve display media requests and route system audio as loopback.
-// Electron requires a video source in the callback even if we only want audio.
-// We pass the first available screen source; the renderer discards the video track.
-// App session only — the embedded browser partition registers its own handler
-// (a user-facing source picker) in BrowserViewManager.
-function configureAppDisplayMediaHandler(targetSession: Session): void {
+ // Auto-approve display media requests and route system audio as loopback.
+ // Electron requires a video source in the callback even if we only want audio.
+ // We pass the first available screen source; the renderer discards the video track.
targetSession.setDisplayMediaRequestHandler(async (_request, callback) => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
if (sources.length === 0) {
@@ -266,81 +205,7 @@ function configureAppDisplayMediaHandler(targetSession: Session): void {
});
}
-// Wire Ctrl/Cmd + (+ / − / 0) to zoom the renderer in/out/reset.
-// The app sets no application menu, so the default menu's zoom roles aren't
-// available — and on Linux the menu bar is suppressed by the frameless
-// `hiddenInset` title bar — so handle the accelerators directly here.
-// `event.preventDefault()` stops the keystroke from leaking into the editor.
-function setupZoomShortcuts(win: BrowserWindow) {
- const ZOOM_STEP = 0.5; // zoom-level units (factor = 1.2 ^ level, ~9.5% per step)
- const MIN_ZOOM_LEVEL = -3;
- const MAX_ZOOM_LEVEL = 3;
- const wc = win.webContents;
-
- wc.on("before-input-event", (event, input) => {
- if (input.type !== "keyDown") return;
- // Cmd on macOS, Ctrl elsewhere.
- if (!(process.platform === "darwin" ? input.meta : input.control)) return;
-
- // input.key is the produced character: "+"/"=" share a physical key (as do
- // "-"/"_"), and numpad +/- produce the same characters, so this covers both.
- const key = input.key;
- if (key === "+" || key === "=") {
- wc.setZoomLevel(Math.min(wc.getZoomLevel() + ZOOM_STEP, MAX_ZOOM_LEVEL));
- event.preventDefault();
- } else if (key === "-" || key === "_") {
- wc.setZoomLevel(Math.max(wc.getZoomLevel() - ZOOM_STEP, MIN_ZOOM_LEVEL));
- event.preventDefault();
- } else if (key === "0") {
- wc.setZoomLevel(0);
- event.preventDefault();
- }
- });
-}
-
-// 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 } = {}) {
+function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
@@ -350,7 +215,6 @@ function createWindow(options: { startHidden?: boolean } = {}) {
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
- icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
@@ -364,21 +228,13 @@ function createWindow(options: { startHidden?: boolean } = {}) {
});
configureSessionPermissions(session.defaultSession);
- configureAppDisplayMediaHandler(session.defaultSession);
- configureSessionPermissions(session.fromPartition(BROWSER_PARTITION), BROWSER_EXTRA_PERMISSIONS);
+ configureSessionPermissions(session.fromPartition(BROWSER_PARTITION));
- mainWindow = win;
setMainWindowForDeepLinks(win);
- win.on("closed", () => {
- if (mainWindow === win) mainWindow = null;
- setMainWindowForDeepLinks(null);
- });
+ win.on("closed", () => setMainWindowForDeepLinks(null));
- // 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.
+ // Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => {
- if (options.startHidden) return;
win.maximize();
win.show();
});
@@ -390,43 +246,20 @@ function createWindow(options: { startHidden?: boolean } = {}) {
return { action: "deny" };
});
- // Handle navigation to external URLs (e.g., clicking a link without target="_blank").
- // Returns true when the URL was external and routed to the system browser.
- const routeExternalNavigation = (url: string): boolean => {
+ // Handle navigation to external URLs (e.g., clicking a link without target="_blank")
+ win.webContents.on("will-navigate", (event, url) => {
const isInternal =
url.startsWith("app://") || url.startsWith("http://localhost:5173");
- if (isInternal) return false;
- shell.openExternal(url);
- return true;
- };
-
- win.webContents.on("will-navigate", (event, url) => {
- if (routeExternalNavigation(url)) event.preventDefault();
- });
-
- // Subframe navigations (e.g. links clicked inside the sandboxed iframe that
- // renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
- // not `will-navigate`. Route their external links to the system browser too,
- // so HTML reports behave like the markdown viewer. Main-frame navigations are
- // already handled by `will-navigate` above — skip them here to avoid double-open.
- //
- // Scope this to our own HTML viewer frames (identified by their app://workspace
- // document origin). Third-party note embeds (YouTube, Figma, Twitter via the
- // embed/iframe blocks) load from their own origins — leave their internal
- // navigation untouched so the embeds keep working.
- win.webContents.on("will-frame-navigate", (event) => {
- if (event.isMainFrame) return;
- if (!event.frame?.url.startsWith("app://workspace/")) return;
- if (routeExternalNavigation(event.url)) event.preventDefault();
+ if (!isInternal) {
+ event.preventDefault();
+ shell.openExternal(url);
+ }
});
// Attach the embedded browser pane manager to this window.
// The WebContentsView is created lazily on first `browser:setVisible`.
browserViewManager.attach(win);
- // Cmd/Ctrl + (+ / − / 0) zoom shortcuts for the renderer UI.
- setupZoomShortcuts(win);
-
if (app.isPackaged) {
win.loadURL("app://-/index.html");
} else {
@@ -434,43 +267,39 @@ function createWindow(options: { startHidden?: boolean } = {}) {
}
}
-// Renderer/child process deaths are otherwise silent in packaged builds (the
-// window or an app iframe just goes blank). Log the reason so crash reports
-// can be correlated with what Chromium thought happened.
-app.on('render-process-gone', (_event, webContents, details) => {
- console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
-});
-app.on('child-process-gone', (_event, details) => {
- console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
-});
-
app.whenReady().then(async () => {
// Register custom protocol before creating window.
// In production this serves the renderer SPA; in dev (and prod) it also
// serves workspace files via app://workspace/ for media previews.
registerAppProtocol();
- // Initialize auto-updater (no-ops in dev). Update state is pushed to the
- // renderer (updater:status), which owns the restart prompt — see updater.ts.
- initUpdater();
+ // Initialize auto-updater (only in production)
+ if (app.isPackaged) {
+ updateElectronApp({
+ updateSource: {
+ type: UpdateSourceType.ElectronPublicUpdateService,
+ repo: "rowboatlabs/rowboat",
+ },
+ notifyUser: true, // Shows native dialog when update is available
+ });
+ }
- // The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
- // and is resolved per call by the shared executor in @x/core. Availability is
- // exposed to the UI via the slack:cliStatus IPC channel; this startup log is
- // diagnostics only.
- getAgentSlackCliStatus().then((status) => {
- console.log('[Slack] agent-slack CLI status:', status);
- }).catch(() => { /* probe failures already surface through slack:cliStatus */ });
+ // Ensure agent-slack CLI is available
+ try {
+ execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
+ } catch {
+ try {
+ console.log('agent-slack not found, installing...');
+ await execAsync('npm install -g agent-slack', { timeout: 60000 });
+ console.log('agent-slack installed successfully');
+ } catch (e) {
+ console.error('Failed to install agent-slack:', e);
+ }
+ }
// 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.
@@ -479,109 +308,12 @@ app.whenReady().then(async () => {
});
registerBrowserControlService(new ElectronBrowserControlService());
- registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));
+ registerNotificationService(new ElectronNotificationService());
setupIpcHandlers();
setupBrowserEventForwarding();
- setupBrowserExtensions();
- // Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
- // the window and the long service-init chain below. The Apps view is
- // reachable as soon as the window paints; starting the server last meant
- // every app iframe hit connection-refused (blank app) for the first ~10s of
- // each launch. Route registration and the token cipher are synchronous;
- // the listen itself is fire-and-forget.
- registerAppsHostApi();
- // GitHub publish token at rest: encrypt via the OS keychain when available
- // (core stays electron-free; the cipher is injected here).
- setGithubTokenCipher({
- isAvailable: () => safeStorage.isEncryptionAvailable(),
- encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
- decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
- });
- // ChatGPT subscription tokens at rest: same keychain-backed cipher.
- setChatGPTTokenCipher({
- isAvailable: () => safeStorage.isEncryptionAvailable(),
- encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
- decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
- });
- initAppsServer().catch((error) => {
- console.error('[Apps] Failed to start:', error);
- });
-
- // 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);
- },
- });
+ createWindow();
// Start workspace watcher as a main-process service
// Watcher runs independently and catches ALL filesystem changes:
@@ -593,81 +325,17 @@ app.whenReady().then(async () => {
// start runs watcher
startRunsWatcher();
- // One-time: port legacy runs/*.jsonl into the new turn/session runtime.
- // Must run BEFORE the session index is built so migrated sessions are picked
- // up by the startup scan. Fully defensive — never blocks boot.
- try {
- const migration = migrateRuns();
- if (migration.scanned > 0) {
- console.log(
- `[runs-migration] migrated ${migration.migratedTurns} turn(s) across ` +
- `${migration.migratedSessions} session(s) from ${migration.scanned} run(s) ` +
- `(${migration.skipped} skipped, ${migration.failed.length} failed)`,
- );
- for (const failure of migration.failed) {
- console.warn(`[runs-migration] left in place (failed): ${failure.file} — ${failure.error}`);
- }
- }
- } catch (error) {
- console.error('[runs-migration] pass failed:', error);
- }
-
- // New runtime: build the in-memory session index (startup scan), then
- // forward the session bus to windows. The renderer window is already up and
- // may have called sessions:list — that handler blocks on
- // markSessionsIndexReady, which must fire even if the scan throws so the
- // list never hangs.
- try {
- await container.resolve('sessions').initialize();
- } finally {
- 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
- // start after initialize(). Failures must never block boot.
- startChannelsWatcher();
- initChannels().catch((error) => {
- console.error('[Channels] Failed to start mobile channels:', error);
- });
-
- // start code-session status tracker (derives working/needs-you/idle + notifications)
- startCodeSessionStatusWatcher();
-
// start services watcher
startServicesWatcher();
// start live-note agent event watcher (forwards bus → renderer)
startLiveNoteAgentWatcher();
- // start bg-task agent event watcher (forwards bus → renderer)
- startBackgroundTaskAgentWatcher();
-
// start live-note scheduler (cron / window)
initLiveNoteScheduler();
- // start bg-task scheduler (cron / window)
- initBackgroundTaskScheduler();
-
- // start disk-skills watcher: live-reload skills dropped into
- // ~/.rowboat/skills or ~/.agents/skills without an app restart
- startSkillsWatcher();
-
- // register event consumers and start the shared event processor
- // (consumes $WorkDir/events/pending/, routes events to all consumers
- // concurrently for Pass-1, then fires each consumer's candidates in parallel)
- registerConsumer(liveNoteEventConsumer);
- registerConsumer(backgroundTaskEventConsumer);
- initEventProcessor();
-
- // If the stored Google grant predates a scope change (only old scopes),
- // disconnect it now so the user re-connects with the current scopes before
- // any Google sync runs against the stale grant.
- await disconnectGoogleIfScopesStale();
+ // start live-note event processor (consumes events/pending/, routes to matching live notes)
+ initLiveNoteEventProcessor();
// start gmail sync
initGmailSync();
@@ -684,6 +352,9 @@ app.whenReady().then(async () => {
// start knowledge graph builder
initGraphBuilder();
+ // start email labeling service
+ initEmailLabeling();
+
// start note tagging service
initNoteTagging();
@@ -699,23 +370,23 @@ app.whenReady().then(async () => {
// start calendar meeting notification service (fires 1-minute warnings)
initCalendarNotifications();
- // start meeting prep scheduler (generates prep notes ~6h before a meeting)
- void initMeetingPrep();
-
// start chrome extension sync server
initChromeSync();
+ // start local sites server for iframe dashboards and other mini apps
+ initLocalSites().catch((error) => {
+ console.error('[LocalSites] Failed to start:', error);
+ });
+
app.on("activate", () => {
- // Reveal the hidden/closed main window (login launches start hidden).
- showApp();
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
});
});
app.on("window-all-closed", () => {
- // 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()) {
+ if (process.platform !== "darwin") {
app.quit();
}
});
@@ -725,17 +396,8 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
-stopSkillsWatcher();
- // Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
- try {
- container.resolve('codeModeManager').disposeAll();
- } catch {
- // nothing live to dispose
- }
- // Kill embedded terminal shells.
- disposeAllTerminals();
- shutdownAppsServer().catch((error) => {
- console.error('[Apps] Failed to shut down cleanly:', error);
+ shutdownLocalSites().catch((error) => {
+ console.error('[LocalSites] Failed to shut down cleanly:', error);
});
shutdownAnalytics().catch((error) => {
console.error('[Analytics] Failed to flush on quit:', error);
diff --git a/apps/x/apps/main/src/meeting-popup.ts b/apps/x/apps/main/src/meeting-popup.ts
deleted file mode 100644
index 5c5f7d53..00000000
--- a/apps/x/apps/main/src/meeting-popup.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-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/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts
index 22660343..dd37e37d 100644
--- a/apps/x/apps/main/src/notification/electron-notification-service.ts
+++ b/apps/x/apps/main/src/notification/electron-notification-service.ts
@@ -1,6 +1,5 @@
import { BrowserWindow, Notification, shell } from "electron";
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
-import { shouldSuppressDuringStartupGrace } from "@x/core/dist/application/notification/service.js";
import { dispatchUrl } from "../deeplink.js";
const HTTP_URL = /^https?:\/\//i;
@@ -12,35 +11,11 @@ export class ElectronNotificationService implements INotificationService {
// gets dropped and macOS clicks just focus the app silently.
private active = new Set();
- // Timestamp the app launched, used to gate grace-eligible notifications (see
- // NotifyInput.suppressDuringStartupGrace). Captured by the caller in main.ts
- // so it reflects process start rather than whenever the first notify fires.
- private readonly launchedAt: number;
-
- constructor(launchedAt: number = Date.now()) {
- this.launchedAt = launchedAt;
- }
-
isSupported(): boolean {
return Notification.isSupported();
}
- notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground, suppressDuringStartupGrace }: NotifyInput): void {
- // Startup grace: a reopen replays every background task that completed
- // while the app was closed, so grace-eligible notifications fired in the
- // first moments after launch are dropped to avoid a notification flood.
- if (shouldSuppressDuringStartupGrace({ suppressDuringStartupGrace }, this.launchedAt)) {
- return;
- }
-
- // Ambient notifications are suppressed while the app is in the
- // foreground — the user is already looking at it. A window counts as
- // foreground only if it's actually focused (minimized / other-space
- // windows are not), so this correctly treats those as background.
- if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
- return;
- }
-
+ notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
// Build the actions array AND a parallel index → link map.
// macOS shows actions[0] inline (Banner) or all of them (Alert);
// additional ones live behind the chevron menu.
diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts
index e0d4b2fc..f61b59cc 100644
--- a/apps/x/apps/main/src/oauth-handler.ts
+++ b/apps/x/apps/main/src/oauth-handler.ts
@@ -1,7 +1,6 @@
import { shell } from 'electron';
import type { Server } from 'http';
import { createAuthServer } from './auth-server.js';
-import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
@@ -18,9 +17,7 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
-function buildRedirectUri(port: number): string {
- return `http://localhost:${port}/oauth/callback`;
-}
+const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
/** Top-level openid-client messages that often wrap a more specific cause. */
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
@@ -117,15 +114,9 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
}
/**
- * Get or create OAuth configuration for a provider.
- * `redirectUri` is required for DCR providers — it is the actual callback URI
- * (including port) that was just bound, so the registration and auth URL stay in sync.
+ * Get or create OAuth configuration for a provider
*/
-async function getProviderConfiguration(
- provider: string,
- redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
- credentialsOverride?: { clientId: string; clientSecret: string },
-): Promise {
+async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise {
const config = await getProviderConfig(provider);
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
if (config.client.mode === 'static' && config.client.clientId) {
@@ -157,7 +148,7 @@ async function getProviderConfiguration(
console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`);
const clientRepo = getClientRegistrationRepo();
const existingRegistration = await clientRepo.getClientRegistration(provider);
-
+
if (existingRegistration) {
console.log(`[OAuth] ${provider}: Using existing DCR registration`);
return await oauthClient.discoverConfiguration(
@@ -166,21 +157,18 @@ async function getProviderConfiguration(
);
}
- // Register new client with the actual redirect URI (port already bound)
+ // Register new client
const scopes = config.scopes || [];
const { config: oauthConfig, registration } = await oauthClient.registerClient(
config.discovery.issuer,
- [redirectUri],
+ [REDIRECT_URI],
scopes
);
-
- // Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
- const boundPort = new URL(redirectUri).port
- ? parseInt(new URL(redirectUri).port, 10)
- : DEFAULT_CALLBACK_PORT;
- await clientRepo.saveClientRegistration(provider, registration, boundPort);
- console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
-
+
+ // Save registration for future use
+ await clientRepo.saveClientRegistration(provider, registration);
+ console.log(`[OAuth] ${provider}: DCR registration saved`);
+
return oauthConfig;
}
} else {
@@ -188,7 +176,7 @@ async function getProviderConfiguration(
if (config.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
-
+
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
const { clientId, clientSecret } = await resolveClientCredentials();
return oauthClient.createStaticConfiguration(
@@ -201,37 +189,6 @@ async function getProviderConfiguration(
}
}
-/**
- * Determine which port to start the OAuth callback server on for a DCR provider.
- *
- * If the provider has an existing registration, probes the port it was registered
- * on. If that port is still available, returns it so the existing client_id keeps
- * working. If it is blocked, clears the stale registration (forcing re-registration
- * on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base.
- *
- * Exported for unit testing.
- */
-export async function resolveStartPort(
- provider: string,
- clientRepo: IClientRegistrationRepo,
-): Promise {
- const existingReg = await clientRepo.getClientRegistration(provider);
- if (!existingReg) return DEFAULT_CALLBACK_PORT;
-
- const registeredPort = await clientRepo.getRegisteredPort(provider);
- try {
- // Probe — fixed-port (no fallback) so we know whether the exact registered port is free
- const probe = await createAuthServer(registeredPort, () => { /* probe */ });
- probe.server.close();
- console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`);
- return registeredPort;
- } catch {
- console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`);
- await clientRepo.clearClientRegistration(provider);
- return DEFAULT_CALLBACK_PORT;
- }
-}
-
/**
* Initiate OAuth flow for a provider
*/
@@ -268,196 +225,154 @@ export async function connectProvider(provider: string, credentials?: { clientId
}
}
- // For static-client providers (Google BYOK) the redirect URI is pre-registered
- // at the OAuth provider console on a fixed port — we must not scan.
- // For DCR providers, resolveStartPort handles the re-registration trap.
- const isStaticClient = providerConfig.client.mode === 'static';
- const startPort = isStaticClient
- ? DEFAULT_CALLBACK_PORT
- : await resolveStartPort(provider, getClientRegistrationRepo());
+ // Get or create OAuth configuration
+ const config = await getProviderConfiguration(provider, credentials);
- // --- Callback server ---
- // Declare `state` before the closure so the callback can close over its binding.
- // The variable is assigned below, before shell.openExternal, so it is always
- // set by the time any browser request arrives.
- let state = '';
+ // Generate PKCE codes
+ const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
+ const state = oauthClient.generateState();
+
+ // Get scopes from config
+ const scopes = providerConfig.scopes || [];
+
+ // Store flow state
+ activeFlows.set(state, { codeVerifier, provider, config });
+
+ // Build authorization URL
+ const authUrl = oauthClient.buildAuthorizationUrl(config, {
+ redirect_uri: REDIRECT_URI,
+ scope: scopes.join(' '),
+ code_challenge: codeChallenge,
+ state,
+ });
+
+ // Create callback server
let callbackHandled = false;
-
- const { server, port: boundPort } = await createAuthServer(
- startPort,
- async (callbackUrl) => {
- // Guard against duplicate callbacks (browser may send multiple requests)
- if (callbackHandled) return;
- callbackHandled = true;
- const receivedState = callbackUrl.searchParams.get('state');
- if (receivedState == null || receivedState === '') {
- throw new Error(
- 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
- );
- }
- if (receivedState !== state) {
- throw new Error('Invalid state parameter - possible CSRF attack');
- }
-
- const flow = activeFlows.get(state);
- if (!flow || flow.provider !== provider) {
- throw new Error('Invalid OAuth flow state');
- }
-
- try {
- // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
- console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
- const tokens = await oauthClient.exchangeCodeForTokens(
- flow.config,
- callbackUrl,
- flow.codeVerifier,
- state
- );
-
- // Save tokens and credentials. For Google, BYOK is the only path
- // that reaches this token exchange (rowboat path returns above
- // before any local server runs); stamp mode: 'byok' so a future
- // refresh / reconnect can't get confused with a rowboat entry.
- console.log(`[OAuth] Token exchange successful for ${provider}`);
- await oauthRepo.upsert(provider, {
- tokens,
- ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
- ...(provider === 'google' ? { mode: 'byok' as const } : {}),
- error: null,
- });
-
- // Trigger immediate sync for relevant providers
- if (provider === 'google') {
- triggerGmailSync();
- triggerCalendarSync();
- } else if (provider === 'fireflies-ai') {
- triggerFirefliesSync();
- }
-
- // For Rowboat sign-in, ensure user + Stripe customer exist before
- // notifying the renderer. Without this, parallel API calls from
- // multiple renderer hooks race to create the user, causing duplicates.
- let signedInUserId: string | undefined;
- if (provider === 'rowboat') {
- try {
- const billing = await getBillingInfo();
- if (billing.userId) {
- signedInUserId = billing.userId;
- analyticsIdentify(billing.userId, {
- ...(billing.userEmail ? { email: billing.userEmail } : {}),
- plan: billing.subscriptionPlanId,
- status: billing.subscriptionStatus,
- });
- analyticsCapture('user_signed_in', {
- plan: billing.subscriptionPlanId,
- status: billing.subscriptionStatus,
- });
- }
- } catch (meError) {
- console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
- }
- }
-
- // Emit success event to renderer
- emitOAuthEvent({
- provider,
- success: true,
- ...(signedInUserId ? { userId: signedInUserId } : {}),
- });
- } catch (error) {
- console.error('OAuth token exchange failed:', error);
- // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
- let cause: unknown = error;
- while (cause != null && typeof cause === 'object' && 'cause' in cause) {
- cause = (cause as { cause?: unknown }).cause;
- if (cause != null) {
- console.error('[OAuth] Caused by:', cause);
- }
- }
- const errorMessage = getOAuthErrorMessage(error);
- emitOAuthEvent({ provider, success: false, error: errorMessage });
- throw error;
- } finally {
- // Clean up
- activeFlows.delete(state);
- if (activeFlow && activeFlow.state === state) {
- clearTimeout(activeFlow.cleanupTimeout);
- activeFlow.server.close();
- activeFlow = null;
- }
- }
- },
- // Static providers (Google BYOK) keep fixed-port behaviour to match the
- // pre-registered redirect URI at the provider's console. DCR providers
- // can fall back since we register the actual bound port below.
- { fallback: !isStaticClient },
- );
-
- // Server is bound. Any throw between here and `activeFlow = ...` would
- // leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
- try {
- // TOCTOU guard: resolveStartPort probed the registered port and found it
- // free, but the port could have been grabbed between probe and real bind,
- // causing fallback to a different port. The cached client_id is registered
- // for the old port — clear it so getProviderConfiguration re-registers
- // with the actual bound port.
- if (!isStaticClient && boundPort !== startPort) {
- console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
- await getClientRegistrationRepo().clearClientRegistration(provider);
+ const { server } = await createAuthServer(8080, async (callbackUrl) => {
+ // Guard against duplicate callbacks (browser may send multiple requests)
+ if (callbackHandled) return;
+ callbackHandled = true;
+ const receivedState = callbackUrl.searchParams.get('state');
+ if (receivedState == null || receivedState === '') {
+ throw new Error(
+ 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
+ );
+ }
+ if (receivedState !== state) {
+ throw new Error('Invalid state parameter - possible CSRF attack');
}
- const redirectUri = buildRedirectUri(boundPort);
- const config = await getProviderConfiguration(provider, redirectUri, credentials);
+ const flow = activeFlows.get(state);
+ if (!flow || flow.provider !== provider) {
+ throw new Error('Invalid OAuth flow state');
+ }
- const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
- state = oauthClient.generateState();
+ try {
+ // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
+ console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
+ const tokens = await oauthClient.exchangeCodeForTokens(
+ flow.config,
+ callbackUrl,
+ flow.codeVerifier,
+ state
+ );
- const scopes = providerConfig.scopes || [];
- activeFlows.set(state, { codeVerifier, provider, config });
+ // Save tokens and credentials. For Google, BYOK is the only path
+ // that reaches this token exchange (rowboat path returns above
+ // before any local server runs); stamp mode: 'byok' so a future
+ // refresh / reconnect can't get confused with a rowboat entry.
+ console.log(`[OAuth] Token exchange successful for ${provider}`);
+ await oauthRepo.upsert(provider, {
+ tokens,
+ ...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
+ ...(provider === 'google' ? { mode: 'byok' as const } : {}),
+ error: null,
+ });
- const authUrl = oauthClient.buildAuthorizationUrl(config, {
- redirect_uri: redirectUri,
- scope: scopes.join(' '),
- code_challenge: codeChallenge,
- state,
- // Google only returns a refresh_token when offline access is requested,
- // and only re-issues one when re-consent is forced. Without these, a
- // BYOK token expires after ~1h with no way to refresh (it goes stale and
- // every Google call — including the Picker — starts failing).
- ...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}),
- });
-
- // Set timeout to clean up abandoned flows. Generous (10 min) because a
- // first-time connect can involve creating/locating OAuth credentials in
- // the Cloud Console mid-flow; a short window tears down the callback
- // server before the user finishes consent, silently dropping the token.
- const cleanupTimeout = setTimeout(() => {
- if (activeFlow?.state === state) {
- console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
- cancelActiveFlow('timed_out');
+ // Trigger immediate sync for relevant providers
+ if (provider === 'google') {
+ triggerGmailSync();
+ triggerCalendarSync();
+ } else if (provider === 'fireflies-ai') {
+ triggerFirefliesSync();
}
- }, 10 * 60 * 1000);
- activeFlow = {
- provider,
- state,
- server,
- cleanupTimeout,
- };
+ // For Rowboat sign-in, ensure user + Stripe customer exist before
+ // notifying the renderer. Without this, parallel API calls from
+ // multiple renderer hooks race to create the user, causing duplicates.
+ let signedInUserId: string | undefined;
+ if (provider === 'rowboat') {
+ try {
+ const billing = await getBillingInfo();
+ if (billing.userId) {
+ signedInUserId = billing.userId;
+ analyticsIdentify(billing.userId, {
+ ...(billing.userEmail ? { email: billing.userEmail } : {}),
+ plan: billing.subscriptionPlan,
+ status: billing.subscriptionStatus,
+ });
+ analyticsCapture('user_signed_in', {
+ plan: billing.subscriptionPlan,
+ status: billing.subscriptionStatus,
+ });
+ }
+ } catch (meError) {
+ console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
+ }
+ }
- // Open in system browser (shares cookies/sessions with user's regular browser)
- shell.openExternal(authUrl.toString());
-
- return { success: true };
- } catch (setupError) {
- // Post-bind setup failed — close the server so the port is released and
- // a retry isn't blocked by our own zombie listener.
- server.close();
- if (state) {
+ // Emit success event to renderer
+ emitOAuthEvent({
+ provider,
+ success: true,
+ ...(signedInUserId ? { userId: signedInUserId } : {}),
+ });
+ } catch (error) {
+ console.error('OAuth token exchange failed:', error);
+ // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
+ let cause: unknown = error;
+ while (cause != null && typeof cause === 'object' && 'cause' in cause) {
+ cause = (cause as { cause?: unknown }).cause;
+ if (cause != null) {
+ console.error('[OAuth] Caused by:', cause);
+ }
+ }
+ const errorMessage = getOAuthErrorMessage(error);
+ emitOAuthEvent({ provider, success: false, error: errorMessage });
+ throw error;
+ } finally {
+ // Clean up
activeFlows.delete(state);
+ if (activeFlow && activeFlow.state === state) {
+ clearTimeout(activeFlow.cleanupTimeout);
+ activeFlow.server.close();
+ activeFlow = null;
+ }
}
- throw setupError;
- }
+ });
+
+ // Set timeout to clean up abandoned flows (2 minutes)
+ // This prevents memory leaks if user never completes the OAuth flow
+ const cleanupTimeout = setTimeout(() => {
+ if (activeFlow?.state === state) {
+ console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
+ cancelActiveFlow('timed_out');
+ }
+ }, 2 * 60 * 1000); // 2 minutes
+
+ // Store complete flow state for cleanup
+ activeFlow = {
+ provider,
+ state,
+ server,
+ cleanupTimeout,
+ };
+
+ // Open in system browser (shares cookies/sessions with user's regular browser)
+ shell.openExternal(authUrl.toString());
+
+ // Wait for callback (server will handle it)
+ return { success: true };
} catch (error) {
console.error('OAuth connection failed:', error);
return {
@@ -516,7 +431,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
try {
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
- const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
+ const res = await fetch(revokeUrl, { method: 'POST' });
if (!res.ok) {
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
}
@@ -540,81 +455,6 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
}
}
-/**
- * Startup migration for Google scope changes. When a connected Google grant was
- * issued before a scope was added (e.g. old installs on gmail.readonly that
- * never received gmail.modify), invalidate it so the user is prompted to
- * reconnect and re-grant with the current scopes. The currently-requested
- * scopes in the provider config are the source of truth: a grant missing any
- * of them is treated as stale.
- *
- * We revoke + clear the stale token but DELIBERATELY keep the provider entry
- * with an `error` set rather than calling disconnectProvider (which deletes the
- * whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your
- * accounts" alert and the connectors "Reconnect" row — key off this `error`
- * field, not off the connected flag. A fully deleted entry has no error and is
- * indistinguishable from "never connected", so no prompt would ever appear.
- *
- * Tokens with no recorded scopes (very old installs that never persisted them)
- * are also treated as stale. Safe to call on every startup — it's a no-op once
- * the grant covers all current scopes, and once invalidated the early return on
- * the missing token keeps it from re-running until the user reconnects.
- */
-export async function disconnectGoogleIfScopesStale(): Promise {
- try {
- const oauthRepo = getOAuthRepo();
- const connection = await oauthRepo.read('google');
-
- // Not connected (or already invalidated) — nothing to migrate.
- if (!connection.tokens) {
- return;
- }
-
- const providerConfig = await getProviderConfig('google');
- const requiredScopes = providerConfig.scopes ?? [];
- if (requiredScopes.length === 0) {
- return;
- }
-
- const granted = new Set(connection.tokens.scopes ?? []);
- const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
- if (missingScopes.length === 0) {
- return;
- }
-
- console.log(
- `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
- 'invalidating it so the user is prompted to reconnect with the new scopes.'
- );
-
- // Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
- if (connection.mode === 'rowboat' && connection.tokens.access_token) {
- try {
- const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
- const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
- if (!res.ok) {
- console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
- }
- } catch (error) {
- console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
- }
- }
-
- // Drop the stale token but keep the entry with an error so the reconnect
- // prompt fires (see the note above).
- await oauthRepo.upsert('google', {
- tokens: null,
- error: 'Google permissions changed. Please reconnect to continue.',
- });
-
- // Nudge any already-open window to re-read state. The renderer's initial
- // mount also re-reads, so the prompt shows even if no window is up yet.
- emitOAuthEvent({ provider: 'google', success: false });
- } catch (error) {
- console.error('[OAuth] Google scope migration check failed:', error);
- }
-}
-
/**
* Get access token for a provider (internal use only)
* Refreshes token if expired
diff --git a/apps/x/apps/main/src/terminal.ts b/apps/x/apps/main/src/terminal.ts
deleted file mode 100644
index 83d5a7c9..00000000
--- a/apps/x/apps/main/src/terminal.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { BrowserWindow } from 'electron';
-import fs from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-// node-pty is a NATIVE module: it stays external to the esbuild bundle and is
-// shipped alongside it in .package/node_modules (see bundle.mjs).
-import * as pty from 'node-pty';
-
-// One PTY per coding session, kept alive while the app runs so the terminal
-// survives pane collapses and session switches. The renderer view re-attaches
-// via `terminal:ensure`, which replays the recent backlog.
-
-const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay
-
-interface TerminalEntry {
- proc: pty.IPty;
- cwd: string;
- backlog: string;
- running: boolean;
-}
-
-const terminals = new Map();
-
-function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void {
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send(channel, payload);
- }
- }
-}
-
-// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable
-// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once.
-let helperFixed = false;
-function ensureSpawnHelperExecutable(): void {
- if (helperFixed || process.platform === 'win32') return;
- helperFixed = true;
- try {
- const pkgDir = path.dirname(require.resolve('node-pty/package.json'));
- const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
- if (fs.existsSync(helper)) {
- fs.chmodSync(helper, 0o755);
- }
- } catch {
- // best effort — spawn() will surface a real error if this mattered
- }
-}
-
-function defaultShell(): { file: string; args: string[] } {
- if (process.platform === 'win32') {
- return { file: 'powershell.exe', args: [] };
- }
- // Login shell so the user's PATH/aliases match their normal terminal.
- return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] };
-}
-
-function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry {
- ensureSpawnHelperExecutable();
- const { file, args } = defaultShell();
- const proc = pty.spawn(file, args, {
- name: 'xterm-256color',
- cwd,
- cols,
- rows,
- env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record,
- });
- const entry: TerminalEntry = { proc, cwd, backlog: '', running: true };
- proc.onData((data) => {
- entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT);
- broadcast('terminal:data', { id, data });
- });
- proc.onExit(({ exitCode }) => {
- entry.running = false;
- broadcast('terminal:exit', { id, exitCode });
- });
- terminals.set(id, entry);
- return entry;
-}
-
-// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or
-// an exited shell gets a fresh PTY; otherwise the live one is reused and the
-// caller repaints from the backlog.
-export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } {
- const existing = terminals.get(id);
- if (existing && existing.running && existing.cwd === cwd) {
- existing.proc.resize(cols, rows);
- return { backlog: existing.backlog, running: true };
- }
- if (existing) {
- disposeTerminal(id);
- }
- const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir();
- const entry = spawnEntry(id, fallbackCwd, cols, rows);
- return { backlog: entry.backlog, running: entry.running };
-}
-
-export function writeTerminal(id: string, data: string): void {
- const entry = terminals.get(id);
- if (entry?.running) entry.proc.write(data);
-}
-
-export function resizeTerminal(id: string, cols: number, rows: number): void {
- const entry = terminals.get(id);
- if (entry?.running) {
- try {
- entry.proc.resize(cols, rows);
- } catch {
- // resizing a dying pty throws — harmless
- }
- }
-}
-
-export function disposeTerminal(id: string): void {
- const entry = terminals.get(id);
- if (!entry) return;
- terminals.delete(id);
- try {
- entry.proc.kill();
- } catch {
- // already gone
- }
-}
-
-export function disposeAllTerminals(): void {
- for (const id of [...terminals.keys()]) disposeTerminal(id);
-}
diff --git a/apps/x/apps/main/src/test-agent.ts b/apps/x/apps/main/src/test-agent.ts
index 45a5207d..738d861a 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/runtime/legacy/runs.js';
-import { bus } from '@x/core/dist/runtime/legacy/bus.js';
+import * as runsCore from '@x/core/dist/runs/runs.js';
+import { bus } from '@x/core/dist/runs/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
deleted file mode 100644
index 2d55081d..00000000
--- a/apps/x/apps/main/src/tray.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-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/main/src/updater.ts b/apps/x/apps/main/src/updater.ts
deleted file mode 100644
index 53369548..00000000
--- a/apps/x/apps/main/src/updater.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron";
-import { capture } from "@x/core/dist/analytics/posthog.js";
-import type { ipc } from "@x/shared";
-
-export type UpdaterStatus = ipc.IPCChannels["updater:status"]["req"];
-
-const REPO = "rowboatlabs/rowboat";
-const CHECK_INTERVAL_MS = 10 * 60 * 1000;
-
-let status: UpdaterStatus = { state: "disabled", version: "", reason: "dev" };
-
-function setStatus(next: Omit): void {
- status = { version: status.version, ...next };
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed() && win.webContents) {
- win.webContents.send("updater:status", status);
- }
- }
-}
-
-export function getUpdaterStatus(): UpdaterStatus {
- return status;
-}
-
-// 32x32 green dot with a white ring (scratchpad-generated PNG). Windows'
-// counterpart of the macOS dock badge: overlays the taskbar icon while an
-// update is staged. Cleared implicitly — installing quits the process.
-const WIN_BADGE_DATA_URL =
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABPUlEQVR42s1XOwoCMRC12CvkAhb2HmMvYS/kCgveQU9gYS17AKu1EKwXsdDCwtQWtk9GkiUbkv2RkAw8WLLJzEvmk8kMwCwmpixiAHIAHEAhweUYC0Ugk0Yq9Esl52a+CJAygfEi5NrJBOg4S5vm6+eOgzhh+zr+Qd805pCyyzUu4wsAta7l+X1j89hjeVljfl5ZQf9oDs01pJY6BxFgpnHapcuoC7TGQoINIdA6dn7bjTauQGst7ugkwH0Z7yDBXQQyPdqnHPtAdwg9Ra27pyDyZVzBCExuI9AUGYpk3wRIp1GsWgSY/rcr1aaCdBrCdAK5XmR8G1cwilWuE2j8T1UtFAHSbcaBIlCEiP6ebCiSIhDdBdGDMHoaRi9ESZTi6JdR9Os4iYYkiZYselOaRFuexMMkmadZEo/TYPgB7Se8LkyPD5UAAAAASUVORK5CYII=";
-
-function showReadyBadge(): void {
- if (process.platform === "darwin") {
- // The window may be closed for days on macOS (app keeps running) — the
- // dock badge is the only surface that says "an update is waiting".
- app.dock?.setBadge("1");
- } else if (process.platform === "win32") {
- const badge = nativeImage.createFromDataURL(WIN_BADGE_DATA_URL);
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed()) win.setOverlayIcon(badge, "Update ready — restart to install");
- }
- }
-}
-
-/**
- * Initialize auto-update, driving Electron's autoUpdater (Squirrel) against
- * update.electronjs.org directly. Events are forwarded to the renderer
- * (updater:status), which shows a "Restart to update" card once the update
- * is staged. By then Squirrel has already installed it — the card only asks
- * for the restart, Chrome-style. Must be called after app ready (it is —
- * from whenReady() in main.ts).
- */
-export function initUpdater(): void {
- const version = app.getVersion();
-
- if (!app.isPackaged) {
- status = { state: "disabled", version, reason: "dev" };
- return;
- }
- if (process.platform === "linux") {
- // Electron's autoUpdater doesn't support Linux (deb/zip installs).
- status = { state: "unsupported", version, reason: "platform" };
- return;
- }
- if (process.platform === "darwin" && !app.isInApplicationsFolder()) {
- // Squirrel.Mac swaps the .app bundle in place, which fails outside
- // /Applications (DMG mount, ~/Downloads). Don't wire the updater —
- // Settings > Help tells the user to move the app.
- status = { state: "unsupported", version, reason: "not-in-applications" };
- return;
- }
-
- status = { state: "idle", version };
-
- autoUpdater.on("checking-for-update", () => {
- setStatus({ state: "checking", lastCheckedAt: status.lastCheckedAt });
- });
- autoUpdater.on("update-available", () => {
- setStatus({ state: "downloading" });
- });
- autoUpdater.on("update-not-available", () => {
- setStatus({ state: "idle", lastCheckedAt: Date.now() });
- });
- autoUpdater.on("update-downloaded", (_event, releaseNotes, releaseName) => {
- // macOS (Squirrel.Mac fed by update.electronjs.org) supplies both the
- // release name and the GitHub release body; Squirrel.Windows only the
- // name. When notes are missing the card shows a static fallback line.
- setStatus({
- state: "ready",
- newVersion: releaseName || undefined,
- releaseNotes: releaseNotes || undefined,
- });
- showReadyBadge();
- // Squirrel.Windows never carries notes, and Squirrel.Mac's copy is a
- // snapshot from download time — stale when the release body is edited
- // after publish (update.electronjs.org caching widens that window).
- // Refresh from the GitHub API; on failure the snapshot (or the card's
- // static fallback line) stands.
- void backfillReleaseNotes(releaseName || undefined);
- });
- autoUpdater.on("error", (err) => {
- setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
- capture("update_failed", { message: err.message });
- });
-
- // update.electronjs.org serves both Squirrel dialects from one URL:
- // Squirrel.Mac GETs it as-is (204 = up to date, JSON = update; that legacy
- // format is serverType "default"), Squirrel.Windows appends /RELEASES.
- autoUpdater.setFeedURL({
- url: `https://update.electronjs.org/${REPO}/${process.platform}-${process.arch}/${version}`,
- serverType: "default",
- });
- // Check now and every 10 minutes, through the same guard as the manual
- // check: a tick is a no-op while a check/download is in flight or an
- // update is already staged.
- checkForUpdates();
- setInterval(checkForUpdates, CHECK_INTERVAL_MS);
-}
-
-/**
- * Replace the staged update's release notes with the current GitHub release
- * body. releaseName is "0.7.7" from Squirrel.Windows and "v0.7.7" from
- * Squirrel.Mac — normalize to the tag form.
- */
-async function backfillReleaseNotes(releaseName: string | undefined): Promise {
- if (!releaseName) return;
- try {
- const tag = `v${releaseName.replace(/^v/, "")}`;
- const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
- headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" },
- });
- if (!res.ok) return;
- const { body } = (await res.json()) as { body?: string };
- const notes = body?.trim();
- // Re-check the state: the fetch raced user actions (quitAndInstall).
- if (notes && status.state === "ready" && status.newVersion === releaseName) {
- setStatus({ ...status, releaseNotes: notes });
- }
- } catch {
- // Offline or rate-limited — the Squirrel snapshot / fallback line stands.
- }
-}
-
-/**
- * Manual "Check for updates". Only meaningful when idle or errored;
- * checking/downloading are already in flight and ready is already staged.
- * Returns the snapshot after initiating.
- */
-export function checkForUpdates(): UpdaterStatus {
- if (status.state === "idle" || status.state === "error") {
- try {
- autoUpdater.checkForUpdates();
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err));
- setStatus({ state: "error", error: error.message, lastCheckedAt: status.lastCheckedAt });
- capture("update_failed", { message: error.message });
- }
- }
- return status;
-}
-
-export function quitAndInstallUpdate(): void {
- capture("update_restarted", { from: status.version, to: status.newVersion });
- autoUpdater.quitAndInstall();
-}
diff --git a/apps/x/apps/preload/package.json b/apps/x/apps/preload/package.json
index 056f14c3..910d241e 100644
--- a/apps/x/apps/preload/package.json
+++ b/apps/x/apps/preload/package.json
@@ -7,8 +7,7 @@
"build": "rm -rf dist && tsc && esbuild dist/preload.js --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload.bundle.js && mv dist/preload.bundle.js dist/preload.js"
},
"dependencies": {
- "@x/shared": "workspace:*",
- "electron-chrome-extensions": "^4.9.0"
+ "@x/shared": "workspace:*"
},
"devDependencies": {
"electron": "^39.2.7",
diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts
index 44b33381..bc69d4bb 100644
--- a/apps/x/apps/preload/src/preload.ts
+++ b/apps/x/apps/preload/src/preload.ts
@@ -1,19 +1,6 @@
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
-import { injectBrowserAction } from 'electron-chrome-extensions/browser-action';
import { ipc as ipcShared } from '@x/shared';
-// Expose the custom element (extension action icons +
-// popups for the embedded browser pane). App documents only — this preload
-// is attached solely to the app window, but guard against it ever being
-// reused for remote content.
-if (location.protocol === 'app:' || location.origin === 'http://localhost:5173') {
- try {
- injectBrowserAction();
- } catch (error) {
- console.error('[preload] injectBrowserAction failed:', error);
- }
-}
-
type InvokeChannels = ipcShared.InvokeChannels;
type IPCChannels = ipcShared.IPCChannels;
type SendChannels = ipcShared.SendChannels;
diff --git a/apps/x/apps/renderer/DESIGN_LANGUAGE.md b/apps/x/apps/renderer/DESIGN_LANGUAGE.md
deleted file mode 100644
index cf412593..00000000
--- a/apps/x/apps/renderer/DESIGN_LANGUAGE.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Rowboat Design Language
-
-Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing.
-
-## Principles
-
-1. **Calm density**
- Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy.
-
-2. **Command first**
- Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states.
-
-3. **Visible work state**
- AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners.
-
-4. **Notes as the canvas**
- The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces.
-
-5. **Neutral precision**
- The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states.
-
-## Tokens
-
-- Radius: `8px` for controls and cards, smaller where density matters.
-- Backgrounds: dev defaults in light and dark mode.
-- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them.
-- Shadows: reserved for the composer, menus, dialogs, and active segmented controls.
-- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking.
-- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts.
-
-## Core Surfaces
-
-- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette.
-- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill.
-- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal.
-- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable.
-- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect.
-
-## Launch Positioning
-
-The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website.
diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json
index 15b53e2d..a193b3f1 100644
--- a/apps/x/apps/renderer/package.json
+++ b/apps/x/apps/renderer/package.json
@@ -5,20 +5,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
- "typecheck": "tsc -b",
"lint": "eslint .",
- "preview": "vite preview",
- "test": "vitest run",
- "test:watch": "vitest"
+ "preview": "vite preview"
},
"dependencies": {
- "@codemirror/language": "^6.12.3",
- "@codemirror/language-data": "^6.5.2",
- "@codemirror/merge": "^6.12.2",
- "@codemirror/state": "^6.6.0",
- "@codemirror/view": "^6.43.1",
- "@eigenpal/docx-editor-react": "^1.0.3",
- "@lezer/highlight": "^1.2.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -47,27 +37,15 @@
"@tiptap/starter-kit": "3.22.4",
"@x/preload": "workspace:*",
"@x/shared": "workspace:*",
- "@xterm/addon-fit": "^0.11.0",
- "@xterm/xterm": "^6.0.0",
- "ai": "^7.0.22",
+ "ai": "^5.0.117",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
- "codemirror": "^6.0.2",
"lucide-react": "^0.562.0",
"mermaid": "^11.14.0",
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",
- "prosemirror-commands": "^1.7.1",
- "prosemirror-dropcursor": "^1.8.2",
- "prosemirror-history": "^1.5.0",
- "prosemirror-keymap": "^1.2.3",
- "prosemirror-model": "^1.25.7",
- "prosemirror-state": "^1.4.4",
- "prosemirror-tables": "^1.8.5",
- "prosemirror-transform": "^1.12.0",
- "prosemirror-view": "^1.41.8",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -86,9 +64,6 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
- "@testing-library/dom": "^10.4.1",
- "@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@@ -97,11 +72,9 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
- "jsdom": "^29.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
- "vite": "^7.2.4",
- "vitest": "catalog:"
+ "vite": "^7.2.4"
}
}
diff --git a/apps/x/apps/renderer/scripts/generate-tour-audio.mjs b/apps/x/apps/renderer/scripts/generate-tour-audio.mjs
deleted file mode 100644
index 26d1535a..00000000
--- a/apps/x/apps/renderer/scripts/generate-tour-audio.mjs
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Regenerates the bundled product-tour narration clips.
- *
- * Parses the TOUR_STEPS texts straight out of product-tour.tsx (so the code
- * stays the single source of truth), synthesizes each one via @x/core's
- * synthesizeSpeech (Rowboat proxy when signed in, direct ElevenLabs otherwise,
- * using the voice id configured there / in ~/.rowboat/config/elevenlabs.json),
- * and writes MP3s to src/assets/tour/.mp3.
- *
- * Run whenever a step's narration text or the tour voice changes:
- * cd apps/x && npm run deps # script imports core's built output
- * node apps/renderer/scripts/generate-tour-audio.mjs
- *
- * Pass step ids to regenerate only those clips (e.g. to re-roll one whose
- * synthesis came out glitchy):
- * node apps/renderer/scripts/generate-tour-audio.mjs welcome done
- */
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-const here = path.dirname(fileURLToPath(import.meta.url))
-const tourSource = path.join(here, '../src/components/product-tour.tsx')
-const outDir = path.join(here, '../src/assets/tour')
-const corePath = path.join(here, '../../../packages/core/dist/voice/voice.js')
-
-const { synthesizeSpeech } = await import(corePath)
-
-const src = await readFile(tourSource, 'utf8')
-const start = src.indexOf('const TOUR_STEPS')
-const end = src.indexOf('\n]', start)
-if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in product-tour.tsx')
-const block = src.slice(start, end)
-
-const steps = []
-// voiceText, when present, is the spoken variant of the bubble text.
-const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g
-for (let m; (m = re.exec(block)); ) {
- // The captures are JS string literals from our own source; evaluate them
- // to resolve the quoting.
- steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() })
-}
-if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?')
-console.log(`Parsed ${steps.length} tour steps`)
-
-const only = process.argv.slice(2)
-if (only.length > 0) {
- const unknown = only.filter((id) => !steps.some((s) => s.id === id))
- if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`)
- steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id)))
- console.log(`Regenerating only: ${only.join(', ')}`)
-}
-
-await mkdir(outDir, { recursive: true })
-for (const step of steps) {
- process.stdout.write(`synthesizing ${step.id}... `)
- const { audioBase64 } = await synthesizeSpeech(step.text)
- const file = path.join(outDir, `${step.id}.mp3`)
- await writeFile(file, Buffer.from(audioBase64, 'base64'))
- console.log(`${(audioBase64.length * 0.75 / 1024).toFixed(0)} KB`)
-}
-console.log(`Done — ${steps.length} clips in ${path.relative(process.cwd(), outDir)}`)
diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css
index 899aa433..5c1eabb2 100644
--- a/apps/x/apps/renderer/src/App.css
+++ b/apps/x/apps/renderer/src/App.css
@@ -35,30 +35,6 @@
}
}
-/* Radix Collapsible expand/collapse — animate height (via the radix CSS var)
- plus a subtle fade. Used by the web search card. */
-@keyframes collapsible-down {
- from {
- height: 0;
- opacity: 0;
- }
- to {
- height: var(--radix-collapsible-content-height);
- opacity: 1;
- }
-}
-
-@keyframes collapsible-up {
- from {
- height: var(--radix-collapsible-content-height);
- opacity: 1;
- }
- to {
- height: 0;
- opacity: 0;
- }
-}
-
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
@@ -82,946 +58,6 @@
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
}
-/* Inbox surface — mapped onto the app's design tokens so it follows the same
- palette, accent, and theme switching as the rest of Rowboat. Light/dark is
- handled by the app tokens (:root / .dark), so no separate .light override. */
-.gmail-shell {
- --gm-bg: var(--background);
- --gm-bg-card: var(--card-surface);
- --gm-bg-input: var(--muted);
- --gm-bg-row-hover: var(--accent);
- --gm-bg-row-selected: var(--rowboat-wash);
- --gm-bg-row-selected-hover: color-mix(in oklab, var(--primary) 18%, var(--background));
- --gm-bg-iframe: #ffffff;
- --gm-bg-pill: var(--background);
- --gm-bg-pill-hover: var(--accent);
- --gm-text: var(--foreground);
- --gm-text-strong: var(--foreground);
- --gm-text-muted: var(--muted-foreground);
- --gm-text-faint: color-mix(in oklab, var(--muted-foreground) 70%, var(--background));
- --gm-text-body: color-mix(in oklab, var(--foreground) 88%, var(--background));
- --gm-border: var(--border);
- --gm-border-strong: var(--rowboat-hairline);
- --gm-accent: var(--primary);
- --gm-accent-hover: color-mix(in oklab, var(--primary) 90%, transparent);
- --gm-accent-glow: color-mix(in oklab, var(--primary) 40%, transparent);
- --gm-accent-fg: var(--primary-foreground);
- --gm-icon-hover-bg: var(--accent);
- --gm-placeholder: var(--muted-foreground);
-
- display: flex;
- height: 100%;
- min-height: 0;
- width: 100%;
- overflow: hidden;
- background: var(--background);
- color: var(--foreground);
-}
-
-.gmail-main {
- display: flex;
- min-width: 0;
- min-height: 0;
- flex: 1;
- flex-direction: column;
- padding: 0;
-}
-
-.gmail-topbar {
- display: flex;
- align-items: center;
- gap: 12px;
- height: 52px;
- padding: 0 20px;
- border-bottom: 1px solid var(--gm-border);
-}
-
-.gmail-topbar-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-left: auto;
-}
-
-.gmail-search {
- display: flex;
- align-items: center;
- gap: 10px;
- width: min(520px, 100%);
- height: 32px;
- padding: 0 12px;
- border-radius: 8px;
- background: var(--gm-bg-input);
- color: var(--gm-text-muted);
-}
-
-.gmail-search input {
- flex: 1;
- border: none;
- outline: none;
- background: transparent;
- color: var(--gm-text);
- font-size: 13px;
- letter-spacing: 0.01em;
-}
-
-.gmail-search input::placeholder {
- color: var(--gm-placeholder);
-}
-
-.gmail-search-clear {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- border: none;
- border-radius: 4px;
- background: transparent;
- color: var(--gm-text-muted);
- cursor: pointer;
- transition: background 120ms ease, color 120ms ease;
-}
-
-.gmail-search-clear:hover {
- background: var(--gm-icon-hover-bg);
- color: var(--gm-text);
-}
-
-.gmail-icon-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: var(--gm-text-muted);
- cursor: pointer;
- transition: background 120ms ease, color 120ms ease;
-}
-
-.gmail-icon-button:hover {
- background: var(--gm-icon-hover-bg);
- color: var(--gm-text);
-}
-
-.gmail-list {
- display: flex;
- flex-direction: column;
- min-width: 0;
- min-height: 0;
- flex: 1;
- overflow: auto;
- background: var(--gm-bg);
- border: none;
- border-radius: 0;
-}
-
-.gmail-row-group {
- display: flex;
- flex-direction: column;
-}
-
-/* Native list virtualization: offscreen rows skip layout and paint entirely.
- Applied only to rows without a mounted ThreadDetail (those hold iframes and
- composers, which must keep rendering while offscreen). */
-.gmail-row-group-cv {
- content-visibility: auto;
- contain-intrinsic-size: auto 40px;
-}
-
-/* While the list is scrolling, rows ignore the pointer so hover restyles and
- prefetch timers don't compete with frame rendering. */
-.gmail-shell[data-scrolling] .gmail-row-shell {
- pointer-events: none;
-}
-
-/* Archived/trashed rows slide out and collapse before removal. Removing the
- class (failed action) snaps the row back. */
-.gmail-row-group-leaving {
- overflow: hidden;
- pointer-events: none;
- animation: gmail-row-leave 160ms ease-in forwards;
-}
-
-@keyframes gmail-row-leave {
- 0% {
- opacity: 1;
- transform: translateX(0);
- max-height: 48px;
- }
- 60% {
- opacity: 0;
- transform: translateX(32px);
- max-height: 48px;
- }
- 100% {
- opacity: 0;
- transform: translateX(32px);
- max-height: 0;
- }
-}
-
-.gmail-list-header {
- position: sticky;
- top: 0;
- z-index: 1;
- display: flex;
- justify-content: space-between;
- height: 32px;
- padding: 0 24px;
- align-items: center;
- background: var(--gm-bg);
- border-bottom: 1px solid var(--gm-border);
- color: var(--gm-text-faint);
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
-}
-
-.gmail-section {
- display: flex;
- flex-direction: column;
-}
-
-.gmail-section + .gmail-section {
- margin-top: 28px;
-}
-
-.gmail-section-sentinel {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 28px;
- color: var(--gm-text-faint);
-}
-
-.gmail-row-shell {
- position: relative;
-}
-
-.gmail-row {
- display: grid;
- grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
- align-items: center;
- gap: 16px;
- width: 100%;
- min-height: 40px;
- padding: 0 24px;
- border: none;
- background: transparent;
- color: var(--gm-text-muted);
- text-align: left;
- cursor: pointer;
- font-family: inherit;
- transition: background 120ms ease;
-}
-
-.gmail-row-actions {
- position: absolute;
- right: 16px;
- top: 50%;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- gap: 2px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 120ms ease;
- /* The overlay may extend over the subject text; a backdrop matching the
- row's current background (see --gm-row-actions-bg below) plus a soft left
- fade keeps it readable instead of colliding. */
- padding-left: 18px;
- background: linear-gradient(to right, transparent, var(--gm-row-actions-bg, var(--gm-bg-row-hover)) 18px);
-}
-
-.gmail-row-shell:hover .gmail-row-actions {
- opacity: 1;
- pointer-events: auto;
-}
-
-/* Backdrop color tracks the row state underneath the overlay. */
-.gmail-row-shell:hover {
- --gm-row-actions-bg: var(--gm-bg-row-hover);
-}
-
-.gmail-row-shell:hover:has(.gmail-row-selected) {
- --gm-row-actions-bg: var(--gm-bg-row-selected-hover);
-}
-
-.gmail-row-shell:hover:has(.gmail-row-selected.gmail-row-focused) {
- --gm-row-actions-bg: var(--gm-bg-row-selected);
-}
-
-.gmail-row-shell:hover .gmail-row-date {
- visibility: hidden;
-}
-
-.gmail-row-action {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border: none;
- border-radius: 4px;
- background: transparent;
- color: var(--gm-text-muted);
- cursor: pointer;
- transition: background 120ms ease, color 120ms ease;
-}
-
-.gmail-row-action:hover {
- background: var(--gm-bg-pill-hover);
- color: var(--gm-text-strong);
-}
-
-.gmail-row-action-danger:hover {
- color: var(--destructive);
-}
-
-.gmail-row-shell:hover .gmail-row {
- background: var(--gm-bg-row-hover);
- box-shadow: none;
-}
-
-.gmail-row-selected {
- background: var(--gm-bg-row-selected);
- box-shadow: inset 2px 0 0 var(--gm-accent);
-}
-
-.gmail-row-shell:hover .gmail-row-selected {
- background: var(--gm-bg-row-selected-hover);
-}
-
-/* The j/k keyboard cursor. Declared after the hover rules (same specificity)
- so the focus ring survives hovering the focused row. */
-.gmail-row-focused,
-.gmail-row-shell:hover .gmail-row-focused {
- background: var(--gm-bg-row-hover);
- box-shadow: inset 0 0 0 1px var(--gm-accent);
-}
-
-.gmail-row-selected.gmail-row-focused,
-.gmail-row-shell:hover .gmail-row-selected.gmail-row-focused {
- background: var(--gm-bg-row-selected);
- box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent);
-}
-
-.gmail-row-unread {
- color: var(--gm-text);
-}
-
-.gmail-row-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: transparent;
-}
-
-.gmail-row-unread .gmail-row-dot {
- background: var(--gm-accent);
- box-shadow: 0 0 8px var(--gm-accent-glow);
-}
-
-.gmail-row-sender,
-.gmail-row-content strong,
-.gmail-row-date {
- font-size: 13px;
- font-weight: 400;
- letter-spacing: -0.005em;
-}
-
-.gmail-row-unread .gmail-row-sender,
-.gmail-row-unread .gmail-row-content strong {
- font-weight: 600;
- color: var(--gm-text-strong);
-}
-
-.gmail-row-unread .gmail-row-date {
- color: var(--gm-text);
-}
-
-.gmail-row-sender,
-.gmail-row-content,
-.gmail-row-content span {
- min-width: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.gmail-row-content {
- display: flex;
- gap: 8px;
- color: var(--gm-text-faint);
- 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: 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);
- white-space: nowrap;
- 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;
- flex-direction: column;
- background: transparent;
-}
-
-.gmail-detail-inline {
- background: var(--gm-bg-card);
- border-top: 1px solid var(--gm-border);
- border-bottom: 1px solid var(--gm-border);
- box-shadow: inset 2px 0 0 var(--gm-accent);
- /* Replays whenever the detail is shown — hidden details are display: none,
- so un-hiding restarts the animation. */
- animation: gmail-detail-open 140ms ease-out;
-}
-
-.gmail-detail-hidden {
- display: none;
-}
-
-@keyframes gmail-detail-open {
- from {
- opacity: 0;
- transform: translateY(-6px);
- }
- to {
- opacity: 1;
- transform: none;
- }
-}
-
-/* The inline reply/forward composer pops in from below the thread. */
-.gmail-compose-inline {
- animation: gmail-compose-open 140ms ease-out;
-}
-
-@keyframes gmail-compose-open {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: none;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .gmail-detail-inline,
- .gmail-compose-inline,
- .gmail-row-group-leaving {
- animation: none;
- }
-}
-
-.gmail-detail-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- height: 48px;
- padding: 0 24px;
- border-bottom: 1px solid var(--gm-border);
- background: transparent;
-}
-
-.gmail-thread-subject-inline {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--gm-text-strong);
- font-size: 15px;
- font-weight: 500;
- letter-spacing: -0.01em;
-}
-
-.gmail-thread-body {
- padding: 20px 24px 28px;
- background: transparent;
-}
-
-.gmail-thread-summary {
- margin-bottom: 20px;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--gm-border);
-}
-
-.gmail-thread-summary-label {
- display: block;
- margin-bottom: 6px;
- color: var(--gm-text-faint);
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.gmail-thread-summary-text {
- display: block;
- color: var(--gm-text);
- font-size: 13px;
- line-height: 1.55;
-}
-
-.gmail-message-stack {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.gmail-message {
- display: grid;
- grid-template-columns: 28px minmax(0, 1fr);
- gap: 12px;
- padding: 12px 0;
- border-top: 1px solid var(--gm-border);
-}
-
-.gmail-message:first-child {
- border-top: 0;
- padding-top: 4px;
-}
-
-.gmail-message-header {
- display: block;
- width: 100%;
- padding: 0;
- margin: 0;
- border: none;
- background: transparent;
- color: inherit;
- font: inherit;
- text-align: left;
- cursor: pointer;
-}
-
-.gmail-message-snippet {
- margin-top: 2px;
- color: var(--gm-text-muted);
- font-size: 12px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.gmail-message:not(.gmail-message-expanded) .gmail-message-header:hover .gmail-message-from strong {
- color: var(--gm-accent);
-}
-
-.gmail-message-avatar {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- color: #fafafa;
- font-weight: 600;
- font-size: 11px;
- letter-spacing: 0.02em;
-}
-
-.gmail-message-main {
- min-width: 0;
-}
-
-.gmail-message-meta {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
-}
-
-.gmail-message-from {
- display: flex;
- align-items: baseline;
- gap: 8px;
- min-width: 0;
-}
-
-.gmail-message-from strong,
-.gmail-message-from span {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.gmail-message-from strong {
- font-size: 13px;
- font-weight: 600;
- color: var(--gm-text-strong);
- letter-spacing: -0.005em;
-}
-
-.gmail-message-from span,
-.gmail-message-to,
-.gmail-message-cc,
-.gmail-message-date {
- color: var(--gm-text-muted);
- font-size: 12px;
-}
-
-.gmail-message-date {
- flex-shrink: 0;
- font-variant-numeric: tabular-nums;
-}
-
-.gmail-message-iframe {
- display: block;
- width: 100%;
- max-width: 820px;
- margin-top: 12px;
- border: 0;
- background: var(--gm-bg-iframe);
- border-radius: 6px;
-}
-
-.gmail-message-iframe-adaptive {
- background: var(--gm-bg-card);
-}
-
-.gmail-message-plain {
- max-width: 820px;
- margin-top: 12px;
- padding: 10px 14px;
- background: var(--gm-bg-iframe);
- border-radius: 6px;
- color: var(--gm-text);
-}
-
-.gmail-message-pre {
- margin: 0;
- font: 14px/1.6 Arial, sans-serif;
- white-space: pre-wrap;
- word-wrap: break-word;
-}
-
-.gmail-message-pre-quoted {
- margin-top: 12px;
- color: var(--gm-text-muted);
-}
-
-.gmail-quote-toggle {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-top: 8px;
- height: 22px;
- padding: 0 10px;
- border: 1px solid var(--gm-border-strong);
- border-radius: 4px;
- background: var(--gm-bg-pill);
- color: var(--gm-text-muted);
- font: inherit;
- font-size: 12px;
- letter-spacing: 0.04em;
- cursor: pointer;
- transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
-}
-
-.gmail-quote-toggle:hover {
- background: var(--gm-bg-pill-hover);
- color: var(--gm-text-strong);
- border-color: var(--gm-border-strong);
-}
-
-.gmail-quote-toggle[aria-expanded="true"] {
- background: var(--gm-bg-row-selected);
- color: var(--gm-accent);
- border-color: var(--gm-accent);
-}
-
-.gmail-message-attachments {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 14px;
- max-width: 820px;
-}
-
-.gmail-attachment {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- max-width: 320px;
- padding: 6px 10px;
- border: 1px solid var(--gm-border-strong);
- border-radius: 6px;
- background: var(--gm-bg-pill);
- color: var(--gm-text);
- font: inherit;
- font-size: 12px;
- cursor: pointer;
- transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
-}
-
-.gmail-attachment:hover {
- background: var(--gm-bg-pill-hover);
- border-color: var(--gm-accent);
- color: var(--gm-accent);
-}
-
-.gmail-attachment-name {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- font-weight: 500;
-}
-
-.gmail-attachment-size {
- flex-shrink: 0;
- color: var(--gm-text-muted);
- font-size: 11px;
- font-variant-numeric: tabular-nums;
-}
-
-.gmail-thread-actions {
- display: flex;
- gap: 8px;
- margin: 20px 0 12px 40px;
-}
-
-.gmail-thread-actions button {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- height: 30px;
- padding: 0 14px;
- border: 1px solid var(--gm-border-strong);
- border-radius: 6px;
- background: var(--gm-bg-pill);
- color: var(--gm-text-body);
- font: inherit;
- font-size: 12px;
- cursor: pointer;
- transition: background 120ms ease, border-color 120ms ease;
-}
-
-.gmail-thread-actions button:hover {
- background: var(--gm-bg-pill-hover);
- border-color: var(--gm-border-strong);
-}
-
-/* Email composer body — Tiptap content, styled to the app's design system. */
-.compose-content {
- outline: none;
- min-height: 120px;
- padding: 12px;
- background: transparent;
- color: var(--foreground);
- font-size: 0.875rem;
- line-height: 1.6;
-}
-
-.compose-content p {
- margin: 0;
-}
-
-.compose-content p + p,
-.compose-content p + ul,
-.compose-content p + ol,
-.compose-content p + blockquote {
- margin-top: 8px;
-}
-
-.compose-content ul,
-.compose-content ol {
- margin: 0;
- padding-left: 22px;
-}
-
-.compose-content ul {
- list-style: disc;
-}
-
-.compose-content ol {
- list-style: decimal;
-}
-
-.compose-content li {
- margin: 2px 0;
-}
-
-.compose-content li > p {
- margin: 0;
-}
-
-.compose-content blockquote {
- margin: 4px 0;
- padding-left: 12px;
- border-left: 2px solid var(--border);
- color: var(--muted-foreground);
-}
-
-.compose-content a {
- color: var(--primary);
- text-decoration: underline;
-}
-
-.compose-content code {
- padding: 1px 4px;
- border-radius: 3px;
- background: var(--muted);
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- font-size: 0.8125rem;
-}
-
-.compose-content p.is-editor-empty:first-child::before {
- content: attr(data-placeholder);
- color: var(--muted-foreground);
- float: left;
- height: 0;
- pointer-events: none;
-}
-
-.gmail-empty-state {
- display: flex;
- align-items: center;
- justify-content: center;
- flex: 1;
- min-height: 0;
- background: transparent;
- color: var(--gm-text-faint);
- font-size: 13px;
-}
-
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -1064,7 +100,7 @@
}
:root {
- --radius: 0.5rem;
+ --radius: 0.625rem;
--background: var(--bg-color, oklch(1 0 0));
--foreground: var(--text-color, oklch(0.145 0 0));
--card: var(--bg-color, oklch(1 0 0));
@@ -1092,25 +128,13 @@
--sidebar-foreground: var(--text-color, oklch(0.145 0 0));
--sidebar-primary: var(--main-color, oklch(0.205 0 0));
--sidebar-primary-foreground: var(--bg-color, oklch(0.985 0 0));
- --sidebar-accent: var(--sub-color, oklch(0.9 0 0));
+ --sidebar-accent: var(--sub-color, oklch(0.90 0 0));
--sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0));
--sidebar-border: var(--sub-alt-color, oklch(0.922 0 0));
--sidebar-ring: var(--main-color, oklch(0.708 0 0));
--scrollbar-track: oklch(0.95 0 0);
--scrollbar-thumb: oklch(0.75 0 0);
--scrollbar-thumb-hover: oklch(0.65 0 0);
- /* Subtle raised-card surface: tints toward foreground, so it reads a hair
- darker than the background in light mode and a hair lighter in dark mode.
- Shared by the web search card and tool-call group. */
- --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground));
- --rowboat-panel: oklch(0.97 0 0);
- --rowboat-raised: oklch(1 0 0);
- --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%);
- --rowboat-hairline: color-mix(in oklab, var(--border) 78%, var(--foreground) 22%);
- --rowboat-command: oklch(0.205 0 0);
- --rowboat-attention: oklch(0.646 0.222 41.116);
- --rowboat-shadow: 0 1px 2px rgb(31 38 48 / 0.07), 0 18px 40px rgb(31 38 48 / 0.08);
- --rowboat-shadow-soft: 0 1px 2px rgb(31 38 48 / 0.05), 0 8px 24px rgb(31 38 48 / 0.06);
}
.dark {
@@ -1148,14 +172,6 @@
--scrollbar-track: oklch(0.2 0 0);
--scrollbar-thumb: oklch(0.4 0 0);
--scrollbar-thumb-hover: oklch(0.5 0 0);
- --rowboat-panel: oklch(0.269 0 0);
- --rowboat-raised: oklch(0.205 0 0);
- --rowboat-wash: color-mix(in oklab, var(--background) 80%, var(--primary) 20%);
- --rowboat-hairline: color-mix(in oklab, var(--border) 70%, var(--foreground) 30%);
- --rowboat-command: oklch(0.922 0 0);
- --rowboat-attention: oklch(0.769 0.188 70.08);
- --rowboat-shadow: 0 1px 2px rgb(0 0 0 / 0.24), 0 22px 44px rgb(0 0 0 / 0.28);
- --rowboat-shadow-soft: 0 1px 2px rgb(0 0 0 / 0.2), 0 12px 28px rgb(0 0 0 / 0.18);
}
@layer base {
@@ -1167,9 +183,6 @@
body {
@apply bg-background text-foreground;
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- font-feature-settings: "cv02", "cv03", "cv04", "cv11";
- text-rendering: optimizeLegibility;
}
::-webkit-scrollbar {
@@ -1191,105 +204,6 @@
}
}
-.rowboat-shell {
- background: var(--background);
-}
-
-.rowboat-sidebar [data-sidebar="sidebar"] {
- background: var(--sidebar);
- border-right: 1px solid var(--sidebar-border);
-}
-
-.rowboat-sidebar [data-sidebar="header"] {
- gap: 0.625rem;
- border-bottom: 1px solid color-mix(in oklab, var(--sidebar-border) 76%, transparent);
- background: var(--sidebar);
-}
-
-.rowboat-section-switcher {
- border: 1px solid color-mix(in oklab, var(--sidebar-border) 78%, transparent);
- background: color-mix(in oklab, var(--sidebar-accent) 58%, var(--sidebar) 42%);
- box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 9%, transparent);
-}
-
-.rowboat-section-switcher button {
- letter-spacing: 0;
-}
-
-.rowboat-section-switcher button[class*="shadow-sm"] {
- background: var(--rowboat-raised);
- box-shadow: var(--rowboat-shadow-soft);
-}
-
-.rowboat-quick-actions button {
- min-height: 2rem;
-}
-
-.rowboat-quick-actions button svg {
- color: color-mix(in oklab, var(--rowboat-command) 72%, var(--sidebar-foreground) 28%);
-}
-
-.rowboat-tabbar {
- background: color-mix(in oklab, var(--background) 78%, var(--muted) 22%);
-}
-
-.rowboat-titlebar {
- background: var(--background);
- border-bottom-color: color-mix(in oklab, var(--border) 82%, transparent);
-}
-
-.rowboat-tab {
- min-height: 2.5rem;
- border-bottom: 1px solid transparent;
-}
-
-.rowboat-tab[class*="bg-background"] {
- border-bottom-color: var(--primary);
- background: var(--rowboat-raised);
- box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 8%, transparent);
-}
-
-.rowboat-composer-dock {
- box-shadow: 0 -20px 44px color-mix(in oklab, var(--background) 78%, transparent);
-}
-
-.rowboat-chat-input {
- border-color: color-mix(in oklab, var(--border) 74%, var(--primary) 26%);
- background: var(--rowboat-raised);
- box-shadow: var(--rowboat-shadow);
-}
-
-.rowboat-chat-input:focus-within {
- border-color: color-mix(in oklab, var(--ring) 76%, var(--border) 24%);
- box-shadow:
- 0 0 0 1px color-mix(in oklab, var(--ring) 38%, transparent),
- var(--rowboat-shadow);
-}
-
-.dark .rowboat-code-chat-input {
- border-color: color-mix(in oklab, var(--border) 76%, transparent);
- background: #000000;
- box-shadow: none;
-}
-
-.dark .rowboat-code-chat-input:focus-within {
- border-color: color-mix(in oklab, var(--ring) 70%, var(--border) 30%);
- box-shadow: 0 0 0 1px color-mix(in oklab, var(--ring) 30%, transparent);
-}
-
-[data-slot="message-content"] {
- line-height: 1.62;
-}
-
-.is-user [data-slot="message-content"] {
- background: color-mix(in oklab, var(--secondary) 88%, var(--rowboat-wash) 12%);
- border: 1px solid color-mix(in oklab, var(--border) 68%, transparent);
-}
-
-.is-assistant [data-slot="message-content"] {
- color: color-mix(in oklab, var(--foreground) 94%, var(--muted-foreground) 6%);
-}
-
/* Markdown content base styles for Streamdown/MessageResponse */
@layer components {
/* Target elements inside MessageResponse wrapper */
@@ -1360,14 +274,10 @@
}
.graph-view {
- background-color: #f8f8f9;
+ background-color: var(--background);
user-select: none;
}
-.dark .graph-view {
- background-color: #0b0b0d;
-}
-
.graph-view::before {
content: '';
position: absolute;
@@ -1445,10 +355,3 @@
transform: translateX(-120%);
}
}
-
-/* Issue #749: BiDi. unicode-bidi:plaintext resolves direction PER BLOCK from
- its first strong character, so a message mixing English + RTL paragraphs
- aligns each block correctly. text-align:start follows the resolved dir. */
-.bidi-auto :is(p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th){unicode-bidi:plaintext;text-align:start;}
-/* Code is inherently LTR — keep it stable inside RTL messages. */
-.bidi-auto :is(pre,code,kbd,samp){direction:ltr;unicode-bidi:isolate;text-align:left;}
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 692a055a..5ec8476b 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -1,26 +1,21 @@
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 { ToolUIPart } from 'ai';
+import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
+import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
-import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
+import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
-import { useSessionChat } from '@/hooks/useSessionChat';
-import { subscribeSessionFeed } from '@/lib/session-chat/feed';
-import { ChatHeader } from './components/chat-header';
-import { ChatEmptyState } from './components/chat-empty-state';
-import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
+import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
import { ImageFileViewer } from '@/components/image-file-viewer';
import { VideoFileViewer } from '@/components/video-file-viewer';
import { AudioFileViewer } from '@/components/audio-file-viewer';
-import { DocxFileViewer } from '@/components/docx-file-viewer';
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer';
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types';
@@ -28,30 +23,16 @@ import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { LiveNotesView } from '@/components/live-notes-view';
-import { BgTasksView } from '@/components/bg-tasks-view';
-import { AppsView } from '@/components/apps/apps-view';
-import { EmailView } from '@/components/email-view';
-import { WorkspaceView } from '@/components/workspace-view';
-import { CodingRunBlock } from '@/components/coding-run';
-import { SubAgentBlock } from '@/components/sub-agent-block';
-import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
-import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
-import { ChatHistoryView } from '@/components/chat-history-view';
-import { HomeView } from '@/components/home-view';
-import { MeetingsView } from '@/components/meetings-view';
-import { CodeView, type ActiveCodeSession } from '@/components/code/code-view';
-import { CodeChat } from '@/components/code/code-chat';
-import { ResizableRightPane } from '@/components/code/resizable-right-pane';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
ConversationContent,
+ ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
- MessageCopyButton,
MessageResponse,
} from '@/components/ai-elements/message';
import {
@@ -59,17 +40,17 @@ import {
type FileMention,
} from '@/components/ai-elements/prompt-input';
-import { TurnActivityIndicator } from '@/components/turn-activity-indicator';
+import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
-import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision';
import { TerminalOutput } from '@/components/terminal-output';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
-import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
+import { Suggestions } from '@/components/ai-elements/suggestions';
+import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
import {
SidebarInset,
SidebarProvider,
@@ -79,17 +60,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
-import { UpdateCard } from "@/components/update-card"
-import { BillingErrorDialog } from "@/components/billing-error-dialog"
-import { CreditCelebration } from "@/components/credit-celebration"
-import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
-import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
-import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
+import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
-import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog'
+import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
import { LiveNoteSidebar } from '@/components/live-note-sidebar'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
@@ -99,7 +75,6 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
import { defaultRemarkPlugins } from 'streamdown'
import remarkBreaks from 'remark-breaks'
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
-import { CaffeinateIndicator } from '@/components/caffeinate-indicator'
import {
type ChatMessage,
type ChatViewportAnchorState,
@@ -117,11 +92,9 @@ 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'
@@ -129,19 +102,14 @@ import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
import { useVoiceMode } from '@/hooks/useVoiceMode'
-import { useVideoMode } from '@/hooks/useVideoMode'
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
-import { VideoCallView } from '@/components/video-call-view'
-import { ProductTour, type TourNavTarget } from '@/components/product-tour'
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
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
+type ListRunsResponseType = z.infer
interface TreeNode extends DirEntry {
children?: TreeNode[]
@@ -186,7 +154,6 @@ function AutoScrollPre({ className, children }: { className?: string; children:
}
const DEFAULT_SIDEBAR_WIDTH = 256
-const DEFAULT_CHAT_PANE_WIDTH = 460
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
const graphPalette = [
{ hue: 210, sat: 72, light: 52 },
@@ -208,18 +175,8 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
-const MEETINGS_TAB_PATH = '__rowboat_meetings__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
-const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
-const APPS_TAB_PATH = '__rowboat_mini_apps__'
-const EMAIL_TAB_PATH = '__rowboat_email__'
-const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
-const WORKSPACE_ROOT = 'knowledge/Workspace'
-const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
-const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
-const HOME_TAB_PATH = '__rowboat_home__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
-const CODE_TAB_PATH = '__rowboat_code__'
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
@@ -272,43 +229,6 @@ const stripKnowledgePrefixForWiki = (relPath: string) => {
const stripMarkdownExtensionForWiki = (wikiPath: string) =>
wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath
-type LinkedGoogleDocMeta = {
- id: string
- title: string
- url?: string
- syncedAt?: string
-}
-
-const parseLinkedGoogleDocFrontmatter = (raw: string | null | undefined): LinkedGoogleDocMeta | null => {
- if (!raw?.includes('google_doc:')) return null
- const doc: Partial = {}
- let inGoogleDoc = false
- for (const line of raw.split('\n')) {
- if (line.trim() === '---') {
- inGoogleDoc = false
- continue
- }
- const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*.*$/)
- if (topLevel) {
- inGoogleDoc = topLevel[1] === 'google_doc'
- continue
- }
- if (!inGoogleDoc) continue
- const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/)
- if (!nested) continue
- const key = nested[1] as keyof LinkedGoogleDocMeta
- if (!['id', 'title', 'url', 'syncedAt'].includes(key)) continue
- let value = nested[2].trim()
- try {
- value = JSON.parse(value)
- } catch {
- value = value.replace(/^['"]|['"]$/g, '')
- }
- doc[key] = value
- }
- return doc.id && doc.title ? doc as LinkedGoogleDocMeta : null
-}
-
const wikiPathCompareKey = (wikiPath: string) =>
stripMarkdownExtensionForWiki(wikiPath).toLowerCase()
@@ -385,17 +305,8 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
-const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
-const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
-const isAppsTabPath = (path: string) => path === APPS_TAB_PATH
-const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
-const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
-const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
-const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
-const isHomeTabPath = (path: string) => path === HOME_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
-const isCodeTabPath = (path: string) => path === CODE_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
const normalized = category?.trim().toLowerCase()
@@ -454,23 +365,7 @@ const buildSuggestedTopicExplorePrompt = ({
const buildLiveNoteSetupPrompt = () =>
'I want to set up a Live note / task.'
-const buildBgTaskSetupPrompt = (description: string) =>
- `Create a background task for me. Here's what I want it to do:\n\n${description}`
-
-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.`
-
-// 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 => {
+const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
if (!hasNumbers) return null
@@ -652,25 +547,13 @@ type ViewState =
| { type: 'graph' }
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
- | { type: 'meetings' }
| { type: 'live-notes' }
- | { type: 'email'; threadId?: string; searchQuery?: string }
- | { type: 'workspace'; path?: string }
- | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode }
- | { type: 'chat-history' }
- | { type: 'home' }
- | { type: 'code' }
- | { type: 'bg-tasks' }
- | { type: 'apps' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId
if (a.type === 'file' && b.type === 'file') return a.path === b.path
if (a.type === 'task' && b.type === 'task') return a.name === b.name
- if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
- if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '')
- if (a.type === 'email' && b.type === 'email') return (a.threadId ?? '') === (b.threadId ?? '') && (a.searchQuery ?? '') === (b.searchQuery ?? '')
return true // both graph
}
@@ -678,15 +561,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
- * Shape: rowboat://open?type=&...
+ * Shape: rowboat://open?type=&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
- * meetings: ?type=meetings
* live-notes: ?type=live-notes
- * email: ?type=email
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
@@ -711,37 +592,8 @@ function parseDeepLink(input: string): ViewState | null {
}
case 'suggested-topics':
return { type: 'suggested-topics' }
- case 'meetings':
- return { type: 'meetings' }
case 'live-notes':
return { type: 'live-notes' }
- case 'email': {
- const threadId = params.get('threadId')
- return { type: 'email', threadId: threadId || undefined }
- }
- case 'workspace': {
- const path = params.get('path')
- return { type: 'workspace', path: path ?? undefined }
- }
- case 'knowledge-view': {
- const folderPath = params.get('folderPath')
- const mode = params.get('mode')
- return {
- type: 'knowledge-view',
- folderPath: folderPath ?? undefined,
- mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined,
- }
- }
- case 'chat-history':
- return { type: 'chat-history' }
- case 'home':
- return { type: 'home' }
- case 'code':
- return { type: 'code' }
- case 'bg-tasks':
- return { type: 'bg-tasks' }
- case 'apps':
- return { type: 'apps' }
default:
return null
}
@@ -755,7 +607,7 @@ function FixedSidebarToggle({
}) {
const { toggleSidebar } = useSidebar()
return (
-
+
{/* Sidebar toggle */}
boolean; redo: () => boolean }
@@ -850,37 +699,12 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
- const [isMeetingsOpen, setIsMeetingsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
- const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
- const [isAppsOpen, setIsAppsOpen] = useState(false)
- const [isEmailOpen, setIsEmailOpen] = useState(false)
- const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
- const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null)
- const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
- const [knowledgeViewMode, setKnowledgeViewMode] = useState('graph')
- // Folder being browsed inside the knowledge view (null = root overview).
- // Lives in ViewState so folder drill-down participates in back/forward history.
- const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null)
- const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false)
- const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge')
- const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
- // Default landing view: Home with the chat docked according to appearance settings.
- const [isHomeOpen, setIsHomeOpen] = useState(true)
- const [emailInitialThreadId, setEmailInitialThreadId] = useState(null)
- const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
- // Search query pushed into the email view's search box (e.g. the assistant's
- // read-view email query), so threads outside the synced inbox get real rows.
- const [emailInitialSearchQuery, setEmailInitialSearchQuery] = useState(null)
- const [emailSearchQueryVersion, setEmailSearchQueryVersion] = useState(0)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
suggestedTopics: boolean
- meetings: boolean
liveNotes: boolean
- bgTasks: boolean
- email: boolean
} | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@@ -891,14 +715,6 @@ function App() {
const [graphError, setGraphError] = useState(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
- // Middle-pane collapse animation. Animating its max-width from 100% is janky:
- // 100% is relative to the parent (far wider than the pane's real width), so the
- // transition spends its first frames non-binding (nothing moves) then snaps shut.
- // Instead we snapshot the pane's real px width before it collapses and drive the
- // transition from that value.
- const [insetCollapseFromPx, setInsetCollapseFromPx] = useState(null)
- const [insetMaxWidth, setInsetMaxWidth] = useState('100%')
- const [insetAnimateMaxWidth, setInsetAnimateMaxWidth] = useState(true)
// Live-note panel: bound to a single note path. Mounted as a sibling of the
// markdown editor so it shares the layout (no overlap with chat) and
// auto-closes when the active note changes.
@@ -934,7 +750,6 @@ function App() {
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState(null)
- const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null)
const debouncedContent = useDebounce(editorContent, 500)
const initialContentRef = useRef('')
const renameInProgressRef = useRef(false)
@@ -952,32 +767,9 @@ function App() {
// Chat state
const [, setMessage] = useState('')
const [conversation, setConversation] = useState([])
- const [billingErrorMatch, setBillingErrorMatch] = useState(null)
- const [billingErrorOpen, setBillingErrorOpen] = useState(false)
- const lastHandledBillingErrorIdRef = useRef(null)
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('')
-
- useEffect(() => {
- for (let i = conversation.length - 1; i >= 0; i--) {
- const item = conversation[i]
- if (!isErrorMessage(item)) continue
- if (item.id === lastHandledBillingErrorIdRef.current) return
- const match = matchBillingError(item.message)
- if (match) {
- lastHandledBillingErrorIdRef.current = item.id
- setBillingErrorMatch(match)
- setBillingErrorOpen(true)
- if (match.kind === 'out_of_credits') dispatchCreditExhausted()
- }
- 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.
- // runId IS the session id in the sessions runtime.
- const sessionChat = useSessionChat(runId)
const runIdRef = useRef(null)
const loadRunRequestIdRef = useRef(0)
const [isProcessing, setIsProcessing] = useState(false)
@@ -985,40 +777,17 @@ function App() {
const processingRunIdsRef = useRef>(new Set())
const streamingBuffersRef = useRef>(new Map())
const [isStopping, setIsStopping] = useState(false)
- const [, setStopClickedAt] = useState(null)
- // Sessions runtime: whole-turn liveness drives the composer Stop control
- // and running indicator. Model reasoning is a narrower state used only to
- // label that indicator "Thinking..." while reasoning is actually streaming.
- const activeIsProcessing = sessionChat.chatState?.isProcessing ?? isProcessing
- const activeIsReasoning = sessionChat.chatState?.isReasoning ?? false
- const activeIsWaitingOnHuman = sessionChat.chatState?.isWaitingOnHuman ?? false
- const activeIsWorking = activeIsProcessing && !activeIsWaitingOnHuman
- // A failed session load must be visible, not a blank chat.
- const sessionLoadErrorItems = React.useMemo(() => (
- sessionChat.error
- ? [{ id: 'session-load-error', kind: 'error', message: `Failed to load chat: ${sessionChat.error}`, timestamp: 0 }]
- : []
- ), [sessionChat.error])
+ const [stopClickedAt, setStopClickedAt] = useState(null)
const [agentId] = useState('copilot')
const [presetMessage, setPresetMessage] = useState(undefined)
// Voice mode state
const [voiceAvailable, setVoiceAvailable] = useState(false)
const [ttsAvailable, setTtsAvailable] = useState(false)
- // TTS plays only during calls now (the standing read-aloud toggle was
- // retired; a per-message "read aloud" action may replace it later).
+ const [ttsEnabled, setTtsEnabled] = useState(false)
const ttsEnabledRef = useRef(false)
- // Voice-to-voice latency marks for the current call turn (performance.now):
- // t0 = utterance accepted, submit = message sent, speak = first TTS
- // speak(). Emitted as call_turn_latency when audio actually starts.
- const callTurnMarksRef = useRef<{ t0: number; submit?: number; speak?: number } | null>(null)
- // Late-bound handle to handleStop (defined much further down) so early
- // call handlers can stop the run without reordering the component.
- const stopRunRef = useRef<(() => Promise) | null>(null)
- // Read-aloud style: 'summary' for typed chat, forced to 'full' during a
- // call and restored after. Context decides — the user never picks it.
+ const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary')
const ttsModeRef = useRef<'summary' | 'full'>('summary')
- const [tourActive, setTourActive] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const voiceTextBufferRef = useRef('')
const spokenIndexRef = useRef(0)
@@ -1028,106 +797,15 @@ function App() {
const ttsRef = useRef(tts)
ttsRef.current = tts
- // Latest assistant line handed to TTS — shown as the caption in the
- // full-screen call view while the assistant is speaking.
- const [assistantCaption, setAssistantCaption] = useState('')
- useEffect(() => {
- if (tts.state === 'idle') setAssistantCaption('')
- }, [tts.state])
-
- // Speak newly completed blocks from the new runtime's live stream
- // (parity with the legacy text-delta voice extraction below). The store
- // accumulates completed blocks in chatState.voiceSegments; we speak only
- // segments that appeared after the current session became active.
- const spokenVoiceRef = useRef<{ key: string | null; count: number }>({ key: null, count: 0 })
- const voiceSegments = sessionChat.chatState?.voiceSegments
- useEffect(() => {
- if (!voiceSegments) return
- if (spokenVoiceRef.current.key !== runId) {
- // Session switch: skip anything already streamed before we arrived.
- spokenVoiceRef.current = { key: runId, count: voiceSegments.length }
- return
- }
- while (spokenVoiceRef.current.count < voiceSegments.length) {
- const segment = voiceSegments[spokenVoiceRef.current.count]
- spokenVoiceRef.current.count += 1
- if (ttsEnabledRef.current) {
- const marks = callTurnMarksRef.current
- if (marks && marks.speak === undefined) marks.speak = performance.now()
- ttsRef.current.speak(segment)
- setAssistantCaption(segment)
- }
- }
- }, [voiceSegments, runId])
-
- // Emit the turn's voice-to-voice latency breakdown once audio is audible.
- useEffect(() => {
- if (tts.state !== 'speaking') return
- const marks = callTurnMarksRef.current
- if (!marks || marks.submit === undefined || marks.speak === undefined) return
- callTurnMarksRef.current = null
- const now = performance.now()
- analytics.callTurnLatency({
- endpointToSubmitMs: marks.submit - marks.t0,
- submitToSpeakMs: marks.speak - marks.submit,
- speakToAudioMs: now - marks.speak,
- totalMs: now - marks.t0,
- })
- }, [tts.state])
-
const voice = useVoiceMode()
const voiceRef = useRef(voice)
voiceRef.current = voice
- // Calls: one engine (hands-free voice loop + forced read-aloud TTS + frame
- // capture), started via presets that only differ in device defaults. The
- // presentation is DERIVED from devices, never picked: screen sharing →
- // 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.
- const [callMinimized, setCallMinimized] = useState(false)
- // In-call mute: a full input pause, not just audio — mic audio stops
- // reaching Deepgram AND camera/screen frame capture stops, so nothing said
- // or shown while muted ever reaches the assistant. Output is untouched
- // (in-flight speech keeps playing; the Stop control handles that).
- const [micMuted, setMicMuted] = useState(false)
- // Practice preset: adds the coaching persona to the system prompt.
- const [practiceMode, setPracticeMode] = useState(false)
- const practiceModeRef = useRef(false)
-
const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined)
const meetingTranscription = useMeetingTranscription(() => {
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([
@@ -1181,14 +859,12 @@ function App() {
}, [])
const handleStartRecording = useCallback(() => {
- // A live call owns the mic — ignore push-to-talk while one is running.
- if (inCallRef.current) return
setIsRecording(true)
isRecordingRef.current = true
voice.start()
}, [voice])
- const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null)
+ const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null)
const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
@@ -1196,9 +872,8 @@ function App() {
const editorRefsByTabId = useRef>(new Map())
const [pendingPaletteSubmit, setPendingPaletteSubmit] = useState<{ text: string; mention: CommandPaletteMention | null } | null>(null)
- const handleSubmitRecording = useCallback(async () => {
- if (!isRecordingRef.current) return
- const text = await voice.submit()
+ const handleSubmitRecording = useCallback(() => {
+ const text = voice.submit()
setIsRecording(false)
isRecordingRef.current = false
if (text) {
@@ -1207,233 +882,28 @@ function App() {
}
}, [voice])
+ const handleToggleTts = useCallback(() => {
+ setTtsEnabled(prev => {
+ const next = !prev
+ ttsEnabledRef.current = next
+ if (!next) {
+ ttsRef.current.cancel()
+ }
+ return next
+ })
+ }, [])
+
+ const handleTtsModeChange = useCallback((mode: 'summary' | 'full') => {
+ setTtsMode(mode)
+ ttsModeRef.current = mode
+ }, [])
+
const handleCancelRecording = useCallback(() => {
voice.cancel()
setIsRecording(false)
isRecordingRef.current = false
}, [voice])
- // Start a call. Presets only differ in device defaults — the engine
- // (continuous listening, auto-submitted utterances, forced read-aloud TTS,
- // frame capture) is identical for all of them. The default entry ('share',
- // the call button's main click) is "work together": screen shared, camera
- // off, floating pill — the user keeps working while the assistant watches
- // along. 'video'/'practice' open face-to-face full screen instead.
- const callStartedAtMsRef = useRef(null)
-
- const startCall = useCallback(async (preset: CallPreset) => {
- if (inCallRef.current) return
- const camera = preset === 'video' || preset === 'practice'
- const ok = await video.start({ camera })
- if (!ok) return // camera denied/unavailable — stay out of the call
- if (preset === 'share') {
- // If screen capture fails (usually the macOS Screen Recording
- // permission), continue as a voice call — sharing is one tap away on
- // the pill once permission is granted.
- const shared = await video.startScreenShare()
- if (!shared) {
- toast("Couldn't share your screen", {
- description: 'Grant Rowboat Screen Recording access, then tap the share button on the call.',
- action: {
- label: 'Open Settings',
- onClick: () => void window.ipc.invoke('meeting:openScreenRecordingSettings', null).catch(() => {}),
- },
- })
- }
- }
-
- // A manual push-to-talk recording can't coexist with the call's mic.
- if (isRecordingRef.current) {
- voiceRef.current.cancel()
- setIsRecording(false)
- isRecordingRef.current = false
- }
- ttsEnabledRef.current = true
- ttsModeRef.current = 'full'
- void voiceRef.current.startContinuous((text) => {
- // Instant "heard you" feedback + start of the latency clock.
- playAckCue()
- callTurnMarksRef.current = { t0: performance.now() }
- pendingVoiceInputRef.current = true
- handlePromptSubmitRef.current?.({ text, files: [] })
- })
-
- setPracticeMode(preset === 'practice')
- practiceModeRef.current = preset === 'practice'
- setMicMuted(false)
- // Pill-first presets start minimized; face-to-face presets start expanded.
- setCallMinimized(preset === 'voice' || preset === 'share')
- inCallRef.current = true
- setInCall(true)
- callStartedAtMsRef.current = performance.now()
- analytics.callStarted(preset)
- }, [video])
-
- const endCall = useCallback(() => {
- if (!inCallRef.current) return
- const startedAt = callStartedAtMsRef.current
- callStartedAtMsRef.current = null
- analytics.callEnded(startedAt != null ? (performance.now() - startedAt) / 1000 : 0)
- voiceRef.current.cancel()
- ttsEnabledRef.current = false
- ttsModeRef.current = 'summary'
- ttsRef.current.cancel()
- callTurnMarksRef.current = null
- video.stop()
- setPracticeMode(false)
- practiceModeRef.current = false
- setMicMuted(false)
- setCallMinimized(false)
- inCallRef.current = false
- setInCall(false)
- }, [video])
-
- // During a call, mute the mic while the assistant is thinking or speaking
- // so its own TTS (or a half-turn) never gets transcribed back at it — and
- // whenever the user muted themselves.
- useEffect(() => {
- if (!inCall) return
- voiceRef.current.setPaused(micMuted || activeIsProcessing || tts.state !== 'idle')
- }, [inCall, micMuted, activeIsProcessing, tts.state])
-
- // The user-mute half that lives in the video pipeline: stop sampling
- // camera/screen frames while muted (see useVideoMode.setCapturePaused).
- const setCapturePaused = video.setCapturePaused
- useEffect(() => {
- setCapturePaused(micMuted)
- }, [micMuted, setCapturePaused])
-
- // Screen sharing: frames of the shared screen ride along with each message
- // next to the webcam frames. The surface change (full screen → pill) falls
- // out of the derivation below.
- const handleToggleScreenShare = useCallback(async () => {
- if (video.screenState === 'live') {
- video.stopScreenShare()
- } else {
- await video.startScreenShare()
- }
- }, [video])
-
- // Meet-style camera mute: the call (and any screen share) stays on, but no
- // webcam frames are captured while the camera is off. Deliberately does NOT
- // change the surface — turning your camera on from the pill puts your video
- // IN the pill; expanding to full screen is its own explicit action.
- const handleToggleCamera = useCallback(() => {
- void video.setCameraEnabled(!video.cameraOn)
- }, [video])
-
- // Zoom-style mute button, except it pauses ALL input (mic + frames) so the
- // user can talk to someone in the room without the assistant listening in.
- // Devices stay acquired (camera light and share indicator stay on) so
- // unmuting is instant.
- const handleToggleMic = useCallback(() => {
- setMicMuted((m) => !m)
- }, [])
-
- // Minimizing the full-screen call drops you back to working — and the pill
- // exists to work *together*, so sharing starts automatically (the symmetric
- // twin of expand, which stops it). If capture fails (permission), the call
- // still minimizes as a plain pill. `callMinimized` is also set so stopping
- // the share from the pill keeps you in the pill rather than snapping back
- // to full screen.
- const handleMinimizeCall = useCallback(async () => {
- setCallMinimized(true)
- await video.startScreenShare()
- }, [video])
-
- // Interrupt the assistant: silence TTS immediately, skip anything already
- // queued from the in-flight turn, and stop the run if it's still
- // generating (if it already finished, stopping the speech is all there is
- // to do). Wired to the Stop control next to the mascot on both surfaces.
- const handleInterruptAssistant = useCallback(() => {
- ttsRef.current.cancel()
- setAssistantCaption('')
- if (voiceSegments) {
- spokenVoiceRef.current.count = voiceSegments.length
- }
- if (activeIsProcessing) {
- void stopRunRef.current?.()
- }
- }, [voiceSegments, activeIsProcessing])
-
- // Current phase of the call (null when not in one).
- const videoCallStatus: 'listening' | 'thinking' | 'speaking' | null =
- inCall
- ? tts.state === 'speaking'
- ? 'speaking'
- : tts.state === 'synthesizing' || activeIsProcessing
- ? 'thinking'
- : 'listening'
- : null
-
- // The call's surface follows one rule: full screen and screen sharing are
- // mutually exclusive (a full-screen call covers the screen — sharing it
- // would show the call itself). Sharing → floating pill, always. Not
- // sharing → full screen unless the user shrank it (`callMinimized`).
- // Expanding the pill auto-stops any share; presenting from full screen
- // auto-collapses to the pill.
- const callSurface: 'fullscreen' | 'popout' | null = !inCall
- ? null
- : video.screenState === 'live' || callMinimized
- ? 'popout'
- : 'fullscreen'
-
- useEffect(() => {
- void window.ipc.invoke('video:setPopout', { show: callSurface === 'popout' }).catch(() => {})
- }, [callSurface])
-
- // Consent surface for screen sharing: an unmissable toast the moment any
- // share starts (auto-started calls included), with one-tap stop. The pill
- // also carries a persistent "Sharing screen" badge, and macOS shows its
- // purple recording indicator.
- const prevScreenStateRef = useRef(video.screenState)
- useEffect(() => {
- const prev = prevScreenStateRef.current
- prevScreenStateRef.current = video.screenState
- if (video.screenState === 'live' && prev !== 'live') {
- toast('Your screen is being shared', {
- description: 'The assistant sees snapshots of it along with what you say.',
- action: { label: 'Stop sharing', onClick: () => video.stopScreenShare() },
- duration: 6000,
- })
- }
- }, [video.screenState, video])
-
- // Keep the popout's mascot/status/devices/caption mirror of the call fresh.
- // The main process caches the latest state and replays it when the popout
- // loads.
- useEffect(() => {
- if (!inCall) return
- void window.ipc
- .invoke('video:popoutState', {
- ttsState: tts.state,
- status: videoCallStatus,
- cameraOn: video.cameraOn,
- micMuted,
- screenSharing: video.screenState === 'live',
- interimText: voice.interimText || null,
- })
- .catch(() => {})
- }, [inCall, tts.state, videoCallStatus, video.cameraOn, micMuted, video.screenState, voice.interimText])
-
- // Execute popout control-bar actions (the popout window has no access to
- // the call's mic/camera/capture — they live here). 'expand' goes full
- // screen, which by the exclusivity rule stops any running share; the main
- // process already refocused the app window.
- useEffect(() => {
- return window.ipc.on('video:popout-action', ({ action }) => {
- if (action === 'toggle-mic') handleToggleMic()
- else if (action === 'toggle-camera') handleToggleCamera()
- else if (action === 'toggle-share') void handleToggleScreenShare()
- else if (action === 'stop-speaking') handleInterruptAssistant()
- else if (action === 'end-call') endCall()
- else if (action === 'expand') {
- if (video.screenState === 'live') video.stopScreenShare()
- setCallMinimized(false)
- }
- })
- }, [handleToggleMic, handleToggleCamera, handleToggleScreenShare, handleInterruptAssistant, endCall, video])
-
// Enter to submit voice input, Escape to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1460,27 +930,20 @@ function App() {
}, [])
// Runs history state
- type RunListItem = { id: string; title?: string; createdAt: string; modifiedAt: string; agentId: string; useCase?: string }
+ type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState([])
// Chat tab state
const [chatTabs, setChatTabs] = useState([{ id: 'default-chat-tab', runId: null }])
- const chatTabsRef = useRef(chatTabs)
- chatTabsRef.current = chatTabs
const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
const [chatViewStateByTab, setChatViewStateByTab] = useState>({
'default-chat-tab': createEmptyChatTabViewState(),
})
const chatViewStateByTabRef = useRef(chatViewStateByTab)
+ const chatTabIdCounterRef = useRef(0)
+ const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
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)
- workDirByTabRef.current = workDirByTab
const chatScrollTopByTabRef = useRef(new Map())
const [toolOpenByTab, setToolOpenByTab] = useState>>({})
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({})
@@ -1493,36 +956,6 @@ function App() {
chatDraftsRef.current.delete(tabId)
}
}, [])
- // Persist a run's work directory to its per-run sidecar config file. The agent
- // runtime reads this same file (config/workdir-.json) on each turn.
- const persistRunWorkDir = useCallback(async (runId: string, value: string | null) => {
- try {
- await window.ipc.invoke('workspace:writeFile', {
- path: `config/workdir-${runId}.json`,
- data: JSON.stringify(value ? { path: value } : {}, null, 2),
- })
- } catch (err) {
- console.error('Failed to persist work directory for run', runId, err)
- }
- }, [])
- // Read a run's persisted work directory (used when (re)opening a run into a tab).
- const loadRunWorkDir = useCallback(async (runId: string): Promise => {
- try {
- const result = await window.ipc.invoke('workspace:readFile', { path: `config/workdir-${runId}.json` })
- const parsed = JSON.parse(result.data)
- const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
- return value || null
- } catch {
- return null
- }
- }, [])
- const setTabWorkDir = useCallback((tabId: string, value: string | null) => {
- setWorkDirByTab((prev) => ({ ...prev, [tabId]: value }))
- // If the tab is already bound to a run, persist immediately so the change
- // applies to that chat's subsequent messages.
- const runId = chatTabsRef.current.find((t) => t.id === tabId)?.runId
- if (runId) void persistRunWorkDir(runId, value)
- }, [persistRunWorkDir])
const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
return toolOpenByTab[tabId]?.[toolId] ?? false
}, [toolOpenByTab])
@@ -1584,27 +1017,10 @@ function App() {
}, [processingRunIds])
// File tab state
- const [fileTabs, setFileTabs] = useState([{ id: 'home-tab', path: HOME_TAB_PATH }])
- const [activeFileTabId, setActiveFileTabId] = useState('home-tab')
+ const [fileTabs, setFileTabs] = useState