diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ec60096f..787e28e6 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,9 +16,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -39,17 +39,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -61,25 +61,25 @@ jobs: # Create a temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - + # Create keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Decode and import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - + # Allow codesign to access the keychain security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Add keychain to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain - + # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" - + # Clean up certificate file rm -f $RUNNER_TEMP/certificate.p12 @@ -122,9 +122,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -145,17 +145,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -187,9 +187,9 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 @@ -212,17 +212,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 2d9816d0..5ddfcf6e 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -24,7 +24,7 @@ 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` | +| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` | | `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` | @@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | +| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) | ##### `live_note_agent` sub-use-case shape diff --git a/apps/x/CODE_MODE_ENGINES_PLAN.md b/apps/x/CODE_MODE_ENGINES_PLAN.md new file mode 100644 index 00000000..ce7a4958 --- /dev/null +++ b/apps/x/CODE_MODE_ENGINES_PLAN.md @@ -0,0 +1,271 @@ +# Code Mode — Managed Engine Provisioning Plan + +Branch: `feat/code-mode-managed-engines` (off `dev` @ `8ce24ebb`) + +## 1. Problem & Goal + +Code mode runs two coding agents — **Claude Code** and **Codex** — by spawning their +ACP adapters, which in turn spawn a heavy **native engine binary** (~205 MB claude, +~194 MB codex). We need code mode to work in **packaged releases with ~99% reliability +for both agents**, without shipping a ~400 MB installer. + +### Current state (HEAD = revert of #614) +- Packaged builds **do not stage** the ACP adapters, and `forge.config.cjs` + `ignore: /^\/node_modules\//` strips them. At runtime `agents.ts` resolves the + adapter via `require.resolve(...)` then spawns it — which **throws + `Cannot find module '@agentclientprotocol/...'`**. +- **Net: packaged code mode is broken in every release.** It only works in `dev` + because pnpm symlinks exist. There is no 400 MB bloat today — but no function either. + +### Why the two prior approaches are insufficient +- **Bundle engines (A):** +~400 MB per installer (one claude + one codex native binary + per OS/arch). Works offline, but installer is huge. +- **Drive from user's local install (B)** — what #614 settled on and was reverted: + requires the user to have **both** CLIs installed **and** logged in, correct version, + on the right PATH. Depends on the user's machine → **structurally cannot hit 99%** + (version skew, GUI-launch PATH stripping, missing installs). This is why #614 was + reverted. + +## 2. Chosen Architecture — Managed Engine Provisioning (validated against Conductor) + +Split the problem into **engine** vs **auth**, treat them differently — exactly what +Conductor (conductor.build) does: + +1. **Engine = owned by the app.** We provision **version-pinned** engine binaries into + **app-support** (`~/.rowboat/engines///`), download-on-demand on first + use, sha256-verified, symlink/path-pinned. Not the user's global npm, not their PATH. + → no version skew, no PATH quirks → this is what delivers the 99%. +2. **Auth = reused from the user.** The engines read existing credentials + (`~/.claude` API key / Pro / Max, `~/.codex` auth.json). No second login. `status.ts` + already inspects these. + +### Empirical proof from Conductor on this machine +- DMG is **123 MB** — far smaller than the ~400 MB of engines → engines are **not** in + the installer; they're downloaded after install. +- Layout observed at `~/Library/Application Support/com.conductor.app/`: + ``` + agent-binaries/claude/2.1.170/claude (222 MB, single Mach-O arm64) + agent-binaries/codex/0.138.0/codex (205 MB, single Mach-O arm64) + bin/claude -> agent-binaries/claude/2.1.170/claude (symlink to active version) + bin/codex -> agent-binaries/codex/0.138.0/codex + agent-binaries/.meta/claude-2.1.170.json = {sha256, size, downloaded_at_unix_ms, ...} + ``` +- So: versioned dirs + stable symlink + sha256 `.meta` ledger + download. We mirror this. + +## 3. Concrete facts that make this clean (verified) + +### Adapters honor an external engine via env var (no code change in adapters needed) +- **Claude** — `@agentclientprotocol/claude-agent-acp@0.39.0`, + `dist/acp-agent.js:39`: `if (process.env.CLAUDE_CODE_EXECUTABLE) return it;` + and line 1552 `pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? ...`. + If unset and no bundled native dep → it throws "set CLAUDE_CODE_EXECUTABLE". +- **Codex** — `@agentclientprotocol/codex-acp@0.0.44`, + `dist/index.js:20900`: `const codexPath = process.env["CODEX_PATH"] ?? "codex";` + → `spawn(codexPath, ["app-server"])`. +- **Implication:** provisioning + setting these two env vars is the *entire* engine story. + +### Our engine packages are already single self-contained binaries +- `@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156` → contains one `claude` + executable (+ LICENSE/README). +- `@openai/codex@0.128.0-darwin-arm64` → `vendor//codex/codex` native binary + **plus a bundled `rg` (ripgrep) at `vendor//path/rg`** — see Risk R1. + +### Pinned versions + platform package names (read from the installed adapter trees) +- **Claude:** adapter `claude-agent-acp@0.39.0` → engine `@anthropic-ai/claude-agent-sdk@0.3.156`. + Platform optional deps `@anthropic-ai/claude-agent-sdk-@0.3.156`: + `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linux-x64-musl`, + `linux-arm64-musl`, `win32-x64`, `win32-arm64`. +- **Codex:** adapter `codex-acp@0.0.44` → `@openai/codex@^0.128.0` (pnpm-**patched**, see R2). + Platform deps aliased `@openai/codex-` → `npm:@openai/codex@0.128.0-`: + `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`. + +## 4. Distribution source — npm platform packages, adapter-pinned (DECIDED) + +We fetch the **per-platform engine packages from the npm registry at the exact versions +our ACP adapters depend on**, extract the native binary, and provision it into +`~/.rowboat/engines/...`. No self-host, no curl installer, no fallback. + +- **Tarball URL:** `https://registry.npmjs.org//-/-.tgz` + - claude: `@anthropic-ai/claude-agent-sdk-@0.3.156` → extract the `claude` binary. + - codex: `@openai/codex@0.128.0-` → extract `vendor//codex/codex` + **and keep `vendor//path/rg`** (see R1). +- **Why adapter-pinned npm (not curl installer, not release bucket, not self-host):** + - The binary is the **exact version our adapter was built/tested against** → ACP + handshake guaranteed → the key to ~99% reliability. + - npm registry is highly available, immutable per-version, and the packument provides + `dist.integrity` (sha512) + `dist.shasum` (sha1) → integrity verification with **zero + infra**. + - The official `curl | bash` installer is for end-users: it installs globally to + `~/.local/bin` and **auto-updates in the background** — exactly what we must avoid. We + want an isolated, pinned, app-managed copy that never moves under the adapter. + (Conductor likewise fetches the raw binary rather than running the installer.) +- **Versions are read from our lockfile at build time** and embedded in + `engine-manifest.json`, so the manifest can never drift from the adapters we ship. + +### Reference: how this relates to Conductor / official installers +- Conductor self-hosts the same kind of native binaries on `storage.conductor.build` + (raw/`.gz`/`.zst` + `{url,gzipUrl,zstdUrl,sha256}` manifest), pairing the adapters with + recent **standalone CLI** versions (claude 2.1.x, codex 0.138). That proves recent + versions work — but we deliberately pin to the **adapter's own** engine version for + determinism. +- Official Claude releases also publish a GPG-signed `manifest.json` (SHA256/platform) at + `downloads.claude.ai/claude-code-releases//`. We don't use it now (npm is simpler + and adapter-matched), but it's the upgrade path if we ever want the latest CLI line. +- If we later want control/availability/compression, we can mirror these npm tarballs to + our own bucket — **runtime stays identical, only manifest URLs change.** + +### Locked decisions +- **Source: npm platform packages, adapter-pinned versions (user).** +- **No self-host (user).** +- **Always provision; no fallback** to a user's pre-installed `claude`/`codex` (user). + Reverted path B is fully retired. +- **Codex pnpm patch — IRRELEVANT (user):** it targets the JS launcher; we point + `CODEX_PATH` at the native binary, so it does not apply. (Risk R2 removed.) + +## 5. Design + +### 5.1 Build-time: generate an engine manifest + stage adapter JS + +**(a) Engine manifest (`engine-manifest.json`, embedded in the app).** +A build script reads the installed adapter dependency trees and emits, per agent: +```jsonc +{ + "claude": { + "version": "0.3.156", + "platforms": { + "darwin-arm64": { "pkg": "@anthropic-ai/claude-agent-sdk-darwin-arm64", + "tarball": "https://registry.npmjs.org/.../-/...-0.3.156.tgz", + "integrity": "sha512-...", + "binRelPath": "claude" }, + "...": {} + } + }, + "codex": { + "version": "0.128.0", + "platforms": { + "darwin-arm64": { "pkg": "@openai/codex-darwin-arm64", + "tarball": "https://registry.npmjs.org/...", + "integrity": "sha512-...", + "binRelPath": "vendor/aarch64-apple-darwin/codex/codex", + "extraPaths": ["vendor/aarch64-apple-darwin/path/rg"] } + } + } +} +``` +- Versions + tarball URLs + integrity are pulled from the lockfile / npm packument so the + manifest is always in sync with the adapters we ship. (Generating it at build time + sidesteps hardcoding fragile package names.) +- `binRelPath` / `extraPaths` capture where the executable (and codex's `rg`) live inside + each tarball. + +**(b) Stage the ACP adapter JS into the package (the part of #614 we KEEP).** +The adapters themselves (tiny, ~15 MB total incl. their non-native JS deps) must exist on +disk in packaged builds so `agents.ts` can resolve + spawn them. In `forge.config.cjs` +`generateAssets`, stage the two adapters and their **non-native** production dependency +closure into `.package/acp/node_modules` (npm-style nested layout), and **exempt +`.package` from the `node_modules` ignore rule**. We **drop** #614's native-engine staging +entirely — engines come from provisioning, not the bundle. + +### 5.2 Runtime: engine provisioner (new module in `packages/core`) + +New file: `packages/core/src/code-mode/acp/engine-provisioner.ts`. + +``` +ensureEngine(agent): Promise<{ executablePath: string }> + 1. Read manifest entry for (agent, currentPlatform). Error clearly if unsupported. + 2. dir = ~/.rowboat/engines/// + 3. If dir exists AND .meta/-.json sha256 matches → return binPath. + 4. Else acquire a cross-process lock (avoid double download), then: + a. Download tarball to a temp file, streaming, with progress events. + b. Verify integrity (sha512/sha256 from manifest). + c. Extract into a temp dir, then atomic rename into / (tar gzip). + d. chmod +x the binary (and codex's rg) on unix. + e. Write .meta/-.json {sha256, size, downloaded_at_unix_ms}. + 5. Return absolute path to the engine executable (binRelPath joined to dir). +``` +- **Progress + cancellation** surfaced over IPC for the first-run "Downloading engine…" UI. +- **Offline / failure** → typed error with a clear, actionable message (not a hang). +- **Resumability / atomicity:** download to temp, verify, then rename; never leave a + half-extracted version dir that passes the existence check. + +### 5.3 Wire provisioning into launch + +In `agents.ts` `getAgentLaunchSpec()` (currently sets only `CLAUDE_CODE_EXECUTABLE`): +- Make it (or its caller in `client.ts` / `manager.ts`) `await ensureEngine(agent)` first, + then set: + - claude → `env.CLAUDE_CODE_EXECUTABLE = ` + - codex → `env.CODEX_PATH = ` (and ensure its `rg` sibling resolves; + keep the vendor dir layout intact so codex finds `../path/rg`). +- Keep `ELECTRON_RUN_AS_NODE=1` for the adapter spawn (unchanged). +- The provisioner replaces both the bundled-engine dependency *and* the reverted + "resolve user's local install" path. (Optionally: if a healthy provisioned engine is + absent but a compatible local install exists, we *may* fall back to it — but the default + and reliable path is the provisioned engine. Decide in review; default = provisioned only.) + +### 5.4 Status & UX (`status.ts` + renderer) +- `checkCodeModeAgentStatus()` becomes: engine = provisioned? (instead of "installed on + PATH?"); auth = existing credentials present? (unchanged logic). +- First-run flow: user picks agent → if engine missing, show "Downloading engine + (~200 MB), one time…" with progress; on completion, proceed. Subsequent uses: instant. +- Clear error states: download failed / offline / unsupported platform / auth missing. + +## 6. File-by-file change plan + +| File | Change | +|---|---| +| `apps/main/forge.config.cjs` | Stage adapter JS closure into `.package/acp/node_modules`; exempt `.package` from node_modules ignore; generate + copy `engine-manifest.json`. (No native engines staged.) | +| `apps/main/scripts/gen-engine-manifest.mjs` *(new)* | Build-time: read lockfile/packuments → emit `engine-manifest.json` (versions, tarball URLs, integrity, bin paths). | +| `packages/core/src/code-mode/acp/engine-provisioner.ts` *(new)* | `ensureEngine()` — download, verify, extract, lock, progress, `.meta` ledger. | +| `packages/core/src/code-mode/acp/agents.ts` | Resolve adapter from staged `.package/acp` first (fallback to node_modules in dev); `await ensureEngine`; set `CLAUDE_CODE_EXECUTABLE`/`CODEX_PATH` to provisioned binaries. | +| `packages/core/src/code-mode/acp/client.ts` / `manager.ts` | Await provisioning before spawn; surface provisioning progress/errors; keep startup deadline. | +| `packages/core/src/code-mode/status.ts` | Engine status = provisioned (not PATH); keep auth checks. | +| `apps/main/src/ipc.ts` + preload + renderer | IPC for provisioning progress + first-run download UI + error states. | +| CI (optional) | Smoke: packaged app boots each adapter, provisions a (small fake) engine, sets env var, answers ACP `initialize`; offline → clear error not a hang. | + +## 7. Edge cases & risks + +- **R1 — Codex needs `rg`.** The codex platform package bundles ripgrep at + `vendor//path/rg`. We must extract/keep the vendor layout so codex finds it + (don't extract the bare binary). Verify codex `app-server` boots from the provisioned dir. +- **R2 — Codex pnpm patch. RESOLVED (irrelevant).** The patch targets the JS launcher; we + point `CODEX_PATH` at the native binary, so it does not apply. +- **R3 — Platform/arch matrix.** Manifest must cover darwin x64/arm64, linux x64/arm64 + (+musl for claude), win32 x64/arm64. Windows engine is `claude.exe`/`codex.exe`. +- **R4 — Integrity & supply chain.** Always verify the manifest integrity hash before + chmod/exec. Treat a hash mismatch as a hard failure. +- **R5 — Disk + upgrades.** Versioned dirs accumulate. Add a cleanup that keeps only the + active pinned version per agent. +- **R6 — First-run network.** Required once per agent; cached forever after. Must be a + clear, cancellable UX, never a silent hang (reuse the #614 startup-deadline lesson). +- **R7 — code signing / Gatekeeper (macOS).** Downloaded native binaries aren't covered by + our app's signature. Verify they run under Gatekeeper (they're already + signed/notarized by Anthropic/OpenAI; quarantine attr may need clearing). Conductor runs + them fine from app-support — confirm we do too. +- **R8 — `engine-manifest` staleness.** Manifest must regenerate whenever the adapter/engine + versions change; tie generation into the build so it can't drift. + +## 8. Phasing + +1. **P1 — Packaging fix (unblocks dev→packaged parity):** stage adapter JS into `.package`, + resolver checks staged path first. Verify packaged code mode can at least *spawn* the + adapter. (Independent of provisioning; the part of #614 worth keeping.) +2. **P2 — Provisioner (core):** `ensureEngine()` + manifest + wire env vars. Verify both + engines provision + boot from `~/.rowboat/engines` on macOS arm64. +3. **P3 — UX + status:** first-run download UI, status panel, error states. +4. **P4 — Cross-platform + CI smoke:** matrix manifest, mac/linux/win smoke. +5. **P5 — Polish:** version cleanup, cancellation, offline messaging, optional local-install + fallback. + +## 9. Decisions & remaining questions + +### Resolved (locked) +1. **Source = npm platform packages, adapter-pinned versions** (not self-host, not curl + installer, not release bucket). +2. **Always provision; no local-install fallback.** +3. **Codex pnpm patch irrelevant.** + +### Still open (can decide during implementation) +- **Provision timing:** on first code-mode *use* (lazy) vs first app launch (eager, + background download). Plan assumes **lazy + cached**. +- **Version-dir cleanup:** keep only the active pinned version per agent (R5). +- **Verify R1** (codex `rg`) and **R7** (Gatekeeper on downloaded macOS binaries) during P2. diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..fa62d0db 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -11,6 +11,9 @@ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, @@ -24,7 +27,11 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: './.package/dist/main.cjs', - external: ['electron'], // Provided by Electron runtime + // electron is provided by the runtime. node-pty is a NATIVE module: it can't + // be inlined (its loader requires .node binaries + a spawn-helper relative to + // its own package dir), so it stays external and is copied into + // .package/node_modules below, where require() from dist/main.cjs finds it. + external: ['electron', 'node-pty'], // Use CommonJS format - many dependencies use require() which doesn't work // well with esbuild's ESM shim. CJS handles dynamic requires natively. format: 'cjs', @@ -42,4 +49,51 @@ await esbuild.build({ }, }); -console.log('✅ Main process bundled to .package/dist-bundle/main.js'); +// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real +// package dir and copy only what's needed at runtime (compiled JS + prebuilt +// binaries). The macOS spawn-helper must be executable — pnpm extraction drops +// the bit, and a non-executable helper makes every PTY spawn fail. +const here = path.dirname(fileURLToPath(import.meta.url)); +const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty')); +const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty'); +fs.rmSync(ptyDest, { recursive: true, force: true }); +fs.mkdirSync(ptyDest, { recursive: true }); +for (const item of ['package.json', 'lib', 'prebuilds']) { + fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true }); +} +const prebuildsDir = path.join(ptyDest, 'prebuilds'); +for (const dir of fs.readdirSync(prebuildsDir)) { + const helper = path.join(prebuildsDir, dir, 'spawn-helper'); + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); +} +console.log('✅ node-pty staged in .package/node_modules'); + +// Bundle the vendored agent-slack CLI into a single self-contained script next +// to main.cjs. It runs as a child process (process.execPath with +// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't +// be inlined into main.cjs. Bundling here means the packaged app needs neither +// node_modules nor a global npm install. +const agentSlackPkg = JSON.parse( + await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'), +); +await esbuild.build({ + entryPoints: ['./node_modules/agent-slack/dist/index.js'], + bundle: true, + platform: 'node', + target: 'node22', + outfile: './.package/dist/agent-slack.cjs', + format: 'cjs', + banner: { js: cjsBanner }, + define: { + 'import.meta.url': '__import_meta_url', + // Without this constant the CLI's --version walks up the directory tree + // for a package.json and would find Rowboat's instead of agent-slack's. + 'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version), + }, + // The CLI probes bun:sqlite via dynamic import inside a try/catch and falls + // back to node:sqlite; keep it external so the probe fails at runtime the + // same way it does under plain node. + external: ['bun:sqlite'], +}); + +console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..5e33bb49 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -5,6 +5,92 @@ const path = require('path'); const pkg = require('./package.json'); +// Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full +// production dependency closure into the packaged app. +// +// Why this is needed: code mode spawns each adapter as a SEPARATE `node ` +// process and locates it at runtime via require.resolve — so it must ship as a real +// on-disk file. esbuild can't inline it (dynamic resolve + spawn target), and Forge +// strips the workspace node_modules (see `ignore` below). Without this, packaged +// builds throw `Cannot find module '@agentclientprotocol/...'`. +// +// Why we reconstruct a nested tree instead of copying node_modules: pnpm's store is a +// symlink farm that legitimately holds multiple versions of the same package (e.g. +// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style +// nested node_modules — dereferencing symlinks and nesting on version conflict — which +// resolves correctly regardless of pnpm layout. +// +// What we DON'T bundle: the agents' native engines (claude / codex, ~200 MB each, shipped +// as platform-specific packages). Those are PROVISIONED on demand into +// ~/.rowboat/engines/// and the adapters are pointed at them via +// CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see packages/core/src/code-mode/acp/). Skipping +// them keeps each OS installer ~400 MB smaller while code mode stays fully functional. +function stageAcpAdapters(mainDir, destNodeModules) { + const fs = require('fs'); + const ADAPTERS = [ + '@agentclientprotocol/claude-agent-acp', + '@agentclientprotocol/codex-acp', + ]; + + // The native engines, shipped as platform packages. Provisioned on demand instead + // (see comment above), so they're excluded from staging. + const isNativeEngine = (key) => + /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // native claude + /^@openai\/codex-(win32|darwin|linux)/.test(key); // native codex + + // Resolve a dependency's real directory by walking node_modules the way Node does, + // looking for the package DIRECTORY. We deliberately do NOT use + // require.resolve(`${key}/package.json`): that throws for packages whose `exports` + // map doesn't expose package.json (e.g. @anthropic-ai/claude-agent-sdk), which would + // silently drop them and their subtrees. realpathSync dereferences pnpm's symlinks. + // Returns null for deps not installed for this OS (platform-optional binaries). + const realDirOf = (key, fromDir) => { + let dir = fromDir; + for (;;) { + const cand = path.join(dir, 'node_modules', ...key.split('/')); + if (fs.existsSync(path.join(cand, 'package.json'))) return fs.realpathSync(cand); + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + }; + + let copied = 0; + const skippedEngines = new Set(); + const install = (srcDir, key, destNM, chain) => { + const destDir = path.join(destNM, ...key.split('/')); + if (fs.existsSync(destDir)) return; // already placed at this exact location + if (chain.has(srcDir)) return; // dependency cycle — resolves to ancestor copy + fs.mkdirSync(path.dirname(destDir), { recursive: true }); + fs.cpSync(srcDir, destDir, { + recursive: true, + dereference: true, + filter: (s) => path.basename(s) !== 'node_modules', // deps handled by recursion + }); + copied++; + const pj = JSON.parse(fs.readFileSync(path.join(srcDir, 'package.json'), 'utf8')); + const deps = { ...pj.dependencies, ...pj.optionalDependencies }; + const nextChain = new Set(chain).add(srcDir); + for (const depKey of Object.keys(deps)) { + if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } + const depDir = realDirOf(depKey, srcDir); + if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain); + } + }; + + for (const key of ADAPTERS) { + const srcDir = realDirOf(key, mainDir); + if (!srcDir) { + throw new Error(`ACP adapter '${key}' is not installed in ${mainDir} — run pnpm install`); + } + install(srcDir, key, destNodeModules, new Set()); + } + if (skippedEngines.size) { + console.log(` (skipped native engines — provisioned on demand: ${[...skippedEngines].join(', ')})`); + } + return copied; +} + module.exports = { packagerConfig: { executableName: 'rowboat', @@ -29,17 +115,23 @@ module.exports = { appleIdPassword: process.env.APPLE_PASSWORD, teamId: process.env.APPLE_TEAM_ID }, - // Since we bundle everything with esbuild, we don't need node_modules at all. - // These settings prevent Forge's dependency walker (flora-colossus) from trying - // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. + // Since we bundle the main process with esbuild, we don't need the workspace + // node_modules. These settings prevent Forge's dependency walker (flora-colossus) + // from trying to analyze/copy node_modules, which fails with pnpm's symlinked + // workspaces. prune: false, - ignore: [ - /src\//, - /node_modules\//, - /.gitignore/, - /bundle\.mjs/, - /tsconfig.json/, - ], + // Strip the workspace src/node_modules (paths are ANCHORED to the app root), BUT + // always keep everything under `.package/` — that's our staged output: the + // bundled main process, the ACP adapters + their dependency closure (staged by + // the generateAssets hook), and the native node-pty module (staged into + // .package/node_modules by bundle.mjs). Without the `.package` exemption the + // node_modules rule would strip those and code mode / the embedded terminal + // would break in packaged builds. + ignore: (p) => { + if (p === '/.package' || p.startsWith('/.package/')) return false; + return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/] + .some((re) => re.test(p)); + }, }, makers: [ { @@ -56,6 +148,7 @@ 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'), }) }, { @@ -66,7 +159,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,10 +172,27 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, + { + name: require.resolve('./makers/maker-pacman.cjs'), + platforms: ['linux'], + config: { + name: 'rowboat', + bin: 'rowboat', + executableName: 'rowboat', + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com', + license: 'Apache', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], + } + }, { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], @@ -173,6 +285,15 @@ module.exports = { fs.mkdirSync(rendererDest, { recursive: true }); fs.cpSync(rendererSrc, rendererDest, { recursive: true }); + // Stage the ACP coding-adapters (+ their JS dependency closure, minus native + // engines) into .package/acp/node_modules. They are spawned as separate node + // processes at runtime and Forge strips the workspace node_modules, so they + // must be copied in explicitly. See stageAcpAdapters() above for the why. + console.log('Staging ACP adapters...'); + const acpDest = path.join(packageDir, 'acp', 'node_modules'); + const staged = stageAcpAdapters(__dirname, acpDest); + console.log(`✅ Staged ${staged} ACP adapter packages into .package/acp/node_modules`); + console.log('✅ All assets staged in .package/'); }, } diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 00000000..0e5ac870 Binary files /dev/null and b/apps/x/apps/main/icons/icon.ico differ diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs new file mode 100644 index 00000000..4cae1da9 --- /dev/null +++ b/apps/x/apps/main/makers/maker-pacman.cjs @@ -0,0 +1,134 @@ +// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages +// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host). +// +// CJS on purpose: forge.config.cjs require()s us. + +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const MakerBase = require('@electron-forge/maker-base').default; + +const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' }; + +class MakerPacman extends MakerBase { + name = 'pacman'; + defaultPlatforms = ['linux']; + + isSupportedOnCurrentPlatform() { + if (process.platform !== 'linux') return false; + try { + execSync('command -v makepkg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + + async make({ dir, makeDir, targetArch, packageJSON, appName }) { + const pkgArch = ARCH_MAP[targetArch] || targetArch; + + const cfg = this.config || {}; + const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase(); + // pacman pkgver disallows '-'; map prerelease tags through. + const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_'); + const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"'); + const maintainer = cfg.maintainer || 'unknown'; + const homepage = cfg.homepage || packageJSON.homepage || ''; + const license = cfg.license || 'custom'; + const bin = cfg.bin || pkgName; + const execName = cfg.executableName || appName || pkgName; + const mimeTypes = cfg.mimeType || []; + const depends = cfg.depends || []; + const iconSrc = cfg.icon; + + const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch)); + await this.ensureDirectory(outDir); + + // Clean prior contents so makepkg starts fresh each run. + for (const f of fs.readdirSync(outDir)) { + fs.rmSync(path.join(outDir, f), { recursive: true, force: true }); + } + + // Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs). + fs.writeFileSync( + path.join(outDir, bin), + `#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`, + { mode: 0o755 }, + ); + + const desktop = [ + '[Desktop Entry]', + `Name=${appName || pkgName}`, + `Comment=${pkgDesc}`, + `Exec=${bin} %U`, + `Icon=${pkgName}`, + 'Type=Application', + 'Categories=Utility;', + 'Terminal=false', + mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null, + '', + ].filter(Boolean).join('\n'); + fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop); + + const sources = [bin, `${pkgName}.desktop`]; + let iconInstall = ''; + if (iconSrc && fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png')); + sources.push('icon.png'); + iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`; + } + + const sumsLine = sources.map(() => "'SKIP'").join(' '); + const sourceLine = sources.map((s) => `'${s}'`).join(' '); + const dependsLine = depends.map((d) => `'${d}'`).join(' '); + // Embed the packager output dir as a bash-safe literal. + const appDirEscaped = dir.replace(/'/g, `'\\''`); + + const pkgbuild = `# Maintainer: ${maintainer} +# Auto-generated by maker-pacman.cjs — do not edit by hand. +pkgname=${pkgName} +pkgver=${pkgVersion} +pkgrel=1 +pkgdesc="${pkgDesc}" +arch=('${pkgArch}') +url="${homepage}" +license=('${license}') +depends=(${dependsLine}) +options=('!strip' '!debug') +source=(${sourceLine}) +sha256sums=(${sumsLine}) + +_appdir='${appDirEscaped}' + +package() { + install -dm755 "$pkgdir/opt/$pkgname" + cp -a "$_appdir/." "$pkgdir/opt/$pkgname/" + + # Electron's sandbox helper needs setuid root for the namespace sandbox. + if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + fi + + install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}" + install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop" +${iconInstall} +} +`; + fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild); + + execSync('makepkg -f --noconfirm --nodeps', { + cwd: outDir, + stdio: 'inherit', + env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch }, + }); + + return fs + .readdirSync(outDir) + .filter((f) => f.endsWith('.pkg.tar.zst')) + .map((f) => path.join(outDir, f)); + } +} + +module.exports = MakerPacman; +module.exports.default = MakerPacman; diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..a7eca38e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,12 +13,16 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", + "agent-slack": "0.9.3", "chokidar": "^4.0.3", "electron-squirrel-startup": "^1.0.1", "html-to-docx": "^1.8.0", "mammoth": "^1.11.0", + "node-pty": "^1.1.0", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", "update-electron-app": "^3.1.2", @@ -27,6 +31,7 @@ }, "devDependencies": { "@electron-forge/cli": "^7.10.2", + "@electron-forge/maker-base": "^7.11.1", "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", "@electron-forge/maker-rpm": "^7.11.1", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 632ee247..3ed9f6a0 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -16,27 +16,45 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import z from 'zod'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; -import { testModelConnection } from '@x/core/dist/models/models.js'; +import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; +import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { 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 { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js'; +import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'; +import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js'; +import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; +import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; +import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js'; +import * as codeGit from '@x/core/dist/code-mode/git/service.js'; +import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; +import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; +import type { CodeSession } from '@x/shared/dist/code-sessions.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/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 { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js'; +import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js'; +import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js'; +import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; import * as composioHandler from './composio-handler.js'; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; @@ -51,7 +69,9 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; +import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; import { startManagedGooglePick } from './google-picker-managed.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; @@ -74,6 +94,190 @@ import { listTasks, readRunIds as readTaskRunIds, } from '@x/core/dist/background-tasks/fileops.js'; + +type SlackHomeChannel = { + id: string; + name: string; + workspaceUrl?: string; + workspaceName?: string; +}; + +type SlackHomeMessage = { + id: string; + workspaceName?: string; + workspaceUrl?: string; + channelId?: string; + channelName?: string; + author?: string; + text: string; + ts: string; + url?: string; +}; + +function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> { + const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> }; + return (parsed.workspaces || []).map((w) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); +} + +type SlackAuthResult = { + ok: boolean; + workspaces: Array<{ url: string; name: string }>; + error?: string; + errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown'; +}; + +// Run `auth import-desktop`, then read back the workspaces via `auth whoami`. +// Shared by the plain and the quit-Slack-first import handlers. +async function importDesktopAndReadWorkspaces(): Promise { + const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false }); + if (!imported.ok) { + return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind }; + } + const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!whoami.ok) { + return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind }; + } + const workspaces = parseWhoamiWorkspaces(whoami.data); + if (workspaces.length === 0) { + return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' }; + } + return { ok: true, workspaces }; +} + +// Windows force-quits Slack so its exclusive Cookies-DB lock releases before +// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with +// Slack open. taskkill exits non-zero when nothing matches — that's fine. +async function quitSlackIfWindows(): Promise { + if (process.platform !== 'win32') return; + try { + await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true }); + } catch { + // No running Slack process to kill — nothing to do. + } + // Give Windows a moment to release the file handles before we copy them. + await new Promise(resolve => setTimeout(resolve, 800)); +} + +function extractArrayPayload(parsed: unknown): unknown[] { + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === 'object') { + const obj = parsed as Record; + for (const key of ['messages', 'channels', 'items', 'results', 'data']) { + if (Array.isArray(obj[key])) return obj[key] as unknown[]; + } + } + return []; +} + +function slackMessageText(message: Record): string { + const value = message.text ?? message.body ?? message.content; + return typeof value === 'string' ? value.trim() : ''; +} + +function slackMessageAuthor(message: Record): string | undefined { + const value = message.username ?? message.user ?? message.author; + return typeof value === 'string' ? value : undefined; +} + +function extractSlackUserName(raw: unknown): string | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record : undefined; + const user = obj.user && typeof obj.user === 'object' ? obj.user as Record : undefined; + const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record : undefined; + + const candidates = [ + profile?.display_name, + profile?.real_name, + userProfile?.display_name, + userProfile?.real_name, + obj.display_name, + obj.displayName, + obj.real_name, + obj.realName, + user?.display_name, + user?.displayName, + user?.real_name, + user?.realName, + obj.name, + user?.name, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + + return null; +} + +async function resolveSlackUserName( + userId: string, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + const key = `${workspaceUrl ?? ''}:${userId}`; + if (cache.has(key)) return cache.get(key) ?? null; + + const args = ['user', 'get', userId]; + if (workspaceUrl) { + args.push('--workspace', workspaceUrl); + } + + const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 }); + if (result.ok) { + const name = extractSlackUserName(result.data ?? {}); + if (name) { + cache.set(key, name); + return name; + } + } else { + console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`); + } + + cache.set(key, userId); + return null; +} + +async function resolveSlackMessageText( + text: string, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g)); + if (matches.length === 0) return text; + + let resolved = text; + for (const match of matches) { + const userId = match[1] ?? match[3]; + if (!userId) continue; + const fallback = match[2] ?? match[0]; + const name = await resolveSlackUserName(userId, workspaceUrl, cache); + resolved = resolved.replaceAll(match[0], name ?? fallback); + } + return resolved; +} + +async function resolveSlackAuthor( + author: string | undefined, + workspaceUrl: string | undefined, + cache: Map, +): Promise { + if (!author) return undefined; + if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author; + return await resolveSlackUserName(author, workspaceUrl, cache) ?? author; +} + +function slackMessageUrl(message: Record, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined { + const direct = message.permalink ?? message.url; + if (typeof direct === 'string' && direct) return direct; + if (!workspaceUrl || !channelId) return undefined; + return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`; +} import { browserIpcHandlers } from './browser/ipc.js'; /** @@ -373,6 +577,32 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro } } +async function requireCodeSession(sessionId: string): Promise { + const repo = container.resolve('codeSessionsRepo'); + const session = await repo.get(sessionId); + if (!session) { + throw new Error(`Unknown code session: ${sessionId}`); + } + return session; +} + +let codeSessionStatusWatcher: (() => void) | null = null; +export async function startCodeSessionStatusWatcher(): Promise { + if (codeSessionStatusWatcher) { + return; + } + const tracker = container.resolve('codeSessionStatusTracker'); + await tracker.start(); + codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('codeSession:status', { sessionId, status }); + } + } + }); +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -445,6 +675,13 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant. + // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage). + // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label + // for full historical coverage of people you've actually emailed. + warmContactIndex(); + warmSentContacts(); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -509,6 +746,9 @@ export function setupIpcHandlers() { 'gmail:getAccountEmail': async () => { return { email: await getAccountEmail() }; }, + 'gmail:getAccountName': async () => { + return { name: await getAccountName() }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, @@ -522,6 +762,22 @@ export function setupIpcHandlers() { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; }, + 'gmail:searchContacts': async (_event, args) => { + const query = args?.query ?? ''; + const limit = args?.limit; + const excludeEmails = args?.excludeEmails; + + // Primary source: people you've actually sent mail to (Gmail SENT label, + // cached + refreshed via the Gmail API). Fallback: local-snapshot index + // — used only when the SENT index hasn't been populated yet (very first + // launch, before the background sync finishes). + const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []); + if (sent.length > 0) { + return { contacts: sent }; + } + const fallback = await searchGmailContacts(query, { limit, excludeEmails }); + return { contacts: fallback }; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, @@ -532,12 +788,17 @@ 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, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) }; }, '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 }; @@ -594,6 +855,24 @@ export function setupIpcHandlers() { 'models:test': async (_event, args) => { return await testModelConnection(args.provider, args.model); }, + 'models:listForProvider': async (_event, args) => { + try { + const models = await listModelsForProvider(args.provider); + return { success: true, models }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to list models'; + return { success: false, error: message }; + } + }, + 'llm:getDefaultModel': async () => { + return await getDefaultModelAndProvider(); + }, + 'llm:generate': async (_event, args) => { + console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`); + const result = await generateOneShot(args); + console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`); + return result; + }, 'models:saveConfig': async (_event, args) => { const repo = container.resolve('modelConfigRepo'); await repo.setConfig(args); @@ -639,17 +918,149 @@ export function setupIpcHandlers() { 'codeMode:getConfig': async () => { const repo = container.resolve('codeModeConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; }, 'codeMode:setConfig': async (_event, args) => { const repo = container.resolve('codeModeConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); invalidateCopilotInstructionsCache(); return { success: true }; }, 'codeMode:checkAgentStatus': async () => { return await checkCodeModeAgentStatus(); }, + 'codeMode:provisionEngine': async (_event, args) => { + // Download + install the agent's engine, streaming progress back to the + // requesting window so Settings can show a live bar. 'check' is instant — skip it. + try { + await ensureEngine(args.agent, { + onProgress: (p) => { + if (p.phase === 'check') return; + _event.sender.send('codeMode:engineProgress', { + agent: args.agent, + phase: p.phase, + receivedBytes: p.receivedBytes, + totalBytes: p.totalBytes, + }); + }, + }); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + 'codeProject:add': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + const project = await repo.add(args.path); + const git = await codeGit.repoInfo(project.path); + return { project, git }; + }, + 'codeProject:remove': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + await repo.remove(args.projectId); + return { success: true }; + }, + 'codeProject:list': async () => { + const repo = container.resolve('codeProjectsRepo'); + const projects = await repo.list(); + return { + projects: await Promise.all(projects.map(async (project) => ({ + project, + git: await codeGit.repoInfo(project.path), + }))), + }; + }, + 'codeSession:create': async (_event, args) => { + const service = container.resolve('codeSessionService'); + const session = await service.create(args); + return { session }; + }, + 'codeSession:list': async () => { + const repo = container.resolve('codeSessionsRepo'); + const tracker = container.resolve('codeSessionStatusTracker'); + return { sessions: await repo.list(), statuses: tracker.getStatuses() }; + }, + 'codeSession:update': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return { session: await service.update(args.sessionId, args.patch) }; + }, + 'codeMode:listModelOptions': async (_event, args) => { + const manager = container.resolve('codeModeManager'); + return manager.listModelOptions(args.agent); + }, + 'codeSession:delete': async (_event, args) => { + const service = container.resolve('codeSessionService'); + disposeTerminal(args.sessionId); + await service.delete(args.sessionId, { + removeWorktree: args.removeWorktree, + deleteBranch: args.deleteBranch, + }); + return { success: true }; + }, + 'codeSession:sendMessage': async (_event, args) => { + const service = container.resolve('codeSessionService'); + // Intentionally not awaited: the turn can run for minutes and streams over + // runs:events. sendMessage validates synchronously enough that busy/unknown + // errors are reported via the run's error events instead. + const resultPromise = service.sendMessage(args.sessionId, args.text); + // Surface immediate rejections (busy session, unknown id) to the caller. + const result = await Promise.race([ + resultPromise, + new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)), + ]); + resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err)); + return result; + }, + 'codeSession:stop': async (_event, args) => { + const service = container.resolve('codeSessionService'); + await service.stop(args.sessionId); + return { success: true }; + }, + 'codeSession:gitStatus': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + const info = await codeGit.repoInfo(session.cwd); + if (!info.isGitRepo) { + return { isRepo: false, branch: null, hasCommits: false, files: [] }; + } + let files = await codeGit.status(session.cwd); + if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) { + const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch); + const byPath = new Map(branchFiles.map((file) => [file.path, file])); + for (const file of files) { + if (!byPath.has(file.path)) byPath.set(file.path, file); + } + files = [...byPath.values()]; + } + return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files }; + }, + 'codeSession:fileDiff': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return codeGit.fileDiff(session.cwd, args.path, { + baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null, + }); + }, + 'codeSession:readdir': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return { entries: await readProjectDir(session.cwd, args.relPath) }; + }, + 'codeSession:readFile': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return readProjectFile(session.cwd, args.relPath); + }, + 'codeSession:mergeBack': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return service.mergeBack(args.sessionId); + }, + 'codeSession:cleanupWorktree': async (_event, args) => { + const service = container.resolve('codeSessionService'); + try { + await service.cleanupWorktree(args.sessionId, args.deleteBranch); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to clean up worktree'; + return { success: false, error: message }; + } + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); @@ -669,21 +1080,191 @@ export function setupIpcHandlers() { 'slack:setConfig': async (_event, args) => { const repo = container.resolve('slackConfigRepo'); await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + // Connecting/disconnecting Slack changes the Copilot's routing (native + // `slack` skill vs. Composio), so rebuild its cached instructions. + invalidateCopilotInstructionsCache(); return { success: true }; }, + 'slack:cliStatus': async () => { + return await getAgentSlackCliStatus(); + }, + 'slack:knowledgeStatus': async () => { + return { + cli: await getAgentSlackCliStatus(), + sources: getSlackKnowledgeSyncStatus(), + }; + }, 'slack:listWorkspaces': async () => { - try { - const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); - const parsed = JSON.parse(stdout); - const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ - url: w.workspace_url || '', - name: w.workspace_name || '', - })); - return { workspaces }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; - return { workspaces: [], error: message }; + const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!result.ok) { + return { workspaces: [], error: result.message, errorKind: result.kind }; } + const workspaces = parseWhoamiWorkspaces(result.data); + return { workspaces }; + }, + 'slack:importDesktopAuth': async () => { + // Pull xoxc token(s) + cookie from the running/installed Slack desktop + // app into agent-slack's credential store, then read back the workspaces. + return await importDesktopAndReadWorkspaces(); + }, + 'slack:quitAndImportDesktop': async () => { + // Windows-only convenience: kill Slack (which locks its Cookies DB) then + // run the normal desktop import in one click. + await quitSlackIfWindows(); + return await importDesktopAndReadWorkspaces(); + }, + 'slack:parseCurlAuth': async (_event, args) => { + // Cross-OS fallback to desktop import: the user pastes a "Copy as cURL" + // request from a signed-in Slack web tab; parse-curl reads it from stdin + // and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain. + const curl = (args.curl ?? '').trim(); + if (!curl) { + return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const }; + } + const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl }); + if (!imported.ok) { + return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind }; + } + const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 }); + if (!whoami.ok) { + return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind }; + } + const workspaces = parseWhoamiWorkspaces(whoami.data); + if (workspaces.length === 0) { + return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const }; + } + return { ok: true, workspaces }; + }, + 'slack:listChannels': async (_event, args) => { + const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 }); + if (!result.ok) { + return { channels: [], error: result.message }; + } + const rawChannels = extractArrayPayload(result.data) as Array<{ + id?: string; + name?: string; + is_private?: boolean; + isPrivate?: boolean; + is_member?: boolean; + isMember?: boolean; + }>; + const channels = rawChannels.map((ch) => ({ + id: ch.id || ch.name || '', + name: ch.name || ch.id || '', + isPrivate: ch.is_private ?? ch.isPrivate, + isMember: ch.is_member ?? ch.isMember, + })).filter((ch) => ch.id && ch.name); + return { channels }; + }, + 'slack:getRecentMessages': async (_event, args) => { + const repo = container.resolve('slackConfigRepo'); + const config = await repo.getConfig(); + if (!config.enabled || config.workspaces.length === 0) { + return { enabled: false, messages: [] }; + } + + const limit = Math.min(Math.max(args.limit ?? 5, 1), 20); + const messages: SlackHomeMessage[] = []; + const userNameCache = new Map(); + + try { + const knowledgeConfig = knowledgeSourcesRepo.getConfig(); + const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled); + let channels: SlackHomeChannel[] = (slackSource?.scopes ?? []) + .filter(scope => scope.type === 'channel') + .map(scope => ({ + id: scope.id, + name: scope.name ?? scope.id, + workspaceUrl: scope.workspaceUrl, + workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name, + })); + + if (channels.length === 0) { + for (const workspace of config.workspaces) { + const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 }); + if (!channelList.ok) { + throw new AgentSlackRunError(channelList.kind, channelList.message); + } + const rawChannels = extractArrayPayload(channelList.data); + for (const raw of rawChannels) { + if (!raw || typeof raw !== 'object') continue; + const channel = raw as Record; + const id = typeof channel.id === 'string' ? channel.id : undefined; + const name = typeof channel.name === 'string' ? channel.name : id; + const isMember = channel.is_member ?? channel.isMember; + if (!id || !name || isMember === false) continue; + channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name }); + } + } + } + + channels = channels.slice(0, 8); + + for (const channel of channels) { + const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500']; + if (channel.workspaceUrl) { + commandArgs.push('--workspace', channel.workspaceUrl); + } + const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 }); + if (!messageList.ok) { + console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`); + continue; + } + const rawMessages = extractArrayPayload(messageList.data); + for (const raw of rawMessages) { + if (!raw || typeof raw !== 'object') continue; + const message = raw as Record; + const ts = typeof message.ts === 'string' ? message.ts : undefined; + const text = slackMessageText(message); + if (!ts || !text) continue; + const channelId = typeof message.channel_id === 'string' + ? message.channel_id + : typeof message.channel === 'string' + ? message.channel + : channel.id; + const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache); + const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache); + messages.push({ + id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`, + workspaceName: channel.workspaceName, + workspaceUrl: channel.workspaceUrl, + channelId, + channelName: channel.name, + author: resolvedAuthor, + text: resolvedText, + ts, + url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts), + }); + } + } + + const rankedIds = await rankSlackHomeMessages(messages, limit); + const byId = new Map(messages.map(message => [message.id, message])); + const rankedMessages = rankedIds + .map(id => byId.get(id)) + .filter((message): message is SlackHomeMessage => Boolean(message)); + return { enabled: true, messages: rankedMessages }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load Slack messages'; + const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined; + return { enabled: true, messages: [], error: message, errorKind }; + } + }, + 'knowledgeSources:getConfig': async () => { + return knowledgeSourcesRepo.getConfig(); + }, + 'knowledgeSources:upsert': async (_event, args) => { + const config = knowledgeSourcesRepo.upsertSource(args); + if (args.provider === 'slack') { + // The Copilot prompt lists the selected Slack channels, so refresh it + // whenever the channel selection changes. + invalidateCopilotInstructionsCache(); + triggerSlackKnowledgeSync(); + void syncSlackKnowledgeSources().catch(error => { + console.error('[SlackKnowledge] Immediate sync after settings update failed:', error); + }); + } + return config; }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet @@ -800,6 +1381,30 @@ export function setupIpcHandlers() { } return { path: result.filePaths[0] ?? null }; }, + 'terminal:ensure': async (_event, args) => { + return ensureTerminal(args.id, args.cwd, args.cols, args.rows); + }, + 'terminal:input': async (_event, args) => { + writeTerminal(args.id, args.data); + return { success: true }; + }, + 'terminal:resize': async (_event, args) => { + resizeTerminal(args.id, args.cols, args.rows); + return { success: true }; + }, + 'terminal:dispose': async (_event, args) => { + disposeTerminal(args.id); + return { success: true }; + }, + 'dialog:openFiles': async (event, args) => { + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win!, { + title: args.title ?? 'Attach files', + ...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}), + properties: ['openFile', 'multiSelections'], + }); + return { paths: result.canceled ? [] : result.filePaths }; + }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); @@ -949,6 +1554,24 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, + 'voice:ensureMicAccess': async () => { + if (process.platform !== 'darwin') return { granted: true }; + const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[voice] Microphone permission status:', status); + if (status === 'granted') return { granted: true }; + // 'not-determined' shows the native TCC prompt and resolves once the + // user responds; 'denied'/'restricted' resolve false without prompting. + // Awaiting this here means the triggering mic click proceeds to + // getUserMedia only after permission is settled — fixing the first + // click silently failing while the prompt was still up. + try { + const granted = await systemPreferences.askForMediaAccess('microphone'); + console.log('[voice] Microphone permission after prompt:', granted); + return { granted }; + } catch { + return { granted: false }; + } + }, // Live-note handlers 'live-note:run': async (_event, args) => { const result = await runLiveNoteAgent(args.filePath, 'manual', args.context); @@ -1043,6 +1666,7 @@ export function setupIpcHandlers() { name: args.name, instructions: args.instructions, ...(args.triggers ? { triggers: args.triggers } : {}), + ...(args.projectId ? { projectId: args.projectId } : {}), ...(args.model ? { model: args.model } : {}), ...(args.provider ? { provider: args.provider } : {}), }); @@ -1082,6 +1706,13 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + 'notifications:getSettings': async () => { + return loadNotificationSettings(); + }, + 'notifications:setSettings': async (_event, args) => { + saveNotificationSettings(args); + return { success: true }; + }, // Embedded browser handlers (WebContentsView + navigation) ...browserIpcHandlers, }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..0c7bc9b9 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, + startCodeSessionStatusWatcher, startServicesWatcher, startLiveNoteAgentWatcher, startBackgroundTaskAgentWatcher, @@ -11,6 +12,7 @@ import { stopServicesWatcher, stopWorkspaceWatcher } from "./ipc.js"; +import { disposeAllTerminals } from "./terminal.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -35,12 +37,13 @@ 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 { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js"; import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; import started from "electron-squirrel-startup"; -import { execSync, exec, execFileSync } from "node:child_process"; -import { promisify } from "node:util"; +import { execFileSync } from "node:child_process"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.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"; @@ -53,8 +56,6 @@ import { } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; -const execAsync = promisify(exec); - const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -220,6 +221,7 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, + icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -251,14 +253,34 @@ function createWindow() { return { action: "deny" }; }); - // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on("will-navigate", (event, url) => { + // Handle navigation to external URLs (e.g., clicking a link without target="_blank"). + // Returns true when the URL was external and routed to the system browser. + const routeExternalNavigation = (url: string): boolean => { const isInternal = url.startsWith("app://") || url.startsWith("http://localhost:5173"); - if (!isInternal) { - event.preventDefault(); - shell.openExternal(url); - } + if (isInternal) return false; + shell.openExternal(url); + return true; + }; + + win.webContents.on("will-navigate", (event, url) => { + if (routeExternalNavigation(url)) event.preventDefault(); + }); + + // Subframe navigations (e.g. links clicked inside the sandboxed iframe that + // renders a background-task / workspace `index.html`) fire `will-frame-navigate`, + // not `will-navigate`. Route their external links to the system browser too, + // so HTML reports behave like the markdown viewer. Main-frame navigations are + // already handled by `will-navigate` above — skip them here to avoid double-open. + // + // Scope this to our own HTML viewer frames (identified by their app://workspace + // document origin). Third-party note embeds (YouTube, Figma, Twitter via the + // embed/iframe blocks) load from their own origins — leave their internal + // navigation untouched so the embeds keep working. + win.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if (!event.frame?.url.startsWith("app://workspace/")) return; + if (routeExternalNavigation(event.url)) event.preventDefault(); }); // Attach the embedded browser pane manager to this window. @@ -289,18 +311,13 @@ app.whenReady().then(async () => { }); } - // Ensure agent-slack CLI is available - try { - execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 }); - } catch { - try { - console.log('agent-slack not found, installing...'); - await execAsync('npm install -g agent-slack', { timeout: 60000 }); - console.log('agent-slack installed successfully'); - } catch (e) { - console.error('Failed to install agent-slack:', e); - } - } + // The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs) + // and is resolved per call by the shared executor in @x/core. Availability is + // exposed to the UI via the slack:cliStatus IPC channel; this startup log is + // diagnostics only. + getAgentSlackCliStatus().then((status) => { + console.log('[Slack] agent-slack CLI status:', status); + }).catch(() => { /* probe failures already surface through slack:cliStatus */ }); // Initialize all config files before UI can access them await initConfigs(); @@ -330,6 +347,9 @@ app.whenReady().then(async () => { // start runs watcher startRunsWatcher(); + // start code-session status tracker (derives working/needs-you/idle + notifications) + startCodeSessionStatusWatcher(); + // start services watcher startServicesWatcher(); @@ -416,6 +436,14 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. + try { + container.resolve('codeModeManager').disposeAll(); + } catch { + // nothing live to dispose + } + // Kill embedded terminal shells. + disposeAllTerminals(); shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index dd37e37d..d86a4898 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -15,7 +15,15 @@ export class ElectronNotificationService implements INotificationService { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void { + // Ambient notifications are suppressed while the app is in the + // foreground — the user is already looking at it. A window counts as + // foreground only if it's actually focused (minimized / other-space + // windows are not), so this correctly treats those as background. + if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) { + return; + } + // Build the actions array AND a parallel index → link map. // macOS shows actions[0] inline (Banner) or all of them (Alert); // additional ones live behind the chevron menu. diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 4fe5d8d0..e0d4b2fc 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId signedInUserId = billing.userId; analyticsIdentify(billing.userId, { ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); } diff --git a/apps/x/apps/main/src/terminal.ts b/apps/x/apps/main/src/terminal.ts new file mode 100644 index 00000000..83d5a7c9 --- /dev/null +++ b/apps/x/apps/main/src/terminal.ts @@ -0,0 +1,126 @@ +import { BrowserWindow } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +// node-pty is a NATIVE module: it stays external to the esbuild bundle and is +// shipped alongside it in .package/node_modules (see bundle.mjs). +import * as pty from 'node-pty'; + +// One PTY per coding session, kept alive while the app runs so the terminal +// survives pane collapses and session switches. The renderer view re-attaches +// via `terminal:ensure`, which replays the recent backlog. + +const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay + +interface TerminalEntry { + proc: pty.IPty; + cwd: string; + backlog: string; + running: boolean; +} + +const terminals = new Map(); + +function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send(channel, payload); + } + } +} + +// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable +// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once. +let helperFixed = false; +function ensureSpawnHelperExecutable(): void { + if (helperFixed || process.platform === 'win32') return; + helperFixed = true; + try { + const pkgDir = path.dirname(require.resolve('node-pty/package.json')); + const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'); + if (fs.existsSync(helper)) { + fs.chmodSync(helper, 0o755); + } + } catch { + // best effort — spawn() will surface a real error if this mattered + } +} + +function defaultShell(): { file: string; args: string[] } { + if (process.platform === 'win32') { + return { file: 'powershell.exe', args: [] }; + } + // Login shell so the user's PATH/aliases match their normal terminal. + return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] }; +} + +function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry { + ensureSpawnHelperExecutable(); + const { file, args } = defaultShell(); + const proc = pty.spawn(file, args, { + name: 'xterm-256color', + cwd, + cols, + rows, + env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record, + }); + const entry: TerminalEntry = { proc, cwd, backlog: '', running: true }; + proc.onData((data) => { + entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT); + broadcast('terminal:data', { id, data }); + }); + proc.onExit(({ exitCode }) => { + entry.running = false; + broadcast('terminal:exit', { id, exitCode }); + }); + terminals.set(id, entry); + return entry; +} + +// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or +// an exited shell gets a fresh PTY; otherwise the live one is reused and the +// caller repaints from the backlog. +export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } { + const existing = terminals.get(id); + if (existing && existing.running && existing.cwd === cwd) { + existing.proc.resize(cols, rows); + return { backlog: existing.backlog, running: true }; + } + if (existing) { + disposeTerminal(id); + } + const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir(); + const entry = spawnEntry(id, fallbackCwd, cols, rows); + return { backlog: entry.backlog, running: entry.running }; +} + +export function writeTerminal(id: string, data: string): void { + const entry = terminals.get(id); + if (entry?.running) entry.proc.write(data); +} + +export function resizeTerminal(id: string, cols: number, rows: number): void { + const entry = terminals.get(id); + if (entry?.running) { + try { + entry.proc.resize(cols, rows); + } catch { + // resizing a dying pty throws — harmless + } + } +} + +export function disposeTerminal(id: string): void { + const entry = terminals.get(id); + if (!entry) return; + terminals.delete(id); + try { + entry.proc.kill(); + } catch { + // already gone + } +} + +export function disposeAllTerminals(): void { + for (const id of [...terminals.keys()]) disposeTerminal(id); +} diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 67876189..eec078d6 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,7 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.12.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@eigenpal/docx-editor-react": "^1.0.3", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -38,10 +44,13 @@ "@tiptap/starter-kit": "3.22.4", "@x/preload": "workspace:*", "@x/shared": "workspace:*", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "ai": "^5.0.117", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror": "^6.0.2", "lucide-react": "^0.562.0", "mermaid": "^11.14.0", "motion": "^12.23.26", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..d8321855 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -160,6 +160,13 @@ border-bottom: 1px solid var(--gm-border); } +.gmail-topbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + .gmail-search { display: flex; align-items: center; @@ -707,6 +714,132 @@ border-color: var(--gm-border-strong); } +/* Standalone "new email" composer — centered modal popup */ +.gmail-compose-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(0, 0, 0, 0.32); +} + +.gmail-compose-modal, +.gmail-compose-card { + --gm-bg-card: #ffffff; + --gm-bg-input: #f4f4f7; + --gm-bg-elevated: #ffffff; + --gm-bg-pill: #ffffff; + --gm-bg-pill-hover: #f4f4f7; + --gm-text: #27272a; + --gm-text-strong: #09090b; + --gm-text-muted: #71717a; + --gm-text-body: #3f3f46; + --gm-border: #e4e4e7; + --gm-border-strong: #d4d4d8; + --gm-accent: #7c3aed; + --gm-accent-hover: #6d28d9; + --gm-accent-fg: #ffffff; + --gm-icon-hover-bg: #f4f4f7; + --gm-placeholder: #a1a1aa; +} + +.gmail-compose-modal { + display: flex; + flex-direction: column; + width: min(840px, 100%); + height: min(720px, calc(100vh - 64px)); + max-height: calc(100vh - 64px); + border: 1px solid var(--gm-border-strong); + border-radius: 10px; + overflow: hidden; + background: var(--gm-bg-card); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); +} + +.gmail-compose-modal-header { + display: flex; + align-items: center; + gap: 10px; + height: 40px; + padding: 0 8px 0 14px; + background: var(--gm-bg-input); + color: var(--gm-text-body); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.gmail-compose-modal-header > span { + flex: 1; +} + +.gmail-compose-modal .gmail-compose-editor { + flex: 1; + min-height: 160px; + max-height: none; + padding: 0 14px; +} + +.gmail-compose-ai-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-compose-ai-input { + flex: 1; + min-width: 0; + height: 30px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + outline: none; + background: var(--gm-bg-input); + color: var(--gm-text); + font: inherit; + font-size: 12px; +} + +.gmail-compose-ai-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px 10px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-compose-ai-presets button { + height: 24px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 999px; + background: var(--gm-bg-pill); + color: var(--gm-text-muted); + font: inherit; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-compose-ai-presets button:hover:not(:disabled) { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-compose-ai-presets button:disabled, +.gmail-compose-ai-input:disabled { + opacity: 0.5; + cursor: default; +} + .gmail-compose-card { max-width: 720px; margin-left: 40px; @@ -800,6 +933,108 @@ gap: 4px; flex: 1; min-width: 0; + position: relative; +} + +.gmail-recipient-suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 30; + margin: 0; + padding: 6px; + list-style: none; + width: max-content; + min-width: 280px; + max-width: min(440px, 100%); + background: var(--gm-bg-elevated, #ffffff); + border: 1px solid var(--gm-border); + border-radius: 10px; + box-shadow: + 0 1px 2px rgba(24, 24, 27, 0.08), + 0 12px 32px rgba(24, 24, 27, 0.18); + max-height: 296px; + overflow-y: auto; + overscroll-behavior: contain; + transform-origin: top left; + animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1); +} + +@keyframes gmail-recipient-suggestions-in { + from { + opacity: 0; + transform: translateY(-2px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.gmail-recipient-suggestion { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--gm-text); + cursor: pointer; + transition: background-color 80ms linear; +} + +.gmail-recipient-suggestion:hover { + background: var(--gm-bg-pill-hover); +} + +.gmail-recipient-suggestion.is-active { + background: rgba(99, 142, 255, 0.18); +} + +.gmail-recipient-suggestion-avatar { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + color: #fff; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.2px; + text-transform: uppercase; +} + +.gmail-recipient-suggestion-text { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + line-height: 1.25; +} + +.gmail-recipient-suggestion-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.gmail-recipient-suggestion-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + color: var(--gm-text-muted); + margin-top: 1px; +} + +.gmail-recipient-suggestion-match { + background: transparent; + color: inherit; + font-weight: 700; + padding: 0; } .gmail-recipient-chip { @@ -885,7 +1120,10 @@ gap: 2px; flex: 1; min-width: 0; - justify-content: center; + justify-content: flex-start; + padding-left: 10px; + margin-left: 2px; + border-left: 1px solid var(--gm-border-strong); } .gmail-compose-link-popover { @@ -957,11 +1195,16 @@ transition: background 120ms ease, color 120ms ease; } -.gmail-compose-tool:hover { +.gmail-compose-tool:hover:not(:disabled) { background: var(--gm-bg-pill-hover); color: var(--gm-text); } +.gmail-compose-tool:disabled { + opacity: 0.4; + cursor: default; +} + .gmail-compose-tool.is-active { background: var(--gm-bg-pill-hover); color: var(--gm-accent); @@ -1052,6 +1295,52 @@ pointer-events: none; } +.gmail-compose-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px 0; +} + +.gmail-compose-attachment { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 240px; + padding: 4px 8px; + border: 1px solid var(--gm-border); + border-radius: 6px; + background: var(--gm-bg-pill); + font-size: 12px; + color: var(--gm-text); +} + +.gmail-compose-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gmail-compose-attachment-size { + color: var(--gm-text-muted); + flex-shrink: 0; +} + +.gmail-compose-attachment-remove { + border: none; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 0 0 0 2px; + flex-shrink: 0; +} + +.gmail-compose-attachment-remove:hover { + color: var(--gm-text); +} + .gmail-compose-actions { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1137aabe..9d333581 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,13 +5,13 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; @@ -29,11 +29,15 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; -import { KnowledgeView } from '@/components/knowledge-view'; +import { CodingRunBlock } from '@/components/coding-run'; +import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view'; import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; +import { CodeView, type ActiveCodeSession } from '@/components/code/code-view'; +import { CodeChat } from '@/components/code/code-chat'; +import { ResizableRightPane } from '@/components/code/resizable-right-pane'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -57,9 +61,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -117,6 +122,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -165,6 +171,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -196,6 +203,7 @@ const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__' const HOME_TAB_PATH = '__rowboat_home__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' +const CODE_TAB_PATH = '__rowboat_code__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -370,6 +378,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH const isHomeTabPath = (path: string) => path === HOME_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH +const isCodeTabPath = (path: string) => path === CODE_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { const normalized = category?.trim().toLowerCase() @@ -620,9 +629,10 @@ type ViewState = | { type: 'live-notes' } | { type: 'email' } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view'; folderPath?: string } + | { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode } | { type: 'chat-history' } | { type: 'home' } + | { type: 'code' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -630,7 +640,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') - if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') + if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '') return true // both graph } @@ -680,12 +690,19 @@ function parseDeepLink(input: string): ViewState | null { } case 'knowledge-view': { const folderPath = params.get('folderPath') - return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + const mode = params.get('mode') + return { + type: 'knowledge-view', + folderPath: folderPath ?? undefined, + mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined, + } } case 'chat-history': return { type: 'chat-history' } case 'home': return { type: 'home' } + case 'code': + return { type: 'code' } default: return null } @@ -773,6 +790,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -798,13 +818,14 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + const [knowledgeViewMode, setKnowledgeViewMode] = useState('graph') // Folder being browsed inside the knowledge view (null = root overview). // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false) const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge') const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -1002,7 +1023,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -1068,7 +1089,7 @@ function App() { }, []) // Runs history state - type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } + type RunListItem = { id: string; title?: string; createdAt: string; modifiedAt: string; agentId: string; useCase?: string } const [runs, setRuns] = useState([]) // Chat tab state @@ -1193,6 +1214,23 @@ function App() { const [activeFileTabId, setActiveFileTabId] = useState('home-tab') const activeFileTabIdRef = useRef(activeFileTabId) activeFileTabIdRef.current = activeFileTabId + // The Code section is tab-derived (no boolean to keep in sync with the other + // section flags): it is open exactly while its sentinel tab is active. + const isCodeOpen = React.useMemo(() => { + const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) + return activeTab ? isCodeTabPath(activeTab.path) : false + }, [fileTabs, activeFileTabId]) + // The code session that owns the right-hand chat pane: rowboat-mode sessions + // bind the assistant chat to their run; direct-mode sessions swap the pane + // for the direct-drive chat. + const [activeCodeSession, setActiveCodeSession] = useState(null) + // A file the code chat asked to review — consumed by the workspace pane. + const [codeDiffPath, setCodeDiffPath] = useState(null) + const boundCodeSessionRef = useRef(null) + // Composer locks for runs that are code sessions: the session's cwd + agent + // are frozen in the chat input (the backend pins them server-side anyway). + // Kept after the Code view unmounts — the chat tab stays bound to the run. + const [codeSessionLocks, setCodeSessionLocks] = useState>({}) const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) @@ -1206,9 +1244,10 @@ function App() { if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isEmailTabPath(tab.path)) return 'Email' if (isWorkspaceTabPath(tab.path)) return 'Workspace' - if (isKnowledgeViewTabPath(tab.path)) return 'Notes' + if (isKnowledgeViewTabPath(tab.path)) return 'Brain' if (isChatHistoryTabPath(tab.path)) return 'Chat history' if (isHomeTabPath(tab.path)) return 'Home' + if (isCodeTabPath(tab.path)) return 'Code' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -1221,6 +1260,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1234,6 +1274,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1244,6 +1285,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -1918,8 +1960,8 @@ function App() { cursor = result.nextCursor } while (cursor) - // Filter for copilot runs only - const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot') + // Filter for copilot chats only (Code-section sessions live in the Code view) + const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session') setRuns(copilotRuns) } catch (err) { console.error('Failed to load runs:', err) @@ -2147,6 +2189,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2155,6 +2198,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2183,10 +2228,20 @@ function App() { setConversation(items) setRunId(id) setMessage('') + // Reconcile composer state with THIS run. Loading a run while another one + // is mid-turn (e.g. binding a code session steals the single chat tab) + // must not leave isProcessing/isStopping pointing at the old run — that + // wedges the composer: stop targets the new run (a no-op) while the old + // run's processing-end arrives flagged as non-active and clears nothing. + setIsProcessing(processingRunIdsRef.current.has(id)) + setIsStopping(false) + setStopClickedAt(null) + setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '') setPendingPermissionRequests(pendingPerms) setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2252,6 +2307,11 @@ function App() { break case 'start': + // Run creation alone isn't a turn. Code-session runs are created when + // the session is (no message follows until the user sends one), so + // marking them processing here would never be cleared — and wedge the + // composer (Stop shown, send blocked) once the session binds a chat tab. + if (event.useCase === 'code_session') return setProcessingRunIds(prev => { if (prev.has(event.runId)) return prev const next = new Set(prev) @@ -2306,19 +2366,6 @@ function App() { status: 'running', timestamp: Date.now(), }]) - // Detect acpx-driven coding-agent runs so the composer can retroactively - // flip code mode on with the right agent (when the user reached the skill - // via plain prompt rather than the explicit toggle). - if (llmEvent.toolName === 'executeCommand') { - const input = llmEvent.input as { command?: unknown } | undefined - const cmd = typeof input?.command === 'string' ? input.command : '' - const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) - if (match) { - window.dispatchEvent(new CustomEvent('code-mode-detected', { - detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, - })) - } - } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2416,6 +2463,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2496,6 +2545,43 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2612,6 +2698,7 @@ function App() { stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2651,6 +2738,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2755,10 +2843,12 @@ function App() { const inferredTitle = inferRunTitleFromMessage(titleSource) setRuns((prev) => { const withoutCurrent = prev.filter((run) => run.id !== currentRunId) + const createdAt = newRunCreatedAt ?? new Date().toISOString() return [{ id: currentRunId!, title: inferredTitle, - createdAt: newRunCreatedAt ?? new Date().toISOString(), + createdAt, + modifiedAt: createdAt, agentId, }, ...withoutCurrent] }) @@ -2826,6 +2916,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -2855,6 +2965,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2881,6 +2992,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2906,6 +3018,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -2934,6 +3047,38 @@ function App() { } }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab]) + // A code session was selected (or changed mode/status) in the Code view. + // Rowboat-mode sessions take over the assistant chat pane by binding their + // run to a chat tab — the conversation IS the assistant chat, no copy. + // Direct-mode sessions render their own pane instead (see right-pane JSX). + const handleCodeSessionSelected = useCallback((active: ActiveCodeSession | null) => { + setActiveCodeSession(active) + if (active) { + const { id, cwd, agent } = active.session + setCodeSessionLocks((prev) => ( + prev[id]?.cwd === cwd && prev[id]?.agent === agent + ? prev + : { ...prev, [id]: { cwd, agent } } + )) + } + const rowboatSessionId = active && active.session.mode === 'rowboat' ? active.session.id : null + if (!rowboatSessionId) { + boundCodeSessionRef.current = null + return + } + if (boundCodeSessionRef.current === rowboatSessionId) return + boundCodeSessionRef.current = rowboatSessionId + const existingTab = chatTabsRef.current.find((t) => t.runId === rowboatSessionId) + if (existingTab) { + switchChatTab(existingTab.id) + return + } + setChatTabs((prev) => prev.map((t) => ( + t.id === activeChatTabIdRef.current ? { ...t, runId: rowboatSessionId } : t + ))) + loadRun(rowboatSessionId) + }, [switchChatTab, loadRun]) + const closeChatTab = useCallback((tabId: string) => { if (chatTabs.length <= 1) return const idx = chatTabs.findIndex(t => t.id === tabId) @@ -3203,6 +3348,14 @@ function App() { setIsHomeOpen(true) return } + if (isCodeTabPath(tab.path)) { + // isCodeOpen itself is derived from the active tab — just clear the rest. + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) @@ -3211,7 +3364,7 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -3601,13 +3754,14 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined, mode: knowledgeViewMode } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } + if (isCodeOpen) return { type: 'code' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3752,6 +3906,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureCodeFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isCodeTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: CODE_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const openEmailView = useCallback((threadId?: string) => { setSelectedPath(null) setIsGraphOpen(false) @@ -3807,6 +3972,18 @@ function App() { ensureMeetingsFileTab() }, [ensureMeetingsFileTab]) + const openCodeView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + ensureCodeFileTab() + }, [ensureCodeFileTab]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -3947,6 +4124,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph')) setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) @@ -3987,6 +4165,17 @@ function App() { setIsHomeOpen(true) ensureHomeFileTab() return + case 'code': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + ensureCodeFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -4015,7 +4204,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -4162,10 +4351,9 @@ function App() { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) }, []) - const handleBaseSave = useCallback(async (name: string | null) => { - if (!selectedPath) return - const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH - const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + const handleBaseSave = useCallback(async (path: string, name: string | null) => { + const isDefault = path === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[path] ?? DEFAULT_BASE_CONFIG if (isDefault && name) { // Save as new base file @@ -4189,14 +4377,14 @@ function App() { // Save in place try { await window.ipc.invoke('workspace:writeFile', { - path: selectedPath, + path, data: JSON.stringify(config, null, 2), }) } catch (err) { console.error('Failed to save base:', err) } } - }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + }, [baseConfigByPath, loadDirectory, navigateToView]) // External search set by app-navigation tool (passed to BasesView) const [externalBaseSearch, setExternalBaseSearch] = useState(undefined) @@ -4350,7 +4538,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -5064,8 +5252,10 @@ function App() { }, }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) + const isBrainGraphOpen = isKnowledgeViewOpen && knowledgeViewMode === 'graph' + useEffect(() => { - if (!isGraphOpen) return + if (!isGraphOpen && !isBrainGraphOpen) return let cancelled = false const buildGraph = async () => { @@ -5180,9 +5370,13 @@ function App() { return () => { cancelled = true } - }, [isGraphOpen, knowledgeFilePaths]) + }, [isGraphOpen, isBrainGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5240,6 +5434,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5280,6 +5489,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5338,9 +5550,20 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5395,16 +5618,19 @@ function App() { isHomeOpen ? 'home' : isEmailOpen ? 'email' : isMeetingsOpen ? 'meetings' + : isCodeOpen ? 'code' : (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge' : isBgTasksOpen ? 'agents' : isWorkspaceOpen ? 'workspaces' : null } onOpenMeetings={openMeetingsView} + onOpenCode={openCodeView} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenEmail={(threadId) => openEmailView(threadId)} onOpenHome={() => void navigateToView({ type: 'home' })} onNewChat={handleNewChatTab} @@ -5417,10 +5643,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5433,7 +5660,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : isFullScreenChat ? ( Version history )} - {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( + {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && ( - {otherAgent && otherDisplay && onSwitchAgent && ( - - )} )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 61ba6fbd..9635b244 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -5,12 +5,18 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, CircleCheck, LoaderIcon, + ShieldCheckIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; @@ -45,17 +51,51 @@ const ToolCode = ({ ); -export type ToolProps = ComponentProps; +export type ToolAutoPermissionDetail = { + decision: "allow"; + reason: string; +}; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +export type ToolProps = ComponentProps & { + autoPermissionDetail?: ToolAutoPermissionDetail; +}; + +export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => { + const toolCard = ( + + {children} + + ); + + if (!autoPermissionDetail) return toolCard; + + return ( +
+ {toolCard} +
+ + + + + Auto-approved + + + + {autoPermissionDetail.reason} + + +
+
+ ); +}; export type ToolHeaderProps = { title?: string; diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index b4574b58..c48df79e 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -4,6 +4,7 @@ import { ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle, Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight, Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles, + Code2, FolderOpen, LayoutTemplate, } from 'lucide-react' import type { z } from 'zod' import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js' @@ -19,6 +20,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' +import { HtmlFileViewer } from '@/components/html-file-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -270,7 +272,16 @@ function TriggersEditor({ // New Task dialog // --------------------------------------------------------------------------- -type DialogMode = 'describe' | 'manual' +type DialogMode = 'describe' | 'manual' | 'templates' | 'coding' + +// Prefills for the "Coding from meetings" preset. +const CODING_PRESET = { + name: 'Implement coding items from meetings', + instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team. + +Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool — group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`, + eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`, +} function NewTaskDialog({ open, @@ -294,6 +305,9 @@ function NewTaskDialog({ const [name, setName] = useState('') const [instructions, setInstructions] = useState('') const [triggers, setTriggers] = useState(undefined) + const [projectId, setProjectId] = useState(undefined) + const [projectName, setProjectName] = useState(undefined) + const [addingProject, setAddingProject] = useState(false) const [submitting, setSubmitting] = useState(false) useEffect(() => { @@ -303,11 +317,64 @@ function NewTaskDialog({ setName('') setInstructions('') setTriggers(undefined) + setProjectId(undefined) + setProjectName(undefined) } }, [open, copilotEnabled]) + // Switch into the coding preset: prefill name/instructions/trigger once. + const enterCodingMode = () => { + setMode('coding') + setName(CODING_PRESET.name) + setInstructions(CODING_PRESET.instructions) + setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria }) + } + + const pickRepo = async () => { + setAddingProject(true) + try { + const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' }) + const dir = res.path + if (!dir) return + const added = await window.ipc.invoke('codeProject:add', { path: dir }) + if (!added.git?.isGitRepo) { + toast('That folder is not a git repository — coding tasks need one.', 'error') + return + } + setProjectId(added.project.id) + setProjectName(added.project.name) + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setAddingProject(false) + } + } + const canSubmitDescribe = description.trim().length > 0 && !submitting const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting + const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting + + const submitCoding = async () => { + if (!canSubmitCoding) return + setSubmitting(true) + try { + const result = await window.ipc.invoke('bg-task:create', { + name: name.trim(), + instructions: instructions.trim(), + ...(triggers ? { triggers } : {}), + ...(projectId ? { projectId } : {}), + }) + if (result.success && result.slug) { + onCreated(result.slug) + } else { + toast(result.error ?? 'Failed to create task', 'error') + } + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error') + } finally { + setSubmitting(false) + } + } const submitDescribe = () => { if (!canSubmitDescribe || !onCreateWithCopilot) return @@ -358,7 +425,116 @@ function NewTaskDialog({ - {mode === 'describe' ? ( + {(mode === 'describe' || mode === 'manual') && ( + + )} + + {mode === 'templates' ? ( + <> +
+ {[ + { + id: 'coding-from-meetings', + title: 'Coding from meetings', + description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.", + icon: Code2, + onSelect: enterCodingMode, + }, + ].map(preset => ( + + ))} +
+ +
+ + +
+ + ) : mode === 'coding' ? ( + <> +
+
+ + {projectName ? ( +
+ + + {projectName} + + +
+ ) : ( + + )} +

+ Code changes run full-auto in an isolated git worktree — your working checkout is never touched. +

+
+
+ + setName(e.target.value)} /> +
+
+ +