diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ec60096f..6566f105 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -23,7 +23,7 @@ jobs: - 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' @@ -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: @@ -129,7 +128,7 @@ jobs: - 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' @@ -176,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: @@ -194,7 +192,7 @@ jobs: - 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' @@ -243,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/.gitignore b/.gitignore index 086ea0b5..2480e5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ .vscode/ data/ .venv/ -.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 75a0f6a5..db51cb63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,15 +102,6 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca | Workspace config | `apps/x/pnpm-workspace.yaml` | | Root scripts | `apps/x/package.json` | -## Feature Deep-Dives - -Long-form docs for specific features. Read the relevant file before making changes in that area — it has the full product flow, technical flows, and (where applicable) a catalog of the LLM prompts involved with exact file:line pointers. - -| 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` | -| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | - ## Common Tasks ### LLM configuration (single provider) diff --git a/apps/x/.claude/launch.json b/apps/x/.claude/launch.json new file mode 100644 index 00000000..3ba43066 --- /dev/null +++ b/apps/x/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "renderer-dev", + "runtimeExecutable": "/Users/tusharmagar/Rowboat/rowboat-V2/apps/x/apps/renderer/node_modules/.bin/vite", + "runtimeArgs": ["--port", "5173"], + "port": 5173 + } + ] +} diff --git a/apps/x/.gitignore b/apps/x/.gitignore index db195fb4..c2658d7d 100644 --- a/apps/x/.gitignore +++ b/apps/x/.gitignore @@ -1,2 +1 @@ node_modules/ -test-fixtures/ diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md deleted file mode 100644 index 2d9816d0..00000000 --- a/apps/x/ANALYTICS.md +++ /dev/null @@ -1,161 +0,0 @@ -# Analytics - -> PostHog instrumentation for `apps/x`. We capture LLM token usage (broken down by feature) and identity/auth events. Renderer (`posthog-js`) and main (`posthog-node`) share one stable distinct_id and one identified user, so events from either process resolve to the same person. - -## Identity model - -- **Anonymous distinct_id** = `installationId` from `~/.rowboat/config/installation.json` (auto-generated on first run; see `packages/core/src/analytics/installation.ts`). -- Renderer fetches it from main on startup via the `analytics:bootstrap` IPC channel and passes it as PostHog's `bootstrap.distinctID`. Main uses it directly in `posthog-node`. -- **On rowboat sign-in**: `posthog.identify(rowboatUserId)` runs in **both** processes. - - Main does it from `apps/main/src/oauth-handler.ts:285` (after `getBillingInfo()` resolves) — this is the load-bearing call, since main always runs. - - Renderer mirrors via `apps/renderer/src/hooks/useAnalyticsIdentity.ts` listening on the `oauth:didConnect` IPC event. - - Main also calls `alias()` so events emitted under the anonymous installation_id are linked to the identified user retroactively. -- **On every app startup**: main re-identifies if rowboat tokens exist (`packages/core/src/analytics/identify.ts`, called from `apps/main/src/main.ts` whenReady). Idempotent — PostHog merges person properties on duplicate identifies. This catches users who installed before analytics existed, and refreshes person properties (plan/status) on every launch. -- **On rowboat sign-out**: `posthog.reset()` in both processes; future events resolve to the installation_id again. -- **`email`** is set on `identify` from main only (sourced from `/v1/me`). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it. - -## Event catalog - -All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. - -### `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` | -| `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` | -| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) | -| `input_tokens` | number | | -| `output_tokens` | number | | -| `total_tokens` | number | | -| `cached_input_tokens` | number? | When the provider reports it | -| `reasoning_tokens` | number? | When the provider reports it | - -#### Use-case taxonomy - -Every `llm_usage` emit point in the codebase: - -| `use_case` | `sub_use_case` | `agent_name`? | Where | File:line | -|---|---|---|---|---| -| `copilot_chat` | (none) | yes | User chat in renderer (default for any `createRun` without `useCase`) | `packages/core/src/agents/runtime.ts:1313` (finish-step in `streamLlm`) | -| `copilot_chat` | `scheduled` | yes | Background scheduled agent runner | `packages/core/src/agent-schedule/runner.ts:167` | -| `copilot_chat` | `file_parse` | inherits | `parseFile` builtin tool inside any chat | `packages/core/src/application/lib/builtin-tools.ts:770` | -| `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 | -| `live_note_agent` | `window` | yes | Pass 2 agent run — fired inside a configured time-of-day window | same call site | -| `live_note_agent` | `event` | yes | Pass 2 agent run — Pass 1 routing flagged the note for an incoming event | same call site | -| `meeting_note` | (none) | no | Meeting transcript summarizer (`generateText`) | `packages/core/src/knowledge/summarize_meeting.ts:161` | -| `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) | - -##### `live_note_agent` sub-use-case shape - -For the live-note feature specifically, `sub_use_case` discriminates **what kind of work happened**: - -- `routing` — Pass 1 LLM classifier deciding which live notes might be relevant to an incoming event. One emit per Pass 1 batch. -- `manual` / `cron` / `window` / `event` — Pass 2 agent run, tagged with the trigger that woke it up. The runner reads its `trigger` argument (`LiveNoteTriggerType`) and passes it directly as `subUseCase`, so dashboards can break runs down by trigger source. - -This means a single end-to-end event flow emits both `routing` (Pass 1) and `event` (Pass 2). A scheduled cron fire emits only `cron`. A user clicking Run emits only `manual`. There is no separate "run" sub-use-case anymore — the trigger IS the sub-use-case for Pass 2. - -`testModelConnection` in `packages/core/src/models/models.ts` is **not** instrumented (diagnostic only — would skew per-model counts). - -### `user_signed_in` - -Emitted when rowboat OAuth completes. Properties: `plan`, `status` (subscription state from `/v1/me`). - -Emitted from **both** processes: -- Main (`apps/main/src/oauth-handler.ts:290`) — always fires; load-bearing. -- Renderer (`apps/renderer/src/hooks/useAnalyticsIdentity.ts:75`) — fires only when the renderer is open. Same distinct_id, so dedup is automatic in PostHog dashboards. - -### `user_signed_out` - -Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.reset()`. - -Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`. - -### Other events (pre-existing, not added by the LLM-usage work) - -All in `apps/renderer/src/lib/analytics.ts`: - -- `chat_session_created` — `{ run_id }` -- `chat_message_sent` — `{ voice_input, voice_output, search_enabled }` -- `oauth_connected` / `oauth_disconnected` — `{ provider }` -- `voice_input_started` — no properties -- `search_executed` — `{ types: string[] }` -- `note_exported` — `{ format }` - -## Person properties - -Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`. - -| Property | Set by | Notes | -|---|---|---| -| `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 | -| `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 | - -## How to add a new event - -1. **Naming**: `snake_case`, `[object]_[verb]` shape (e.g. `note_exported`, not `exportedNote`). Matches PostHog convention. -2. **Pick the right helper**: - - LLM token usage → `captureLlmUsage()` from `@x/core/dist/analytics/usage.js`. Always include `useCase`; add `subUseCase` if it refines an existing top-level case. - - Anything else from main → `capture()` from `@x/core/dist/analytics/posthog.js`. - - Anything else from renderer → add a typed wrapper to `apps/renderer/src/lib/analytics.ts` and call it from the UI code (don't call `posthog.capture()` directly from components). -3. **If it's a new LLM call site**: - - Goes through `createRun`? Pass `useCase` (and optionally `subUseCase`) to the create call. The runtime auto-emits at every `finish-step` — no further code needed. - - Direct `generateText` / `generateObject`? Call `captureLlmUsage` after the call with `model`, `provider`, `usage` from the result. - - Inside a builtin tool? Call `getCurrentUseCase()` from `analytics/use_case.ts` first — the parent run's tag is propagated via `AsyncLocalStorage`. Use `ctx?.useCase ?? 'copilot_chat'` as fallback. -4. **Update this file in the same PR.** That's the contract — without it, dashboards and downstream consumers drift. - -## How to add a new use-case sub-case - -- **New `sub_use_case` under an existing top-level case**: just pick a string and add a row to the taxonomy table above. No code changes beyond the call site. -- **New top-level `use_case`**: edit the `UseCase` enum in `packages/shared/src/runs.ts` and the matching `UseCase` type in `packages/core/src/analytics/use_case.ts`. Then update this doc. - -## Configuration - -PostHog credentials live in two env vars (also baked into the binary at packaging time — never set at runtime in distributed builds): - -- `VITE_PUBLIC_POSTHOG_KEY` — project API key (e.g. `phc_xxx`). Public-facing — safe to commit if you'd rather hardcode. -- `VITE_PUBLIC_POSTHOG_HOST` — e.g. `https://us.i.posthog.com`. Defaults to US cloud if unset. - -Where they're consumed: -- **Renderer** (Vite): `import.meta.env.VITE_PUBLIC_POSTHOG_*` — inlined at build time. -- **Main** (esbuild via `apps/main/bundle.mjs`): inlined into `main.cjs` at packaging time using esbuild `define`. In dev (`npm run dev`), main reads them from `process.env` at runtime. - -For GitHub Actions / packaged builds: set both as workflow env vars (from secrets) on the step that runs `npm run package` or `npm run make`. They'll be baked in. - -If unset, analytics no-op silently — you'll see `[Analytics] POSTHOG_KEY not set; analytics disabled` in main-process logs. - -`installationId`: stored in `~/.rowboat/config/installation.json`, generated on first run. - -## File map - -| File | Purpose | -|---|---| -| `packages/core/src/analytics/installation.ts` | Stable per-install distinct_id | -| `packages/core/src/analytics/posthog.ts` | Main-process client (`capture`, `identify`, `reset`, `shutdown`) | -| `packages/core/src/analytics/usage.ts` | `captureLlmUsage()` helper | -| `packages/core/src/analytics/use_case.ts` | `AsyncLocalStorage` for tool-internal LLM call inheritance | -| `apps/renderer/src/lib/analytics.ts` | Renderer event wrappers | -| `apps/renderer/src/hooks/useAnalyticsIdentity.ts` | Renderer identify/reset on OAuth events | -| `apps/main/src/oauth-handler.ts` | Main-side identify/reset/sign-in/sign-out events | -| `apps/main/src/main.ts` | `before-quit` hook flushes queued events | -| `packages/shared/src/ipc.ts` | `analytics:bootstrap` IPC channel definition | -| `apps/main/src/ipc.ts` | `analytics:bootstrap` handler + forwards `userId` on `oauth:didConnect` | -| `apps/main/bundle.mjs` | Bakes `POSTHOG_KEY`/`POSTHOG_HOST` into packaged `main.cjs` | diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md deleted file mode 100644 index d8a157d7..00000000 --- a/apps/x/LIVE_NOTE.md +++ /dev/null @@ -1,408 +0,0 @@ -# Live Notes - -> A single `live:` frontmatter block that turns a markdown note into a self-updating artifact — refreshed on a schedule (cron / windows), in response to incoming events (Gmail, Calendar), or on demand. - -A live note has exactly **one** `live:` block in its YAML frontmatter. The block carries a persistent **objective** (what the note should keep being), an optional **triggers** object (when the agent should fire), and runtime fields the system writes back. The body below the H1 is owned by the live-note agent — it freely synthesizes, dedupes, and reorganizes the content to satisfy the objective. A note with no `live:` key is just a static note. - -**Example** (a note that shows the current Chicago time, refreshed hourly): - -~~~markdown ---- -live: - objective: | - Show the current time in Chicago, IL in 12-hour format. Keep it as one - short line, no extra prose. - active: true - triggers: - cronExpr: "0 * * * *" - lastAttemptAt: "2026-05-08T15:00:00.123Z" - lastRunAt: "2026-05-08T15:00:01.234Z" - lastRunId: "..." - lastRunSummary: "Updated — 3:00 PM, Central Time." - lastRunError: null ---- - -# Chicago time - -3:00 PM, Central Time -~~~ - -## Table of Contents - -1. [Product Overview](#product-overview) -2. [Architecture at a Glance](#architecture-at-a-glance) -3. [Technical Flows](#technical-flows) -4. [Schema Reference](#schema-reference) -5. [Body Structure](#body-structure) -6. [Daily-Note Template & Migrations](#daily-note-template--migrations) -7. [Renderer UI](#renderer-ui) -8. [Prompts Catalog](#prompts-catalog) -9. [File Map](#file-map) - ---- - -## Product Overview - -### One note, one objective - -A live note has at most one `live:` block. The block has exactly one `objective`. The objective can be long and cover multiple sub-topics — the agent treats the note holistically and is free to lay out the body however the objective suggests. **There is no second objective per note.** When the user asks Copilot to "also keep an eye on X" in an already-live note, Copilot is trained to extend the existing objective in natural language rather than fork a second block. - -This is intentional: the user is *delegating awareness*, not configuring automations. Multiple agents per note led to ownership confusion, scope boundaries, and orchestration concerns that don't fit a personal-knowledge tool. - -### Triggers - -The `triggers` object has three independently optional sub-fields. Each one is its own channel; mix freely. - -| Field | When it fires | Shape | -|---|---|---| -| **`cronExpr`** | At exact cron times | `cronExpr: "0 * * * *"` | -| **`windows`** | Once per day per window, anywhere inside a time-of-day band | `windows: [{ startTime: "09:00", endTime: "12:00" }]` | -| **`eventMatchCriteria`** | When a matching event arrives (e.g. new Gmail thread) | `eventMatchCriteria: "Emails about Q3 planning"` | - -A `triggers` block with no fields (or no `triggers` key at all) is **manual-only** — the agent fires only when the user clicks Run in the panel. - -`cronExpr` enforces a 2-minute grace window — if the scheduled time passed more than 2 minutes ago (e.g. the app was offline), the run is skipped, not replayed. `windows` are forgiving by design: as long as it's still inside the band and the day's cycle hasn't fired yet, the agent fires the moment the app is open. Each window's daily cycle is anchored at `startTime`. - -The `once` trigger from the prior model has been **dropped** — it didn't fit the "ongoing awareness" framing. - -### Creating a live note - -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. - -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. - -### Viewing and managing live notes - -The editor toolbar has a Radio-icon button (right side) that opens the **Live Note panel** for the current note. The panel: - -- **Empty state** (passive note) — "Make this note live" CTA that hands off to Copilot for the natural-language flow. -- **Editor** — single panel with: objective textarea, triggers editor (cron / windows list / eventMatchCriteria, each independently shown via add/remove), status row (last-run summary + active toggle), Advanced (collapsed: model + provider), footer (Edit with Copilot · Save · Run now), and a danger-zone "Make passive" button. -- **Status hook** — `useLiveNoteAgentStatus` subscribes to `live-note-agent:events` IPC; the Run button shows a spinner whenever the agent is running. - -Every mutation goes through IPC to the backend — the renderer never writes the file itself. This avoids races with the scheduler/runner writing runtime fields like `lastRunAt`. - -### What the runtime agent does - -When a trigger fires, the live-note agent receives a short message: -- The workspace-relative path to the note and a localized timestamp. -- The objective. -- 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. -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`. - -The agent has the full workspace toolkit (read/edit/grep/web-search/browser/MCP). - ---- - -## Architecture at a Glance - -``` -Editor toolbar Radio button ─click──► LiveNoteSidebar (React) - │ - ├──► IPC: live-note:get / set / - │ setActive / delete / run - │ -Backend (main process) - ├─ Scheduler loop (15 s) ──┐ - ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent - └─ Builtin tool │ │ - run-live-note-agent ────┘ ▼ - file-readText / -edit - │ - ▼ - body region(s) rewritten on disk - frontmatter lastRun* patched -``` - -**Single-writer invariant** — the renderer is never a file writer for the `live:` key. All on-disk changes go through backend helpers in `packages/core/src/knowledge/live-note/fileops.ts` (`setLiveNote`, `patchLiveNote`, `setLiveNoteActive`, `deleteLiveNote`). `extractAllFrontmatterValues` in the renderer's frontmatter helper explicitly skips the `live:` key (and `buildFrontmatter` splices it back from the original raw on save), so the FrontmatterProperties UI can't accidentally mangle it. - -**Event contract** — `window.dispatchEvent(CustomEvent('rowboat:open-live-note-panel', { detail: { filePath } }))` is the sole entry point from editor toolbar → panel. `rowboat:open-copilot-edit-live-note` opens the Copilot sidebar with the note attached. - ---- - -## Technical Flows - -### Scheduling (cron / windows) - -- **Module**: `packages/core/src/knowledge/live-note/scheduler.ts`. Polls every **15 seconds** (`POLL_INTERVAL_MS`). -- Each tick: `workspace.readdir('knowledge', { recursive: true })`, filter `.md`, `fetchLiveNote(relPath)` for each. -- For each note with a `live:` block where `active !== false`, `dueTimedTrigger(triggers, lastRunAt)` returns `'cron'`, `'window'`, or `null` — pure cycle check, no backoff. The scheduler then calls `backoffRemainingMs(lastAttemptAt)` separately so it can log "matched cron, backoff 4m remaining" rather than collapse the two reasons. -- When due AND not in backoff, fire `runLiveNoteAgent(relPath, source)` where `source` is `'cron'` or `'window'` (the granular trigger surfaces all the way to the agent message — see Trigger granularity). -- **Cycle anchoring** — anchored on `lastRunAt`, which is bumped only on *successful* completions. A failed run leaves the cycle unfired so the scheduler retries. -- **Backoff** — `RETRY_BACKOFF_MS = 5 min`. If `lastAttemptAt` is within that window, the scheduler skips the note. Covers both in-flight runs (the in-memory concurrency guard handles the common case; backoff is the disk-persistent backstop) and post-failure storming. Manual runs (clicked Run) bypass this — they don't go through the scheduler. -- **Cron grace** — `cronExpr` enforces a 2-minute grace; missed schedules are skipped, not replayed. -- **Windows** have no grace — anywhere inside the band counts. A failed run inside the band leaves the window unfired; the next eligible tick (after backoff) retries. -- **Window cycle anchor** — a window's daily cycle starts at `startTime`. Once a *successful* fire lands strictly after today's `startTime`, that window is done for the day. The strict comparison handles the boundary case (e.g. an 08:00–12:00 + a 12:00–15:00 window each get to fire even when the morning fire happens exactly at 12:00:00). -- **Startup** — `initLiveNoteScheduler()` is called in `apps/main/src/main.ts` at app-ready, alongside `initLiveNoteEventProcessor()`. - -### Event pipeline - -**Producers** — any data source that should feed live notes emits events: -- **Gmail** (`packages/core/src/knowledge/sync_gmail.ts`) — call sites after a successful thread sync invoke `createEvent({ source: 'gmail', type: 'email.synced', payload: })`. -- **Calendar** (`packages/core/src/knowledge/sync_calendar.ts`) — one bundled event per sync, with a markdown digest payload built by `summarizeCalendarSync()`. - -**Storage** — `packages/core/src/knowledge/live-note/events.ts` writes each event as a JSON file under `~/.rowboat/events/pending/.json`. IDs come from the DI-resolved `IdGen` (ISO-based, lexicographically sortable) — so `readdirSync(...).sort()` is strict FIFO. - -**Consumer loop** — same file, `init()` polls every **5 seconds**, then `processPendingEvents()` walks sorted filenames. For each event: -1. Parse via `KnowledgeEventSchema`; malformed files go to `done/` with `error` set (the loop stays alive). -2. `listEventEligibleLiveNotes()` scans every `.md` under `knowledge/`. Only notes where `live.active !== false` and `live.triggers?.eventMatchCriteria` is set are event-eligible. -3. `findCandidates(event, eligible)` runs Pass 1 LLM routing (below). -4. For each candidate, `runLiveNoteAgent(filePath, 'event', event.payload)` **sequentially** — preserves total ordering within the event. -5. Enrich the event JSON with `processedAt`, `candidateFilePaths`, `runIds`, `error?`, then move to `events/done/.json`. - -**Pass 1 routing** (`routing.ts`): -- **Short-circuit** — if `event.targetFilePath` is set (manual re-run events), skip the LLM and return that note directly. -- Batches of `BATCH_SIZE = 20`. -- Per batch, `generateObject()` with `ROUTING_SYSTEM_PROMPT` + `buildRoutingPrompt()` and `Pass1OutputSchema` → `{ filePaths: string[] }`. Direct path-based dedup (no composite key needed since live-note is one-per-file). - -**Pass 2 decision** happens inside the live-note agent (see Run flow) — Pass 1 is liberal, the agent vetoes false positives before touching the body. - -### Trigger granularity - -Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | 'event'` — propagated end-to-end through `runLiveNoteAgent(filePath, trigger, context?)`, the `liveNoteBus` start event, and the `live-note-agent:events` IPC payload. - -- The **scheduler** passes `'cron'` or `'window'` based on which sub-trigger `dueTimedTrigger` matched. -- The **event processor** always passes `'event'`. -- The **panel Run button** and the **`run-live-note-agent` builtin tool** both pass `'manual'`. - -`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)". - -### Run flow (`runLiveNoteAgent`) - -Module: `packages/core/src/knowledge/live-note/runner.ts`. - -1. **Concurrency guard** — static `runningLiveNotes: Set` keyed by `filePath`. Duplicate calls return `{ action: 'no_update', error: 'Already running' }`. -2. **Fetch live note** via `fetchLiveNote(filePath)`. -3. **Snapshot body** via `readNoteBody(filePath)` for the post-run diff. -4. **Create agent run** — `createRun({ agentId: 'live-note-agent' })`. -5. **Bump `lastAttemptAt` + `lastRunId` immediately** (before the agent executes). `lastAttemptAt` is the disk-persistent backoff anchor — the scheduler suppresses fires within `RETRY_BACKOFF_MS` (5 min) of it, covering both in-flight runs and post-failure backoff. **`lastRunAt` is not touched here** — that field is the cycle anchor and should only move on success. -6. **Emit `live_note_agent_start`** on the `liveNoteBus` with the trigger type (`manual` / `timed` / `event`). -7. **Send agent message** built by `buildMessage(filePath, live, trigger, context?)` (see Prompts Catalog #4). The path is converted to its workspace-relative form (`knowledge/${filePath}`) so the agent's tools resolve correctly. -8. **Wait for completion** — `waitForRunCompletion(runId)`, then `extractAgentResponse(runId)` for the summary. -9. **Compare body**: re-read body via `readNoteBody(filePath)`, diff vs the snapshot. If changed → `action: 'replace'`; else → `action: 'no_update'`. -10. **On success:** `patchLiveNote(filePath, { lastRunAt: now, lastRunSummary, lastRunError: undefined })`. -11. **On failure:** `patchLiveNote(filePath, { lastRunError: msg })`. **`lastRunAt` and `lastRunSummary` are deliberately untouched** so the user keeps seeing the last good state in the UI, and the scheduler treats the cycle as unfired (windows will retry inside the same band, gated only by the 5-min backoff). -12. **Emit `live_note_agent_complete`** with `summary` or `error`. -13. **Cleanup**: `runningLiveNotes.delete(filePath)` in a `finally` block. - -Returned to callers: `{ filePath, runId, action, contentBefore, contentAfter, summary, error? }`. - -**Stops** — when the user clicks Stop in the panel, `live-note:stop` resolves the latest `lastRunId` and calls `runsCore.stop(runId, false)`. The runner's `waitForRunCompletion` throws, the failure branch records `lastRunError`, and the bus emits `complete` with the error. The cycle stays unfired (so the run is retried on the next tick after backoff expires) — exactly the same path as any other failure. - -### IPC surface - -| Channel | Caller → handler | Purpose | -|---|---|---| -| `live-note:run` | Renderer (panel Run button) | Fires `runLiveNoteAgent(..., 'manual')` | -| `live-note:get` | Panel on open | Returns the parsed `LiveNote \| null` from frontmatter | -| `live-note:set` | Panel save | Validates + writes the whole `live:` block | -| `live-note:setActive` | Panel toggle | Flips `active` | -| `live-note:delete` | Panel "Make passive" | Removes the entire `live:` block | -| `live-note:stop` | Panel Stop button | Resolves the live block's `lastRunId` and calls `runsCore.stop(runId)` | -| `live-note:listNotes` | Background-agents view | Lists all live notes with summary fields | -| `live-note-agent:events` | Server → renderer (`webContents.send`) | Forwards `liveNoteBus` events to `useLiveNoteAgentStatus` | - -Request/response schemas live in `packages/shared/src/ipc.ts`; handlers in `apps/main/src/ipc.ts`; backend helpers in `packages/core/src/knowledge/live-note/fileops.ts`. - -### Concurrency & FIFO guarantees - -- **Per-note serialization** — the `runningLiveNotes` guard in `runner.ts`. A note is at most running once at a time; overlapping triggers (manual + scheduled + event) return `error: 'Already running'`. -- **Backend is single writer for `live:`** — all editing goes through fileops; the renderer's FrontmatterProperties UI explicitly preserves `live:` byte-for-byte across saves. -- **File lock** — every fileops mutation runs under `withFileLock(absPath)` so the runner, scheduler, and IPC handlers serialize on the file. -- **Event FIFO** — monotonic `IdGen` IDs → lexicographic filenames → `sort()` in `processPendingEvents()`. Candidates within one event are processed sequentially. -- **No retry storms** — `lastRunAt` is set at the *start* of a run, not the end. A crash mid-run leaves the note marked as ran; the scheduler's next tick computes the next occurrence from that point. - ---- - -## Schema Reference - -All canonical schemas live in `packages/shared/src/live-note.ts`: - -- `LiveNoteSchema` — the entire `live:` block. Fields: `objective`, `active` (default true), `triggers?`, `model?`, `provider?`. **Runtime-managed (never hand-write):** `lastAttemptAt`, `lastRunAt`, `lastRunId`, `lastRunSummary`, `lastRunError`. -- `TriggersSchema` — single object with three optional sub-fields: `cronExpr`, `windows`, `eventMatchCriteria`. Each window is `{ startTime, endTime }` (24-hour HH:MM, local). -- `KnowledgeEventSchema` — the on-disk shape of each event JSON in `events/pending/` and `events/done/`. Enrichment fields (`processedAt`, `candidateFilePaths`, `runIds`, `error`) are populated when moving to `done/`. -- `Pass1OutputSchema` — `{ filePaths: string[] }`. - -The skill's Canonical Schema block is auto-generated at module load — `stringifyYaml(z.toJSONSchema(LiveNoteSchema))` — so editing `LiveNoteSchema` propagates to the skill on the next build. - ---- - -## Body Structure - -The agent owns the entire body below the H1. There is **no formal section ownership** anymore — the agent edits, reorganizes, and dedupes freely. - -The contract (defined in the run-agent system prompt — `packages/core/src/knowledge/live-note/agent.ts`): - -- **Defaults** (used when the objective doesn't specify a layout): - - H1 stays the note title. - - First, a 1-3 sentence rolling summary capturing the current state. - - 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. -- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. - ---- - -## Default Note Policy - -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. - -**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start: - -- 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. - ---- - -## Renderer UI - -- **Toolbar pill** — `apps/renderer/src/components/editor-toolbar.tsx`. A Radio-icon pill with a state-dependent label, top-right of the editor toolbar. `markdown-editor.tsx` derives the state via `useLiveNoteForPath(notePath)` and passes a `LivePillState` prop: - - `passive` → muted "Make live" label. - - `idle` → "Live · 5 m" using `formatRelativeTime(lastRunAt)`. - - `running` → "Updating…" with `animate-pulse` and a soft `bg-primary/10` highlight. - - `error` → "Live · failed 5 m" in amber, off `lastAttemptAt`. - Click dispatches `rowboat:open-live-note-panel` with `{ filePath }`. The hook ticks once a minute so the relative-time label stays fresh while the user has the editor open. -- **Panel** — `apps/renderer/src/components/live-note-sidebar.tsx`. Right-anchored, mounted once in `App.tsx`. Self-listens for `rowboat:open-live-note-panel`; on open, calls `live-note:get` and renders. All mutations go through IPC. - - Constant top header: Radio icon, "Live note" title, note name subtitle, X close. - - Empty state (passive): "Make this note live" CTA — hands off to Copilot via `rowboat:open-copilot-edit-live-note`. - - Editor (live): status row (schedule summary + active toggle — pulses with `animate-pulse` and `bg-primary/10` while running, label flips to "Updating…"), persistent error banner showing `lastRunError` until the next successful run, objective textarea, triggers editor (cron field + windows list + eventMatchCriteria textarea, each independently add/remove), last-run details, Advanced (collapsed; model + provider), footer (Edit with Copilot · Save · Run now / Stop), danger-zone "Make passive". The footer's primary action toggles between Run-now (idle) and Stop (running, destructive variant) — Stop calls `live-note:stop`. -- **Status hook** — `apps/renderer/src/hooks/use-live-note-agent-status.ts`. Subscribes to `live-note-agent:events` IPC and maintains a `Map`. -- **Live-state hook** — `apps/renderer/src/hooks/use-live-note-for-path.ts`. Fetches `live-note:get` on mount, refetches when the agent run completes (so `lastRunAt` / `lastRunSummary` / `lastRunError` are fresh), refetches when the file changes externally, and ticks once a minute for relative-time labels. Used by the markdown editor (toolbar pill) and could be reused by anyone needing reactive live-note state for a single path. -- **Edit-with-Copilot flow** — panel dispatches `rowboat:open-copilot-edit-live-note` (App.tsx listener handles it via `submitFromPalette`). -- **FrontmatterProperties safety** — `apps/renderer/src/lib/frontmatter.ts` has `STRUCTURED_KEYS = new Set(['live'])`. `extractAllFrontmatterValues` filters those keys out (so they never appear in the editable property list), and `buildFrontmatter(fields, preserveRaw)` splices the original `live:` block back from `preserveRaw` on save. - ---- - -## Prompts Catalog - -Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd apps/x && npm run deps` to rebuild the affected package, then restart the app. - -### 1. Routing system prompt (Pass 1 classifier) - -- **Purpose**: decide which live notes *might* be relevant to an incoming event. Liberal — prefers false positives; the live-note agent does Pass 2. -- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`ROUTING_SYSTEM_PROMPT`). -- **Output**: structured `Pass1OutputSchema` — `{ filePaths: string[] }`. -- **Invoked by**: `findCandidates()` per batch of 20 notes via `generateObject({ model, system, prompt, schema })`. - -### 2. Routing user prompt template - -- **Purpose**: formats the event and the current batch of live notes into the user message for Pass 1. -- **File**: `packages/core/src/knowledge/live-note/routing.ts` (`buildRoutingPrompt`). -- **Inputs**: `event` (source, type, createdAt, payload), `batch: ParsedLiveNote[]` (each: `filePath`, `objective`, `eventMatchCriteria`). -- **Output**: plain text, two sections — `## Event` and `## Live notes`. - -### 3. Live-note agent instructions - -- **Purpose**: system prompt for the background agent that rewrites note bodies. Sets tone, defines the default body structure, prescribes patch-style updates, points at the knowledge graph. -- **File**: `packages/core/src/knowledge/live-note/agent.ts` (`LIVE_NOTE_AGENT_INSTRUCTIONS`). -- **Inputs**: `${WorkDir}` template literal substituted at module load. -- **Output**: free-form — agent calls tools, ends with a 1-2 sentence summary used as `lastRunSummary`. -- **Invoked by**: `buildLiveNoteAgent()`, called during agent runtime setup. Tool set = all `BuiltinTools` except `executeCommand`. - -### 4. Live-note agent message (`buildMessage`) - -- **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. - -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. -- **`timed`** — same as `manual`. Called by the scheduler with no `context`. -- **`event`** — adds a Pass 2 decision block listing the note's `eventMatchCriteria` and the event payload, with the directive to skip the edit if the event isn't truly relevant. - -### 5. Live Note skill (Copilot-facing) - -- **Purpose**: teaches Copilot the `live:` model — operational posture (act-first), the strong/medium/anti-signal taxonomy and how to act on each, the **always-extend-not-fork** rule for already-live notes, user-facing language (call them "live notes"; surface the **Live Note panel** by name), the auto-run-once-on-create/edit default, schema, triggers, YAML-safety rules, insertion workflow, and the `run-live-note-agent` tool with `context` backfills. -- **File**: `packages/core/src/application/assistant/skills/live-note/skill.ts`. Exported `skill` constant. -- **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`. - -### 6. Copilot trigger paragraph - -- **Purpose**: tells Copilot *when* to load the `live-note` skill, and frames how aggressively to act once loaded. -- **File**: `packages/core/src/application/assistant/instructions.ts` (look for the "Live Notes" paragraph). -- **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. -- **Extend-not-fork rule**: explicit guidance — "if the note is already live, extend its existing objective in natural language; never create a second objective." - -### 7. `run-live-note-agent` tool — `context` parameter description - -- **Purpose**: a mini-prompt (a Zod `.describe()`) that guides Copilot on when to pass extra context for a run. -- **File**: `packages/core/src/application/lib/builtin-tools.ts` (the `run-live-note-agent` tool definition). -- **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. - -### 8. Calendar sync digest (event payload template) - -- **Purpose**: shapes what the routing classifier sees for a calendar event. Not a system prompt, but a deterministic markdown template that becomes `event.payload`. -- **File**: `packages/core/src/knowledge/sync_calendar.ts` (`summarizeCalendarSync`, wrapped by `publishCalendarSyncEvent()`). -- **Output**: markdown with a counts header, `## Changed events` (per-event block: title, ID, time, organizer, location, attendees, truncated description), `## Deleted event IDs`. Capped at ~50 events; descriptions truncated to 500 chars. -- **Why care**: the quality of Pass 1 matching depends on how clear this payload is. - ---- - -## Logging - -All live-note logs use the `PrefixLogger` with the prefix `LiveNote:` so they're greppable as a group. Every component logs lifecycle events at one consistent level. - -| Component | Prefix | What it logs | -|---|---|---| -| Scheduler | `LiveNote:Scheduler` | One tick summary per tick when work happened (`tick — scanned N md, K live, fired J, backoff M`). Per-note ` — firing (matched cron)` and ` — skip (matched window, backoff 4m remaining)`. Quiet when no live notes or none due. | -| Agent (runner) | `LiveNote:Agent` | ` — start trigger=cron runId=…`, ` — done action=replace summary="…"` (truncated to 120 chars), ` — failed: `, ` — skip: already running`. | -| Routing | `LiveNote:Routing` | `event: — routing against N live notes`, `event: — Pass1 → K candidates: a.md, b.md`, `event: — Pass1 batch X failed: …`. | -| Events | `LiveNote:Events` | `event: — received source=gmail type=email.synced`, `event: — dispatching to K candidates: …`, `event: — processed ok=2 errors=0`. | -| Fileops | (only logs failures) | Lock contention or write errors. Otherwise silent. | - -Conventions: -- Lower-case verbs (`firing`, `skip`, `done`, `failed`) so lines scan visually. -- File path is always the second token where applicable. -- Run summaries truncated to 120 chars with a single `…` so log lines stay under terminal-width. -- Scheduler emits *one* tick summary per tick, not a row per note. Per-note rows only when something fires or hits a notable skip. - -## File Map - -| Purpose | File | -|---|---| -| Zod schemas (live note, triggers, events, Pass1) | `packages/shared/src/live-note.ts` | -| IPC channel schemas | `packages/shared/src/ipc.ts` | -| IPC handlers (main process) | `apps/main/src/ipc.ts` | -| Frontmatter helpers (parse / split / join) | `packages/core/src/application/lib/parse-frontmatter.ts` | -| File operations (`fetchLiveNote`, `setLiveNote`, `patchLiveNote`, `deleteLiveNote`, `setLiveNoteActive`, `readNoteBody`, `listLiveNotes`) | `packages/core/src/knowledge/live-note/fileops.ts` | -| Scheduler (cron / windows) | `packages/core/src/knowledge/live-note/scheduler.ts` | -| Trigger due-check helper (`computeNextDue` / `dueTimedTrigger`) | `packages/core/src/knowledge/live-note/schedule-utils.ts` | -| Event producer + consumer loop | `packages/core/src/knowledge/live-note/events.ts` | -| Pass 1 routing (LLM classifier) | `packages/core/src/knowledge/live-note/routing.ts` | -| 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` | -| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | -| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | -| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | -| Skill registration | `packages/core/src/application/assistant/skills/index.ts` | -| Copilot trigger paragraph | `packages/core/src/application/assistant/instructions.ts` | -| `run-live-note-agent` builtin tool | `packages/core/src/application/lib/builtin-tools.ts` | -| 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` | -| Renderer frontmatter helper (preserves `live:`) | `apps/renderer/src/lib/frontmatter.ts` | -| App-level listeners (panel open + Copilot edit) | `apps/renderer/src/App.tsx` | -| Live Notes view (sidebar nav target) | `apps/renderer/src/components/live-notes-view.tsx` | -| CSS (panel styles, legacy filenames) | `apps/renderer/src/styles/live-note-panel.css`, `apps/renderer/src/styles/editor.css` | -| Main process startup (schedulers & processors) | `apps/main/src/main.ts` | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..2444e356 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,13 +10,11 @@ */ import * as esbuild from 'esbuild'; -import { readFile } from 'node:fs/promises'; // 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'], @@ -33,12 +31,6 @@ await esbuild.build({ // Replace import.meta.url directly with our polyfill variable define: { 'import.meta.url': '__import_meta_url', - // Inject PostHog credentials at build time. Reuse the renderer's - // VITE_PUBLIC_* envs so packaging only needs one set of values. - // 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 ?? ''), }, }); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7806f6cd..178cb7e1 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -11,9 +11,6 @@ module.exports = { icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', appCategoryType: 'public.app-category.productivity', - protocols: [ - { name: 'Rowboat', schemes: ['rowboat'] }, - ], extendInfo: { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, @@ -56,7 +53,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'), }) }, { @@ -67,9 +63,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' } }) }, @@ -80,9 +74,7 @@ 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' } } }, 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/package.json b/apps/x/apps/main/package.json index 3330c3c0..74cb1598 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,8 +13,6 @@ "make": "electron-forge make" }, "dependencies": { - "@agentclientprotocol/claude-agent-acp": "^0.39.0", - "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 5c46ca3f..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,8 +19,13 @@ export interface AuthServerResult { port: number; } -function tryBindPort( - port: number, +/** + * 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) => { @@ -33,7 +37,7 @@ function tryBindPort( } const url = new URL(req.url, `http://localhost:${port}`); - + if (url.pathname === OAUTH_CALLBACK_PATH) { const error = url.searchParams.get('error'); @@ -92,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); } @@ -103,51 +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). - */ -export async function createAuthServer( - port: number = DEFAULT_PORT, - onCallback: (callbackUrl: URL) => void | Promise, - opts: { fallback?: boolean } = {}, -): Promise { - const fallback = opts.fallback === true; - const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port; - - for (let p = port; p <= limit; p++) { - try { - return await tryBindPort(p, onCallback); - } 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/control-service.ts b/apps/x/apps/main/src/browser/control-service.ts deleted file mode 100644 index 7c97ea7a..00000000 --- a/apps/x/apps/main/src/browser/control-service.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { IBrowserControlService } from '@x/core/dist/application/browser-control/service.js'; -import type { BrowserControlAction, BrowserControlInput, BrowserControlResult, SuggestedBrowserSkill } from '@x/shared/dist/browser-control.js'; -import { ensureLoaded, matchSkillsForUrl } from '@x/core/dist/application/browser-skills/index.js'; -import { browserViewManager } from './view.js'; -import { normalizeNavigationTarget } from './navigation.js'; - -async function getSuggestedSkills(url: string | undefined): Promise { - if (!url) return undefined; - try { - const status = await ensureLoaded(); - if (status.status === 'ready' || status.status === 'stale') { - const matched = matchSkillsForUrl(status.index, url); - if (matched.length === 0) return undefined; - return matched.map((e) => ({ id: e.id, title: e.title, path: e.path })); - } - } catch (err) { - console.warn('[browser-control] suggestedSkills lookup failed:', err); - } - return undefined; -} - -function buildSuccessResult( - action: BrowserControlAction, - message: string, - page?: BrowserControlResult['page'], -): BrowserControlResult { - return { - success: true, - action, - message, - browser: browserViewManager.getState(), - ...(page ? { page } : {}), - }; -} - -function buildErrorResult(action: BrowserControlAction, error: string): BrowserControlResult { - return { - success: false, - action, - error, - browser: browserViewManager.getState(), - }; -} - -export class ElectronBrowserControlService implements IBrowserControlService { - async execute( - input: BrowserControlInput, - ctx?: { signal?: AbortSignal }, - ): Promise { - const signal = ctx?.signal; - - try { - switch (input.action) { - case 'open': { - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('open', 'Opened a browser session.', page); - } - - case 'get-state': - return buildSuccessResult('get-state', 'Read the current browser state.'); - - case 'new-tab': { - const target = input.target ? normalizeNavigationTarget(input.target) : undefined; - const result = await browserViewManager.newTab(target); - if (!result.ok) { - return buildErrorResult('new-tab', result.error ?? 'Failed to open a new tab.'); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - const suggestedSkills = await getSuggestedSkills(page?.url); - const success = buildSuccessResult( - 'new-tab', - target ? `Opened a new tab for ${target}.` : 'Opened a new tab.', - page, - ); - return suggestedSkills ? { ...success, suggestedSkills } : success; - } - - case 'switch-tab': { - const tabId = input.tabId; - if (!tabId) { - return buildErrorResult('switch-tab', 'tabId is required for switch-tab.'); - } - const result = browserViewManager.switchTab(tabId); - if (!result.ok) { - return buildErrorResult('switch-tab', `No browser tab exists with id ${tabId}.`); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('switch-tab', `Switched to tab ${tabId}.`, page); - } - - case 'close-tab': { - const tabId = input.tabId; - if (!tabId) { - return buildErrorResult('close-tab', 'tabId is required for close-tab.'); - } - const result = browserViewManager.closeTab(tabId); - if (!result.ok) { - return buildErrorResult('close-tab', `Could not close tab ${tabId}.`); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('close-tab', `Closed tab ${tabId}.`, page); - } - - case 'navigate': { - const rawTarget = input.target; - if (!rawTarget) { - return buildErrorResult('navigate', 'target is required for navigate.'); - } - const target = normalizeNavigationTarget(rawTarget); - const result = await browserViewManager.navigate(target); - if (!result.ok) { - return buildErrorResult('navigate', result.error ?? `Failed to navigate to ${target}.`); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - const suggestedSkills = await getSuggestedSkills(page?.url); - const success = buildSuccessResult('navigate', `Navigated to ${target}.`, page); - return suggestedSkills ? { ...success, suggestedSkills } : success; - } - - case 'back': { - const result = browserViewManager.back(); - if (!result.ok) { - return buildErrorResult('back', 'The active tab cannot go back.'); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('back', 'Went back in the active tab.', page); - } - - case 'forward': { - const result = browserViewManager.forward(); - if (!result.ok) { - return buildErrorResult('forward', 'The active tab cannot go forward.'); - } - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('forward', 'Went forward in the active tab.', page); - } - - case 'reload': { - browserViewManager.reload(); - await browserViewManager.ensureActiveTabReady(signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('reload', 'Reloaded the active tab.', page); - } - - case 'read-page': { - const result = await browserViewManager.readPage( - { - maxElements: input.maxElements, - maxTextLength: input.maxTextLength, - }, - signal, - ); - if (!result.ok || !result.page) { - return buildErrorResult('read-page', result.error ?? 'Failed to read the current page.'); - } - const suggestedSkills = await getSuggestedSkills(result.page.url); - const success = buildSuccessResult('read-page', 'Read the current page.', result.page); - return suggestedSkills ? { ...success, suggestedSkills } : success; - } - - case 'click': { - const result = await browserViewManager.click( - { - index: input.index, - selector: input.selector, - snapshotId: input.snapshotId, - }, - signal, - ); - if (!result.ok) { - return buildErrorResult('click', result.error ?? 'Failed to click the requested element.'); - } - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( - 'click', - result.description ? `Clicked ${result.description}.` : 'Clicked the requested element.', - page, - ); - } - - case 'type': { - const text = input.text; - if (text === undefined) { - return buildErrorResult('type', 'text is required for type.'); - } - const result = await browserViewManager.type( - { - index: input.index, - selector: input.selector, - snapshotId: input.snapshotId, - }, - text, - signal, - ); - if (!result.ok) { - return buildErrorResult('type', result.error ?? 'Failed to type into the requested element.'); - } - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( - 'type', - result.description ? `Typed into ${result.description}.` : 'Typed into the requested element.', - page, - ); - } - - case 'press': { - const key = input.key; - if (!key) { - return buildErrorResult('press', 'key is required for press.'); - } - const result = await browserViewManager.press( - key, - { - index: input.index, - selector: input.selector, - snapshotId: input.snapshotId, - }, - signal, - ); - if (!result.ok) { - return buildErrorResult('press', result.error ?? `Failed to press ${key}.`); - } - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult( - 'press', - result.description ? `Pressed ${result.description}.` : `Pressed ${key}.`, - page, - ); - } - - case 'scroll': { - const result = await browserViewManager.scroll( - input.direction ?? 'down', - input.amount ?? 700, - signal, - ); - if (!result.ok) { - return buildErrorResult('scroll', result.error ?? 'Failed to scroll the page.'); - } - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('scroll', `Scrolled ${input.direction ?? 'down'}.`, page); - } - - case 'wait': { - const duration = input.ms ?? 1000; - await browserViewManager.wait(duration, signal); - const page = await browserViewManager.readPageSummary(signal, { waitForReady: false }) ?? undefined; - return buildSuccessResult('wait', `Waited ${duration}ms for the page to settle.`, page); - } - } - } catch (error) { - return buildErrorResult( - input.action, - error instanceof Error ? error.message : 'Browser control failed unexpectedly.', - ); - } - } -} diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts deleted file mode 100644 index fa3b1ac1..00000000 --- a/apps/x/apps/main/src/browser/ipc.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { BrowserWindow } from 'electron'; -import { ipc } from '@x/shared'; -import { browserViewManager, type BrowserState } from './view.js'; - -type IPCChannels = ipc.IPCChannels; - -type InvokeHandler = ( - event: Electron.IpcMainInvokeEvent, - args: IPCChannels[K]['req'], -) => IPCChannels[K]['res'] | Promise; - -type BrowserHandlers = { - 'browser:setBounds': InvokeHandler<'browser:setBounds'>; - 'browser:setVisible': InvokeHandler<'browser:setVisible'>; - 'browser:newTab': InvokeHandler<'browser:newTab'>; - 'browser:switchTab': InvokeHandler<'browser:switchTab'>; - 'browser:closeTab': InvokeHandler<'browser:closeTab'>; - 'browser:navigate': InvokeHandler<'browser:navigate'>; - 'browser:back': InvokeHandler<'browser:back'>; - 'browser:forward': InvokeHandler<'browser:forward'>; - 'browser:reload': InvokeHandler<'browser:reload'>; - 'browser:getState': InvokeHandler<'browser:getState'>; -}; - -/** - * Browser-specific IPC handlers, exported as a plain object so they can be - * spread into the main `registerIpcHandlers({...})` call in ipc.ts. This - * mirrors the convention of keeping feature handlers flat and namespaced by - * channel prefix (`browser:*`). - */ -export const browserIpcHandlers: BrowserHandlers = { - 'browser:setBounds': async (_event, args) => { - browserViewManager.setBounds(args); - return { ok: true }; - }, - 'browser:setVisible': async (_event, args) => { - browserViewManager.setVisible(args.visible); - return { ok: true }; - }, - 'browser:newTab': async (_event, args) => { - return browserViewManager.newTab(args.url); - }, - 'browser:switchTab': async (_event, args) => { - return browserViewManager.switchTab(args.tabId); - }, - 'browser:closeTab': async (_event, args) => { - return browserViewManager.closeTab(args.tabId); - }, - 'browser:navigate': async (_event, args) => { - return browserViewManager.navigate(args.url); - }, - 'browser:back': async () => { - return browserViewManager.back(); - }, - 'browser:forward': async () => { - return browserViewManager.forward(); - }, - 'browser:reload': async () => { - browserViewManager.reload(); - return { ok: true }; - }, - 'browser:getState': async () => { - return browserViewManager.getState(); - }, -}; - -/** - * Wire the BrowserViewManager's state-updated event to all renderer windows - * as a `browser:didUpdateState` push. Must be called once after the main - * window is created so the manager has a window to attach to. - */ -export function setupBrowserEventForwarding(): void { - browserViewManager.on('state-updated', (state: BrowserState) => { - 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/navigation.ts b/apps/x/apps/main/src/browser/navigation.ts deleted file mode 100644 index ac840956..00000000 --- a/apps/x/apps/main/src/browser/navigation.ts +++ /dev/null @@ -1,41 +0,0 @@ -const SEARCH_ENGINE_BASE_URL = 'https://www.google.com/search?q='; - -const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; -const IPV4_HOST_RE = /^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/.*)?$/; -const LOCALHOST_RE = /^localhost(?::\d+)?(?:\/.*)?$/i; -const DOMAIN_LIKE_RE = /^[\w.-]+\.[a-z]{2,}(?::\d+)?(?:\/.*)?$/i; - -export function normalizeNavigationTarget(target: string): string { - const trimmed = target.trim(); - if (!trimmed) { - throw new Error('Navigation target cannot be empty.'); - } - - const lower = trimmed.toLowerCase(); - if ( - lower.startsWith('javascript:') - || lower.startsWith('file://') - || lower.startsWith('chrome://') - || lower.startsWith('chrome-extension://') - ) { - throw new Error('That URL scheme is not allowed in the embedded browser.'); - } - - if (HAS_SCHEME_RE.test(trimmed)) { - return trimmed; - } - - const looksLikeHost = - LOCALHOST_RE.test(trimmed) - || DOMAIN_LIKE_RE.test(trimmed) - || IPV4_HOST_RE.test(trimmed); - - if (looksLikeHost && !/\s/.test(trimmed)) { - const scheme = LOCALHOST_RE.test(trimmed) || IPV4_HOST_RE.test(trimmed) - ? 'http://' - : 'https://'; - return `${scheme}${trimmed}`; - } - - return `${SEARCH_ENGINE_BASE_URL}${encodeURIComponent(trimmed)}`; -} diff --git a/apps/x/apps/main/src/browser/page-scripts.ts b/apps/x/apps/main/src/browser/page-scripts.ts deleted file mode 100644 index fc079327..00000000 --- a/apps/x/apps/main/src/browser/page-scripts.ts +++ /dev/null @@ -1,546 +0,0 @@ -import type { BrowserPageElement } from '@x/shared/dist/browser-control.js'; - -const INTERACTABLE_SELECTORS = [ - 'a[href]', - 'button', - 'input', - 'textarea', - 'select', - 'summary', - '[role="button"]', - '[role="link"]', - '[role="tab"]', - '[role="menuitem"]', - '[role="option"]', - '[contenteditable="true"]', - '[tabindex]:not([tabindex="-1"])', -].join(', '); - -const CLICKABLE_TARGET_SELECTORS = [ - 'a[href]', - 'button', - 'summary', - 'label', - 'input', - 'textarea', - 'select', - '[role="button"]', - '[role="link"]', - '[role="tab"]', - '[role="menuitem"]', - '[role="option"]', - '[role="checkbox"]', - '[role="radio"]', - '[role="switch"]', - '[role="menuitemcheckbox"]', - '[role="menuitemradio"]', - '[aria-pressed]', - '[aria-expanded]', - '[aria-checked]', - '[contenteditable="true"]', - '[tabindex]:not([tabindex="-1"])', -].join(', '); - -const DOM_HELPERS_SOURCE = String.raw` -const truncateText = (value, max) => { - const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (!normalized) return ''; - if (normalized.length <= max) return normalized; - const safeMax = Math.max(0, max - 3); - return normalized.slice(0, safeMax).trim() + '...'; -}; - -const cssEscapeValue = (value) => { - if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { - return CSS.escape(value); - } - return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\' + char); -}; - -const isVisibleElement = (element) => { - if (!(element instanceof Element)) return false; - const style = window.getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { - return false; - } - if (element.getAttribute('aria-hidden') === 'true') return false; - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -}; - -const isDisabledElement = (element) => { - if (!(element instanceof Element)) return true; - if (element.getAttribute('aria-disabled') === 'true') return true; - return 'disabled' in element && Boolean(element.disabled); -}; - -const isUselessClickTarget = (element) => ( - element === document.body - || element === document.documentElement -); - -const getElementRole = (element) => { - const explicitRole = element.getAttribute('role'); - if (explicitRole) return explicitRole; - if (element instanceof HTMLAnchorElement) return 'link'; - if (element instanceof HTMLButtonElement) return 'button'; - if (element instanceof HTMLInputElement) return element.type === 'checkbox' ? 'checkbox' : 'input'; - if (element instanceof HTMLTextAreaElement) return 'textbox'; - if (element instanceof HTMLSelectElement) return 'combobox'; - if (element instanceof HTMLElement && element.isContentEditable) return 'textbox'; - return null; -}; - -const getElementType = (element) => { - if (element instanceof HTMLInputElement) return element.type || 'text'; - if (element instanceof HTMLTextAreaElement) return 'textarea'; - if (element instanceof HTMLSelectElement) return 'select'; - if (element instanceof HTMLButtonElement) return 'button'; - if (element instanceof HTMLElement && element.isContentEditable) return 'contenteditable'; - return null; -}; - -const getElementLabel = (element) => { - const ariaLabel = truncateText(element.getAttribute('aria-label') ?? '', 120); - if (ariaLabel) return ariaLabel; - - if ('labels' in element && element.labels && element.labels.length > 0) { - const labelText = truncateText( - Array.from(element.labels).map((label) => label.innerText || label.textContent || '').join(' '), - 120, - ); - if (labelText) return labelText; - } - - if (element.id) { - const label = document.querySelector('label[for="' + cssEscapeValue(element.id) + '"]'); - const labelText = truncateText(label?.textContent ?? '', 120); - if (labelText) return labelText; - } - - const placeholder = truncateText(element.getAttribute('placeholder') ?? '', 120); - if (placeholder) return placeholder; - - const text = truncateText( - element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement - ? element.value - : element.textContent ?? '', - 120, - ); - return text || null; -}; - -const describeElement = (element) => { - const role = getElementRole(element) || element.tagName.toLowerCase(); - const label = getElementLabel(element); - return label ? role + ' "' + label + '"' : role; -}; - -const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max); - -const getAssociatedControl = (element) => { - if (!(element instanceof Element)) return null; - if (element instanceof HTMLLabelElement) return element.control; - const parentLabel = element.closest('label'); - return parentLabel instanceof HTMLLabelElement ? parentLabel.control : null; -}; - -const resolveClickTarget = (element) => { - if (!(element instanceof Element)) return null; - - const clickableAncestor = element.closest(${JSON.stringify(CLICKABLE_TARGET_SELECTORS)}); - const labelAncestor = element.closest('label'); - const associatedControl = getAssociatedControl(element); - const candidates = [clickableAncestor, labelAncestor, associatedControl, element]; - - for (const candidate of candidates) { - if (!(candidate instanceof Element)) continue; - if (isUselessClickTarget(candidate)) continue; - if (!isVisibleElement(candidate)) continue; - if (isDisabledElement(candidate)) continue; - return candidate; - } - - for (const candidate of candidates) { - if (candidate instanceof Element) return candidate; - } - - return null; -}; - -const getVerificationTargetState = (element) => { - if (!(element instanceof Element)) return null; - - const text = truncateText(element.innerText || element.textContent || '', 200); - const activeElement = document.activeElement; - const isActive = - activeElement instanceof Element - ? activeElement === element || element.contains(activeElement) - : false; - - return { - selector: buildUniqueSelector(element), - descriptor: describeElement(element), - text: text || null, - checked: - element instanceof HTMLInputElement && (element.type === 'checkbox' || element.type === 'radio') - ? element.checked - : null, - value: - element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement - ? truncateText(element.value ?? '', 200) - : element instanceof HTMLSelectElement - ? truncateText(element.value ?? '', 200) - : element instanceof HTMLElement && element.isContentEditable - ? truncateText(element.innerText || element.textContent || '', 200) - : null, - selectedIndex: element instanceof HTMLSelectElement ? element.selectedIndex : null, - open: - 'open' in element && typeof element.open === 'boolean' - ? element.open - : null, - disabled: isDisabledElement(element), - active: isActive, - ariaChecked: element.getAttribute('aria-checked'), - ariaPressed: element.getAttribute('aria-pressed'), - ariaExpanded: element.getAttribute('aria-expanded'), - }; -}; - -const getPageVerificationState = () => { - const activeElement = document.activeElement instanceof Element ? document.activeElement : null; - return { - url: window.location.href, - title: document.title || '', - textSample: truncateText(document.body?.innerText || document.body?.textContent || '', 2000), - activeSelector: activeElement ? buildUniqueSelector(activeElement) : null, - }; -}; - -const buildUniqueSelector = (element) => { - if (!(element instanceof Element)) return null; - - if (element.id) { - const idSelector = '#' + cssEscapeValue(element.id); - try { - if (document.querySelectorAll(idSelector).length === 1) return idSelector; - } catch {} - } - - const segments = []; - let current = element; - while (current && current instanceof Element && current !== document.documentElement) { - const tag = current.tagName.toLowerCase(); - if (!tag) break; - - let segment = tag; - const name = current.getAttribute('name'); - if (name) { - const nameSelector = tag + '[name="' + cssEscapeValue(name) + '"]'; - try { - if (document.querySelectorAll(nameSelector).length === 1) { - segments.unshift(nameSelector); - return segments.join(' > '); - } - } catch {} - } - - const parent = current.parentElement; - if (parent) { - const sameTagSiblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); - const position = sameTagSiblings.indexOf(current) + 1; - segment += ':nth-of-type(' + position + ')'; - } - - segments.unshift(segment); - const selector = segments.join(' > '); - try { - if (document.querySelectorAll(selector).length === 1) return selector; - } catch {} - - current = current.parentElement; - } - - return segments.length > 0 ? segments.join(' > ') : null; -}; -`; - -type RawBrowserPageElement = BrowserPageElement & { - selector: string; -}; - -export type RawBrowserPageSnapshot = { - url: string; - title: string; - loading: boolean; - text: string; - elements: RawBrowserPageElement[]; -}; - -export type ElementTarget = { - index?: number; - selector?: string; - snapshotId?: string; -}; - -export function buildReadPageScript(maxElements: number, maxTextLength: number): string { - return `(() => { - ${DOM_HELPERS_SOURCE} - const candidates = Array.from(document.querySelectorAll(${JSON.stringify(INTERACTABLE_SELECTORS)})); - const elements = []; - const seenSelectors = new Set(); - - for (const candidate of candidates) { - if (!(candidate instanceof Element)) continue; - if (!isVisibleElement(candidate)) continue; - - const selector = buildUniqueSelector(candidate); - if (!selector || seenSelectors.has(selector)) continue; - seenSelectors.add(selector); - - elements.push({ - index: elements.length + 1, - selector, - tagName: candidate.tagName.toLowerCase(), - role: getElementRole(candidate), - type: getElementType(candidate), - label: getElementLabel(candidate), - text: truncateText(candidate.innerText || candidate.textContent || '', 120) || null, - placeholder: truncateText(candidate.getAttribute('placeholder') ?? '', 120) || null, - href: candidate instanceof HTMLAnchorElement ? candidate.href : candidate.getAttribute('href'), - disabled: isDisabledElement(candidate), - }); - - if (elements.length >= ${JSON.stringify(maxElements)}) break; - } - - return { - url: window.location.href, - title: document.title || '', - loading: document.readyState !== 'complete', - text: truncateText(document.body?.innerText || document.body?.textContent || '', ${JSON.stringify(maxTextLength)}), - elements, - }; - })()`; -} - -export function buildClickScript(selector: string): string { - return `(() => { - ${DOM_HELPERS_SOURCE} - const requestedSelector = ${JSON.stringify(selector)}; - if (/^(body|html)$/i.test(requestedSelector.trim())) { - return { - ok: false, - error: 'Refusing to click the page body. Read the page again and target a specific element.', - }; - } - - const element = document.querySelector(requestedSelector); - if (!(element instanceof Element)) { - return { ok: false, error: 'Element not found.' }; - } - if (isUselessClickTarget(element)) { - return { - ok: false, - error: 'Refusing to click the page body. Read the page again and target a specific element.', - }; - } - - const target = resolveClickTarget(element); - if (!(target instanceof Element)) { - return { ok: false, error: 'Could not resolve a clickable target.' }; - } - if (isUselessClickTarget(target)) { - return { - ok: false, - error: 'Resolved click target was too generic. Read the page again and choose a specific control.', - }; - } - if (!isVisibleElement(target)) { - return { ok: false, error: 'Resolved click target is not visible.' }; - } - if (isDisabledElement(target)) { - return { ok: false, error: 'Resolved click target is disabled.' }; - } - - const before = { - page: getPageVerificationState(), - target: getVerificationTargetState(target), - }; - - if (target instanceof HTMLElement) { - target.scrollIntoView({ block: 'center', inline: 'center' }); - target.focus({ preventScroll: true }); - } - - const rect = target.getBoundingClientRect(); - const clientX = clampNumber(rect.left + (rect.width / 2), 1, Math.max(1, window.innerWidth - 1)); - const clientY = clampNumber(rect.top + (rect.height / 2), 1, Math.max(1, window.innerHeight - 1)); - const topElement = document.elementFromPoint(clientX, clientY); - const eventTarget = - topElement instanceof Element && (topElement === target || topElement.contains(target) || target.contains(topElement)) - ? topElement - : target; - - if (eventTarget instanceof HTMLElement) { - eventTarget.focus({ preventScroll: true }); - } - - return { - ok: true, - description: describeElement(target), - clickPoint: { - x: Math.round(clientX), - y: Math.round(clientY), - }, - verification: { - before, - targetSelector: buildUniqueSelector(target) || requestedSelector, - }, - }; - })()`; -} - -export function buildVerifyClickScript(targetSelector: string | null, before: unknown): string { - return `(() => { - ${DOM_HELPERS_SOURCE} - const beforeState = ${JSON.stringify(before)}; - const selector = ${JSON.stringify(targetSelector)}; - const afterPage = getPageVerificationState(); - const afterTarget = selector ? getVerificationTargetState(document.querySelector(selector)) : null; - const beforeTarget = beforeState?.target ?? null; - const reasons = []; - - if (beforeState?.page?.url !== afterPage.url) reasons.push('url changed'); - if (beforeState?.page?.title !== afterPage.title) reasons.push('title changed'); - if (beforeState?.page?.textSample !== afterPage.textSample) reasons.push('page text changed'); - if (beforeState?.page?.activeSelector !== afterPage.activeSelector) reasons.push('focus changed'); - - if (beforeTarget && !afterTarget) { - reasons.push('clicked element disappeared'); - } - - if (beforeTarget && afterTarget) { - if (beforeTarget.checked !== afterTarget.checked) reasons.push('checked state changed'); - if (beforeTarget.value !== afterTarget.value) reasons.push('value changed'); - if (beforeTarget.selectedIndex !== afterTarget.selectedIndex) reasons.push('selection changed'); - if (beforeTarget.open !== afterTarget.open) reasons.push('open state changed'); - if (beforeTarget.disabled !== afterTarget.disabled) reasons.push('disabled state changed'); - if (beforeTarget.active !== afterTarget.active) reasons.push('target focus changed'); - if (beforeTarget.ariaChecked !== afterTarget.ariaChecked) reasons.push('aria-checked changed'); - if (beforeTarget.ariaPressed !== afterTarget.ariaPressed) reasons.push('aria-pressed changed'); - if (beforeTarget.ariaExpanded !== afterTarget.ariaExpanded) reasons.push('aria-expanded changed'); - if (beforeTarget.text !== afterTarget.text) reasons.push('target text changed'); - } - - return { - changed: reasons.length > 0, - reasons, - }; - })()`; -} - -export function buildTypeScript(selector: string, text: string): string { - return `(() => { - ${DOM_HELPERS_SOURCE} - const element = document.querySelector(${JSON.stringify(selector)}); - if (!(element instanceof Element)) { - return { ok: false, error: 'Element not found.' }; - } - if (!isVisibleElement(element)) { - return { ok: false, error: 'Element is not visible.' }; - } - if (isDisabledElement(element)) { - return { ok: false, error: 'Element is disabled.' }; - } - - const nextValue = ${JSON.stringify(text)}; - - const setNativeValue = (target, value) => { - const prototype = Object.getPrototypeOf(target); - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); - if (descriptor && typeof descriptor.set === 'function') { - descriptor.set.call(target, value); - } else { - target.value = value; - } - }; - - if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { - if (element.readOnly) { - return { ok: false, error: 'Element is read-only.' }; - } - element.scrollIntoView({ block: 'center', inline: 'center' }); - element.focus({ preventScroll: true }); - setNativeValue(element, nextValue); - element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' })); - element.dispatchEvent(new Event('change', { bubbles: true })); - return { ok: true, description: describeElement(element) }; - } - - if (element instanceof HTMLElement && element.isContentEditable) { - element.scrollIntoView({ block: 'center', inline: 'center' }); - element.focus({ preventScroll: true }); - element.textContent = nextValue; - element.dispatchEvent(new InputEvent('input', { bubbles: true, data: nextValue, inputType: 'insertText' })); - return { ok: true, description: describeElement(element) }; - } - - return { ok: false, error: 'Element does not accept text input.' }; - })()`; -} - -export function buildFocusScript(selector: string): string { - return `(() => { - ${DOM_HELPERS_SOURCE} - const element = document.querySelector(${JSON.stringify(selector)}); - if (!(element instanceof Element)) { - return { ok: false, error: 'Element not found.' }; - } - if (!isVisibleElement(element)) { - return { ok: false, error: 'Element is not visible.' }; - } - if (element instanceof HTMLElement) { - element.scrollIntoView({ block: 'center', inline: 'center' }); - element.focus({ preventScroll: true }); - } - return { ok: true, description: describeElement(element) }; - })()`; -} - -export function buildScrollScript(offset: number): string { - return `(() => { - window.scrollBy({ top: ${JSON.stringify(offset)}, left: 0, behavior: 'auto' }); - return { ok: true }; - })()`; -} - -export function normalizeKeyCode(key: string): string { - const trimmed = key.trim(); - if (!trimmed) return 'Enter'; - - const aliases: Record = { - esc: 'Escape', - escape: 'Escape', - return: 'Enter', - enter: 'Enter', - tab: 'Tab', - space: 'Space', - ' ': 'Space', - left: 'ArrowLeft', - right: 'ArrowRight', - up: 'ArrowUp', - down: 'ArrowDown', - arrowleft: 'ArrowLeft', - arrowright: 'ArrowRight', - arrowup: 'ArrowUp', - arrowdown: 'ArrowDown', - backspace: 'Backspace', - delete: 'Delete', - }; - - const alias = aliases[trimmed.toLowerCase()]; - if (alias) return alias; - if (trimmed.length === 1) return trimmed.toUpperCase(); - return trimmed[0].toUpperCase() + trimmed.slice(1); -} diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts deleted file mode 100644 index 90b7d849..00000000 --- a/apps/x/apps/main/src/browser/view.ts +++ /dev/null @@ -1,840 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { EventEmitter } from 'node:events'; -import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron'; -import type { - BrowserPageElement, - BrowserPageSnapshot, - BrowserState, - BrowserTabState, -} from '@x/shared/dist/browser-control.js'; -import { normalizeNavigationTarget } from './navigation.js'; -import { - buildClickScript, - buildFocusScript, - buildReadPageScript, - buildScrollScript, - buildTypeScript, - buildVerifyClickScript, - normalizeKeyCode, - type ElementTarget, - type RawBrowserPageSnapshot, -} from './page-scripts.js'; - -export type { BrowserPageSnapshot, BrowserState, BrowserTabState }; - -/** - * Embedded browser pane implementation. - * - * Each browser tab owns its own WebContentsView. Only the active tab's view is - * attached to the main window at a time, but inactive tabs keep their own page - * history and loaded state in memory so switching tabs feels immediate. - * - * All tabs share one persistent session partition so cookies/localStorage/ - * form-fill state survive app restarts, and the browser surface spoofs a - * standard Chrome UA so sites like Google (OAuth) don't reject it. - */ - -export const BROWSER_PARTITION = 'persist:rowboat-browser'; - -// 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 POST_ACTION_IDLE_MS = 400; -const POST_ACTION_MAX_ELEMENTS = 25; -const POST_ACTION_MAX_TEXT_LENGTH = 4000; -const DEFAULT_READ_MAX_ELEMENTS = 50; -const DEFAULT_READ_MAX_TEXT_LENGTH = 8000; - -export interface BrowserBounds { - x: number; - y: number; - width: number; - height: number; -} - -type BrowserTab = { - id: string; - view: WebContentsView; - domReadyAt: number | null; - loadError: string | null; -}; - -type CachedSnapshot = { - snapshotId: string; - elements: Array<{ index: number; selector: string }>; -}; - -const EMPTY_STATE: BrowserState = { - activeTabId: null, - tabs: [], -}; - -function abortIfNeeded(signal?: AbortSignal): void { - if (!signal?.aborted) return; - throw signal.reason instanceof Error ? signal.reason : new Error('Browser action aborted'); -} - -async function sleep(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0) return; - abortIfNeeded(signal); - await new Promise((resolve, reject) => { - const abortSignal = signal; - const timer = setTimeout(() => { - abortSignal?.removeEventListener('abort', onAbort); - resolve(); - }, ms); - - const onAbort = () => { - clearTimeout(timer); - abortSignal?.removeEventListener('abort', onAbort); - reject(abortSignal?.reason instanceof Error ? abortSignal.reason : new Error('Browser action aborted')); - }; - - abortSignal?.addEventListener('abort', onAbort, { once: true }); - }); -} - - -export class BrowserViewManager extends EventEmitter { - private window: BrowserWindow | null = null; - private browserSession: Session | null = null; - private tabs = new Map(); - private tabOrder: string[] = []; - private activeTabId: string | null = null; - private attachedTabId: string | null = null; - private visible = false; - private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; - private snapshotCache = new Map(); - private cleanupWindowListeners: (() => void) | null = null; - - attach(window: BrowserWindow): void { - this.cleanupWindowListeners?.(); - this.cleanupWindowListeners = null; - this.window = window; - const hostWebContents = window.webContents; - - const resetForHostWindowNavigation = () => { - // Renderer refreshes do not run React unmount cleanup reliably, so the - // native browser view must be detached from the main process side. - this.visible = false; - this.bounds = { x: 0, y: 0, width: 0, height: 0 }; - this.syncAttachedView(); - }; - - const handleDidStartLoading = () => { - resetForHostWindowNavigation(); - }; - - const handleRenderProcessGone = () => { - resetForHostWindowNavigation(); - }; - - const handleClosed = () => { - if (this.window !== window) return; - - const tabs = [...this.tabs.values()]; - this.cleanupWindowListeners = null; - this.window = null; - this.browserSession = null; - this.bounds = { x: 0, y: 0, width: 0, height: 0 }; - for (const tab of tabs) { - this.destroyTab(tab); - } - this.tabs.clear(); - this.tabOrder = []; - this.activeTabId = null; - this.attachedTabId = null; - this.visible = false; - this.snapshotCache.clear(); - }; - - hostWebContents.on('did-start-loading', handleDidStartLoading); - hostWebContents.on('render-process-gone', handleRenderProcessGone); - window.on('closed', handleClosed); - - this.cleanupWindowListeners = () => { - if (!hostWebContents.isDestroyed()) { - hostWebContents.removeListener('did-start-loading', handleDidStartLoading); - hostWebContents.removeListener('render-process-gone', handleRenderProcessGone); - } - if (!window.isDestroyed()) { - window.removeListener('closed', handleClosed); - } - }; - } - - private getSession(): Session { - if (this.browserSession) return this.browserSession; - const browserSession = session.fromPartition(BROWSER_PARTITION); - browserSession.setUserAgent(SPOOF_UA); - this.browserSession = browserSession; - return browserSession; - } - - private emitState(): void { - this.emit('state-updated', this.snapshotState()); - } - - private getTab(tabId: string | null): BrowserTab | null { - if (!tabId) return null; - return this.tabs.get(tabId) ?? null; - } - - private getActiveTab(): BrowserTab | null { - return this.getTab(this.activeTabId); - } - - private invalidateSnapshot(tabId: string): void { - this.snapshotCache.delete(tabId); - } - - private isEmbeddedTabUrl(url: string): boolean { - return /^https?:\/\//i.test(url) || url === 'about:blank'; - } - - private createView(): WebContentsView { - const view = new WebContentsView({ - webPreferences: { - session: this.getSession(), - contextIsolation: true, - sandbox: true, - nodeIntegration: false, - }, - }); - - view.webContents.setUserAgent(SPOOF_UA); - return view; - } - - private wireEvents(tab: BrowserTab): void { - const { id: tabId, view } = tab; - const wc = view.webContents; - - const reapplyBounds = () => { - if ( - this.attachedTabId === tabId && - this.visible && - this.bounds.width > 0 && - this.bounds.height > 0 - ) { - view.setBounds(this.bounds); - } - }; - - const invalidateAndEmit = () => { - this.invalidateSnapshot(tabId); - this.emitState(); - }; - - wc.on('did-start-navigation', (_event, _url, _isInPlace, isMainFrame) => { - if (isMainFrame !== false) { - tab.domReadyAt = null; - tab.loadError = null; - } - this.invalidateSnapshot(tabId); - reapplyBounds(); - }); - wc.on('did-navigate', () => { reapplyBounds(); invalidateAndEmit(); }); - wc.on('did-navigate-in-page', () => { reapplyBounds(); invalidateAndEmit(); }); - wc.on('did-start-loading', () => { - tab.loadError = null; - this.invalidateSnapshot(tabId); - reapplyBounds(); - this.emitState(); - }); - wc.on('did-stop-loading', () => { reapplyBounds(); invalidateAndEmit(); }); - wc.on('did-finish-load', () => { reapplyBounds(); invalidateAndEmit(); }); - wc.on('dom-ready', () => { - tab.domReadyAt = Date.now(); - reapplyBounds(); - invalidateAndEmit(); - }); - wc.on('did-frame-finish-load', reapplyBounds); - wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (isMainFrame && errorCode !== -3) { - const target = validatedURL || wc.getURL() || 'page'; - tab.loadError = errorDescription - ? `Failed to load ${target}: ${errorDescription}.` - : `Failed to load ${target}.`; - } - reapplyBounds(); - invalidateAndEmit(); - }); - wc.on('page-title-updated', this.emitState.bind(this)); - - wc.setWindowOpenHandler(({ url }) => { - if (this.isEmbeddedTabUrl(url)) { - void this.newTab(url); - } else { - void shell.openExternal(url); - } - return { action: 'deny' }; - }); - } - - private snapshotTabState(tab: BrowserTab): BrowserTabState { - const wc = tab.view.webContents; - return { - id: tab.id, - url: wc.getURL(), - title: wc.getTitle(), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - loading: wc.isLoading(), - }; - } - - private syncAttachedView(): void { - if (!this.window) return; - - const contentView = this.window.contentView; - const activeTab = this.getActiveTab(); - - if (!this.visible || !activeTab) { - const attachedTab = this.getTab(this.attachedTabId); - if (attachedTab) { - contentView.removeChildView(attachedTab.view); - } - this.attachedTabId = null; - return; - } - - if (this.attachedTabId && this.attachedTabId !== activeTab.id) { - const attachedTab = this.getTab(this.attachedTabId); - if (attachedTab) { - contentView.removeChildView(attachedTab.view); - } - this.attachedTabId = null; - } - - if (this.attachedTabId !== activeTab.id) { - contentView.addChildView(activeTab.view); - this.attachedTabId = activeTab.id; - } - - if (this.bounds.width > 0 && this.bounds.height > 0) { - activeTab.view.setBounds(this.bounds); - } - } - - private createTab(initialUrl: string): BrowserTab { - if (!this.window) { - throw new Error('BrowserViewManager: no window attached'); - } - - const tabId = randomUUID(); - const tab: BrowserTab = { - id: tabId, - view: this.createView(), - domReadyAt: null, - loadError: null, - }; - - this.wireEvents(tab); - this.tabs.set(tabId, tab); - this.tabOrder.push(tabId); - this.activeTabId = tabId; - this.invalidateSnapshot(tabId); - this.syncAttachedView(); - this.emitState(); - - const targetUrl = - initialUrl === 'about:blank' - ? HOME_URL - : normalizeNavigationTarget(initialUrl); - void tab.view.webContents.loadURL(targetUrl).catch((error) => { - tab.loadError = error instanceof Error - ? error.message - : `Failed to load ${targetUrl}.`; - this.emitState(); - }); - - return tab; - } - - private ensureInitialTab(): BrowserTab { - const activeTab = this.getActiveTab(); - if (activeTab) return activeTab; - return this.createTab(HOME_URL); - } - - private destroyTab(tab: BrowserTab): void { - this.invalidateSnapshot(tab.id); - tab.view.webContents.removeAllListeners(); - if (!tab.view.webContents.isDestroyed()) { - tab.view.webContents.close(); - } - } - - private async waitForWebContentsSettle( - tab: BrowserTab, - signal?: AbortSignal, - idleMs = POST_ACTION_IDLE_MS, - timeoutMs = NAVIGATION_TIMEOUT_MS, - ): Promise { - const wc = tab.view.webContents; - const startedAt = Date.now(); - let sawLoading = wc.isLoading(); - - while (Date.now() - startedAt < timeoutMs) { - abortIfNeeded(signal); - if (wc.isDestroyed()) return; - if (tab.loadError) { - throw new Error(tab.loadError); - } - - if (tab.domReadyAt != null) { - const domReadyForMs = Date.now() - tab.domReadyAt; - const requiredIdleMs = sawLoading ? idleMs : Math.min(idleMs, 200); - if (domReadyForMs >= requiredIdleMs) return; - await sleep(Math.min(100, requiredIdleMs - domReadyForMs), signal); - continue; - } - - if (wc.isLoading()) { - sawLoading = true; - await sleep(100, signal); - continue; - } - - await sleep(sawLoading ? idleMs : Math.min(idleMs, 200), signal); - if (tab.loadError) { - throw new Error(tab.loadError); - } - if (!wc.isLoading() || tab.domReadyAt != null) return; - sawLoading = true; - } - } - - private async executeOnActiveTab( - script: string, - signal?: AbortSignal, - options?: { waitForReady?: boolean }, - ): Promise { - abortIfNeeded(signal); - const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); - if (options?.waitForReady !== false) { - await this.waitForWebContentsSettle(activeTab, signal); - } - abortIfNeeded(signal); - return activeTab.view.webContents.executeJavaScript(script, true) as Promise; - } - - private cacheSnapshot(tabId: string, rawSnapshot: RawBrowserPageSnapshot, loading: boolean): BrowserPageSnapshot { - const snapshotId = randomUUID(); - const elements: BrowserPageElement[] = rawSnapshot.elements.map((element, index) => { - const { selector, ...rest } = element; - void selector; - return { - ...rest, - index: index + 1, - }; - }); - - this.snapshotCache.set(tabId, { - snapshotId, - elements: rawSnapshot.elements.map((element, index) => ({ - index: index + 1, - selector: element.selector, - })), - }); - - return { - snapshotId, - url: rawSnapshot.url, - title: rawSnapshot.title, - loading, - text: rawSnapshot.text, - elements, - }; - } - - private resolveElementSelector(tabId: string, target: ElementTarget): { ok: true; selector: string } | { ok: false; error: string } { - if (target.selector?.trim()) { - return { ok: true, selector: target.selector.trim() }; - } - - if (target.index == null) { - return { ok: false, error: 'Provide an element index or selector.' }; - } - - const cachedSnapshot = this.snapshotCache.get(tabId); - if (!cachedSnapshot) { - return { ok: false, error: 'No page snapshot is available yet. Call read-page first.' }; - } - - if (target.snapshotId && cachedSnapshot.snapshotId !== target.snapshotId) { - return { ok: false, error: 'The page changed since the last read-page call. Call read-page again.' }; - } - - const entry = cachedSnapshot.elements.find((element) => element.index === target.index); - if (!entry) { - return { ok: false, error: `No element found for index ${target.index}.` }; - } - - return { ok: true, selector: entry.selector }; - } - - setVisible(visible: boolean): void { - this.visible = visible; - if (visible) { - this.ensureInitialTab(); - } - this.syncAttachedView(); - } - - setBounds(bounds: BrowserBounds): void { - this.bounds = bounds; - const activeTab = this.getActiveTab(); - if (activeTab && this.attachedTabId === activeTab.id && this.visible) { - activeTab.view.setBounds(bounds); - } - } - - async ensureActiveTabReady(signal?: AbortSignal): Promise { - const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); - await this.waitForWebContentsSettle(activeTab, signal); - } - - async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> { - try { - const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL); - return { ok: true, tabId: tab.id }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - switchTab(tabId: string): { ok: boolean } { - if (!this.tabs.has(tabId)) return { ok: false }; - if (this.activeTabId === tabId) return { ok: true }; - this.activeTabId = tabId; - this.syncAttachedView(); - this.emitState(); - return { ok: true }; - } - - closeTab(tabId: string): { ok: boolean } { - const tab = this.tabs.get(tabId); - if (!tab) return { ok: false }; - if (this.tabOrder.length <= 1) return { ok: false }; - - const closingIndex = this.tabOrder.indexOf(tabId); - const nextActiveTabId = - this.activeTabId === tabId - ? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null - : this.activeTabId; - - if (this.attachedTabId === tabId && this.window) { - this.window.contentView.removeChildView(tab.view); - this.attachedTabId = null; - } - - this.tabs.delete(tabId); - this.tabOrder = this.tabOrder.filter((id) => id !== tabId); - this.activeTabId = nextActiveTabId; - this.destroyTab(tab); - this.syncAttachedView(); - this.emitState(); - - return { ok: true }; - } - - async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> { - try { - const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); - this.invalidateSnapshot(activeTab.id); - await activeTab.view.webContents.loadURL(normalizeNavigationTarget(rawUrl)); - return { ok: true }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - back(): { ok: boolean } { - const activeTab = this.getActiveTab(); - if (!activeTab) return { ok: false }; - const history = activeTab.view.webContents.navigationHistory; - if (!history.canGoBack()) return { ok: false }; - this.invalidateSnapshot(activeTab.id); - history.goBack(); - return { ok: true }; - } - - forward(): { ok: boolean } { - const activeTab = this.getActiveTab(); - if (!activeTab) return { ok: false }; - const history = activeTab.view.webContents.navigationHistory; - if (!history.canGoForward()) return { ok: false }; - this.invalidateSnapshot(activeTab.id); - history.goForward(); - return { ok: true }; - } - - reload(): void { - const activeTab = this.getActiveTab(); - if (!activeTab) return; - this.invalidateSnapshot(activeTab.id); - activeTab.view.webContents.reload(); - } - - async readPage( - options?: { maxElements?: number; maxTextLength?: number; waitForReady?: boolean }, - signal?: AbortSignal, - ): Promise<{ ok: boolean; page?: BrowserPageSnapshot; error?: string }> { - try { - const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); - const rawSnapshot = await this.executeOnActiveTab( - buildReadPageScript( - options?.maxElements ?? DEFAULT_READ_MAX_ELEMENTS, - options?.maxTextLength ?? DEFAULT_READ_MAX_TEXT_LENGTH, - ), - signal, - { waitForReady: options?.waitForReady }, - ); - return { - ok: true, - page: this.cacheSnapshot(activeTab.id, rawSnapshot, activeTab.view.webContents.isLoading()), - }; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to read the current page.', - }; - } - } - - async readPageSummary( - signal?: AbortSignal, - options?: { waitForReady?: boolean }, - ): Promise { - const result = await this.readPage( - { - maxElements: POST_ACTION_MAX_ELEMENTS, - maxTextLength: POST_ACTION_MAX_TEXT_LENGTH, - waitForReady: options?.waitForReady, - }, - signal, - ); - return result.ok ? result.page ?? null : null; - } - - async click(target: ElementTarget, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> { - const activeTab = this.getActiveTab(); - if (!activeTab) { - return { ok: false, error: 'No active browser tab is open.' }; - } - - const resolved = this.resolveElementSelector(activeTab.id, target); - if (!resolved.ok) return resolved; - - try { - const result = await this.executeOnActiveTab<{ - ok: boolean; - error?: string; - description?: string; - clickPoint?: { - x: number; - y: number; - }; - verification?: { - before: unknown; - targetSelector: string | null; - }; - }>( - buildClickScript(resolved.selector), - signal, - ); - if (!result.ok) return result; - if (!result.clickPoint) { - return { - ok: false, - error: 'Could not determine where to click on the page.', - }; - } - - this.window?.focus(); - activeTab.view.webContents.focus(); - activeTab.view.webContents.sendInputEvent({ - type: 'mouseMove', - x: result.clickPoint.x, - y: result.clickPoint.y, - movementX: 0, - movementY: 0, - }); - activeTab.view.webContents.sendInputEvent({ - type: 'mouseDown', - x: result.clickPoint.x, - y: result.clickPoint.y, - button: 'left', - clickCount: 1, - }); - activeTab.view.webContents.sendInputEvent({ - type: 'mouseUp', - x: result.clickPoint.x, - y: result.clickPoint.y, - button: 'left', - clickCount: 1, - }); - - this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(activeTab, signal); - - if (result.verification) { - const verification = await this.executeOnActiveTab<{ changed: boolean; reasons: string[] }>( - buildVerifyClickScript(result.verification.targetSelector, result.verification.before), - signal, - { waitForReady: false }, - ); - - if (!verification.changed) { - return { - ok: false, - error: 'Click did not change the page state. Target may not be the correct control.', - description: result.description, - }; - } - } - - return result; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to click the element.', - }; - } - } - - async type(target: ElementTarget, text: string, signal?: AbortSignal): Promise<{ ok: boolean; error?: string; description?: string }> { - const activeTab = this.getActiveTab(); - if (!activeTab) { - return { ok: false, error: 'No active browser tab is open.' }; - } - - const resolved = this.resolveElementSelector(activeTab.id, target); - if (!resolved.ok) return resolved; - - try { - const result = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>( - buildTypeScript(resolved.selector, text), - signal, - ); - if (!result.ok) return result; - this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(activeTab, signal); - return result; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to type into the element.', - }; - } - } - - async press( - key: string, - target?: ElementTarget, - signal?: AbortSignal, - ): Promise<{ ok: boolean; error?: string; description?: string }> { - const activeTab = this.getActiveTab(); - if (!activeTab) { - return { ok: false, error: 'No active browser tab is open.' }; - } - - let description = 'active element'; - - if (target?.index != null || target?.selector?.trim()) { - const resolved = this.resolveElementSelector(activeTab.id, target); - if (!resolved.ok) return resolved; - - try { - const focusResult = await this.executeOnActiveTab<{ ok: boolean; error?: string; description?: string }>( - buildFocusScript(resolved.selector), - signal, - ); - if (!focusResult.ok) return focusResult; - description = focusResult.description ?? description; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to focus the element before pressing a key.', - }; - } - } - - try { - const wc = activeTab.view.webContents; - const keyCode = normalizeKeyCode(key); - wc.sendInputEvent({ type: 'keyDown', keyCode }); - if (keyCode.length === 1) { - wc.sendInputEvent({ type: 'char', keyCode }); - } - wc.sendInputEvent({ type: 'keyUp', keyCode }); - - this.invalidateSnapshot(activeTab.id); - await this.waitForWebContentsSettle(activeTab, signal); - - return { - ok: true, - description: `${keyCode} on ${description}`, - }; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to press the requested key.', - }; - } - } - - async scroll(direction: 'up' | 'down' = 'down', amount = 700, signal?: AbortSignal): Promise<{ ok: boolean; error?: string }> { - const activeTab = this.getActiveTab(); - if (!activeTab) { - return { ok: false, error: 'No active browser tab is open.' }; - } - - try { - const offset = Math.max(1, amount) * (direction === 'up' ? -1 : 1); - const result = await this.executeOnActiveTab<{ ok: boolean; error?: string }>( - buildScrollScript(offset), - signal, - ); - if (!result.ok) return result; - this.invalidateSnapshot(activeTab.id); - await sleep(250, signal); - return result; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Failed to scroll the page.', - }; - } - } - - async wait(ms = 1000, signal?: AbortSignal): Promise { - await sleep(ms, signal); - const activeTab = this.getActiveTab(); - if (!activeTab) return; - await this.waitForWebContentsSettle(activeTab, signal); - } - - getState(): BrowserState { - return this.snapshotState(); - } - - private snapshotState(): BrowserState { - if (this.tabOrder.length === 0) return { ...EMPTY_STATE }; - return { - activeTabId: this.activeTabId, - tabs: this.tabOrder - .map((tabId) => this.tabs.get(tabId)) - .filter((tab): tab is BrowserTab => tab != null) - .map((tab) => this.snapshotTabState(tab)), - }; - } -} - -export const browserViewManager = new BrowserViewManager(); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 8fc4b754..111eb5a5 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -44,7 +44,6 @@ export async function isConfigured(): Promise<{ configured: boolean }> { export function setApiKey(apiKey: string): { success: boolean; error?: string } { try { composioClient.setApiKey(apiKey); - invalidateCopilotInstructionsCache(); return { success: true }; } catch (error) { return { @@ -293,6 +292,20 @@ export function listConnected(): { toolkits: string[] } { return { toolkits: composioAccountsRepo.getConnectedToolkits() }; } +/** + * Check if Composio should be used for Google services (Gmail, etc.) + */ +export async function useComposioForGoogle(): Promise<{ enabled: boolean }> { + return { enabled: await composioClient.useComposioForGoogle() }; +} + +/** + * Check if Composio should be used for Google Calendar + */ +export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean }> { + return { enabled: await composioClient.useComposioForGoogleCalendar() }; +} + /** * List available Composio toolkits — filtered to curated list only. * Return type matches the ZToolkit schema from core/composio/types.ts. diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts deleted file mode 100644 index aaaaa3bc..00000000 --- a/apps/x/apps/main/src/deeplink.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { BrowserWindow } from "electron"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { WorkDir } from "@x/core/dist/config/config.js"; - -export const DEEP_LINK_SCHEME = "rowboat"; -const URL_PREFIX = `${DEEP_LINK_SCHEME}://`; -const ACTION_HOST = "action"; - -let pendingUrl: string | null = null; -let mainWindowRef: BrowserWindow | null = null; - -export function setMainWindowForDeepLinks(win: BrowserWindow | null): void { - mainWindowRef = win; -} - -export function consumePendingDeepLink(): string | null { - const url = pendingUrl; - pendingUrl = null; - return url; -} - -export function extractDeepLinkFromArgv(argv: readonly string[]): string | null { - for (const arg of argv) { - if (typeof arg === "string" && arg.startsWith(URL_PREFIX)) return arg; - } - return null; -} - -/** - * Dispatch any rowboat:// URL — chooses among action / oauth-completion / - * navigation automatically. Use this from notification click handlers and - * other URL entry points. - * - * OAuth completion (rowboat://oauth/google/done?session=) is handled - * in main, not the renderer, because claiming tokens writes oauth.json and - * triggers sync — both main-process concerns. - */ -export function dispatchUrl(url: string): void { - if (parseAction(url)) { - void dispatchAction(url); - } else if (parseOAuthCompletion(url)) { - void dispatchOAuthCompletion(url); - } else { - dispatchDeepLink(url); - } -} - -export function dispatchDeepLink(url: string): void { - if (!url.startsWith(URL_PREFIX)) return; - - pendingUrl = url; - - const win = mainWindowRef; - if (!win || win.isDestroyed()) return; - focusWindow(win); - - if (win.webContents.isLoading()) return; - - win.webContents.send("app:openUrl", { url }); - pendingUrl = null; -} - -interface MeetingNotesAction { - type: "take-meeting-notes" | "join-and-take-meeting-notes"; - eventId: string; -} - -type ParsedAction = MeetingNotesAction; - -function parseAction(url: string): ParsedAction | null { - if (!url.startsWith(URL_PREFIX)) return null; - const rest = url.slice(URL_PREFIX.length); - const queryIdx = rest.indexOf("?"); - const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, ""); - if (host !== ACTION_HOST) return null; - const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); - const type = params.get("type"); - if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { - const eventId = params.get("eventId"); - return eventId ? { type, eventId } : null; - } - return null; -} - -async function dispatchAction(url: string): Promise { - const parsed = parseAction(url); - if (!parsed) return; - - const openMeeting = parsed.type === "join-and-take-meeting-notes"; - await handleTakeMeetingNotes(parsed.eventId, openMeeting); -} - -async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { - const win = mainWindowRef; - if (!win || win.isDestroyed()) return; - focusWindow(win); - - const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`); - let event: unknown; - try { - const raw = await fs.readFile(filePath, "utf-8"); - event = JSON.parse(raw); - } catch (err) { - console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err); - return; - } - - const payload = { event, openMeeting }; - - if (win.webContents.isLoading()) { - win.webContents.once("did-finish-load", () => { - win.webContents.send("app:takeMeetingNotes", payload); - }); - return; - } - - win.webContents.send("app:takeMeetingNotes", payload); -} - -// --- OAuth completion (rowboat-mode Google connect) --- - -interface OAuthCompletion { - provider: "google"; - state: string; -} - -/** - * Match rowboat://oauth/google/done?session=. Returns null for - * anything else — including paths with the right shape but wrong provider - * or a missing `session` query param. - */ -function parseOAuthCompletion(url: string): OAuthCompletion | 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 !== 3 || parts[0] !== "oauth" || parts[2] !== "done") return null; - if (parts[1] !== "google") return null; - const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); - const state = params.get("session"); - return state ? { provider: "google", state } : null; -} - -async function dispatchOAuthCompletion(url: string): Promise { - const parsed = parseOAuthCompletion(url); - if (!parsed) return; - - // Bring the app to the front so the renderer can react to the - // oauthEvent IPC that completeRowboatGoogleConnect emits. - const win = mainWindowRef; - if (win && !win.isDestroyed()) focusWindow(win); - - // Lazy-import to keep deeplink.ts free of OAuth deps and avoid a - // potential circular dep with oauth-handler.ts. - const { completeRowboatGoogleConnect } = await import("./oauth-handler.js"); - await completeRowboatGoogleConnect(parsed.state); -} - -function focusWindow(win: BrowserWindow): void { - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); -} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e5d407f8..a2230eda 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } 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'; @@ -8,7 +8,6 @@ import { 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/runs/runs.js'; @@ -31,16 +30,10 @@ 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 { 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 { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; -import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; -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'; @@ -51,29 +44,6 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.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, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.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'; -import { - fetchLiveNote, - setLiveNote, - setLiveNoteActive, - 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'; -import { browserIpcHandlers } from './browser/ipc.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -140,18 +110,6 @@ function markdownToHtml(markdown: string, title: string): string { ${html}` } -function resolveShellPath(filePath: string): string { - if (filePath.startsWith('~')) { - return path.join(os.homedir(), filePath.slice(1)); - } - - if (path.isAbsolute(filePath)) { - return filePath; - } - - return workspace.resolveWorkspacePath(filePath); -} - type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -313,7 +271,7 @@ function handleWorkspaceChange(event: z.infer): void { } } -export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void { +export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { if (!win.isDestroyed() && win.webContents) { @@ -392,32 +350,6 @@ export async function startServicesWatcher(): Promise { }); } -let liveNoteAgentWatcher: (() => void) | null = null; -export function startLiveNoteAgentWatcher(): void { - if (liveNoteAgentWatcher) return; - liveNoteAgentWatcher = liveNoteBus.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('live-note-agent:events', event); - } - } - }); -} - -let backgroundTaskAgentWatcher: (() => void) | null = null; -export function startBackgroundTaskAgentWatcher(): void { - if (backgroundTaskAgentWatcher) return; - backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => { - const windows = BrowserWindow.getAllWindows(); - for (const win of windows) { - if (!win.isDestroyed() && win.webContents) { - win.webContents.send('bg-task-agent:events', event); - } - } - }); -} - export function stopRunsWatcher(): void { if (runsWatcher) { runsWatcher(); @@ -449,16 +381,6 @@ export function setupIpcHandlers() { // args is null for this channel (no request payload) return getVersions(); }, - 'app:consumePendingDeepLink': async () => { - return { url: consumePendingDeepLink() }; - }, - 'analytics:bootstrap': async () => { - return { - installationId: getInstallationId(), - apiUrl: API_URL, - appVersion: app.getVersion(), - }; - }, 'workspace:getRoot': async () => { return workspace.getRoot(); }, @@ -489,38 +411,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 }); - }, - 'gmail:triggerSync': async () => { - triggerGmailSync(); - return {}; - }, - 'gmail:sendReply': async (_event, args) => { - return sendThreadReply(args); - }, - 'gmail:getConnectionStatus': async () => { - return getGmailConnectionStatus(); - }, - 'gmail:getAccountEmail': async () => { - return { email: await getAccountEmail() }; - }, - '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); - }, - 'gmail:saveMessageHeight': async (_event, args) => { - saveMessageBodyHeight(args.threadId, args.messageId, args.height); - return {}; - }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -531,17 +421,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) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) }; }, '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 }; @@ -560,35 +445,6 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, - '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 () => { if (await isSignedIn()) { return await listGatewayModels(); @@ -640,20 +496,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(); - }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); @@ -724,8 +566,11 @@ export function setupIpcHandlers() { 'composio:list-toolkits': async () => { return composioHandler.listToolkits(); }, - 'migration:check-composio-google': async () => { - return qualifyAndDisconnectComposioGoogle(); + 'composio:use-composio-for-google': async () => { + return composioHandler.useComposioForGoogle(); + }, + 'composio:use-composio-for-google-calendar': async () => { + return composioHandler.useComposioForGoogleCalendar(); }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { @@ -762,17 +607,24 @@ export function setupIpcHandlers() { }, // Shell integration handlers 'shell:openPath': async (_event, args) => { - const filePath = resolveShellPath(args.path); + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } else if (!path.isAbsolute(filePath)) { + // Workspace-relative path — resolve against ~/.rowboat/ + filePath = path.join(os.homedir(), '.rowboat', filePath); + } 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); + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } else if (!path.isAbsolute(filePath)) { + // Workspace-relative path — resolve against ~/.rowboat/ + filePath = path.join(os.homedir(), '.rowboat', filePath); + } const stat = await fs.stat(filePath); if (stat.size > 10 * 1024 * 1024) { throw new Error('File too large (>10MB)'); @@ -791,19 +643,6 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, - 'dialog:openDirectory': async (event, args) => { - const win = BrowserWindow.fromWebContents(event.sender); - const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir(); - const result = await dialog.showOpenDialog(win!, { - title: args.title ?? 'Choose work directory', - defaultPath, - properties: ['openDirectory', 'createDirectory'], - }); - if (result.canceled || result.filePaths.length === 0) { - return { path: null }; - } - return { path: result.filePaths[0] ?? null }; - }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); @@ -919,140 +758,9 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - // Live-note handlers - 'live-note:run': async (_event, args) => { - const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); - return { - success: !result.error, - runId: result.runId, - action: result.action, - summary: result.summary, - contentAfter: result.contentAfter, - error: result.error, - }; - }, - 'live-note:get': async (_event, args) => { - try { - const live = await fetchLiveNote(args.filePath); - return { success: true, live }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'live-note:set': async (_event, args) => { - try { - await setLiveNote(args.filePath, args.live); - const live = await fetchLiveNote(args.filePath); - return { success: true, live }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'live-note:setActive': async (_event, args) => { - try { - await setLiveNoteActive(args.filePath, args.active); - const live = await fetchLiveNote(args.filePath); - return { success: true, live }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'live-note:delete': async (_event, args) => { - try { - await deleteLiveNote(args.filePath); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'live-note:stop': async (_event, args) => { - try { - const live = await fetchLiveNote(args.filePath); - if (!live?.lastRunId) { - return { success: false, error: 'No active run for this note' }; - } - await runsCore.stop(live.lastRunId, false); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - 'live-note:listNotes': async () => { - 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.model ? { model: args.model } : {}), - ...(args.provider ? { provider: args.provider } : {}), - }); - 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(); }, - // 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 f4415b5d..42c9f3fd 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,11 +1,9 @@ -import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron"; +import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron"; import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, - startLiveNoteAgentWatcher, - startBackgroundTaskAgentWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -24,35 +22,11 @@ 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 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 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 { 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 { initConfigs } from "@x/core/dist/config/initConfigs.js"; -import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; import started from "electron-squirrel-startup"; 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 { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; -import { setupBrowserEventForwarding } from "./browser/ipc.js"; -import { ElectronBrowserControlService } from "./browser/control-service.js"; -import { ElectronNotificationService } from "./notification/electron-notification-service.js"; -import { - DEEP_LINK_SCHEME, - dispatchUrl, - extractDeepLinkFromArgv, - setMainWindowForDeepLinks, -} from "./deeplink.js"; -import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -62,44 +36,6 @@ const __dirname = dirname(__filename); // 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()) { - console.error('[Main] Another Rowboat instance is already running; exiting this process.'); - app.quit(); - process.exit(0); -} - -// Register as the OS handler for rowboat:// URLs. -// In dev, point at the right argv so the OS can re-invoke us correctly. -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME, process.execPath, [ - path.resolve(process.argv[1]), - ]); - } -} else { - app.setAsDefaultProtocolClient(DEEP_LINK_SCHEME); -} - -// First-launch URL on Windows/Linux comes through argv. -{ - const initialUrl = extractDeepLinkFromArgv(process.argv); - if (initialUrl) dispatchUrl(initialUrl); -} - -// macOS sends URLs via 'open-url' (both first launch and while running). -app.on("open-url", (event, url) => { - event.preventDefault(); - dispatchUrl(url); -}); - -// Subsequent launches on Windows/Linux land here via the single-instance lock. -app.on("second-instance", (_event, argv) => { - const url = extractDeepLinkFromArgv(argv); - if (url) dispatchUrl(url); -}); - // Fix PATH for packaged Electron apps on macOS/Linux. // Packaged apps inherit a minimal environment that doesn't include paths from // the user's shell profile (such as those provided by nvm, Homebrew, etc.). @@ -120,9 +56,7 @@ function initializeExecutionEnvironment(): void { ).trim(); const env = JSON.parse(stdout) as Record; - // Let the user's shell environment win for overlapping keys like PATH. - // Finder/launched GUI apps on macOS often start with a stripped PATH. - process.env = { ...process.env, ...env }; + process.env = { ...env, ...process.env }; } catch (error) { console.error('Failed to load shell environment', error); } @@ -140,29 +74,16 @@ const rendererPath = app.isPackaged : path.join(__dirname, "../../../renderer/dist"); // Development console.log("rendererPath", rendererPath); -// Register custom protocol for serving built renderer files in production -// AND for serving local workspace files to the renderer (images, PDFs, video). -// -// app://workspace/ → workspace file (path-traversal guarded) -// app:///... → renderer SPA (existing behavior) +// Register custom protocol for serving built renderer files in production. +// This keeps SPA routes working when users deep link into the packaged app. function registerAppProtocol() { protocol.handle("app", (request) => { const url = new URL(request.url); - // Workspace files: app://workspace/ - if (url.host === "workspace") { - try { - const relPath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); - if (!relPath) return new Response("Not Found", { status: 404 }); - const absPath = resolveWorkspacePath(relPath); - return net.fetch(pathToFileURL(absPath).toString()); - } catch { - return new Response("Forbidden", { status: 403 }); - } - } - - // Renderer SPA — existing logic + // url.pathname starts with "/" let urlPath = url.pathname; + + // If it's "/" or a SPA route (no extension), serve index.html if (urlPath === "/" || !path.extname(urlPath)) { urlPath = "/index.html"; } @@ -181,36 +102,12 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, corsEnabled: true, allowServiceWorkers: true, - // Required for byte-range requests so