diff --git a/.gitignore b/.gitignore index e9a53be..575aaea 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ examples/VPS_SETUP.md .commandcode/settings.json .commandcode/taste/taste.md ecc-pre-bypass.md +blog-draft/crewcode-multi-agent-desktop-ace.md +blog-draft/preview/hero.svg diff --git a/AGENTS.md b/AGENTS.md index da18382..f1a26ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,11 @@ reconciliation, startup, or session switching. Preserve global delivery for newly appended live errors and active-scope delivery for live agent-exit warnings. See `docs/notifications.md`. +Thinking-log headers use the shared outlined thought-bubble icon; keep the +streaming shimmer and separate disclosure chevron behavior intact. +Turn Work Log headers use the shared outlined work-log icon alongside the +disclosure chevron; individual rows retain their tool-specific icons. + Git and GitHub notification-bar events are live and user-triggered only. Publish observed successful commit, push, pull, fetch, sync, merge, repository-publish, and PR mutation results; never notify from polling, catalogue refresh, startup, @@ -78,6 +83,20 @@ once per minute so PRs created or changed by other users update the Browser and open-count badge. Keep that poll single-flight, preserve selection when possible, and never turn polled transitions into notification-bar events. +Custom chat backgrounds and their derived palettes remain device-local appearance +data. Accept only bounded PNG, JPG, WebP, or GIF uploads; never put the image or +palette in transcripts, workspace files, Brain continuity state, or an SSH host. +Fresh chats may always render the image; regular solo chats require an explicit +preference and content-sized translucent agent bubbles; the sticky loader remains +transparent over the wallpaper while its compact loading visual uses a mode-aware +frosted capsule without the decorative text glow. Fresh-chat welcome content, +thinking blocks, and work logs require mode-aware frosted surfaces with bounded +responsive widths; thinking and work-log blocks remain content-sized. +Image-matched colors must be locally derived, contrast-safe, opt-out, and must not +overwrite the selected named theme. +Honor reduced motion and keep removal reversible. See +`docs/fresh-chat-backgrounds.md`. + Git tabs retain meaningful unfinished UI state across inactive-tab unmounts in bounded process-session memory keyed by exact outer tab/worktree identity. Keep commit/amend drafts, PR creation fields/step, PR Browser location, review summary, @@ -102,6 +121,13 @@ use asynchronous filesystem/child-process APIs; never put sync I/O or `spawnSync` in an automatic refresh path. See `docs/terminal-stream-performance.md`. +The composer input context menu provides Cut, Copy, and Paste through the typed +CrewCode clipboard client on desktop and web. Preserve the captured selection, +remove selected text only after a successful clipboard write, refuse stale +asynchronous edits when the draft has changed, surface clipboard denial in the +menu, and keep the theme-token menu inside the viewport. See +`docs/getting-started.md`. + ## What is CrewCode? CrewCode is a desktop ACE (Agent Coding Environment) GUI built with Electron + React + TypeScript. It lets developers run a *crew* of AI coding agents (Claude Code, Codex, OpenCode, etc.) in parallel across local git worktrees, each in its own workspace with a chat thread, embedded terminal panes, and a code/markdown editor — all in one frameless native-feeling window. @@ -123,8 +149,18 @@ shell job control. Keep Git fetch/push on the separate interactive path for cred helpers, and never infer release success until the version commit, tag, and push are all observed. See `docs/releasing.md`. +Automatic provider-usage probes must treat an observed CLI crash differently from an +ordinary unavailable response. Cool down the crashing interactive fallback so window +focus refreshes cannot repeatedly relaunch it and generate a core-dump storm; retain +the non-interactive probe and report usage as unavailable until a later safe retry. + > `npm run dev` uses `env -u ELECTRON_RUN_AS_NODE` to prevent Electron's Node.js mode from interfering. +Displayed CrewCode versions must come from the shared runtime build-info +contract. Keep the app-menu header, About card, and Settings Updates aligned with +Electron's `app.getVersion()` or the connected Brain/server build version; never +hardcode a renderer version label. See `docs/about-crewcode.md`. + ## Architecture This is a standard **electron-vite** three-process project: @@ -229,6 +265,11 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh All changes must consider the SSH use case. Don't assume local-only execution. See `docs/remote-ssh-workspaces.md` for the user-facing behavior contract (ssh:// roots, agent-first auth, TOFU host pinning, remote LSP/polling constraints). +CrewCoder is spawned on the workspace host for both local and SSH roots. Keep ACP +text-file capabilities available, but do not label an SSH CrewCoder session as a +virtual filesystem: its process, provider-native tools, and workspace are co-located +on the VPS. A virtual-custody flag disables native transports such as Codex app-server. + ## GitHub CLI Usage Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows. @@ -304,6 +345,13 @@ while Electron Background Brain owns it. Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. +Hub phone/browser sign-in persists for 30 days through a secure HttpOnly SameSite +cookie whose secret is stored only as a revocable Hub-side digest. Keep this browser +session independent from short-lived machine tickets and from Brain process lifetime. +Electron Background Brain and foreground/headless `crewcode brain` must reuse the same +default owner-only machine credential; switching modes never creates a second machine +or requires phone approval. A custom Brain data directory remains a separate identity. + Hub-connected web Settings lists every machine enrolled to the authenticated owner and keeps reversible disablement distinct from permanent revocation. Disable must persist the authority suspension, reject heartbeats/tickets/new Brain relays, close existing @@ -344,6 +392,20 @@ The composer PromptPicker has separate Prompts and Skills tabs backed by the sha On phones, Code Editor keeps the code canvas primary and opens its file tree as a dismissible right overlay. Git Sidebar must remain the same stateful surface on desktop and mobile, becoming an off-canvas panel with backdrop/close controls instead of being hidden or squeezing chat/editor content. Changes by turn is a full-screen mobile review: its catalogue stacks above the diff when open, while a targeted changed-file route keeps the catalogue closed. Keep all JS/CSS decisions aligned at `≤768px`; see `docs/mobile-responsive-pages.md`. +The Code Editor file tree is lazy and must expose dependency, build-output, cache, +and workspace-local Python environment folders on demand for local and SSH roots. +Keep `.git` and `.DS_Store` hidden from direct browsing. Do not widen bounded +workspace search or fallback scans to recursively index generated/dependency trees. +Open saved editor files must reconcile on Code Editor mount so writes made while +the surface was unmounted are observed. Keep filesystem events as the local fast +path and only one asynchronous active-file check in flight for missed events/SSH. +Never overwrite a dirty buffer: compare disk with its original baseline and mark +a conflict only when both diverged. File-tree context menus must remain anchored +to their row while flipping/clamping fully inside the viewport. Keep their +surface, text, interaction, separator, and destructive colors on shared theme +tokens; do not introduce fixed palette colors. +See `docs/code-editor.md`. + Git Workspace phone layout keeps the shared `useGitSidebar` state/actions, a compact two-column overview, changed files stacked above a bounded diff, and the remaining Git tools in a bounded scroll panel. Use the canonical `≤768px` breakpoint, ≥36px actionable controls, and 16px text inputs; do not restore fixed desktop-width columns or unbounded stacked panels. The final work-log changed-file chips and Turn Changes drawer must use the same turn-change aggregation. A chip targets its exact turn/file in the drawer with the agent-summary/list sidebar closed; do not route it through the ordinary editor file-open action or rebuild a second, lossy file list. @@ -432,3 +494,12 @@ Plugin validation commands: npx vitest run src/main/plugin-contract.test.ts npm run typecheck ``` + +Codex usage reporting must resolve managed npm installations, prefer the +explicit `rateLimitsByLimitId.codex` app-server bucket when available, and +represent unavailable/fetching data explicitly in the Workspace Dock rather +than displaying a misleading `0%`. A dev process launched from within a managed +CrewCoder Codex turn must not pass that parent turn's private `CODEX_HOME`, +thread identity, or managed-launch metadata into the standalone usage probe. +Legacy CrewCoder sessions with an unprefixed `gpt-*` model use the Codex usage +bucket; current namespaced `provider:model` selections remain authoritative. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3280a9..49b28ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,14 @@ # Contributing to CrewCode -Thanks for your interest in CrewCode. Contributions are welcome, but please read this first — CrewCode is maintained by a solo developer, and these guidelines exist to keep that sustainable. +Thanks for your interest in CrewCode. Contributions are welcome, but please read this first, CrewCode is maintained by a solo developer, and these guidelines exist to keep that sustainable. ## Before you write code: open an issue first **Do not open a large pull request without discussing it first.** -For anything beyond a small fix, open an issue describing what you want to change and why, and wait for a maintainer thumbs-up before building it. This protects your time — it's far better to hear "not a direction I want to take" before you've written 1,000 lines than after. +For anything beyond a small fix, open an issue describing what you want to change and why, and wait for a maintainer thumbs-up before building it. This protects your time, it's far better to hear "not a direction I want to take" before you've written 1,000 lines than after. -PRs that don't follow this may be closed without a full review, simply because the change wasn't agreed on up front. That's not personal — it's how a one-person project stays maintainable. +PRs that don't follow this may be closed without a full review, simply because the change wasn't agreed on up front. That's not personal , it's how a one-person project stays maintainable. ## What gets merged @@ -36,7 +36,7 @@ Be conservative about adding new dependencies. Every new package is a maintenanc ## Security -CrewCode has filesystem, shell, git, and SSH access — it is security-sensitive. Every PR is reviewed line by line, and changes touching process execution, file access, or dependencies get extra scrutiny. +CrewCode has filesystem, shell, git, and SSH access, it is security-sensitive. Every PR is reviewed line by line, and changes touching process execution, file access, or dependencies get extra scrutiny. Do **not** report security vulnerabilities in public issues or pull requests. See [SECURITY.md](./SECURITY.md) for how to report them privately. diff --git a/README.md b/README.md index b8acf94..eda1bc3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![X](https://img.shields.io/badge/X-@OnPointTools-000000.svg)](https://x.com/OnPointTools) [![YouTube](https://img.shields.io/badge/YouTube-@CjWisdom-FF0000.svg)](https://www.youtube.com/@CjWisdom) -**[Want to continue where you left off — from any browser? Jump to the setup →](#continue-your-work-from-anywhere)** +**[Want to continue where you left off, from any browser? Jump to the setup →](#continue-your-work-from-anywhere)** @@ -86,7 +86,7 @@ updated" produces threads you can open, read, and continue yourself. This is distinct from Crew Surface. Crew lanes are ephemeral, live in one tab, and exist for the duration of a crew run. Delegated threads are ordinary `Session`s with -transcripts, archiving, and drawer rows — they outlive the turn that made them. +transcripts, archiving, and drawer rows, they outlive the turn that made them. ### Control Center for supervision @@ -131,7 +131,7 @@ with the coding agent in the active chat. - Run local speech with NVIDIA Parakeet TDT 0.6B v2 for transcription and Kokoro-82M with the `am_michael` voice for speech - Choose Automatic, GPU, or CPU local inference; idle models unload while the lightweight voice sidecar stays available - Keep hosted provider keys in Electron's main process; permanent keys never enter renderer state -- Use the separate composer microphone for speech-to-text only—it inserts text at the caret for review. +- Use the separate composer microphone for speech-to-text only, it inserts text at the caret for review. Realtime voice is off by default. Hosted providers require their own API keys and billing; local voice requires a Python 3.11 environment and the documented @@ -153,7 +153,7 @@ for setup, architecture, provider availability, and security details. ### Workbench/Canvas mode -— Run multiple chats and terminals at once on the same worktree. +- Run multiple chats and terminals at once on the same worktree. ### Code Editor @@ -186,12 +186,12 @@ Plugin agent providers support `mock`, `exec`, and `http` runtimes, so you can c ## Continue your work from anywhere -Leave your PC running at home and pick up exactly where you left off from a browser — +Leave your PC running at home and pick up exactly where you left off from a browser, or run CrewCode headless on a VPS, NAS, or always-on server so your agents are always reachable. -- **Desktop & web continuity (Background Brain):** enable **Settings → Desktop & Web** on an enrolled machine and CrewCode keeps a detached Brain running even after the Electron window closes. Desktop and browser share the same machine-authoritative workspaces, chat sessions, transcripts, terminals, and agents — provider resume state carries over, so you continue the same conversation instead of starting from scratch. -- **Self-hosted Hub:** one always-on `crewcode hub` gives you a single passkey-secured dashboard of all your enrolled machines. Brains connect outbound-only, so your dev machines need no open inbound ports. The relay is end-to-end encrypted — the Hub never sees your code, prompts, transcripts, or terminal output, and files never leave the machine they live on. +- **Desktop & web continuity (Background Brain):** enable **Settings → Desktop & Web** on an enrolled machine and CrewCode keeps a detached Brain running even after the Electron window closes. Desktop and browser share the same machine-authoritative workspaces, chat sessions, transcripts, terminals, and agents, provider resume state carries over, so you continue the same conversation instead of starting from scratch. +- **Self-hosted Hub:** one always-on `crewcode hub` gives you a single passkey-secured dashboard of all your enrolled machines. Brains connect outbound-only, so your dev machines need no open inbound ports. The relay is end-to-end encrypted, the Hub never sees your code, prompts, transcripts, or terminal output, and files never leave the machine they live on. - **Always available:** run `crewcode brain` on a VPS or home server with explicit workspace roots and your workspaces are reachable from any device, anytime. - **Phone access:** the Hub's mobile dashboard lists your online machines and recent threads, with a QR-assisted setup over Tailscale HTTPS. - **Secure by default:** remote authority is off until you grant explicit workspace roots and scopes; pairing codes and connection tickets are short-lived and single-use; sign-in is passkey-based; provider API keys never leave the Brain's machine. @@ -298,7 +298,7 @@ npm run typecheck ## Contributing -CrewCode is open to contributions — issues, bug reports, and pull requests are all welcome. If you're planning a larger change, open an issue first so we can talk through the approach before you build it. +CrewCode is open to contributions, issues, bug reports, and pull requests are all welcome. If you're planning a larger change, open an issue first so we can talk through the approach before you build it. ## License @@ -310,6 +310,6 @@ In short: you are free to use, modify, and redistribute CrewCode, including in c Some bundled components are under different licenses and are **not** covered by Apache-2.0. See [NOTICE](./NOTICE) for the full list. -Most notably, the file-tree icon set in `src/renderer/src/assets/bearded-icons/` is [Bearded Icons](https://github.com/BeardedBear/bearded-icons) by BeardedBear, licensed under **GPL-3.0**. These assets are used unmodified and are aggregated with — not incorporated into — CrewCode's Apache-licensed source. If you fork CrewCode and need a fully permissive stack, replace that directory with a permissively licensed icon set. +Most notably, the file-tree icon set in `src/renderer/src/assets/bearded-icons/` is [Bearded Icons](https://github.com/BeardedBear/bearded-icons) by BeardedBear, licensed under **GPL-3.0**. These assets are used unmodified and are aggregated with, not incorporated into, CrewCode's Apache-licensed source. If you fork CrewCode and need a fully permissive stack, replace that directory with a permissively licensed icon set. Copyright © 2026 OnPoint Tools. diff --git a/docs/README.md b/docs/README.md index b33c88b..ae3a360 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ internal references). | [keybindings.md](./keybindings.md) | Every shortcut, defaults per group, and the editable `~/.crewcode/keys.json` override file | | [tweaks-panel.md](./tweaks-panel.md) | The floating Layout panel: density and workspace dock presentation controls | | [system-monitor.md](./system-monitor.md) | CPU/memory pill and panel: per-workspace process usage, jump-to and kill controls | +| [about-crewcode.md](./about-crewcode.md) | About card and automatically sourced app/build version | ### Chat and agents @@ -23,6 +24,7 @@ internal references). | [using-crews.md](./using-crews.md) | Crews: parallel lanes, supervisors, and the select → verify → reconcile → apply Merge lanes workflow | | [reasoning-effort.md](./reasoning-effort.md) | Provider-native reasoning effort levels in the composer and crew lanes | | [realtime-voice.md](./realtime-voice.md) | Voice orb providers, coding-agent routing, natural spoken replies, credentials, and local roadmap | +| [fresh-chat-backgrounds.md](./fresh-chat-backgrounds.md) | Device-local chat wallpapers, regular-chat coverage, and image-derived app colors | | [prompt-skill-studio.md](./prompt-skill-studio.md) | Prompt Builder Studio and local `.crewcode` prompt/skill folders _(needs a user-guide rewrite — currently changelog-style)_ | ### Workspaces diff --git a/docs/about-crewcode.md b/docs/about-crewcode.md new file mode 100644 index 0000000..8316668 --- /dev/null +++ b/docs/about-crewcode.md @@ -0,0 +1,16 @@ +# About CrewCode + +Open the CrewCode brand menu and select **About CrewCode** to see a compact app +identity card. It shows the running CrewCode version and build commit. + +The app menu header, About card, and **Settings → Updates** all read the same +runtime build-info contract. Desktop builds use Electron's packaged app version; +browser clients read the version of the CrewCode server or Brain they are +connected to. The Hub relay classifies that metadata read under its existing +`workspace:read` scope. No version label is maintained separately in renderer +code. + +The version advances through the normal release scripts, which update +`package.json`. Electron packages expose that value through `app.getVersion()`, +and headless entries receive the same package version at build time. An installed +update therefore reports its new version after CrewCode restarts into that build. diff --git a/docs/code-editor.md b/docs/code-editor.md index 99b9d1f..033ea02 100644 --- a/docs/code-editor.md +++ b/docs/code-editor.md @@ -6,6 +6,9 @@ CrewCode's code editor uses a fork of CodeMirror 6 for the active editing surfac - `src/renderer/src/components/editor/CodeEditor.tsx` owns the surrounding product UI: tabs, file tree, save/format actions, disk-change conflict handling, plugin editor actions, and search-result jumps. - The file-tree context menu **Cut** / **Copy** / **Paste** a file or folder through an in-session clipboard (not the OS clipboard; **Copy path** still does that). Paste into the folder under the cursor, a file's parent, or the tree root. **Copy** then paste uses sandboxed `fs.copyFile` and suffixes name collisions with `copy`. **Cut** then paste uses sandboxed `fs.move`, dims the source row until the move lands, refuses the current parent and pasting a folder into itself, and clears the clipboard only after a successful move. **Duplicate** still creates a sibling copy in place. The clipboard is workspace-scoped and clears when the tree root changes. SSH workspaces use the remote copy/move paths. +- The lazy file tree exposes workspace-local dependency, generated-output, cache, and Python environment folders such as `node_modules`, `dist`, `.next`, `.cache`, `.venv`, and `site-packages`. Their contents load only when expanded, for both local and SSH workspaces. Repository metadata (`.git`) and OS metadata (`.DS_Store`) remain hidden. Broad workspace search and fallback indexing continue to respect Git ignores and prune large generated/dependency trees. +- Open files reconcile with disk whenever the Code Editor mounts, so an agent edit made while another outer tab was active is not accepted as a new watch baseline. Native filesystem events remain the fast path; a 1.5-second, single-flight check of only the active saved file covers missed events and SSH workspaces. Clean buffers reload automatically. Unsaved buffers are never overwritten: CrewCode marks a conflict only when disk also diverged from the buffer's original baseline. +- The file-tree context menu stays anchored near the right-click location, flips left/up at the viewport edges, and clamps to an 8px viewport margin so every action remains visible. It deliberately does not jump to the screen center, which would detach a contextual action from the row that opened it. Its surface, text, hover, focus, separator, and destructive states use the shared theme tokens rather than fixed colors. - Git Sidebar changed-file rows open the active worktree's patch in the editor's existing `PierreDiff` review surface. The Settings-selected default branch is the comparison ref when configured; closing the review returns to the prior editor state without changing the checked-out branch. - `src/renderer/src/components/editor/CrewCodeMirrorEditor.tsx` owns the live editing surface. - CodeMirror is intentionally kept below `CodeEditor` so high-frequency typing, selection, autocomplete, and scroll state do not force broad React/App re-renders. diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index b5bda88..2a6d948 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -236,7 +236,12 @@ local path as remote access. For SSH roots, `spawnAgentProcess` starts CrewCoder on the remote host. Its bash tool is therefore local to the spawned CrewCoder process, which means remote for -an SSH workspace. +an SSH workspace. The initialize handshake consequently keeps +`crewcode/virtualFilesystem` false for SSH just as it does locally: ACP text-file +methods may still proxy through CrewCode's SFTP layer, but the CrewCoder process, +Codex app-server, and workspace share the same VPS filesystem. Marking that setup +virtual would incorrectly disable provider-native file tools and force Codex onto +its direct Responses fallback. Current limitation: the local ACP filesystem host reads saved disk bytes. It does not yet query dirty CodeMirror or Writer buffers in the renderer, so an unsaved diff --git a/docs/current-state.md b/docs/current-state.md index 5fe6e5b..025e0e1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,6 +4,24 @@ Desktop system-tray behavior is opt-in through **Settings → General → Keep r Real agent integration is wired through normalized bridges (pi, OpenCode, Claude, Codex, Hermes, CrewCoder, Ollama, and OpenRouter) with PTY panes remaining available for terminals. +The CrewCode app menu includes an **About CrewCode** card with the running app +version and build commit. Its menu-header version, the About card, and Settings +Updates all share the runtime `appBuildInfo` source. Electron uses +`app.getVersion()`; browser clients read the serving Brain/server build info +instead of displaying a renderer placeholder. See `docs/about-crewcode.md`. + +Codex usage reporting prefers the standalone npm CLI at +`~/.codex-cli-npm/bin/codex` when present, then resolves managed npm +installations through `CODEX_MANAGED_PACKAGE_ROOT`. It accepts both the legacy +`rateLimits` response and the newer `rateLimitsByLimitId.codex` app-server +bucket. The Workspace Dock shows an em dash while usage is unavailable or +fetching instead of presenting missing data as `0%`. When a development build +is started from inside a managed CrewCoder Codex turn, its standalone usage +probe removes the parent turn's private Codex runtime identity before launch so +the CLI reads the user's normal Codex account state rather than CrewCoder's +app-server state directory. The dock also recognizes legacy persisted CrewCoder +sessions whose Codex GPT model id predates the current `codex:model` namespace. + Workspaces, worktrees, git operations, terminals, settings, and crew sessions are all real and persisted (workspaces + tabs to disk, messages to localStorage). ## Desktop and web continuity @@ -28,7 +46,10 @@ probe, attachment, and hydration phases during the enable-triggered reload inste leaving the desktop window blank. Desktop & Web Settings probes the enrolled Hub, shows its observed canonical browser/passkey origin with an **Open Hub** action, and states that enabling Brain does not start the separate Hub service. The enrollment address is -never treated as proof of browser origin or reachability. See +never treated as proof of browser origin or reachability. Foreground and Electron +Background Brain modes share the default owner-only machine credential, while the Hub +keeps a phone signed in across browser and Brain restarts through a revocable 30-day +HttpOnly session cookie whose secret is stored only as a digest. See `docs/desktop-web-continuity.md`. Settings → General stores a workspace-scoped default branch. The selector detects local branches from the active repository. A new solo-chat session captures that setting once, reuses or creates the branch worktree, selects it for that chat surface, then clears the one-shot request so existing chats and later manual branch switches are never moved retroactively. Git Workspace and Git Sidebar also use the live setting as their comparison base without checking it out: committed branch differences remain reviewable alongside local status, while only true working-tree rows expose stage/unstage actions. Delegated threads retain their separate base/worktree contract. @@ -177,7 +198,7 @@ Writer DOCX/PDF support is a conversion workflow, not native binary editing. Per ## Code Editor -The code editor's active editing surface is CodeMirror 6 (`CrewCodeMirrorEditor`) wrapped by `CodeEditor`, which still owns tabs, file-tree UI, save/format, disk-conflict prompts, plugin editor actions, and its Pierre diff review surface. File-tree **Cut** / **Copy** / **Paste** use a workspace-scoped in-session clipboard (not the OS clipboard; **Copy path** still does that). Copy pastes through sandboxed `fs.copyFile` with a `copy` suffix on collisions; cut pastes through sandboxed `fs.move`, dims the source until it lands, and refuses the current parent or a folder into itself. Keep **Duplicate** as a same-directory sibling copy. Clicking a changed file in Git Sidebar opens the active worktree's single-file patch in that editor diff surface; it must not diff the workspace root when a worktree is selected. Keep high-frequency editor document/selection/autocomplete state inside CodeMirror instead of lifting it into `App.tsx`. Direct `@codemirror/*` dependencies use local `file:` links to the independently cloned package repositories under `packages/crew-codemirror`; rebuild those sources with `npm run codemirror:build`, and never run the upstream `codemirror:install` bootstrap over uncommitted package edits because it hard-resets every child repository. Editor themes come from `packages/crew-codemirror/theme-library`, use the checked IDs in `src/shared/editor-theme-types.ts`, and must reconfigure through CodeMirror's theme `Compartment` so switching palettes never destroys document, history, selection, scroll, or LSP state. Editor file/tree icons use the vendored Bearded Icons assets and mapping under `src/renderer/src/assets/bearded-icons`; preserve its GPL-3.0 license/attribution, prefer exact filename then compound-extension mappings, and never runtime-reference the gitignored `.crewcode/` source. The FileTree Outline must derive active-document symbols inside CodeMirror: prefer hierarchical LSP document symbols for TS/JS, use the bounded local fallback for supported non-LSP languages, reject stale responses by document identity, and keep symbol extraction out of `App.tsx`. TypeScript/JavaScript intelligence uses one shared `@codemirror/lsp-client` and `typescript-language-server` process per workspace. Keep JSON-RPC framing and process ownership in the main process, sanitize LSP Markdown, refuse definition/problem paths outside the workspace sandbox, and launch SSH language servers remotely rather than reading remote projects through local-only paths; remote hosts must provide TypeScript and `typescript-language-server`, and CrewCode must not install them automatically. The Problems and workspace-search indexes must remain bounded. Code Actions currently apply only validated, non-overlapping edits to an unchanged active document; fail closed on commands, stale responses, malformed ranges, and multi-file edits. LSP rename and workspace replace are preview-first multi-file flows: block affected dirty tabs, reject outside-workspace/stale/malformed edits, verify file snapshots immediately before writing, and roll back completed writes after a later failure. Preserve the same guarantees over SSH. Editor AI completions use a dedicated provider/model setting and disposable `agent:completion` bridges: bounded context, `toolPolicy: 'read-only'`, `thinking: 'off'`, no persisted conversation/resume state, 20-second timeout, cancellation on every stale edit, and built-in providers only (no plugins/Copilot API). Ghost text must never show model reasoning: disabling provider reasoning is not sufficient, because models that inline `` blocks into the content stream arrive as ordinary `text_delta`. Every completion route must normalize through the shared `src/main/agents/completion-text.ts` (strip reasoning blocks, reject unterminated ones, then unwrap one fence) — do not re-inline a per-provider copy of that logic. Completion-only hosted APIs are deliberately distinct from chat providers: OpenCode Go uses its OpenAI-compatible bearer-key endpoint and OpenRouter reuses its API route; both must remain ephemeral and never write completion content to conversation history. See `docs/code-editor.md`. +The code editor's active editing surface is CodeMirror 6 (`CrewCodeMirrorEditor`) wrapped by `CodeEditor`, which still owns tabs, file-tree UI, save/format, disk-conflict prompts, plugin editor actions, and its Pierre diff review surface. Open saved files reconcile against disk when the editor mounts, use filesystem events as the local fast path, and retain one bounded single-flight active-file check for missed events and SSH; clean agent edits reload, while a dirty buffer is preserved and marked conflicted only when disk diverges from its original baseline. The lazy file tree keeps dependency, generated-output, cache, and workspace-local Python environment folders browsable on demand in local and SSH workspaces; only `.git` and `.DS_Store` stay hidden from direct browsing, while broad search/fallback scans retain their generated-tree exclusions. Its context menu stays cursor-anchored but flips and clamps within the viewport rather than rendering off-screen. File-tree **Cut** / **Copy** / **Paste** use a workspace-scoped in-session clipboard (not the OS clipboard; **Copy path** still does that). Copy pastes through sandboxed `fs.copyFile` with a `copy` suffix on collisions; cut pastes through sandboxed `fs.move`, dims the source until it lands, and refuses the current parent or a folder into itself. Keep **Duplicate** as a same-directory sibling copy. Clicking a changed file in Git Sidebar opens the active worktree's single-file patch in that editor diff surface; it must not diff the workspace root when a worktree is selected. Keep high-frequency editor document/selection/autocomplete state inside CodeMirror instead of lifting it into `App.tsx`. Direct `@codemirror/*` dependencies use local `file:` links to the independently cloned package repositories under `packages/crew-codemirror`; rebuild those sources with `npm run codemirror:build`, and never run the upstream `codemirror:install` bootstrap over uncommitted package edits because it hard-resets every child repository. Editor themes come from `packages/crew-codemirror/theme-library`, use the checked IDs in `src/shared/editor-theme-types.ts`, and must reconfigure through CodeMirror's theme `Compartment` so switching palettes never destroys document, history, selection, scroll, or LSP state. Editor file/tree icons use the vendored Bearded Icons assets and mapping under `src/renderer/src/assets/bearded-icons`; preserve its GPL-3.0 license/attribution, prefer exact filename then compound-extension mappings, and never runtime-reference the gitignored `.crewcode/` source. The FileTree Outline must derive active-document symbols inside CodeMirror: prefer hierarchical LSP document symbols for TS/JS, use the bounded local fallback for supported non-LSP languages, reject stale responses by document identity, and keep symbol extraction out of `App.tsx`. TypeScript/JavaScript intelligence uses one shared `@codemirror/lsp-client` and `typescript-language-server` process per workspace. Keep JSON-RPC framing and process ownership in the main process, sanitize LSP Markdown, refuse definition/problem paths outside the workspace sandbox, and launch SSH language servers remotely rather than reading remote projects through local-only paths; remote hosts must provide TypeScript and `typescript-language-server`, and CrewCode must not install them automatically. The Problems and workspace-search indexes must remain bounded. Code Actions currently apply only validated, non-overlapping edits to an unchanged active document; fail closed on commands, stale responses, malformed ranges, and multi-file edits. LSP rename and workspace replace are preview-first multi-file flows: block affected dirty tabs, reject outside-workspace/stale/malformed edits, verify file snapshots immediately before writing, and roll back completed writes after a later failure. Preserve the same guarantees over SSH. Editor AI completions use a dedicated provider/model setting and disposable `agent:completion` bridges: bounded context, `toolPolicy: 'read-only'`, `thinking: 'off'`, no persisted conversation/resume state, 20-second timeout, cancellation on every stale edit, and built-in providers only (no plugins/Copilot API). Ghost text must never show model reasoning: disabling provider reasoning is not sufficient, because models that inline `` blocks into the content stream arrive as ordinary `text_delta`. Every completion route must normalize through the shared `src/main/agents/completion-text.ts` (strip reasoning blocks, reject unterminated ones, then unwrap one fence) — do not re-inline a per-provider copy of that logic. Completion-only hosted APIs are deliberately distinct from chat providers: OpenCode Go uses its OpenAI-compatible bearer-key endpoint and OpenRouter reuses its API route; both must remain ephemeral and never write completion content to conversation history. See `docs/code-editor.md`. ## Workbench Mode diff --git a/docs/desktop-web-continuity.md b/docs/desktop-web-continuity.md index c6fe777..0f74656 100644 --- a/docs/desktop-web-continuity.md +++ b/docs/desktop-web-continuity.md @@ -39,6 +39,15 @@ and `npm run hub:mobile` (Tailscale HTTPS). The foreground `npm run brain` comma for headless/manual operation only; stop or disable Electron Background Brain before using the same default Brain data directory. +Enrollment is shared across those two Brain modes. Both read the machine credential +from the default `~/.crewcode/brain/hub-machine.json`, so changing between Electron +Background Brain and foreground/headless `npm run brain` must not launch another phone +approval or create another machine. The Hub separately remembers a successful phone +sign-in with a revocable 30-day HttpOnly browser cookie backed by a persisted digest; +Brain restarts and Brain-mode changes do not clear it. Passkey sign-in is required +again only after explicit sign-out, cookie/site-data removal, session expiry, or Hub +revocation. A custom `--data-dir` intentionally represents a different Brain identity. + Enabling starts a detached `crewcode brain --desktop-background` process. Electron probes an owner-only loopback rendezvous and reloads onto the same typed RPC/event adapter used by the browser while retaining desktop-only window, picker, updater, diff --git a/docs/fresh-chat-backgrounds.md b/docs/fresh-chat-backgrounds.md new file mode 100644 index 0000000..dfc82bb --- /dev/null +++ b/docs/fresh-chat-backgrounds.md @@ -0,0 +1,11 @@ +# Chat backgrounds and adaptive colors + +CrewCode can show a custom image behind the welcome surface of every fresh solo chat. The fresh-chat welcome content sits on a responsive, light- or dark-mode-aware frosted surface so its identity, prompt suggestions, and supporting copy stay readable without obscuring the surrounding image. **Show in regular solo chats** keeps that image behind the conversation after the first message; left-aligned, content-sized agent bubbles preserve message contrast without hiding the image or stretching across the regular-density reading column. Thinking and work-log blocks are also content-sized and use mode-aware frosted surfaces. They may expand only as far as the established chat reading width when their contents need more room. The sticky loader container stays transparent, while its compact loading visual receives a matching frosted capsule and drops the decorative yellow text glow so it remains crisp over the image. Crew and terminal panes are not changed. + +Choose, replace, or remove the image in **Settings → Appearance → Chat background**. CrewCode accepts PNG, JPG, WebP, and GIF files up to 2 MB, centers the image, and crops it to fill the available chat canvas. A contrast overlay keeps welcome copy and suggestions readable. + +**Match CrewCode colors** derives a compact light or dark palette from a downsampled copy of the image in the renderer. New uploads enable this option after successful analysis. The derived palette changes app surface, border, accent, sidebar, and chat-bubble tokens, but does not overwrite the user's selected named theme. Turning the option off or removing the background restores that named theme. + +The image and derived palette are stored in renderer-local settings. They remain specific to the current device/browser profile: neither is written into a workspace, transcript, Brain continuity data, or an SSH host. Color analysis uses the browser canvas locally and makes no network request. + +SVG is intentionally not accepted because uploaded SVG can contain active markup. Reduced-motion preferences disable the wallpaper entrance animation. diff --git a/docs/getting-started.md b/docs/getting-started.md index 094c228..c9a0987 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -86,7 +86,10 @@ A provider that isn't detected shows as unavailable in the model picker. from Threads. 2. **Start a chat** — pick an agent and model in the composer, choose a mode (**ask / plan / build / full**), and send. Plan mode is read-only; you - switch to build when you're ready for edits. + switch to build when you're ready for edits. Right-click the composer input + for theme-aware **Cut**, **Copy**, and **Paste** actions. Cut and Copy require + selected text; denied clipboard access is reported in the menu and never + removes or replaces the draft. 3. **Watch the work** — tool calls, todos, and diffs stream into the thread. The terminal column (`Cmd J`) and code editor live in the same tab. 4. **Review and commit** — the Git Sidebar or the full diff --git a/docs/releasing.md b/docs/releasing.md index abf6ff4..2c68deb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -41,6 +41,13 @@ configured credential helpers can still authenticate. A verification command tha needs input must fail explicitly rather than leaving a stopped release job that could later resume and mutate version/tag state. +CrewCode's desktop usage indicators probe installed provider CLIs independently of +the release command. If Claude's interactive usage process exits from a crash signal, +CrewCode pauses that fallback for ten minutes. This prevents focus-driven refreshes +from creating a repeated core-dump loop while leaving the direct usage probe available. +It is not evidence that an interrupted release succeeded; release state must still be +verified from the version commit and remote tag. + ## What CI does Pushing a `v*` tag triggers `.github/workflows/release.yml`, which builds on @@ -155,7 +162,7 @@ update as never-installing. Upgraders holding the old `autoUpdate`/`installOnQui booleans are migrated onto the policy by `migrateUpdatePolicy`. Dev builds short circuit to an `unconfigured` event. -The version and build hash in Settings come from `app:buildInfo` +The version and build hash in the app-menu header, About card, and Settings come from `app:buildInfo` (`app.getVersion()` plus a `__BUILD_HASH__` short SHA injected by `electron.vite.config.ts`). Never hardcode them in the UI — a stale literal misreports the version at exactly the moment a user checks it, right after an diff --git a/docs/remote-ssh-workspaces.md b/docs/remote-ssh-workspaces.md index d234dae..4a2227a 100644 --- a/docs/remote-ssh-workspaces.md +++ b/docs/remote-ssh-workspaces.md @@ -54,7 +54,7 @@ the connection** (possible MITM or server rekey). | Area | Behavior | | --- | --- | -| File tree / editor | SFTP-backed reads and writes | +| File tree / editor | SFTP-backed reads and writes; the active saved file uses a bounded single-flight check for agent edits because remote filesystem events are unavailable | | Git | git runs on the remote host (remote credentials/agent apply) | | Terminals | remote shell sessions | | Agents | agent CLIs run on the remote host | @@ -62,9 +62,11 @@ the connection** (possible MITM or server rekey). | Writer file watching | bounded polling (remote filesystem events are unavailable) | CrewCode advertises ACP text-file methods for both local and remote CrewCoder -sessions. Remote custody is negotiated separately through explicit initialize -metadata; the presence of file methods alone does not disable provider-native -tools or reject providers in an ordinary local chat. +sessions. Those methods do not make an SSH workspace virtual to CrewCoder: the +agent process is launched on the remote workspace host, so provider-native tools +and transports retain direct access to that same filesystem. ACP reads and writes +can still traverse CrewCode's bounded SFTP helpers without disabling native Codex +app-server operation. ## Limitations diff --git a/docs/tailwind-renderer.md b/docs/tailwind-renderer.md index a21233b..9ce47fd 100644 --- a/docs/tailwind-renderer.md +++ b/docs/tailwind-renderer.md @@ -11,6 +11,12 @@ CrewCode's renderer supports Tailwind CSS v4 utilities through `@tailwindcss/vit ## Usage rules +Thinking-log headers use the shared outlined thought-bubble icon rather than the +generic sparkle glyph. The separate disclosure chevron continues to communicate +expand/collapse state. Turn Work Log headers pair their disclosure chevron with +the shared outlined activity-ledger icon; individual rows retain their +tool-specific icons. + Use Tailwind utilities for new or converted renderer component layout and responsive behavior. Do not hardcode a second palette or bypass the tokens in `colors_and_type.css`. Technical values and tool output remain in the configured mono font. Legacy root class names may remain when tests or integrations use them as stable row identities. In that case, keep compatibility selectors narrow and implement the component's internal layout with utilities. diff --git a/docs/web-remote-access.md b/docs/web-remote-access.md index f528a51..72cea0a 100644 --- a/docs/web-remote-access.md +++ b/docs/web-remote-access.md @@ -263,6 +263,11 @@ protocol and must never be required for self-hosted operation. - Subsequent browser sessions authenticate to the Hub with WebAuthn/passkeys. An optional external OIDC provider may be configured by the Hub owner, but is not required. +- Successful Hub sign-in creates a revocable 30-day trusted-browser session. Its + HttpOnly cookie persists across phone/browser restarts, while only its digest is + stored in Hub SQLite. Clearing site data, signing out, expiry, or server-side + revocation requires passkey sign-in again; none of those events enrolls or + re-enrolls a machine. - The browser uses secure, HttpOnly, SameSite cookies for the Hub session; Hub bearer tokens must not be stored in `localStorage`. - State-changing Hub routes require CSRF protection and exact checks against the @@ -573,6 +578,14 @@ tokens are never written to the Hub database and are invalidated by Hub restart, expiry, first successful use, or a failed guess against their id. +That credential belongs to the machine, not to a particular Brain process. Electron +Background Brain and the foreground/headless `crewcode brain` command both reuse the +same default `~/.crewcode/brain/hub-machine.json`; stop one before starting the other, +but do not enroll again when switching modes. A custom `--data-dir` is a deliberately +separate Brain identity and therefore does not reuse the default credential. The +phone's persisted Hub browser session is likewise Hub-owned rather than Brain-owned, +so restarting or changing Brain mode does not forget the phone. + Remote authority is disabled by default. Enable only explicit Brain-local roots and scopes, for example: diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9533de0..5d26c59 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,9 +3,10 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { resolve } from 'path' import { execSync } from 'child_process' +import { readFileSync } from 'fs' -// Settings shows the build hash next to the version so a bug report identifies -// an exact commit. Resolved at build time because the packaged app has no .git. +// Version surfaces show the build hash so a bug report identifies an exact +// commit. Resolve it at build time because the packaged app has no .git. function buildHash(): string { try { return execSync('git rev-parse --short HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || 'dev' @@ -14,10 +15,15 @@ function buildHash(): string { } } +const appVersion = JSON.parse(readFileSync(resolve('package.json'), 'utf8')).version as string + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], - define: { __BUILD_HASH__: JSON.stringify(buildHash()) }, + define: { + __APP_VERSION__: JSON.stringify(appVersion), + __BUILD_HASH__: JSON.stringify(buildHash()), + }, build: { rollupOptions: { external: ['fsevents'], diff --git a/src/main/agents/crewcoder-bridge.test.ts b/src/main/agents/crewcoder-bridge.test.ts index f99a94b..149250b 100644 --- a/src/main/agents/crewcoder-bridge.test.ts +++ b/src/main/agents/crewcoder-bridge.test.ts @@ -32,17 +32,17 @@ describe('CrewCoder compact capability', () => { }) describe('CrewCoder filesystem custody handshake', () => { - it('keeps local ACP file capabilities separate from virtual custody', () => { - expect(crewCoderInitializeParams(false)).toEqual(expect.objectContaining({ + it('keeps ACP file capabilities separate from virtual custody', () => { + expect(crewCoderInitializeParams()).toEqual(expect.objectContaining({ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false }, _meta: { 'crewcode/virtualFilesystem': false }, })) }) - it('marks the filesystem virtual only for a remote process', () => { - expect(crewCoderInitializeParams(true)).toEqual(expect.objectContaining({ + it('does not disable provider-native tools for SSH because CrewCoder runs on the workspace host', () => { + expect(crewCoderInitializeParams()).not.toMatchObject({ _meta: { 'crewcode/virtualFilesystem': true }, - })) + }) }) }) diff --git a/src/main/agents/crewcoder-bridge.ts b/src/main/agents/crewcoder-bridge.ts index 24663f3..09d1abb 100644 --- a/src/main/agents/crewcoder-bridge.ts +++ b/src/main/agents/crewcoder-bridge.ts @@ -404,17 +404,18 @@ function splitModel(model: string | undefined): { provider?: string; model?: str return { provider: value.slice(0, separator), model: value.slice(separator + 1) } } -export function crewCoderInitializeParams(remote: boolean): Record { +export function crewCoderInitializeParams(): Record { return { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false, }, - // File capabilities are advertised for local chats too. Keep virtual - // custody as a separate, explicit signal so providers are restricted only - // when those methods proxy a filesystem outside the agent process host. - _meta: { 'crewcode/virtualFilesystem': remote }, + // spawnAgentProcess co-locates CrewCoder with the workspace for both local + // and SSH roots. ACP file methods remain available for host-mediated text + // I/O, but this is not a virtual filesystem boundary: provider-native file + // tools and transports operate on the same host and workspace. + _meta: { 'crewcode/virtualFilesystem': false }, } } @@ -817,7 +818,7 @@ export async function createCrewCoderBridge( }) try { - const initializeResult = await request('initialize', crewCoderInitializeParams(remote)) + const initializeResult = await request('initialize', crewCoderInitializeParams()) compactMethod = crewCoderCompactMethod(initializeResult) let resumed = false diff --git a/src/main/build-info.test.ts b/src/main/build-info.test.ts new file mode 100644 index 0000000..cdffe76 --- /dev/null +++ b/src/main/build-info.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { BUILD_HASH, createAppBuildInfo } from './build-info' + +describe('app build info', () => { + it('combines the runtime version with the build-time commit identity', () => { + expect(createAppBuildInfo('9.8.7', true)).toEqual({ + version: '9.8.7', + buildHash: BUILD_HASH, + packaged: true, + }) + }) +}) diff --git a/src/main/build-info.ts b/src/main/build-info.ts new file mode 100644 index 0000000..090151d --- /dev/null +++ b/src/main/build-info.ts @@ -0,0 +1,17 @@ +import type { AppBuildInfo } from '../shared/updater-types' + +// Injected into every main-process entry by electron-vite. Headless/browser +// runtimes cannot ask Electron's app object, so they use the same package +// version captured at build time. +declare const __APP_VERSION__: string +declare const __BUILD_HASH__: string + +export const BUILD_VERSION = typeof __APP_VERSION__ === 'string' + ? __APP_VERSION__ + : process.env.npm_package_version ?? 'dev' + +export const BUILD_HASH = typeof __BUILD_HASH__ === 'string' ? __BUILD_HASH__ : 'dev' + +export function createAppBuildInfo(version = BUILD_VERSION, packaged = false): AppBuildInfo { + return { version, buildHash: BUILD_HASH, packaged } +} diff --git a/src/main/filesystem-service.test.ts b/src/main/filesystem-service.test.ts index 6b59fd1..9d4c476 100644 --- a/src/main/filesystem-service.test.ts +++ b/src/main/filesystem-service.test.ts @@ -66,12 +66,33 @@ describe('FilesystemService', () => { expect(service.copyFile(root, 'readme.md', '../')).toEqual({ error: 'destination escapes root' }) }) - it('lists directories while hiding ignored dependency trees', async () => { + it('keeps dependency, build, and Python environment folders browsable on demand', async () => { const { root, service } = fixture() mkdirSync(join(root, 'node_modules')) + mkdirSync(join(root, 'dist')) + mkdirSync(join(root, '.venv')) writeFileSync(join(root, 'visible.ts'), 'ok') writeFileSync(join(root, 'node_modules', 'hidden.js'), 'no') const result = await service.readDir(root) - expect(result.nodes?.map(node => node.name) ?? []).toEqual(['visible.ts']) + expect(result.nodes?.map(node => node.name) ?? []).toEqual(['.venv', 'dist', 'node_modules', 'visible.ts']) + expect((await service.readDir(root, 'node_modules')).nodes?.map(node => node.name)).toEqual(['hidden.js']) + }) + + it('still hides repository metadata from the editor tree', async () => { + const { root, service } = fixture() + mkdirSync(join(root, '.git')) + writeFileSync(join(root, '.DS_Store'), 'noise') + writeFileSync(join(root, 'visible.ts'), 'ok') + expect((await service.readDir(root)).nodes?.map(node => node.name)).toEqual(['visible.ts']) + }) + + it('does not pull dependency and generated trees into fallback workspace scans', async () => { + const { root, service } = fixture() + mkdirSync(join(root, 'node_modules')) + mkdirSync(join(root, 'dist')) + writeFileSync(join(root, 'visible.ts'), 'ok') + writeFileSync(join(root, 'node_modules', 'dependency.js'), 'large tree') + writeFileSync(join(root, 'dist', 'bundle.js'), 'generated') + expect((await service.listFiles(root)).files).toEqual(['visible.ts']) }) }) diff --git a/src/main/filesystem-service.ts b/src/main/filesystem-service.ts index c65341a..828d825 100644 --- a/src/main/filesystem-service.ts +++ b/src/main/filesystem-service.ts @@ -1,7 +1,7 @@ import { execFile } from 'child_process' import { basename, dirname, extname, isAbsolute, join, normalize, relative, sep } from 'path' import { cpSync, existsSync, mkdirSync, promises as fsp, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs' -import { IGNORE, MAX_FILE_BYTES } from './fs-constants' +import { MAX_FILE_BYTES, SCAN_IGNORED_ENTRIES, TREE_HIDDEN_ENTRIES } from './fs-constants' import { uniqueCopyName } from './fs-copy-name' import { isRemoteRoot } from './remote/ssh-target' import { remoteListFiles, remoteReadDir, remoteReadFile, remoteWriteFile } from './remote/remote-fs' @@ -32,7 +32,7 @@ export class FilesystemService { return { error: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'path missing' : (error as Error).message } } const nodes = (await Promise.all(entries.map(async (name): Promise => { - if (IGNORE.has(name)) return null + if (TREE_HIDDEN_ENTRIES.has(name)) return null const absolute = join(target, name) let stat try { stat = await fsp.stat(absolute) } catch { return null } @@ -245,7 +245,7 @@ export class FilesystemService { let entries: string[] try { entries = readdirSync(absoluteDir) } catch { return } for (const name of entries) { - if (IGNORE.has(name)) continue + if (SCAN_IGNORED_ENTRIES.has(name)) continue const absolute = join(absoluteDir, name) let stat try { stat = statSync(absolute) } catch { continue } diff --git a/src/main/fs-constants.ts b/src/main/fs-constants.ts index 7a15d67..9d13cd9 100644 --- a/src/main/fs-constants.ts +++ b/src/main/fs-constants.ts @@ -1,7 +1,12 @@ -// Shared between the local (fs.ts) and remote (remote/remote-fs.ts) file -// surfaces so both hide the same noise and enforce the same read ceiling. +// Shared between local and SSH filesystem surfaces. The editor tree is lazy, so +// dependency/build directories remain browsable on demand. Broad fallback scans +// still prune them to avoid walking enormous generated trees. -export const IGNORE = new Set([ +export const TREE_HIDDEN_ENTRIES = new Set([ + '.git', '.DS_Store', +]) + +export const SCAN_IGNORED_ENTRIES = new Set([ '.git', 'node_modules', '.next', 'out', 'dist', '.DS_Store', '.cache', '.turbo', ]) diff --git a/src/main/fs.ts b/src/main/fs.ts index b3d8049..6f400de 100644 --- a/src/main/fs.ts +++ b/src/main/fs.ts @@ -2,7 +2,7 @@ import electron from 'electron' import { join, basename, relative, isAbsolute, normalize, sep, dirname, extname } from 'path' import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, rmSync, renameSync } from 'fs' import { execFile, spawnSync } from 'child_process' -import { IGNORE, MAX_ATTACHMENT_FILE_BYTES, MAX_ATTACHMENT_FILE_MB, MAX_FILE_BYTES } from './fs-constants' +import { MAX_ATTACHMENT_FILE_BYTES, MAX_ATTACHMENT_FILE_MB, MAX_FILE_BYTES } from './fs-constants' import { isRemoteRoot } from './remote/ssh-target' import { remoteMkdir, remoteMove, remoteDelete, remoteRename, remoteCopyFile, diff --git a/src/main/hub-auth.ts b/src/main/hub-auth.ts index ef2bacc..6727bc2 100644 --- a/src/main/hub-auth.ts +++ b/src/main/hub-auth.ts @@ -13,7 +13,12 @@ import { HubStore, type HubUser } from './hub-store' const CHALLENGE_TTL_MS = 5 * 60_000 export const HUB_BOOTSTRAP_TTL_MS = 10 * 60_000 -export const HUB_SESSION_TTL_MS = 12 * 60 * 60_000 +// A Hub session represents a revocable trusted browser (commonly the owner's +// phone), not a Brain connection ticket. Keep it across browser restarts so +// changing between foreground and desktop-background Brain processes does not +// force the owner through passkey sign-in again. Per-machine tickets remain +// short-lived and single-use. +export const HUB_SESSION_TTL_MS = 30 * 24 * 60 * 60_000 interface PendingChallenge { challenge: string diff --git a/src/main/hub-brain-relay.ts b/src/main/hub-brain-relay.ts index fa15195..1eaf14a 100644 --- a/src/main/hub-brain-relay.ts +++ b/src/main/hub-brain-relay.ts @@ -20,6 +20,7 @@ import { createBrainRelayCipher, type BrainRelayCipher } from './hub-relay-crypt import { startRemoteAccessServer } from './remote-access-server' const READ_METHODS = new Set([ + 'app.buildInfo', 'workspaces.list', 'workspaces.inspectPath', 'fs.readDir', 'fs.readFile', 'fs.readDataUrl', 'fs.listFiles', 'git.status', 'git.diff', 'git.conflictDiff', 'git.log', 'git.branches', 'git.remotes', 'worktrees.list', 'github.status', 'github.prCreateContext', 'github.prCatalogue', 'github.prDetail', 'github.prDiff', 'github.prReviewContext', 'github.prManagementContext', 'github.prChecksContext', 'github.prCheckLog', 'github.avatar', 'gh.status', diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts index 07b967d..a104ebe 100644 --- a/src/main/hub-relay.test.ts +++ b/src/main/hub-relay.test.ts @@ -235,6 +235,7 @@ async function encryptedRpc(input: { describe('Brain-local RPC authorization', () => { it('classifies workspace, terminal, and agent methods without a permissive fallback', () => { + expect(brainScopeForMethod('app.buildInfo')).toBe('workspace:read') expect(brainScopeForMethod('workspaces.list')).toBe('workspace:read') expect(brainScopeForMethod('git.conflictDiff')).toBe('workspace:read') expect(brainScopeForMethod('fs.writeFile')).toBe('workspace:write') diff --git a/src/main/hub-server.test.ts b/src/main/hub-server.test.ts index f67c4d0..5e202ba 100644 --- a/src/main/hub-server.test.ts +++ b/src/main/hub-server.test.ts @@ -3,10 +3,11 @@ import { mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' -import { hubRelayExpiryReason, startHubServer, type RunningHubServer } from './hub-server' +import { hubRelayExpiryReason, hubSessionCookie, startHubServer, type RunningHubServer } from './hub-server' import { HUB_RELAY_ABSOLUTE_TIMEOUT_MS, HUB_RELAY_IDLE_TIMEOUT_MS } from '../shared/hub-relay-types' import { HubEnrollmentIssuer, HUB_ENROLLMENT_TTL_MS } from './hub-machine-enrollment' import { HubStore } from './hub-store' +import { HUB_SESSION_TTL_MS } from './hub-auth' const cleanups: Array<() => void | Promise> = [] afterEach(async () => { @@ -44,6 +45,15 @@ async function authenticatedServer(now: () => number, publicOrigin?: string): Pr } describe('Hub enrollment credentials', () => { + it('keeps an authenticated phone session across browser restarts without weakening cookie boundaries', () => { + expect(hubSessionCookie('https://crewcode.example', 'session.secret')).toBe( + `__Host-crewcode_hub_session=session.secret; Path=/; HttpOnly; SameSite=Strict; Max-Age=${HUB_SESSION_TTL_MS / 1_000}; Secure`, + ) + expect(hubSessionCookie('http://localhost:3774', 'session.secret')).toBe( + `crewcode_hub_session=session.secret; Path=/; HttpOnly; SameSite=Strict; Max-Age=${HUB_SESSION_TTL_MS / 1_000}`, + ) + }) + it('keeps enrollment tokens memory-only, expiring, and single-use', () => { let time = 1_000 const issuer = new HubEnrollmentIssuer(() => time) @@ -100,6 +110,7 @@ describe('Hub store', () => { expect(store.authenticateMachine(enrolled.token)?.id).toBe(enrolled.machine.id) expect(store.authenticateMachine(`${enrolled.machine.id}.wrong`)).toBeNull() expect(readFileSync(path).includes(Buffer.from(enrolled.token.split('.')[1]))).toBe(false) + const persisted = store.createSession(owner.id, 3_500, 10_000) expect(store.revokeSession(created.session.id, 4_000)).toBe(true) expect(store.authenticateSession(created.token, 5_000)).toBeNull() store.close() @@ -107,6 +118,8 @@ describe('Hub store', () => { const reopened = new HubStore(path) expect(reopened.owner()?.username).toBe('Owner') expect(reopened.credentialsForUser(owner.id)[0]?.publicKey).toEqual(new Uint8Array([1, 2, 3])) + expect(reopened.authenticateSession(created.token, 5_000)).toBeNull() + expect(reopened.authenticateSession(persisted.token, 5_000)?.userId).toBe(owner.id) reopened.close() }) }) diff --git a/src/main/hub-server.ts b/src/main/hub-server.ts index 11b18d7..c29b0b9 100644 --- a/src/main/hub-server.ts +++ b/src/main/hub-server.ts @@ -15,7 +15,7 @@ import { type BrainAccessScope, type HubRelayControlFrame, } from '../shared/hub-relay-types' -import { HubAuth } from './hub-auth' +import { HUB_SESSION_TTL_MS, HubAuth } from './hub-auth' import { HubDeviceEnrollmentIssuer, HubEnrollmentIssuer, HUB_MACHINE_ONLINE_WINDOW_MS } from './hub-machine-enrollment' import { HubStore, type HubSession } from './hub-store' import QRCode from 'qrcode' @@ -154,9 +154,13 @@ function cookieName(publicOrigin: string): string { return new URL(publicOrigin).protocol === 'https:' ? SESSION_COOKIE_HTTPS : SESSION_COOKIE_HTTP } -function setSessionCookie(response: ServerResponse, publicOrigin: string, token: string): void { +export function hubSessionCookie(publicOrigin: string, token: string): string { const secure = new URL(publicOrigin).protocol === 'https:' ? '; Secure' : '' - response.setHeader('set-cookie', `${cookieName(publicOrigin)}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict${secure}`) + return `${cookieName(publicOrigin)}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(HUB_SESSION_TTL_MS / 1_000)}${secure}` +} + +function setSessionCookie(response: ServerResponse, publicOrigin: string, token: string): void { + response.setHeader('set-cookie', hubSessionCookie(publicOrigin, token)) } function clearSessionCookie(response: ServerResponse, publicOrigin: string): void { diff --git a/src/main/index.ts b/src/main/index.ts index bb3fd91..949d7a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,6 +63,7 @@ import { localVoiceService } from './local-voice-service' import { packagedHeadlessArgs } from './packaged-cli-dispatch' import { BrainDesktopService } from './brain-desktop-service' import { revealBrowserWindow, SystemTrayService } from './system-tray' +import { resolveCodexCommand } from './rate-limits/resolve-agent' const { app, BrowserWindow, clipboard, ipcMain, Menu, nativeImage, protocol, session, shell, Tray } = electron import { spawn } from 'child_process' @@ -370,6 +371,15 @@ function probeShell(shell: string, flags: string, cmd: string): string | null { } function detectAgentPath(cmd: string): string | null { + // Codex distributed through the managed npm package may expose a temporary + // PATH alias that is removed or stale by the time Electron starts. Reuse the + // resolver used by the rate-limit probe so registry availability and actual + // launches agree. + if (cmd === 'codex') { + const managed = resolveCodexCommand() + if (managed !== cmd && existsSync(managed)) return managed + } + // 1) Try Electron's own PATH first (covers /usr/bin, /usr/local/bin, etc.) const which = spawnSync('which', [cmd], { encoding: 'utf8', timeout: SHELL_PROBE_TIMEOUT_MS }) const fromPath = which.stdout?.trim() diff --git a/src/main/plugin-contract.ts b/src/main/plugin-contract.ts index efedce3..57bafc0 100644 --- a/src/main/plugin-contract.ts +++ b/src/main/plugin-contract.ts @@ -22,7 +22,7 @@ import { type PluginStatusItemContribution, type PluginTerminalWatcherContribution, } from '../shared/plugin-types' -import { IGNORE, MAX_FILE_BYTES } from './fs-constants' +import { MAX_FILE_BYTES, SCAN_IGNORED_ENTRIES } from './fs-constants' import { isRemoteRoot } from './remote/ssh-target' const ID_RE = /^[a-z0-9][a-z0-9._-]*$/i @@ -526,7 +526,7 @@ function listWorkspaceFiles(root: string): PluginInvokeResult { const files: string[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { - if (IGNORE.has(name)) continue + if (SCAN_IGNORED_ENTRIES.has(name)) continue const abs = join(dir, name) const st = statSync(abs) if (st.isDirectory()) walk(abs) diff --git a/src/main/plugins.ts b/src/main/plugins.ts index 68c6a50..8637538 100644 --- a/src/main/plugins.ts +++ b/src/main/plugins.ts @@ -35,7 +35,7 @@ import { type RegisteredPluginTerminalWatcher, type PluginTabContribution, } from '../shared/plugin-types' -import { IGNORE, MAX_FILE_BYTES } from './fs-constants' +import { MAX_FILE_BYTES, SCAN_IGNORED_ENTRIES } from './fs-constants' import { isRemoteRoot } from './remote/ssh-target' import { invokePluginCapabilityWithPlugins, pluginPermissionFingerprint, requiredPermissionForPluginContribution, resolvePluginAssetTarget, validatePluginManifest } from './plugin-contract' import { commitPluginGitInstall, inspectPluginGitRepository, readPluginGitSources } from './plugin-git-install' @@ -525,7 +525,7 @@ function listWorkspaceFiles(root: string): PluginInvokeResult { const files: string[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { - if (IGNORE.has(name)) continue + if (SCAN_IGNORED_ENTRIES.has(name)) continue const abs = join(dir, name) const st = statSync(abs) if (st.isDirectory()) walk(abs) diff --git a/src/main/rate-limits/claude-fetch.ts b/src/main/rate-limits/claude-fetch.ts index c61b611..53a2185 100644 --- a/src/main/rate-limits/claude-fetch.ts +++ b/src/main/rate-limits/claude-fetch.ts @@ -7,6 +7,23 @@ import { getSpawnArgsForWindows } from '../win32-utils' const DIRECT_TIMEOUT_MS = 12_000 const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 +export const CLAUDE_CRASH_COOLDOWN_MS = 10 * 60 * 1000 + +type ClaudeProbeResult = ProviderRateLimits & { processCrash?: string } + +let interactiveProbeBlockedUntil = 0 + +export function noteClaudeProbeCrash(now = Date.now()): void { + interactiveProbeBlockedUntil = Math.max(interactiveProbeBlockedUntil, now + CLAUDE_CRASH_COOLDOWN_MS) +} + +export function claudeInteractiveProbeIsCoolingDown(now = Date.now()): boolean { + return now < interactiveProbeBlockedUntil +} + +export function resetClaudeProbeCrashCooldownForTest(): void { + interactiveProbeBlockedUntil = 0 +} const SESSION_RE = /current\s*session/i const WEEKLY_RE = /current\s*week/i @@ -102,11 +119,11 @@ function buildClaudeResult(output: string, errorFallback: string): ProviderRateL } } -async function fetchViaCommand(commandOverride?: string | null): Promise { +async function fetchViaCommand(commandOverride?: string | null): Promise { const claudeCommand = resolveClaudeCommand(commandOverride) const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(claudeCommand, ['/usage']) - return new Promise((resolve) => { + return new Promise((resolve) => { let output = '' let resolved = false let child: ReturnType @@ -163,16 +180,28 @@ async function fetchViaCommand(commandOverride?: string | null): Promise { + child.on('close', (_exitCode, signal) => { if (resolved) return resolved = true clearTimeout(timeout) + if (signal) { + resolve({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `Claude usage probe crashed (${signal})`, + status: 'error', + processCrash: signal, + }) + return + } resolve(buildClaudeResult(output, describeFailure(stripTerminalSequences(output)))) }) }) } -async function fetchViaPty(commandOverride?: string | null): Promise { +async function fetchViaPty(commandOverride?: string | null): Promise { const pty = await import('node-pty') const claudeCommand = resolveClaudeCommand(commandOverride) @@ -180,7 +209,7 @@ async function fetchViaPty(commandOverride?: string | null): Promise((resolve) => { + return new Promise((resolve) => { let output = '' let resolved = false let sentUsage = false @@ -290,7 +319,7 @@ async function fetchViaPty(commandOverride?: string | null): Promise { + const onExit = term.onExit(({ signal }) => { dispose() if (enterInterval) clearInterval(enterInterval) if (!resolved) { @@ -298,13 +327,15 @@ async function fetchViaPty(commandOverride?: string | null): Promise 0 resolve({ provider: 'claude', session, weekly, updatedAt: Date.now(), - error: session || weekly ? null : 'Claude exited before /usage rendered', + error: session || weekly ? null : crashed ? `Claude usage probe crashed (signal ${signal})` : 'Claude exited before /usage rendered', status: session || weekly ? 'ok' : 'error', + ...(crashed ? { processCrash: `signal ${signal}` } : {}), }) } }) @@ -315,9 +346,18 @@ async function fetchViaPty(commandOverride?: string | null): Promise { const direct = await fetchViaCommand(commandOverride) if (direct.status === 'ok' || direct.status === 'unavailable') return direct + if (direct.processCrash) { + noteClaudeProbeCrash() + return direct + } + + // The fallback is a real interactive CLI process. After an observed crash, + // do not let focus-driven refreshes relaunch it into a core-dump loop. + if (claudeInteractiveProbeIsCoolingDown()) return direct try { const pty = await fetchViaPty(commandOverride) + if (pty.processCrash) noteClaudeProbeCrash() return pty.status === 'ok' ? pty : direct } catch { return direct diff --git a/src/main/rate-limits/claude-probe-cooldown.test.ts b/src/main/rate-limits/claude-probe-cooldown.test.ts new file mode 100644 index 0000000..c5e8ba8 --- /dev/null +++ b/src/main/rate-limits/claude-probe-cooldown.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + CLAUDE_CRASH_COOLDOWN_MS, + claudeInteractiveProbeIsCoolingDown, + noteClaudeProbeCrash, + resetClaudeProbeCrashCooldownForTest, +} from './claude-fetch' + +describe('Claude usage probe crash cooldown', () => { + afterEach(() => resetClaudeProbeCrashCooldownForTest()) + + it('blocks the interactive fallback for ten minutes after a process crash', () => { + noteClaudeProbeCrash(1_000) + + expect(claudeInteractiveProbeIsCoolingDown(1_000)).toBe(true) + expect(claudeInteractiveProbeIsCoolingDown(1_000 + CLAUDE_CRASH_COOLDOWN_MS - 1)).toBe(true) + expect(claudeInteractiveProbeIsCoolingDown(1_000 + CLAUDE_CRASH_COOLDOWN_MS)).toBe(false) + }) + + it('never shortens an existing cooldown', () => { + noteClaudeProbeCrash(10_000) + noteClaudeProbeCrash(5_000) + + expect(claudeInteractiveProbeIsCoolingDown(10_000 + CLAUDE_CRASH_COOLDOWN_MS - 1)).toBe(true) + }) +}) diff --git a/src/main/rate-limits/codex-fetch.test.ts b/src/main/rate-limits/codex-fetch.test.ts new file mode 100644 index 0000000..3638dfe --- /dev/null +++ b/src/main/rate-limits/codex-fetch.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { selectCodexRateLimits } from './codex-fetch' +import { sanitizeCodexCommandEnv } from './resolve-agent' + +describe('Codex rate-limit response compatibility', () => { + it('prefers the explicit codex bucket when provided', () => { + const bucket = { primary: { usedPercent: 12 }, secondary: { usedPercent: 34 } } + expect(selectCodexRateLimits({ + rateLimits: { primary: { usedPercent: 1 } }, + rateLimitsByLimitId: { codex: bucket }, + })).toBe(bucket) + }) + + it('falls back to the legacy single-bucket response', () => { + const legacy = { primary: { usedPercent: 8 } } + expect(selectCodexRateLimits({ rateLimits: legacy })).toBe(legacy) + }) +}) + +describe('Codex usage probe environment', () => { + it('removes a parent managed Codex runtime identity', () => { + expect(sanitizeCodexCommandEnv({ + PATH: '/usr/bin', + CODEX_HOME: '/home/test/.crewcoder/codex-app-server', + CODEX_THREAD_ID: 'thread-parent', + CODEX_MANAGED_PACKAGE_ROOT: '/opt/crewcoder/node_modules/@openai/codex', + CODEX_MANAGED_BY_NPM: '1', + CODEX_CI: '1', + CREWCODER_PROVIDER: 'codex', + CREWCODER_MODEL: 'gpt-test', + })).toEqual({ PATH: '/usr/bin' }) + }) + + it('preserves an explicitly configured home outside a managed parent turn', () => { + expect(sanitizeCodexCommandEnv({ + PATH: '/usr/bin', + CODEX_HOME: '/home/test/.codex-work', + })).toEqual({ + PATH: '/usr/bin', + CODEX_HOME: '/home/test/.codex-work', + }) + }) +}) diff --git a/src/main/rate-limits/codex-fetch.ts b/src/main/rate-limits/codex-fetch.ts index 24d148b..cdf25d1 100644 --- a/src/main/rate-limits/codex-fetch.ts +++ b/src/main/rate-limits/codex-fetch.ts @@ -1,6 +1,6 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' -import { buildAgentCommandEnv, resolveCodexCommand } from './resolve-agent' +import { buildCodexCommandEnv, resolveCodexCommand } from './resolve-agent' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' const RPC_TIMEOUT_MS = 10_000 @@ -27,8 +27,13 @@ type RpcRateLimitsResult = { secondary?: RpcRateWindow } -type RpcRateLimitsResponse = { +export type RpcRateLimitsResponse = { rateLimits?: RpcRateLimitsResult + rateLimitsByLimitId?: Record | null +} + +export function selectCodexRateLimits(response: RpcRateLimitsResponse): RpcRateLimitsResult | undefined { + return response.rateLimitsByLimitId?.codex ?? response.rateLimits } function buildRpcMessage(id: number, method: string, params?: unknown): string { @@ -83,7 +88,7 @@ async function fetchViaRpc(commandOverride?: string | null): Promise { @@ -154,7 +159,7 @@ async function fetchViaRpc(commandOverride?: string | null): Promise, + env: buildCodexCommandEnv(codexCommand, { TERM: 'xterm-256color' }) as Record, }) const disposables: { dispose: () => void }[] = [] diff --git a/src/main/rate-limits/resolve-agent.ts b/src/main/rate-limits/resolve-agent.ts index 9e7558e..c4c171f 100644 --- a/src/main/rate-limits/resolve-agent.ts +++ b/src/main/rate-limits/resolve-agent.ts @@ -40,6 +40,32 @@ function findFirstExecutable(directories: string[], executableNames: string[]): return null } +function managedCodexExecutable(): string | null { + const packageRoot = process.env.CODEX_MANAGED_PACKAGE_ROOT + const platform = process.platform === 'win32' ? 'win32' : process.platform + const packageNames = [`codex-${platform}-${process.arch}`, `codex-${process.platform}-${process.arch}`] + const vendorTargets = process.platform === 'linux' + ? [`${process.arch}-unknown-linux-musl`, `${process.arch}-unknown-linux-gnu`] + : [] + const candidates = [ + ...(packageRoot ? [ + join(packageRoot, 'bin', ...getExecutableNames(process.platform, 'codex')), + ...packageNames.flatMap(name => [ + ...vendorTargets.map(target => join(dirname(packageRoot), name, 'vendor', target, 'bin', 'codex')), + join(dirname(packageRoot), name, 'bin', ...getExecutableNames(process.platform, 'codex')), + ]), + ] : []), + // npm's managed launcher can leave the native package's `codex-path` + // directory on PATH even when the alias itself is stale. The real binary + // is beside it in the sibling `bin` directory. + ...splitPath(process.env.PATH ?? process.env.Path).flatMap(entry => + entry.endsWith('codex-path') + ? [join(dirname(entry), 'bin', ...getExecutableNames(process.platform, 'codex'))] + : []), + ] + return findFirstExecutable([ ...candidates.map(dirname) ], getExecutableNames(process.platform, 'codex')) +} + function getVersionManagerDirectories(platform: NodeJS.Platform, homePath: string, executableNames: string[]): string[] { const directories = [ join(homePath, '.volta', 'bin'), @@ -127,6 +153,19 @@ function resolveCommand(commandName: string, overridePath?: string | null, platf const shellCandidate = resolveFromLoginShell(commandName) if (shellCandidate) return shellCandidate + if (commandName === 'codex') { + // A standalone npm install is the user's normal CLI and may be absent + // from Electron's PATH when CrewCode is launched from a desktop entry. + const standalone = findFirstExecutable( + [join(homePath, '.codex-cli-npm', 'bin')], + executableNames, + ) + if (standalone) return standalone + + const managedCandidate = managedCodexExecutable() + if (managedCandidate) return managedCandidate + } + const versionManagerCandidate = findFirstExecutable( getVersionManagerDirectories(platform, homePath, executableNames), executableNames @@ -153,6 +192,39 @@ export function buildAgentCommandEnv(commandPath?: string | null, extra?: Record return env } +/** + * A development build can be launched from inside a CrewCoder-backed Codex + * turn. In that case the Electron process inherits the parent app-server's + * private CODEX_HOME and runtime identity. Those values belong to the parent + * process; passing them to the user's standalone CLI makes the usage probe + * open the wrong state database (and commonly fail before initialization). + * + * Preserve an explicitly configured CODEX_HOME in ordinary shells. The full + * managed-runtime signature is deliberately required before removing it. + */ +export function sanitizeCodexCommandEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sanitized = { ...env } + const inheritedManagedRuntime = sanitized.CODEX_MANAGED_BY_NPM === '1' + && Boolean(sanitized.CODEX_MANAGED_PACKAGE_ROOT) + && Boolean(sanitized.CODEX_THREAD_ID) + + if (inheritedManagedRuntime) { + delete sanitized.CODEX_HOME + delete sanitized.CODEX_THREAD_ID + delete sanitized.CODEX_MANAGED_PACKAGE_ROOT + delete sanitized.CODEX_MANAGED_BY_NPM + delete sanitized.CODEX_CI + delete sanitized.CREWCODER_PROVIDER + delete sanitized.CREWCODER_MODEL + } + + return sanitized +} + +export function buildCodexCommandEnv(commandPath?: string | null, extra?: Record): NodeJS.ProcessEnv { + return sanitizeCodexCommandEnv(buildAgentCommandEnv(commandPath, extra)) +} + export function resolveCodexCommand(overridePath?: string | null): string { return resolveCommand('codex', overridePath) } diff --git a/src/main/remote-access-server.test.ts b/src/main/remote-access-server.test.ts index 8e3d2c9..21bb1d0 100644 --- a/src/main/remote-access-server.test.ts +++ b/src/main/remote-access-server.test.ts @@ -34,6 +34,10 @@ describe('remote access server', () => { body: JSON.stringify({ protocolVersion: CREWCODE_REMOTE_PROTOCOL_VERSION, id, method, params }), }) + expect(await (await rpc('build', 'app.buildInfo', {})).json()).toMatchObject({ + ok: true, + result: { version: expect.any(String), buildHash: expect.any(String), packaged: false }, + }) expect(await (await rpc('empty', 'continuity.get', {})).json()).toMatchObject({ ok: true, result: { version: 1, revision: 0, values: {} } }) const updated = await (await rpc('patch', 'continuity.update', { values: { 'crewcode:activeWorkspaceId': 'workspace-one' } })).json() expect(updated).toMatchObject({ ok: true, result: { revision: 1, values: { 'crewcode:activeWorkspaceId': 'workspace-one' } } }) diff --git a/src/main/remote-access-server.ts b/src/main/remote-access-server.ts index 8299cf1..d9acbda 100644 --- a/src/main/remote-access-server.ts +++ b/src/main/remote-access-server.ts @@ -43,6 +43,7 @@ import { getAgentKey, setAgentKey } from './agents/agent-keys' import { getSessionHints } from './agents/sessionStore' import { WebSocketServer, WebSocket } from 'ws' import { ContinuityStateService, continuityStatePath } from './continuity-state-service' +import { createAppBuildInfo } from './build-info' const MAX_REQUEST_BYTES = 2 * 1024 * 1024 const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 @@ -266,6 +267,7 @@ export async function startRemoteAccessServer(options: RemoteAccessServerOptions return true } const handlers = new Map([ + ['app.buildInfo', () => createAppBuildInfo()], ['auth.sessions', () => auth.list()], ['auth.revoke', params => ({ revoked: auth.revoke(String(params.sessionId ?? '')) })], ['workspaces.list', () => workspaceService.list().filter(workspace => { diff --git a/src/main/remote/remote-fs.ts b/src/main/remote/remote-fs.ts index 6d01c1e..3a1a8e5 100644 --- a/src/main/remote/remote-fs.ts +++ b/src/main/remote/remote-fs.ts @@ -2,7 +2,7 @@ import { posix } from 'path' import type { SFTPWrapper, Stats, FileEntry } from 'ssh2' import { getSftp, execRemote } from './ssh-pool' import { parseRemoteTarget, resolveRemote, attrIsDir, attrIsFile, type RemoteTarget } from './ssh-target' -import { IGNORE, MAX_FILE_BYTES } from '../fs-constants' +import { MAX_FILE_BYTES, SCAN_IGNORED_ENTRIES, TREE_HIDDEN_ENTRIES } from '../fs-constants' import type { FsNode } from '../fs' // Wrap a path for safe interpolation into a remote shell command (recursive @@ -35,7 +35,7 @@ export async function remoteReadDir(root: string, sub = ''): Promise<{ nodes?: F const nodes: FsNode[] = [] for (const entry of list) { const name = entry.filename - if (IGNORE.has(name)) continue + if (TREE_HIDDEN_ENTRIES.has(name)) continue const abs = posix.join(r.abs, name) const isDir = attrIsDir(entry.attrs.mode) nodes.push({ @@ -239,7 +239,7 @@ export async function remoteListFiles(root: string): Promise<{ files?: string[]; return { files: git.stdout.split('\n').map(l => l.trim()).filter(Boolean) } } - const prunes = [...IGNORE].map(d => `-name ${sh(d)}`).join(' -o ') + const prunes = [...SCAN_IGNORED_ENTRIES].map(d => `-name ${sh(d)}`).join(' -o ') const find = await execRemote( t, `cd ${sh(t.path)} && find . \\( ${prunes} \\) -prune -o -type f -print | sed 's|^\\./||' | head -n 20000`, diff --git a/src/main/remote/remote-ipc.ts b/src/main/remote/remote-ipc.ts index 9bf06d3..672c986 100644 --- a/src/main/remote/remote-ipc.ts +++ b/src/main/remote/remote-ipc.ts @@ -3,7 +3,7 @@ import { posix } from 'path' import type { SFTPWrapper, FileEntry } from 'ssh2' import { getSftp, execRemote, connectRemote, disconnectRemote, disconnectAllRemotes } from './ssh-pool' import { parseRemoteTarget, formatRemoteRoot, attrIsDir, type RemoteTarget } from './ssh-target' -import { IGNORE } from '../fs-constants' +import { SCAN_IGNORED_ENTRIES } from '../fs-constants' const { ipcMain } = electron @@ -45,7 +45,7 @@ export function registerRemoteIpc(): void { const entries: RemoteDirEntry[] = [] for (const e of list) { - if (e.filename.startsWith('.') || IGNORE.has(e.filename)) continue + if (e.filename.startsWith('.') || SCAN_IGNORED_ENTRIES.has(e.filename)) continue entries.push({ name: e.filename, kind: attrIsDir(e.attrs.mode) ? 'dir' : 'file' }) } entries.sort((a, b) => (a.kind !== b.kind ? (a.kind === 'dir' ? -1 : 1) : a.name.localeCompare(b.name))) diff --git a/src/main/updater.ts b/src/main/updater.ts index 6c271ec..4f6a492 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -8,10 +8,7 @@ import { type UpdaterConfig, type UpdaterEvent, } from '../shared/updater-types' - -// Injected by electron-vite `define` at build time (see electron.vite.config.ts). -declare const __BUILD_HASH__: string -const BUILD_HASH = typeof __BUILD_HASH__ === 'string' ? __BUILD_HASH__ : 'dev' +import { createAppBuildInfo } from './build-info' function broadcast(event: UpdaterEvent): void { for (const win of BrowserWindow.getAllWindows()) { @@ -59,11 +56,7 @@ export function registerUpdaterIpc(): void { })) autoUpdater.on('update-downloaded', info => broadcast({ type: 'downloaded', version: info?.version })) - ipcMain.handle('app:buildInfo', (): AppBuildInfo => ({ - version: app.getVersion(), - buildHash: BUILD_HASH, - packaged: app.isPackaged, - })) + ipcMain.handle('app:buildInfo', (): AppBuildInfo => createAppBuildInfo(app.getVersion(), app.isPackaged)) ipcMain.handle('app:homePath', (): string => homedir()) ipcMain.handle('updater:configure', (_e, raw: unknown) => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index be8e467..c44c7ca 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,6 +39,7 @@ import { isSessionViewTab, type SessionDragPayload } from './components/thread/s import type { Prompt as PromptDef, Skill as SkillDef } from './types/prompts' import { Icon } from './components/ui/Icon' import { LoadingScreen } from './components/ui/LoadingScreen' +import { AboutDialog } from './components/ui/AboutDialog' import { MobileShell, useMobileShell } from './components/ui/MobileShell' import type { AgentActivityState } from './components/ui/AgentActivityIndicator' import { Onboarding } from './components/onboarding/Onboarding' @@ -264,6 +265,7 @@ export default function App() { const { state: settings, set: setSetting } = useSettings() const { show } = useNotifications() const selectionSpeech = useSelectionSpeechState() + const [aboutOpen, setAboutOpen] = useState(false) useSettingsEffects() // MCP servers come from two places: the app-managed registry (Settings UI) and @@ -928,6 +930,7 @@ export default function App() { const providerUsed = activeAgentRateLimits?.session?.usedPercent ?? 0 const providerLimit = 100 const providerResetDescription = activeAgentRateLimits?.session?.resetDescription ?? null + const rateLimitStatus = activeAgentRateLimits?.status ?? 'idle' const hourlyUsedPercent = activeAgentRateLimits?.session?.usedPercent ?? 0 const hourlyResetDescription = activeAgentRateLimits?.session?.resetDescription ?? null const weeklyUsedPercent = activeAgentRateLimits?.weekly?.usedPercent ?? 0 @@ -3061,6 +3064,7 @@ export default function App() { case 'start-crew': startCrewFromAnywhere(); return case 'start-canvas': startCanvasFromAnywhere(); return case 'updates': window.electronAPI?.updaterCheck?.(); return + case 'about': setAboutOpen(true); return case 'quit-stop-brain': void window.electronAPI?.brainDesktopStopAndQuit(); return case 'docs': window.electronAPI?.openExternal?.('https://crewcode-docs.logixhub.icu'); return case 'toggle-menulet': @@ -3614,6 +3618,7 @@ export default function App() { onSubmit={resolveSigningPassphrase} onCancel={() => resolveSigningPassphrase(null)} /> + setAboutOpen(false)} /> window.dispatchEvent(new CustomEvent('crewcode:manage-external-directories')) : undefined} hourlyUsedPercent={hourlyUsedPercent} diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index 4506caa..75f8d43 100644 --- a/src/renderer/src/components/chat/ChatPane.tsx +++ b/src/renderer/src/components/chat/ChatPane.tsx @@ -39,6 +39,7 @@ import { getCrewCodeClient } from '../../runtime/crewcode-client' import { isCrewLaneSessionKey } from '../../../../shared/custody-types' import { isSessionDrag, readSessionDrag, type SessionDragPayload } from '../thread/session-drag' import { crewCoderApprovalForProfile, crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { shouldShowChatBackground } from './fresh-chat-background' type CrewBranchWithMessagesProps = Omit, 'messagesByTab'> @@ -790,6 +791,13 @@ export function ChatPane({ return true }, [onSessionDrop]) + const chatBackgroundVisible = shouldShowChatBackground({ + threadView, + hasBackground: !!appSettings.freshChatBackground, + messageCount: messages.length, + showInRegularChats: appSettings.showChatBackgroundInRegularChats, + }) + return (
-
+
+ {chatBackgroundVisible ? ( +
) @@ -2543,7 +2661,6 @@ export function SettingsScreen({ activeWorkspace }: { activeWorkspace?: Workspac