diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 4b1150a..7eda5fd 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -12,8 +12,8 @@ body:
id: version
attributes:
label: Version
- description: Settings → About, or the GitHub Release tag (for example 0.20.0).
- placeholder: 0.20.0
+ description: Settings → About, or the GitHub Release tag (for example 0.21.0).
+ placeholder: 0.21.0
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 12eb295..b569695 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,10 +23,24 @@ jobs:
run: npx playwright install --with-deps chromium
working-directory: apps/desktop
- run: npm run test:visual
+ - name: Upload visual diffs
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: visual-diffs
+ path: apps/desktop/test-results/
+ if-no-files-found: ignore
+ retention-days: 14
+ # Run the remaining checks even when an earlier step failed, so a
+ # screenshot or test failure cannot hide a lint/type/format/build break.
- run: npm run typecheck
+ if: ${{ !cancelled() }}
- run: npm run lint
+ if: ${{ !cancelled() }}
- run: npm run format:check
+ if: ${{ !cancelled() }}
- run: npm run build --workspace=@locus/desktop
+ if: ${{ !cancelled() }}
docker-image:
runs-on: ubuntu-latest
@@ -41,7 +55,7 @@ jobs:
run: |
mkdir -p images/agent/vendor
node scripts/prefetch-github-release-asset.mjs --owner coder --repo code-server --tag v4.96.4 --file code-server-4.96.4-linux-amd64.tar.gz --out images/agent/vendor/code-server.tar.gz
- - run: docker build -t locus/agent:0.1 -t locus/agent:0.20.0 images/agent
+ - run: docker build -t locus/agent:0.1 -t locus/agent:0.21.0 images/agent
android:
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2922041..e3f559f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,28 @@
## Unreleased
+## 0.21.0 — 2026-11-13
+
+Decide from the Inbox, a turn summary card, tray and actionable notifications, encrypted backup and restore, background update checks with one-click install, a model benchmark, a smoother first hour with a public repo, and much faster workspaces on large repos. Settings → About is **0.21.0**.
+
+**Inbox decisions.** The Inbox shows a decision card for each waiting item: what will run, where it runs, the agent's last words, and a risk hint. Approve, Deny, Always allow and Open chat sit on the card, with keyboard shortcuts (J/K, A, D, Enter) and **Approve all** when several chats wait on the same tool.
+
+**Turn summary and first task.** When a turn changes files, runs tests or opens a PR, a card under the reply lists the files changed, whether tests passed, and next steps: Review changes, Open PR, Continue, or Undo this turn. The first-task card on New agent now suggests real work on your most recent repo or folder.
+
+**Tray and notifications.** The desktop app has a tray icon and dock/taskbar badge with the needs-you count. Notifications say what the agent wants and open the chat when clicked; on macOS, approvals can be answered straight from the notification.
+
+**Backup and restore.** Settings → Advanced → Back up and restore, or `locus backup` / `locus restore`, writes one passphrase-encrypted file with your sessions, settings, boards, projects, Jobs, Brain, workspaces and stored keys. Machine-bound keys are never copied; the vault is re-encrypted for the new machine, so a restore on a new laptop keeps every provider key. A wrong passphrase or tampered file is refused.
+
+**Updates.** The desktop checks for new releases shortly after launch and every six hours, and **Install** downloads, verifies the checksum and installs (in place for Linux AppImage). Turn it off in Settings → About. CLI: `locus update`.
+
+**Model benchmark.** Settings → Benchmark, or `locus eval run`, runs five small coding tasks against the models you pick and records pass rate, tokens, cost, tool calls and time per model.
+
+**First hour with a public repo.** With Docker stopped, actions that need a container say so and offer to start on Host instead. A linked repo is preselected across New agent, Design, Jobs, Brain and IDE. No-token accounts show their rate limit and when it resets. Codebase gains an About card for the repo, and the setup wizard tests local servers such as Ollama with a real call.
+
+**Performance on large repos.** Agent search after an edit no longer rebuilds the whole index (seconds down to milliseconds on very large repos), VM file listings, search without ripgrep, repo index rebuilds, the repo map and Quick Open are all much faster, and the desktop app now ships ripgrep.
+
+**Fixes.** Undo on a git workspace reliably restores edited files. The desktop app uses Electron's async secure-storage API, so the deprecation warnings are gone and existing vault keys still unlock. The control plane and worker start without `ssh-keygen` (only VM agents need it).
+
## 0.20.0 — 2026-11-11
Eight new Terminal themes, a UI/UX polish pass on Swarm, the composer, and empty states, and launchers that check Docker is actually running before they start. Settings → About is **0.20.0**.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7ff761b..bf99306 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,6 +6,7 @@ Thanks for trying Locus. This tree is a Node workspaces monorepo (`apps/*`, `pac
- Node.js 22.22+ (Brain FTS5 needs `node:sqlite`; GitHub Actions uses 22.22)
- npm 10+ (comes with Node)
+- `ssh-keygen` (`openssh-client` on Debian/Ubuntu) for VM agents. Without it the control plane still starts and logs a warning; Host and Docker agents work
- Optional: QEMU + `qemu-img` for real VMs; Docker CLI for real containers. Without them the mocks still run the UI
```bash
@@ -48,8 +49,8 @@ First agent without Docker: Settings → Providers (Ollama needs no key) then Ne
After merge to `main`:
```bash
-git tag v0.20.0
-git push origin v0.20.0
+git tag v0.21.0
+git push origin v0.21.0
```
GitHub Releases are the distribution channel (`v*` tags). Pushing a `v*` tag publishes Linux, macOS, and Windows installers, then **Prune GitHub Releases** deletes older published releases so only the last three remain (git tags are kept). See [docs/public-release.md](docs/public-release.md) for listing settings this tree cannot change (visibility, vulnerability reporting, unsigned macOS/Windows). Keep the repository description as the README tagline and topics `ai-agents`, `qemu`, `docker`, `computer-use`, `electron`, `local-first`.
diff --git a/README.md b/README.md
index f8000de..f35066f 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
Locus is a desktop control plane for AI coding agents that run on hardware you own — a Docker container, a QEMU virtual machine, or a folder on disk. Chat on one side, the live guest on the other. Code, keys, sessions, and disks stay under `~/.locus`.
-This is **v0.20.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.
+This is **v0.21.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.
## Install
@@ -206,13 +206,15 @@ The **Services** tab on that rail lists Swarm nodes and services when this engin
### Settings
-Grouped as **Models** (Providers, MCP, Skills), **Workspace** (Machines, Workers, Environments, Git, Integrations), and **System** (Appearance, Notifications, Phone, Chrome, Advanced, About). The page can find a provider, MCP server, skill, worker, environment, Git account, or integration by name.
+Grouped as **Models** (Providers, MCP, Skills, Benchmark), **Workspace** (Machines, Workers, Environments, Git, Integrations), and **System** (Appearance, Notifications, Phone, Chrome, Advanced, About). The page can find a provider, MCP server, skill, worker, environment, Git account, or integration by name.
Git includes **Sign in with GitHub** / **Sign in with GitLab** / **Sign in with Bitbucket**. MCP defaults to deferred tool schemas (**Preload tool schemas** per server). HTTP MCP to an AWS API Gateway signs SigV4 when AWS keys are in that server's env. Traces live on the agent or design ⋯ menu → **View trace**.
+Settings → About checks GitHub Releases at launch and every six hours. **Install** verifies the download against `SHA256SUMS`, then swaps a Linux AppImage in place and relaunches, runs the Windows installer, or opens the dmg / deb / rpm. Settings → Advanced → **Back up and restore** writes one passphrase-encrypted `.locusbak` with sessions, settings, stored keys, boards, projects, Jobs and Brain, and restores it on another machine at the next start (`locus backup` / `locus restore`). Settings → **Benchmark** runs five small coding tasks as real Host turns against the models you pick and compares pass rate, cost and time (`locus eval run`).
+
Settings → Phone mints a LAN pairing token (QR on that tab). Settings → Workers lists LAN / Cloud VM / Fargate hosts. Settings → Environments lists named boxes saved from New agent or Automations. Settings → Notifications toggles desktop alerts and quiet hours. Settings → Appearance filters themes and sets editor font size, format on save, and the minimap. Settings → Advanced **Tailscale Funnel** exposes **only** `/api/hooks` (never the whole control plane) so GitHub can reach forge webhooks; `locus config funnel enable|disable`.
-Integrations: Slack, Grafana, Jira, Confluence, Linear, Miro, Figma, incident.io, AWS IAM, Portainer, Kubernetes, Sentry, Gmail, Google Calendar, Google Drive, and agent-authored HTTP plugins. Settings → About is **0.20.0**.
+Integrations: Slack, Grafana, Jira, Confluence, Linear, Miro, Figma, incident.io, AWS IAM, Portainer, Kubernetes, Sentry, Gmail, Google Calendar, Google Drive, and agent-authored HTTP plugins. Settings → About is **0.21.0**.
@@ -306,7 +308,7 @@ Developer / Swarm in the sidebar switches the product shell. Developer keeps the
**CLI.** `npx locus` / `npm run locus --` is a loopback client for the same control plane: agents, design, games, assets, IDE, projects, Codebase, Brain, Slack, Grafana, Docker, automations, workflows, Settings, phone remote, and Funnel. `locus serve` starts the server when the app is not running. `locus project keep` leaves that control plane up after the desktop quits. `locus workflow` imports and runs Agent Loop Builder graphs. `locus brain setup` writes personal editor hooks that send a redacted transcript tail to the local brain. Live VNC and canvas clicking stay in the desktop. Guide: [docs/cli.md](docs/cli.md).
-**App.** 106 themes (Ember default). Settings → About is **0.20.0**. Electron packages (Linux AppImage, macOS dmg, Windows NSIS), or `npm run dev` in the browser. **Ctrl/Cmd+K** command palette. **Ctrl/Cmd+N** new agent, new automation, or (on Design, Games, Assets) the empty composer. **Ctrl/Cmd+F** find in chat. **Ctrl/Cmd+,** settings.
+**App.** 106 themes (Ember default). Settings → About is **0.21.0**. Electron packages (Linux AppImage, macOS dmg, Windows NSIS), or `npm run dev` in the browser. **Ctrl/Cmd+K** command palette. **Ctrl/Cmd+N** new agent, new automation, or (on Design, Games, Assets) the empty composer. **Ctrl/Cmd+F** find in chat. **Ctrl/Cmd+,** settings.
## Security model
diff --git a/SECURITY.md b/SECURITY.md
index 278afab..82e1e41 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -49,6 +49,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c
- **What goes to providers.** Chat messages, tool results (after secret redaction), and screenshots when Vision is on are sent to the provider you enabled. Locus does not send traces, vault files, or `master.key`.
- **Training.** Locus does not train models. Whether a provider trains on API traffic is that provider's policy; use your provider's zero-retention / API-data controls when you need them.
- **Retention.** Session JSON, traces, screenshots, recordings, and disks stay under `~/.locus` on this machine until you delete the session or Reset all data. Automations that open a new chat per run keep the newest 60 chats for that job (pinned and in-flight chats stay) and delete the rest from this machine. There is no cloud copy of the control plane.
+- **Backups.** A `.locusbak` from Settings → Advanced or `locus backup` contains every stored key and token, decrypted inside an AES-256-GCM archive keyed by your passphrase (scrypt). Treat the file and passphrase like the keys themselves. `master.key`, `master.key.safe` and `secrets.enc` are never copied; restore re-encrypts the vault with the target machine's key. Backup and restore routes refuse anyone but the local operator (not a paired phone). A wrong passphrase or tampered archive fails the GCM check and nothing is staged.
- **Isolation.** This is a single-operator local app. Each agent has its own guest (Docker workspace or VM disk). That is workspace isolation, not multi-tenant SaaS tenancy. Do not run untrusted operators against one `~/.locus`.
## Reliability
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 53a371c..c5cfaa1 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -10,8 +10,8 @@ android {
applicationId = "app.locus.android"
minSdk = 26
targetSdk = 35
- versionCode = 34
- versionName = "0.20.0"
+ versionCode = 35
+ versionName = "0.21.0"
}
buildTypes {
release {
diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json
index eb67822..7cefd9d 100644
--- a/apps/chrome-extension/manifest.json
+++ b/apps/chrome-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Locus",
- "version": "0.20.0",
+ "version": "0.21.0",
"description": "Let Locus host agents use your Chrome tabs — navigate, click, type, and screenshot the pages you already have open.",
"action": {
"default_title": "Locus",
diff --git a/apps/cli/package.json b/apps/cli/package.json
index ea33719..77a39a1 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/cli",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Locus CLI — control plane client for agents, Slack, Codebase, Docker, and automations",
"type": "module",
@@ -22,6 +22,6 @@
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts
index f9117c0..071c75a 100644
--- a/apps/cli/src/args.ts
+++ b/apps/cli/src/args.ts
@@ -39,6 +39,8 @@ const BOOLEAN_FLAGS = new Set([
"printable",
"no-texture",
"no-enhance",
+ "include-disks",
+ "inspect",
]);
const VALUE_FLAGS = new Set([
@@ -137,9 +139,15 @@ const VALUE_FLAGS = new Set([
"notify",
"node",
"memory",
+ "case",
+ "repeat",
+ "out",
+ "current",
+ "passphrase-env",
+ "poll-ms",
]);
-const MULTI_VALUE_FLAGS = new Set(["arg", "repo"]);
+const MULTI_VALUE_FLAGS = new Set(["arg", "repo", "model", "case"]);
function appendFlag(prev: FlagValue | undefined, name: string, next: string): string {
if (MULTI_VALUE_FLAGS.has(name) && typeof prev === "string" && prev.length) {
diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts
index f06600b..74ca36e 100644
--- a/apps/cli/src/commands.ts
+++ b/apps/cli/src/commands.ts
@@ -60,6 +60,16 @@ import { helpFor } from "./help.js";
import { isCompletionShell, renderCompletion } from "./completion.js";
import { startServer } from "./serve.js";
import { CLI_VERSION } from "./version.js";
+import {
+ cmdBackup,
+ cmdEvalCancel,
+ cmdEvalCases,
+ cmdEvalList,
+ cmdEvalRun,
+ cmdEvalShow,
+ cmdRestore,
+ cmdUpdate,
+} from "./maintenance.js";
export interface Ctx {
client: LocusClient;
@@ -388,6 +398,17 @@ const ROUTES: Array<{ path: string[]; run: Handler }> = [
{ path: ["image", "fetch-virtio"], run: cmdImageFetchVirtio },
{ path: ["image"], run: cmdImageList },
+ { path: ["backup"], run: (ctx) => cmdBackup(ctx) },
+ { path: ["restore"], run: (ctx) => cmdRestore(ctx) },
+ { path: ["update"], run: cmdUpdate },
+
+ { path: ["eval", "cases"], run: cmdEvalCases },
+ { path: ["eval", "run"], run: cmdEvalRun },
+ { path: ["eval", "list"], run: cmdEvalList },
+ { path: ["eval", "show"], run: cmdEvalShow },
+ { path: ["eval", "cancel"], run: cmdEvalCancel },
+ { path: ["eval"], run: cmdEvalList },
+
{ path: ["debug", "logs"], run: cmdDebugLogs },
{ path: ["debug", "log"], run: cmdDebugLog },
{ path: ["debug", "eval"], run: cmdDebugEval },
diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts
index 026dc04..d12e990 100644
--- a/apps/cli/src/help.ts
+++ b/apps/cli/src/help.ts
@@ -118,6 +118,16 @@ Config / Settings:
locus config revoke-all --confirm REVOKE
locus config reset --confirm RESET
+Backup / updates:
+ locus backup [file.locusbak] [--include-disks] Encrypted copy of ~/.locus
+ locus restore --yes [--inspect] Applies at the next start
+ locus restore status|cancel
+ locus update [download] Check GitHub releases
+
+Model benchmark:
+ locus eval run --model provider/model[,…] [--case id,…] [--repeat N] [--wait]
+ locus eval cases|list|show |cancel
+
Images / debug:
locus image list|capture|delete|fetch|fetch-virtio
locus debug logs|log|eval|egress|approvals
@@ -311,6 +321,41 @@ Agents flag bad claims with correct_graph; inbox Accept/Dismiss is the human gat
locus config theme ember
locus config workers add 'locus-worker:add?url=…'
locus config reset --confirm RESET`,
+ backup: `Write an encrypted backup of ~/.locus (sessions, settings, vault, boards, projects, Brain).
+
+ locus backup [file.locusbak] [--include-disks]
+
+The passphrase comes from LOCUS_BACKUP_PASSPHRASE (or --passphrase-env NAME), or a hidden
+prompt. Stored secrets travel inside the encrypted file and are re-encrypted with the target
+machine's key on restore. VM disks and images are left out unless --include-disks.`,
+ restore: `Restore a backup made by \`locus backup\` or Settings → Advanced.
+
+ locus restore --inspect Check the passphrase and show what is inside
+ locus restore --yes Stage it; quit and reopen Locus to apply
+ locus restore status
+ locus restore cancel
+
+The swap happens at the next start, before anything loads. Current files move to
+~/.locus/.before-restore-/. This machine's master key stays in place.`,
+ update: `Check GitHub Releases for a newer Locus.
+
+ locus update Show the latest release for this platform
+ locus update download Download the installer and verify it against SHA256SUMS
+
+The desktop app checks at launch and every six hours unless Settings → About →
+Check automatically is off. On a Linux AppImage, Install swaps the file and relaunches.`,
+ eval: `Benchmark models on small coding tasks as real Host agent turns.
+
+ locus eval cases
+ locus eval run --model openai/gpt-5 --model anthropic/claude-sonnet [--case fix-add,off-by-one]
+ [--repeat 3] [--timeout 10] [--wait]
+ locus eval list
+ locus eval show
+ locus eval cancel
+
+Each case starts with failing tests. A case passes when \`npm test\` passes afterwards and the
+tests were not edited. Runs record tokens, estimated cost and time. Benchmark sessions are
+archived so you can open a failure. Results: ~/.locus/benchmarks/.`,
debug: `JSONL traces under ~/.locus/traces, plus audit exports.
locus debug logs
diff --git a/apps/cli/src/maintenance.test.ts b/apps/cli/src/maintenance.test.ts
new file mode 100644
index 0000000..91943dd
--- /dev/null
+++ b/apps/cli/src/maintenance.test.ts
@@ -0,0 +1,145 @@
+import { createServer, type IncomingMessage, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import { afterEach, describe, expect, it } from "vitest";
+import { runCli } from "./index.js";
+import { PASSPHRASE_ENV } from "./maintenance.js";
+
+type Handler = (req: IncomingMessage, url: URL, body: unknown) => unknown;
+const closers: Array<() => Promise> = [];
+const savedPass = process.env[PASSPHRASE_ENV];
+
+afterEach(async () => {
+ if (savedPass == null) delete process.env[PASSPHRASE_ENV];
+ else process.env[PASSPHRASE_ENV] = savedPass;
+ await Promise.all(closers.splice(0).map((close) => close()));
+});
+
+async function mock(handlers: Record) {
+ const bodies: Record = {};
+ const server: Server = createServer((req, res) => {
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
+ const key = `${req.method} ${url.pathname}`;
+ const chunks: Buffer[] = [];
+ req.on("data", (c) => chunks.push(c as Buffer));
+ req.on("end", () => {
+ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
+ bodies[key] = body;
+ const handler = handlers[key];
+ res.writeHead(handler ? 200 : 404, { "content-type": "application/json" });
+ res.end(JSON.stringify(handler ? handler(req, url, body) : { error: `no mock ${key}` }));
+ });
+ });
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
+ closers.push(() => new Promise((r) => server.close(() => r())));
+ return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, bodies };
+}
+
+describe("locus backup / restore", () => {
+ it("backs up with the passphrase from the environment", async () => {
+ process.env[PASSPHRASE_ENV] = "correct horse";
+ const api = await mock({
+ "POST /api/backup": () => ({
+ path: "/home/me/x.locusbak",
+ sizeBytes: 2048,
+ manifest: { files: 12, bytes: 4096, includeDisks: false, skipped: [] },
+ }),
+ });
+ const out = await runCli(["--api", api.url, "backup", "x.locusbak"]);
+ expect(out.code, out.stderr).toBe(0);
+ expect(out.stdout).toMatch(/Backed up 12 files/);
+ expect(out.stdout).toMatch(/--include-disks/);
+ expect(api.bodies["POST /api/backup"]).toMatchObject({
+ passphrase: "correct horse",
+ includeDisks: false,
+ });
+ expect((api.bodies["POST /api/backup"] as { out: string }).out).toMatch(/x\.locusbak$/);
+ });
+
+ it("refuses to prompt without a terminal", async () => {
+ delete process.env[PASSPHRASE_ENV];
+ const api = await mock({});
+ const out = await runCli(["--api", api.url, "backup"]);
+ expect(out.code).not.toBe(0);
+ expect(out.stderr).toMatch(/LOCUS_BACKUP_PASSPHRASE/);
+ });
+
+ it("asks for --yes before staging a restore, then stages it", async () => {
+ process.env[PASSPHRASE_ENV] = "correct horse";
+ const api = await mock({
+ "POST /api/restore": () => ({ files: 9, manifest: { createdAt: "2026-11-01T00:00:00Z" } }),
+ });
+ const refused = await runCli(["--api", api.url, "restore", "b.locusbak"]);
+ expect(refused.code).not.toBe(0);
+ expect(refused.stderr).toMatch(/--yes/);
+ const ok = await runCli(["--api", api.url, "restore", "b.locusbak", "--yes"]);
+ expect(ok.code, ok.stderr).toBe(0);
+ expect(ok.stdout).toMatch(/Staged 9 files/);
+ expect(ok.stdout).toMatch(/reopen Locus/);
+ });
+});
+
+describe("locus update", () => {
+ it("reports an available release", async () => {
+ const api = await mock({
+ "GET /api/updates": (_req, url) => {
+ expect(url.searchParams.get("refresh")).toBe("1");
+ return {
+ status: "available",
+ current: "0.20.0",
+ latest: "v0.21.0",
+ asset: { name: "Locus-0.21.0.AppImage", url: "u" },
+ checksums: { name: "SHA256SUMS-linux", url: "s" },
+ };
+ },
+ });
+ const out = await runCli(["--api", api.url, "update"]);
+ expect(out.stdout).toMatch(/Locus 0\.21\.0 is available/);
+ expect(out.stdout).toMatch(/locus update download/);
+ });
+});
+
+describe("locus eval", () => {
+ it("starts a run with parsed provider/model targets", async () => {
+ const api = await mock({
+ "POST /api/benchmarks": (_r, _u, body) => ({
+ id: "bench-1",
+ status: "running",
+ results: [{}, {}],
+ targets: (body as { targets: unknown[] }).targets,
+ summary: [],
+ }),
+ });
+ const out = await runCli([
+ "--api",
+ api.url,
+ "eval",
+ "run",
+ "--model",
+ "openrouter/anthropic/claude,ollama/qwen3",
+ "--case",
+ "fix-add",
+ ]);
+ expect(out.code, out.stderr).toBe(0);
+ expect(out.stdout).toMatch(/Started bench-1/);
+ expect(api.bodies["POST /api/benchmarks"]).toMatchObject({
+ targets: [
+ { providerId: "openrouter", model: "anthropic/claude" },
+ { providerId: "ollama", model: "qwen3" },
+ ],
+ caseIds: ["fix-add"],
+ });
+ });
+
+ it("rejects a target without a provider", async () => {
+ const api = await mock({});
+ const out = await runCli(["--api", api.url, "eval", "run", "--model", "gpt-5"]);
+ expect(out.code).not.toBe(0);
+ expect(out.stderr).toMatch(/provider\/model/);
+ });
+
+ it("lists the built-in cases", async () => {
+ const out = await runCli(["eval", "cases"]);
+ expect(out.stdout).toMatch(/fix-add/);
+ expect(out.stdout).toMatch(/cross-file-feature/);
+ });
+});
diff --git a/apps/cli/src/maintenance.ts b/apps/cli/src/maintenance.ts
new file mode 100644
index 0000000..87d6e49
--- /dev/null
+++ b/apps/cli/src/maintenance.ts
@@ -0,0 +1,358 @@
+/**
+ * `locus backup`, `locus restore`, `locus update` and `locus eval`.
+ * Kept out of commands.ts so that file only carries the route entries.
+ */
+import { resolve } from "node:path";
+import { createInterface } from "node:readline";
+import {
+ BENCHMARK_CASES,
+ benchmarkTargetKey,
+ formatUsd,
+ parseBenchmarkTarget,
+ type BenchmarkRun,
+ type BenchmarkTargetSummary,
+} from "@locus/shared";
+import { UsageError, flagBool, flagList, flagNum, flagStr, need } from "./args.js";
+import { formatKv, formatTable as formatColumns, printJson } from "./format.js";
+import { CLI_VERSION } from "./version.js";
+import type { Ctx } from "./commands.js";
+
+export const PASSPHRASE_ENV = "LOCUS_BACKUP_PASSPHRASE";
+
+function done(ctx: Ctx, value: T, text: () => string): void {
+ if (ctx.json) printJson(ctx.writeln, value);
+ else ctx.writeln(text());
+}
+
+/** Header list + string rows → the shared column table. */
+function formatTable(headers: string[], rows: string[][]): string {
+ return formatColumns(
+ rows,
+ headers.map((header, i) => ({
+ key: header,
+ header,
+ width: Math.max(header.length, ...rows.map((row) => (row[i] ?? "").length)),
+ value: (row: string[]) => row[i] ?? "",
+ })),
+ );
+}
+
+function bytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ const units = ["KB", "MB", "GB", "TB"];
+ let v = n / 1024;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i += 1;
+ }
+ return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
+}
+
+/** Read without echo. Only used on an interactive terminal. */
+function promptHidden(question: string): Promise {
+ return new Promise((resolvePromise) => {
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
+ const out = rl as unknown as {
+ _writeToOutput?: (s: string) => void;
+ output: NodeJS.WriteStream;
+ };
+ let asked = false;
+ out._writeToOutput = (s: string) => {
+ if (!asked) {
+ process.stderr.write(s);
+ return;
+ }
+ if (s.includes("\n")) process.stderr.write("\n");
+ };
+ rl.question(question, (answer) => {
+ rl.close();
+ resolvePromise(answer);
+ });
+ asked = true;
+ });
+}
+
+/**
+ * Passphrase from `--passphrase-env NAME` (default LOCUS_BACKUP_PASSPHRASE), else a hidden
+ * prompt. Never a plain flag, so it does not land in shell history or `ps`.
+ */
+export async function readPassphrase(
+ ctx: Ctx,
+ opts: { confirm: boolean; env?: NodeJS.ProcessEnv; tty?: boolean; prompt?: typeof promptHidden },
+): Promise {
+ const env = opts.env ?? process.env;
+ const name = flagStr(ctx.flags, "passphrase-env") ?? PASSPHRASE_ENV;
+ const fromEnv = env[name];
+ if (fromEnv) return fromEnv;
+ const tty = opts.tty ?? Boolean(process.stdin.isTTY);
+ if (!tty) {
+ throw new UsageError(`Set ${name} (or --passphrase-env NAME) to the backup passphrase.`);
+ }
+ const ask = opts.prompt ?? promptHidden;
+ const first = await ask("Backup passphrase: ");
+ if (opts.confirm) {
+ const again = await ask("Repeat passphrase: ");
+ if (again !== first) throw new UsageError("Passphrases did not match.");
+ }
+ return first;
+}
+
+interface BackupResponse {
+ path: string;
+ sizeBytes: number;
+ manifest: { files: number; bytes: number; includeDisks: boolean; skipped: string[] };
+}
+
+export async function cmdBackup(
+ ctx: Ctx,
+ deps: Parameters[1] = { confirm: true },
+): Promise {
+ const out = ctx.args[0] ?? flagStr(ctx.flags, "out");
+ const passphrase = await readPassphrase(ctx, { ...deps, confirm: true });
+ const result = await ctx.client.post("/api/backup", {
+ passphrase,
+ out: out ? resolve(out) : undefined,
+ includeDisks: flagBool(ctx.flags, "include-disks"),
+ appVersion: CLI_VERSION,
+ });
+ done(ctx, result, () => {
+ const lines = [
+ `Backed up ${result.manifest.files} files (${bytes(result.manifest.bytes)}) to ${result.path}`,
+ `Archive: ${bytes(result.sizeBytes)}, encrypted with your passphrase.`,
+ ];
+ if (!result.manifest.includeDisks)
+ lines.push("VM disks and images were left out (--include-disks).");
+ if (result.manifest.skipped.length) {
+ lines.push(
+ `Could not read ${result.manifest.skipped.length}: ${result.manifest.skipped.slice(0, 5).join(", ")}`,
+ );
+ }
+ return lines.join("\n");
+ });
+}
+
+export async function cmdRestore(
+ ctx: Ctx,
+ deps: Parameters[1] = { confirm: false },
+): Promise {
+ const sub = ctx.args[0];
+ if (sub === "status") {
+ const result = await ctx.client.get<{ pending: { stagedAt: string; from: string } | null }>(
+ "/api/restore",
+ );
+ done(ctx, result, () =>
+ result.pending
+ ? `Restore of ${result.pending.from} is staged (${result.pending.stagedAt}). Restart Locus to apply it.`
+ : "No restore is staged.",
+ );
+ return;
+ }
+ if (sub === "cancel") {
+ const result = await ctx.client.delete<{ ok: true }>("/api/restore");
+ done(ctx, result, () => "Cancelled the staged restore.");
+ return;
+ }
+ const path = resolve(need(ctx.args, 0, "backup file"));
+ const passphrase = await readPassphrase(ctx, { ...deps, confirm: false });
+ if (flagBool(ctx.flags, "inspect")) {
+ const manifest = await ctx.client.post>("/api/backup/inspect", {
+ path,
+ passphrase,
+ });
+ done(ctx, manifest, () =>
+ formatKv([
+ ["Created", String(manifest.createdAt ?? "")],
+ ["Locus", String(manifest.appVersion ?? "unknown")],
+ ["From", `${String(manifest.dataDir ?? "")} (${String(manifest.platform ?? "")})`],
+ ["Files", String(manifest.files ?? 0)],
+ ["Size", bytes(Number(manifest.bytes ?? 0))],
+ ["VM disks", manifest.includeDisks ? "included" : "not included"],
+ ]),
+ );
+ return;
+ }
+ if (!ctx.flags.yes) {
+ throw new UsageError(
+ "Restore replaces this machine's Locus data at the next start (the current files are kept under ~/.locus/.before-restore-*). Re-run with --yes.",
+ );
+ }
+ const result = await ctx.client.post<{ files: number; manifest: { createdAt: string } }>(
+ "/api/restore",
+ { path, passphrase },
+ );
+ done(ctx, result, () =>
+ [
+ `Staged ${result.files} files from the backup made ${result.manifest.createdAt}.`,
+ "Quit and reopen Locus (or restart `locus serve`) to finish the restore.",
+ ].join("\n"),
+ );
+}
+
+type UpdateInfo = {
+ status: "available" | "up-to-date" | "unavailable";
+ current: string;
+ latest?: string;
+ htmlUrl?: string;
+ asset?: { name: string; url: string };
+ checksums?: { name: string; url: string };
+ message?: string;
+};
+
+export async function cmdUpdate(ctx: Ctx): Promise {
+ const current = flagStr(ctx.flags, "current") ?? CLI_VERSION;
+ const info = await ctx.client.get(
+ `/api/updates?current=${encodeURIComponent(current)}&refresh=1`,
+ );
+ if (ctx.args[0] === "download") {
+ if (info.status !== "available") {
+ done(ctx, info, () => `Locus ${current} is current.`);
+ return;
+ }
+ if (!info.asset || !info.checksums) {
+ throw new UsageError(
+ `No verified installer for this platform. See ${info.htmlUrl ?? "GitHub releases"}.`,
+ );
+ }
+ const saved = await ctx.client.post<{ path: string; sha256: string }>("/api/updates/download", {
+ asset: info.asset,
+ checksums: info.checksums,
+ });
+ done(ctx, saved, () => `Verified against ${info.checksums!.name}. Saved to ${saved.path}`);
+ return;
+ }
+ done(ctx, info, () => {
+ if (info.status === "available") {
+ const latest = (info.latest ?? "").replace(/^v/i, "");
+ return [
+ `Locus ${latest} is available (you have ${current}).`,
+ info.asset
+ ? `Installer: ${info.asset.name}. Run \`locus update download\` to fetch and verify it.`
+ : "",
+ info.htmlUrl ? `Release notes: ${info.htmlUrl}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+ }
+ if (info.status === "up-to-date") return `Locus ${current} is the latest release.`;
+ return info.message ?? "Could not check for updates.";
+ });
+}
+
+type RunWithSummary = BenchmarkRun & { summary: BenchmarkTargetSummary[] };
+
+function summaryTable(run: RunWithSummary): string {
+ return formatTable(
+ ["Model", "Passed", "Rate", "Cost", "Median time", "Tokens"],
+ run.summary.map((row) => [
+ benchmarkTargetKey(row.target),
+ `${row.passed}/${row.total}`,
+ row.total ? `${Math.round(row.passRate * 100)}%` : "-",
+ row.usd != null ? formatUsd(row.usd) : "-",
+ row.medianMs ? `${Math.round(row.medianMs / 1000)}s` : "-",
+ row.tokens ? row.tokens.toLocaleString("en-US") : "-",
+ ]),
+ );
+}
+
+function runDetail(run: RunWithSummary): string {
+ const header = `${run.id} · ${run.status}${run.error ? ` (${run.error})` : ""}`;
+ const cases = formatTable(
+ ["Case", "Model", "Try", "Result", "Why", "Time"],
+ run.results.map((row) => [
+ row.caseId,
+ benchmarkTargetKey(row.target),
+ String(row.attempt),
+ row.status,
+ row.reason ?? "",
+ row.durationMs ? `${Math.round(row.durationMs / 1000)}s` : "",
+ ]),
+ );
+ return [header, "", summaryTable(run), "", cases].join("\n");
+}
+
+export async function cmdEvalCases(ctx: Ctx): Promise {
+ const rows = BENCHMARK_CASES.map(({ id, title }) => ({ id, title }));
+ done(ctx, rows, () =>
+ formatTable(
+ ["Case", "What it tests"],
+ rows.map((r) => [r.id, r.title]),
+ ),
+ );
+}
+
+export async function cmdEvalRun(ctx: Ctx): Promise {
+ const raw = [...flagList(ctx.flags, "model"), ...ctx.args];
+ if (!raw.length)
+ throw new UsageError("Pass --model provider/model (repeatable or comma-separated).");
+ const targets = raw.map((value) => {
+ const target = parseBenchmarkTarget(value);
+ if (!target) throw new UsageError(`Use provider/model, for example openai/gpt-5: ${value}`);
+ return target;
+ });
+ const timeoutMin = flagNum(ctx.flags, "timeout");
+ let run = await ctx.client.post("/api/benchmarks", {
+ targets,
+ caseIds: flagList(ctx.flags, "case"),
+ repeat: flagNum(ctx.flags, "repeat"),
+ timeoutMs: timeoutMin ? timeoutMin * 60_000 : undefined,
+ });
+ if (!flagBool(ctx.flags, "wait")) {
+ done(
+ ctx,
+ run,
+ () =>
+ `Started ${run.id}: ${run.results.length} case runs. Follow it with \`locus eval show ${run.id}\`.`,
+ );
+ return;
+ }
+ const pollMs = flagNum(ctx.flags, "poll-ms") ?? 3000;
+ let reported = 0;
+ while (run.status === "running") {
+ await new Promise((r) => setTimeout(r, pollMs));
+ run = await ctx.client.get(`/api/benchmarks/${encodeURIComponent(run.id)}`);
+ if (!ctx.json) {
+ const finished = run.results.filter((r) => r.status !== "pending" && r.status !== "running");
+ for (const row of finished.slice(reported)) {
+ ctx.err(
+ `${row.status === "passed" ? "✓" : "✗"} ${row.caseId} · ${benchmarkTargetKey(row.target)} · ${row.reason ?? ""}`,
+ );
+ }
+ reported = finished.length;
+ }
+ }
+ done(ctx, run, () => runDetail(run));
+}
+
+export async function cmdEvalList(ctx: Ctx): Promise {
+ const { runs } = await ctx.client.get<{ runs: RunWithSummary[] }>("/api/benchmarks");
+ done(ctx, runs, () =>
+ runs.length
+ ? formatTable(
+ ["Run", "Status", "Created", "Models"],
+ runs.map((run) => [
+ run.id,
+ run.status,
+ run.createdAt.slice(0, 16).replace("T", " "),
+ run.summary
+ .map((s) => `${benchmarkTargetKey(s.target)} ${s.passed}/${s.total}`)
+ .join(", "),
+ ]),
+ )
+ : "No benchmark runs yet. Start one with `locus eval run --model provider/model`.",
+ );
+}
+
+export async function cmdEvalShow(ctx: Ctx): Promise {
+ const id = need(ctx.args, 0, "run id");
+ const run = await ctx.client.get(`/api/benchmarks/${encodeURIComponent(id)}`);
+ done(ctx, run, () => runDetail(run));
+}
+
+export async function cmdEvalCancel(ctx: Ctx): Promise {
+ const id = need(ctx.args, 0, "run id");
+ const run = await ctx.client.post(
+ `/api/benchmarks/${encodeURIComponent(id)}/cancel`,
+ );
+ done(ctx, run, () => `Cancelling ${run.id}.`);
+}
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png
index 76f7ad0..ccc39bf 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png
index 3d75ba6..2473283 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png
index 03a747b..490c5d6 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png
index f19357f..b4a0b5b 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png
index 5f2414b..6e1c577 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png
index 23d243d..36f4652 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png differ
diff --git a/apps/desktop/electron/ambient.cjs b/apps/desktop/electron/ambient.cjs
new file mode 100644
index 0000000..4340e02
--- /dev/null
+++ b/apps/desktop/electron/ambient.cjs
@@ -0,0 +1,183 @@
+// Ambient status while Locus is in the background: a tray icon whose menu shows
+// running / needs-you counts, the dock or taskbar badge, and system notifications
+// that open the right chat (or, on macOS, Approve / Deny from the notification).
+// Keep channel names in sync with preload.cjs (sandboxed preloads cannot require this file).
+
+const STATUS = "locus:ambient-status";
+const NOTIFY = "locus:ambient-notify";
+const ACTION = "locus:ambient-action";
+
+function clampCount(value) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 999) : 0;
+}
+
+/** Normalise the renderer's status payload. */
+function parseStatus(raw) {
+ const value = raw && typeof raw === "object" ? raw : {};
+ return { running: clampCount(value.running), needsYou: clampCount(value.needsYou) };
+}
+
+/** Tray tooltip plus the two summary rows at the top of its menu. */
+function trayCopy(status) {
+ const { running, needsYou } = parseStatus(status);
+ const needs =
+ needsYou === 0 ? "Nothing needs you" : `${needsYou} need${needsYou === 1 ? "s" : ""} you`;
+ const run = running === 0 ? "No agents running" : `${running} running`;
+ const tooltip =
+ running === 0 && needsYou === 0
+ ? "Locus"
+ : `Locus — ${[needsYou ? needs : "", running ? run : ""].filter(Boolean).join(" · ")}`;
+ return { tooltip, needs, run };
+}
+
+/** Validate a notification request from the renderer. Returns undefined to drop it. */
+function parseNotify(raw) {
+ if (!raw || typeof raw !== "object") return undefined;
+ const title = typeof raw.title === "string" ? raw.title.slice(0, 200) : "";
+ if (!title.trim()) return undefined;
+ const body = typeof raw.body === "string" ? raw.body.slice(0, 400) : "";
+ const sessionId = typeof raw.sessionId === "string" ? raw.sessionId.slice(0, 200) : undefined;
+ const callId = typeof raw.callId === "string" ? raw.callId.slice(0, 200) : undefined;
+ return {
+ title,
+ body,
+ sessionId,
+ callId,
+ approvable: Boolean(raw.approvable && sessionId && callId),
+ };
+}
+
+/** Notification action buttons. Electron only renders them on macOS. */
+function notificationActions(request, platform) {
+ if (!request.approvable || platform !== "darwin") return [];
+ return [
+ { type: "button", text: "Approve" },
+ { type: "button", text: "Deny" },
+ ];
+}
+
+function actionForIndex(index) {
+ if (index === 0) return "approve";
+ if (index === 1) return "deny";
+ return "open";
+}
+
+/**
+ * Wire tray, badge and notifications. `electron` is the module (injected for tests);
+ * `getWindow` returns the main BrowserWindow; `icon` is a path to the app icon.
+ */
+function installAmbient({
+ electron,
+ ipcMain,
+ getWindow,
+ icon,
+ platform = process.platform,
+ senderOk,
+}) {
+ const { app, Menu, Notification, Tray, nativeImage } = electron;
+ let tray;
+ let status = { running: 0, needsYou: 0 };
+ // Keep live Notification objects referenced, or GC drops their click handlers.
+ const live = new Set();
+
+ function showWindow() {
+ const win = getWindow();
+ if (!win || win.isDestroyed()) return undefined;
+ if (win.isMinimized()) win.restore();
+ win.show();
+ win.focus();
+ return win;
+ }
+
+ function send(payload, show = true) {
+ const win = show ? showWindow() : getWindow();
+ if (win && !win.isDestroyed()) win.webContents.send(ACTION, payload);
+ }
+
+ function rebuildMenu() {
+ if (!tray) return;
+ const copy = trayCopy(status);
+ tray.setToolTip(copy.tooltip);
+ tray.setContextMenu(
+ Menu.buildFromTemplate([
+ { label: copy.needs, enabled: status.needsYou > 0, click: () => send({ action: "inbox" }) },
+ { label: copy.run, enabled: false },
+ { type: "separator" },
+ { label: "Show Locus", click: () => showWindow() },
+ { label: "Quit Locus", click: () => app.quit() },
+ ]),
+ );
+ }
+
+ function ensureTray() {
+ if (tray || !Tray) return;
+ try {
+ let image = nativeImage.createFromPath(icon);
+ if (!image.isEmpty()) image = image.resize({ width: 16, height: 16 });
+ tray = new Tray(image);
+ tray.on("click", () => showWindow());
+ rebuildMenu();
+ } catch {
+ // Some Linux desktops have no status area; the badge and notifications still work.
+ tray = undefined;
+ }
+ }
+
+ ipcMain.on(STATUS, (event, raw) => {
+ if (senderOk && !senderOk(event)) return;
+ status = parseStatus(raw);
+ ensureTray();
+ rebuildMenu();
+ try {
+ if (typeof app.setBadgeCount === "function") app.setBadgeCount(status.needsYou);
+ } catch {
+ /* badge unsupported on this desktop */
+ }
+ });
+
+ ipcMain.on(NOTIFY, (event, raw) => {
+ if (senderOk && !senderOk(event)) return;
+ const request = parseNotify(raw);
+ if (!request || !Notification || !Notification.isSupported()) return;
+ const note = new Notification({
+ title: request.title,
+ body: request.body,
+ actions: notificationActions(request, platform),
+ });
+ live.add(note);
+ const forget = () => live.delete(note);
+ const target = { sessionId: request.sessionId, callId: request.callId };
+ note.on("click", () => {
+ forget();
+ send({ action: "open", ...target });
+ });
+ note.on("action", (_event, index) => {
+ forget();
+ const action = actionForIndex(index);
+ // Approve / Deny resolve in the background; only "open" brings the window up.
+ send({ action, ...target }, action === "open");
+ });
+ note.on("close", forget);
+ note.show();
+ });
+
+ return {
+ destroy() {
+ tray?.destroy();
+ tray = undefined;
+ },
+ };
+}
+
+module.exports = {
+ ACTION,
+ NOTIFY,
+ STATUS,
+ actionForIndex,
+ installAmbient,
+ notificationActions,
+ parseNotify,
+ parseStatus,
+ trayCopy,
+};
diff --git a/apps/desktop/electron/ambient.test.ts b/apps/desktop/electron/ambient.test.ts
new file mode 100644
index 0000000..8ab6593
--- /dev/null
+++ b/apps/desktop/electron/ambient.test.ts
@@ -0,0 +1,137 @@
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it, vi } from "vitest";
+
+const desktopRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
+type Listener = (...args: unknown[]) => void;
+
+const ambient = createRequire(import.meta.url)("./ambient.cjs") as {
+ ACTION: string;
+ NOTIFY: string;
+ STATUS: string;
+ actionForIndex: (index: number) => string;
+ notificationActions: (request: { approvable: boolean }, platform: string) => unknown[];
+ parseNotify: (
+ raw: unknown,
+ ) =>
+ | { title: string; body: string; sessionId?: string; callId?: string; approvable: boolean }
+ | undefined;
+ parseStatus: (raw: unknown) => { running: number; needsYou: number };
+ trayCopy: (status: unknown) => { tooltip: string; needs: string; run: string };
+ installAmbient: (opts: Record) => { destroy: () => void };
+};
+
+describe("ambient helpers", () => {
+ it("clamps status counts", () => {
+ expect(ambient.parseStatus({ running: 2.7, needsYou: -1 })).toEqual({
+ running: 2,
+ needsYou: 0,
+ });
+ expect(ambient.parseStatus(null)).toEqual({ running: 0, needsYou: 0 });
+ });
+
+ it("writes tray copy", () => {
+ expect(ambient.trayCopy({ running: 0, needsYou: 0 }).tooltip).toBe("Locus");
+ const copy = ambient.trayCopy({ running: 3, needsYou: 1 });
+ expect(copy.tooltip).toBe("Locus — 1 needs you · 3 running");
+ expect(copy.needs).toBe("1 needs you");
+ expect(ambient.trayCopy({ running: 0, needsYou: 2 }).needs).toBe("2 need you");
+ });
+
+ it("validates notification requests", () => {
+ expect(ambient.parseNotify({ title: " " })).toBeUndefined();
+ expect(ambient.parseNotify({ title: "T", approvable: true })?.approvable).toBe(false);
+ expect(
+ ambient.parseNotify({ title: "T", sessionId: "s", callId: "c", approvable: true })
+ ?.approvable,
+ ).toBe(true);
+ });
+
+ it("only adds Approve / Deny buttons on macOS", () => {
+ expect(ambient.notificationActions({ approvable: true }, "darwin")).toHaveLength(2);
+ expect(ambient.notificationActions({ approvable: true }, "linux")).toEqual([]);
+ expect(ambient.notificationActions({ approvable: false }, "darwin")).toEqual([]);
+ expect([0, 1, 5].map(ambient.actionForIndex)).toEqual(["approve", "deny", "open"]);
+ });
+
+ it("keeps preload channels aligned", () => {
+ const preload = readFileSync(join(desktopRoot, "electron/preload.cjs"), "utf8");
+ for (const channel of [ambient.STATUS, ambient.NOTIFY, ambient.ACTION]) {
+ expect(preload).toContain(`"${channel}"`);
+ }
+ });
+});
+
+describe("installAmbient", () => {
+ function setup(platform = "darwin") {
+ const handlers = new Map();
+ const sent: unknown[] = [];
+ const win = {
+ isDestroyed: () => false,
+ isMinimized: () => false,
+ restore: vi.fn(),
+ show: vi.fn(),
+ focus: vi.fn(),
+ webContents: { send: (_channel: string, payload: unknown) => sent.push(payload) },
+ };
+ const notes: Array<{ handlers: Map; opts: Record }> = [];
+ class FakeNotification {
+ handlers = new Map();
+ constructor(public opts: Record) {
+ notes.push(this);
+ }
+ static isSupported() {
+ return true;
+ }
+ on(event: string, fn: Listener) {
+ this.handlers.set(event, fn);
+ }
+ show() {}
+ }
+ const tray = { setToolTip: vi.fn(), setContextMenu: vi.fn(), on: vi.fn(), destroy: vi.fn() };
+ const app = { setBadgeCount: vi.fn(), quit: vi.fn() };
+ ambient.installAmbient({
+ electron: {
+ app,
+ Menu: { buildFromTemplate: (items: unknown[]) => items },
+ Notification: FakeNotification,
+ Tray: function Tray() {
+ return tray;
+ },
+ nativeImage: {
+ createFromPath: () => ({ isEmpty: () => false, resize: () => ({}) }),
+ },
+ },
+ ipcMain: { on: (channel: string, fn: Listener) => handlers.set(channel, fn) },
+ getWindow: () => win,
+ icon: "icon.png",
+ platform,
+ });
+ return { handlers, sent, win, notes, tray, app };
+ }
+
+ it("updates the badge and tray from status", () => {
+ const { handlers, tray, app } = setup();
+ handlers.get(ambient.STATUS)!({}, { running: 1, needsYou: 2 });
+ expect(app.setBadgeCount).toHaveBeenCalledWith(2);
+ expect(tray.setToolTip).toHaveBeenCalledWith("Locus — 2 need you · 1 running");
+ });
+
+ it("routes notification clicks and actions back to the renderer", () => {
+ const { handlers, sent, win, notes } = setup();
+ handlers.get(ambient.NOTIFY)!(
+ {},
+ { title: "Chat", body: "npm test", sessionId: "s1", callId: "c1", approvable: true },
+ );
+ expect(notes[0]!.opts.actions).toHaveLength(2);
+ notes[0]!.handlers.get("action")!({}, 0);
+ expect(sent.at(-1)).toEqual({ action: "approve", sessionId: "s1", callId: "c1" });
+ // Approve resolves in the background without raising the window.
+ expect(win.show).not.toHaveBeenCalled();
+ notes[0]!.handlers.get("click")!();
+ expect(sent.at(-1)).toEqual({ action: "open", sessionId: "s1", callId: "c1" });
+ expect(win.show).toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 0e4eddc..3cca440 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -11,7 +11,8 @@ const {
const { spawn } = require("node:child_process");
const { randomUUID } = require("node:crypto");
const { join } = require("node:path");
-const { installCloseGuard } = require("./close-guard.cjs");
+const { installAmbient } = require("./ambient.cjs");
+const { installCloseGuard, senderIsWindow } = require("./close-guard.cjs");
const {
controlPlaneKeepAlive,
desktopServerRecordPath,
@@ -33,7 +34,8 @@ const {
const { desktopCliInvocation } = require("./cli-argv.cjs");
const { resolveLaunch, syncPackagedUi, uiBuildStamp } = require("./launch.cjs");
const { loadUrlWithRetry, waitForUrl, waitUntilUrlDown } = require("./net-wait.cjs");
-const { wrapMasterKey } = require("./vault-key.cjs");
+const { locusHome, wrapMasterKey } = require("./vault-key.cjs");
+const { planUpdateInstall, swapAppImage } = require("./update-install.cjs");
const { dropWindowsApplicationMenu, installWindowsInputGuard } = require("./windows-input.cjs");
const { applyZoomLevel, zoomAction } = require("./zoom.cjs");
@@ -100,6 +102,55 @@ const closeGuard = installCloseGuard({
ipcMain,
getWindow: () => mainWindow,
});
+installAmbient({
+ electron: require("electron"),
+ ipcMain,
+ getWindow: () => mainWindow,
+ icon: appIcon,
+ senderOk: (event) => senderIsWindow(event, () => mainWindow),
+});
+/** Set by restart-for-update / restart-after-restore: stop the plane even with Keep running. */
+let forceStopServer = false;
+
+function restartApp(opts = {}) {
+ forceStopServer = true;
+ app.relaunch(opts.execPath ? { execPath: opts.execPath } : undefined);
+ app.quit();
+}
+
+ipcMain.handle("locus:restart-app", (event) => {
+ if (!senderIsWindow(event, () => mainWindow)) return { ok: false };
+ restartApp();
+ return { ok: true };
+});
+
+ipcMain.handle("locus:install-update", async (event, file) => {
+ if (!senderIsWindow(event, () => mainWindow)) return { ok: false, error: "Not allowed" };
+ const plan = planUpdateInstall({
+ file,
+ updatesDir: join(locusHome(), "updates"),
+ platform: process.platform,
+ appImage: process.env.APPIMAGE,
+ });
+ try {
+ if (plan.kind === "reject") return { ok: false, error: plan.reason };
+ if (plan.kind === "appimage") {
+ swapAppImage(plan.source, plan.target);
+ restartApp({ execPath: plan.target });
+ return { ok: true, action: "relaunching" };
+ }
+ if (plan.kind === "windows") {
+ spawn(plan.source, [], { detached: true, stdio: "ignore", windowsHide: false }).unref();
+ forceStopServer = true;
+ app.quit();
+ return { ok: true, action: "installer" };
+ }
+ const error = await shell.openPath(plan.source);
+ return error ? { ok: false, error } : { ok: true, action: "opened" };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+});
async function ensureServer() {
const recordPath = desktopServerRecordPath();
@@ -127,7 +178,7 @@ async function ensureServer() {
} else if (record) {
await forgetDesktopServer(recordPath);
}
- startServer();
+ await startServer();
if (child) ownsServer = true;
}
@@ -171,7 +222,7 @@ async function reviveServer() {
return;
}
dropDeadHandles();
- startServer();
+ await startServer();
if (child) ownsServer = true;
}
@@ -229,7 +280,7 @@ async function checkControlPlane() {
scheduleRestart();
}
-function startServer() {
+async function startServer() {
const launch = resolveLaunch({
packaged: app.isPackaged,
dev,
@@ -239,12 +290,15 @@ function startServer() {
repoRoot: join(__dirname, "..", "..", ".."),
});
if (!launch) return;
+ const vaultEnv = await wrapMasterKey(safeStorage, { unlinkPlaintext: app.isPackaged });
+ // Quit may have started, or another start won, while the key was unwrapped.
+ if (quitPhase !== "idle" || child) return;
child = spawn(launch.command, launch.args, {
stdio: launch.stdio,
env: {
...process.env,
...launch.env,
- ...wrapMasterKey(safeStorage, { unlinkPlaintext: app.isPackaged }),
+ ...vaultEnv,
LOCUS_DESKTOP_OWNER: desktopOwner,
},
cwd: launch.cwd,
@@ -258,7 +312,7 @@ function startServer() {
async function releaseServer() {
const recordPath = desktopServerRecordPath();
- const keepAlive = await controlPlaneKeepAlive(fetch, KEEP_ALIVE_URL);
+ const keepAlive = forceStopServer ? false : await controlPlaneKeepAlive(fetch, KEEP_ALIVE_URL);
const action = serverQuitAction({ ownsChild: ownsServer, keepAlive, adoptedPid });
if (action === "detach") {
if (child && child.pid) {
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
index 51b3ea2..50eb6ac 100644
--- a/apps/desktop/electron/preload.cjs
+++ b/apps/desktop/electron/preload.cjs
@@ -6,6 +6,10 @@ const SET_GUARD = "locus:set-close-guard";
const CLOSE_REQUESTED = "locus:close-requested";
const CLOSE_ACK = "locus:close-ack";
const CONFIRM_CLOSE = "locus:confirm-close";
+// Keep these in sync with electron/ambient.cjs.
+const AMBIENT_STATUS = "locus:ambient-status";
+const AMBIENT_NOTIFY = "locus:ambient-notify";
+const AMBIENT_ACTION = "locus:ambient-action";
contextBridge.exposeInMainWorld("locusDesktop", {
setCloseGuard(armed) {
@@ -22,4 +26,35 @@ contextBridge.exposeInMainWorld("locusDesktop", {
confirmClose() {
ipcRenderer.send(CONFIRM_CLOSE);
},
+ /** Install a verified update from /updates (AppImage swap, NSIS, or open). */
+ installUpdate(path) {
+ return ipcRenderer.invoke("locus:install-update", String(path));
+ },
+ /** Quit and relaunch, stopping the control plane even when Keep running is on. */
+ restartApp() {
+ return ipcRenderer.invoke("locus:restart-app");
+ },
+ /** Tray menu and dock/taskbar badge: how many agents run and how many need you. */
+ setAmbientStatus(status) {
+ ipcRenderer.send(AMBIENT_STATUS, {
+ running: Number(status?.running) || 0,
+ needsYou: Number(status?.needsYou) || 0,
+ });
+ },
+ /** System notification that opens the chat; on macOS it can Approve / Deny too. */
+ notify(request) {
+ ipcRenderer.send(AMBIENT_NOTIFY, {
+ title: String(request?.title ?? ""),
+ body: String(request?.body ?? ""),
+ sessionId: request?.sessionId ? String(request.sessionId) : undefined,
+ callId: request?.callId ? String(request.callId) : undefined,
+ approvable: Boolean(request?.approvable),
+ });
+ },
+ /** Clicks on the tray menu or a notification: open, inbox, approve, deny. */
+ onAmbientAction(callback) {
+ const listener = (_event, payload) => callback(payload);
+ ipcRenderer.on(AMBIENT_ACTION, listener);
+ return () => ipcRenderer.removeListener(AMBIENT_ACTION, listener);
+ },
});
diff --git a/apps/desktop/electron/update-install.cjs b/apps/desktop/electron/update-install.cjs
new file mode 100644
index 0000000..06c1bae
--- /dev/null
+++ b/apps/desktop/electron/update-install.cjs
@@ -0,0 +1,55 @@
+const { chmodSync, copyFileSync, existsSync, renameSync, rmSync, statSync } = require("node:fs");
+const { basename, dirname, join, relative, resolve, isAbsolute } = require("node:path");
+
+/**
+ * Decide how to install a verified update the control plane downloaded into
+ * `/updates/`.
+ *
+ * - Linux AppImage: replace the running AppImage file and relaunch.
+ * - Windows NSIS: run the installer, then quit so it can replace the app.
+ * - Everything else (dmg, deb, rpm, dev builds): open the file with the system handler.
+ */
+function planUpdateInstall({ file, updatesDir, platform, appImage }) {
+ if (typeof file !== "string" || !file) return { kind: "reject", reason: "No installer path" };
+ const abs = resolve(file);
+ const rel = relative(resolve(updatesDir), abs);
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
+ return { kind: "reject", reason: "Installer is not in the Locus updates folder" };
+ }
+ const name = basename(abs);
+ if (platform === "linux" && appImage && /\.AppImage$/i.test(name)) {
+ return { kind: "appimage", source: abs, target: resolve(appImage) };
+ }
+ if (platform === "win32" && /\.exe$/i.test(name)) {
+ return { kind: "windows", source: abs };
+ }
+ return { kind: "open", source: abs };
+}
+
+/**
+ * Copy the new AppImage beside the running one, then rename it over the original.
+ * A rename on the same filesystem is atomic, so a crash leaves either version intact.
+ */
+function swapAppImage(
+ source,
+ target,
+ fs = { chmodSync, copyFileSync, existsSync, renameSync, rmSync, statSync },
+) {
+ if (!fs.existsSync(source)) throw new Error("The downloaded AppImage is missing");
+ const staged = join(dirname(target), `.${basename(target)}.update`);
+ try {
+ fs.copyFileSync(source, staged);
+ fs.chmodSync(staged, 0o755);
+ fs.renameSync(staged, target);
+ } catch (err) {
+ try {
+ fs.rmSync(staged, { force: true });
+ } catch {
+ /* best effort */
+ }
+ throw err;
+ }
+ return target;
+}
+
+module.exports = { planUpdateInstall, swapAppImage };
diff --git a/apps/desktop/electron/update-install.test.ts b/apps/desktop/electron/update-install.test.ts
new file mode 100644
index 0000000..e39771b
--- /dev/null
+++ b/apps/desktop/electron/update-install.test.ts
@@ -0,0 +1,95 @@
+import { mkdtempSync, readFileSync, statSync, writeFileSync, existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+type Plan =
+ | { kind: "reject"; reason: string }
+ | { kind: "appimage"; source: string; target: string }
+ | { kind: "windows"; source: string }
+ | { kind: "open"; source: string };
+
+const { planUpdateInstall, swapAppImage } = createRequire(import.meta.url)(
+ "./update-install.cjs",
+) as {
+ planUpdateInstall: (opts: {
+ file: unknown;
+ updatesDir: string;
+ platform: string;
+ appImage?: string;
+ }) => Plan;
+ swapAppImage: (source: string, target: string) => string;
+};
+
+describe("planUpdateInstall", () => {
+ const updatesDir = join(tmpdir(), "locus-home", "updates");
+
+ it("refuses files outside the updates folder", () => {
+ expect(
+ planUpdateInstall({
+ file: "/tmp/evil.AppImage",
+ updatesDir,
+ platform: "linux",
+ appImage: "/a",
+ }),
+ ).toMatchObject({ kind: "reject" });
+ expect(
+ planUpdateInstall({ file: join(updatesDir, "..", "x.exe"), updatesDir, platform: "win32" }),
+ ).toMatchObject({ kind: "reject" });
+ expect(planUpdateInstall({ file: 7, updatesDir, platform: "linux" })).toMatchObject({
+ kind: "reject",
+ });
+ });
+
+ it("swaps a running AppImage on Linux", () => {
+ const file = join(updatesDir, "Locus-0.21.0.AppImage");
+ expect(
+ planUpdateInstall({ file, updatesDir, platform: "linux", appImage: "/opt/Locus.AppImage" }),
+ ).toEqual({ kind: "appimage", source: file, target: "/opt/Locus.AppImage" });
+ });
+
+ it("opens the file when not running from an AppImage", () => {
+ const file = join(updatesDir, "Locus-0.21.0.AppImage");
+ expect(planUpdateInstall({ file, updatesDir, platform: "linux" })).toEqual({
+ kind: "open",
+ source: file,
+ });
+ const deb = join(updatesDir, "Locus-0.21.0-amd64.deb");
+ expect(
+ planUpdateInstall({ file: deb, updatesDir, platform: "linux", appImage: "/a" }).kind,
+ ).toBe("open");
+ });
+
+ it("runs the NSIS installer on Windows and opens a dmg on macOS", () => {
+ const exe = join(updatesDir, "Locus Setup 0.21.0.exe");
+ expect(planUpdateInstall({ file: exe, updatesDir, platform: "win32" })).toEqual({
+ kind: "windows",
+ source: exe,
+ });
+ const dmg = join(updatesDir, "Locus-0.21.0.dmg");
+ expect(planUpdateInstall({ file: dmg, updatesDir, platform: "darwin" }).kind).toBe("open");
+ });
+});
+
+describe("swapAppImage", () => {
+ it("replaces the target with an executable copy", () => {
+ const dir = mkdtempSync(join(tmpdir(), "locus-appimage-"));
+ const source = join(dir, "new.AppImage");
+ const target = join(dir, "Locus.AppImage");
+ writeFileSync(source, "new build");
+ writeFileSync(target, "old build");
+ swapAppImage(source, target);
+ expect(readFileSync(target, "utf8")).toBe("new build");
+ if (process.platform !== "win32") expect(statSync(target).mode & 0o111).not.toBe(0);
+ expect(existsSync(join(dir, ".Locus.AppImage.update"))).toBe(false);
+ });
+
+ it("fails without touching the target when the download is missing", () => {
+ const dir = mkdtempSync(join(tmpdir(), "locus-appimage-"));
+ const target = join(dir, "Locus.AppImage");
+ writeFileSync(target, "old build");
+ expect(() => swapAppImage(join(dir, "missing.AppImage"), target)).toThrow(/missing/);
+ expect(readFileSync(target, "utf8")).toBe("old build");
+ });
+});
diff --git a/apps/desktop/electron/vault-key.cjs b/apps/desktop/electron/vault-key.cjs
index 115178a..dbed1dd 100644
--- a/apps/desktop/electron/vault-key.cjs
+++ b/apps/desktop/electron/vault-key.cjs
@@ -7,17 +7,18 @@ function locusHome() {
return process.env.LOCUS_HOME || join(homedir(), ".locus");
}
-function wrapMasterKey(safeStorage, opts = {}) {
+async function wrapMasterKey(safeStorage, opts = {}) {
if (process.env.LOCUS_MASTER_KEY?.trim())
return { LOCUS_MASTER_KEY: process.env.LOCUS_MASTER_KEY.trim() };
- if (!safeStorage?.isEncryptionAvailable?.()) return {};
+ if (!(await encryptionAvailable(safeStorage))) return {};
const dir = locusHome();
mkdirSync(dir, { recursive: true, mode: 0o700 });
const wrapped = join(dir, "master.key.safe");
const raw = join(dir, "master.key");
try {
if (existsSync(wrapped)) {
- const key = safeStorage.decryptString(readFileSync(wrapped));
+ const { key, shouldReEncrypt } = await decrypt(safeStorage, readFileSync(wrapped));
+ if (shouldReEncrypt) await writeWrapped(safeStorage, wrapped, key);
unlinkPlaintextKey(raw, opts.unlinkPlaintext);
return { LOCUS_MASTER_KEY: key };
}
@@ -28,7 +29,7 @@ function wrapMasterKey(safeStorage, opts = {}) {
key = randomBytes(32).toString("hex");
writeFileSync(raw, `${key}\n`, { encoding: "utf8", mode: 0o600 });
}
- writeFileSync(wrapped, safeStorage.encryptString(key), { mode: 0o600 });
+ await writeWrapped(safeStorage, wrapped, key);
unlinkPlaintextKey(raw, opts.unlinkPlaintext);
return { LOCUS_MASTER_KEY: key };
} catch {
@@ -36,6 +37,26 @@ function wrapMasterKey(safeStorage, opts = {}) {
}
}
+// Electron deprecated the synchronous safeStorage calls; use the async API.
+async function encryptionAvailable(safeStorage) {
+ try {
+ return (await safeStorage?.isAsyncEncryptionAvailable?.()) === true;
+ } catch {
+ return false;
+ }
+}
+
+async function decrypt(safeStorage, encrypted) {
+ const out = await safeStorage.decryptStringAsync(encrypted);
+ if (typeof out === "string") return { key: out, shouldReEncrypt: false };
+ return { key: out.result, shouldReEncrypt: out.shouldReEncrypt === true };
+}
+
+async function writeWrapped(safeStorage, wrapped, key) {
+ const encrypted = await safeStorage.encryptStringAsync(key);
+ writeFileSync(wrapped, encrypted, { mode: 0o600 });
+}
+
function unlinkPlaintextKey(raw, enabled) {
if (!enabled || !existsSync(raw)) return;
try {
diff --git a/apps/desktop/electron/vault-key.test.ts b/apps/desktop/electron/vault-key.test.ts
index 5bd9976..cc009f9 100644
--- a/apps/desktop/electron/vault-key.test.ts
+++ b/apps/desktop/electron/vault-key.test.ts
@@ -7,18 +7,23 @@ import { afterEach, describe, expect, it } from "vitest";
const { wrapMasterKey } = createRequire(import.meta.url)("./vault-key.cjs") as {
wrapMasterKey: (
safeStorage: {
- isEncryptionAvailable?: () => boolean;
- encryptString: (plain: string) => Buffer;
- decryptString: (encrypted: Buffer) => string;
+ isAsyncEncryptionAvailable?: () => Promise;
+ encryptStringAsync: (plain: string) => Promise;
+ decryptStringAsync: (
+ encrypted: Buffer,
+ ) => Promise<{ result: string; shouldReEncrypt: boolean }>;
},
opts?: { unlinkPlaintext?: boolean },
- ) => { LOCUS_MASTER_KEY?: string };
+ ) => Promise<{ LOCUS_MASTER_KEY?: string }>;
};
const fakeStorage = {
- isEncryptionAvailable: () => true,
- encryptString: (plain: string) => Buffer.from(plain, "utf8"),
- decryptString: (encrypted: Buffer) => Buffer.from(encrypted).toString("utf8"),
+ isAsyncEncryptionAvailable: async () => true,
+ encryptStringAsync: async (plain: string) => Buffer.from(plain, "utf8"),
+ decryptStringAsync: async (encrypted: Buffer) => ({
+ result: Buffer.from(encrypted).toString("utf8"),
+ shouldReEncrypt: false,
+ }),
};
describe("wrapMasterKey", () => {
@@ -32,36 +37,36 @@ describe("wrapMasterKey", () => {
else process.env.LOCUS_MASTER_KEY = previousKey;
});
- it("keeps plaintext master.key in unpackaged mode", () => {
+ it("keeps plaintext master.key in unpackaged mode", async () => {
const dir = mkdtempSync(join(tmpdir(), "locus-wrap-dev-"));
delete process.env.LOCUS_MASTER_KEY;
process.env.LOCUS_HOME = dir;
writeFileSync(join(dir, "master.key"), `${"ab".repeat(32)}\n`, { mode: 0o600 });
- const result = wrapMasterKey(fakeStorage, { unlinkPlaintext: false });
+ const result = await wrapMasterKey(fakeStorage, { unlinkPlaintext: false });
expect(result.LOCUS_MASTER_KEY).toBe("ab".repeat(32));
expect(existsSync(join(dir, "master.key"))).toBe(true);
expect(existsSync(join(dir, "master.key.safe"))).toBe(true);
});
- it("deletes leftover plaintext master.key after wrapping", () => {
+ it("deletes leftover plaintext master.key after wrapping", async () => {
const dir = mkdtempSync(join(tmpdir(), "locus-wrap-pkg-"));
delete process.env.LOCUS_MASTER_KEY;
process.env.LOCUS_HOME = dir;
writeFileSync(join(dir, "master.key"), `${"cd".repeat(32)}\n`, { mode: 0o600 });
- const result = wrapMasterKey(fakeStorage, { unlinkPlaintext: true });
+ const result = await wrapMasterKey(fakeStorage, { unlinkPlaintext: true });
expect(result.LOCUS_MASTER_KEY).toBe("cd".repeat(32));
expect(existsSync(join(dir, "master.key"))).toBe(false);
expect(existsSync(join(dir, "master.key.safe"))).toBe(true);
});
- it("deletes leftover plaintext when a wrapped key already exists", () => {
+ it("deletes leftover plaintext when a wrapped key already exists", async () => {
const dir = mkdtempSync(join(tmpdir(), "locus-wrap-left-"));
delete process.env.LOCUS_MASTER_KEY;
process.env.LOCUS_HOME = dir;
writeFileSync(join(dir, "master.key"), `${"ef".repeat(32)}\n`, { mode: 0o600 });
- wrapMasterKey(fakeStorage, { unlinkPlaintext: false });
+ await wrapMasterKey(fakeStorage, { unlinkPlaintext: false });
expect(existsSync(join(dir, "master.key"))).toBe(true);
- const result = wrapMasterKey(fakeStorage, { unlinkPlaintext: true });
+ const result = await wrapMasterKey(fakeStorage, { unlinkPlaintext: true });
expect(result.LOCUS_MASTER_KEY).toBe("ef".repeat(32));
expect(existsSync(join(dir, "master.key"))).toBe(false);
});
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 4469f7a..56206b8 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"author": "Locus contributors",
"license": "MIT",
@@ -45,7 +45,7 @@
"jsdom": "^30.1.3",
"typescript": "^7.0.2",
"vite": "^8.3.5",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
},
"build": {
"appId": "dev.locus.desktop",
@@ -107,7 +107,10 @@
"target": [
"dmg"
],
- "category": "public.app-category.developer-tools"
+ "category": "public.app-category.developer-tools",
+ "binaries": [
+ "Contents/Resources/server/bin/rg"
+ ]
},
"win": {
"target": [
diff --git a/apps/desktop/scripts/bundle-server.mjs b/apps/desktop/scripts/bundle-server.mjs
index b4bd1a3..f01493a 100644
--- a/apps/desktop/scripts/bundle-server.mjs
+++ b/apps/desktop/scripts/bundle-server.mjs
@@ -1,6 +1,6 @@
import * as esbuild from "esbuild";
import { existsSync, readdirSync, readFileSync } from "node:fs";
-import { cp, mkdir } from "node:fs/promises";
+import { chmod, cp, mkdir } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -113,6 +113,21 @@ export async function copyTypescriptPackage(destDir) {
return destDir;
}
+/** Platform ripgrep from `@vscode/ripgrep`, copied to `/bin` so Files search never walks in JS. */
+export function ripgrepBinaryPath(platform = process.platform, arch = process.arch) {
+ const name = platform === "win32" ? "rg.exe" : "rg";
+ return require.resolve(`@vscode/ripgrep-${platform}-${arch}/bin/${name}`);
+}
+
+export async function copyRipgrep(serverDir) {
+ const src = ripgrepBinaryPath();
+ const dest = join(serverDir, "bin", process.platform === "win32" ? "rg.exe" : "rg");
+ await mkdir(dirname(dest), { recursive: true });
+ await cp(src, dest);
+ if (process.platform !== "win32") await chmod(dest, 0o755);
+ return dest;
+}
+
export async function bundleControlPlane(
outfile = join(desktopRoot, "build", "server", "index.mjs"),
) {
@@ -124,6 +139,7 @@ export async function bundleControlPlane(
}),
);
await copyTypescriptPackage(join(desktopRoot, "build", "server-typescript-ls"));
+ await copyRipgrep(dirname(outfile));
return outfile;
}
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3091b8b..a9b9395 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -47,7 +47,7 @@ vi.mock("@/lib/api", () => ({
connected: false,
tabs: [],
})),
- checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.20.0" })),
+ checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.21.0" })),
workersStatus: vi.fn(async () => []),
tailscale: vi.fn(async () => ({
installed: false,
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index d168db6..cf41b08 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -58,7 +58,11 @@ import {
import { applyChatDensity, applyEditorPrefs, applyTheme } from "@/lib/theme";
import { resolveUiShell, setUiShell, type UiShell } from "@/lib/mobileShell";
import { needsYouCount, needsYouKeys } from "@/lib/needsYou";
+import { ambientCounts, needsYouNotice, runAmbientAction, showNeedsYouNotice } from "@/lib/ambient";
+import { desktopBridge } from "@/lib/closeGuard";
+import { showErrorNotice } from "@/lib/notice";
import { phoneToken } from "@/lib/phoneAuth";
+import { useBackgroundUpdateCheck } from "@/lib/appUpdate";
import { Store, initialState, isIdeSession, reducer, sectionEnter } from "@/state/store";
const AutomationsPane = lazy(async () => ({
@@ -134,6 +138,10 @@ const MobileShell = lazy(async () => ({
export function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const [shell, setShell] = useState(() => resolveUiShell());
+ useBackgroundUpdateCheck({
+ enabled: Boolean(state.config) && shell !== "mobile" && !phoneToken(),
+ checkForUpdates: state.config?.checkForUpdates,
+ });
const [shellReady, setShellReady] = useState(false);
const [readyForShell, setReadyForShell] = useState(shell);
const [slackToasts, setSlackToasts] = useState([]);
@@ -357,10 +365,14 @@ export function App() {
if (shouldMarkUnread(event.session.id, selectedRef.current)) {
dispatch({ type: "unread", id: event.session.id });
}
- notifyTurnDone(
- event.session.title,
- "Needs you",
+ const sessionId = event.session.id;
+ showNeedsYouNotice(
+ needsYouNotice(event.session, fresh),
desktopNotifyAllowed(configRef.current, "needsYou"),
+ () => {
+ dispatch({ type: "select", id: sessionId });
+ dispatch({ type: "inbox", open: false });
+ },
);
}
break;
@@ -500,10 +512,12 @@ export function App() {
shouldNotifyTurn(event.sessionId, selectedId, document.hidden)
) {
const title = session?.title ?? "Agent";
+ const doneId = event.sessionId;
notifyTurnDone(
title,
"Turn finished",
desktopNotifyAllowed(configRef.current, "agents"),
+ () => dispatch({ type: "select", id: doneId }),
);
}
break;
@@ -696,6 +710,26 @@ export function App() {
});
}, [state.sessions, state.boards, state.brain, state.unreadIds]);
+ // Desktop shell: tray menu + dock/taskbar badge keep the counts visible in the background.
+ const ambient = ambientCounts(state.sessions, state.boards, state.brain);
+ useEffect(() => {
+ desktopBridge()?.setAmbientStatus?.(ambient);
+ }, [ambient.running, ambient.needsYou]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const subscribe = desktopBridge()?.onAmbientAction;
+ if (!subscribe) return;
+ return subscribe((payload) => {
+ void runAmbientAction(payload, {
+ dispatch,
+ resolveApproval: (sessionId, callId, decision) =>
+ api.resolveApproval(sessionId, callId, decision),
+ onError: (err, decision) =>
+ showErrorNotice(err, decision === "approved" ? "Could not approve" : "Could not deny"),
+ });
+ });
+ }, [dispatch]);
+
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (matchModKey(e, "n")) {
diff --git a/apps/desktop/src/components/automations/NewAutomation.tsx b/apps/desktop/src/components/automations/NewAutomation.tsx
index 197fe53..5aee899 100644
--- a/apps/desktop/src/components/automations/NewAutomation.tsx
+++ b/apps/desktop/src/components/automations/NewAutomation.tsx
@@ -218,25 +218,17 @@ export function NewAutomationModal() {
setTemplateId(current?.templateId ?? templates.find((t) => t.os === nextOs)?.id ?? "");
setProviderId(current?.providerId ?? pickProvider(providers)?.id ?? "");
setModel(current?.model ?? pickProvider(providers)?.defaultModel ?? "");
- setRepo(
- current?.repo?.url ??
- (state.section === "codebase"
- ? (state.repos.find((row) => row.id === state.selectedRepoId)?.checkout.url ?? "")
- : ""),
- );
- setRepoId(
- current
- ? liveRepoId(state.repos, current.repoId)
- : state.section === "codebase"
- ? (state.selectedRepoId ?? "")
- : "",
- );
- setRef(
- current?.repo?.ref ??
- (state.section === "codebase"
- ? (state.repos.find((row) => row.id === state.selectedRepoId)?.checkout.ref ?? "")
- : ""),
- );
+ // From Codebase, the selected repo. Elsewhere, a lone linked repo — except
+ // for starters that don't use git (Slack ops webhook).
+ const seedRepo =
+ state.section === "codebase"
+ ? state.repos.find((row) => row.id === state.selectedRepoId)
+ : !current && state.automationStarterId !== "slack-ops" && state.repos.length === 1
+ ? state.repos[0]
+ : undefined;
+ setRepo(current?.repo?.url ?? seedRepo?.checkout.url ?? "");
+ setRepoId(current ? liveRepoId(state.repos, current.repoId) : (seedRepo?.id ?? ""));
+ setRef(current?.repo?.ref ?? seedRepo?.checkout.ref ?? "");
setStartVm(current?.startVm ?? true);
setReuseSession(current?.reuseSession ?? false);
setOwnerSessionId(
diff --git a/apps/desktop/src/components/board/BoardEmpty.test.tsx b/apps/desktop/src/components/board/BoardEmpty.test.tsx
index c25df20..0f42d7c 100644
--- a/apps/desktop/src/components/board/BoardEmpty.test.tsx
+++ b/apps/desktop/src/components/board/BoardEmpty.test.tsx
@@ -107,12 +107,12 @@ describe("BoardEmpty", () => {
]);
});
- it("uses the same primary New board chrome as New agent and New design", () => {
+ it("submits with the same Send-style chrome as New agent and New design", () => {
render();
const send = host.querySelector(".composer-send") as HTMLButtonElement;
expect(send.className).toContain("btn");
expect(send.className).toContain("primary");
- expect(send.getAttribute("aria-label")).toBe("New board");
- expect(send.textContent).toMatch(/New board/);
+ expect(send.getAttribute("aria-label")).toBe("Create board");
+ expect(send.textContent).toMatch(/Create/);
});
});
diff --git a/apps/desktop/src/components/board/BoardEmpty.tsx b/apps/desktop/src/components/board/BoardEmpty.tsx
index f2bdd28..8a21afc 100644
--- a/apps/desktop/src/components/board/BoardEmpty.tsx
+++ b/apps/desktop/src/components/board/BoardEmpty.tsx
@@ -4,7 +4,7 @@ import { api } from "@/lib/api";
import { resolveAgentDefaults } from "@/lib/lastAgent";
import { useStore } from "@/state/store";
import { EmptyHero } from "@/components/layout/EmptyHero";
-import { IconPlus } from "@/components/layout/Icons";
+import { IconReturn } from "@/components/layout/Icons";
export function BoardEmpty() {
const { state, dispatch } = useStore();
@@ -62,11 +62,12 @@ export function BoardEmpty() {
className="btn primary small composer-send"
type="submit"
disabled={busy}
- title="New board"
- aria-label={busy ? "Creating…" : "New board"}
+ title="Create board"
+ aria-label={busy ? "Creating…" : "Create board"}
>
-
- {busy ? "Creating…" : "New board"}
+ {/* Submit reads like every other composer's Send; "New board" is the sidebar's. */}
+ {busy ? "Creating…" : "Create"}
+
diff --git a/apps/desktop/src/components/brain/Brain.tsx b/apps/desktop/src/components/brain/Brain.tsx
index 6a3c6ca..6c0638a 100644
--- a/apps/desktop/src/components/brain/Brain.tsx
+++ b/apps/desktop/src/components/brain/Brain.tsx
@@ -170,8 +170,35 @@ export function BrainPane() {
+ {state.repos.length ? (
+
+ {state.repos.slice(0, 3).map((row) => (
+
+ void run("build", async () => {
+ const next = await api.brainBuild({ repo: row.checkout.url });
+ setDoc(next);
+ dispatch({ type: "brain", brain: next });
+ setOutput(next.output ?? "");
+ })
+ }
+ >
+ Index {shortRepoName(row.checkout.url, row.name)}
+
+ ))}
+
+ ) : null}
) : null}
diff --git a/apps/desktop/src/components/chat/ChatPane.tsx b/apps/desktop/src/components/chat/ChatPane.tsx
index 2a95321..29b3ae2 100644
--- a/apps/desktop/src/components/chat/ChatPane.tsx
+++ b/apps/desktop/src/components/chat/ChatPane.tsx
@@ -1,3 +1,4 @@
+import { DockerDownSessionNotice } from "@/components/layout/DockerDownCallout";
import {
useCallback,
useEffect,
@@ -97,6 +98,7 @@ import {
} from "@/lib/chatTurn";
import { CHAT_RECOVERY_TIMEOUT_MS } from "@/lib/net";
import { turnDiffByAssistantId } from "@/lib/artifacts";
+import { latestTurnOutcome } from "@/lib/turnOutcome";
import { saveLastAgent } from "@/lib/lastAgent";
import { chatFindHits, stepChatFindIndex } from "@/lib/chatFind";
import { isStuckToBottom, pinThreadToLatest, schedulePinThreadToLatest } from "@/lib/threadScroll";
@@ -257,6 +259,10 @@ export function ChatPane() {
() => (session && !viewing ? turnDiffByAssistantId(session) : new Map()),
[session, viewing],
);
+ const turnOutcome = useMemo(
+ () => (session && !viewing ? latestTurnOutcome(session) : undefined),
+ [session, viewing],
+ );
function pinToLatest() {
stickToBottom.current = true;
@@ -1149,6 +1155,7 @@ export function ChatPane() {
findHits={findHits}
findActiveId={findActiveId}
turnDiffs={turnDiffs}
+ turnOutcome={turnOutcome}
cloneStartId={cloneStartId}
cloneProgress={cloneProgress}
buildStartId={buildStartId}
@@ -1172,6 +1179,8 @@ export function ChatPane() {
onRetryLast={onRetryLast}
onCopyText={onCopyText}
onRemoveSubscription={onRemoveSubscription}
+ onContinue={() => box.current?.focus()}
+ onUndoTurn={(checkpointId) => setConfirmAction({ kind: "undo", checkpointId })}
/>
{showJump ? (
) : null}
+ {docker && !viewing ? : null}
{viewing ? (
diff --git a/apps/desktop/src/components/chat/ChatThread.tsx b/apps/desktop/src/components/chat/ChatThread.tsx
index 1f051ff..6d8785d 100644
--- a/apps/desktop/src/components/chat/ChatThread.tsx
+++ b/apps/desktop/src/components/chat/ChatThread.tsx
@@ -44,6 +44,8 @@ import {
type ListWindow,
} from "@/lib/listWindow";
import { TurnDiffChip } from "./ArtifactDrawer";
+import { TurnOutcomeCard } from "./TurnOutcomeCard";
+import type { TurnOutcome } from "@/lib/turnOutcome";
import { ExpandableLog } from "./ExpandableLog";
import { ShotCard, ToolActivity } from "./ChatToolActivity";
import { SubagentFold } from "./SubagentCard";
@@ -272,6 +274,7 @@ export const ChatThread = memo(function ChatThread({
findHits,
findActiveId,
turnDiffs,
+ turnOutcome,
cloneStartId,
cloneProgress,
buildStartId,
@@ -295,6 +298,8 @@ export const ChatThread = memo(function ChatThread({
onRetryLast,
onCopyText,
onRemoveSubscription,
+ onContinue,
+ onUndoTurn,
}: {
threadRef: RefObject;
onScroll: (event: UIEvent) => void;
@@ -310,6 +315,8 @@ export const ChatThread = memo(function ChatThread({
findHits: string[];
findActiveId?: string;
turnDiffs: ReadonlyMap;
+ /** Summary card for the latest finished turn (files, tests, next steps). */
+ turnOutcome?: TurnOutcome;
cloneStartId?: string;
cloneProgress?: CloneProgress;
buildStartId?: string;
@@ -333,6 +340,8 @@ export const ChatThread = memo(function ChatThread({
onRetryLast: () => void;
onCopyText: (value: string) => void;
onRemoveSubscription: (id: string) => void;
+ onContinue?: () => void;
+ onUndoTurn?: (checkpointId: string) => void;
}) {
const swarmWhoBase = { session, teamName, members: teamMembers };
const workingWho = swarmMode
@@ -696,6 +705,15 @@ export const ChatThread = memo(function ChatThread({
onOpenPath={onOpenPath}
/>
) : null}
+ {turnOutcome && m.id === turnOutcome.assistantId && !viewing && !turning ? (
+ onContinue?.()}
+ onUndo={onUndoTurn}
+ />
+ ) : null}
);
diff --git a/apps/desktop/src/components/chat/ExpandableLog.test.tsx b/apps/desktop/src/components/chat/ExpandableLog.test.tsx
index 867cc48..3ee02fc 100644
--- a/apps/desktop/src/components/chat/ExpandableLog.test.tsx
+++ b/apps/desktop/src/components/chat/ExpandableLog.test.tsx
@@ -65,7 +65,7 @@ describe("ExpandableLog", () => {
"ERROR: failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory";
render( );
expect(host.querySelector("summary")?.textContent).toBe(
- "Docker isn't running — switch to Host or start Docker",
+ "Docker isn't running — start Docker, then Start",
);
expect(host.querySelector("pre")?.textContent).toBe(raw);
});
diff --git a/apps/desktop/src/components/chat/ExpandableLog.tsx b/apps/desktop/src/components/chat/ExpandableLog.tsx
index 11e725f..57913f9 100644
--- a/apps/desktop/src/components/chat/ExpandableLog.tsx
+++ b/apps/desktop/src/components/chat/ExpandableLog.tsx
@@ -1,12 +1,17 @@
import { summarizeLogBlob } from "@locus/shared";
-import { plainJobError } from "@/lib/automations";
+import { DOCKER_DOWN_AGENT_MESSAGE, plainJobError } from "@/lib/automations";
export function ExpandableLog(props: { text: string; compact?: boolean; danger?: boolean }) {
// Known infrastructure errors (Docker down) read as one plain sentence; the
// raw text stays one click away.
const plain = plainJobError(props.text);
+ // Agents can't change runtime, so the job wording ("switch to Host") would
+ // promise something this chat can't do. The thread callout offers a Host copy.
const { summary, detail } = plain
- ? { summary: plain.summary, detail: plain.detail }
+ ? {
+ summary: plain.cause === "docker" ? DOCKER_DOWN_AGENT_MESSAGE : plain.summary,
+ detail: plain.detail,
+ }
: summarizeLogBlob(props.text);
if (!detail) {
if (props.compact) {
diff --git a/apps/desktop/src/components/chat/TurnOutcomeCard.tsx b/apps/desktop/src/components/chat/TurnOutcomeCard.tsx
new file mode 100644
index 0000000..1fa238c
--- /dev/null
+++ b/apps/desktop/src/components/chat/TurnOutcomeCard.tsx
@@ -0,0 +1,114 @@
+import { useState } from "react";
+import { turnOutcomeHeadline, turnTestsLabel, type TurnOutcome } from "@/lib/turnOutcome";
+import { PatchStackCard } from "./PatchStackCard";
+
+const FILES_SHOWN = 5;
+
+/**
+ * Summary under the last reply of a finished turn: what changed, whether tests
+ * passed, and the next step (review, open the PR, continue, or undo the turn).
+ */
+export function TurnOutcomeCard({
+ sessionId,
+ outcome,
+ onOpenPath,
+ onContinue,
+ onUndo,
+}: {
+ sessionId: string;
+ outcome: TurnOutcome;
+ onOpenPath: (path: string, line?: number) => void;
+ onContinue: () => void;
+ onUndo?: (checkpointId: string) => void;
+}) {
+ const [reviewing, setReviewing] = useState(false);
+ const tests = turnTestsLabel(outcome.tests);
+ const shown = outcome.files.slice(0, FILES_SHOWN);
+ const more = outcome.files.length - shown.length;
+ const canReview = outcome.patches.length > 0 || outcome.files.length > 0;
+
+ function review() {
+ if (outcome.patches.length) {
+ setReviewing((open) => !open);
+ return;
+ }
+ const first = outcome.files[0];
+ if (first) onOpenPath(first.path);
+ }
+
+ return (
+
+
+ {turnOutcomeHeadline(outcome)}
+ {tests ? (
+
+ {tests.ok ? "✓" : "✕"} {tests.text}
+
+ ) : null}
+ {outcome.commands ? (
+
+ {outcome.commands === 1 ? "1 command" : `${outcome.commands} commands`}
+
+ ) : null}
+
+ {shown.length ? (
+
+ {shown.map((file) => (
+
+ onOpenPath(file.path)}>
+ {file.path}
+
+ {file.additions || file.deletions ? (
+
+ +{file.additions} {" "}
+ −{file.deletions}
+
+ ) : null}
+
+ ))}
+ {more > 0 ? +{more} more : null}
+
+ ) : null}
+
+ {canReview ? (
+
+ {reviewing ? "Hide changes" : "Review changes"}
+
+ ) : null}
+ {outcome.prUrl ? (
+
+ Open PR
+
+ ) : null}
+
+ Continue
+
+ {outcome.undoCheckpointId && onUndo ? (
+
onUndo(outcome.undoCheckpointId!)}
+ >
+ Undo this turn
+
+ ) : null}
+
+ {reviewing && outcome.patches.length ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/components/codebase/ArchitecturePane.test.tsx b/apps/desktop/src/components/codebase/ArchitecturePane.test.tsx
index eebf2e1..8cd80ed 100644
--- a/apps/desktop/src/components/codebase/ArchitecturePane.test.tsx
+++ b/apps/desktop/src/components/codebase/ArchitecturePane.test.tsx
@@ -53,15 +53,18 @@ describe("ArchitecturePane", () => {
it("analyses a checkout into repo, feature, and code sections and suggests improvements", async () => {
vi.mocked(api.repoArchitecture).mockResolvedValueOnce(report);
render();
- expect(host.querySelector("[data-testid='architecture-repo']")).toBeTruthy();
- expect(host.querySelector("[data-testid='architecture-feature']")).toBeTruthy();
- expect(host.querySelector("[data-testid='architecture-code']")).toBeTruthy();
+ // One empty state until the first pass, not three empty section cards.
+ expect(host.querySelector("[data-testid='architecture-empty']")).toBeTruthy();
+ expect(host.querySelector("[data-testid='architecture-repo']")).toBeNull();
expect(host.textContent).toContain("Not analysed yet");
await act(async () => {
host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
+ expect(host.querySelector("[data-testid='architecture-repo']")).toBeTruthy();
+ expect(host.querySelector("[data-testid='architecture-feature']")).toBeTruthy();
+ expect(host.querySelector("[data-testid='architecture-code']")).toBeTruthy();
expect(api.repoArchitecture).toHaveBeenCalledWith("r1");
expect(host.textContent).toContain("Pass-through loadInvoice");
@@ -91,7 +94,7 @@ describe("ArchitecturePane", () => {
await act(async () => {
const improve = host.querySelector(
- "[data-testid='architecture-repo'] button",
+ "[data-testid='architecture-improve-repo']",
) as HTMLButtonElement;
improve.click();
await Promise.resolve();
diff --git a/apps/desktop/src/components/codebase/ArchitecturePane.tsx b/apps/desktop/src/components/codebase/ArchitecturePane.tsx
index 06da992..144d4d9 100644
--- a/apps/desktop/src/components/codebase/ArchitecturePane.tsx
+++ b/apps/desktop/src/components/codebase/ArchitecturePane.tsx
@@ -8,6 +8,7 @@ import {
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
import { Select } from "@/components/layout/Select";
+import { EmptyHint } from "@/components/layout/EmptyHero";
const SHOWN = 6;
@@ -76,6 +77,51 @@ export function ArchitecturePane({ repoId, repoName }: { repoId: string; repoNam
? architectureSection(report, "code", file)
: placeholder(file || "Code", "code");
+ // Before the first pass the three sections would all say "Not analysed yet";
+ // show one empty state with the action that fills them.
+ if (!report) {
+ return (
+
+
+ void analyse()}
+ >
+ {busy === "analyse" ? "Analysing…" : "Analyse"}
+
+ void analyse("suggest")}
+ >
+ Suggest improvements
+
+ void improve("repo")}
+ >
+ {busy === "repo" ? "Starting…" : "Improve with agent"}
+
+
+ }
+ />
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+ }
+
return (
diff --git a/apps/desktop/src/components/codebase/Codebase.test.tsx b/apps/desktop/src/components/codebase/Codebase.test.tsx
index 2c00528..5de65d2 100644
--- a/apps/desktop/src/components/codebase/Codebase.test.tsx
+++ b/apps/desktop/src/components/codebase/Codebase.test.tsx
@@ -2718,4 +2718,78 @@ describe("CodebasePane", () => {
expect(host.querySelector(".pr-inbox-item")?.textContent).toContain("Cached issue");
expect(api.repoIssues).not.toHaveBeenCalled();
});
+
+ it("asks to start Docker instead of creating a repo agent that can only fail", async () => {
+ vi.mocked(api.startRepoAgent).mockClear();
+ render({
+ ...initialState,
+ section: "codebase",
+ repos: [repo],
+ selectedRepoId: "r1",
+ dockerKind: "docker",
+ dockerReady: false,
+ });
+ const start = [...host.querySelectorAll(".codebase-head button")].find(
+ (btn) => btn.textContent === "Start agent",
+ ) as HTMLButtonElement;
+ await act(async () => {
+ start.click();
+ await Promise.resolve();
+ });
+ expect(api.startRepoAgent).not.toHaveBeenCalled();
+ const callout = host.querySelector("[data-testid='docker-down-callout']");
+ expect(callout?.textContent).toContain("Docker isn’t running");
+ const labels = [...callout!.querySelectorAll("button")].map((btn) => btn.textContent);
+ expect(labels).toEqual(expect.arrayContaining(["Start on Host instead", "Check again"]));
+ });
+
+ it("leads with the change stack, not an agent review, on a merged pull request", () => {
+ render({
+ ...initialState,
+ section: "codebase",
+ repos: [repo],
+ selectedRepoId: "r1",
+ selectedPullNumber: 12,
+ activePull: { ...pull, state: "merged" },
+ });
+ const head = host.querySelector(".pr-detail-head") as HTMLElement;
+ const labels = [...head.querySelectorAll(".row-actions > button, .row-actions > a")].map(
+ (el) => el.textContent,
+ );
+ expect(labels).toContain("View change stack");
+ expect(labels).not.toContain("Fix with agent");
+ expect(labels).not.toContain("Review with agent");
+ });
+
+ it("points an empty open-PR list at All instead of an empty detail pane", async () => {
+ vi.mocked(api.repoPulls).mockResolvedValueOnce([]);
+ const actions: Action[] = [];
+ render(
+ {
+ ...initialState,
+ section: "codebase",
+ repos: [{ ...repo, openPullCount: 0 }],
+ selectedRepoId: "r1",
+ },
+ (action) => actions.push(action),
+ );
+ act(() => {
+ const tab = [...host.querySelectorAll(".codebase-tabs button")].find((btn) =>
+ btn.textContent?.startsWith("Pull requests"),
+ );
+ (tab as HTMLButtonElement).click();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ const empty = host.querySelector("[data-testid='pr-detail-empty']");
+ expect(empty?.textContent).toContain("Merged and closed pull requests are under All.");
+ expect(empty?.textContent).toContain("Show all pull requests");
+ // A zero count is not shown on the tab.
+ expect(host.querySelector(".codebase-tabs .tab-count")).toBeNull();
+ expect(
+ host.querySelector(".codebase-tabs [role='tab'][aria-selected='true']")?.textContent,
+ ).toBe("Pull requests");
+ });
});
diff --git a/apps/desktop/src/components/codebase/Codebase.tsx b/apps/desktop/src/components/codebase/Codebase.tsx
index 3a42d4d..a50676f 100644
--- a/apps/desktop/src/components/codebase/Codebase.tsx
+++ b/apps/desktop/src/components/codebase/Codebase.tsx
@@ -8,10 +8,17 @@ import {
type ReactNode,
} from "react";
import {
+ type RepoAbout,
+ DEFAULT_DOCKER_IMAGE,
+ anonymousForgeLabel,
+ forgeRateLimitResetMinutes,
+ policyAllowsHostAgents,
+ sessionRuntime,
diffStats,
fileDiffStats,
findBoundAgent,
forgeLabel,
+ forgeHasToken,
forgesWithTokens,
isDraftPull,
isIdeSession,
@@ -57,6 +64,9 @@ import { peekIssueDetail, peekIssueList } from "@/lib/issueCache";
import { formatMarkdown } from "@/lib/markdown";
import { loadLastAgent, resolveAgentDefaults, saveLastAgent } from "@/lib/lastAgent";
import { openSettings, useStore } from "@/state/store";
+import { startHostRepoAgent } from "@/lib/hostAgent";
+import { isDockerDownError } from "@/lib/automations";
+import { DockerDownCallout, dockerIsDown } from "@/components/layout/DockerDownCallout";
import { firstHunkLine, prCommentMarks, requestWorkspaceOpenPr } from "@/lib/workspaceOpenPr";
import {
buildWorkspaceTree,
@@ -245,9 +255,24 @@ export function repoWebUrl(url: string): string | undefined {
return /^https:\/\//i.test(https) ? https : undefined;
}
+type DockerBlock = { detail: string; retry: () => void; host?: () => void };
+
+function aboutHasContent(about: RepoAbout): boolean {
+ return Boolean(
+ about.description ||
+ about.license ||
+ about.language ||
+ typeof about.stars === "number" ||
+ about.readme,
+ );
+}
+
function ErrorBanner({ error }: { error: string }) {
const { dispatch } = useStore();
- const message = formatRemoteError(error);
+ // A raw docker.sock dial error is one plain sentence here; Check again lives on the callout.
+ const message = isDockerDownError(error)
+ ? "Docker isn't running. Start Docker, then try again."
+ : formatRemoteError(error);
const auth = isForgeAuthError(message);
return (
(null);
+ const [rateRetry, setRateRetry] = useState(0);
+ const [about, setAbout] = useState(null);
+ const hostAllowed = policyAllowsHostAgents(state.config?.managedPolicy);
+ useEffect(() => setDockerBlock(null), [repo?.id]);
const automations = useMemo(
() => state.automations.filter((item) => item.repoId === repo?.id),
[state.automations, repo?.id],
@@ -468,7 +498,35 @@ export function CodebasePane() {
return () => {
cancelled = true;
};
- }, [repo?.id, pullState]);
+ }, [repo?.id, pullState, rateRetry]);
+
+ // Description, license, stars and a README excerpt for the Overview. Best effort:
+ // a failure just leaves the card out.
+ useEffect(() => {
+ setAbout(null);
+ if (!repo?.id || typeof api.repoAbout !== "function") return;
+ let cancelled = false;
+ void api
+ .repoAbout(repo.id)
+ .then((next) => {
+ if (!cancelled) setAbout(next);
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [repo?.id]);
+
+ // Anonymous quota ran out: try again on our own once it resets (capped at an hour).
+ useEffect(() => {
+ const minutes = forgeRateLimitResetMinutes(pullsError ?? issuesError);
+ if (minutes == null) return;
+ const id = window.setTimeout(
+ () => setRateRetry((n) => n + 1),
+ Math.min(minutes, 60) * 60_000 + 5_000,
+ );
+ return () => window.clearTimeout(id);
+ }, [pullsError, issuesError]);
useEffect(() => {
if (!repo) {
@@ -503,7 +561,7 @@ export function CodebasePane() {
return () => {
cancelled = true;
};
- }, [repo?.id, issueState, tab]);
+ }, [repo?.id, issueState, tab, rateRetry]);
useEffect(() => {
if (prTab !== "files" || !jumpPath) return;
@@ -733,8 +791,51 @@ export function CodebasePane() {
}
}
+ /**
+ * Repo agents, setup, review and fix all boot a Docker container. When the
+ * daemon is down, say so before creating a session that can only fail.
+ */
+ function blockedOnDocker(block: DockerBlock): boolean {
+ if (!dockerIsDown(state)) return false;
+ setError(null);
+ setDockerBlock(block);
+ return true;
+ }
+
+ async function startHostAgent() {
+ if (!repo || !state.config) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const session = await startHostRepoAgent({
+ config: state.config,
+ repo,
+ title: shortRepoName(repo.checkout.url, repo.name),
+ });
+ setDockerBlock(null);
+ dispatch({ type: "session", session });
+ dispatch({ type: "select", id: session.id });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not start a Host agent");
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function startAgent(reuse = false) {
if (!repo) return;
+ const reusesNonDocker = reuse && bound && sessionRuntime(bound) !== "docker";
+ if (
+ !reusesNonDocker &&
+ blockedOnDocker({
+ detail: "Start agent runs this repo in a Docker container.",
+ retry: () => void startAgent(reuse),
+ host: hostAllowed ? () => void startHostAgent() : undefined,
+ })
+ ) {
+ return;
+ }
+ setDockerBlock(null);
setBusy(true);
setError(null);
try {
@@ -875,6 +976,15 @@ export function CodebasePane() {
async function startSetup() {
if (!repo) return;
+ if (
+ blockedOnDocker({
+ detail: "Setup environment installs and starts the app in a Docker container.",
+ retry: () => void startSetup(),
+ })
+ ) {
+ return;
+ }
+ setDockerBlock(null);
setBusy(true);
setError(null);
try {
@@ -890,6 +1000,15 @@ export function CodebasePane() {
async function startFix(number: number) {
if (!repo) return;
+ if (
+ blockedOnDocker({
+ detail: "Fix with agent checks out the pull request in a Docker container.",
+ retry: () => void startFix(number),
+ })
+ ) {
+ return;
+ }
+ setDockerBlock(null);
setBusy(true);
setError(null);
try {
@@ -910,6 +1029,15 @@ export function CodebasePane() {
async function startReview(number: number) {
if (!repo) return;
+ if (
+ blockedOnDocker({
+ detail: "Review with agent fetches the pull request into a Docker container.",
+ retry: () => void startReview(number),
+ })
+ ) {
+ return;
+ }
+ setDockerBlock(null);
setBusy(true);
setError(null);
try {
@@ -1276,11 +1404,14 @@ export function CodebasePane() {
const inbox = pulls.filter(isOpenInboxPull);
const pullsProblem = fetchProblem(pullsError);
const issuesProblem = fetchProblem(issuesError);
+ const rateResetMinutes = forgeRateLimitResetMinutes(pullsError);
const blockedNote =
pullsProblem === "needs_token"
? "Needs a token"
: pullsProblem === "rate_limited"
- ? "Rate limited"
+ ? rateResetMinutes != null
+ ? `Rate limited · resets in ${rateResetMinutes} min`
+ : "Rate limited"
: pullsProblem
? "Couldn't load"
: null;
@@ -1290,7 +1421,8 @@ export function CodebasePane() {
: pullsReady && pullState === "open"
? inbox.length
: undefined;
- const topError = error ?? pullsError ?? issuesError;
+ // The open PR shows its own action error under the PR header; don't repeat it here.
+ const topError = (pullDetailOpen ? null : error) ?? pullsError ?? issuesError;
return (
@@ -1357,10 +1489,24 @@ export function CodebasePane() {
{topError ?
: null}
+ {dockerBlock ? (
+
{
+ const retry = dockerBlock.retry;
+ setDockerBlock(null);
+ retry();
+ }}
+ onDismiss={() => setDockerBlock(null)}
+ busy={busy}
+ />
+ ) : null}
{!pullDetailOpen && ideOpen ? (
) : null}
-
+
{(
[
"overview",
@@ -1372,14 +1518,32 @@ export function CodebasePane() {
"settings",
] as const
).map((id) => (
- setTab(id)}>
+ setTab(id)}
+ >
{id === "pulls"
- ? `Pull requests${typeof repo.openPullCount === "number" ? ` ${repo.openPullCount}` : ""}`
+ ? "Pull requests"
: id === "issues"
- ? `Issues${typeof repo.openIssueCount === "number" ? ` ${repo.openIssueCount}` : ""}`
+ ? "Issues"
: id === "automations"
? "Jobs"
: id[0]!.toUpperCase() + id.slice(1)}
+ {/* Counts are open-only; a 0 next to a list of merged PRs read as wrong. */}
+ {id === "pulls" && repo.openPullCount ? (
+
+ {repo.openPullCount}
+
+ ) : null}
+ {id === "issues" && repo.openIssueCount ? (
+
+ {repo.openIssueCount}
+
+ ) : null}
))}
@@ -1424,7 +1588,67 @@ export function CodebasePane() {
Brain claims
-
+ {about && aboutHasContent(about) ? (
+
+
+ About
+ {about.homepage ? (
+
+ Website
+
+ ) : null}
+
+
+ {about.description ? (
+
{about.description}
+ ) : null}
+
+ {[
+ about.license ? `${about.license} license` : null,
+ about.language ?? null,
+ about.stars ? `★ ${about.stars.toLocaleString()}` : null,
+ about.forks
+ ? `${about.forks.toLocaleString()} fork${about.forks === 1 ? "" : "s"}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+ {about.goodFirstIssues ? (
+ <>
+ {" · "}
+ setTab("issues")}>
+ {about.goodFirstIssues} good first issue
+ {about.goodFirstIssues === 1 ? "" : "s"}
+
+ >
+ ) : null}
+
+ {about.topics?.length ? (
+
+ {about.topics.map((topic) => (
+
+ {topic}
+
+ ))}
+
+ ) : null}
+ {about.readme ? (
+
+ README
+
+
+ ) : null}
+
+
+ ) : null}
+
-
+
Review inbox
{pullsReady ? (
@@ -1675,7 +1903,12 @@ export function CodebasePane() {
{ value: "", label: "Auto" },
...(state.config?.forges ?? [])
.filter((row) => row.kind === repo.checkout.forge)
- .map((row) => ({ value: row.id, label: row.name })),
+ .map((row) => ({
+ value: row.id,
+ label: forgeHasToken(row)
+ ? row.name
+ : `${row.name} · ${anonymousForgeLabel(row.kind)}`,
+ })),
]}
onChange={setAccountId}
/>
@@ -1685,7 +1918,7 @@ export function CodebasePane() {
setDockerImage(e.target.value)}
- placeholder="locus/node"
+ placeholder={`${DEFAULT_DOCKER_IMAGE} (default)`}
/>
@@ -1713,10 +1946,19 @@ export function CodebasePane() {
void saveSettings()}>
Save
- void unlink()}>
+
+
+
+
Unlink this repo
+
+ Removes it from Codebase. Existing agents keep their checkout; nothing changes on{" "}
+ {forgeLabel(repo.checkout.forge)}.
+
+
+ void unlink()}>
Unlink
-
+
) : null}
@@ -1812,6 +2054,12 @@ function PullInbox(
) {
const openCount = props.pulls.filter(isOpenInboxPull).length;
const draftCount = props.pulls.filter(isDraftPull).length;
+ const emptyPullsTitle =
+ props.pullState === "open"
+ ? "No open pull requests"
+ : props.pullState === "draft"
+ ? "No draft pull requests"
+ : "No pull requests";
function onGutterDown(event: ReactPointerEvent) {
event.preventDefault();
@@ -1870,7 +2118,7 @@ function PullInbox(
) : props.pullsLoading && props.pulls.length === 0 ? (
) : props.pulls.length === 0 ? (
-
+
) : (
props.pulls.map((row) => {
const active = props.selectedNumber === row.number;
@@ -1879,9 +2127,10 @@ function PullInbox(
key={row.number}
type="button"
className={`pr-inbox-item ${active ? "active" : ""}`}
+ title={row.headRef && row.baseRef ? `${row.headRef} → ${row.baseRef}` : undefined}
onClick={() => props.onSelect(row.number)}
>
-
+
);
})
@@ -1920,6 +2169,26 @@ function PullInbox(
) : props.pullLoadError ? (
+ ) : props.pulls.length === 0 && !props.pullsLoading && !props.blocked ? (
+ props.setPullState("all")}
+ >
+ Show all pull requests
+
+ )
+ }
+ testId="pr-detail-empty"
+ />
) : (
)}
@@ -1970,6 +2239,13 @@ function IssueInbox(props: {
window.addEventListener("pointerup", up);
}
+ const emptyIssuesTitle =
+ props.issueState === "open"
+ ? "No open issues"
+ : props.issueState === "closed"
+ ? "No closed issues"
+ : "No issues";
+
return (
) : props.issues.length === 0 ? (
-
+
) : (
props.issues.map((row) => {
const active = props.selectedNumber === row.number;
@@ -2061,6 +2337,28 @@ function IssueInbox(props: {
) : props.issueLoadError ? (
+ ) : props.issues.length === 0 && !props.issuesLoading && !props.blocked ? (
+ props.setIssueState("all")}
+ >
+ Show all issues
+
+ )
+ }
+ testId="issue-detail-empty"
+ />
) : (
)}
@@ -2525,6 +2823,9 @@ function PullDetail(props: PullDetailProps) {
{ id: "files", label: "Files", count: pull.files.length || undefined },
];
+ // Merged or closed: an agent review or fix can't change the outcome any more.
+ const pullClosed = pull.state === "merged" || pull.state === "closed";
+
return (
@@ -2555,18 +2856,38 @@ function PullDetail(props: PullDetailProps) {
{props.focused ?
:
}
-
- Fix with agent
-
-
- Review with agent
-
+ {pullClosed ? (
+ <>
+ {pull.url ? (
+
+ Open on {forgeLabel(pull.forge)}
+
+ ) : null}
+
props.setPrTab("stack")}>
+ View change stack
+
+ >
+ ) : (
+ <>
+
+ Fix with agent
+
+
+ Review with agent
+
+ >
+ )}
}
title="Pull request actions"
toggleClassName="btn icon ghost"
align="right"
>
+ {pullClosed ? (
+
+ Review with agent
+
+ ) : null}
props.onOpenInIde()}>
{props.openingLabel ?? "Open PR in IDE"}
@@ -2602,10 +2923,13 @@ function PullDetail(props: PullDetailProps) {
{props.error ?
: null}
-
+
{tabs.map((row) => (
{
if (row.id === "stack") openStack(null);
diff --git a/apps/desktop/src/components/codebase/PullTable.tsx b/apps/desktop/src/components/codebase/PullTable.tsx
index bb9a8f3..a5ed906 100644
--- a/apps/desktop/src/components/codebase/PullTable.tsx
+++ b/apps/desktop/src/components/codebase/PullTable.tsx
@@ -104,8 +104,13 @@ export function PullTable(props: {
);
}
-export function PullSummaryCell(props: { row: PullRequestRef }) {
+export function PullSummaryCell(props: {
+ row: PullRequestRef;
+ /** The narrow PR list leaves branches to the detail header (they only fit as "cla… → m…"). */
+ branches?: boolean;
+}) {
const row = props.row;
+ const showBranches = props.branches !== false;
const badge = statusBadge(row);
const updated = formatUpdatedPhrase(row.updatedAt);
return (
@@ -122,7 +127,7 @@ export function PullSummaryCell(props: { row: PullRequestRef }) {
#{row.number}
{updated ? {updated} : null}
- {row.headRef || row.baseRef ? (
+ {showBranches && (row.headRef || row.baseRef) ? (
{row.headRef ? {row.headRef} : null}
{row.headRef && row.baseRef ? (
diff --git a/apps/desktop/src/components/codebase/RepoField.tsx b/apps/desktop/src/components/codebase/RepoField.tsx
index 8ea281a..14165b3 100644
--- a/apps/desktop/src/components/codebase/RepoField.tsx
+++ b/apps/desktop/src/components/codebase/RepoField.tsx
@@ -1,5 +1,11 @@
import { forgeLabel, type LinkedRepo } from "@locus/shared";
-import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
+import {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from "react";
import { IconClose } from "@/components/layout/Icons";
import { Select } from "@/components/layout/Select";
import { api } from "@/lib/api";
@@ -177,6 +183,15 @@ function ComposerRepoCombobox(props: {
const [active, setActive] = useState(0);
const root = useRef(null);
const search = useRef(null);
+ // Open upward when the list would run off the bottom of the window (and
+ // there is more room above), instead of covering what sits under the composer.
+ const [dropUp, setDropUp] = useState(false);
+ useLayoutEffect(() => {
+ if (!open || !root.current) return;
+ const rect = root.current.getBoundingClientRect();
+ const below = window.innerHeight - rect.bottom;
+ setDropUp(below < 300 && rect.top > below);
+ }, [open]);
const showFilter = props.repos.length > 5;
const needle = repoQuery.trim().toLowerCase();
const visibleRepos = needle
@@ -439,7 +454,7 @@ function ComposerRepoCombobox(props: {
{open ? (
{
expect(host.textContent).not.toMatch(/Try a sample landing page/i);
});
- it("attaches a linked repo when creating a design", async () => {
+ it("preselects the only linked repo as design context", async () => {
vi.mocked(api.createSession).mockClear();
vi.mocked(api.createSession).mockResolvedValue(created);
render(undefined, { repos: [linkedRepo] });
const picker = host.querySelector("[aria-label='Repository']") as HTMLButtonElement;
expect(picker.title).toMatch(/design context/i);
- expect(host.querySelector(".composer-repo-placeholder")?.textContent).toBe("No repo context");
- await pickComposerRepoBranch(host, "acme/app", "main");
+ // The only linked repo is preselected as read-only context.
+ expect(host.querySelector(".composer-repo-placeholder")).toBeNull();
expect(host.querySelector(".composer-repo-chip-name")?.textContent).toBe("app");
expect(host.querySelector(".composer-repo-chip-ref")?.textContent).toBe("main");
const create = [...host.querySelectorAll("button")].find(
diff --git a/apps/desktop/src/components/design/DesignEmpty.tsx b/apps/desktop/src/components/design/DesignEmpty.tsx
index e80fe08..3cc01d2 100644
--- a/apps/desktop/src/components/design/DesignEmpty.tsx
+++ b/apps/desktop/src/components/design/DesignEmpty.tsx
@@ -405,7 +405,18 @@ export function DesignEmpty({ surface = "design" }: { surface?: CanvasLaunchSurf
selectWorkspace({ kind: "environment", env });
}
+ // Design with exactly one linked repo: use it as read-only context by default.
+ const repoPickTouched = useRef(false);
+ const onlyRepoId = surface === "design" && state.repos.length === 1 ? state.repos[0]!.id : "";
+ useEffect(() => {
+ if (!onlyRepoId || repoPickTouched.current) return;
+ repoPickTouched.current = true;
+ setSelectedRepos([onlyRepoId]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onlyRepoId]);
+
function setSelectedRepos(ids: string[], refs?: Record
) {
+ repoPickTouched.current = true;
const next = [...new Set(ids.filter(Boolean))];
const hadRepos = repoIds.length > 0;
setRepoIds(next);
diff --git a/apps/desktop/src/components/docker/Docker.tsx b/apps/desktop/src/components/docker/Docker.tsx
index e65e58c..7dada2e 100644
--- a/apps/desktop/src/components/docker/Docker.tsx
+++ b/apps/desktop/src/components/docker/Docker.tsx
@@ -19,6 +19,7 @@ import { useStore } from "@/state/store";
import { DockerStatsPanel, useDockerStats } from "./DockerStatsPanel";
import { IconArrowUpRight, IconClose, IconCopy, IconStop } from "@/components/layout/Icons";
import { EmptyHero } from "@/components/layout/EmptyHero";
+import { dockerIsDown, recheckDocker } from "@/components/layout/DockerDownCallout";
import { ErrorBanner } from "@/components/layout/Skeleton";
import { SearchField } from "@/components/layout/SearchField";
import { WorkspaceEditor } from "@/components/workspace/WorkspaceEditor";
@@ -52,6 +53,7 @@ function DockerContainersPane() {
const livePicked = liveDockerContainers(picked);
const [tab, setTab] = useState("logs");
const [logs, setLogs] = useState("");
+ const [checkingDocker, setCheckingDocker] = useState(false);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [grep, setGrep] = useState("");
@@ -208,6 +210,47 @@ function DockerContainersPane() {
return () => window.removeEventListener("keydown", onKey);
}, [tab, visibleLogText, logs, item?.name]);
+ if (!item && dockerIsDown(state)) {
+ return (
+
+
+
+
+ {
+ setCheckingDocker(true);
+ void recheckDocker(dispatch)
+ .then((ready) => (ready ? refreshList() : undefined))
+ .catch((err: unknown) =>
+ setError(err instanceof Error ? err.message : "Check failed"),
+ )
+ .finally(() => setCheckingDocker(false));
+ }}
+ >
+ {checkingDocker ? "Checking…" : "Check again"}
+
+ {
+ dispatch({ type: "section", section: "agents" });
+ dispatch({ type: "select", id: null });
+ }}
+ >
+ New Host agent
+
+
+
+
+ );
+ }
+
if (!item) {
return (
@@ -215,7 +258,6 @@ function DockerContainersPane() {
-
+ {/* Wide windows already show Add Grafana as the sidebar's primary button. */}
+
({
+ api: {
+ createSession: vi.fn(),
+ startVm: vi.fn(),
+ health: vi.fn(),
+ },
+}));
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const repo = {
+ id: "r1",
+ name: "acme/app",
+ checkout: { forge: "github", url: "https://github.com/acme/app.git", ref: "main" },
+} as LinkedRepo;
+
+const session = {
+ id: "s1",
+ title: "app",
+ os: "ubuntu",
+ templateId: "ubuntu",
+ runtime: "docker",
+ providerId: "p1",
+ model: "m1",
+ repoId: "r1",
+ status: "error",
+ error:
+ "ERROR: failed to connect to the docker API at unix:///var/run/docker.sock; dial unix /var/run/docker.sock: connect: no such file or directory",
+ messages: [{ id: "u1", role: "user", content: "Fix the flaky test", createdAt: "" }],
+ createdAt: "",
+ updatedAt: "",
+} as unknown as AgentSession;
+
+const config = {
+ providers: [],
+ templates: [{ id: "ubuntu", os: "ubuntu" }],
+} as unknown as LocusConfig;
+
+describe("DockerDownSessionNotice", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ function render(target: AgentSession, dispatch: (action: Action) => void = () => undefined) {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ }
+
+ it("offers a Host copy with the same repo, prompt and model", async () => {
+ const copy = { ...session, id: "s2", runtime: "host" } as AgentSession;
+ vi.mocked(api.createSession).mockResolvedValue(copy);
+ const actions: Action[] = [];
+ render(session, (action) => actions.push(action));
+ expect(host.textContent).toContain("runtime can't change");
+ const button = [...host.querySelectorAll("button")].find(
+ (btn) => btn.textContent === "Start a Host copy",
+ ) as HTMLButtonElement;
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(api.createSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runtime: "host",
+ repoId: "r1",
+ prompt: "Fix the flaky test",
+ providerId: "p1",
+ model: "m1",
+ }),
+ );
+ expect(actions).toContainEqual({ type: "select", id: "s2" });
+ });
+
+ it("stays out of the way for other errors and non-Docker agents", () => {
+ render({ ...session, error: "HTTP 500" } as AgentSession);
+ expect(host.querySelector("[data-testid='docker-down-session']")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/layout/DockerDownCallout.tsx b/apps/desktop/src/components/layout/DockerDownCallout.tsx
new file mode 100644
index 0000000..dba21cb
--- /dev/null
+++ b/apps/desktop/src/components/layout/DockerDownCallout.tsx
@@ -0,0 +1,161 @@
+import { useState } from "react";
+import { policyAllowsHostAgents, sessionRuntime, type AgentSession } from "@locus/shared";
+import { api } from "@/lib/api";
+import { isDockerDownError } from "@/lib/automations";
+import { startHostRepoAgent } from "@/lib/hostAgent";
+import { useStore, type Action } from "@/state/store";
+
+/** True when the Docker CLI is installed but the daemon is not answering. */
+export function dockerIsDown(state: { dockerKind: "mock" | "docker"; dockerReady: boolean }) {
+ return state.dockerKind === "docker" && !state.dockerReady;
+}
+
+/** Ask the control plane again and update the store. Resolves to the new readiness. */
+export async function recheckDocker(dispatch: (action: Action) => void): Promise {
+ const health = await api.health();
+ const dockerKind = health.docker === "docker" ? "docker" : "mock";
+ const dockerReady = dockerKind === "docker" && health.dockerReady !== false;
+ dispatch({ type: "dockerHealth", dockerKind, dockerReady });
+ return dockerKind === "mock" || dockerReady;
+}
+
+/**
+ * The one "Docker isn't running" callout: what needed Docker, how to start it,
+ * Check again, and (when the caller can offer one) a Host alternative.
+ */
+export function DockerDownCallout(props: {
+ /** One sentence on what needed Docker, e.g. "Start agent runs in a Docker container." */
+ detail: string;
+ hostLabel?: string;
+ onHost?: () => void;
+ /** Called after Check again finds Docker running. */
+ onReady?: () => void;
+ onDismiss?: () => void;
+ busy?: boolean;
+ testId?: string;
+}) {
+ const { dispatch } = useStore();
+ const [checking, setChecking] = useState(false);
+ const [stillDown, setStillDown] = useState(false);
+ const busy = props.busy || checking;
+
+ async function checkAgain() {
+ setChecking(true);
+ try {
+ const ready = await recheckDocker(dispatch);
+ setStillDown(!ready);
+ if (ready) props.onReady?.();
+ } catch {
+ setStillDown(true);
+ } finally {
+ setChecking(false);
+ }
+ }
+
+ return (
+
+
Docker isn’t running
+
+ {props.detail} Open Docker Desktop, or run sudo systemctl start docker on
+ Linux, then check again.
+
+ {stillDown ?
Still not reachable.
: null}
+
+ {props.onHost ? (
+
+ {props.hostLabel ?? "Use Host instead"}
+
+ ) : null}
+ void checkAgain()}
+ >
+ {checking ? "Checking…" : "Check again"}
+
+ {props.onDismiss ? (
+
+ Dismiss
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * Shown in a Docker agent's thread when its container could not start because
+ * the daemon is down. The runtime is fixed per agent, so instead of "switch to
+ * Host" this offers a Host copy (same repo, first prompt and model) or Start.
+ */
+export function DockerDownSessionNotice(props: { session: AgentSession }) {
+ const { state, dispatch } = useStore();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const { session } = props;
+ if (sessionRuntime(session) !== "docker" || !isDockerDownError(session.error)) return null;
+ const hostAllowed = policyAllowsHostAgents(state.config?.managedPolicy);
+ const repo = session.repoId ? state.repos.find((row) => row.id === session.repoId) : undefined;
+ const firstPrompt = session.messages.find((row) => row.role === "user")?.content;
+
+ async function hostCopy() {
+ if (!state.config) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const next = await startHostRepoAgent({
+ config: state.config,
+ repo,
+ title: `${session.title} (Host)`,
+ prompt: firstPrompt,
+ providerId: session.providerId,
+ model: session.model,
+ });
+ dispatch({ type: "session", session: next });
+ dispatch({ type: "select", id: next.id });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not start a Host copy");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function start() {
+ setBusy(true);
+ setError(null);
+ try {
+ await api.startVm(session.id);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not start");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
void hostCopy() : undefined}
+ onReady={() => void start()}
+ busy={busy}
+ />
+ {error ? {error}
: null}
+
+ );
+}
diff --git a/apps/desktop/src/components/layout/EmptyState.test.tsx b/apps/desktop/src/components/layout/EmptyState.test.tsx
index 0e831e7..7b3f3f5 100644
--- a/apps/desktop/src/components/layout/EmptyState.test.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.test.tsx
@@ -421,6 +421,35 @@ describe("EmptyState recipes", () => {
);
});
+ it("suggests a real first task on a linked repo and selects that repo", async () => {
+ vi.mocked(api.createSession).mockClear();
+ vi.mocked(api.createSession).mockResolvedValue(created);
+ render(undefined, {
+ config: { ...config, setupFirstTask: "pending" },
+ repos: [linkedRepo],
+ });
+ const card = host.querySelector("[data-testid='first-task-card']");
+ expect(card?.textContent).toContain("Try a first task on acme/app");
+ // Finish setup waits until the first task is done or skipped.
+ expect(host.textContent).not.toContain("Finish setup");
+ await act(async () => {
+ (host.querySelector("[data-testid='first-task-0']") as HTMLButtonElement).click();
+ await Promise.resolve();
+ });
+ expect((host.querySelector("textarea") as HTMLTextAreaElement).value).toMatch(
+ /Explain how the acme\/app repository is put together/,
+ );
+ const create = [...host.querySelectorAll("button")].find(
+ (btn) => btn.getAttribute("aria-label") === "Start chat",
+ );
+ await act(async () => {
+ create!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(api.createSession).toHaveBeenCalledWith(expect.objectContaining({ repoId: "r1" }));
+ });
+
it("hides the first-task pill after a couple of agent sessions", () => {
render(undefined, { sessions: [created, { ...created, id: "s2" }] });
expect(host.querySelector("[aria-label='Try a first task']")).toBeNull();
@@ -875,7 +904,29 @@ describe("EmptyState recipes", () => {
expect(host.querySelector(".composer-attach")).toBeNull();
});
- it("defaults the composer to No repo and can attach a linked repo", async () => {
+ it("preselects the only linked repo", async () => {
+ vi.mocked(api.createSession).mockClear();
+ vi.mocked(api.createSession).mockResolvedValue(created);
+ render(() => undefined, { repos: [linkedRepo] });
+ expect(host.querySelector(".composer-repo-placeholder")).toBeNull();
+ expect(host.querySelector(".composer-repo-chip-name")).not.toBeNull();
+ const create = [...host.querySelectorAll("button")].find(
+ (btn) => btn.getAttribute("aria-label") === "Start chat",
+ );
+ await act(async () => {
+ create!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(api.createSession).toHaveBeenCalledWith(expect.objectContaining({ repoId: "r1" }));
+ });
+
+ it("hides the Connect GitHub banner once a repo is linked", () => {
+ render(() => undefined, { repos: [linkedRepo] });
+ expect(host.textContent).not.toContain("Connect GitHub");
+ });
+
+ it("defaults the composer to No repo with several linked repos and can attach one", async () => {
vi.mocked(api.createSession).mockClear();
vi.mocked(api.createSession).mockResolvedValue(created);
const actions: Action[] = [];
@@ -883,7 +934,17 @@ describe("EmptyState recipes", () => {
(action) => {
actions.push(action);
},
- { repos: [linkedRepo] },
+ {
+ repos: [
+ linkedRepo,
+ {
+ ...linkedRepo,
+ id: "r2",
+ name: "acme/web",
+ checkout: { ...linkedRepo.checkout, url: "https://github.com/acme/web.git" },
+ },
+ ],
+ },
);
const picker = host.querySelector("[aria-label='Repository']") as HTMLButtonElement;
expect(picker).not.toBeNull();
diff --git a/apps/desktop/src/components/layout/EmptyState.tsx b/apps/desktop/src/components/layout/EmptyState.tsx
index eefc6f8..055456e 100644
--- a/apps/desktop/src/components/layout/EmptyState.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.tsx
@@ -62,6 +62,11 @@ import { ModelPicker } from "@/components/settings/ModelPicker";
import { OsPicker } from "@/components/settings/OsPicker";
import { ComposerRepoPicker } from "@/components/codebase/RepoField";
import { FirstTaskPills, RecipeCards } from "@/components/settings/RecipeCards";
+import {
+ recentHostFolders,
+ suggestFirstTasks,
+ type SuggestedFirstTask,
+} from "@/lib/firstTaskSuggest";
import {
FinishSetupChecklist,
loadFinishSetupDismissed,
@@ -182,6 +187,18 @@ export function EmptyState() {
const hasProvider =
setup?.hasProvider ?? Boolean(state.config?.providers.some((row) => row.enabled));
const showSample = showFirstTaskPills({ sessions: state.sessions, hasProvider });
+ const firstTasks = useMemo(
+ () =>
+ suggestFirstTasks({
+ repos: state.repos,
+ hostFolders: hostAgentsLocked
+ ? []
+ : recentHostFolders(state.sessions, loadLastAgent().hostDir),
+ }),
+ [state.repos, state.sessions, hostAgentsLocked],
+ );
+ const firstTaskTarget = firstTasks[0]?.target;
+ const firstTaskPending = state.config?.setupFirstTask === "pending" && hasProvider;
const primaryRepo = state.repos.find((row) => row.id === repoIds[0]);
useEffect(() => {
@@ -218,8 +235,15 @@ export function EmptyState() {
setVision(undefined);
}
- function applyFirstTask(task: FirstTask) {
+ function applyFirstTask(task: FirstTask | SuggestedFirstTask) {
clearRecipeExtras();
+ const target = "target" in task ? task.target : undefined;
+ if (target?.kind === "repo") {
+ setSelectedRepos([target.repoId]);
+ } else if (target?.kind === "host") {
+ if (effectiveRuntime !== "host") changeRuntime("host");
+ selectWorkspace({ kind: "host", path: target.path });
+ }
setPrompt(task.prompt);
setError(null);
if (state.config?.setupFirstTask === "pending") {
@@ -306,7 +330,19 @@ export function EmptyState() {
selectWorkspace({ kind: "environment", env });
}
+ // One linked repo: preselect it once, so "No repo" isn't the default next to
+ // first-task chips that already name the repo. Clearing it sticks.
+ const repoPickTouched = useRef(false);
+ const onlyRepoId = state.repos.length === 1 ? state.repos[0]!.id : "";
+ useEffect(() => {
+ if (!onlyRepoId || repoPickTouched.current) return;
+ repoPickTouched.current = true;
+ setSelectedRepos([onlyRepoId]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onlyRepoId]);
+
function setSelectedRepos(ids: string[], refs?: Record) {
+ repoPickTouched.current = true;
const next = [...new Set(ids.filter(Boolean))];
const hadRepos = repoIds.length > 0;
setRepoIds(next);
@@ -441,8 +477,15 @@ export function EmptyState() {
!setup.linuxImageReady &&
!checklistOpen,
);
+ // A linked public repo already clones without a token, so the nudge only
+ // shows before the first repo is linked.
const showGitCallout = Boolean(
- state.config && !hasForge && !checklistOpen && !showEngineCallout && !setupHintDismissed,
+ state.config &&
+ !hasForge &&
+ state.repos.length === 0 &&
+ !checklistOpen &&
+ !showEngineCallout &&
+ !setupHintDismissed,
);
function useHostInstead() {
@@ -541,18 +584,46 @@ export function EmptyState() {
) : null}
- {state.config?.setupFirstTask === "pending" && hasProvider ? (
+ {firstTaskPending ? (
-
{t("empty.firstTask.title")}
-
{t("empty.firstTask.body")}
+ {firstTaskTarget && firstTaskTarget.kind !== "private" ? (
+ <>
+
Try a first task on {firstTaskTarget.name}
+
+ {firstTaskTarget.kind === "repo"
+ ? "Real work on a repo you linked. Nothing is pushed."
+ : `Real work in ${firstTaskTarget.path} on this machine. Nothing is pushed.`}
+
+ >
+ ) : (
+ <>
+
{t("empty.firstTask.title")}
+
{t("empty.firstTask.body")}
+ >
+ )}
- applyFirstTask(FIRST_TASKS[0]!)}
- >
- {t("empty.firstTask.cta")}
-
+ {firstTaskTarget && firstTaskTarget.kind !== "private" ? (
+ firstTasks.map((task, i) => (
+ applyFirstTask(task)}
+ >
+ {task.title}
+
+ ))
+ ) : (
+ applyFirstTask(firstTasks[0] ?? FIRST_TASKS[0]!)}
+ >
+ {t("empty.firstTask.cta")}
+
+ )}
) : null}
- {setup ? (
+ {setup && !firstTaskPending ? (
) : !hubMode && showSample ? (
task.prompt === prompt)?.id ?? null}
+ tasks={firstTasks}
+ selectedId={firstTasks.find((task) => task.prompt === prompt)?.id ?? null}
onSelect={applyFirstTask}
/>
) : null}
diff --git a/apps/desktop/src/components/layout/InboxPane.test.tsx b/apps/desktop/src/components/layout/InboxPane.test.tsx
index 22341f7..c563112 100644
--- a/apps/desktop/src/components/layout/InboxPane.test.tsx
+++ b/apps/desktop/src/components/layout/InboxPane.test.tsx
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentSession } from "@locus/shared";
import { Store, initialState, type UiState } from "@/state/store";
+import { api } from "@/lib/api";
import { InboxPane } from "./InboxPane";
const waiting = {
@@ -61,4 +62,63 @@ describe("InboxPane", () => {
expect(dispatch).toHaveBeenCalledWith({ type: "select", id: "s1" });
expect(dispatch).toHaveBeenCalledWith({ type: "inbox", open: false });
});
+
+ const approval = {
+ ...waiting,
+ id: "s2",
+ title: "Run checks",
+ needsHuman: undefined,
+ runtime: "docker",
+ messages: [
+ {
+ id: "m1",
+ role: "assistant",
+ content: "Running the suite to confirm.",
+ createdAt: "2020-01-01T00:00:00.000Z",
+ toolCalls: [
+ {
+ id: "c1",
+ name: "exec",
+ arguments: { command: "npm test" },
+ startedAt: "2020-01-01T00:00:00.000Z",
+ approval: "needed",
+ },
+ ],
+ },
+ ],
+ } as unknown as AgentSession;
+
+ it("shows the command and reason for an approval and decides it in place", async () => {
+ const spy = vi.spyOn(api, "resolveApproval").mockResolvedValue(approval);
+ const dispatch = render({ ...initialState, inboxOpen: true, sessions: [approval] });
+ expect(host.querySelector("[data-testid='inbox-command']")?.textContent).toBe("npm test");
+ expect(host.querySelector("[data-testid='inbox-why']")?.textContent).toBe(
+ "Running the suite to confirm.",
+ );
+ expect(host.textContent).toContain("Docker container");
+ await act(async () => {
+ (host.querySelector("[data-testid='inbox-approve']") as HTMLButtonElement).click();
+ });
+ expect(spy).toHaveBeenCalledWith("s2", "c1", "approved", undefined, undefined);
+ expect(dispatch).toHaveBeenCalledWith({ type: "session", session: approval });
+ spy.mockRestore();
+ });
+
+ it("moves with J/K and denies with D", async () => {
+ const spy = vi.spyOn(api, "resolveApproval").mockResolvedValue(approval);
+ render({ ...initialState, inboxOpen: true, sessions: [waiting, approval] });
+ expect(host.querySelector("[data-testid='inbox-stepper']")?.textContent).toContain("1 of 2");
+ expect(host.querySelector("[data-testid='inbox-next']")?.textContent).toContain(
+ "Review checkout",
+ );
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "j" }));
+ });
+ expect(host.querySelector("[data-testid='inbox-next']")?.textContent).toContain("Run checks");
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "d" }));
+ });
+ expect(spy).toHaveBeenCalledWith("s2", "c1", "denied", undefined, undefined);
+ spy.mockRestore();
+ });
});
diff --git a/apps/desktop/src/components/layout/InboxPane.tsx b/apps/desktop/src/components/layout/InboxPane.tsx
index 8960675..a56ed06 100644
--- a/apps/desktop/src/components/layout/InboxPane.tsx
+++ b/apps/desktop/src/components/layout/InboxPane.tsx
@@ -1,42 +1,293 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseProductMode, sessionMatchesSurface } from "@locus/shared";
-import { buildInboxRows, inboxKindLabel, openInboxRow } from "@/lib/inboxList";
+import { api } from "@/lib/api";
+import {
+ approvalPreview,
+ sameToolRows,
+ stepInboxFocus,
+ type ApprovalPreview,
+} from "@/lib/inboxDecision";
+import { buildInboxRows, inboxKindLabel, openInboxRow, type InboxRow } from "@/lib/inboxList";
+import { showErrorNotice } from "@/lib/notice";
import { useStore } from "@/state/store";
import { EmptyHero } from "./EmptyHero";
-/** Main pane while the rail Inbox is open: the next item to handle, or all caught up. */
+type Decision = "approved" | "denied";
+
+function isTypingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
+}
+
+const RISK_LABEL = { low: "Low risk", medium: "Check first", high: "High risk" } as const;
+
+function ApprovalDetail({ preview }: { preview: ApprovalPreview }) {
+ return (
+
+
+
+ {RISK_LABEL[preview.risk]}
+
+ {preview.label}
+
+ {preview.why ? (
+
+ {preview.why}
+
+ ) : null}
+ {preview.command ? (
+
+ {preview.command}
+
+ ) : null}
+ {preview.files.length ? (
+
+ {preview.files.slice(0, 6).map((file) => (
+ {file}
+ ))}
+ {preview.files.length > 6 ? +{preview.files.length - 6} more : null}
+
+ ) : null}
+
+ Tool
+
+ {preview.call.name}
+
+ Runs in
+ {preview.where}
+ {preview.path && !preview.files.length ? (
+ <>
+ Path
+
+ {preview.path}
+
+ >
+ ) : null}
+ {preview.branch ? (
+ <>
+ Branch
+
+ {preview.branch}
+
+ >
+ ) : null}
+ Why ask
+ {preview.riskReason}
+
+ {preview.argsText && !preview.command ? (
+
+ Arguments
+ {preview.argsText}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Main pane while the rail Inbox is open: a decision card for the focused item.
+ * Approvals show the command, files, and the agent's reason, and can be decided
+ * here. J/K (or ↑/↓) move, A approves, D denies, Enter opens the chat.
+ */
export function InboxPane() {
const { state, dispatch } = useStore();
const mode = parseProductMode(state.config?.productMode);
- const rows = buildInboxRows({
- sessions: state.sessions.filter((s) => sessionMatchesSurface(s, mode)),
- boards: state.boards,
- brain: state.brain,
- automations: state.automations,
- runs: state.runs,
- });
- const next = rows[0];
+ const sessions = useMemo(
+ () => state.sessions.filter((s) => sessionMatchesSurface(s, mode)),
+ [state.sessions, mode],
+ );
+ const rows = useMemo(
+ () =>
+ buildInboxRows({
+ sessions,
+ boards: state.boards,
+ brain: state.brain,
+ automations: state.automations,
+ runs: state.runs,
+ }),
+ [sessions, state.boards, state.brain, state.automations, state.runs],
+ );
+ const [focusKey, setFocusKey] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const busyRef = useRef(false);
+
+ const index = Math.max(
+ 0,
+ rows.findIndex((row) => row.key === focusKey),
+ );
+ const current: InboxRow | undefined = rows[index];
+ const session = sessions.find((row) => row.id === current?.sessionId);
+ const preview = current?.callId ? approvalPreview(session, current.callId) : undefined;
+ const sameTool = useMemo(
+ () => (current && preview?.batchable ? sameToolRows(rows, sessions, current) : []),
+ [current, preview?.batchable, rows, sessions],
+ );
+
+ // Keep focus on the same position when the decided row drops out of the list.
+ useEffect(() => {
+ if (!rows.length) {
+ if (focusKey !== null) setFocusKey(null);
+ return;
+ }
+ if (!focusKey || !rows.some((row) => row.key === focusKey)) {
+ setFocusKey(rows[Math.min(index, rows.length - 1)]!.key);
+ }
+ }, [rows, focusKey, index]);
+
+ const decide = useCallback(
+ async (targets: InboxRow[], decision: Decision, always = false) => {
+ if (busyRef.current || !targets.length) return;
+ busyRef.current = true;
+ setBusy(true);
+ try {
+ for (const row of targets) {
+ if (!row.sessionId || !row.callId) continue;
+ const updated = await api.resolveApproval(
+ row.sessionId,
+ row.callId,
+ decision,
+ undefined,
+ always || undefined,
+ );
+ dispatch({ type: "session", session: updated });
+ }
+ } catch (err) {
+ showErrorNotice(err, decision === "approved" ? "Could not approve" : "Could not deny");
+ } finally {
+ busyRef.current = false;
+ setBusy(false);
+ }
+ },
+ [dispatch],
+ );
+
+ useEffect(() => {
+ function onKey(event: KeyboardEvent) {
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
+ if (isTypingTarget(event.target)) return;
+ if (document.querySelector("[role='dialog']")) return;
+ const key = event.key;
+ if (key === "j" || key === "ArrowDown") {
+ event.preventDefault();
+ setFocusKey((k) => stepInboxFocus(rows, k, 1));
+ } else if (key === "k" || key === "ArrowUp") {
+ event.preventDefault();
+ setFocusKey((k) => stepInboxFocus(rows, k, -1));
+ } else if (key === "Enter" && current) {
+ event.preventDefault();
+ openInboxRow(current, dispatch);
+ } else if ((key === "a" || key === "d") && current && preview?.batchable) {
+ event.preventDefault();
+ void decide([current], key === "a" ? "approved" : "denied");
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [rows, current, preview?.batchable, decide, dispatch]);
return (
-
- {next ? (
+
+ {current ? (
<>
-
-
{inboxKindLabel(next.kind, mode)}
-
{next.title}
-
{next.detail}
-
openInboxRow(next, dispatch)}
- >
- Open
-
+ {rows.length > 1 ? (
+
+ setFocusKey(stepInboxFocus(rows, current.key, -1))}
+ >
+ ↑ Prev
+
+
+ {index + 1} of {rows.length}
+
+ = rows.length - 1}
+ aria-label="Next item"
+ onClick={() => setFocusKey(stepInboxFocus(rows, current.key, 1))}
+ >
+ Next ↓
+
+
+ ) : null}
+
+
{inboxKindLabel(current.kind, mode)}
+
{current.title}
+ {preview ? (
+
+ ) : (
+
{current.detail}
+ )}
+
+ {preview?.batchable ? (
+ <>
+ void decide([current], "approved")}
+ >
+ Approve A
+
+ void decide([current], "denied")}
+ >
+ Deny D
+
+ void decide([current], "approved", true)}
+ >
+ Always allow
+
+ >
+ ) : null}
+ openInboxRow(current, dispatch)}
+ >
+ {preview?.batchable ? "Open chat" : "Open"}
+
+
+ {sameTool.length > 1 ? (
+
void decide(sameTool, "approved")}
+ >
+ Approve all {sameTool.length} waiting {preview?.call.name} calls
+
+ ) : null}
+
+ J /K move · A approve · D deny ·{" "}
+ Enter open
+
>
) : (
{
"Providers",
"MCP",
"Skills",
+ "Benchmark",
"Machines",
"Workers",
"Environments",
@@ -2047,7 +2048,7 @@ describe("Sidebar", () => {
"GH",
);
expect(host.querySelector(".repo-item.active .title-text")?.textContent).toBe("app");
- expect(host.querySelector(".repo-item.active .meta")?.textContent).toBe("acme/app · main");
+ expect(host.querySelector(".repo-item.active .meta")?.textContent).toBe("acme · main");
expect(host.querySelector(".repo-item.active .pr-count")?.textContent).toBe("3");
expect(host.querySelector(".repo-item.active .repo-flag")?.textContent).toBe("auto");
// The header shows the total; the footer only counts search matches.
@@ -2075,9 +2076,7 @@ describe("Sidebar", () => {
};
render({ ...initialState, section: "codebase", selectedRepoId: "r3", repos: [repo] });
expect(host.querySelector(".repo-item .title-text")?.textContent).toBe("Locus");
- expect(host.querySelector(".repo-item .meta")?.textContent).toBe(
- "Modsofthenation/Locus · main",
- );
+ expect(host.querySelector(".repo-item .meta")?.textContent).toBe("Modsofthenation · main");
});
it("shows an empty catalog hint", () => {
diff --git a/apps/desktop/src/components/layout/Sidebar.tsx b/apps/desktop/src/components/layout/Sidebar.tsx
index 7925bc3..831af99 100644
--- a/apps/desktop/src/components/layout/Sidebar.tsx
+++ b/apps/desktop/src/components/layout/Sidebar.tsx
@@ -1798,17 +1798,17 @@ export function Sidebar() {
) : null}
-
- {row.name}
- {row.checkout.ref ? ` · ${row.checkout.ref}` : ""}
-
-
-
- {typeof row.openPullCount === "number" ? row.openPullCount : "—"}
+ {repoRowMeta(row)}
+ {row.openPullCount ? (
+
+ {row.openPullCount}
+
+ ) : null}
)
- ) : state.section === "slack" ? (
+ ) : state.section === "slack" &&
+ // Signed out there is nothing to count; the list already says "Sign in".
+ (slackBooting ||
+ state.slackStatus?.connected ||
+ state.slackConversations.length > 0) ? (
{slackBooting
? "Loading conversations"
@@ -2544,3 +2548,20 @@ export function Sidebar() {
);
}
+
+/**
+ * Second line of a Codebase sidebar row. The title already shows the repo
+ * name, so show the owner and ref instead of repeating `owner/name` (which
+ * got cut off as "Modsofthenation/Local-a…").
+ */
+export function repoRowMeta(row: { name: string; checkout: { url: string; ref?: string } }) {
+ const short = shortRepoName(row.checkout.url, row.name);
+ const slash = row.name.lastIndexOf("/");
+ const owner =
+ slash > 0 && row.name.slice(slash + 1) === short
+ ? row.name.slice(0, slash)
+ : row.name === short
+ ? ""
+ : row.name;
+ return [owner, row.checkout.ref].filter(Boolean).join(" · ");
+}
diff --git a/apps/desktop/src/components/layout/Topbar.tsx b/apps/desktop/src/components/layout/Topbar.tsx
index c9c3fab..7b57f73 100644
--- a/apps/desktop/src/components/layout/Topbar.tsx
+++ b/apps/desktop/src/components/layout/Topbar.tsx
@@ -49,6 +49,8 @@ import { CompareModal } from "@/components/chat/CompareModal";
import { WorkersModal } from "@/components/settings/WorkersModal";
import { TraceModal } from "@/components/chat/TraceModal";
import { ExpandableLog } from "@/components/chat/ExpandableLog";
+import { dockerIsDown } from "./DockerDownCallout";
+import { isDockerDownError } from "@/lib/automations";
import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
import { composerModelLabel } from "@/components/settings/ModelPicker";
@@ -209,7 +211,10 @@ export function Topbar() {
const kicker = repo
? [
forgeLabel(repo.checkout.forge),
- typeof repo.openPullCount === "number" ? `${repo.openPullCount} open` : null,
+ typeof repo.openPullCount === "number"
+ ? `${repo.openPullCount} open PR${repo.openPullCount === 1 ? "" : "s"}`
+ : null,
+ repo.checkout.ref || null,
]
.filter(Boolean)
.join(" · ")
@@ -650,7 +655,9 @@ export function Topbar() {
- {session.error &&
}
+ {session.error && !isDockerDownError(session.error) ? (
+
+ ) : null}
{vm && (vm.status === "running" || vm.status === "ready" || vm.status === "paused") ? (
@@ -905,7 +912,9 @@ export function Topbar() {
- {session.error && }
+ {session.error && !isDockerDownError(session.error) ? (
+
+ ) : null}
{hubMode ? (
void api
.startVm(session.id)
diff --git a/apps/desktop/src/components/projects/ProjectEmpty.test.tsx b/apps/desktop/src/components/projects/ProjectEmpty.test.tsx
index afe9bc4..229c9c8 100644
--- a/apps/desktop/src/components/projects/ProjectEmpty.test.tsx
+++ b/apps/desktop/src/components/projects/ProjectEmpty.test.tsx
@@ -100,12 +100,12 @@ describe("ProjectEmpty", () => {
expect(labels).toEqual(["Feature", "Migration", "Gardening", "Blank"]);
});
- it("uses the same primary New project chrome as New board", () => {
+ it("submits with the same Send-style chrome as Board", () => {
render();
const send = host.querySelector(".composer-send") as HTMLButtonElement;
expect(send.className).toContain("btn");
expect(send.className).toContain("primary");
- expect(send.getAttribute("aria-label")).toBe("New project");
- expect(send.textContent).toMatch(/New project/);
+ expect(send.getAttribute("aria-label")).toBe("Create project");
+ expect(send.textContent).toMatch(/Create/);
});
});
diff --git a/apps/desktop/src/components/projects/ProjectEmpty.tsx b/apps/desktop/src/components/projects/ProjectEmpty.tsx
index d878aaa..4558c55 100644
--- a/apps/desktop/src/components/projects/ProjectEmpty.tsx
+++ b/apps/desktop/src/components/projects/ProjectEmpty.tsx
@@ -15,7 +15,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { useStore } from "@/state/store";
import { EmptyHero } from "@/components/layout/EmptyHero";
-import { IconPlus } from "@/components/layout/Icons";
+import { IconPlus, IconReturn } from "@/components/layout/Icons";
import { PersonaAvatar } from "@/components/layout/PersonaAvatar";
export function ProjectEmpty() {
@@ -175,7 +175,7 @@ export function ProjectEmpty() {
className="btn primary small composer-send"
type="submit"
disabled={busy}
- title={groupChat ? "Start group chat" : hubMode ? "New team" : "New project"}
+ title={groupChat ? "Start group chat" : hubMode ? "New team" : "Create project"}
aria-label={
busy
? groupChat
@@ -187,11 +187,11 @@ export function ProjectEmpty() {
? "Start group chat"
: hubMode
? "New team"
- : "New project"
+ : "Create project"
}
>
-
-
+ {groupChat || hubMode ? : null}
+
{busy
? groupChat
? "Creating…"
@@ -202,8 +202,9 @@ export function ProjectEmpty() {
? "Start group chat"
: hubMode
? "New team"
- : "New project"}
+ : "Create"}
+ {groupChat || hubMode ? null : }
diff --git a/apps/desktop/src/components/settings/BackupSettings.tsx b/apps/desktop/src/components/settings/BackupSettings.tsx
new file mode 100644
index 0000000..6355a48
--- /dev/null
+++ b/apps/desktop/src/components/settings/BackupSettings.tsx
@@ -0,0 +1,239 @@
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { APP_VERSION } from "@/lib/appInfo";
+import { desktopBridge } from "@/lib/closeGuard";
+import { askConfirm } from "@/lib/dialog";
+
+function bytes(n: number): string {
+ if (n < 1024 * 1024) return `${Math.max(1, Math.round(n / 1024))} KB`;
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
+ return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
+}
+
+/** Settings → Advanced → Back up and restore. */
+export function BackupSettings() {
+ const [pass, setPass] = useState("");
+ const [pass2, setPass2] = useState("");
+ const [out, setOut] = useState("");
+ const [disks, setDisks] = useState(false);
+ const [backupMsg, setBackupMsg] = useState(null);
+ const [path, setPath] = useState("");
+ const [restorePass, setRestorePass] = useState("");
+ const [restoreMsg, setRestoreMsg] = useState(null);
+ const [pending, setPending] = useState<{ stagedAt: string; from: string } | null>(null);
+ const [busy, setBusy] = useState<"backup" | "inspect" | "restore" | null>(null);
+
+ useEffect(() => {
+ // Older control planes (and partial API mocks) have no restore route.
+ void Promise.resolve()
+ .then(() => api.restoreStatus())
+ .then((r) => setPending(r.pending))
+ .catch(() => undefined);
+ }, []);
+
+ const mismatch = pass2.length > 0 && pass !== pass2;
+ const bridge = desktopBridge();
+
+ async function backup() {
+ setBusy("backup");
+ setBackupMsg(null);
+ try {
+ const result = await api.backup({
+ passphrase: pass,
+ out: out.trim() || undefined,
+ includeDisks: disks,
+ appVersion: APP_VERSION,
+ });
+ const skipped = result.manifest.skipped.length
+ ? ` ${result.manifest.skipped.length} unreadable files were skipped.`
+ : "";
+ setBackupMsg(
+ `Saved ${result.manifest.files} files (${bytes(result.sizeBytes)}) to ${result.path}.${skipped} Keep the passphrase; it cannot be recovered.`,
+ );
+ setPass("");
+ setPass2("");
+ } catch (err) {
+ setBackupMsg(err instanceof Error ? err.message : "Backup failed");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function inspect() {
+ setBusy("inspect");
+ setRestoreMsg(null);
+ try {
+ const m = await api.inspectBackup(path.trim(), restorePass);
+ setRestoreMsg(
+ `Backup from ${new Date(m.createdAt).toLocaleString()} (Locus ${m.appVersion ?? "?"}, ${m.platform}): ${m.files} files, ${bytes(m.bytes)}${m.includeDisks ? ", with VM disks" : ""}.`,
+ );
+ } catch (err) {
+ setRestoreMsg(err instanceof Error ? err.message : "Could not read the backup");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function restore() {
+ setBusy("restore");
+ setRestoreMsg(null);
+ try {
+ const result = await api.stageRestore(path.trim(), restorePass);
+ setRestoreMsg(
+ `Staged ${result.files} files. Restart Locus to finish; your current data moves to ~/.locus/.before-restore-*.`,
+ );
+ setRestorePass("");
+ const status = await api.restoreStatus();
+ setPending(status.pending);
+ } catch (err) {
+ setRestoreMsg(err instanceof Error ? err.message : "Restore failed");
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ return (
+
+ Back up and restore
+
+ One encrypted file with your sessions, settings, stored keys and tokens, boards, projects,
+ Jobs, Brain and agent workspaces. Keys are re-encrypted for the new machine on restore.
+ Logs, caches and dependency folders are left out.
+
+
+
+ Passphrase (8+ characters)
+ setPass(e.target.value)}
+ />
+
+
+ Repeat passphrase
+ setPass2(e.target.value)}
+ />
+
+
+ Save to (optional)
+ setOut(e.target.value)}
+ />
+
+
+ setDisks(e.target.checked)} />
+ Include VM disks and images (can be many GB)
+
+
+
+ void backup()}
+ >
+ {busy === "backup" ? "Backing up…" : "Back up now"}
+
+
+ {mismatch ? The passphrases do not match.
: null}
+ {backupMsg ? (
+
+ {backupMsg}
+
+ ) : null}
+
+ Restore
+ {pending ? (
+
+
+ A restore of {pending.from} is staged. It applies the next time Locus starts.
+
+ {bridge?.restartApp ? (
+
void bridge.restartApp?.()}
+ >
+ Restart Locus now
+
+ ) : null}
+
+ void api.cancelRestore().then(() => {
+ setPending(null);
+ setRestoreMsg("Cancelled the staged restore.");
+ })
+ }
+ >
+ Cancel restore
+
+
+ ) : null}
+
+
+ Backup file
+ setPath(e.target.value)}
+ />
+
+
+ Passphrase
+ setRestorePass(e.target.value)}
+ />
+
+
+
+ void inspect()}
+ >
+ {busy === "inspect" ? "Checking…" : "Check backup"}
+
+
+ void askConfirm({
+ title: "Restore from backup?",
+ body: "This replaces this machine's Locus data when Locus next starts. Your current files are kept under ~/.locus/.before-restore-*.",
+ confirmLabel: "Restore",
+ danger: true,
+ }).then((ok) => {
+ if (ok) void restore();
+ })
+ }
+ >
+ {busy === "restore" ? "Restoring…" : "Restore from backup"}
+
+
+ {restoreMsg ? (
+
+ {restoreMsg}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/BenchmarkSettings.test.tsx b/apps/desktop/src/components/settings/BenchmarkSettings.test.tsx
new file mode 100644
index 0000000..c944c56
--- /dev/null
+++ b/apps/desktop/src/components/settings/BenchmarkSettings.test.tsx
@@ -0,0 +1,167 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { AiProvider } from "@locus/shared";
+
+const apiMock = vi.hoisted(() => ({
+ benchmarkCases: vi.fn(),
+ benchmarkRuns: vi.fn(),
+ startBenchmark: vi.fn(),
+ cancelBenchmark: vi.fn(),
+ backup: vi.fn(),
+ inspectBackup: vi.fn(),
+ restoreStatus: vi.fn(),
+ stageRestore: vi.fn(),
+ cancelRestore: vi.fn(),
+}));
+vi.mock("@/lib/api", () => ({ api: apiMock }));
+
+import { BenchmarkSettings } from "./BenchmarkSettings";
+import { BackupSettings } from "./BackupSettings";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const provider = {
+ id: "openai",
+ name: "OpenAI",
+ kind: "openai",
+ baseUrl: "",
+ models: ["gpt-a", "gpt-b"],
+ defaultModel: "gpt-a",
+ enabled: true,
+ extraHeaders: {},
+} as unknown as AiProvider;
+
+let host: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ vi.clearAllMocks();
+});
+
+async function flush() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+function setInput(el: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
+ setter.call(el, value);
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+describe("BenchmarkSettings", () => {
+ it("starts a run for the picked model and shows the summary", async () => {
+ apiMock.benchmarkCases.mockResolvedValue({
+ cases: [
+ { id: "fix-add", title: "Fix a one-line bug", prompt: "" },
+ { id: "off-by-one", title: "Find an off-by-one", prompt: "" },
+ ],
+ });
+ apiMock.benchmarkRuns.mockResolvedValue({ runs: [] });
+ apiMock.startBenchmark.mockImplementation(async (body) => ({
+ id: "bench-1",
+ createdAt: "2026-11-12T00:00:00Z",
+ status: "done",
+ targets: body.targets,
+ caseIds: body.caseIds,
+ repeat: 1,
+ results: [
+ {
+ caseId: "fix-add",
+ target: body.targets[0],
+ attempt: 1,
+ status: "passed",
+ durationMs: 4000,
+ },
+ ],
+ summary: [
+ {
+ target: body.targets[0],
+ passed: 1,
+ total: 1,
+ passRate: 1,
+ usd: 0.02,
+ medianMs: 4000,
+ tokens: 10,
+ },
+ ],
+ }));
+ act(() => root.render( ));
+ await flush();
+ const start = host.querySelector("[data-testid='benchmark-start']")!;
+ expect(start.disabled).toBe(true);
+ act(() => {
+ host.querySelector("[data-testid='benchmark-provider-openai']")!.click();
+ });
+ expect(start.disabled).toBe(false);
+ expect(start.textContent).toBe("Run 2 cases");
+ await act(async () => start.click());
+ await flush();
+ expect(apiMock.startBenchmark).toHaveBeenCalledWith({
+ targets: [{ providerId: "openai", model: "gpt-a" }],
+ caseIds: ["fix-add", "off-by-one"],
+ repeat: 1,
+ });
+ const run = host.querySelector("[data-testid='benchmark-run']")!;
+ expect(run.textContent).toContain("openai/gpt-a");
+ expect(run.textContent).toContain("1/1 · 100%");
+ expect(run.textContent).toContain("$0.02");
+ });
+});
+
+describe("BackupSettings", () => {
+ it("needs matching passphrases before backing up", async () => {
+ apiMock.restoreStatus.mockResolvedValue({ pending: null });
+ apiMock.backup.mockResolvedValue({
+ path: "/home/me/b.locusbak",
+ sizeBytes: 4096,
+ manifest: { files: 3, bytes: 9000, includeDisks: false, skipped: [] },
+ });
+ act(() => root.render( ));
+ await flush();
+ const [pass, pass2] = host.querySelectorAll("input[type='password']");
+ const start = host.querySelector("[data-testid='backup-start']")!;
+ act(() => {
+ setInput(pass!, "correct horse");
+ setInput(pass2!, "correct hors");
+ });
+ expect(start.disabled).toBe(true);
+ expect(host.textContent).toContain("do not match");
+ act(() => setInput(pass2!, "correct horse"));
+ expect(start.disabled).toBe(false);
+ await act(async () => start.click());
+ await flush();
+ expect(apiMock.backup).toHaveBeenCalledWith(
+ expect.objectContaining({ passphrase: "correct horse", includeDisks: false }),
+ );
+ expect(host.textContent).toContain("/home/me/b.locusbak");
+ });
+
+ it("shows a staged restore with a cancel action", async () => {
+ apiMock.restoreStatus.mockResolvedValue({
+ pending: { stagedAt: "2026-11-12T00:00:00Z", from: "/tmp/b.locusbak" },
+ });
+ apiMock.cancelRestore.mockResolvedValue({ ok: true });
+ act(() => root.render( ));
+ await flush();
+ const pending = host.querySelector("[data-testid='restore-pending']")!;
+ expect(pending.textContent).toContain("/tmp/b.locusbak");
+ const cancel = [...pending.querySelectorAll("button")].find(
+ (b) => b.textContent === "Cancel restore",
+ )!;
+ await act(async () => cancel.click());
+ await flush();
+ expect(apiMock.cancelRestore).toHaveBeenCalled();
+ expect(host.querySelector("[data-testid='restore-pending']")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/settings/BenchmarkSettings.tsx b/apps/desktop/src/components/settings/BenchmarkSettings.tsx
new file mode 100644
index 0000000..6123de7
--- /dev/null
+++ b/apps/desktop/src/components/settings/BenchmarkSettings.tsx
@@ -0,0 +1,298 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ benchmarkTargetKey,
+ formatUsd,
+ type AiProvider,
+ type BenchmarkTarget,
+} from "@locus/shared";
+import { api, type BenchmarkRunView } from "@/lib/api";
+import { HowItWorks } from "./SettingsBits";
+
+type Case = { id: string; title: string };
+
+function seconds(ms?: number): string {
+ return ms ? `${Math.max(1, Math.round(ms / 1000))}s` : "–";
+}
+
+/**
+ * Settings → Benchmark. Runs the built-in coding cases as real Host agent turns against the
+ * models you pick, then compares pass rate, cost and time.
+ */
+export function BenchmarkSettings(props: {
+ providers: AiProvider[];
+ onOpenSession?: (id: string) => void;
+}) {
+ const enabled = useMemo(() => props.providers.filter((p) => p.enabled), [props.providers]);
+ const [cases, setCases] = useState([]);
+ const [runs, setRuns] = useState([]);
+ const [picked, setPicked] = useState>({});
+ const [caseIds, setCaseIds] = useState(null);
+ const [repeat, setRepeat] = useState(1);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [open, setOpen] = useState(null);
+
+ const running = runs.find((run) => run.status === "running");
+ const runningId = running?.id;
+
+ useEffect(() => {
+ let cancelled = false;
+ void Promise.all([api.benchmarkCases(), api.benchmarkRuns()])
+ .then(([c, r]) => {
+ if (cancelled) return;
+ setCases(c.cases);
+ setRuns(r.runs);
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) setError(err instanceof Error ? err.message : "Could not load benchmarks");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!runningId) return;
+ const timer = setInterval(() => {
+ void api
+ .benchmarkRuns()
+ .then((r) => setRuns(r.runs))
+ .catch(() => undefined);
+ }, 3000);
+ return () => clearInterval(timer);
+ }, [runningId]);
+
+ const targets: BenchmarkTarget[] = Object.entries(picked)
+ .filter(([, model]) => model)
+ .map(([providerId, model]) => ({ providerId, model }));
+ const selectedCases = caseIds ?? cases.map((c) => c.id);
+ const caseRuns = targets.length * selectedCases.length * repeat;
+
+ async function start() {
+ setBusy(true);
+ setError(null);
+ try {
+ const run = await api.startBenchmark({ targets, caseIds: selectedCases, repeat });
+ setRuns((rows) => [run, ...rows.filter((row) => row.id !== run.id)]);
+ setOpen(run.id);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not start the benchmark");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ Each case is a tiny JavaScript project with failing tests. Locus starts an autonomous Host
+ agent on a fresh copy, sends the task, then runs npm test itself. A case
+ passes only when the tests pass and the test files are unchanged. Cost is estimated from
+ the provider’s rates. Benchmark chats are archived so you can open a failure and read it.
+ Runs execute one case at a time and use your provider quota. Results are saved under{" "}
+ ~/.locus/benchmarks. CLI: locus eval run --model provider/model.
+
+
+
+
+ Models
+ {enabled.length ? (
+
+ ) : (
+ Enable a provider in Settings → Providers first.
+ )}
+
+
+
+ Cases
+
+
+ Runs per case
+ setRepeat(Number(event.target.value))}>
+ {[1, 2, 3, 5].map((n) => (
+
+ {n}
+
+ ))}
+
+
+
+ void start()}
+ >
+ {running
+ ? "Benchmark running…"
+ : `Run ${caseRuns || ""} case${caseRuns === 1 ? "" : "s"}`}
+
+ {running ? (
+
+ void api
+ .cancelBenchmark(running.id)
+ .then((run) => setRuns((rows) => rows.map((r) => (r.id === run.id ? run : r))))
+ }
+ >
+ Cancel
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {runs.map((run) => {
+ const done = run.results.filter((r) => r.status !== "pending" && r.status !== "running");
+ return (
+
+ setOpen(open === run.id ? null : run.id)}
+ >
+ {new Date(run.createdAt).toLocaleString()}
+
+ {run.status === "running"
+ ? `Running · ${done.length}/${run.results.length}`
+ : run.status === "done"
+ ? "Done"
+ : run.status === "cancelled"
+ ? "Cancelled"
+ : `Error${run.error ? ` · ${run.error}` : ""}`}
+
+
+
+
+
+ Model
+ Passed
+ Cost
+ Median time
+
+
+
+ {run.summary.map((row) => (
+
+ {benchmarkTargetKey(row.target)}
+
+ {row.passed}/{row.total}
+ {row.total ? ` · ${Math.round(row.passRate * 100)}%` : ""}
+
+ {row.usd != null ? formatUsd(row.usd) : "–"}
+ {seconds(row.medianMs)}
+
+ ))}
+
+
+ {open === run.id ? (
+
+
+
+ Case
+ Model
+ Result
+ Time
+
+
+
+
+ {run.results.map((row, i) => (
+
+ {row.caseId}
+ {benchmarkTargetKey(row.target)}
+
+ {row.status}
+ {row.reason && row.status !== "passed" ? ` · ${row.reason}` : ""}
+
+ {seconds(row.durationMs)}
+
+ {row.sessionId && props.onOpenSession ? (
+ props.onOpenSession?.(row.sessionId!)}
+ >
+ Open chat
+
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/Settings.test.tsx b/apps/desktop/src/components/settings/Settings.test.tsx
index 73ddd44..7ffa4b9 100644
--- a/apps/desktop/src/components/settings/Settings.test.tsx
+++ b/apps/desktop/src/components/settings/Settings.test.tsx
@@ -75,7 +75,7 @@ vi.mock("@/lib/api", () => ({
rotateChromeExtension: vi.fn(),
checkUpdates: vi.fn(async () => ({
status: "unavailable",
- current: "0.20.0",
+ current: "0.21.0",
message: "No GitHub release found yet. Check for updates works after a v* tag is published.",
})),
refreshCursorCli: vi.fn(async () => ({ changed: false, models: [] })),
@@ -355,6 +355,7 @@ describe("Settings", () => {
"Providers",
"MCP",
"Skills",
+ "Benchmark",
"Machines",
"Workers",
"Environments",
diff --git a/apps/desktop/src/components/settings/Settings.tsx b/apps/desktop/src/components/settings/Settings.tsx
index dbe7062..b26f35f 100644
--- a/apps/desktop/src/components/settings/Settings.tsx
+++ b/apps/desktop/src/components/settings/Settings.tsx
@@ -27,6 +27,7 @@ import {
promptQueueLimit,
UBUNTU_DEFAULT_HARDWARE,
defaultNetworking,
+ parseCheckForUpdates,
parseProductMode,
type AgentSkill,
type AiProvider,
@@ -41,6 +42,7 @@ import {
type WorkerBackend,
} from "@locus/shared";
import { api, type HypervisorStatus } from "@/lib/api";
+import { installAppUpdate, installOutcomeMessage, updateVersionLabel } from "@/lib/appUpdate";
import {
APP_DEVELOPER,
APP_LICENSE,
@@ -85,6 +87,8 @@ import {
import { PolicyBanner, policyControlLocked } from "./PolicyBanner";
import { AppearanceSettings } from "./AppearanceSettings";
import { NotificationSettings } from "./NotificationSettings";
+import { BenchmarkSettings } from "./BenchmarkSettings";
+import { BackupSettings } from "./BackupSettings";
import { HowItWorks, SettingsToggleRow } from "./SettingsBits";
import { SearchField } from "@/components/layout/SearchField";
import { BusyButton } from "@/components/layout/Skeleton";
@@ -968,7 +972,7 @@ export function Settings() {
async function checkForUpdates() {
setUpdateBusy(true);
try {
- const result = await api.checkUpdates(APP_VERSION);
+ const result = await api.checkUpdates(APP_VERSION, { refresh: true });
setUpdate(result);
setUpdateDownload(null);
} catch (err) {
@@ -1250,6 +1254,13 @@ export function Settings() {
/>
)}
+ {tab === "benchmark" && (
+ dispatch({ type: "select", id })}
+ />
+ )}
+
{tab === "notifications" && (
) : (
- No Git accounts connected yet. Add GitHub, GitLab, Bitbucket, Gitea, Forgejo, or
- Azure DevOps in Git.
+ No Git tokens yet. Public repos still link and load without one (GitHub allows 60
+ requests an hour). Add GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps in
+ Git for private repos and higher limits.
)}
+
Danger zone
@@ -2802,13 +2815,35 @@ export function Settings() {
GitHub releases
+
+
+ Check automatically
+ Look for a new release at launch and every six hours.
+
+
+ setDraft({
+ ...draft,
+ checkForUpdates: !parseCheckForUpdates(draft.checkForUpdates),
+ })
+ }
+ />
+
{update ? (
- {update.status === "available"
- ? `Version ${update.latest?.replace(/^v/i, "")} is available.`
- : update.status === "up-to-date"
- ? "You are on the latest release."
- : (update.message ?? "Updates are not available yet.")}
+ {update.status === "available" && update.message
+ ? update.message
+ : update.status === "available"
+ ? `Version ${update.latest?.replace(/^v/i, "")} is available.`
+ : update.status === "up-to-date"
+ ? "You are on the latest release."
+ : (update.message ?? "Updates are not available yet.")}
) : null}
{update?.status === "available" ? (
@@ -2828,20 +2863,24 @@ export function Settings() {
type="button"
className="btn small"
busy={updateBusy}
- busyLabel="Verifying…"
+ busyLabel="Installing…"
+ data-testid="about-install-update"
onClick={() => {
void (async () => {
setUpdateBusy(true);
try {
- const result = await api.downloadUpdate(
- update.asset!,
- update.checksums!,
- );
- setUpdateDownload(result);
+ const outcome = await installAppUpdate(update);
+ if (outcome.action !== "download-only") {
+ setUpdateDownload({ path: outcome.path, sha256: "" });
+ }
+ setUpdate({
+ ...update,
+ message: installOutcomeMessage(outcome, updateVersionLabel(update)),
+ });
} catch (err) {
setUpdate({
...update,
- message: err instanceof Error ? err.message : "Download failed",
+ message: err instanceof Error ? err.message : "Install failed",
});
} finally {
setUpdateBusy(false);
@@ -2849,7 +2888,7 @@ export function Settings() {
})();
}}
>
- Download and verify
+ Install update
) : null}
{update.htmlUrl ? (
@@ -2861,7 +2900,7 @@ export function Settings() {
) : null}
{updateDownload ? (
- Verified {updateDownload.sha256.slice(0, 12)}… Saved to {updateDownload.path}.
+ Verified against SHA256SUMS. Saved to {updateDownload.path}.
(null);
const [tokenOpen, setTokenOpen] = useState(false);
- const status = forgeAccountStatus(f);
+ const baseStatus = forgeAccountStatus(f);
+ // Linked public repos already use a token-less account; say what it can do.
+ const status =
+ baseStatus.kind === "signed-out" && linkedCount > 0
+ ? { ...baseStatus, label: anonymousForgeLabel(f.kind) }
+ : baseStatus;
const handle = forgeAccountHandle(f);
const sso = forgeSupportsSso(f.kind);
const scopes = forgeOauthScopes(f.kind).split(/\s+/).filter(Boolean).join(" · ");
diff --git a/apps/desktop/src/components/settings/SetupWizard.test.tsx b/apps/desktop/src/components/settings/SetupWizard.test.tsx
index c001365..a9f6514 100644
--- a/apps/desktop/src/components/settings/SetupWizard.test.tsx
+++ b/apps/desktop/src/components/settings/SetupWizard.test.tsx
@@ -18,6 +18,8 @@ vi.mock("@/lib/api", () => ({
api: {
saveConfig: vi.fn(),
setup: vi.fn(),
+ providerModels: vi.fn(),
+ testProvider: vi.fn(),
},
}));
@@ -399,7 +401,7 @@ describe("SetupWizard", () => {
root.render( );
});
const enable = [...host.querySelectorAll("button")].find(
- (btn) => btn.textContent === "Save key",
+ (btn) => btn.textContent === "Use Claude Code",
);
expect(enable).toBeTruthy();
expect((enable as HTMLButtonElement).disabled).toBe(false);
@@ -469,7 +471,7 @@ describe("SetupWizard", () => {
root.render( );
});
const enable = [...host.querySelectorAll("button")].find(
- (btn) => btn.textContent === "Save key",
+ (btn) => btn.textContent === "Use Kiro CLI",
);
expect(enable).toBeTruthy();
expect((enable as HTMLButtonElement).disabled).toBe(false);
@@ -485,4 +487,72 @@ describe("SetupWizard", () => {
const saved = vi.mocked(api.saveConfig).mock.calls.at(-1)?.[0] as LocusConfig;
expect(saved.providers[0]).toMatchObject({ id: "kiro-cli", enabled: true });
});
+
+ it("picks a model the local server actually has and reports the test call", async () => {
+ const next = {
+ ...config,
+ providers: [
+ {
+ id: "ollama",
+ name: "Ollama",
+ kind: "openai-compatible" as const,
+ baseUrl: "http://127.0.0.1:11434/v1",
+ models: ["llama3.2"],
+ defaultModel: "llama3.2",
+ enabled: false,
+ extraHeaders: {},
+ },
+ ],
+ };
+ vi.mocked(api.providerModels).mockResolvedValue({ models: ["qwen3-coder", "phi4"] });
+ vi.mocked(api.testProvider).mockResolvedValue({ ok: true, models: 2 });
+ vi.mocked(api.saveConfig).mockImplementation(async (saved) => saved);
+ vi.mocked(api.setup).mockResolvedValue({ ...setup, hasProvider: true });
+ let state: UiState = {
+ ...initialState,
+ config: next,
+ setup: { ...setup, hasProvider: false },
+ setupOpen: true,
+ };
+
+ function AppSlice() {
+ return (
+ {
+ state = reducer(state, action);
+ act(() => {
+ root.render( );
+ });
+ },
+ }}
+ >
+
+
+ );
+ }
+
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root.render( );
+ });
+ const use = [...host.querySelectorAll("button")].find(
+ (btn) => btn.textContent === "Use Ollama",
+ );
+ expect(use).toBeTruthy();
+ await act(async () => {
+ use!.click();
+ for (let i = 0; i < 6; i += 1) await Promise.resolve();
+ });
+ const saved = vi.mocked(api.saveConfig).mock.calls.at(-1)?.[0] as LocusConfig;
+ expect(saved.providers[0]).toMatchObject({
+ enabled: true,
+ defaultModel: "qwen3-coder",
+ models: ["qwen3-coder", "phi4"],
+ });
+ expect(host.textContent).toMatch(/Ollama · qwen3-coder · responded in \d+ ms/);
+ });
});
diff --git a/apps/desktop/src/components/settings/SetupWizard.tsx b/apps/desktop/src/components/settings/SetupWizard.tsx
index 144053c..90684dd 100644
--- a/apps/desktop/src/components/settings/SetupWizard.tsx
+++ b/apps/desktop/src/components/settings/SetupWizard.tsx
@@ -20,6 +20,8 @@ export function SetupWizard() {
const [error, setError] = useState(null);
const [restartNote, setRestartNote] = useState(null);
const [productMode, setProductMode] = useState<"developer" | "swarm">("developer");
+ /** Result of the test call after saving: what answered, or why it didn't. */
+ const [verified, setVerified] = useState<{ ok: boolean; text: string } | null>(null);
if (!state.setupOpen || !setup || !config) return null;
const current = config;
@@ -80,12 +82,60 @@ export function SetupWizard() {
if (!provider) return;
setBusy("save");
setError(null);
+ setVerified(null);
try {
+ const key = apiKey.trim();
+ const local = isLocalOpenAiBase(provider.baseUrl);
+ // A local server (Ollama, LM Studio) only has the models someone pulled.
+ // Ask it, so the default isn't a model that isn't there.
+ let models: string[] | null = null;
+ if (local) {
+ models = await api
+ .providerModels(provider.id, key ? { apiKey: key } : {})
+ .then((res) => res.models)
+ .catch(() => null);
+ }
+ const defaultModel =
+ models?.length && !models.includes(provider.defaultModel)
+ ? models[0]!
+ : provider.defaultModel;
const providers = current.providers.map((p) =>
- p.id === provider.id ? { ...p, apiKey: apiKey.trim() || p.apiKey, enabled: true } : p,
+ p.id === provider.id
+ ? {
+ ...p,
+ apiKey: key || p.apiKey,
+ enabled: true,
+ defaultModel,
+ ...(models?.length ? { models } : {}),
+ }
+ : p,
);
await persist({ ...current, providers });
setApiKey("");
+ if (local && models && models.length === 0) {
+ setVerified({
+ ok: false,
+ text: `${provider.name} is running but has no models yet. Pull one (for example \`ollama pull llama3.2\`), then pick it in the composer.`,
+ });
+ return;
+ }
+ const started = performance.now();
+ try {
+ const result = await api.testProvider(provider.id);
+ const ms = Math.round(performance.now() - started);
+ setVerified(
+ result.ok
+ ? { ok: true, text: `${provider.name} · ${defaultModel} · responded in ${ms} ms` }
+ : { ok: false, text: `${provider.name} saved, but the test call failed.` },
+ );
+ } catch (err) {
+ setVerified({
+ ok: false,
+ text: `${provider.name} saved, but the test call failed: ${
+ err instanceof Error ? err.message : "no response"
+ }`,
+ });
+ }
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save provider");
} finally {
@@ -141,6 +191,13 @@ export function SetupWizard() {
const hostLabel = setup.hostOs?.label ?? "this computer";
const keyOptional = hasUsableKey("", provider?.baseUrl, provider?.kind);
+ const enabledProvider = current.providers.find((p) => p.enabled);
+ // Order: free tier, then no-key local servers and CLIs, then cloud keys; the
+ // hint says what each needs instead of one flat list of 18 names.
+ const providerOptions = [...current.providers]
+ .map((p, index) => ({ p, index, group: providerGroup(p) }))
+ .sort((a, b) => a.group.rank - b.group.rank || a.index - b.index)
+ .map(({ p, group }) => ({ value: p.id, label: p.name, hint: group.hint }));
return (
Set up Locus
- Detected {hostLabel}. Add a provider key, then start chatting on this machine. Docker
- and QEMU are optional if you want a container or a full guest desktop later.
+ Detected {hostLabel}.{" "}
+ {setup.hasProvider
+ ? "A provider is ready, so you can start chatting on this machine."
+ : "Pick a model provider, then start chatting on this machine."}{" "}
+ Docker and QEMU are optional if you want a container or a full guest desktop later.
{t("setup.firstTask.hint")}
@@ -163,9 +223,12 @@ export function SetupWizard() {
Provider
-
+
{setup.hasProvider
- ? "An enabled provider can answer chat."
+ ? (verified?.text ??
+ (enabledProvider
+ ? `${enabledProvider.name} · ${enabledProvider.defaultModel} is enabled.`
+ : "An enabled provider can answer chat."))
: "Paste an OpenCode Zen key for the free tier, or another provider key. Local servers such as Ollama, and CLI providers (Cursor CLI, Claude Code, Codex CLI, Kiro CLI), do not need a key."}
{!setup.hasProvider && (
@@ -173,10 +236,7 @@ export function SetupWizard() {
({
- value: p.id,
- label: p.name,
- }))}
+ options={providerOptions}
onChange={(id) => {
setProviderId(id);
setApiKey("");
@@ -205,7 +265,11 @@ export function SetupWizard() {
disabled={busy !== null || (!apiKey.trim() && !keyOptional)}
onClick={() => void saveProvider()}
>
- Save key
+ {busy === "save"
+ ? "Checking…"
+ : keyOptional
+ ? `Use ${provider?.name ?? "provider"}`
+ : "Save key"}
)}
@@ -344,3 +408,11 @@ export function SetupWizard() {
);
}
+
+function providerGroup(p: AiProvider): { rank: number; hint: string } {
+ if (p.kind === "opencode") return { rank: 0, hint: "Free tier · key" };
+ if (p.kind.endsWith("-cli")) return { rank: 2, hint: "CLI login · no key" };
+ if (p.kind === "claude-code") return { rank: 2, hint: "CLI login · no key" };
+ if (isLocalOpenAiBase(p.baseUrl)) return { rank: 1, hint: "Local · no key" };
+ return { rank: 3, hint: "API key" };
+}
diff --git a/apps/desktop/src/components/workspace/IdeEmpty.tsx b/apps/desktop/src/components/workspace/IdeEmpty.tsx
index a1dee1a..b8a8990 100644
--- a/apps/desktop/src/components/workspace/IdeEmpty.tsx
+++ b/apps/desktop/src/components/workspace/IdeEmpty.tsx
@@ -230,6 +230,35 @@ export function IdeEmpty() {
From GitHub, GitLab or a URL
+ {mode !== "clone" && state.repos.length ? (
+
+
Linked repos
+
+ {state.repos.slice(0, 3).map((repo) => (
+ void cloneLinked(repo)}
+ >
+
+ {forgeShort(repo.checkout.forge)}
+
+
+
+ Open {shortRepoName(repo.checkout.url, repo.name)}
+
+
+ {repo.name}
+ {repo.checkout.ref ? ` · ${repo.checkout.ref}` : ""}
+
+
+
+ ))}
+
+
+ ) : null}
{recents.length ? (
Recent folders
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index 2c57a52..2ac5f00 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -688,19 +688,134 @@ button:disabled {
color: var(--muted);
margin-bottom: var(--space-2) !important;
}
+/* Actions sit under the title so the sidebar never cuts a chat name to "Fl…". */
.inbox-row-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
- padding: var(--space-1-5) var(--space-2) var(--space-1-5) 0;
- align-self: center;
+ padding: 0 var(--space-2) var(--space-2) 28px;
}
-.session-item.inbox-row {
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
+.session-item.has-menu.inbox-row {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: stretch;
padding: 0;
gap: 0;
}
+.inbox-decision-card {
+ width: min(640px, 100%);
+}
+.inbox-decision-card .inbox-next {
+ width: 100%;
+ box-sizing: border-box;
+}
+.inbox-stepper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.inbox-decision-body {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin: var(--space-1) 0 var(--space-2);
+}
+.inbox-decision-what {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.inbox-risk {
+ padding: 0 var(--space-2);
+ border-radius: var(--radius-pill);
+ border: 1px solid currentColor;
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ line-height: 1.6;
+}
+.inbox-risk.low {
+ color: var(--ok);
+}
+.inbox-risk.medium {
+ color: var(--warn);
+}
+.inbox-risk.high {
+ color: var(--danger);
+}
+.inbox-decision-why {
+ margin: 0;
+ padding: var(--space-1) var(--space-3);
+ border-left: 2px solid var(--line-strong);
+ color: var(--muted);
+ font-size: var(--type-sm);
+}
+.inbox-decision-command,
+.inbox-decision-args pre {
+ margin: 0;
+ padding: var(--space-2) var(--space-3);
+ max-height: 200px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md, 6px);
+ background: var(--bg-2);
+ font-size: var(--type-xs);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.inbox-decision-files {
+ margin: 0;
+ padding-left: var(--space-4);
+ font-family: var(--mono, ui-monospace, monospace);
+ font-size: var(--type-xs);
+}
+.inbox-decision-facts {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: var(--space-1) var(--space-3);
+ margin: 0;
+ font-size: var(--type-sm);
+}
+.inbox-decision-facts dt {
+ color: var(--muted);
+}
+.inbox-decision-facts dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.inbox-decision-args summary {
+ cursor: pointer;
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.inbox-decision-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.inbox-decision-actions kbd,
+.inbox-keys kbd {
+ font-size: var(--type-2xs);
+ opacity: 0.75;
+}
+.inbox-approve-all {
+ margin-top: var(--space-1);
+}
+.inbox-keys {
+ margin: var(--space-3) 0 0;
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+@media (pointer: coarse) {
+ .inbox-keys,
+ .inbox-decision-actions kbd {
+ display: none;
+ }
+}
.side-rail .rail-label {
max-width: 100%;
overflow: hidden;
@@ -3788,6 +3903,89 @@ button.patch-path-open:hover {
margin: var(--space-1-5) 0 0;
max-width: 720px;
}
+.turn-outcome {
+ max-width: 720px;
+ margin-top: var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--bg-1);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.turn-outcome-head {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-3);
+}
+.turn-outcome-title {
+ font-weight: var(--weight-semibold);
+}
+.turn-outcome-tests {
+ font-size: var(--type-sm);
+}
+.turn-outcome-tests.ok {
+ color: var(--ok);
+}
+.turn-outcome-tests.failed {
+ color: var(--danger);
+}
+.turn-outcome-meta {
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.turn-outcome-files {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: var(--type-sm);
+}
+.turn-outcome-files li {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.turn-outcome-files button {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--text);
+ font-family: var(--mono, ui-monospace, monospace);
+ font-size: var(--type-xs);
+ cursor: pointer;
+ text-align: left;
+}
+.turn-outcome-files button:hover {
+ text-decoration: underline;
+}
+.turn-outcome-stat {
+ flex: 0 0 auto;
+ font-size: var(--type-xs);
+}
+.turn-outcome-stat .add {
+ color: var(--ok);
+}
+.turn-outcome-stat .del {
+ color: var(--danger);
+}
+.turn-outcome-more {
+ color: var(--muted);
+}
+.turn-outcome-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
.task-list {
max-width: 720px;
margin: var(--chat-task-margin);
@@ -4861,6 +5059,10 @@ button.patch-path-open:hover {
overflow-y: auto;
text-align: left;
}
+.composer-repo-pop.up {
+ top: auto;
+ bottom: calc(100% + 6px);
+}
.composer-repo-search {
width: 100%;
margin: 0 0 var(--space-1);
@@ -15327,6 +15529,11 @@ button.pill {
.setup-wizard-modal {
width: min(640px, calc(100vw - 32px));
}
+/* Pin the wizard near the top so it doesn't jump when the provider card collapses. */
+.modal-backdrop:has(> .setup-wizard-modal) {
+ place-items: start center;
+ padding-top: max(var(--space-6), 10vh);
+}
.setup-hero-lead {
margin-top: 0;
}
@@ -19223,11 +19430,19 @@ a.forge-badge.forge-link:hover {
gap: var(--space-1);
margin: 0;
padding: 0 18px;
- flex-wrap: wrap;
+ /* One row that scrolls sideways on narrow windows instead of wrapping to two. */
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
+.codebase-tabs::-webkit-scrollbar {
+ display: none;
+}
.codebase-tabs button {
+ white-space: nowrap;
+ flex-shrink: 0;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
@@ -19716,10 +19931,70 @@ button.repo-stat:hover {
border-radius: var(--radius-sm);
padding: 1px var(--space-1-5);
}
+@media (min-width: 761px) {
+ .empty-actions.narrow-only {
+ display: none;
+ }
+}
+.repo-danger-zone {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ margin-top: var(--space-6);
+ padding: var(--space-3);
+ border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--line));
+ border-radius: var(--radius-lg);
+}
+.repo-danger-zone .hint {
+ margin: var(--space-1) 0 0;
+}
.pr-table-pane {
min-height: 220px;
flex: 1;
}
+/* Nothing to review: one line, not half the page. */
+.pr-table-pane.is-empty {
+ min-height: 0;
+ flex: 0 0 auto;
+}
+.overview-about {
+ max-width: 760px;
+}
+.overview-about-lead {
+ margin: 0 0 var(--space-1-5);
+ font-size: var(--type-md, 15px);
+}
+.overview-about-facts {
+ margin: 0;
+ color: var(--muted);
+}
+.overview-about-topics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+ margin: var(--space-2) 0 0;
+}
+.overview-about-topics .chip {
+ padding: 1px var(--space-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+.overview-about-readme {
+ margin-top: var(--space-2);
+}
+.overview-about-readme > summary {
+ cursor: pointer;
+ color: var(--muted);
+ font-size: var(--type-xs);
+}
+/* Only the review inbox flexes; a banner above must not squeeze the other cards. */
+.overview-panes > .surface-pane:not(.pr-table-pane),
+.overview-panes > .repo-stats {
+ flex-shrink: 0;
+}
.pr-table-body {
padding: 0;
gap: 0;
@@ -23921,3 +24196,106 @@ img.slack-emoji {
.git-account-advanced .git-account-remove {
margin-top: var(--space-1);
}
+
+/* Settings → About: automatic update checks */
+.about-auto-update {
+ padding: var(--space-2) 0;
+}
+
+/* Settings → Benchmark */
+.benchmark-settings {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+.benchmark-settings .notify-card h4 {
+ margin: 0;
+ padding: 13px 18px 0;
+ font-size: var(--type-md);
+}
+.benchmark-settings .notify-card > .about-updates-actions,
+.benchmark-settings .notify-card > .hint,
+.benchmark-repeat {
+ padding: 0 18px 13px;
+}
+.benchmark-pick {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+.benchmark-repeat {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.benchmark-run-head {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: var(--space-2);
+ padding: 13px 18px;
+ background: none;
+ border: 0;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.benchmark-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--type-sm);
+}
+.benchmark-table th,
+.benchmark-table td {
+ padding: 6px 18px;
+ text-align: left;
+ border-top: 1px solid var(--line);
+ overflow-wrap: anywhere;
+}
+.benchmark-table th {
+ color: var(--muted);
+ font-weight: var(--weight-semibold);
+}
+.benchmark-cases tr[data-status="passed"] td:nth-child(3) {
+ color: var(--ok, var(--accent));
+}
+.benchmark-cases tr[data-status="failed"] td:nth-child(3),
+.benchmark-cases tr[data-status="error"] td:nth-child(3) {
+ color: var(--danger);
+}
+
+/* Settings → Advanced → Back up and restore */
+.backup-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--space-3);
+ margin: var(--space-3) 0;
+}
+.backup-grid label {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.backup-grid .backup-check {
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-2);
+}
+.backup-restore-title {
+ margin: var(--space-4) 0 0;
+ font-size: var(--type-md);
+}
+
+/* "Open on GitHub" on a merged PR is a link styled as a button. */
+.pr-detail-head a.btn,
+.pr-detail-head a.btn:hover {
+ text-decoration: none;
+ color: var(--text);
+}
diff --git a/apps/desktop/src/lib/ambient.test.ts b/apps/desktop/src/lib/ambient.test.ts
new file mode 100644
index 0000000..fceb66a
--- /dev/null
+++ b/apps/desktop/src/lib/ambient.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it, vi } from "vitest";
+import type { AgentSession, ChatMessage } from "@locus/shared";
+import { ambientCounts, needsYouNotice, runAmbientAction, showNeedsYouNotice } from "./ambient";
+
+const at = "2026-11-12T10:00:00.000Z";
+
+function session(id: string, extra: Partial
= {}): AgentSession {
+ return {
+ id,
+ title: `Chat ${id}`,
+ status: "idle",
+ messages: [],
+ createdAt: at,
+ updatedAt: at,
+ ...extra,
+ } as AgentSession;
+}
+
+const approval = session("s1", {
+ messages: [
+ {
+ id: "a",
+ role: "assistant",
+ content: "",
+ createdAt: at,
+ toolCalls: [
+ {
+ id: "c1",
+ name: "exec",
+ arguments: { command: "npm test" },
+ startedAt: at,
+ approval: "needed",
+ },
+ ],
+ },
+ ] as ChatMessage[],
+});
+
+describe("ambientCounts", () => {
+ it("counts running agents and items that need you, skipping archived chats", () => {
+ expect(
+ ambientCounts([
+ session("r", { status: "running" }),
+ session("x", { status: "running", archived: true }),
+ approval,
+ session("h", { needsHuman: "Which branch?" }),
+ ]),
+ ).toEqual({ running: 1, needsYou: 2 });
+ });
+});
+
+describe("needsYouNotice", () => {
+ it("names the tool and command for an approval", () => {
+ const notice = needsYouNotice(approval, ["a:c1"]);
+ expect(notice.title).toBe("Chat s1 needs approval");
+ expect(notice.body).toContain("npm test");
+ expect(notice.callId).toBe("c1");
+ expect(notice.approvable).toBe(true);
+ });
+
+ it("uses the agent's question otherwise", () => {
+ const notice = needsYouNotice(session("s2", { needsHuman: "Which branch?" }), ["h:s2"]);
+ expect(notice).toMatchObject({ title: "Chat s2", body: "Which branch?", approvable: false });
+ });
+});
+
+describe("showNeedsYouNotice", () => {
+ it("prefers the desktop bridge", () => {
+ const notify = vi.fn();
+ const notice = needsYouNotice(approval, ["a:c1"]);
+ showNeedsYouNotice(notice, true, vi.fn(), {
+ setCloseGuard: vi.fn(),
+ onCloseRequested: vi.fn(),
+ ackClosePrompt: vi.fn(),
+ confirmClose: vi.fn(),
+ notify,
+ });
+ expect(notify).toHaveBeenCalledWith(notice);
+ });
+
+ it("does nothing when notifications are off", () => {
+ const notify = vi.fn();
+ showNeedsYouNotice(needsYouNotice(approval, ["a:c1"]), false, vi.fn(), {
+ setCloseGuard: vi.fn(),
+ onCloseRequested: vi.fn(),
+ ackClosePrompt: vi.fn(),
+ confirmClose: vi.fn(),
+ notify,
+ });
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe("runAmbientAction", () => {
+ it("opens the chat or the inbox", async () => {
+ const dispatch = vi.fn();
+ const resolveApproval = vi.fn();
+ await runAmbientAction({ action: "open", sessionId: "s1" }, { dispatch, resolveApproval });
+ expect(dispatch).toHaveBeenCalledWith({ type: "select", id: "s1" });
+ await runAmbientAction({ action: "inbox" }, { dispatch, resolveApproval });
+ expect(dispatch).toHaveBeenCalledWith({ type: "inbox", open: true });
+ expect(resolveApproval).not.toHaveBeenCalled();
+ });
+
+ it("approves in the background and falls back to the chat on error", async () => {
+ const dispatch = vi.fn();
+ const updated = session("s1");
+ const resolveApproval = vi.fn().mockResolvedValueOnce(updated);
+ await runAmbientAction(
+ { action: "approve", sessionId: "s1", callId: "c1" },
+ { dispatch, resolveApproval },
+ );
+ expect(resolveApproval).toHaveBeenCalledWith("s1", "c1", "approved");
+ expect(dispatch).toHaveBeenCalledWith({ type: "session", session: updated });
+
+ const onError = vi.fn();
+ resolveApproval.mockRejectedValueOnce(new Error("gone"));
+ await runAmbientAction(
+ { action: "deny", sessionId: "s1", callId: "c1" },
+ { dispatch, resolveApproval, onError },
+ );
+ expect(onError).toHaveBeenCalled();
+ expect(dispatch).toHaveBeenLastCalledWith({ type: "select", id: "s1" });
+ });
+});
diff --git a/apps/desktop/src/lib/ambient.ts b/apps/desktop/src/lib/ambient.ts
new file mode 100644
index 0000000..ca6fddd
--- /dev/null
+++ b/apps/desktop/src/lib/ambient.ts
@@ -0,0 +1,117 @@
+import type { AgentSession, BoardSummary, BrainSummary } from "@locus/shared";
+import type { Dispatch } from "react";
+import type { Action } from "@/state/store";
+import {
+ activeAgents,
+ desktopBridge,
+ type AmbientAction,
+ type LocusDesktopBridge,
+} from "./closeGuard";
+import { approvalPreview } from "./inboxDecision";
+import { needsYouCount } from "./needsYou";
+import { notifyTurnDone } from "./notify";
+
+export type AmbientCounts = { running: number; needsYou: number };
+
+/** Counts for the tray menu and the dock / taskbar badge. */
+export function ambientCounts(
+ sessions: AgentSession[],
+ boards?: BoardSummary[],
+ brain?: Pick | null,
+): AmbientCounts {
+ const live = sessions.filter((row) => !row.archived);
+ return {
+ running: activeAgents(live).length,
+ needsYou: needsYouCount(live, boards, brain),
+ };
+}
+
+export type NeedsYouNotice = {
+ title: string;
+ body: string;
+ sessionId: string;
+ callId?: string;
+ approvable: boolean;
+};
+
+/**
+ * What a "needs you" notification should say for the gates that just appeared:
+ * the tool and its command for an approval, else the agent's question.
+ */
+export function needsYouNotice(session: AgentSession, freshKeys: string[]): NeedsYouNotice {
+ const approvalKey = freshKeys.find((key) => key.startsWith("a:"));
+ const callId = approvalKey?.slice(2);
+ const preview = callId ? approvalPreview(session, callId) : undefined;
+ if (preview) {
+ const target = preview.files[0] ?? preview.branch;
+ const body = preview.command
+ ? `Wants to run: ${preview.command}`
+ : target
+ ? `${preview.label} (${target})`
+ : preview.label;
+ return {
+ title: `${session.title} needs approval`,
+ body: body.length > 200 ? `${body.slice(0, 199)}…` : body,
+ sessionId: session.id,
+ callId,
+ approvable: preview.batchable,
+ };
+ }
+ const reason = session.needsHuman?.trim();
+ return {
+ title: session.title,
+ body: reason ? reason.slice(0, 200) : "Needs you",
+ sessionId: session.id,
+ approvable: false,
+ };
+}
+
+/** Desktop shell: native notification (Approve / Deny on macOS). Browser: web Notification. */
+export function showNeedsYouNotice(
+ notice: NeedsYouNotice,
+ enabled: boolean,
+ onOpen: () => void,
+ bridge: LocusDesktopBridge | undefined = desktopBridge(),
+): void {
+ if (!enabled) return;
+ if (bridge?.notify) {
+ bridge.notify(notice);
+ return;
+ }
+ notifyTurnDone(notice.title, notice.body, true, onOpen);
+}
+
+/** Act on a tray or notification click from the desktop shell. */
+export async function runAmbientAction(
+ payload: AmbientAction,
+ deps: {
+ dispatch: Dispatch;
+ resolveApproval: (
+ sessionId: string,
+ callId: string,
+ decision: "approved" | "denied",
+ ) => Promise;
+ onError?: (err: unknown, decision: "approved" | "denied") => void;
+ },
+): Promise {
+ const { dispatch } = deps;
+ if (payload.action === "inbox") {
+ dispatch({ type: "inbox", open: true });
+ return;
+ }
+ if (!payload.sessionId) return;
+ if ((payload.action === "approve" || payload.action === "deny") && payload.callId) {
+ const decision = payload.action === "approve" ? "approved" : "denied";
+ try {
+ const session = await deps.resolveApproval(payload.sessionId, payload.callId, decision);
+ dispatch({ type: "session", session });
+ } catch (err) {
+ deps.onError?.(err, decision);
+ // Could not decide in the background: take the operator to the chat instead.
+ dispatch({ type: "select", id: payload.sessionId });
+ }
+ return;
+ }
+ dispatch({ type: "select", id: payload.sessionId });
+ dispatch({ type: "inbox", open: false });
+}
diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index 46eaba4..1b365aa 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -1,5 +1,8 @@
import type {
AgentCanvasRef,
+ BenchmarkRun,
+ BenchmarkTarget,
+ BenchmarkTargetSummary,
AgentSession,
Automation,
AutomationRun,
@@ -36,6 +39,7 @@ import type {
ArchScope,
ChangeStack,
ChangeStackRevision,
+ RepoAbout,
RepoBranchList,
SlackConversation,
SlackGifSearch,
@@ -285,6 +289,8 @@ async function rememberMutatedIssue(
return issue;
}
+export type BenchmarkRunView = BenchmarkRun & { summary: BenchmarkTargetSummary[] };
+
export const api = {
health: () =>
json<{ ok: boolean; hypervisor: string; docker?: string; dockerReady?: boolean }>(
@@ -762,7 +768,7 @@ export const api = {
"/api/providers/cursor-cli/refresh",
{ method: "POST" },
),
- checkUpdates: (current: string) =>
+ checkUpdates: (current: string, opts: { refresh?: boolean } = {}) =>
json<{
status: "available" | "up-to-date" | "unavailable";
current: string;
@@ -773,7 +779,8 @@ export const api = {
asset?: { name: string; url: string };
checksums?: { name: string; url: string };
message?: string;
- }>(`/api/updates?current=${encodeURIComponent(current)}`),
+ checkedAt?: string;
+ }>(`/api/updates?current=${encodeURIComponent(current)}${opts.refresh ? "&refresh=1" : ""}`),
downloadUpdate: (
asset: { name: string; url: string },
checksums: { name: string; url: string },
@@ -782,6 +789,42 @@ export const api = {
method: "POST",
body: JSON.stringify({ asset, checksums }),
}),
+ backup: (body: {
+ passphrase: string;
+ out?: string;
+ includeDisks?: boolean;
+ appVersion?: string;
+ }) =>
+ json<{
+ path: string;
+ sizeBytes: number;
+ manifest: { files: number; bytes: number; includeDisks: boolean; skipped: string[] };
+ }>("/api/backup", { method: "POST", body: JSON.stringify(body) }),
+ inspectBackup: (path: string, passphrase: string) =>
+ json<{
+ createdAt: string;
+ appVersion?: string;
+ platform: string;
+ dataDir: string;
+ files: number;
+ bytes: number;
+ includeDisks: boolean;
+ }>("/api/backup/inspect", { method: "POST", body: JSON.stringify({ path, passphrase }) }),
+ restoreStatus: () => json<{ pending: { stagedAt: string; from: string } | null }>("/api/restore"),
+ stageRestore: (path: string, passphrase: string) =>
+ json<{ files: number; manifest: { createdAt: string }; restartRequired: true }>(
+ "/api/restore",
+ { method: "POST", body: JSON.stringify({ path, passphrase }) },
+ ),
+ cancelRestore: () => json<{ ok: true }>("/api/restore", { method: "DELETE" }),
+ benchmarkCases: () =>
+ json<{ cases: Array<{ id: string; title: string; prompt: string }> }>("/api/benchmarks/cases"),
+ benchmarkRuns: () => json<{ runs: BenchmarkRunView[] }>("/api/benchmarks"),
+ benchmarkRun: (id: string) => json(`/api/benchmarks/${encodeURIComponent(id)}`),
+ startBenchmark: (body: { targets: BenchmarkTarget[]; caseIds?: string[]; repeat?: number }) =>
+ json("/api/benchmarks", { method: "POST", body: JSON.stringify(body) }),
+ cancelBenchmark: (id: string) =>
+ json(`/api/benchmarks/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
openUpdate: (path: string) =>
json<{ ok: true }>("/api/updates/open", {
method: "POST",
@@ -831,9 +874,11 @@ export const api = {
if (path) query.set("path", path);
if (depth != null) query.set("depth", String(depth));
const qs = query.toString();
- return json<{ entries: WorkspaceEntry[]; changes?: WorkspaceFileChange[] }>(
- `/api/sessions/${encodeURIComponent(id)}/workspace${qs ? `?${qs}` : ""}`,
- );
+ return json<{
+ entries: WorkspaceEntry[];
+ changes?: WorkspaceFileChange[];
+ truncated?: boolean;
+ }>(`/api/sessions/${encodeURIComponent(id)}/workspace${qs ? `?${qs}` : ""}`);
},
workspaceFile: (id: string, path: string) =>
json<{ path: string; content: string; mtime: string; baseline?: string }>(
@@ -1442,6 +1487,7 @@ export const api = {
json<{ accounts: ForgeImportAccount[] }>("/api/repos/import", { method: "POST" }),
repo: (id: string) => json(`/api/repos/${id}`),
repoBranches: (id: string) => json(`/api/repos/${id}/branches`),
+ repoAbout: (id: string) => json(`/api/repos/${id}/about`),
patchRepo: (id: string, input: PatchLinkedRepoInput) =>
json(`/api/repos/${id}`, { method: "PATCH", body: JSON.stringify(input) }),
deleteRepo: async (id: string) => {
diff --git a/apps/desktop/src/lib/appUpdate.test.ts b/apps/desktop/src/lib/appUpdate.test.ts
new file mode 100644
index 0000000..9ace43e
--- /dev/null
+++ b/apps/desktop/src/lib/appUpdate.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ installAppUpdate,
+ installOutcomeMessage,
+ shouldAnnounceUpdate,
+ type AppUpdateInfo,
+} from "./appUpdate";
+
+const available: AppUpdateInfo = {
+ status: "available",
+ current: "0.20.0",
+ latest: "v0.21.0",
+ asset: { name: "Locus-0.21.0.AppImage", url: "https://example/Locus-0.21.0.AppImage" },
+ checksums: { name: "SHA256SUMS-linux", url: "https://example/SHA256SUMS-linux" },
+};
+
+describe("shouldAnnounceUpdate", () => {
+ it("announces each new version once", () => {
+ expect(shouldAnnounceUpdate(available, null)).toBe(true);
+ expect(shouldAnnounceUpdate(available, "0.21.0")).toBe(false);
+ expect(shouldAnnounceUpdate(available, "v0.21.0")).toBe(false);
+ expect(shouldAnnounceUpdate(available, "0.20.5")).toBe(true);
+ });
+
+ it("stays quiet when up to date or unavailable", () => {
+ expect(shouldAnnounceUpdate({ status: "up-to-date", latest: "v0.20.0" }, null)).toBe(false);
+ expect(shouldAnnounceUpdate({ status: "unavailable" }, null)).toBe(false);
+ });
+});
+
+describe("installAppUpdate", () => {
+ it("verifies, then hands the file to the desktop installer", async () => {
+ const download = vi
+ .fn()
+ .mockResolvedValue({ path: "/h/.locus/updates/x", sha256: "a", verified: true });
+ const installUpdate = vi.fn().mockResolvedValue({ ok: true, action: "relaunching" });
+ const outcome = await installAppUpdate(available, {
+ download,
+ open: vi.fn(),
+ bridge: {
+ setCloseGuard: () => undefined,
+ onCloseRequested: () => () => undefined,
+ ackClosePrompt: () => undefined,
+ confirmClose: () => undefined,
+ installUpdate,
+ },
+ });
+ expect(download).toHaveBeenCalledWith(available.asset, available.checksums);
+ expect(installUpdate).toHaveBeenCalledWith("/h/.locus/updates/x");
+ expect(outcome).toEqual({ action: "relaunching", path: "/h/.locus/updates/x" });
+ expect(installOutcomeMessage(outcome, "0.21.0")).toMatch(/restart/);
+ });
+
+ it("opens the verified file in a browser build without the bridge", async () => {
+ const open = vi.fn().mockResolvedValue({ ok: true });
+ const outcome = await installAppUpdate(available, {
+ download: vi.fn().mockResolvedValue({ path: "/p", sha256: "a", verified: true }),
+ open,
+ bridge: undefined,
+ });
+ expect(open).toHaveBeenCalledWith("/p");
+ expect(outcome.action).toBe("opened");
+ });
+
+ it("surfaces a failed install", async () => {
+ await expect(
+ installAppUpdate(available, {
+ download: vi.fn().mockResolvedValue({ path: "/p", sha256: "a", verified: true }),
+ bridge: {
+ setCloseGuard: () => undefined,
+ onCloseRequested: () => () => undefined,
+ ackClosePrompt: () => undefined,
+ confirmClose: () => undefined,
+ installUpdate: vi.fn().mockResolvedValue({ ok: false, error: "disk full" }),
+ },
+ }),
+ ).rejects.toThrow("disk full");
+ });
+
+ it("falls back to the download link when there is no SHA256SUMS", async () => {
+ const outcome = await installAppUpdate({ ...available, checksums: undefined });
+ expect(outcome).toEqual({ action: "download-only", url: available.asset!.url });
+ });
+});
diff --git a/apps/desktop/src/lib/appUpdate.ts b/apps/desktop/src/lib/appUpdate.ts
new file mode 100644
index 0000000..361f234
--- /dev/null
+++ b/apps/desktop/src/lib/appUpdate.ts
@@ -0,0 +1,135 @@
+import { useEffect } from "react";
+import { parseCheckForUpdates } from "@locus/shared";
+import { api } from "./api";
+import { APP_VERSION } from "./appInfo";
+import { desktopBridge, type LocusDesktopBridge } from "./closeGuard";
+import { showNotice } from "./notice";
+
+export type AppUpdateInfo = Awaited>;
+
+/** localStorage key holding the last version we announced, so each release prompts once. */
+export const UPDATE_ANNOUNCED_KEY = "locus-update-announced";
+const FIRST_CHECK_DELAY_MS = 15_000;
+const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
+
+export function updateVersionLabel(update: Pick): string {
+ return (update.latest ?? "").replace(/^v/i, "");
+}
+
+export function shouldAnnounceUpdate(
+ update: Pick,
+ lastAnnounced: string | null | undefined,
+): boolean {
+ if (update.status !== "available" || !update.latest) return false;
+ return updateVersionLabel(update) !== (lastAnnounced ?? "").replace(/^v/i, "");
+}
+
+export type InstallOutcome =
+ | { action: "relaunching" | "installer" | "opened"; path: string }
+ | { action: "download-only"; url: string };
+
+/**
+ * Download the release asset, check it against SHA256SUMS, then hand it to the desktop
+ * shell: AppImage builds swap in place and relaunch, Windows runs the installer, others open
+ * the file. Without a verified download path we fall back to the release asset URL.
+ */
+export async function installAppUpdate(
+ update: AppUpdateInfo,
+ deps: {
+ download?: typeof api.downloadUpdate;
+ open?: typeof api.openUpdate;
+ bridge?: LocusDesktopBridge;
+ } = {},
+): Promise {
+ if (!update.asset) throw new Error("This release has no installer for this platform.");
+ if (!update.checksums) return { action: "download-only", url: update.asset.url };
+ const download = deps.download ?? api.downloadUpdate;
+ const open = deps.open ?? api.openUpdate;
+ const bridge = deps.bridge ?? desktopBridge();
+ const verified = await download(update.asset, update.checksums);
+ if (bridge?.installUpdate) {
+ const result = await bridge.installUpdate(verified.path);
+ if (!result.ok) throw new Error(result.error || "Could not install the update.");
+ return { action: result.action ?? "opened", path: verified.path };
+ }
+ await open(verified.path);
+ return { action: "opened", path: verified.path };
+}
+
+export function installOutcomeMessage(outcome: InstallOutcome, version: string): string {
+ if (outcome.action === "relaunching") return `Installing Locus ${version}. Locus will restart.`;
+ if (outcome.action === "installer") return `Running the Locus ${version} installer.`;
+ if (outcome.action === "download-only") return `Download Locus ${version} from GitHub releases.`;
+ return `Opened the verified Locus ${version} installer.`;
+}
+
+function readAnnounced(): string | null {
+ try {
+ return globalThis.localStorage?.getItem(UPDATE_ANNOUNCED_KEY) ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function writeAnnounced(version: string): void {
+ try {
+ globalThis.localStorage?.setItem(UPDATE_ANNOUNCED_KEY, version);
+ } catch {
+ /* private mode */
+ }
+}
+
+export function announceUpdate(update: AppUpdateInfo): void {
+ const version = updateVersionLabel(update);
+ writeAnnounced(version);
+ showNotice(`Locus ${version} is available.`, {
+ actionLabel: update.asset ? "Install" : "View release",
+ durationMs: 30_000,
+ onAction: () => {
+ if (!update.asset) {
+ if (update.htmlUrl) window.open(update.htmlUrl, "_blank", "noreferrer");
+ return;
+ }
+ showNotice(`Downloading Locus ${version}…`, { durationMs: 60_000 });
+ void installAppUpdate(update)
+ .then((outcome) => {
+ if (outcome.action === "download-only") window.open(outcome.url, "_blank", "noreferrer");
+ showNotice(installOutcomeMessage(outcome, version), { durationMs: 10_000 });
+ })
+ .catch((err: unknown) => {
+ showNotice(err instanceof Error ? err.message : "Update failed", { durationMs: 10_000 });
+ });
+ },
+ });
+}
+
+/**
+ * Check GitHub Releases shortly after launch and every six hours while Locus is open.
+ * Off when Settings → About → Check automatically is off, or on a paired phone.
+ */
+export function useBackgroundUpdateCheck(opts: {
+ enabled: boolean;
+ checkForUpdates: unknown;
+}): void {
+ const on = opts.enabled && parseCheckForUpdates(opts.checkForUpdates);
+ useEffect(() => {
+ if (!on) return;
+ let cancelled = false;
+ const run = () => {
+ void api
+ .checkUpdates(APP_VERSION)
+ .then((update) => {
+ if (cancelled || !shouldAnnounceUpdate(update, readAnnounced())) return;
+ announceUpdate(update);
+ })
+ .catch(() => undefined);
+ };
+ const first = setTimeout(run, FIRST_CHECK_DELAY_MS);
+ const every = setInterval(run, CHECK_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ clearTimeout(first);
+ clearInterval(every);
+ };
+ }, [on]);
+}
diff --git a/apps/desktop/src/lib/automations.ts b/apps/desktop/src/lib/automations.ts
index 0267093..1452814 100644
--- a/apps/desktop/src/lib/automations.ts
+++ b/apps/desktop/src/lib/automations.ts
@@ -341,6 +341,8 @@ export function runOutcomeLabel(
}
export const DOCKER_DOWN_MESSAGE = "Docker isn't running — switch to Host or start Docker";
+/** Agent threads: the runtime is fixed, so no "switch to Host". */
+export const DOCKER_DOWN_AGENT_MESSAGE = "Docker isn't running — start Docker, then Start";
export type PlainJobError = {
/** One plain sentence for the run row / notice. */
diff --git a/apps/desktop/src/lib/closeGuard.ts b/apps/desktop/src/lib/closeGuard.ts
index 8b6ccb2..5380e73 100644
--- a/apps/desktop/src/lib/closeGuard.ts
+++ b/apps/desktop/src/lib/closeGuard.ts
@@ -11,6 +11,30 @@ export type LocusDesktopBridge = {
onCloseRequested: (callback: () => void) => () => void;
ackClosePrompt: () => void;
confirmClose: () => void;
+ /** Present in desktop builds with update install support. */
+ installUpdate?: (
+ path: string,
+ ) => Promise<{ ok: boolean; action?: "relaunching" | "installer" | "opened"; error?: string }>;
+ /** Quit and relaunch, stopping the control plane even when Keep running is on. */
+ restartApp?: () => Promise<{ ok: boolean }>;
+ /** Tray menu + dock/taskbar badge counts. */
+ setAmbientStatus?: (status: { running: number; needsYou: number }) => void;
+ /** System notification routed back through `onAmbientAction`. */
+ notify?: (request: {
+ title: string;
+ body: string;
+ sessionId?: string;
+ callId?: string;
+ approvable?: boolean;
+ }) => void;
+ onAmbientAction?: (callback: (payload: AmbientAction) => void) => () => void;
+};
+
+/** Tray menu or notification click, delivered from the desktop shell. */
+export type AmbientAction = {
+ action: "open" | "inbox" | "approve" | "deny";
+ sessionId?: string;
+ callId?: string;
};
declare global {
diff --git a/apps/desktop/src/lib/firstTaskSuggest.test.ts b/apps/desktop/src/lib/firstTaskSuggest.test.ts
new file mode 100644
index 0000000..38d0716
--- /dev/null
+++ b/apps/desktop/src/lib/firstTaskSuggest.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { FIRST_TASKS } from "@locus/shared";
+import { recentHostFolders, suggestFirstTasks } from "./firstTaskSuggest";
+
+describe("suggestFirstTasks", () => {
+ it("prefers the most recently active linked repo", () => {
+ const tasks = suggestFirstTasks({
+ repos: [
+ { id: "r1", name: "old", lastFetchAt: "2026-01-01T00:00:00.000Z" },
+ { id: "r2", name: "webapp", lastPushedAt: "2026-11-01T00:00:00.000Z" },
+ ],
+ hostFolders: ["/home/me/notes"],
+ });
+ expect(tasks.map((t) => t.title)).toEqual(["Explain webapp", "Fix a failing test in webapp"]);
+ expect(tasks[0]!.target).toEqual({ kind: "repo", repoId: "r2", name: "webapp" });
+ expect(tasks[0]!.prompt).toContain("read-only");
+ expect(tasks[1]!.prompt).toContain("Do not commit or push");
+ });
+
+ it("falls back to a folder used on Host", () => {
+ const tasks = suggestFirstTasks({ repos: [], hostFolders: ["/home/me/api/"] });
+ expect(tasks[0]!.title).toBe("Explain api");
+ expect(tasks[0]!.target).toEqual({ kind: "host", path: "/home/me/api/", name: "api" });
+ });
+
+ it("keeps the generic samples when there is nothing to work on", () => {
+ const tasks = suggestFirstTasks({ repos: [], hostFolders: [] });
+ expect(tasks.map((t) => t.id)).toEqual(FIRST_TASKS.map((t) => t.id));
+ expect(tasks.every((t) => t.target.kind === "private")).toBe(true);
+ });
+});
+
+describe("recentHostFolders", () => {
+ it("orders Host folders newest first, last-used first, without duplicates", () => {
+ const folders = recentHostFolders(
+ [
+ { runtime: "host", hostDir: "/a", updatedAt: "2026-01-01" },
+ { runtime: "host", hostDir: "/b", updatedAt: "2026-03-01" },
+ { runtime: "docker", hostDir: "/c", updatedAt: "2026-04-01" },
+ { runtime: "host", hostDir: "/d", updatedAt: "2026-05-01", archived: true },
+ ],
+ "/a",
+ );
+ expect(folders).toEqual(["/a", "/b"]);
+ });
+});
diff --git a/apps/desktop/src/lib/firstTaskSuggest.ts b/apps/desktop/src/lib/firstTaskSuggest.ts
new file mode 100644
index 0000000..c1f233f
--- /dev/null
+++ b/apps/desktop/src/lib/firstTaskSuggest.ts
@@ -0,0 +1,107 @@
+import {
+ FIRST_TASKS,
+ sessionRuntime,
+ type AgentSession,
+ type FirstTask,
+ type LinkedRepo,
+} from "@locus/shared";
+
+/** Where a suggested first task should run. */
+export type FirstTaskTarget =
+ | { kind: "repo"; repoId: string; name: string }
+ | { kind: "host"; path: string; name: string }
+ | { kind: "private" };
+
+export type SuggestedFirstTask = FirstTask & { target: FirstTaskTarget };
+
+function folderName(path: string): string {
+ const parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
+ return parts[parts.length - 1] || path;
+}
+
+function explainPrompt(where: string): string {
+ return [
+ `Explain how ${where} is put together. Read the README, the build/package files and the main entry points.`,
+ "Reply with: what it does, the main modules and how they connect, how to build and test it, and three things a new contributor should know.",
+ "This is read-only: do not edit, commit or push anything.",
+ ].join(" ");
+}
+
+function fixTestPrompt(where: string): string {
+ return [
+ `In ${where}, find how the tests are run and run them.`,
+ "If one fails, find the cause and make the smallest fix that makes it pass, then run the tests again.",
+ "If everything passes, pick one small, clearly-scoped improvement (a missing test or an obvious bug) and do that instead.",
+ "Do not commit or push. Summarise what you changed and why.",
+ ].join(" ");
+}
+
+/** Most recently used Host folders, newest first, without duplicates. */
+export function recentHostFolders(
+ sessions: readonly Pick[],
+ lastHostDir?: string,
+): string[] {
+ const rows = sessions
+ .filter((row) => !row.archived && sessionRuntime(row) === "host" && row.hostDir?.trim())
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
+ .map((row) => row.hostDir!.trim());
+ const out = lastHostDir?.trim() ? [lastHostDir.trim(), ...rows] : rows;
+ return [...new Set(out)];
+}
+
+/**
+ * First tasks built from what the operator already has: a linked repo, else a
+ * folder they used on Host, else the generic private-folder samples.
+ * Real work on real code shows what an agent can do; hello.txt does not.
+ */
+export function suggestFirstTasks(input: {
+ repos: readonly Pick[];
+ hostFolders: readonly string[];
+}): SuggestedFirstTask[] {
+ const repo = [...input.repos].sort((a, b) => {
+ const at = a.lastPushedAt ?? a.lastFetchAt ?? "";
+ const bt = b.lastPushedAt ?? b.lastFetchAt ?? "";
+ return at < bt ? 1 : at > bt ? -1 : 0;
+ })[0];
+ if (repo) {
+ const target: FirstTaskTarget = { kind: "repo", repoId: repo.id, name: repo.name };
+ return [
+ {
+ id: `repo-explain:${repo.id}`,
+ title: `Explain ${repo.name}`,
+ blurb: "Read-only tour of the repo: modules, build, tests.",
+ prompt: explainPrompt(`the ${repo.name} repository`),
+ target,
+ },
+ {
+ id: `repo-fix:${repo.id}`,
+ title: `Fix a failing test in ${repo.name}`,
+ blurb: "Runs the tests and fixes one failure. Nothing is pushed.",
+ prompt: fixTestPrompt(`the ${repo.name} repository`),
+ target,
+ },
+ ];
+ }
+ const folder = input.hostFolders[0];
+ if (folder) {
+ const name = folderName(folder);
+ const target: FirstTaskTarget = { kind: "host", path: folder, name };
+ return [
+ {
+ id: `host-explain:${folder}`,
+ title: `Explain ${name}`,
+ blurb: `Read-only tour of ${folder}.`,
+ prompt: explainPrompt(`the project in /workspace (${name})`),
+ target,
+ },
+ {
+ id: `host-fix:${folder}`,
+ title: `Fix a failing test in ${name}`,
+ blurb: "Runs the tests and fixes one failure. Nothing is pushed.",
+ prompt: fixTestPrompt(`the project in /workspace (${name})`),
+ target,
+ },
+ ];
+ }
+ return FIRST_TASKS.map((task) => ({ ...task, target: { kind: "private" } }));
+}
diff --git a/apps/desktop/src/lib/hostAgent.ts b/apps/desktop/src/lib/hostAgent.ts
new file mode 100644
index 0000000..a2ad027
--- /dev/null
+++ b/apps/desktop/src/lib/hostAgent.ts
@@ -0,0 +1,37 @@
+import type { AgentSession, LinkedRepo, LocusConfig } from "@locus/shared";
+import { api } from "@/lib/api";
+import { loadLastAgent, resolveAgentDefaults, saveLastAgent } from "@/lib/lastAgent";
+
+/**
+ * Start a Host coding agent on a linked repo (it clones into a private folder on
+ * this machine). Used when Docker is down and the person picks "Use Host instead".
+ */
+export async function startHostRepoAgent(input: {
+ config: LocusConfig;
+ repo?: LinkedRepo;
+ title?: string;
+ prompt?: string;
+ providerId?: string;
+ model?: string;
+}): Promise {
+ const defaults = resolveAgentDefaults(input.config, "ubuntu", loadLastAgent());
+ const providerId = input.providerId || defaults.providerId;
+ const model = input.model || defaults.model;
+ const session = await api.createSession({
+ ...(input.title ? { title: input.title } : {}),
+ os: "ubuntu",
+ templateId:
+ defaults.templateId ||
+ input.config.templates.find((row) => row.os === "ubuntu")?.id ||
+ "host",
+ runtime: "host",
+ providerId,
+ model,
+ startVm: false,
+ vision: "off",
+ ...(input.prompt?.trim() ? { prompt: input.prompt.trim() } : {}),
+ ...(input.repo ? { repoId: input.repo.id, repo: input.repo.checkout, branchLock: true } : {}),
+ });
+ saveLastAgent({ os: "ubuntu", runtime: "host", providerId, model });
+ return session;
+}
diff --git a/apps/desktop/src/lib/inboxDecision.test.ts b/apps/desktop/src/lib/inboxDecision.test.ts
new file mode 100644
index 0000000..ae033a9
--- /dev/null
+++ b/apps/desktop/src/lib/inboxDecision.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from "vitest";
+import type { AgentSession, ChatMessage, ToolCall } from "@locus/shared";
+import {
+ approvalPreview,
+ approvalRisk,
+ pendingActionLabel,
+ sameToolRows,
+ stepInboxFocus,
+} from "./inboxDecision";
+import { buildInboxRows } from "./inboxList";
+
+const at = "2026-11-12T10:00:00.000Z";
+
+function pending(id: string, name: string, args: Record): ToolCall {
+ return { id, name, arguments: args, startedAt: at, approval: "needed" };
+}
+
+function session(
+ id: string,
+ messages: ChatMessage[],
+ extra: Partial = {},
+): AgentSession {
+ return {
+ id,
+ title: `Chat ${id}`,
+ status: "idle",
+ messages,
+ createdAt: at,
+ updatedAt: at,
+ ...extra,
+ } as AgentSession;
+}
+
+const execSession = session(
+ "s1",
+ [
+ { id: "u", role: "user", content: "run the tests", createdAt: at },
+ {
+ id: "a",
+ role: "assistant",
+ content: "I'll run the unit tests to confirm the fix.",
+ createdAt: at,
+ toolCalls: [pending("c1", "exec", { command: "npm test -- --run" })],
+ },
+ ] as ChatMessage[],
+ { runtime: "host", hostDir: "/home/me/app" },
+);
+
+describe("approvalPreview", () => {
+ it("shows the command, the agent's reason and where it runs", () => {
+ const preview = approvalPreview(execSession, "c1");
+ expect(preview).toBeDefined();
+ expect(preview!.tool).toBe("exec");
+ expect(preview!.command).toBe("npm test -- --run");
+ expect(preview!.label.startsWith("Ran")).toBe(false);
+ expect(preview!.why).toBe("I'll run the unit tests to confirm the fix.");
+ expect(preview!.where).toBe("This machine · /home/me/app");
+ expect(preview!.risk).toBe("medium");
+ expect(preview!.batchable).toBe(true);
+ });
+
+ it("is undefined once the call is decided", () => {
+ const done = session("s2", [
+ {
+ id: "a",
+ role: "assistant",
+ content: "",
+ createdAt: at,
+ toolCalls: [{ ...pending("c1", "exec", { command: "ls" }), finishedAt: at }],
+ },
+ ] as ChatMessage[]);
+ expect(approvalPreview(done, "c1")).toBeUndefined();
+ });
+
+ it("lists files for a patch", () => {
+ const patch = ["--- a/src/x.ts", "+++ b/src/x.ts", "@@ -1 +1 @@", "-a", "+b"].join("\n");
+ const s = session(
+ "s3",
+ [
+ {
+ id: "a",
+ role: "assistant",
+ content: "",
+ createdAt: at,
+ toolCalls: [pending("p1", "apply_patch", { patch })],
+ },
+ ] as ChatMessage[],
+ { runtime: "docker" },
+ );
+ const preview = approvalPreview(s, "p1");
+ expect(preview!.files).toEqual(["src/x.ts"]);
+ expect(preview!.where).toBe("Docker container");
+ expect(preview!.risk).toBe("low");
+ });
+});
+
+describe("approvalRisk", () => {
+ it("flags destructive commands and outward actions", () => {
+ expect(approvalRisk("exec", { command: "rm -rf build" }, "docker").risk).toBe("high");
+ expect(approvalRisk("exec", { command: "git push --force origin main" }, "docker").risk).toBe(
+ "high",
+ );
+ expect(approvalRisk("git_pr_merge", {}, "docker").risk).toBe("high");
+ expect(approvalRisk("git_push", { branch: "fix" }, "docker").risk).toBe("medium");
+ expect(approvalRisk("exec", { command: "ls" }, "docker").risk).toBe("low");
+ });
+});
+
+describe("sameToolRows / stepInboxFocus", () => {
+ const second = session("s4", [
+ {
+ id: "a",
+ role: "assistant",
+ content: "",
+ createdAt: at,
+ toolCalls: [pending("c2", "exec", { command: "npm run lint" })],
+ },
+ ] as ChatMessage[]);
+ const pushSession = session("s5", [
+ {
+ id: "a",
+ role: "assistant",
+ content: "",
+ createdAt: at,
+ toolCalls: [pending("c3", "git_push", { branch: "x" })],
+ },
+ ] as ChatMessage[]);
+ const sessions = [execSession, second, pushSession];
+ const rows = buildInboxRows({ sessions });
+
+ it("groups pending approvals of the same tool across chats", () => {
+ const same = sameToolRows(rows, sessions, rows[0]!);
+ expect(same.map((row) => row.callId)).toEqual(["c1", "c2"]);
+ });
+
+ it("steps focus within range", () => {
+ expect(stepInboxFocus(rows, null, 1)).toBe(rows[1]!.key);
+ expect(stepInboxFocus(rows, rows[2]!.key, 1)).toBe(rows[2]!.key);
+ expect(stepInboxFocus(rows, rows[0]!.key, -1)).toBe(rows[0]!.key);
+ expect(stepInboxFocus([], null, 1)).toBeNull();
+ });
+});
+
+describe("pendingActionLabel", () => {
+ it("asks in the present tense", () => {
+ expect(pendingActionLabel("Ran npm test")).toBe("Run npm test");
+ expect(pendingActionLabel("Edited app.ts")).toBe("Edit app.ts");
+ expect(pendingActionLabel("Pushed")).toBe("Pushed");
+ });
+});
diff --git a/apps/desktop/src/lib/inboxDecision.ts b/apps/desktop/src/lib/inboxDecision.ts
new file mode 100644
index 0000000..7511994
--- /dev/null
+++ b/apps/desktop/src/lib/inboxDecision.ts
@@ -0,0 +1,203 @@
+import {
+ canonicalToolCall,
+ isAskUserToolName,
+ operatorVisibleText,
+ patchFilesFromTool,
+ sessionRuntime,
+ toolActivityLabel,
+ type AgentSession,
+ type ToolCall,
+} from "@locus/shared";
+import type { InboxRow } from "./inboxList";
+
+export type ApprovalRisk = "low" | "medium" | "high";
+
+/** Everything the Inbox needs to approve or deny a tool call without opening the chat. */
+export type ApprovalPreview = {
+ call: ToolCall;
+ /** Canonical tool name (`exec`, `apply_patch`, `git_push`, …). */
+ tool: string;
+ /** Plain sentence: "Ran npm test", "Edited src/app.ts". */
+ label: string;
+ command?: string;
+ path?: string;
+ branch?: string;
+ /** Files an edit would touch. */
+ files: string[];
+ /** Arguments as pretty JSON, clipped, for anything the fields above miss. */
+ argsText: string;
+ /** The agent's last words before it asked, so the operator sees why. */
+ why?: string;
+ /** Where it runs: "Host · /home/me/repo", "Docker container", … */
+ where: string;
+ risk: ApprovalRisk;
+ riskReason: string;
+ /** Approve / Deny apply (ask_user needs an answer in the chat instead). */
+ batchable: boolean;
+};
+
+const ARGS_MAX = 1200;
+const WHY_MAX = 280;
+
+function str(args: Record, key: string): string | undefined {
+ const value = args[key];
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function clip(text: string, max: number): string {
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
+}
+
+export function findPendingCall(
+ session: AgentSession | undefined,
+ callId: string | undefined,
+): { call: ToolCall; index: number } | undefined {
+ if (!session || !callId) return undefined;
+ for (let i = 0; i < session.messages.length; i++) {
+ for (const call of session.messages[i]!.toolCalls ?? []) {
+ if (call.id === callId && call.approval === "needed" && !call.finishedAt) {
+ return { call, index: i };
+ }
+ }
+ }
+ return undefined;
+}
+
+function lastAgentWords(session: AgentSession, index: number): string | undefined {
+ for (let i = index; i >= 0; i--) {
+ const message = session.messages[i]!;
+ if (message.role === "user") return undefined;
+ if (message.role !== "assistant") continue;
+ const text = operatorVisibleText(message.role, message.content).trim();
+ if (text) return clip(text.replace(/\s+/g, " "), WHY_MAX);
+ }
+ return undefined;
+}
+
+function whereLabel(session: AgentSession): string {
+ const runtime = sessionRuntime(session);
+ const folder = session.hostDir?.trim();
+ if (runtime === "host") return folder ? `This machine · ${folder}` : "This machine";
+ if (runtime === "docker") return "Docker container";
+ return "Virtual machine";
+}
+
+const DESTRUCTIVE_COMMAND =
+ /\b(rm\s+-[a-z]*[rf]|sudo|mkfs|dd\s+if=|chmod\s+-R|chown\s+-R|git\s+push\s+.*--force|git\s+reset\s+--hard|git\s+clean\s+-[a-z]*f|curl\b.*\|\s*(?:ba|z)?sh|wget\b.*\|\s*(?:ba|z)?sh|shutdown|reboot|kill\s+-9|drop\s+(?:table|database))\b/i;
+
+const OUTWARD_TOOLS = new Set([
+ "git_push",
+ "git_pr",
+ "git_pr_merge",
+ "git_pr_comment",
+ "git_pr_review",
+ "slack_post",
+ "brain_brief",
+]);
+
+/** A hint, not a policy: what could go wrong if this is approved. */
+export function approvalRisk(
+ tool: string,
+ args: Record,
+ runtime: ReturnType,
+): { risk: ApprovalRisk; reason: string } {
+ const command = str(args, "command") ?? "";
+ if (tool === "git_pr_merge") return { risk: "high", reason: "Merges a pull request" };
+ if ((tool === "exec" || tool === "powershell") && DESTRUCTIVE_COMMAND.test(command)) {
+ return { risk: "high", reason: "Command can delete or overwrite data" };
+ }
+ if (OUTWARD_TOOLS.has(tool)) {
+ return { risk: "medium", reason: "Visible to other people once it runs" };
+ }
+ if (runtime === "host" && (tool === "exec" || tool === "powershell")) {
+ return { risk: "medium", reason: "Runs on this machine, outside a sandbox" };
+ }
+ if (runtime === "host" && (tool === "file_write" || tool === "apply_patch")) {
+ return { risk: "medium", reason: "Changes files on this machine" };
+ }
+ if (tool.startsWith("mcp_")) return { risk: "medium", reason: "Calls an external MCP tool" };
+ return {
+ risk: "low",
+ reason: runtime === "host" ? "Read-only or contained" : "Contained in the agent's guest",
+ };
+}
+
+const PAST_TO_ASK: Array<[RegExp, string]> = [
+ [/^Ran\b/, "Run"],
+ [/^Wrote\b/, "Write"],
+ [/^Edited\b/, "Edit"],
+ [/^Read\b/, "Read"],
+ [/^Listed\b/, "List"],
+ [/^Grepped\b/, "Search"],
+ [/^Searched\b/, "Search"],
+ [/^Built\b/, "Build"],
+ [/^Updated\b/, "Update"],
+];
+
+/** Activity labels are written for finished calls ("Ran npm test"); ask in the present. */
+export function pendingActionLabel(label: string): string {
+ for (const [pattern, verb] of PAST_TO_ASK) {
+ if (pattern.test(label)) return label.replace(pattern, verb);
+ }
+ return label;
+}
+
+export function approvalPreview(
+ session: AgentSession | undefined,
+ callId: string | undefined,
+): ApprovalPreview | undefined {
+ const found = findPendingCall(session, callId);
+ if (!session || !found) return undefined;
+ const { call, index } = found;
+ const canon = canonicalToolCall(call.name, call.arguments);
+ const args = canon.arguments;
+ const files = patchFilesFromTool(canon.name, args).map((file) => file.path);
+ const runtime = sessionRuntime(session);
+ const { risk, reason } = approvalRisk(canon.name, args, runtime);
+ let argsText: string;
+ try {
+ argsText = clip(JSON.stringify(call.arguments, null, 2), ARGS_MAX);
+ } catch {
+ argsText = "";
+ }
+ return {
+ call,
+ tool: canon.name,
+ label: pendingActionLabel(toolActivityLabel(call.name, call.arguments)),
+ command: str(args, "command"),
+ path: str(args, "path"),
+ branch: str(args, "branch"),
+ files,
+ argsText: argsText === "{}" ? "" : argsText,
+ why: lastAgentWords(session, index),
+ where: whereLabel(session),
+ risk,
+ riskReason: reason,
+ batchable: !isAskUserToolName(call.name),
+ };
+}
+
+/** Other pending approvals of the same tool, for "Approve all". Includes `row` itself. */
+export function sameToolRows(
+ rows: InboxRow[],
+ sessions: AgentSession[],
+ row: InboxRow,
+): InboxRow[] {
+ const byId = new Map(sessions.map((session) => [session.id, session]));
+ const name = findPendingCall(byId.get(row.sessionId ?? ""), row.callId)?.call.name;
+ if (!name || isAskUserToolName(name)) return [];
+ return rows.filter((other) => {
+ if (!other.sessionId || !other.callId) return false;
+ const call = findPendingCall(byId.get(other.sessionId), other.callId)?.call;
+ return call?.name === name;
+ });
+}
+
+/** Move the focused Inbox row by `delta`, staying in range. */
+export function stepInboxFocus(rows: InboxRow[], key: string | null, delta: number): string | null {
+ if (!rows.length) return null;
+ const index = rows.findIndex((row) => row.key === key);
+ const from = index < 0 ? 0 : index;
+ const next = Math.min(rows.length - 1, Math.max(0, from + delta));
+ return rows[next]!.key;
+}
diff --git a/apps/desktop/src/lib/notify.ts b/apps/desktop/src/lib/notify.ts
index 82ac96a..4ca2d4b 100644
--- a/apps/desktop/src/lib/notify.ts
+++ b/apps/desktop/src/lib/notify.ts
@@ -44,13 +44,29 @@ export function shouldNotifyNeedsYou(
return shouldNotifyTurn(sessionId, selectedId, hidden, enabled);
}
-export function notifyTurnDone(title: string, body = "Turn finished", enabled = true): void {
+export function notifyTurnDone(
+ title: string,
+ body = "Turn finished",
+ enabled = true,
+ onClick?: () => void,
+): void {
if (!enabled) return;
if (typeof Notification === "undefined") return;
if (Notification.permission === "denied") return;
const show = () => {
try {
- new Notification(title, { body, silent: false });
+ const note = new Notification(title, { body, silent: false });
+ if (onClick) {
+ note.onclick = () => {
+ try {
+ window.focus();
+ } catch {
+ /* focus is best effort */
+ }
+ onClick();
+ note.close();
+ };
+ }
} catch {
/* ignore missing Notification support */
}
diff --git a/apps/desktop/src/lib/palette.test.ts b/apps/desktop/src/lib/palette.test.ts
index 5299edc..7f6d0e7 100644
--- a/apps/desktop/src/lib/palette.test.ts
+++ b/apps/desktop/src/lib/palette.test.ts
@@ -336,6 +336,7 @@ describe("paletteItems", () => {
"providers",
"mcp",
"skills",
+ "benchmark",
"machines",
"workers",
"environments",
diff --git a/apps/desktop/src/lib/settingsNav.test.ts b/apps/desktop/src/lib/settingsNav.test.ts
index ee927f5..06744eb 100644
--- a/apps/desktop/src/lib/settingsNav.test.ts
+++ b/apps/desktop/src/lib/settingsNav.test.ts
@@ -20,6 +20,7 @@ describe("settingsNav", () => {
"Providers",
"MCP",
"Skills",
+ "Benchmark",
"Machines",
"Workers",
"Environments",
@@ -81,6 +82,7 @@ describe("settingsNav", () => {
"providers",
"mcp",
"skills",
+ "benchmark",
"machines",
"workers",
"environments",
diff --git a/apps/desktop/src/lib/settingsNav.ts b/apps/desktop/src/lib/settingsNav.ts
index 9661509..f0856b0 100644
--- a/apps/desktop/src/lib/settingsNav.ts
+++ b/apps/desktop/src/lib/settingsNav.ts
@@ -11,13 +11,14 @@ export type SettingsTab =
| "integrations"
| "mcp"
| "skills"
+ | "benchmark"
| "phone"
| "chrome"
| "advanced"
| "about";
export const SETTINGS_NAV_GROUPS: { label: string; items: SettingsTab[] }[] = [
- { label: "Models", items: ["providers", "mcp", "skills"] },
+ { label: "Models", items: ["providers", "mcp", "skills", "benchmark"] },
{ label: "Workspace", items: ["machines", "workers", "environments", "git", "integrations"] },
{
label: "System",
diff --git a/apps/desktop/src/lib/turnOutcome.test.ts b/apps/desktop/src/lib/turnOutcome.test.ts
new file mode 100644
index 0000000..bda8366
--- /dev/null
+++ b/apps/desktop/src/lib/turnOutcome.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from "vitest";
+import type { AgentSession, ChatMessage, ToolCall } from "@locus/shared";
+import {
+ isTestCommand,
+ latestTurnOutcome,
+ turnOutcomeHeadline,
+ turnTestsLabel,
+} from "./turnOutcome";
+
+const at = "2026-11-12T10:00:00.000Z";
+
+function call(
+ id: string,
+ name: string,
+ args: Record,
+ extra: Partial = {},
+): ToolCall {
+ return { id, name, arguments: args, startedAt: at, finishedAt: at, result: "ok", ...extra };
+}
+
+function msg(
+ id: string,
+ role: ChatMessage["role"],
+ content = "",
+ toolCalls?: ToolCall[],
+): ChatMessage {
+ return { id, role, content, createdAt: at, toolCalls } as ChatMessage;
+}
+
+const PATCH = [
+ "--- a/src/add.ts",
+ "+++ b/src/add.ts",
+ "@@ -1,1 +1,1 @@",
+ "-export const add = (a, b) => a - b;",
+ "+export const add = (a, b) => a + b;",
+].join("\n");
+
+function session(messages: ChatMessage[], extra: Partial = {}): AgentSession {
+ return {
+ id: "s1",
+ title: "Fix add",
+ status: "idle",
+ messages,
+ createdAt: at,
+ updatedAt: at,
+ ...extra,
+ } as AgentSession;
+}
+
+describe("isTestCommand", () => {
+ it("recognises common test runners", () => {
+ for (const cmd of [
+ "npm test",
+ "npm run test",
+ "pnpm test:unit",
+ "npx vitest run src",
+ "pytest -q",
+ "go test ./...",
+ "cargo test",
+ "./gradlew test",
+ "make check",
+ ]) {
+ expect(isTestCommand(cmd), cmd).toBe(true);
+ }
+ });
+
+ it("ignores non-test commands", () => {
+ for (const cmd of ["ls -la", "npm install", "git status", "cat test.txt"]) {
+ expect(isTestCommand(cmd), cmd).toBe(false);
+ }
+ });
+});
+
+describe("latestTurnOutcome", () => {
+ const turn = [
+ msg("u1", "user", "fix add"),
+ msg("a1", "assistant", "", [
+ call("c1", "apply_patch", { patch: PATCH }),
+ call("c2", "exec", { command: "npm test" }, { result: "1 failed (exit 1)" }),
+ call("c3", "exec", { command: "npm test" }, { result: "all passed (exit 0)" }),
+ call("c4", "exec", { command: "ls" }),
+ ]),
+ msg("a2", "assistant", "Fixed the sign in add()."),
+ ];
+
+ it("summarises files, commands, tests and the undo checkpoint", () => {
+ const outcome = latestTurnOutcome(
+ session(turn, {
+ checkpoints: [{ id: "cp0", messageCount: 0, createdAt: at }],
+ }),
+ );
+ expect(outcome).toBeDefined();
+ expect(outcome!.assistantId).toBe("a2");
+ expect(outcome!.files.map((f) => f.path)).toEqual(["src/add.ts"]);
+ expect(outcome!.patches).toHaveLength(1);
+ expect(outcome!.commands).toBe(3);
+ expect(outcome!.tests).toEqual([
+ { command: "npm test", passed: false },
+ { command: "npm test", passed: true },
+ ]);
+ expect(outcome!.undoCheckpointId).toBe("cp0");
+ expect(turnOutcomeHeadline(outcome!)).toBe("1 file changed · +1 −1");
+ // The re-run after the fix is what counts.
+ expect(turnTestsLabel(outcome!.tests)).toEqual({ text: "Tests passed", ok: true });
+ });
+
+ it("only offers undo for the checkpoint taken right before this turn", () => {
+ const outcome = latestTurnOutcome(
+ session(turn, { checkpoints: [{ id: "old", messageCount: 5, createdAt: at }] }),
+ );
+ expect(outcome!.undoCheckpointId).toBeUndefined();
+ });
+
+ it("stays hidden while the turn runs or waits on approval", () => {
+ expect(latestTurnOutcome(session(turn, { status: "running" }))).toBeUndefined();
+ const waiting = [
+ msg("u1", "user", "push it"),
+ msg("a1", "assistant", "", [
+ { id: "c9", name: "git_push", arguments: {}, startedAt: at, approval: "needed" },
+ ]),
+ ];
+ expect(latestTurnOutcome(session(waiting))).toBeUndefined();
+ });
+
+ it("stays hidden for a turn that only talked", () => {
+ expect(
+ latestTurnOutcome(session([msg("u1", "user", "hi"), msg("a1", "assistant", "Hello!")])),
+ ).toBeUndefined();
+ });
+
+ it("picks up a PR link", () => {
+ const outcome = latestTurnOutcome(
+ session([
+ msg("u1", "user", "open a pr"),
+ msg("a1", "assistant", "", [
+ call("p1", "git_pr", {}, { result: "Opened https://github.com/acme/app/pull/12" }),
+ ]),
+ ]),
+ );
+ expect(outcome?.prUrl).toBe("https://github.com/acme/app/pull/12");
+ });
+});
+
+describe("turnTestsLabel", () => {
+ it("reports failures across distinct commands", () => {
+ expect(
+ turnTestsLabel([
+ { command: "npm test", passed: true },
+ { command: "pytest", passed: false },
+ ]),
+ ).toEqual({ text: "1 of 2 test runs failed", ok: false });
+ expect(turnTestsLabel([])).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/src/lib/turnOutcome.ts b/apps/desktop/src/lib/turnOutcome.ts
new file mode 100644
index 0000000..ed952dd
--- /dev/null
+++ b/apps/desktop/src/lib/turnOutcome.ts
@@ -0,0 +1,158 @@
+import {
+ canonicalToolCall,
+ parseExitCode,
+ patchFilesFromTool,
+ rewindTargets,
+ workspaceChangesFromMessages,
+ type AgentSession,
+ type ChatMessage,
+ type PatchFile,
+ type ToolCall,
+ type WorkspaceFileChange,
+} from "@locus/shared";
+import { prUrlFromToolOutput } from "./markdown";
+
+export type TurnTestRun = {
+ command: string;
+ passed: boolean;
+};
+
+export type TurnOutcome = {
+ /** Last assistant message of the turn; the card renders under it. */
+ assistantId: string;
+ files: WorkspaceFileChange[];
+ /** Parsed patches from this turn's edit calls, for the Change Stack walkthrough. */
+ patches: PatchFile[];
+ commands: number;
+ tests: TurnTestRun[];
+ prUrl?: string;
+ /** Checkpoint taken just before this turn's prompt. Rewinding to it undoes the turn. */
+ undoCheckpointId?: string;
+};
+
+const TEST_COMMAND =
+ /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?::[\w-]+)?|npx\s+(?:vitest|jest|playwright\s+test)|vitest|jest|pytest|go\s+test|cargo\s+test|dotnet\s+test|rspec|mix\s+test|phpunit|(?:\.\/)?gradlew?\s+(?:\S+\s+)*test|mvn\s+(?:\S+\s+)*test|make\s+(?:check|test))\b/i;
+
+/** True when a shell command looks like it runs a test suite. */
+export function isTestCommand(command: string): boolean {
+ return TEST_COMMAND.test(command);
+}
+
+function commandOf(call: ToolCall): string | undefined {
+ const canon = canonicalToolCall(call.name, call.arguments);
+ if (canon.name !== "exec" && canon.name !== "powershell") return undefined;
+ const command = canon.arguments.command;
+ return typeof command === "string" && command.trim() ? command.trim() : undefined;
+}
+
+function callPassed(call: ToolCall): boolean {
+ if (call.error) return false;
+ if (call.approval === "denied") return false;
+ const code = parseExitCode(call.result);
+ return code === undefined || code === 0;
+}
+
+function lastUserIndex(messages: ChatMessage[]): number {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]?.role === "user") return i;
+ }
+ return -1;
+}
+
+/**
+ * What the latest finished turn did: files it changed, commands and tests it ran,
+ * a PR it opened, and the checkpoint that undoes it. Undefined while the turn is
+ * still running, or when the turn only talked (nothing worth a summary card).
+ */
+export function latestTurnOutcome(
+ session: Pick,
+): TurnOutcome | undefined {
+ if (session.status === "running" || session.status === "provisioning") return undefined;
+ const messages = session.messages;
+ const start = lastUserIndex(messages);
+ if (start < 0) return undefined;
+ const turn = messages.slice(start + 1);
+ let assistantId: string | undefined;
+ for (let i = turn.length - 1; i >= 0; i--) {
+ const row = turn[i]!;
+ if (row.role === "assistant" && (row.content.trim() || row.toolCalls?.length)) {
+ assistantId = row.id;
+ break;
+ }
+ }
+ if (!assistantId) return undefined;
+
+ let commands = 0;
+ const tests: TurnTestRun[] = [];
+ const patches: PatchFile[] = [];
+ let prUrl: string | undefined;
+ for (const message of turn) {
+ for (const call of message.toolCalls ?? []) {
+ if (!call.finishedAt && call.result === undefined && !call.error) {
+ // A call still waiting (approval or otherwise) means the turn is not done.
+ return undefined;
+ }
+ const command = commandOf(call);
+ if (command) {
+ commands += 1;
+ if (isTestCommand(command)) tests.push({ command, passed: callPassed(call) });
+ }
+ if (callPassed(call)) {
+ const canon = canonicalToolCall(call.name, call.arguments);
+ if (canon.name === "apply_patch") {
+ patches.push(...patchFilesFromTool("apply_patch", canon.arguments, call.result));
+ }
+ }
+ if (call.name === "git_pr") {
+ prUrl = prUrlFromToolOutput(call.result ?? "") ?? prUrl;
+ }
+ }
+ }
+ const files = workspaceChangesFromMessages(turn);
+ if (!files.length && !tests.length && !prUrl) return undefined;
+
+ const checkpoint = rewindTargets(session)
+ .filter((row) => row.messageCount === start)
+ .at(-1);
+
+ return {
+ assistantId,
+ files,
+ patches,
+ commands,
+ tests,
+ prUrl,
+ undoCheckpointId: checkpoint?.id,
+ };
+}
+
+/** "3 files changed · +40 −12" */
+export function turnOutcomeHeadline(outcome: Pick): string {
+ const n = outcome.files.length;
+ if (!n) return "No file changes";
+ let add = 0;
+ let del = 0;
+ for (const file of outcome.files) {
+ add += file.additions ?? 0;
+ del += file.deletions ?? 0;
+ }
+ const label = n === 1 ? "1 file changed" : `${n} files changed`;
+ return add || del ? `${label} · +${add} −${del}` : label;
+}
+
+/** "Tests passed", "1 of 2 test runs failed", or undefined when no tests ran. */
+export function turnTestsLabel(tests: TurnTestRun[]): { text: string; ok: boolean } | undefined {
+ if (!tests.length) return undefined;
+ // The last run of each command is the one that counts (agents re-run after a fix).
+ const latest = new Map();
+ for (const run of tests) latest.set(run.command, run.passed);
+ const runs = [...latest.values()];
+ const failed = runs.filter((ok) => !ok).length;
+ if (!failed)
+ return {
+ text: runs.length === 1 ? "Tests passed" : `${runs.length} test runs passed`,
+ ok: true,
+ };
+ if (runs.length === 1) return { text: "Tests failed", ok: false };
+ return { text: `${failed} of ${runs.length} test runs failed`, ok: false };
+}
diff --git a/apps/desktop/src/state/store.ts b/apps/desktop/src/state/store.ts
index 7eee166..5443116 100644
--- a/apps/desktop/src/state/store.ts
+++ b/apps/desktop/src/state/store.ts
@@ -360,6 +360,7 @@ export type Action =
dockerReady?: boolean;
setup?: SetupStatus;
}
+ | { type: "dockerHealth"; dockerKind: "mock" | "docker"; dockerReady: boolean }
| { type: "session"; session: AgentSession }
| { type: "sessions"; sessions: AgentSession[] }
| { type: "deleted"; id: string }
@@ -616,6 +617,11 @@ function reduceUiState(state: UiState, action: Action): UiState {
setupOpen: action.setup?.needsWizard ?? state.setupOpen,
};
}
+ case "dockerHealth":
+ if (state.dockerKind === action.dockerKind && state.dockerReady === action.dockerReady) {
+ return state;
+ }
+ return { ...state, dockerKind: action.dockerKind, dockerReady: action.dockerReady };
case "reset":
return {
...initialState,
diff --git a/apps/desktop/src/visual.tsx b/apps/desktop/src/visual.tsx
index cd2002a..80ec5c7 100644
--- a/apps/desktop/src/visual.tsx
+++ b/apps/desktop/src/visual.tsx
@@ -127,7 +127,7 @@ function stubApi(): void {
api.checkUpdates = async () =>
({
status: "unavailable",
- current: "0.20.0",
+ current: "0.21.0",
message: "No GitHub release found yet.",
}) as never;
}
diff --git a/apps/desktop/test/pack.test.ts b/apps/desktop/test/pack.test.ts
index 29ca447..937ae13 100644
--- a/apps/desktop/test/pack.test.ts
+++ b/apps/desktop/test/pack.test.ts
@@ -23,7 +23,7 @@ const pkg = JSON.parse(readFileSync(join(desktopRoot, "package.json"), "utf8"))
linux?: { target?: string[]; executableName?: string; packageName?: string };
deb?: { artifactName?: string; packageName?: string };
rpm?: { artifactName?: string; packageName?: string };
- mac?: { target?: string[] };
+ mac?: { target?: string[]; binaries?: string[] };
win?: { target?: string[] };
extraResources?: Array<{ from?: string; to?: string }>;
files?: string[];
@@ -434,6 +434,23 @@ describe("electron packaging", () => {
}
}, 30_000);
+ it("ships a platform ripgrep beside the bundled server", async () => {
+ expect(pkg.build?.mac?.binaries).toContain("Contents/Resources/server/bin/rg");
+ const { copyRipgrep } = (await import(
+ pathToFileURL(join(desktopRoot, "scripts/bundle-server.mjs")).href
+ )) as { copyRipgrep: (serverDir: string) => Promise };
+ const dir = mkdtempSync(join(tmpdir(), "locus-rg-bundle-"));
+ try {
+ const rg = await copyRipgrep(dir);
+ expect(rg).toBe(join(dir, "bin", process.platform === "win32" ? "rg.exe" : "rg"));
+ const probe = spawnSync(rg, ["--version"], { encoding: "utf8", timeout: 10_000 });
+ expect(probe.status).toBe(0);
+ expect(probe.stdout).toMatch(/^ripgrep \d+/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it("bundles AWS IAM tools without the AWS SDK", async () => {
const serverPkg = JSON.parse(
readFileSync(join(desktopRoot, "../server/package.json"), "utf8"),
diff --git a/apps/server/bench/RESULTS.md b/apps/server/bench/RESULTS.md
new file mode 100644
index 0000000..926e85d
--- /dev/null
+++ b/apps/server/bench/RESULTS.md
@@ -0,0 +1,136 @@
+# Workspace performance on real repositories (2026-11-12, v0.20.0)
+
+Raw numbers: [`results-2026-11-12.json`](results-2026-11-12.json) (before) and [`results-2026-11-12-after.json`](results-2026-11-12-after.json) (after the fixes). Harness: [`repoPerf.ts`](repoPerf.ts), [`editGrepLoop.ts`](editGrepLoop.ts).
+
+## After the fixes
+
+The fixes for the findings below landed on this branch. Same machine, same clones, 3 runs, median ms / worst event-loop stall ms:
+
+| Operation | react | django | vscode | kubernetes |
+|---|---:|---:|---:|---:|
+| Agent grep, next call after one file edit | 3,472 → **32** | – | 18,040 → **91** | – |
+| VM guest listing script | – | – | 31,570 → **~60** | – |
+| Files search without rg (walk fallback) | 3,525 / 2,439 → **80 / 22** | 1,677 / 534 → **88 / 19** | 12,037 / 7,751 → **147 / 53** | 9,516 / 5,998 → **199 / 61** |
+| Agent grep with index, selective literal | 139 / 99 → **33 / 9** | 133 / 133 → **45 / 10** | 414 / 263 → **100 / 25** | 391 / 219 → **126 / 48** |
+| Agent grep with index, no match | 142 / 104 → **34 / 8** | 116 / 61 → **39 / 6** | 414 / 295 → **82 / 15** | 349 / 227 → **96 / 14** |
+| Grep index load (now once per process) | 113 / 102 → **30 / 17** | 82 / 74 → **17 / 13** | 325 / 278 → **87 / 45** | 246 / 207 → **48 / 35** |
+| Grep index on disk | 12.2 → **9.2 MB** | 9.3 → **7.6 MB** | 56.9 → **40.1 MB** | 41.5 → **30.2 MB** |
+| Repo index, warm rebuild | 358 → **125** | 542 → **275** | 1,053 → **374** | 1,135 → **535** |
+| Repo index, rebuild after 50 edits | 386 → **157** | 438 → **296** | 1,066 → **409** | 1,201 → **584** |
+| Per-turn ranked repo map | 12 → **4** | 8 → **4** | 78 → **13** | 64 → **13** |
+| Quick Open, 6 keystrokes | 91 → 91 | 44 → **28** | 253 → **169** | 228 → 208 |
+| Git status, 250 changes | 34 → **23** | 46 → **36** | 85 → **70** | 113 → **97** |
+
+Notes:
+- The grep index is now built in the background, and the grep that triggers the build uses plain ripgrep. Building still takes 3–17 s, but it yields every 16 files, so the worst stall dropped from 389 ms to about 100 ms on vscode.
+- After this run, indexed grep was changed to skip `git status` when the index can't narrow the pattern. A spot check put a common word on kubernetes at 13–16 ms indexed versus 9 ms plain, where it was ~115 ms before that change.
+- On a warm file cache the index now roughly breaks even with plain ripgrep for rare tokens: 81–98 ms versus 99–125 ms. `git status` (70–90 ms on these repos) is most of what remains. Cold disks were not measured.
+- The bundled `@vscode/ripgrep` is a static musl build and ran ~1.5× slower than the distro `rg` 14.1. `rg` on `PATH` is preferred, and the bundled copy is the fallback that keeps search off the JS walk.
+- Files tree, git dirty stats, and repo index cold builds are unchanged within noise. The listing change mainly adds the `truncated` flag.
+
+## Setup
+
+- Linux container, 4 vCPU, 16 GB RAM, Node 22.23, ripgrep on `PATH`, **no ctags**. The file cache was warm, so cold-disk numbers will be worse.
+- Each operation ran 3 times. Tables show the median. **Stall** is the worst event-loop delay seen while the operation ran. The control plane is single-threaded, so a stall freezes every HTTP request, WebSocket stream and agent turn for that long.
+- Repos (shallow clones at `HEAD`):
+
+| Repo | Tracked files | Indexed files | Checkout |
+|---|---:|---:|---:|
+| expressjs/express | 216 | 216 | 0.7 MB |
+| pallets/flask | 236 | 231 | 1.7 MB |
+| facebook/react | 7,276 | 7,195 | 39 MB |
+| django/django | 7,100 | 5,602 | 44 MB |
+| microsoft/vscode | 21,212 | 19,666 | 339 MB |
+| kubernetes/kubernetes | 31,757 | 18,975 | 270 MB |
+
+## Headline numbers (median ms / worst stall ms)
+
+| Operation | express | flask | react | django | vscode | kubernetes |
+|---|---:|---:|---:|---:|---:|---:|
+| Repo index, cold build | 56 / 16 | 51 / 9 | 457 / 35 | 611 / 29 | 1,344 / 128 | 1,225 / 95 |
+| Repo index, warm rebuild (nothing changed) | 45 / 10 | 33 / 10 | 358 / 32 | 542 / 37 | 1,053 / 96 | 1,135 / 87 |
+| Per-turn ranked repo map | 2 | 1 | 12 | 8 | 78 | 64 |
+| Quick Open, 6 keystrokes | 2 | 2 | 91 | 44 | 253 | 228 |
+| Trigram grep index build | 79 / 14 | 128 / 22 | 3,311 / 122 | 2,907 / 115 | **17,053 / 389** | **12,044 / 304** |
+| Trigram index load (on every grep) | 6 | 11 | 113 / 102 | 82 / 74 | 325 / 278 | 246 / 207 |
+| Agent grep, **with** index (selective literal) | 20 | 24 | 139 / 99 | 133 / 133 | 414 / 263 | 391 / 219 |
+| Agent grep, **without** index (same function) | 14 | 13 | 58 / 10 | 80 / 10 | 160 / 10 | 106 / 11 |
+| Agent grep, next call after one file edit | – | – | **3,472** | – | **18,040** | – |
+| Files search with rg | 9 | 10 | 15 | 14 | 15 | 13 |
+| Files search without rg (walk fallback) | 58 | 57 | 3,525 / 2,439 | 1,677 / 534 | **12,037 / 7,751** | **9,516 / 5,998** |
+| VM guest listing script (`find -exec printf`) | – | – | – | – | **31,570** | – |
+| Files tree, full listing | 9 | 9 | 231 / 71 | 261 | 364 | 385 |
+| Git status, clean | 13 | 14 | 30 | 48 | 78 | 97 |
+| Git dirty stats, 50 modified + 200 untracked | 17 | 21 | 44 | 50 | 100 | 315 |
+
+Small repos are fine everywhere. The problems start at about 5k files and get serious at 20k.
+
+## Where to improve (ranked by user impact, as found before the fixes)
+
+### 1. Any file edit makes the next agent `grep` rebuild the whole trigram index
+
+`file_write` / `apply_patch` call `updateIndexPaths`, which rewrites `files.jsonl`. `ensureGrepIndex` uses that file's mtime and size as its freshness stamp, so the next grep rebuilds every posting list from scratch (`grepIndex.ts` `ensureGrepIndex` → `buildGrepIndex`). Agents edit and grep in a loop, so on a large repo almost every grep pays for a full rebuild:
+
+- react: 167 ms per grep with no edits, **3.5 s** after an edit.
+- vscode: 437 ms per grep with no edits, **18 s** after an edit, with stalls up to 389 ms and 140–290 MB of heap churn.
+
+**Fix:** update postings incrementally for the changed paths, and track freshness per path instead of per file. Or drop the index entirely (see #2).
+
+### 2. VM Files pane: the guest listing forks one `printf` per file
+
+`workspaceFindScript` (used by `listGuestWorkspaceEntries` for VM sessions) runs `find … -exec printf 'f\t%s\n' {} \;`. That starts a new process for every directory and file. After `head -n 16000` closes the pipe, `find` keeps walking and forking until it reaches the end of the tree. On the vscode tree, in this container (no SSH hop), it took **31.6 s**. The same listing with `-printf` took **0.05 s**. Over SSH into a real VM it will be slower still.
+
+**Fix:** use `-printf 'f\t%p\n'` (GNU find, which all supported Linux guests have), or the POSIX batch form `-exec printf 'f\t%s\n' {} +`.
+
+### 3. The trigram index makes agent grep slower than plain ripgrep
+
+Even with no edits, the indexed path is 1.4–3.7× slower than the same function without a cache dir on every repo over 5k files. It also blocks the event loop for 60–300 ms per call. On every call it `JSON.parse`s the whole postings file (12 MB on react, 57 MB on vscode, 41 MB on kubernetes) and runs `git status`. ripgrep alone searches 20k files in 100–235 ms. The index is also skipped for common words, wide regexes, and short literals (kubernetes' selective query fell back too).
+
+**Fix (pick one):**
+- Turn `workspaceGrepIndex` off by default and delete the index. This is the simplest option and the fastest in every measurement.
+- Or keep the index in memory across calls, store it in a binary or sorted format instead of JSON, and build it off the main thread.
+
+Also, `runAgentRipgrep` passes up to 4,000 candidate paths as argv. That exceeds the 32 KB Windows command-line limit, so on Windows the spawn fails and the call silently falls back. This comes from reading the code; it was not measured.
+
+### 4. ripgrep is not bundled, and the fallback freezes the server for seconds
+
+`resolveRipgrep()` only looks for `rg` on `PATH`. Guests ship rg, but the host-side Files search and the indexed agent grep run on the control-plane host. Most macOS and Windows desktops don't have rg installed. Without it, the Files pane uses `grepByWalk`: **12 s on vscode, with a 7.8 s event-loop stall**, and 3.5 s on react. The indexed agent grep returns `undefined` and falls back to the guest.
+
+**Fix:** ship `@vscode/ripgrep` (or a per-platform binary in `extraResources`) and resolve it before `PATH`. If the walk fallback stays, move it into a worker thread and yield between files.
+
+### 5. A repo index "warm" rebuild costs nearly as much as a cold one
+
+With nothing changed, a forced rebuild costs 78–89% of a cold build (react 358 vs 457 ms, vscode 1,053 vs 1,344 ms). This happens after every `git_clone` / `git_fetch`. Two causes:
+
+- `fillIndexSymbols` treats `symbols: []` as "not extracted yet", so source files with no symbols are re-read on every build: 324 re-reads on react, 298 on django.
+- Every listed file is `lstat`ed again (20k calls on vscode) on the main thread, with stalls around 100 ms.
+
+**Fix:** store `symbols: null` vs `[]` (or a `symbolsAt` stamp) so empty results are cached. On rebuild, diff against `git ls-files -s` / `git status` instead of `lstat`ing every file. Also, `ctags -R -x .` runs over the whole tree with an 8 MB buffer and an 8 s timeout. That wasn't measured here because ctags isn't installed, but on a large repo it would overflow and waste up to 8 s before falling back. Pass it the chosen file list instead.
+
+### 6. Quick Open and the per-turn repo map are synchronous O(n) work on the server thread
+
+- `searchRepoMap` fuzzy-scores every path and symbol on each keystroke. It takes 40 ms per query on 20k files, 250 ms for a 6-keystroke burst, all synchronous. **Fix:** reuse the previous result set when the query extends the last one, precompute lowercased paths and basenames, and debounce on the client.
+- `formatRepoMap({ rank: true })` runs on every agent turn (cached for 30 s per query). It takes 64–78 ms on 20k-file repos. The cost is `scoped.filter(f => !matched.includes(f))`, which is O(n·m), plus `lines.join("\n")` re-run for every line. **Fix:** use a `Set` and a running length counter. This is a one-line quick win.
+
+### 7. Index memory is never released
+
+`readyIndexes` in `repoIndex.ts` only clears in tests. Each session that touches a 20k-file repo keeps 16–40 MB of heap until the process exits, including deleted sessions and "Keep running" control planes. **Fix:** evict on session delete and put an LRU cap on the map.
+
+### 8. Files tree listing silently truncates and lstats files one at a time
+
+On vscode and kubernetes the full listing hits `WORKSPACE_LIST_MAX_ENTRIES` (16,000) without telling the caller. `captureImage` calls the unbounded listing to copy a worker workspace, so large workspaces lose files without warning. Within each directory, files are `lstat`ed one at a time (`await` in a loop). **Fix:** return a `truncated` flag, batch the `lstat`s per directory, and use `git ls-files` for tracked trees.
+
+### 9. Git status spawns 3–4 processes; dirty stats has a 2 s hard timeout
+
+`workspaceGitStatus` runs `rev-parse --is-inside-work-tree`, `status`, `rev-parse --abbrev-ref`, and `rev-parse @{upstream}` one after another (97 ms clean on kubernetes). `git status --porcelain=v2 --branch -z` returns branch, upstream and ahead/behind in a single process. `gitWorkspaceChanges` reached 315 ms on kubernetes with edits. It runs with a 2 s timeout, and on larger monorepos or cold disks it will time out and silently report no changes. **Fix:** collapse the git calls into one, and raise or adapt the timeout, or surface the timeout instead of returning `[]`.
+
+## Re-running
+
+```bash
+npm ci
+git clone --depth 200 https://github.com/microsoft/vscode /tmp/repos/vscode
+node --expose-gc --import tsx apps/server/bench/repoPerf.ts /tmp/repos/vscode --runs 3 --out results.json
+npx tsx apps/server/bench/editGrepLoop.ts /tmp/repos/vscode --edits 3
+```
+
+The harness appends to 50 tracked files and creates 200 scratch files in each repo, then restores them with `git checkout` and deletes the scratch files. Point it at throwaway clones only.
diff --git a/apps/server/bench/editGrepLoop.ts b/apps/server/bench/editGrepLoop.ts
new file mode 100644
index 0000000..88ed4fd
--- /dev/null
+++ b/apps/server/bench/editGrepLoop.ts
@@ -0,0 +1,59 @@
+/**
+ * Agent edit → grep loop. After `file_write` / `apply_patch`, the session calls
+ * `updateIndexPaths`, which rewrites files.jsonl. The trigram grep index keys its freshness
+ * on that file's mtime/size, so the next indexed `grep` rebuilds every posting list.
+ *
+ * npx tsx apps/server/bench/editGrepLoop.ts [--edits 5]
+ */
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { performance } from "node:perf_hooks";
+import { buildWorkspaceIndex, updateIndexPaths } from "../src/workspace/repoIndex.js";
+import { grepIndexedWorkspace, settleGrepIndexBuilds } from "../src/workspace/grepIndex.js";
+
+const args = process.argv.slice(2);
+const root = resolve(args[0]!);
+const edits = Number(args[args.indexOf("--edits") + 1] || 5);
+const cacheDir = mkdtempSync(join(tmpdir(), "locus-editgrep-"));
+const target = execFileSync("git", ["-C", root, "ls-files"], { maxBuffer: 1 << 28 })
+ .toString()
+ .split("\n")
+ .find((p) => /\.(ts|js|py|go)$/.test(p))!;
+
+const t = async (label: string, fn: () => Promise) => {
+ const t0 = performance.now();
+ await fn();
+ const ms = Math.round(performance.now() - t0);
+ console.log(` ${label.padEnd(40)} ${String(ms).padStart(7)} ms`);
+ return ms;
+};
+
+console.log(`== ${root} (editing ${target})`);
+await buildWorkspaceIndex(root, { cacheDir, force: true });
+await t("first grep (index builds in background)", () =>
+ grepIndexedWorkspace({ root, pattern: "constructor", cacheDir }),
+);
+await t("background index build", () => settleGrepIndexBuilds());
+await t("grep again (no edits)", () =>
+ grepIndexedWorkspace({ root, pattern: "constructor", cacheDir }),
+);
+const after: number[] = [];
+try {
+ for (let i = 0; i < edits; i++) {
+ writeFileSync(join(root, target), `// edit ${i}\n`, { flag: "a" });
+ await t(`edit ${i + 1}: updateIndexPaths`, () => updateIndexPaths(root, cacheDir, [target]));
+ after.push(
+ await t(`edit ${i + 1}: next grep`, () =>
+ grepIndexedWorkspace({ root, pattern: "constructor", cacheDir }),
+ ),
+ );
+ }
+} finally {
+ execFileSync("git", ["-C", root, "checkout", "--", target]);
+ rmSync(cacheDir, { recursive: true, force: true });
+}
+console.log(
+ ` mean grep after an edit: ${Math.round(after.reduce((a, b) => a + b, 0) / after.length)} ms`,
+);
diff --git a/apps/server/bench/repoPerf.ts b/apps/server/bench/repoPerf.ts
new file mode 100644
index 0000000..65807c6
--- /dev/null
+++ b/apps/server/bench/repoPerf.ts
@@ -0,0 +1,441 @@
+/**
+ * Workspace performance benchmark against real repositories.
+ *
+ * npx tsx apps/server/bench/repoPerf.ts [ ...] [--runs 3] [--out results.json]
+ *
+ * Measures the control-plane code paths that scale with repo size: the repo index
+ * (Quick Open / repo_map), the trigram grep index used by the agent `grep` tool, the
+ * Files-pane search, the Files tree listing, and git status / dirty stats. For each
+ * operation it records wall time (median of N runs), the worst event-loop stall seen
+ * while it ran, heap growth, and on-disk cache size.
+ *
+ * Repos are benchmarked read-only except for a handful of scratch files written to
+ * simulate an edited checkout (removed again at the end).
+ */
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync, unlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, join, resolve } from "node:path";
+import { monitorEventLoopDelay, performance } from "node:perf_hooks";
+import { formatRepoMap, searchRepoMap } from "@locus/shared";
+import {
+ buildWorkspaceIndex,
+ getWorkspaceIndex,
+ resetWorkspaceIndexCache,
+} from "../src/workspace/repoIndex.js";
+import {
+ buildGrepIndex,
+ dropGrepIndex,
+ grepIndexedWorkspace,
+ loadGrepIndex,
+} from "../src/workspace/grepIndex.js";
+import { grepWorkspace, resolveRipgrep } from "../src/workspace/workspaceGrep.js";
+import { listWorkspace } from "../src/workspace/workspaceFiles.js";
+import { workspaceGitStatus } from "../src/workspace/workspaceGit.js";
+import {
+ gitWorkspaceChanges,
+ resetGitWorkspaceChangesCache,
+} from "../src/workspace/gitWorkspaceChanges.js";
+
+type Sample = {
+ op: string;
+ medianMs: number;
+ minMs: number;
+ maxMs: number;
+ /** Worst single event-loop stall while the op ran (ms). */
+ maxLoopStallMs: number;
+ heapDeltaMb?: number;
+ note?: string;
+};
+
+type RepoResult = {
+ repo: string;
+ trackedFiles: number;
+ checkoutMb: number;
+ samples: Sample[];
+ facts: Record;
+};
+
+const args = process.argv.slice(2);
+const runs = Number(flag("--runs") ?? 3);
+const out = flag("--out");
+const repos = args.filter((a, i) => !a.startsWith("--") && !args[i - 1]?.startsWith("--"));
+
+function flag(name: string): string | undefined {
+ const i = args.indexOf(name);
+ return i >= 0 ? args[i + 1] : undefined;
+}
+
+function median(xs: number[]): number {
+ const s = [...xs].sort((a, b) => a - b);
+ return s[Math.floor(s.length / 2)]!;
+}
+
+function dirBytes(dir: string): number {
+ if (!existsSync(dir)) return 0;
+ const r = spawnSync("du", ["-sb", dir], { encoding: "utf8" });
+ return Number(r.stdout.split("\t")[0] ?? 0);
+}
+
+function fileBytes(path: string): number {
+ try {
+ return statSync(path).size;
+ } catch {
+ return 0;
+ }
+}
+
+const mb = (n: number) => Math.round((n / 1024 / 1024) * 10) / 10;
+const r1 = (n: number) => Math.round(n * 10) / 10;
+
+async function measure(
+ op: string,
+ n: number,
+ fn: (i: number) => Promise,
+ opts: { before?: () => void | Promise; note?: (v: T) => string } = {},
+): Promise<{ sample: Sample; last: T }> {
+ const times: number[] = [];
+ let stall = 0;
+ let heapDelta = 0;
+ let last!: T;
+ for (let i = 0; i < n; i++) {
+ await opts.before?.();
+ global.gc?.();
+ const heap0 = process.memoryUsage().heapUsed;
+ const h = monitorEventLoopDelay({ resolution: 5 });
+ h.enable();
+ const t0 = performance.now();
+ last = await fn(i);
+ times.push(performance.now() - t0);
+ // Let the histogram's timer fire once more so a purely synchronous op is recorded too.
+ await new Promise((r) => setTimeout(r, 12));
+ h.disable();
+ stall = Math.max(stall, h.max / 1e6);
+ heapDelta = Math.max(heapDelta, process.memoryUsage().heapUsed - heap0);
+ }
+ const sample: Sample = {
+ op,
+ medianMs: r1(median(times)),
+ minMs: r1(Math.min(...times)),
+ maxMs: r1(Math.max(...times)),
+ maxLoopStallMs: r1(stall),
+ heapDeltaMb: mb(heapDelta),
+ ...(opts.note ? { note: opts.note(last) } : {}),
+ };
+ console.log(
+ ` ${op.padEnd(44)} ${String(sample.medianMs).padStart(9)} ms stall ${String(sample.maxLoopStallMs).padStart(7)} ms heap +${sample.heapDeltaMb} MB${sample.note ? ` (${sample.note})` : ""}`,
+ );
+ return { sample, last };
+}
+
+/** Pick realistic search terms from the repo itself so every repo gets comparable queries. */
+function pickQueries(paths: string[]): { file: string; symbol: string } {
+ const src = paths.filter((p) => /\.(ts|js|py|go|rs|java)$/.test(p) && !/test/i.test(p));
+ const mid = src[Math.floor(src.length / 2)] ?? paths[0] ?? "index";
+ const stem = basename(mid).replace(/\.[^.]+$/, "");
+ return { file: stem.slice(0, Math.max(4, Math.min(8, stem.length))), symbol: stem };
+}
+
+async function benchRepo(dir: string): Promise {
+ const root = resolve(dir);
+ const name = basename(root);
+ const tracked = execFileSync("git", ["-C", root, "ls-files", "-z"], { maxBuffer: 1 << 28 })
+ .toString()
+ .split("\0")
+ .filter(Boolean);
+ const result: RepoResult = {
+ repo: name,
+ trackedFiles: tracked.length,
+ checkoutMb: mb(dirBytes(root) - dirBytes(join(root, ".git"))),
+ samples: [],
+ facts: {},
+ };
+ console.log(`\n== ${name}: ${tracked.length} tracked files, ${result.checkoutMb} MB checkout`);
+ const push = (s: Sample) => result.samples.push(s);
+ const scratch = mkdtempSync(join(tmpdir(), `locus-bench-${name}-`));
+ const cacheDir = (i: number | string) => join(scratch, `idx-${i}`);
+
+ // --- Repo index (Quick Open, repo_map, Files pane "Indexing N") -------------------
+ {
+ const { sample, last } = await measure(
+ "index: cold build",
+ runs,
+ (i) => buildWorkspaceIndex(root, { cacheDir: cacheDir(`cold-${i}`), force: true }),
+ {
+ before: () => resetWorkspaceIndexCache(),
+ note: (r) =>
+ `${r.stats.files} files, read ${r.stats.read}, truncated=${!!r.stats.truncated}`,
+ },
+ );
+ push(sample);
+ result.facts.indexFiles = last.stats.files;
+ result.facts.indexTruncated = !!last.stats.truncated;
+ result.facts.indexSymbols = last.map.files.reduce((n, f) => n + f.symbols.length, 0);
+ result.facts.indexSymbolFiles = last.map.files.filter((f) => f.symbols.length).length;
+ result.facts.indexCacheMb = mb(dirBytes(cacheDir("cold-0")));
+ }
+ push(
+ (
+ await measure(
+ "index: warm rebuild (force, mtime reuse)",
+ runs,
+ () => buildWorkspaceIndex(root, { cacheDir: cacheDir("cold-0"), force: true }),
+ {
+ before: () => resetWorkspaceIndexCache(),
+ note: (r) => `reused ${r.stats.reused}, read ${r.stats.read}`,
+ },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ "index: hydrate from disk (new process)",
+ runs,
+ () => getWorkspaceIndex(root, { cacheDir: cacheDir("cold-0") }),
+ { before: () => resetWorkspaceIndexCache() },
+ )
+ ).sample,
+ );
+ const built = await getWorkspaceIndex(root, { cacheDir: cacheDir("cold-0") });
+ const q = pickQueries(built.map.files.map((f) => f.path));
+ result.facts.queryFile = q.file;
+ push(
+ (
+ await measure(
+ "repo_map: formatRepoMap (no query)",
+ runs,
+ async () => formatRepoMap(built.map, {}),
+ { note: (s) => `${Math.round(s.length / 1024)} KB output` },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ "repo_map: per-turn ranked prompt (query+rank)",
+ runs,
+ async () =>
+ formatRepoMap(built.map, {
+ query: `fix the ${q.symbol} handler and update tests for src config`,
+ rank: true,
+ }),
+ { note: (s) => `${Math.round(s.length / 1024)} KB output` },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ `quick open: searchRepoMap("${q.file}")`,
+ Math.max(runs, 5),
+ async () => searchRepoMap(built.map, q.file, { limit: 50 }),
+ { note: (r) => `${r.length} hits` },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure("quick open: searchRepoMap per keystroke x6", Math.max(runs, 5), async () => {
+ for (let k = 1; k <= Math.min(6, q.file.length); k++)
+ searchRepoMap(built.map, q.file.slice(0, k), { limit: 50 });
+ })
+ ).sample,
+ );
+
+ // --- Trigram grep index (agent `grep` tool on Docker/Host) ------------------------
+ {
+ const paths = built.map.files.map((f) => f.path);
+ const { sample, last } = await measure(
+ "grep index: build",
+ runs,
+ (i) => buildGrepIndex(root, cacheDir(`grep-${i}`), paths),
+ { note: (g) => `${g.files.length} files, ${g.postings.size} grams` },
+ );
+ push(sample);
+ result.facts.grepGrams = last.postings.size;
+ result.facts.grepPostingsMb = mb(fileBytes(join(cacheDir("grep-0"), "grep-postings.bin")));
+ // ensureGrepIndex reads files.jsonl from the same dir: seed the main cache dir.
+ await buildGrepIndex(root, cacheDir("cold-0"), paths);
+ push(
+ (
+ await measure(
+ "grep index: load from disk (once per process)",
+ runs,
+ () => loadGrepIndex(cacheDir("cold-0")),
+ { before: () => dropGrepIndex() },
+ )
+ ).sample,
+ );
+ }
+ const rg = await resolveRipgrep();
+ const greps: Array<[string, string]> = [
+ ["selective literal", q.symbol],
+ ["common word", "return"],
+ ["regex", "function\\s+\\w+Error"],
+ ["no match", "zzqxLocusNoSuchTokenqq"],
+ ];
+ for (const [label, pattern] of greps) {
+ push(
+ (
+ await measure(
+ `agent grep (indexed): ${label}`,
+ runs,
+ () => grepIndexedWorkspace({ root, pattern, cacheDir: cacheDir("cold-0") }),
+ {
+ note: (r) =>
+ `usedIndex=${r?.usedIndex} candidates=${r?.candidates ?? "-"} ${Math.round((r?.output.length ?? 0) / 1024)} KB`,
+ },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ `agent grep (no index): ${label}`,
+ runs,
+ () => grepIndexedWorkspace({ root, pattern }),
+ { note: (r) => `${Math.round((r?.output.length ?? 0) / 1024)} KB` },
+ )
+ ).sample,
+ );
+ if (rg) {
+ push(
+ (
+ await measure(`baseline rg -l: ${label}`, runs, async () =>
+ spawnSync(rg, ["-l", "--no-config", "-e", pattern, "."], {
+ cwd: root,
+ maxBuffer: 1 << 28,
+ }),
+ )
+ ).sample,
+ );
+ }
+ }
+
+ // --- Files pane search (/api/.../workspace/grep) ----------------------------------
+ for (const [label, pattern] of greps.slice(0, 2)) {
+ push(
+ (
+ await measure(`files search: ${label}`, runs, () => grepWorkspace(root, pattern, 80), {
+ note: (r) => `${r.items.length} hits truncated=${!!r.truncated}`,
+ })
+ ).sample,
+ );
+ }
+ push(
+ (
+ await measure(
+ "files search: common word (walk fallback)",
+ 1,
+ () => grepWorkspace(root, "return", 80, { engine: "walk" }),
+ { note: (r) => `${r.items.length} hits` },
+ )
+ ).sample,
+ );
+
+ // --- Files tree ---------------------------------------------------------------
+ push(
+ (
+ await measure("list: depth 1", runs, () => listWorkspace(root, "", 1), {
+ note: (r) => `${r.length} entries`,
+ })
+ ).sample,
+ );
+ push(
+ (
+ await measure("list: full tree (no depth)", runs, () => listWorkspace(root, ""), {
+ note: (r) => `${r.length} entries`,
+ })
+ ).sample,
+ );
+
+ // --- Git status / dirty stats ---------------------------------------------------
+ push(
+ (
+ await measure("git: status (clean)", runs, () => workspaceGitStatus(root), {
+ note: (s) => `${s.files.length} files`,
+ })
+ ).sample,
+ );
+ push(
+ (
+ await measure("git: dirty stats (clean)", runs, () => gitWorkspaceChanges(root), {
+ before: () => resetGitWorkspaceChangesCache(),
+ })
+ ).sample,
+ );
+ // Simulate an agent mid-task: 50 modified tracked files + 200 untracked files.
+ const modified = tracked.filter((p) => /\.(ts|js|py|go|md)$/.test(p)).slice(0, 50);
+ const untracked: string[] = [];
+ try {
+ for (const p of modified) writeFileSync(join(root, p), "// locus bench\n", { flag: "a" });
+ for (let i = 0; i < 200; i++) {
+ const p = join(root, `locus-bench-untracked-${i}.txt`);
+ writeFileSync(p, `bench ${i}\n`);
+ untracked.push(p);
+ }
+ push(
+ (
+ await measure(
+ "git: status (50 mod + 200 untracked)",
+ runs,
+ () => workspaceGitStatus(root),
+ {
+ note: (s) => `${s.files.length} files`,
+ },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ "git: dirty stats (50 mod + 200 untracked)",
+ runs,
+ () => gitWorkspaceChanges(root),
+ {
+ before: () => resetGitWorkspaceChangesCache(),
+ note: (r) => `${r.length} rows`,
+ },
+ )
+ ).sample,
+ );
+ push(
+ (
+ await measure(
+ "index: incremental rebuild after edits",
+ runs,
+ () => buildWorkspaceIndex(root, { cacheDir: cacheDir("cold-0"), force: true }),
+ { before: () => resetWorkspaceIndexCache(), note: (r) => `read ${r.stats.read}` },
+ )
+ ).sample,
+ );
+ } finally {
+ execFileSync("git", ["-C", root, "checkout", "--", ...modified]);
+ for (const p of untracked) unlinkSync(p);
+ }
+
+ rmSync(scratch, { recursive: true, force: true });
+ return result;
+}
+
+const all: RepoResult[] = [];
+for (const dir of repos) all.push(await benchRepo(dir));
+if (out) {
+ writeFileSync(
+ out,
+ JSON.stringify(
+ {
+ when: new Date().toISOString(),
+ node: process.version,
+ runs,
+ ripgrep: Boolean(await resolveRipgrep()),
+ ctags: spawnSync("ctags", ["--version"]).status === 0,
+ results: all,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log(`\nwrote ${out}`);
+}
diff --git a/apps/server/bench/results-2026-11-12-after.json b/apps/server/bench/results-2026-11-12-after.json
new file mode 100644
index 0000000..f4f69d8
--- /dev/null
+++ b/apps/server/bench/results-2026-11-12-after.json
@@ -0,0 +1,1737 @@
+{
+ "when": "2026-11-12T02:41:48.970Z",
+ "node": "v22.23.4",
+ "runs": 3,
+ "ripgrep": true,
+ "ctags": false,
+ "results": [
+ {
+ "repo": "express",
+ "trackedFiles": 216,
+ "checkoutMb": 0.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 57.8,
+ "minMs": 50.5,
+ "maxMs": 73.2,
+ "maxLoopStallMs": 18.2,
+ "heapDeltaMb": 8.4,
+ "note": "216 files, read 132, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 26.3,
+ "minMs": 24.1,
+ "maxMs": 27.5,
+ "maxLoopStallMs": 7.9,
+ "heapDeltaMb": 2.3,
+ "note": "reused 216, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 2.2,
+ "minMs": 2.2,
+ "maxMs": 2.6,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.2,
+ "minMs": 0.1,
+ "maxMs": 0.9,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.1,
+ "note": "7 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 0.6,
+ "minMs": 0.6,
+ "maxMs": 1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.3,
+ "note": "7 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"index\")",
+ "medianMs": 0.6,
+ "minMs": 0.5,
+ "maxMs": 2.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.3,
+ "note": "40 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 1.2,
+ "minMs": 1.1,
+ "maxMs": 1.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.8
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 76.7,
+ "minMs": 69.3,
+ "maxMs": 83.3,
+ "maxLoopStallMs": 7.8,
+ "heapDeltaMb": 10.1,
+ "note": "216 files, 4167 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 3.2,
+ "minMs": 3,
+ "maxMs": 3.4,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 1.1
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 13.6,
+ "minMs": 13.1,
+ "maxMs": 14.3,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.5,
+ "note": "usedIndex=true candidates=28 27 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 9.1,
+ "minMs": 9,
+ "maxMs": 10.6,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.3,
+ "note": "28 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 8.4,
+ "minMs": 8.2,
+ "maxMs": 9.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 12,
+ "minMs": 11.5,
+ "maxMs": 12.8,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.8,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 6.4,
+ "minMs": 6.1,
+ "maxMs": 6.5,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.4,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 9.2,
+ "minMs": 9.1,
+ "maxMs": 9.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 16.4,
+ "minMs": 15.2,
+ "maxMs": 18,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.6,
+ "note": "usedIndex=true candidates=41 4 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 11.7,
+ "minMs": 10.9,
+ "maxMs": 12.6,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.2,
+ "note": "8 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 11.6,
+ "minMs": 10.9,
+ "maxMs": 11.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 5.4,
+ "minMs": 5.2,
+ "maxMs": 5.7,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.3,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 9.2,
+ "minMs": 8.7,
+ "maxMs": 10,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 8.7,
+ "minMs": 8.3,
+ "maxMs": 9.3,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 27.2,
+ "minMs": 23.3,
+ "maxMs": 28.2,
+ "maxLoopStallMs": 6.9,
+ "heapDeltaMb": 0.9,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 9.6,
+ "minMs": 8.3,
+ "maxMs": 10.5,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.6,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 27.7,
+ "minMs": 27.7,
+ "maxMs": 27.7,
+ "maxLoopStallMs": 9.1,
+ "heapDeltaMb": 2.9,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 2.3,
+ "minMs": 1.8,
+ "maxMs": 2.8,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.1,
+ "note": "14 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 9.2,
+ "minMs": 8.8,
+ "maxMs": 12.1,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 1.3,
+ "note": "284 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 6.2,
+ "minMs": 5.8,
+ "maxMs": 6.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.3,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 14.3,
+ "minMs": 14,
+ "maxMs": 14.9,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.6
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 6.4,
+ "minMs": 6.2,
+ "maxMs": 6.4,
+ "maxLoopStallMs": 5.4,
+ "heapDeltaMb": 0.5,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 18.7,
+ "minMs": 18.1,
+ "maxMs": 19.3,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.9,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 48.3,
+ "minMs": 45,
+ "maxMs": 49.5,
+ "maxLoopStallMs": 9.5,
+ "heapDeltaMb": 6.1,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 216,
+ "indexTruncated": false,
+ "indexSymbols": 104,
+ "indexSymbolFiles": 31,
+ "indexCacheMb": 0,
+ "queryFile": "index",
+ "grepGrams": 4167,
+ "grepPostingsMb": 0.1
+ }
+ },
+ {
+ "repo": "flask",
+ "trackedFiles": 236,
+ "checkoutMb": 1.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 52,
+ "minMs": 46,
+ "maxMs": 69.2,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 8.3,
+ "note": "231 files, read 73, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 24.9,
+ "minMs": 22.6,
+ "maxMs": 25.4,
+ "maxLoopStallMs": 7.6,
+ "heapDeltaMb": 2,
+ "note": "reused 231, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 2.2,
+ "minMs": 2.1,
+ "maxMs": 2.5,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.2,
+ "minMs": 0.1,
+ "maxMs": 0.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.1,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 0.6,
+ "minMs": 0.5,
+ "maxMs": 0.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.4,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"ctx\")",
+ "medianMs": 1.1,
+ "minMs": 0.7,
+ "maxMs": 2.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.6,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 2.1,
+ "minMs": 1.9,
+ "maxMs": 3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.3
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 116.4,
+ "minMs": 112.7,
+ "maxMs": 138.5,
+ "maxLoopStallMs": 24.1,
+ "heapDeltaMb": 15.3,
+ "note": "231 files, 8520 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 5.9,
+ "minMs": 5.3,
+ "maxMs": 6.3,
+ "maxLoopStallMs": 6.1,
+ "heapDeltaMb": 2.1
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 12,
+ "minMs": 11.2,
+ "maxMs": 12.2,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.6,
+ "note": "usedIndex=true candidates=28 47 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 8.2,
+ "minMs": 8.1,
+ "maxMs": 9.1,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.6,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 9.6,
+ "minMs": 9.3,
+ "maxMs": 10,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 13.3,
+ "minMs": 11.6,
+ "maxMs": 15.8,
+ "maxLoopStallMs": 7.3,
+ "heapDeltaMb": 0.6,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 7.8,
+ "minMs": 7,
+ "maxMs": 8.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.9,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 11.3,
+ "minMs": 10.7,
+ "maxMs": 12.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 16.8,
+ "minMs": 16.2,
+ "maxMs": 17.3,
+ "maxLoopStallMs": 6.3,
+ "heapDeltaMb": 0.5,
+ "note": "usedIndex=true candidates=41 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 14.6,
+ "minMs": 14.2,
+ "maxMs": 14.8,
+ "maxLoopStallMs": 6.2,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 12.8,
+ "minMs": 12.1,
+ "maxMs": 13.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 6.2,
+ "minMs": 6.2,
+ "maxMs": 6.5,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.3,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 10.3,
+ "minMs": 9.8,
+ "maxMs": 10.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 10.1,
+ "minMs": 8.4,
+ "maxMs": 10.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 10.5,
+ "minMs": 9.9,
+ "maxMs": 44.2,
+ "maxLoopStallMs": 12.7,
+ "heapDeltaMb": 0.6,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 9.7,
+ "minMs": 8.4,
+ "maxMs": 9.9,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.6,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 34.3,
+ "minMs": 34.3,
+ "maxMs": 34.3,
+ "maxLoopStallMs": 8,
+ "heapDeltaMb": 4.2,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 2.4,
+ "minMs": 1.9,
+ "maxMs": 2.4,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.1,
+ "note": "15 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 10.1,
+ "minMs": 9.1,
+ "maxMs": 10.4,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 1.3,
+ "note": "287 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 6.1,
+ "minMs": 5.9,
+ "maxMs": 7.1,
+ "maxLoopStallMs": 5.4,
+ "heapDeltaMb": 0.2,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 13.2,
+ "minMs": 12.2,
+ "maxMs": 15.1,
+ "maxLoopStallMs": 6.4,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 5.8,
+ "minMs": 5.7,
+ "maxMs": 6.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.5,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 19.4,
+ "minMs": 18.1,
+ "maxMs": 23.3,
+ "maxLoopStallMs": 7.6,
+ "heapDeltaMb": 0.8,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 39.4,
+ "minMs": 38.4,
+ "maxMs": 49.5,
+ "maxLoopStallMs": 8.4,
+ "heapDeltaMb": 6.4,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 231,
+ "indexTruncated": false,
+ "indexSymbols": 574,
+ "indexSymbolFiles": 56,
+ "indexCacheMb": 0,
+ "queryFile": "ctx",
+ "grepGrams": 8520,
+ "grepPostingsMb": 0.3
+ }
+ },
+ {
+ "repo": "react",
+ "trackedFiles": 7276,
+ "checkoutMb": 38.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 481.7,
+ "minMs": 432.6,
+ "maxMs": 492.7,
+ "maxLoopStallMs": 44.9,
+ "heapDeltaMb": 17.3,
+ "note": "7195 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 125.2,
+ "minMs": 115.9,
+ "maxMs": 129.4,
+ "maxLoopStallMs": 28.7,
+ "heapDeltaMb": 18,
+ "note": "reused 7195, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 12.9,
+ "minMs": 12.2,
+ "maxMs": 13.2,
+ "maxLoopStallMs": 13.2,
+ "heapDeltaMb": 6.3
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.2,
+ "minMs": 0.2,
+ "maxMs": 0.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.3,
+ "note": "11 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 4.3,
+ "minMs": 4,
+ "maxMs": 5.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 3.9,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"Suspense\")",
+ "medianMs": 16.8,
+ "minMs": 16.5,
+ "maxMs": 17.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 10.2,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 91.1,
+ "minMs": 83,
+ "maxMs": 97,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 13.3
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 3408.8,
+ "minMs": 3206,
+ "maxMs": 3413.9,
+ "maxLoopStallMs": 147.1,
+ "heapDeltaMb": 40.1,
+ "note": "7195 files, 47916 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 30.3,
+ "minMs": 24.1,
+ "maxMs": 30.5,
+ "maxLoopStallMs": 17.4,
+ "heapDeltaMb": 12.8
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 32.7,
+ "minMs": 32.5,
+ "maxMs": 34.2,
+ "maxLoopStallMs": 9.1,
+ "heapDeltaMb": 2.2,
+ "note": "usedIndex=true candidates=10 31 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 36.2,
+ "minMs": 35.2,
+ "maxMs": 37.6,
+ "maxLoopStallMs": 7.3,
+ "heapDeltaMb": 0.3,
+ "note": "31 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 30.4,
+ "minMs": 27.5,
+ "maxMs": 31.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 34.3,
+ "minMs": 33.5,
+ "maxMs": 36.9,
+ "maxLoopStallMs": 9.9,
+ "heapDeltaMb": 4.6,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 10.7,
+ "minMs": 10.4,
+ "maxMs": 11.1,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.8,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 106.6,
+ "minMs": 96.9,
+ "maxMs": 110.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 48.7,
+ "minMs": 47,
+ "maxMs": 56.2,
+ "maxLoopStallMs": 12.9,
+ "heapDeltaMb": 5.4,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 20.4,
+ "minMs": 18.6,
+ "maxMs": 24.1,
+ "maxLoopStallMs": 6.7,
+ "heapDeltaMb": 0.5,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 34.6,
+ "minMs": 34,
+ "maxMs": 35,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 33.8,
+ "minMs": 32.1,
+ "maxMs": 34.4,
+ "maxLoopStallMs": 7.5,
+ "heapDeltaMb": 0.9,
+ "note": "usedIndex=true candidates=3 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 37.6,
+ "minMs": 37.2,
+ "maxMs": 44,
+ "maxLoopStallMs": 11.9,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 31.6,
+ "minMs": 30.5,
+ "maxMs": 34.8,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 63.2,
+ "minMs": 62.2,
+ "maxMs": 63.9,
+ "maxLoopStallMs": 8.5,
+ "heapDeltaMb": 0.7,
+ "note": "49 hits truncated=false"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 14.7,
+ "minMs": 12.4,
+ "maxMs": 15.3,
+ "maxLoopStallMs": 7.9,
+ "heapDeltaMb": 1,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 80.1,
+ "minMs": 80.1,
+ "maxMs": 80.1,
+ "maxLoopStallMs": 21.6,
+ "heapDeltaMb": 5.8,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 3.7,
+ "minMs": 3.4,
+ "maxMs": 3.7,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.1,
+ "note": "35 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 198.1,
+ "minMs": 187.5,
+ "maxMs": 222.8,
+ "maxLoopStallMs": 20.6,
+ "heapDeltaMb": 12.3,
+ "note": "7863 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 25.4,
+ "minMs": 24.5,
+ "maxMs": 26.3,
+ "maxLoopStallMs": 6.1,
+ "heapDeltaMb": 0.2,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 34,
+ "minMs": 32.2,
+ "maxMs": 35.5,
+ "maxLoopStallMs": 6.7,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 23.4,
+ "minMs": 22.1,
+ "maxMs": 23.7,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.5,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 45.4,
+ "minMs": 44.5,
+ "maxMs": 58.5,
+ "maxLoopStallMs": 7.5,
+ "heapDeltaMb": 0.8,
+ "note": "226 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 157,
+ "minMs": 143,
+ "maxMs": 159.7,
+ "maxLoopStallMs": 29.1,
+ "heapDeltaMb": 17.3,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 7195,
+ "indexTruncated": false,
+ "indexSymbols": 3003,
+ "indexSymbolFiles": 476,
+ "indexCacheMb": 1.2,
+ "queryFile": "Suspense",
+ "grepGrams": 47916,
+ "grepPostingsMb": 9.2
+ }
+ },
+ {
+ "repo": "django",
+ "trackedFiles": 7100,
+ "checkoutMb": 44.2,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 595.1,
+ "minMs": 577.8,
+ "maxMs": 623.6,
+ "maxLoopStallMs": 39.1,
+ "heapDeltaMb": 16.4,
+ "note": "5602 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 275.3,
+ "minMs": 267.2,
+ "maxMs": 315.4,
+ "maxLoopStallMs": 24,
+ "heapDeltaMb": 11.6,
+ "note": "reused 5602, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 10.5,
+ "minMs": 9.6,
+ "maxMs": 11.2,
+ "maxLoopStallMs": 11.1,
+ "heapDeltaMb": 5.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.2,
+ "minMs": 0.2,
+ "maxMs": 0.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 3.8,
+ "minMs": 3.6,
+ "maxMs": 3.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"jsonb\")",
+ "medianMs": 7.9,
+ "minMs": 7,
+ "maxMs": 8.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 5.7,
+ "note": "46 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 28,
+ "minMs": 23.2,
+ "maxMs": 33.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 3.4
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 2810.8,
+ "minMs": 2761.6,
+ "maxMs": 2824.4,
+ "maxLoopStallMs": 24.1,
+ "heapDeltaMb": 38.2,
+ "note": "5602 files, 19868 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 16.6,
+ "minMs": 14.6,
+ "maxMs": 16.6,
+ "maxLoopStallMs": 12.8,
+ "heapDeltaMb": 5.7
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 45.2,
+ "minMs": 44.6,
+ "maxMs": 45.9,
+ "maxLoopStallMs": 9.5,
+ "heapDeltaMb": 0.6,
+ "note": "usedIndex=true candidates=40 16 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 41.8,
+ "minMs": 40.6,
+ "maxMs": 42.7,
+ "maxLoopStallMs": 6.8,
+ "heapDeltaMb": 0.3,
+ "note": "16 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 38.3,
+ "minMs": 37.8,
+ "maxMs": 39.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 46.9,
+ "minMs": 44.7,
+ "maxMs": 49.1,
+ "maxLoopStallMs": 9.5,
+ "heapDeltaMb": 2,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 10.3,
+ "minMs": 9.7,
+ "maxMs": 11.7,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.7,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 46.4,
+ "minMs": 43.9,
+ "maxMs": 52.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 57.2,
+ "minMs": 54.9,
+ "maxMs": 63.3,
+ "maxLoopStallMs": 12.5,
+ "heapDeltaMb": 2.8,
+ "note": "usedIndex=true candidates=679 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 43.1,
+ "minMs": 43,
+ "maxMs": 47.1,
+ "maxLoopStallMs": 9.5,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 42,
+ "minMs": 39.7,
+ "maxMs": 42.4,
+ "maxLoopStallMs": 5.4,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 39.2,
+ "minMs": 36.1,
+ "maxMs": 40.3,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.2,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 41.6,
+ "minMs": 40.3,
+ "maxMs": 49.5,
+ "maxLoopStallMs": 6.9,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 37.9,
+ "minMs": 34.8,
+ "maxMs": 38.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 73.8,
+ "minMs": 73.7,
+ "maxMs": 76.7,
+ "maxLoopStallMs": 9.3,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 12.9,
+ "minMs": 12.1,
+ "maxMs": 13.8,
+ "maxLoopStallMs": 7.4,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 88.1,
+ "minMs": 88.1,
+ "maxMs": 88.1,
+ "maxLoopStallMs": 19.4,
+ "heapDeltaMb": 5.4,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 3,
+ "minMs": 2.9,
+ "maxMs": 3.1,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.1,
+ "note": "28 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 268.6,
+ "minMs": 257.6,
+ "maxMs": 273.1,
+ "maxLoopStallMs": 9.4,
+ "heapDeltaMb": 3.2,
+ "note": "10244 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 36.5,
+ "minMs": 32.9,
+ "maxMs": 36.9,
+ "maxLoopStallMs": 6.1,
+ "heapDeltaMb": 0.2,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 48.2,
+ "minMs": 48,
+ "maxMs": 48.8,
+ "maxLoopStallMs": 7.9,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 35.9,
+ "minMs": 35.8,
+ "maxMs": 39.1,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.5,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 51.9,
+ "minMs": 51.2,
+ "maxMs": 52.9,
+ "maxLoopStallMs": 6.9,
+ "heapDeltaMb": 0.8,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 296.4,
+ "minMs": 281,
+ "maxMs": 319.8,
+ "maxLoopStallMs": 28.9,
+ "heapDeltaMb": 16.8,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 5602,
+ "indexTruncated": false,
+ "indexSymbols": 4209,
+ "indexSymbolFiles": 502,
+ "indexCacheMb": 0.7,
+ "queryFile": "jsonb",
+ "grepGrams": 19868,
+ "grepPostingsMb": 7.6
+ }
+ },
+ {
+ "repo": "vscode",
+ "trackedFiles": 21212,
+ "checkoutMb": 338.8,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 1274.7,
+ "minMs": 1201.5,
+ "maxMs": 1329.5,
+ "maxLoopStallMs": 132.4,
+ "heapDeltaMb": 27.9,
+ "note": "19666 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 374.1,
+ "minMs": 361.6,
+ "maxMs": 377.5,
+ "maxLoopStallMs": 87.6,
+ "heapDeltaMb": 22.4,
+ "note": "reused 19666, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 24,
+ "minMs": 24,
+ "maxMs": 30.1,
+ "maxLoopStallMs": 30.1,
+ "heapDeltaMb": 15.9
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.5,
+ "minMs": 0.5,
+ "maxMs": 0.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.6,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 12.6,
+ "minMs": 12,
+ "maxMs": 14.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 5.4,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"ThreadMe\")",
+ "medianMs": 37.1,
+ "minMs": 28.8,
+ "maxMs": 55.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 6.2,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 169,
+ "minMs": 157.3,
+ "maxMs": 198.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 12.5
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 16585.6,
+ "minMs": 16469.1,
+ "maxMs": 16724.5,
+ "maxLoopStallMs": 116.8,
+ "heapDeltaMb": 141.9,
+ "note": "19666 files, 49263 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 87.4,
+ "minMs": 84.9,
+ "maxMs": 92.4,
+ "maxLoopStallMs": 45.2,
+ "heapDeltaMb": 11.2
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 100.1,
+ "minMs": 96.7,
+ "maxMs": 111.8,
+ "maxLoopStallMs": 25.1,
+ "heapDeltaMb": 20.4,
+ "note": "usedIndex=true candidates=13 2 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 105.1,
+ "minMs": 98,
+ "maxMs": 111.4,
+ "maxLoopStallMs": 9,
+ "heapDeltaMb": 0.2,
+ "note": "2 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 87.5,
+ "minMs": 79.5,
+ "maxMs": 92.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 88.4,
+ "minMs": 84.9,
+ "maxMs": 89.4,
+ "maxLoopStallMs": 19.9,
+ "heapDeltaMb": 8,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 12.3,
+ "minMs": 12,
+ "maxMs": 12.7,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.6,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 236,
+ "minMs": 226.1,
+ "maxMs": 243.4,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 116.7,
+ "minMs": 116.1,
+ "maxMs": 117.6,
+ "maxLoopStallMs": 25.1,
+ "heapDeltaMb": 16.8,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 32.9,
+ "minMs": 31.9,
+ "maxMs": 33.4,
+ "maxLoopStallMs": 8.1,
+ "heapDeltaMb": 0.4,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 88.8,
+ "minMs": 81.4,
+ "maxMs": 91.8,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 81.7,
+ "minMs": 77.4,
+ "maxMs": 82,
+ "maxLoopStallMs": 14.7,
+ "heapDeltaMb": 5,
+ "note": "usedIndex=true candidates=2 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 96.1,
+ "minMs": 95.9,
+ "maxMs": 102.5,
+ "maxLoopStallMs": 7.5,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 80.1,
+ "minMs": 78.2,
+ "maxMs": 90.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 144.3,
+ "minMs": 140.7,
+ "maxMs": 167.9,
+ "maxLoopStallMs": 12,
+ "heapDeltaMb": 0.5,
+ "note": "3 hits truncated=false"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 17.3,
+ "minMs": 17,
+ "maxMs": 17.7,
+ "maxLoopStallMs": 10.2,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 146.8,
+ "minMs": 146.8,
+ "maxMs": 146.8,
+ "maxLoopStallMs": 53.4,
+ "heapDeltaMb": 6.4,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 5.2,
+ "minMs": 3.9,
+ "maxMs": 5.7,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 0.2,
+ "note": "45 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 387.3,
+ "minMs": 368.7,
+ "maxMs": 392.5,
+ "maxLoopStallMs": 11.8,
+ "heapDeltaMb": 17.1,
+ "note": "16000 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 71.5,
+ "minMs": 70,
+ "maxMs": 73.6,
+ "maxLoopStallMs": 7.9,
+ "heapDeltaMb": 0.2,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 88.3,
+ "minMs": 84,
+ "maxMs": 91.2,
+ "maxLoopStallMs": 11.3,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 70,
+ "minMs": 69.8,
+ "maxMs": 71.7,
+ "maxLoopStallMs": 11.8,
+ "heapDeltaMb": 0.5,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 90.9,
+ "minMs": 89.2,
+ "maxMs": 96.8,
+ "maxLoopStallMs": 9.8,
+ "heapDeltaMb": 0.8,
+ "note": "201 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 409.4,
+ "minMs": 398.9,
+ "maxMs": 414.3,
+ "maxLoopStallMs": 86.5,
+ "heapDeltaMb": 30,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 19666,
+ "indexTruncated": false,
+ "indexSymbols": 3604,
+ "indexSymbolFiles": 755,
+ "indexCacheMb": 2.8,
+ "queryFile": "ThreadMe",
+ "grepGrams": 49263,
+ "grepPostingsMb": 40.1
+ }
+ },
+ {
+ "repo": "kubernetes",
+ "trackedFiles": 31757,
+ "checkoutMb": 269.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 1307.4,
+ "minMs": 1272.2,
+ "maxMs": 1322.8,
+ "maxLoopStallMs": 107,
+ "heapDeltaMb": 42.1,
+ "note": "18975 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 534.7,
+ "minMs": 518.2,
+ "maxMs": 697.7,
+ "maxLoopStallMs": 86.9,
+ "heapDeltaMb": 24.6,
+ "note": "reused 18975, read 0"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 29.1,
+ "minMs": 27.2,
+ "maxMs": 30.6,
+ "maxLoopStallMs": 30.6,
+ "heapDeltaMb": 15.5
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.6,
+ "minMs": 0.6,
+ "maxMs": 0.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.6,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 12.8,
+ "minMs": 12.4,
+ "maxMs": 14.6,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 4.7,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"create\")",
+ "medianMs": 36.7,
+ "minMs": 35,
+ "maxMs": 38.9,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 7.5,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 208.5,
+ "minMs": 188.3,
+ "maxMs": 215.6,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 8.1
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 11799.9,
+ "minMs": 11734.4,
+ "maxMs": 11805.9,
+ "maxLoopStallMs": 76.8,
+ "heapDeltaMb": 135.4,
+ "note": "18975 files, 47061 grams"
+ },
+ {
+ "op": "grep index: load from disk (once per process)",
+ "medianMs": 48,
+ "minMs": 38.7,
+ "maxMs": 56.8,
+ "maxLoopStallMs": 35.3,
+ "heapDeltaMb": 13.9
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 126,
+ "minMs": 111.7,
+ "maxMs": 165.2,
+ "maxLoopStallMs": 47.5,
+ "heapDeltaMb": 5.8,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 14.3,
+ "minMs": 13.9,
+ "maxMs": 16.1,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.4,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 137.7,
+ "minMs": 127.6,
+ "maxMs": 142.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 115,
+ "minMs": 113.1,
+ "maxMs": 119.8,
+ "maxLoopStallMs": 20.3,
+ "heapDeltaMb": 7.1,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 16,
+ "minMs": 14.2,
+ "maxMs": 16.6,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.5,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 239.8,
+ "minMs": 160.1,
+ "maxMs": 298.8,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 141.3,
+ "minMs": 136.6,
+ "maxMs": 144.2,
+ "maxLoopStallMs": 27,
+ "heapDeltaMb": 15,
+ "note": "usedIndex=true candidates=2360 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 132,
+ "minMs": 122.8,
+ "maxMs": 134.6,
+ "maxLoopStallMs": 7.8,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 104.4,
+ "minMs": 102.2,
+ "maxMs": 114.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 96.2,
+ "minMs": 93.8,
+ "maxMs": 103.7,
+ "maxLoopStallMs": 14,
+ "heapDeltaMb": 0.3,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 123.1,
+ "minMs": 119.8,
+ "maxMs": 128.7,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 0.2,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 117.8,
+ "minMs": 106,
+ "maxMs": 124,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 17.9,
+ "minMs": 16.5,
+ "maxMs": 23.3,
+ "maxLoopStallMs": 13,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 17.8,
+ "minMs": 17.1,
+ "maxMs": 21.2,
+ "maxLoopStallMs": 13,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 198.7,
+ "minMs": 198.7,
+ "maxMs": 198.7,
+ "maxLoopStallMs": 61.3,
+ "heapDeltaMb": 8,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 4.5,
+ "minMs": 4.3,
+ "maxMs": 5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.1,
+ "note": "33 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 405.2,
+ "minMs": 388.5,
+ "maxMs": 421.7,
+ "maxLoopStallMs": 11.8,
+ "heapDeltaMb": 13.7,
+ "note": "16000 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 90.8,
+ "minMs": 86.7,
+ "maxMs": 97.6,
+ "maxLoopStallMs": 17.4,
+ "heapDeltaMb": 0.2,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 119.8,
+ "minMs": 112.1,
+ "maxMs": 156.3,
+ "maxLoopStallMs": 10.8,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 96.8,
+ "minMs": 94.9,
+ "maxMs": 103.4,
+ "maxLoopStallMs": 14,
+ "heapDeltaMb": 0.5,
+ "note": "249 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 383.2,
+ "minMs": 350.6,
+ "maxMs": 387.3,
+ "maxLoopStallMs": 14.2,
+ "heapDeltaMb": 0.8,
+ "note": "249 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 584,
+ "minMs": 583.9,
+ "maxMs": 592.5,
+ "maxLoopStallMs": 83.3,
+ "heapDeltaMb": 26.6,
+ "note": "read 0"
+ }
+ ],
+ "facts": {
+ "indexFiles": 18975,
+ "indexTruncated": false,
+ "indexSymbols": 4577,
+ "indexSymbolFiles": 699,
+ "indexCacheMb": 2.8,
+ "queryFile": "create",
+ "grepGrams": 47061,
+ "grepPostingsMb": 30.2
+ }
+ }
+ ]
+}
diff --git a/apps/server/bench/results-2026-11-12.json b/apps/server/bench/results-2026-11-12.json
new file mode 100644
index 0000000..53ec03f
--- /dev/null
+++ b/apps/server/bench/results-2026-11-12.json
@@ -0,0 +1,1737 @@
+{
+ "when": "2026-11-12T01:35:58.009Z",
+ "node": "v22.23.4",
+ "runs": 3,
+ "ripgrep": true,
+ "ctags": false,
+ "results": [
+ {
+ "repo": "express",
+ "trackedFiles": 216,
+ "checkoutMb": 0.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 56.2,
+ "minMs": 50.5,
+ "maxMs": 76.6,
+ "maxLoopStallMs": 15.7,
+ "heapDeltaMb": 7.4,
+ "note": "216 files, read 132, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 45,
+ "minMs": 43.8,
+ "maxMs": 53.2,
+ "maxLoopStallMs": 9.8,
+ "heapDeltaMb": 5.1,
+ "note": "reused 216, read 101"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 2.4,
+ "minMs": 1.8,
+ "maxMs": 3.2,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 1.8,
+ "minMs": 1.8,
+ "maxMs": 1.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.8,
+ "note": "7 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 1.7,
+ "minMs": 1.7,
+ "maxMs": 1.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2,
+ "note": "7 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"index\")",
+ "medianMs": 0.5,
+ "minMs": 0.5,
+ "maxMs": 2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.3,
+ "note": "40 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 1.8,
+ "minMs": 1.5,
+ "maxMs": 2.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.4
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 79.3,
+ "minMs": 75.3,
+ "maxMs": 81.2,
+ "maxLoopStallMs": 14.2,
+ "heapDeltaMb": 11,
+ "note": "216 files, 4167 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 5.6,
+ "minMs": 5.5,
+ "maxMs": 6.1,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 2.8
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 20.2,
+ "minMs": 18.9,
+ "maxMs": 21.6,
+ "maxLoopStallMs": 7.6,
+ "heapDeltaMb": 3.3,
+ "note": "usedIndex=true candidates=28 27 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 14.3,
+ "minMs": 13.2,
+ "maxMs": 14.5,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.5,
+ "note": "28 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 9.3,
+ "minMs": 8.5,
+ "maxMs": 9.5,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 18.3,
+ "minMs": 17.7,
+ "maxMs": 21.8,
+ "maxLoopStallMs": 10,
+ "heapDeltaMb": 3.7,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 11.8,
+ "minMs": 11.5,
+ "maxMs": 13.1,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.8,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 10.4,
+ "minMs": 9.5,
+ "maxMs": 10.7,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 23.9,
+ "minMs": 22.4,
+ "maxMs": 25.2,
+ "maxLoopStallMs": 8.5,
+ "heapDeltaMb": 3.4,
+ "note": "usedIndex=true candidates=41 4 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 18.2,
+ "minMs": 16.5,
+ "maxMs": 18.2,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.4,
+ "note": "8 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 11.4,
+ "minMs": 11.2,
+ "maxMs": 12.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 12.1,
+ "minMs": 12,
+ "maxMs": 12.3,
+ "maxLoopStallMs": 6.4,
+ "heapDeltaMb": 3.1,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 15.1,
+ "minMs": 13.9,
+ "maxMs": 16.9,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 8.8,
+ "minMs": 8.5,
+ "maxMs": 9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 22.8,
+ "minMs": 20.8,
+ "maxMs": 26.3,
+ "maxLoopStallMs": 6.2,
+ "heapDeltaMb": 0.9,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 9.4,
+ "minMs": 9,
+ "maxMs": 10.5,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 0.6,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 58.4,
+ "minMs": 58.4,
+ "maxMs": 58.4,
+ "maxLoopStallMs": 11.5,
+ "heapDeltaMb": 3.6,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 2.5,
+ "minMs": 2.3,
+ "maxMs": 2.9,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.1,
+ "note": "14 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 9.2,
+ "minMs": 8.9,
+ "maxMs": 9.3,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 1.2,
+ "note": "284 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 13,
+ "minMs": 12.7,
+ "maxMs": 13.6,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.6,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 13.8,
+ "minMs": 13.8,
+ "maxMs": 15.2,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.6
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 14.4,
+ "minMs": 14.1,
+ "maxMs": 16.3,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.8,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 17.4,
+ "minMs": 17.2,
+ "maxMs": 18.5,
+ "maxLoopStallMs": 7.4,
+ "heapDeltaMb": 0.9,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 62.6,
+ "minMs": 59.6,
+ "maxMs": 63.5,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 7.7,
+ "note": "read 101"
+ }
+ ],
+ "facts": {
+ "indexFiles": 216,
+ "indexTruncated": false,
+ "indexSymbols": 104,
+ "indexSymbolFiles": 31,
+ "indexCacheMb": 0,
+ "queryFile": "index",
+ "grepGrams": 4167,
+ "grepPostingsMb": 0.2
+ }
+ },
+ {
+ "repo": "flask",
+ "trackedFiles": 236,
+ "checkoutMb": 1.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 50.5,
+ "minMs": 42,
+ "maxMs": 56,
+ "maxLoopStallMs": 8.8,
+ "heapDeltaMb": 8.2,
+ "note": "231 files, read 73, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 32.8,
+ "minMs": 29.3,
+ "maxMs": 38.2,
+ "maxLoopStallMs": 10,
+ "heapDeltaMb": 3.7,
+ "note": "reused 231, read 17"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 2.4,
+ "minMs": 2.1,
+ "maxMs": 2.8,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.8,
+ "minMs": 0.7,
+ "maxMs": 0.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.2,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 1.2,
+ "minMs": 1.2,
+ "maxMs": 1.4,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 1.8,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"ctx\")",
+ "medianMs": 1.1,
+ "minMs": 0.7,
+ "maxMs": 2.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.5,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 2.2,
+ "minMs": 2.2,
+ "maxMs": 2.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.4
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 128.1,
+ "minMs": 128,
+ "maxMs": 154.9,
+ "maxLoopStallMs": 22,
+ "heapDeltaMb": 16.8,
+ "note": "231 files, 8520 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 10.5,
+ "minMs": 8.9,
+ "maxMs": 11.8,
+ "maxLoopStallMs": 11.8,
+ "heapDeltaMb": 5.5
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 23.6,
+ "minMs": 21.4,
+ "maxMs": 24.2,
+ "maxLoopStallMs": 11.8,
+ "heapDeltaMb": 6.1,
+ "note": "usedIndex=true candidates=28 47 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 13,
+ "minMs": 12.5,
+ "maxMs": 13.1,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.7,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 9.9,
+ "minMs": 8.7,
+ "maxMs": 10.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 23.2,
+ "minMs": 21.3,
+ "maxMs": 27.3,
+ "maxLoopStallMs": 15.3,
+ "heapDeltaMb": 6.2,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 11.7,
+ "minMs": 10.9,
+ "maxMs": 11.7,
+ "maxLoopStallMs": 6.8,
+ "heapDeltaMb": 0.6,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 10.5,
+ "minMs": 8.7,
+ "maxMs": 13.8,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 25.8,
+ "minMs": 24.3,
+ "maxMs": 26.6,
+ "maxLoopStallMs": 10.8,
+ "heapDeltaMb": 6,
+ "note": "usedIndex=true candidates=41 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 18.1,
+ "minMs": 16.6,
+ "maxMs": 19.4,
+ "maxLoopStallMs": 6.5,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 12.3,
+ "minMs": 11.7,
+ "maxMs": 12.4,
+ "maxLoopStallMs": 5.1,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 18.8,
+ "minMs": 16.7,
+ "maxMs": 19.5,
+ "maxLoopStallMs": 13.3,
+ "heapDeltaMb": 5.8,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 14.6,
+ "minMs": 13.5,
+ "maxMs": 18.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 8.9,
+ "minMs": 8.3,
+ "maxMs": 9.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 10.6,
+ "minMs": 10,
+ "maxMs": 10.7,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.7,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 9.8,
+ "minMs": 9.7,
+ "maxMs": 11,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.7,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 57.2,
+ "minMs": 57.2,
+ "maxMs": 57.2,
+ "maxLoopStallMs": 10.2,
+ "heapDeltaMb": 5.9,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 2.5,
+ "minMs": 2.4,
+ "maxMs": 2.6,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.1,
+ "note": "15 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 9,
+ "minMs": 7.9,
+ "maxMs": 9.4,
+ "maxLoopStallMs": 5.9,
+ "heapDeltaMb": 1.2,
+ "note": "287 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 13.6,
+ "minMs": 13.2,
+ "maxMs": 13.6,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.5,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 13,
+ "minMs": 11.8,
+ "maxMs": 13.4,
+ "maxLoopStallMs": 7.1,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 13.6,
+ "minMs": 13.1,
+ "maxMs": 14.1,
+ "maxLoopStallMs": 5.6,
+ "heapDeltaMb": 0.7,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 20.5,
+ "minMs": 18.8,
+ "maxMs": 27.5,
+ "maxLoopStallMs": 7.2,
+ "heapDeltaMb": 0.8,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 49.7,
+ "minMs": 46.4,
+ "maxMs": 51.3,
+ "maxLoopStallMs": 8.5,
+ "heapDeltaMb": 7.2,
+ "note": "read 17"
+ }
+ ],
+ "facts": {
+ "indexFiles": 231,
+ "indexTruncated": false,
+ "indexSymbols": 574,
+ "indexSymbolFiles": 56,
+ "indexCacheMb": 0,
+ "queryFile": "ctx",
+ "grepGrams": 8520,
+ "grepPostingsMb": 0.4
+ }
+ },
+ {
+ "repo": "react",
+ "trackedFiles": 7276,
+ "checkoutMb": 38.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 456.9,
+ "minMs": 447,
+ "maxMs": 466.8,
+ "maxLoopStallMs": 35.3,
+ "heapDeltaMb": 18.6,
+ "note": "7195 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 357.5,
+ "minMs": 327.8,
+ "maxMs": 376.9,
+ "maxLoopStallMs": 32.2,
+ "heapDeltaMb": 18.3,
+ "note": "reused 7195, read 324"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 12.9,
+ "minMs": 12.6,
+ "maxMs": 13.1,
+ "maxLoopStallMs": 13.1,
+ "heapDeltaMb": 6.4
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 0.6,
+ "minMs": 0.6,
+ "maxMs": 0.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 1.1,
+ "note": "11 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 12.3,
+ "minMs": 10.9,
+ "maxMs": 14,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 3.7,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"Suspense\")",
+ "medianMs": 16.5,
+ "minMs": 13.8,
+ "maxMs": 17.8,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 10.1,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 91.3,
+ "minMs": 87.2,
+ "maxMs": 107.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 6.5
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 3311,
+ "minMs": 3154.3,
+ "maxMs": 3525.2,
+ "maxLoopStallMs": 121.8,
+ "heapDeltaMb": 87.5,
+ "note": "7195 files, 47916 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 112.5,
+ "minMs": 106,
+ "maxMs": 113,
+ "maxLoopStallMs": 102.2,
+ "heapDeltaMb": 47.7
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 139,
+ "minMs": 137.7,
+ "maxMs": 153.4,
+ "maxLoopStallMs": 98.7,
+ "heapDeltaMb": 49.2,
+ "note": "usedIndex=true candidates=10 31 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 58.2,
+ "minMs": 56.6,
+ "maxMs": 58.3,
+ "maxLoopStallMs": 10.4,
+ "heapDeltaMb": 0.4,
+ "note": "31 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 31.5,
+ "minMs": 30.2,
+ "maxMs": 35.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 161,
+ "minMs": 151.1,
+ "maxMs": 161.9,
+ "maxLoopStallMs": 114.5,
+ "heapDeltaMb": 51.4,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 29.2,
+ "minMs": 27.6,
+ "maxMs": 31.6,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.8,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 100.3,
+ "minMs": 63,
+ "maxMs": 110.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 163.3,
+ "minMs": 157.6,
+ "maxMs": 165.3,
+ "maxLoopStallMs": 103,
+ "heapDeltaMb": 51.1,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 41.5,
+ "minMs": 41.3,
+ "maxMs": 44.7,
+ "maxLoopStallMs": 8.6,
+ "heapDeltaMb": 0.6,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 37.4,
+ "minMs": 32.6,
+ "maxMs": 42.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 142,
+ "minMs": 141.5,
+ "maxMs": 152.9,
+ "maxLoopStallMs": 103.8,
+ "heapDeltaMb": 48.3,
+ "note": "usedIndex=true candidates=3 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 57.3,
+ "minMs": 55.1,
+ "maxMs": 73.4,
+ "maxLoopStallMs": 7.3,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 33.4,
+ "minMs": 29.1,
+ "maxMs": 33.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 62.4,
+ "minMs": 53.8,
+ "maxMs": 62.8,
+ "maxLoopStallMs": 8.5,
+ "heapDeltaMb": 0.7,
+ "note": "49 hits truncated=false"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 14.8,
+ "minMs": 12.1,
+ "maxMs": 15.1,
+ "maxLoopStallMs": 8.3,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 3525.4,
+ "minMs": 3525.4,
+ "maxMs": 3525.4,
+ "maxLoopStallMs": 2439,
+ "heapDeltaMb": 57.1,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 5.1,
+ "minMs": 2,
+ "maxMs": 7.1,
+ "maxLoopStallMs": 5.7,
+ "heapDeltaMb": 0.1,
+ "note": "35 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 231.4,
+ "minMs": 213.7,
+ "maxMs": 348.2,
+ "maxLoopStallMs": 70.8,
+ "heapDeltaMb": 7.5,
+ "note": "7863 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 30.3,
+ "minMs": 28,
+ "maxMs": 32.6,
+ "maxLoopStallMs": 8.1,
+ "heapDeltaMb": 0.5,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 40.5,
+ "minMs": 34.1,
+ "maxMs": 42.6,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 33.5,
+ "minMs": 33.3,
+ "maxMs": 35.8,
+ "maxLoopStallMs": 7.1,
+ "heapDeltaMb": 0.7,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 43.5,
+ "minMs": 41.1,
+ "maxMs": 51.5,
+ "maxLoopStallMs": 13,
+ "heapDeltaMb": 0.8,
+ "note": "226 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 386.3,
+ "minMs": 372,
+ "maxMs": 426.1,
+ "maxLoopStallMs": 34.6,
+ "heapDeltaMb": 23,
+ "note": "read 324"
+ }
+ ],
+ "facts": {
+ "indexFiles": 7195,
+ "indexTruncated": false,
+ "indexSymbols": 3003,
+ "indexSymbolFiles": 476,
+ "indexCacheMb": 1.2,
+ "queryFile": "Suspense",
+ "grepGrams": 47916,
+ "grepPostingsMb": 12.2
+ }
+ },
+ {
+ "repo": "django",
+ "trackedFiles": 7100,
+ "checkoutMb": 44.2,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 611.2,
+ "minMs": 578.2,
+ "maxMs": 630.2,
+ "maxLoopStallMs": 29.2,
+ "heapDeltaMb": 20.7,
+ "note": "5602 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 542,
+ "minMs": 481.6,
+ "maxMs": 575.5,
+ "maxLoopStallMs": 36.9,
+ "heapDeltaMb": 7,
+ "note": "reused 5602, read 298"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 10.1,
+ "minMs": 10,
+ "maxMs": 14.7,
+ "maxLoopStallMs": 14.7,
+ "heapDeltaMb": 5.2
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 1.3,
+ "minMs": 1.3,
+ "maxMs": 4.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2.4,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 8.4,
+ "minMs": 6.5,
+ "maxMs": 22.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2.7,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"jsonb\")",
+ "medianMs": 6.4,
+ "minMs": 6.2,
+ "maxMs": 8.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 5.7,
+ "note": "46 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 43.9,
+ "minMs": 36.5,
+ "maxMs": 45.1,
+ "maxLoopStallMs": 7.1,
+ "heapDeltaMb": 0.4
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 2907,
+ "minMs": 2859,
+ "maxMs": 2972.8,
+ "maxLoopStallMs": 115.4,
+ "heapDeltaMb": 48.6,
+ "note": "5602 files, 19868 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 82.2,
+ "minMs": 74.7,
+ "maxMs": 84.4,
+ "maxLoopStallMs": 74.3,
+ "heapDeltaMb": 52.6
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 133.4,
+ "minMs": 120.8,
+ "maxMs": 197.6,
+ "maxLoopStallMs": 133.4,
+ "heapDeltaMb": 54,
+ "note": "usedIndex=true candidates=40 16 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 79.5,
+ "minMs": 77.1,
+ "maxMs": 86.1,
+ "maxLoopStallMs": 9.6,
+ "heapDeltaMb": 0.4,
+ "note": "16 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 39.3,
+ "minMs": 34.8,
+ "maxMs": 46.4,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 128.2,
+ "minMs": 123.8,
+ "maxMs": 136.1,
+ "maxLoopStallMs": 73.2,
+ "heapDeltaMb": 54.2,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 46,
+ "minMs": 45.5,
+ "maxMs": 57.7,
+ "maxLoopStallMs": 8.3,
+ "heapDeltaMb": 1.2,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 48.8,
+ "minMs": 43,
+ "maxMs": 55.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 130.5,
+ "minMs": 122.4,
+ "maxMs": 143.7,
+ "maxLoopStallMs": 80.5,
+ "heapDeltaMb": 54.7,
+ "note": "usedIndex=true candidates=679 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 79.9,
+ "minMs": 76.4,
+ "maxMs": 80.8,
+ "maxLoopStallMs": 11.9,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 37.3,
+ "minMs": 35.9,
+ "maxMs": 37.4,
+ "maxLoopStallMs": 5.3,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 115.6,
+ "minMs": 98.4,
+ "maxMs": 121.3,
+ "maxLoopStallMs": 61.1,
+ "heapDeltaMb": 53.2,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 80.7,
+ "minMs": 76,
+ "maxMs": 87.8,
+ "maxLoopStallMs": 10.7,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 34.6,
+ "minMs": 33.6,
+ "maxMs": 35.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 75.8,
+ "minMs": 69.5,
+ "maxMs": 78.7,
+ "maxLoopStallMs": 8.6,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 13.5,
+ "minMs": 12.1,
+ "maxMs": 14.1,
+ "maxLoopStallMs": 8.2,
+ "heapDeltaMb": 0.9,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 1677.1,
+ "minMs": 1677.1,
+ "maxMs": 1677.1,
+ "maxLoopStallMs": 533.7,
+ "heapDeltaMb": 46.5,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 4.1,
+ "minMs": 1.9,
+ "maxMs": 4.4,
+ "maxLoopStallMs": 5.5,
+ "heapDeltaMb": 0.1,
+ "note": "28 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 260.8,
+ "minMs": 247,
+ "maxMs": 276.7,
+ "maxLoopStallMs": 10.5,
+ "heapDeltaMb": 13,
+ "note": "10244 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 48.2,
+ "minMs": 47,
+ "maxMs": 55.5,
+ "maxLoopStallMs": 8.2,
+ "heapDeltaMb": 0.5,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 47.2,
+ "minMs": 45.2,
+ "maxMs": 48.4,
+ "maxLoopStallMs": 8.2,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 45.9,
+ "minMs": 44.2,
+ "maxMs": 51.5,
+ "maxLoopStallMs": 8.9,
+ "heapDeltaMb": 0.8,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 50.4,
+ "minMs": 49.6,
+ "maxMs": 60.3,
+ "maxLoopStallMs": 8.6,
+ "heapDeltaMb": 0.8,
+ "note": "250 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 437.6,
+ "minMs": 414.1,
+ "maxMs": 444.2,
+ "maxLoopStallMs": 26.7,
+ "heapDeltaMb": 14.8,
+ "note": "read 298"
+ }
+ ],
+ "facts": {
+ "indexFiles": 5602,
+ "indexTruncated": false,
+ "indexSymbols": 4209,
+ "indexSymbolFiles": 502,
+ "indexCacheMb": 0.7,
+ "queryFile": "jsonb",
+ "grepGrams": 19868,
+ "grepPostingsMb": 9.3
+ }
+ },
+ {
+ "repo": "vscode",
+ "trackedFiles": 21212,
+ "checkoutMb": 338.8,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 1343.7,
+ "minMs": 1331.8,
+ "maxMs": 1366.2,
+ "maxLoopStallMs": 128.3,
+ "heapDeltaMb": 43.2,
+ "note": "19666 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 1052.8,
+ "minMs": 1022,
+ "maxMs": 1072.4,
+ "maxLoopStallMs": 96.4,
+ "heapDeltaMb": 41.8,
+ "note": "reused 19666, read 45"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 27,
+ "minMs": 24.2,
+ "maxMs": 27.9,
+ "maxLoopStallMs": 28,
+ "heapDeltaMb": 15.9
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 1.1,
+ "minMs": 1,
+ "maxMs": 1.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 78.2,
+ "minMs": 75.7,
+ "maxMs": 92.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 5.1,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"ThreadMe\")",
+ "medianMs": 40.7,
+ "minMs": 31.2,
+ "maxMs": 45.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 6.2,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 253.1,
+ "minMs": 233.8,
+ "maxMs": 261.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 10.2
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 17052.8,
+ "minMs": 17045.8,
+ "maxMs": 17111.7,
+ "maxLoopStallMs": 389,
+ "heapDeltaMb": 137.5,
+ "note": "19666 files, 49263 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 325.2,
+ "minMs": 324.3,
+ "maxMs": 336.7,
+ "maxLoopStallMs": 277.6,
+ "heapDeltaMb": 208
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 413.5,
+ "minMs": 391.2,
+ "maxMs": 415.2,
+ "maxLoopStallMs": 262.8,
+ "heapDeltaMb": 212.5,
+ "note": "usedIndex=true candidates=13 2 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 160.2,
+ "minMs": 151.9,
+ "maxMs": 161.9,
+ "maxLoopStallMs": 10.3,
+ "heapDeltaMb": 0.4,
+ "note": "2 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 85.5,
+ "minMs": 81.4,
+ "maxMs": 86.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 441.8,
+ "minMs": 438.8,
+ "maxMs": 460.8,
+ "maxLoopStallMs": 301.7,
+ "heapDeltaMb": 207.1,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 78.5,
+ "minMs": 77.4,
+ "maxMs": 82.2,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 0.7,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 237.3,
+ "minMs": 147.7,
+ "maxMs": 241.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 503,
+ "minMs": 478.8,
+ "maxMs": 529.5,
+ "maxLoopStallMs": 281,
+ "heapDeltaMb": 210.6,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 94.9,
+ "minMs": 94,
+ "maxMs": 98.1,
+ "maxLoopStallMs": 11.7,
+ "heapDeltaMb": 0.5,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 80.1,
+ "minMs": 79.1,
+ "maxMs": 83.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 413.9,
+ "minMs": 402.6,
+ "maxMs": 436.7,
+ "maxLoopStallMs": 294.9,
+ "heapDeltaMb": 206.3,
+ "note": "usedIndex=true candidates=2 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 161.8,
+ "minMs": 151.1,
+ "maxMs": 162.1,
+ "maxLoopStallMs": 9,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 72.6,
+ "minMs": 71.9,
+ "maxMs": 87.2,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 136.7,
+ "minMs": 133.5,
+ "maxMs": 153.8,
+ "maxLoopStallMs": 9.7,
+ "heapDeltaMb": 0.5,
+ "note": "3 hits truncated=false"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 14.6,
+ "minMs": 12.9,
+ "maxMs": 16.5,
+ "maxLoopStallMs": 8.2,
+ "heapDeltaMb": 0.6,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 12036.7,
+ "minMs": 12036.7,
+ "maxMs": 12036.7,
+ "maxLoopStallMs": 7751.1,
+ "heapDeltaMb": 334,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 5.7,
+ "minMs": 2.5,
+ "maxMs": 6.1,
+ "maxLoopStallMs": 5.8,
+ "heapDeltaMb": 0.1,
+ "note": "45 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 364.3,
+ "minMs": 357.7,
+ "maxMs": 418.7,
+ "maxLoopStallMs": 31.9,
+ "heapDeltaMb": 12.6,
+ "note": "16010 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 78.4,
+ "minMs": 76.4,
+ "maxMs": 79.5,
+ "maxLoopStallMs": 9.2,
+ "heapDeltaMb": 0.5,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 82,
+ "minMs": 79.5,
+ "maxMs": 84.5,
+ "maxLoopStallMs": 8.4,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 85,
+ "minMs": 78.1,
+ "maxMs": 89.9,
+ "maxLoopStallMs": 10.8,
+ "heapDeltaMb": 0.7,
+ "note": "250 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 99.5,
+ "minMs": 81.6,
+ "maxMs": 103.1,
+ "maxLoopStallMs": 10.3,
+ "heapDeltaMb": 0.8,
+ "note": "201 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 1066.4,
+ "minMs": 1037.5,
+ "maxMs": 1076.3,
+ "maxLoopStallMs": 100.7,
+ "heapDeltaMb": 38.9,
+ "note": "read 45"
+ }
+ ],
+ "facts": {
+ "indexFiles": 19666,
+ "indexTruncated": false,
+ "indexSymbols": 3604,
+ "indexSymbolFiles": 755,
+ "indexCacheMb": 2.8,
+ "queryFile": "ThreadMe",
+ "grepGrams": 49263,
+ "grepPostingsMb": 56.9
+ }
+ },
+ {
+ "repo": "kubernetes",
+ "trackedFiles": 31757,
+ "checkoutMb": 269.7,
+ "samples": [
+ {
+ "op": "index: cold build",
+ "medianMs": 1224.5,
+ "minMs": 1221.6,
+ "maxMs": 1270.2,
+ "maxLoopStallMs": 94.6,
+ "heapDeltaMb": 41.7,
+ "note": "18975 files, read 800, truncated=false"
+ },
+ {
+ "op": "index: warm rebuild (force, mtime reuse)",
+ "medianMs": 1134.6,
+ "minMs": 1090.5,
+ "maxMs": 1171.1,
+ "maxLoopStallMs": 87.4,
+ "heapDeltaMb": 36.7,
+ "note": "reused 18975, read 101"
+ },
+ {
+ "op": "index: hydrate from disk (new process)",
+ "medianMs": 29.7,
+ "minMs": 29.4,
+ "maxMs": 29.8,
+ "maxLoopStallMs": 29.4,
+ "heapDeltaMb": 15.4
+ },
+ {
+ "op": "repo_map: formatRepoMap (no query)",
+ "medianMs": 1.3,
+ "minMs": 1.1,
+ "maxMs": 1.3,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 2.3,
+ "note": "12 KB output"
+ },
+ {
+ "op": "repo_map: per-turn ranked prompt (query+rank)",
+ "medianMs": 63.6,
+ "minMs": 59.8,
+ "maxMs": 71.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 4.7,
+ "note": "12 KB output"
+ },
+ {
+ "op": "quick open: searchRepoMap(\"create\")",
+ "medianMs": 40.4,
+ "minMs": 36.2,
+ "maxMs": 47.6,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 7.2,
+ "note": "50 hits"
+ },
+ {
+ "op": "quick open: searchRepoMap per keystroke x6",
+ "medianMs": 228.2,
+ "minMs": 197.1,
+ "maxMs": 261.1,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 7.8
+ },
+ {
+ "op": "grep index: build",
+ "medianMs": 12043.8,
+ "minMs": 11819.5,
+ "maxMs": 12084.1,
+ "maxLoopStallMs": 303.6,
+ "heapDeltaMb": 293.6,
+ "note": "18975 files, 47061 grams"
+ },
+ {
+ "op": "grep index: load from disk (every call)",
+ "medianMs": 246,
+ "minMs": 230,
+ "maxMs": 248,
+ "maxLoopStallMs": 206.7,
+ "heapDeltaMb": 165.6
+ },
+ {
+ "op": "agent grep (indexed): selective literal",
+ "medianMs": 390.6,
+ "minMs": 370.3,
+ "maxMs": 406.6,
+ "maxLoopStallMs": 218.8,
+ "heapDeltaMb": 149.4,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): selective literal",
+ "medianMs": 106,
+ "minMs": 103,
+ "maxMs": 107.4,
+ "maxLoopStallMs": 10.6,
+ "heapDeltaMb": 1,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: selective literal",
+ "medianMs": 119.3,
+ "minMs": 117,
+ "maxMs": 133.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): common word",
+ "medianMs": 367.5,
+ "minMs": 342.6,
+ "maxMs": 382.3,
+ "maxLoopStallMs": 235.5,
+ "heapDeltaMb": 149.4,
+ "note": "usedIndex=false candidates=- 47 KB"
+ },
+ {
+ "op": "agent grep (no index): common word",
+ "medianMs": 102.8,
+ "minMs": 102.6,
+ "maxMs": 114.5,
+ "maxLoopStallMs": 15.5,
+ "heapDeltaMb": 0.8,
+ "note": "47 KB"
+ },
+ {
+ "op": "baseline rg -l: common word",
+ "medianMs": 227.5,
+ "minMs": 216.1,
+ "maxMs": 244.7,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): regex",
+ "medianMs": 405.3,
+ "minMs": 394.3,
+ "maxMs": 415.2,
+ "maxLoopStallMs": 209.7,
+ "heapDeltaMb": 151.7,
+ "note": "usedIndex=true candidates=2360 0 KB"
+ },
+ {
+ "op": "agent grep (no index): regex",
+ "medianMs": 234.6,
+ "minMs": 227.8,
+ "maxMs": 253.8,
+ "maxLoopStallMs": 13.2,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: regex",
+ "medianMs": 109,
+ "minMs": 104.1,
+ "maxMs": 109.9,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "agent grep (indexed): no match",
+ "medianMs": 348.8,
+ "minMs": 346.4,
+ "maxMs": 371.2,
+ "maxLoopStallMs": 226.6,
+ "heapDeltaMb": 149.1,
+ "note": "usedIndex=true candidates=0 0 KB"
+ },
+ {
+ "op": "agent grep (no index): no match",
+ "medianMs": 213,
+ "minMs": 212.7,
+ "maxMs": 215.6,
+ "maxLoopStallMs": 12.5,
+ "heapDeltaMb": 0.4,
+ "note": "0 KB"
+ },
+ {
+ "op": "baseline rg -l: no match",
+ "medianMs": 95.9,
+ "minMs": 94,
+ "maxMs": 103.5,
+ "maxLoopStallMs": 5.2,
+ "heapDeltaMb": 0.2
+ },
+ {
+ "op": "files search: selective literal",
+ "medianMs": 14.5,
+ "minMs": 12.4,
+ "maxMs": 15.1,
+ "maxLoopStallMs": 6.8,
+ "heapDeltaMb": 0.8,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word",
+ "medianMs": 13.4,
+ "minMs": 13.2,
+ "maxMs": 13.9,
+ "maxLoopStallMs": 8.2,
+ "heapDeltaMb": 0.9,
+ "note": "80 hits truncated=true"
+ },
+ {
+ "op": "files search: common word (walk fallback)",
+ "medianMs": 9515.6,
+ "minMs": 9515.6,
+ "maxMs": 9515.6,
+ "maxLoopStallMs": 5997.9,
+ "heapDeltaMb": 200.5,
+ "note": "80 hits"
+ },
+ {
+ "op": "list: depth 1",
+ "medianMs": 5.1,
+ "minMs": 1.7,
+ "maxMs": 5.2,
+ "maxLoopStallMs": 6,
+ "heapDeltaMb": 0.1,
+ "note": "33 entries"
+ },
+ {
+ "op": "list: full tree (no depth)",
+ "medianMs": 385.2,
+ "minMs": 373.2,
+ "maxMs": 408.1,
+ "maxLoopStallMs": 14.9,
+ "heapDeltaMb": 8.8,
+ "note": "16010 entries"
+ },
+ {
+ "op": "git: status (clean)",
+ "medianMs": 96.6,
+ "minMs": 92.6,
+ "maxMs": 101.4,
+ "maxLoopStallMs": 9.3,
+ "heapDeltaMb": 0.5,
+ "note": "0 files"
+ },
+ {
+ "op": "git: dirty stats (clean)",
+ "medianMs": 105.9,
+ "minMs": 94.3,
+ "maxMs": 123,
+ "maxLoopStallMs": 10.5,
+ "heapDeltaMb": 0.5
+ },
+ {
+ "op": "git: status (50 mod + 200 untracked)",
+ "medianMs": 113.1,
+ "minMs": 102.9,
+ "maxMs": 116.8,
+ "maxLoopStallMs": 11.1,
+ "heapDeltaMb": 0.7,
+ "note": "249 files"
+ },
+ {
+ "op": "git: dirty stats (50 mod + 200 untracked)",
+ "medianMs": 314.5,
+ "minMs": 288,
+ "maxMs": 339,
+ "maxLoopStallMs": 9.4,
+ "heapDeltaMb": 0.8,
+ "note": "249 rows"
+ },
+ {
+ "op": "index: incremental rebuild after edits",
+ "medianMs": 1200.8,
+ "minMs": 1179.3,
+ "maxMs": 1266.2,
+ "maxLoopStallMs": 87.1,
+ "heapDeltaMb": 36.2,
+ "note": "read 101"
+ }
+ ],
+ "facts": {
+ "indexFiles": 18975,
+ "indexTruncated": false,
+ "indexSymbols": 4577,
+ "indexSymbolFiles": 699,
+ "indexCacheMb": 2.7,
+ "queryFile": "create",
+ "grepGrams": 47061,
+ "grepPostingsMb": 41.5
+ }
+ }
+ ]
+}
diff --git a/apps/server/package.json b/apps/server/package.json
index d637f7b..6d81568 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/server",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Locus control plane — loopback Hono API, WebSocket hub, and session services",
"type": "module",
@@ -20,6 +20,7 @@
"@locus/providers": "*",
"@locus/shared": "*",
"@locus/worker": "*",
+ "@vscode/ripgrep": "^1.18.0",
"hono": "^4.13.13",
"typescript-ls": "npm:typescript@^5.9.3",
"vscode-css-languageservice": "^6.3.10",
@@ -33,6 +34,6 @@
"@types/ws": "^8.18.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts
index fdebc42..e78d792 100644
--- a/apps/server/src/app.test.ts
+++ b/apps/server/src/app.test.ts
@@ -4285,6 +4285,9 @@ describe("docker agents", () => {
model: config.providers[0]!.defaultModel,
startVm: true,
});
+ // Boot first: a turn sent mid-boot can be auto-resumed once the guest is
+ // ready, adding a later checkpoint after this test writes v2.
+ await waitUntilDockerRunning(ctx.sessions, created.id);
const vm = ctx.sessions.getMachine(created.vmId!);
const ws = vm!.diskPath;
await execFileAsync("git", ["init"], { cwd: ws });
@@ -6691,7 +6694,7 @@ describe("golden images, media, and abort", () => {
return new Response("Not Found", { status: 404 });
}) as typeof fetch,
});
- const res = await app.request("/api/updates?current=0.20.0");
+ const res = await app.request("/api/updates?current=0.21.0");
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string; message?: string };
expect(body.status).toBe("unavailable");
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index a629493..0acbf32 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -37,6 +37,9 @@ import { registerShareRoutes } from "./http/shareRoutes.js";
import { registerDebugRoutes } from "./http/debugRoutes.js";
import { registerMachineRoutes } from "./http/machineRoutes.js";
import { registerTailscaleRoutes } from "./http/tailscaleRoutes.js";
+import { registerBackupRoutes } from "./http/backupRoutes.js";
+import { registerBenchmarkRoutes } from "./http/benchmarkRoutes.js";
+import { BenchmarkService } from "./evals/benchmark.js";
import {
attachPhoneGate,
attachCanvasPreviewHostGate,
@@ -86,6 +89,7 @@ export function createApp(opts: {
slack?: SlackGateway;
brain?: BrainService;
workflows?: WorkflowService;
+ benchmarks?: BenchmarkService;
peerAddress?: (c: import("hono").Context) => string | undefined;
phoneTls?: { fingerprint: string };
}): Hono {
@@ -243,6 +247,8 @@ export function createApp(opts: {
registerMachineRoutes(app, { store: opts.store, sessions: opts.sessions });
registerDockerRoutes(app, opts.sessions.dockerInventory);
registerTailscaleRoutes(app, { store: opts.store, runner });
+ registerBackupRoutes(app, { store: opts.store, peerAddress: opts.peerAddress });
+ registerBenchmarkRoutes(app, opts.benchmarks ?? new BenchmarkService(opts.store, opts.sessions));
const grafana = new GrafanaExplorer(opts.store, opts.sessions, opts.fetchFn ?? fetch, brain);
registerSlackRoutes(app, slack, slackOauth, opts.store);
diff --git a/apps/server/src/core/backup.test.ts b/apps/server/src/core/backup.test.ts
new file mode 100644
index 0000000..3e8a6ad
--- /dev/null
+++ b/apps/server/src/core/backup.test.ts
@@ -0,0 +1,172 @@
+import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ RESTORE_MARKER,
+ RESTORE_STAGING_DIR,
+ applyPendingRestore,
+ createBackup,
+ extractBackup,
+ includeTopLevel,
+ inspectBackup,
+ pendingRestore,
+ safeJoin,
+ stageRestore,
+} from "./backup.js";
+import { JsonStore } from "./store.js";
+
+const cleanup: string[] = [];
+const savedKey = process.env.LOCUS_MASTER_KEY;
+
+afterEach(async () => {
+ if (savedKey == null) delete process.env.LOCUS_MASTER_KEY;
+ else process.env.LOCUS_MASTER_KEY = savedKey;
+ await Promise.all(cleanup.splice(0).map((d) => rm(d, { recursive: true, force: true })));
+});
+
+async function tmp(prefix: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), prefix));
+ cleanup.push(dir);
+ return dir;
+}
+
+async function seedSource(): Promise {
+ delete process.env.LOCUS_MASTER_KEY;
+ const dir = await tmp("locus-bak-src-");
+ const store = new JsonStore(dir);
+ await store.init();
+ const config = await store.loadConfig();
+ config.providers[0]!.apiKey = "sk-from-old-machine";
+ await store.saveConfig(config);
+ await mkdir(join(dir, "sessions"), { recursive: true });
+ await writeFile(
+ join(dir, "sessions", "s1.json"),
+ JSON.stringify({ id: "s1", workspace: join(dir, "docker-workspaces", "s1") }),
+ );
+ await mkdir(join(dir, "docker-workspaces", "s1", "node_modules", "x"), { recursive: true });
+ await writeFile(join(dir, "docker-workspaces", "s1", "app.js"), "console.log(1)\n");
+ await writeFile(join(dir, "docker-workspaces", "s1", "node_modules", "x", "i.js"), "big");
+ await mkdir(join(dir, "disks"), { recursive: true });
+ await writeFile(join(dir, "disks", "vm.qcow2"), Buffer.alloc(4096, 1));
+ await mkdir(join(dir, "logs"), { recursive: true });
+ await writeFile(join(dir, "logs", "server.log"), "noise");
+ return dir;
+}
+
+describe("backup", () => {
+ it("excludes keys, caches and (by default) disks at the top level", () => {
+ for (const name of ["master.key", "master.key.safe", "secrets.enc", "logs", "updates"]) {
+ expect(includeTopLevel(name, true)).toBe(false);
+ }
+ expect(includeTopLevel(".before-restore-2026", true)).toBe(false);
+ expect(includeTopLevel("disks", false)).toBe(false);
+ expect(includeTopLevel("disks", true)).toBe(true);
+ expect(includeTopLevel("sessions", false)).toBe(true);
+ });
+
+ it("refuses paths that escape the restore root", () => {
+ const root = join(tmpdir(), "root");
+ expect(() => safeJoin(root, "../evil")).toThrow(/Unsafe/);
+ expect(() => safeJoin(root, "/etc/passwd")).toThrow(/Unsafe/);
+ expect(() => safeJoin(root, "a//b")).toThrow(/Unsafe/);
+ expect(safeJoin(root, "sessions/a.json")).toBe(join(root, "sessions", "a.json"));
+ });
+
+ it("round-trips to another machine: new key, same secrets, rewritten paths", async () => {
+ const src = await seedSource();
+ const out = join(await tmp("locus-bak-out-"), "b.locusbak");
+ const made = await createBackup({ dataDir: src, passphrase: "correct horse", outPath: out });
+ expect(made.manifest.dataDir).toBe(src);
+ expect(made.manifest.includeDisks).toBe(false);
+
+ const raw = await readFile(out);
+ expect(raw.subarray(0, 10).toString()).toBe("LOCUSBAK1\n");
+ expect(raw.includes(Buffer.from("sk-from-old-machine"))).toBe(false);
+
+ const peek = await extractBackup({
+ path: out,
+ passphrase: "correct horse",
+ destDir: await tmp("locus-bak-peek-"),
+ });
+ expect(Object.values(peek.vault.providerKeys)).toContain("sk-from-old-machine");
+
+ // The "new machine" has its own data dir and master key.
+ const dest = await tmp("locus-bak-dest-");
+ const destStore = new JsonStore(dest);
+ await destStore.init();
+ await writeFile(join(dest, "stale.json"), "{}");
+ const staged = await stageRestore({ dataDir: dest, path: out, passphrase: "correct horse" });
+ expect(staged.restartRequired).toBe(true);
+ expect(await pendingRestore(dest)).toBeDefined();
+ // Live state is untouched until the next start.
+ expect(await readFile(join(dest, "stale.json"), "utf8")).toBe("{}");
+
+ const applied = await applyPendingRestore(dest);
+ expect(applied.applied).toBe(true);
+ const names = await readdir(dest);
+ expect(names).not.toContain(RESTORE_MARKER);
+ expect(names).not.toContain(RESTORE_STAGING_DIR);
+ expect(names).not.toContain("stale.json");
+ expect(await readdir(applied.movedAsideTo!)).toContain("stale.json");
+ expect(names).not.toContain("logs");
+ await expect(stat(join(dest, "docker-workspaces", "s1", "node_modules"))).rejects.toThrow();
+ expect(await readFile(join(dest, "docker-workspaces", "s1", "app.js"), "utf8")).toContain(
+ "console",
+ );
+
+ const session = JSON.parse(await readFile(join(dest, "sessions", "s1.json"), "utf8"));
+ expect(session.workspace).toBe(join(dest, "docker-workspaces", "s1"));
+
+ const config = await new JsonStore(dest).loadConfig();
+ expect(config.providers[0]!.apiKey).toBe("sk-from-old-machine");
+ });
+
+ it("includes disks when asked", async () => {
+ const src = await seedSource();
+ const out = join(await tmp("locus-bak-out-"), "d.locusbak");
+ await createBackup({
+ dataDir: src,
+ passphrase: "correct horse",
+ outPath: out,
+ includeDisks: true,
+ });
+ const dest = await tmp("locus-bak-x-");
+ await extractBackup({ path: out, passphrase: "correct horse", destDir: dest });
+ expect((await stat(join(dest, "disks", "vm.qcow2"))).size).toBe(4096);
+ });
+
+ it("rejects a wrong passphrase and a short one", async () => {
+ const src = await seedSource();
+ const out = join(await tmp("locus-bak-out-"), "w.locusbak");
+ await createBackup({ dataDir: src, passphrase: "correct horse", outPath: out });
+ await expect(
+ inspectBackup({ path: out, passphrase: "wrong horse", scratchDir: await tmp("s-") }),
+ ).rejects.toThrow(/Wrong passphrase/);
+ await expect(createBackup({ dataDir: src, passphrase: "short", outPath: out })).rejects.toThrow(
+ /at least 8/,
+ );
+ const dest = await tmp("locus-bak-wrong-");
+ await expect(
+ stageRestore({ dataDir: dest, path: out, passphrase: "wrong horse" }),
+ ).rejects.toThrow(/Wrong passphrase/);
+ expect(await readdir(dest)).not.toContain(RESTORE_STAGING_DIR);
+ });
+
+ it("rejects a tampered archive", async () => {
+ const src = await seedSource();
+ const out = join(await tmp("locus-bak-out-"), "t.locusbak");
+ await createBackup({ dataDir: src, passphrase: "correct horse", outPath: out });
+ const raw = await readFile(out);
+ raw[raw.length - 40] = raw[raw.length - 40]! ^ 0xff;
+ await writeFile(out, raw);
+ await expect(
+ inspectBackup({ path: out, passphrase: "correct horse", scratchDir: await tmp("s-") }),
+ ).rejects.toThrow(/Wrong passphrase|damaged/);
+ });
+
+ it("is a no-op when nothing is staged", async () => {
+ const dir = await tmp("locus-bak-none-");
+ expect(await applyPendingRestore(dir)).toEqual({ applied: false });
+ });
+});
diff --git a/apps/server/src/core/backup.ts b/apps/server/src/core/backup.ts
new file mode 100644
index 0000000..54d74cd
--- /dev/null
+++ b/apps/server/src/core/backup.ts
@@ -0,0 +1,710 @@
+/**
+ * Back up and restore the Locus data directory (`~/.locus`).
+ *
+ * A backup is one passphrase-encrypted file:
+ *
+ * LOCUSBAK1\n
+ * {"format":1,"kdf":"scrypt",...}\n (header: salt, iv, scrypt cost)
+ * AES-256-GCM( gzip( records ) )
+ * 16-byte GCM tag
+ *
+ * Each record is a 4-byte big-endian header length, a JSON header, then `size` bytes.
+ * The vault is not copied as `secrets.enc` (that file is bound to this machine's master
+ * key or OS keychain). Its decrypted contents travel as a `vault` record inside the
+ * encrypted archive and are re-encrypted with the target machine's key on restore.
+ *
+ * Restore never edits live state. It extracts into `.restore-staging`, re-encrypts the
+ * vault, rewrites old data-dir paths, then writes `restore-pending.json`. The next control
+ * plane start calls `applyPendingRestore` before anything loads: the current files move
+ * to `.before-restore-/` and the staged tree takes their place.
+ */
+import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from "node:crypto";
+import {
+ closeSync,
+ createReadStream,
+ createWriteStream,
+ mkdirSync,
+ openSync,
+ symlinkSync,
+ writeSync,
+} from "node:fs";
+import {
+ appendFile,
+ lstat,
+ mkdir,
+ open,
+ readFile,
+ readdir,
+ readlink,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
+import { Readable, Writable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { createGunzip, createGzip } from "node:zlib";
+import {
+ emptySecrets,
+ loadOrCreateMasterKey,
+ readVaultFile,
+ writeVaultFile,
+ type SecretBundle,
+} from "./vault.js";
+
+export const BACKUP_MAGIC = "LOCUSBAK1\n";
+export const BACKUP_EXTENSION = ".locusbak";
+export const RESTORE_STAGING_DIR = ".restore-staging";
+export const RESTORE_MARKER = "restore-pending.json";
+export const RESTORE_FAILED_MARKER = "restore-failed.json";
+const BEFORE_RESTORE_PREFIX = ".before-restore-";
+const MIN_PASSPHRASE = 8;
+const TAG_BYTES = 16;
+const SCRYPT = { N: 1 << 15, r: 8, p: 1 };
+
+/** Never in a backup: machine-bound keys, restore bookkeeping, caches and downloads. */
+const ALWAYS_EXCLUDED = new Set([
+ "master.key",
+ "master.key.safe",
+ "master.salt",
+ "secrets.enc",
+ RESTORE_STAGING_DIR,
+ RESTORE_MARKER,
+ RESTORE_FAILED_MARKER,
+ "updates",
+ "logs",
+ "indexes",
+ "host-chrome",
+ "worker-mirrors",
+]);
+/** Large VM files. Opt in with `includeDisks`. */
+const DISK_DIRS = ["disks", "images", "media", "vm-trees"];
+/** Dependency folders anywhere in the tree. They are rebuilt by `npm install` / `pip`. */
+const NESTED_EXCLUDED = new Set(["node_modules", ".venv"]);
+/**
+ * Kept in place on restore. Keys stay bound to this machine; the browser profile and
+ * downloads are local. Disk folders are kept only when the backup did not include them.
+ */
+const KEEP_ON_RESTORE = new Set([
+ "master.key",
+ "master.key.safe",
+ "master.salt",
+ "host-chrome",
+ "updates",
+ RESTORE_STAGING_DIR,
+ RESTORE_MARKER,
+ RESTORE_FAILED_MARKER,
+]);
+
+export interface BackupManifest {
+ format: 1;
+ appVersion?: string;
+ createdAt: string;
+ platform: string;
+ /** Absolute data directory on the machine that made the backup. */
+ dataDir: string;
+ includeDisks: boolean;
+ files: number;
+ bytes: number;
+ /** Paths that could not be read (permissions, vanished mid-backup). */
+ skipped: string[];
+}
+
+export interface BackupResult {
+ path: string;
+ manifest: BackupManifest;
+ sizeBytes: number;
+}
+
+export interface RestoreStageResult {
+ manifest: BackupManifest;
+ files: number;
+ restartRequired: true;
+}
+
+export interface RestoreApplyResult {
+ applied: boolean;
+ movedAsideTo?: string;
+ error?: string;
+}
+
+interface Header {
+ format: 1;
+ kdf: "scrypt";
+ salt: string;
+ iv: string;
+ N: number;
+ r: number;
+ p: number;
+}
+
+type RecordHeader =
+ | { type: "manifest"; size: number }
+ | { type: "vault"; size: number }
+ | { type: "dir"; path: string; mode?: number; size: 0 }
+ | { type: "file"; path: string; mode?: number; size: number }
+ | { type: "symlink"; path: string; target: string; size: 0 };
+
+export function assertPassphrase(passphrase: unknown): string {
+ if (typeof passphrase !== "string" || passphrase.length < MIN_PASSPHRASE) {
+ throw new Error(`Use a passphrase of at least ${MIN_PASSPHRASE} characters.`);
+ }
+ return passphrase;
+}
+
+function deriveKey(passphrase: string, salt: Buffer, cost = SCRYPT): Promise {
+ return new Promise((resolvePromise, reject) => {
+ scryptCb(
+ passphrase,
+ salt,
+ 32,
+ { N: cost.N, r: cost.r, p: cost.p, maxmem: 128 * cost.N * cost.r * 2 },
+ (err, key) => (err ? reject(err) : resolvePromise(key)),
+ );
+ });
+}
+
+export function defaultBackupPath(now = new Date(), home = homedir()): string {
+ const stamp = now.toISOString().replace(/[:]/g, "").replace(/\..+$/, "").replace("T", "-");
+ return join(home, `locus-backup-${stamp}${BACKUP_EXTENSION}`);
+}
+
+/** True when a top-level entry of the data dir belongs in a backup. */
+export function includeTopLevel(name: string, includeDisks: boolean): boolean {
+ if (ALWAYS_EXCLUDED.has(name)) return false;
+ if (name.startsWith(BEFORE_RESTORE_PREFIX)) return false;
+ if (!includeDisks && DISK_DIRS.includes(name)) return false;
+ return true;
+}
+
+interface WalkEntry {
+ rel: string;
+ abs: string;
+ kind: "dir" | "file" | "symlink";
+ size: number;
+ mode: number;
+}
+
+async function walk(
+ root: string,
+ includeDisks: boolean,
+ skipped: string[],
+ outPath: string,
+): Promise {
+ const out: WalkEntry[] = [];
+ const outAbs = resolve(outPath);
+ async function visit(abs: string, rel: string, depth: number): Promise {
+ let names: string[];
+ try {
+ names = await readdir(abs);
+ } catch {
+ skipped.push(rel || ".");
+ return;
+ }
+ names.sort();
+ for (const name of names) {
+ if (depth === 0 && !includeTopLevel(name, includeDisks)) continue;
+ if (NESTED_EXCLUDED.has(name)) continue;
+ const childAbs = join(abs, name);
+ const childRel = rel ? `${rel}/${name}` : name;
+ if (resolve(childAbs) === outAbs) continue;
+ let info;
+ try {
+ info = await lstat(childAbs);
+ } catch {
+ skipped.push(childRel);
+ continue;
+ }
+ if (info.isSymbolicLink()) {
+ out.push({ rel: childRel, abs: childAbs, kind: "symlink", size: 0, mode: info.mode });
+ } else if (info.isDirectory()) {
+ out.push({ rel: childRel, abs: childAbs, kind: "dir", size: 0, mode: info.mode });
+ await visit(childAbs, childRel, depth + 1);
+ } else if (info.isFile()) {
+ out.push({ rel: childRel, abs: childAbs, kind: "file", size: info.size, mode: info.mode });
+ }
+ // Sockets, FIFOs and devices are skipped silently.
+ }
+ }
+ await visit(root, "", 0);
+ return out;
+}
+
+function recordBytes(header: RecordHeader): Buffer {
+ const json = Buffer.from(JSON.stringify(header), "utf8");
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(json.length, 0);
+ return Buffer.concat([len, json]);
+}
+
+async function* streamFileExactly(abs: string, size: number): AsyncGenerator {
+ let sent = 0;
+ if (size > 0) {
+ const stream = createReadStream(abs, { start: 0, end: size - 1 });
+ for await (const chunk of stream as AsyncIterable) {
+ const room = size - sent;
+ const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
+ sent += piece.length;
+ yield piece;
+ if (sent >= size) break;
+ }
+ }
+ // The file shrank while we read it: pad so the record stays well formed.
+ while (sent < size) {
+ const pad = Buffer.alloc(Math.min(64 * 1024, size - sent));
+ sent += pad.length;
+ yield pad;
+ }
+}
+
+/** Write an encrypted backup of `dataDir` to `outPath`. */
+export async function createBackup(opts: {
+ dataDir: string;
+ passphrase: string;
+ outPath?: string;
+ includeDisks?: boolean;
+ appVersion?: string;
+ now?: Date;
+}): Promise {
+ const passphrase = assertPassphrase(opts.passphrase);
+ const dataDir = resolve(opts.dataDir);
+ const includeDisks = Boolean(opts.includeDisks);
+ const outPath = resolve(opts.outPath?.trim() || defaultBackupPath(opts.now));
+ if (!outPath.endsWith(BACKUP_EXTENSION)) {
+ throw new Error(`Backup file names end in ${BACKUP_EXTENSION}.`);
+ }
+ const key = await loadOrCreateMasterKey(dataDir);
+ const secrets = (await readVaultFile(dataDir, key)) ?? emptySecrets();
+ const skipped: string[] = [];
+ const entries = await walk(dataDir, includeDisks, skipped, outPath);
+ const files = entries.filter((e) => e.kind === "file");
+ const manifest: BackupManifest = {
+ format: 1,
+ appVersion: opts.appVersion,
+ createdAt: (opts.now ?? new Date()).toISOString(),
+ platform: process.platform,
+ dataDir,
+ includeDisks,
+ files: files.length,
+ bytes: files.reduce((sum, e) => sum + e.size, 0),
+ skipped,
+ };
+
+ const salt = randomBytes(16);
+ const iv = randomBytes(12);
+ const header: Header = {
+ format: 1,
+ kdf: "scrypt",
+ salt: salt.toString("base64"),
+ iv: iv.toString("base64"),
+ ...SCRYPT,
+ };
+ const cipher = createCipheriv("aes-256-gcm", await deriveKey(passphrase, salt), iv);
+
+ async function* records(): AsyncGenerator {
+ const manifestBytes = Buffer.from(JSON.stringify(manifest), "utf8");
+ yield recordBytes({ type: "manifest", size: manifestBytes.length });
+ yield manifestBytes;
+ const vaultBytes = Buffer.from(JSON.stringify(secrets), "utf8");
+ yield recordBytes({ type: "vault", size: vaultBytes.length });
+ yield vaultBytes;
+ for (const entry of entries) {
+ if (entry.kind === "dir") {
+ yield recordBytes({ type: "dir", path: entry.rel, mode: entry.mode & 0o777, size: 0 });
+ } else if (entry.kind === "symlink") {
+ let target: string;
+ try {
+ target = await readlink(entry.abs);
+ } catch {
+ skipped.push(entry.rel);
+ continue;
+ }
+ yield recordBytes({ type: "symlink", path: entry.rel, target, size: 0 });
+ } else {
+ // Fail early on unreadable files so the record count stays honest.
+ try {
+ const handle = await open(entry.abs, "r");
+ await handle.close();
+ } catch {
+ skipped.push(entry.rel);
+ continue;
+ }
+ yield recordBytes({
+ type: "file",
+ path: entry.rel,
+ mode: entry.mode & 0o777,
+ size: entry.size,
+ });
+ yield* streamFileExactly(entry.abs, entry.size);
+ }
+ }
+ }
+
+ await mkdir(dirname(outPath), { recursive: true });
+ const tmpPath = `${outPath}.partial`;
+ await writeFile(tmpPath, `${BACKUP_MAGIC}${JSON.stringify(header)}\n`, { mode: 0o600 });
+ try {
+ await pipeline(
+ Readable.from(records()),
+ createGzip({ level: 6 }),
+ cipher,
+ createWriteStream(tmpPath, { flags: "a", mode: 0o600 }),
+ );
+ await appendFile(tmpPath, cipher.getAuthTag());
+ await rename(tmpPath, outPath);
+ } catch (err) {
+ await rm(tmpPath, { force: true }).catch(() => undefined);
+ throw err;
+ }
+ const sizeBytes = (await stat(outPath)).size;
+ return { path: outPath, manifest, sizeBytes };
+}
+
+async function readHeader(path: string): Promise<{ header: Header; offset: number; size: number }> {
+ const size = (await stat(path)).size;
+ const handle = await open(path, "r");
+ try {
+ const buf = Buffer.alloc(Math.min(4096, size));
+ await handle.read(buf, 0, buf.length, 0);
+ const magic = buf.subarray(0, BACKUP_MAGIC.length).toString("utf8");
+ if (magic !== BACKUP_MAGIC) throw new Error("Not a Locus backup file.");
+ const nl = buf.indexOf(0x0a, BACKUP_MAGIC.length);
+ if (nl < 0) throw new Error("The backup header is damaged.");
+ const header = JSON.parse(buf.subarray(BACKUP_MAGIC.length, nl).toString("utf8")) as Header;
+ if (header.format !== 1 || header.kdf !== "scrypt") {
+ throw new Error("This backup was made by a newer Locus. Update Locus, then restore.");
+ }
+ const offset = nl + 1;
+ if (size < offset + TAG_BYTES) throw new Error("The backup file is truncated.");
+ return { header, offset, size };
+ } finally {
+ await handle.close();
+ }
+}
+
+/** Resolve a record path under `root`, refusing anything that escapes it. */
+export function safeJoin(root: string, rel: string): string {
+ if (!rel || isAbsolute(rel) || rel.includes("\0"))
+ throw new Error(`Unsafe path in backup: ${rel}`);
+ const parts = rel.split("/");
+ if (parts.some((p) => p === ".." || p === "")) throw new Error(`Unsafe path in backup: ${rel}`);
+ const abs = resolve(root, normalize(parts.join(sep)));
+ const back = relative(root, abs);
+ if (!back || back.startsWith("..") || isAbsolute(back)) {
+ throw new Error(`Unsafe path in backup: ${rel}`);
+ }
+ return abs;
+}
+
+/**
+ * Stream-parse the decrypted, gunzipped record stream. Files go straight to disk under
+ * `destDir`; manifest and vault records are collected in memory.
+ */
+class RecordExtractor extends Writable {
+ manifest?: BackupManifest;
+ vault?: SecretBundle;
+ files = 0;
+ private buf: Buffer = Buffer.alloc(0);
+ private current?: RecordHeader;
+ private remaining = 0;
+ private fd?: number;
+ private meta: Buffer[] = [];
+
+ constructor(private readonly destDir: string) {
+ super();
+ }
+
+ override _write(chunk: Buffer, _enc: BufferEncoding, done: (err?: Error | null) => void): void {
+ try {
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
+ this.drain();
+ done();
+ } catch (err) {
+ this.closeFd();
+ done(err as Error);
+ }
+ }
+
+ override _final(done: (err?: Error | null) => void): void {
+ if (this.current || this.buf.length) {
+ this.closeFd();
+ done(new Error("The backup ended in the middle of a file."));
+ return;
+ }
+ done();
+ }
+
+ private closeFd(): void {
+ if (this.fd != null) {
+ closeSync(this.fd);
+ this.fd = undefined;
+ }
+ }
+
+ private drain(): void {
+ for (;;) {
+ if (!this.current) {
+ if (this.buf.length < 4) return;
+ const len = this.buf.readUInt32BE(0);
+ if (len > 1 << 20) throw new Error("The backup has a damaged record.");
+ if (this.buf.length < 4 + len) return;
+ const header = JSON.parse(this.buf.subarray(4, 4 + len).toString("utf8")) as RecordHeader;
+ this.buf = this.buf.subarray(4 + len);
+ this.beginRecord(header);
+ continue;
+ }
+ if (this.remaining > 0) {
+ if (!this.buf.length) return;
+ const take = Math.min(this.remaining, this.buf.length);
+ const piece = this.buf.subarray(0, take);
+ this.buf = this.buf.subarray(take);
+ this.remaining -= take;
+ if (this.fd != null) writeSync(this.fd, piece);
+ else this.meta.push(piece);
+ }
+ if (this.remaining === 0) this.finishRecord();
+ }
+ }
+
+ private beginRecord(header: RecordHeader): void {
+ this.current = header;
+ this.remaining = header.size;
+ this.meta = [];
+ if (header.type === "dir") {
+ mkdirSync(safeJoin(this.destDir, header.path), { recursive: true, mode: 0o700 });
+ } else if (header.type === "symlink") {
+ const abs = safeJoin(this.destDir, header.path);
+ mkdirSync(dirname(abs), { recursive: true, mode: 0o700 });
+ try {
+ symlinkSync(header.target, abs);
+ } catch {
+ /* Windows without symlink rights: skip the link. */
+ }
+ } else if (header.type === "file") {
+ const abs = safeJoin(this.destDir, header.path);
+ mkdirSync(dirname(abs), { recursive: true, mode: 0o700 });
+ this.fd = openSync(abs, "w", (header.mode ?? 0o600) & 0o777 || 0o600);
+ this.files += 1;
+ }
+ }
+
+ private finishRecord(): void {
+ const header = this.current!;
+ this.closeFd();
+ if (header.type === "manifest") {
+ this.manifest = JSON.parse(Buffer.concat(this.meta).toString("utf8")) as BackupManifest;
+ } else if (header.type === "vault") {
+ this.vault = {
+ ...emptySecrets(),
+ ...(JSON.parse(Buffer.concat(this.meta).toString("utf8")) as Partial),
+ };
+ }
+ this.meta = [];
+ this.current = undefined;
+ }
+}
+
+function friendlyDecryptError(err: unknown): Error {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (
+ /unable to authenticate|incorrect header check|invalid (stored block|distance)|unknown compression|unexpected end/i.test(
+ msg,
+ )
+ ) {
+ return new Error("Wrong passphrase, or the backup file is damaged.");
+ }
+ return err instanceof Error ? err : new Error(msg);
+}
+
+/** Decrypt and extract a backup into `destDir`. The GCM tag is checked before returning. */
+export async function extractBackup(opts: {
+ path: string;
+ passphrase: string;
+ destDir: string;
+}): Promise<{ manifest: BackupManifest; vault: SecretBundle; files: number }> {
+ const passphrase = assertPassphrase(opts.passphrase);
+ const { header, offset, size } = await readHeader(opts.path);
+ const handle = await open(opts.path, "r");
+ const tag = Buffer.alloc(TAG_BYTES);
+ try {
+ await handle.read(tag, 0, TAG_BYTES, size - TAG_BYTES);
+ } finally {
+ await handle.close();
+ }
+ const key = await deriveKey(passphrase, Buffer.from(header.salt, "base64"), header);
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(header.iv, "base64"));
+ decipher.setAuthTag(tag);
+ await mkdir(opts.destDir, { recursive: true, mode: 0o700 });
+ const extractor = new RecordExtractor(opts.destDir);
+ const end = size - TAG_BYTES - 1;
+ try {
+ await pipeline(
+ end >= offset ? createReadStream(opts.path, { start: offset, end }) : Readable.from([]),
+ decipher,
+ createGunzip(),
+ extractor,
+ );
+ } catch (err) {
+ throw friendlyDecryptError(err);
+ }
+ if (!extractor.manifest) throw new Error("The backup has no manifest.");
+ return {
+ manifest: extractor.manifest,
+ vault: extractor.vault ?? emptySecrets(),
+ files: extractor.files,
+ };
+}
+
+/** Read only the manifest (still decrypts the archive to verify it). */
+export async function inspectBackup(opts: {
+ path: string;
+ passphrase: string;
+ scratchDir: string;
+}): Promise {
+ const dest = join(opts.scratchDir, `inspect-${Date.now()}`);
+ try {
+ return (await extractBackup({ ...opts, destDir: dest })).manifest;
+ } finally {
+ await rm(dest, { recursive: true, force: true }).catch(() => undefined);
+ }
+}
+
+function jsonEscaped(path: string): string {
+ return JSON.stringify(path).slice(1, -1);
+}
+
+/**
+ * Point absolute paths from the source machine's data dir at this one.
+ * Only JSON state files are rewritten; workspaces and repos are user content.
+ */
+export async function rewriteDataDirPaths(
+ root: string,
+ fromDir: string,
+ toDir: string,
+): Promise {
+ if (resolve(fromDir) === resolve(toDir)) return 0;
+ const from = jsonEscaped(fromDir.replace(/[\\/]+$/, ""));
+ const to = jsonEscaped(toDir.replace(/[\\/]+$/, ""));
+ const skipDirs = new Set(["docker-workspaces", "repos", "share", "disks", "images", "media"]);
+ let changed = 0;
+ async function visit(abs: string, depth: number): Promise {
+ let names: string[];
+ try {
+ names = await readdir(abs);
+ } catch {
+ return;
+ }
+ for (const name of names) {
+ if (depth === 0 && skipDirs.has(name)) continue;
+ const child = join(abs, name);
+ const info = await lstat(child).catch(() => undefined);
+ if (!info) continue;
+ if (info.isDirectory()) {
+ await visit(child, depth + 1);
+ } else if (
+ info.isFile() &&
+ (name.endsWith(".json") || name.endsWith(".jsonl")) &&
+ info.size < 64 * 1024 * 1024
+ ) {
+ const raw = await readFile(child, "utf8");
+ if (!raw.includes(from)) continue;
+ await writeFile(child, raw.split(from).join(to), { mode: info.mode & 0o777 });
+ changed += 1;
+ }
+ }
+ }
+ await visit(root, 0);
+ return changed;
+}
+
+/**
+ * Decrypt a backup into `/.restore-staging`, re-encrypt its vault with this
+ * machine's master key, and mark it for the next start. Live state is untouched.
+ */
+export async function stageRestore(opts: {
+ dataDir: string;
+ path: string;
+ passphrase: string;
+}): Promise {
+ const dataDir = resolve(opts.dataDir);
+ const staging = join(dataDir, RESTORE_STAGING_DIR);
+ await rm(join(dataDir, RESTORE_MARKER), { force: true });
+ await rm(staging, { recursive: true, force: true });
+ try {
+ const { manifest, vault, files } = await extractBackup({
+ path: opts.path,
+ passphrase: opts.passphrase,
+ destDir: staging,
+ });
+ const key = await loadOrCreateMasterKey(dataDir);
+ await writeVaultFile(staging, key, vault);
+ await rewriteDataDirPaths(staging, manifest.dataDir, dataDir);
+ await writeFile(
+ join(dataDir, RESTORE_MARKER),
+ `${JSON.stringify({ stagedAt: new Date().toISOString(), from: opts.path, manifest }, null, 2)}\n`,
+ { mode: 0o600 },
+ );
+ return { manifest, files, restartRequired: true };
+ } catch (err) {
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
+ throw err;
+ }
+}
+
+export async function pendingRestore(
+ dataDir: string,
+): Promise<{ stagedAt: string; from: string; manifest: BackupManifest } | undefined> {
+ try {
+ return JSON.parse(await readFile(join(dataDir, RESTORE_MARKER), "utf8"));
+ } catch {
+ return undefined;
+ }
+}
+
+export async function cancelPendingRestore(dataDir: string): Promise {
+ await rm(join(dataDir, RESTORE_MARKER), { force: true });
+ await rm(join(dataDir, RESTORE_STAGING_DIR), { recursive: true, force: true });
+}
+
+/**
+ * Swap a staged restore into place. Call before the store or any service loads.
+ * The previous files are kept under `.before-restore-/`.
+ */
+export async function applyPendingRestore(
+ dataDir: string,
+ now = new Date(),
+): Promise {
+ const root = resolve(dataDir);
+ const marker = await pendingRestore(root);
+ if (!marker) return { applied: false };
+ const staging = join(root, RESTORE_STAGING_DIR);
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
+ const aside = join(root, `${BEFORE_RESTORE_PREFIX}${stamp}`);
+ try {
+ const stagedNames = new Set(await readdir(staging));
+ const keep = new Set(KEEP_ON_RESTORE);
+ if (!marker.manifest.includeDisks) for (const d of DISK_DIRS) keep.add(d);
+ await mkdir(aside, { recursive: true, mode: 0o700 });
+ for (const name of await readdir(root)) {
+ if (name.startsWith(BEFORE_RESTORE_PREFIX)) continue;
+ if (keep.has(name) && !stagedNames.has(name)) continue;
+ if (KEEP_ON_RESTORE.has(name)) continue;
+ await rename(join(root, name), join(aside, name));
+ }
+ for (const name of stagedNames) {
+ await rename(join(staging, name), join(root, name));
+ }
+ await rm(staging, { recursive: true, force: true });
+ await rm(join(root, RESTORE_MARKER), { force: true });
+ return { applied: true, movedAsideTo: aside };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ // Do not retry on every start. The old files are under `aside`.
+ await rename(join(root, RESTORE_MARKER), join(root, RESTORE_FAILED_MARKER)).catch(
+ () => undefined,
+ );
+ return { applied: false, movedAsideTo: aside, error: message };
+ }
+}
diff --git a/apps/server/src/core/store.perf.test.ts b/apps/server/src/core/store.perf.test.ts
index 99f40f8..a366d05 100644
--- a/apps/server/src/core/store.perf.test.ts
+++ b/apps/server/src/core/store.perf.test.ts
@@ -77,7 +77,7 @@ describe("session persist perf", () => {
const stringifyPretty: number[] = [];
const stringifyCompact: number[] = [];
- for (let i = 0; i < 6; i += 1) {
+ for (let i = 0; i < 21; i += 1) {
const prettyStart = performance.now();
JSON.stringify(row, null, 2);
stringifyPretty.push(performance.now() - prettyStart);
@@ -116,6 +116,9 @@ describe("session persist perf", () => {
expect(second).toBe(first);
expect(JSON.parse(first)).toEqual(row);
expect(compact.length).toBeLessThan(pretty.length);
+ // Both stringify a ~260 KB payload in about half a millisecond, so their
+ // medians differ by noise on a loaded CI runner (it missed by 1–2%). Keep
+ // a guard against compact serialization getting materially slower.
expect(median(stringifyCompact)).toBeLessThanOrEqual(median(stringifyPretty) * 1.5);
expect(first.includes("\n ")).toBe(false);
});
diff --git a/apps/server/src/core/updates.test.ts b/apps/server/src/core/updates.test.ts
index 7e86389..3eb22be 100644
--- a/apps/server/src/core/updates.test.ts
+++ b/apps/server/src/core/updates.test.ts
@@ -3,7 +3,12 @@ import { createHash } from "node:crypto";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { checkGithubRelease, downloadAndVerifyUpdate, githubReleaseToUpdate } from "./updates.js";
+import {
+ UpdateChecker,
+ checkGithubRelease,
+ downloadAndVerifyUpdate,
+ githubReleaseToUpdate,
+} from "./updates.js";
const release = {
tag_name: "v0.4.3",
@@ -90,3 +95,45 @@ describe("checkGithubRelease", () => {
expect(update.asset?.name).toBe("Locus-0.4.3.dmg");
});
});
+
+describe("UpdateChecker", () => {
+ it("serves repeat checks from cache for an hour and refreshes on demand", async () => {
+ let calls = 0;
+ let now = 1_000_000;
+ const checker = new UpdateChecker(
+ async ({ current }) => {
+ calls += 1;
+ return { status: "up-to-date" as const, current, latest: "v0.20.0" };
+ },
+ () => now,
+ );
+ await checker.get({ current: "0.20.0", platform: "linux" });
+ await checker.get({ current: "0.20.0", platform: "linux" });
+ expect(calls).toBe(1);
+ await checker.get({ current: "0.20.0", platform: "linux", refresh: true });
+ expect(calls).toBe(2);
+ now += 61 * 60 * 1000;
+ const fresh = await checker.get({ current: "0.20.0", platform: "linux" });
+ expect(calls).toBe(3);
+ expect(fresh.checkedAt).toBe(new Date(now).toISOString());
+ });
+
+ it("retries an unavailable result after ten minutes", async () => {
+ let calls = 0;
+ let now = 0;
+ const checker = new UpdateChecker(
+ async ({ current }) => {
+ calls += 1;
+ return { status: "unavailable" as const, current, message: "offline" };
+ },
+ () => now,
+ );
+ await checker.get({ current: "0.20.0", platform: "linux" });
+ now += 5 * 60 * 1000;
+ await checker.get({ current: "0.20.0", platform: "linux" });
+ expect(calls).toBe(1);
+ now += 6 * 60 * 1000;
+ await checker.get({ current: "0.20.0", platform: "linux" });
+ expect(calls).toBe(2);
+ });
+});
diff --git a/apps/server/src/core/updates.ts b/apps/server/src/core/updates.ts
index 88321bd..b9cb4ed 100644
--- a/apps/server/src/core/updates.ts
+++ b/apps/server/src/core/updates.ts
@@ -141,3 +141,42 @@ export async function checkGithubRelease(opts: {
};
}
}
+
+const CHECK_TTL_MS = 60 * 60 * 1000;
+const UNAVAILABLE_TTL_MS = 10 * 60 * 1000;
+
+/**
+ * Cache GitHub release checks so the desktop's background check, Settings → About, the
+ * phone and the CLI share one request an hour (GitHub allows 60 unauthenticated per hour).
+ */
+export class UpdateChecker {
+ private cache = new Map();
+
+ constructor(
+ private readonly check: typeof checkGithubRelease = checkGithubRelease,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ async get(opts: {
+ current: string;
+ platform: string;
+ fetchFn?: typeof fetch;
+ refresh?: boolean;
+ }): Promise {
+ const key = `${opts.platform}:${opts.current}`;
+ const hit = this.cache.get(key);
+ if (hit && !opts.refresh) {
+ const ttl = hit.result.status === "unavailable" ? UNAVAILABLE_TTL_MS : CHECK_TTL_MS;
+ if (this.now() - hit.at < ttl)
+ return { ...hit.result, checkedAt: new Date(hit.at).toISOString() };
+ }
+ const result = await this.check({
+ current: opts.current,
+ platform: opts.platform,
+ fetchFn: opts.fetchFn,
+ });
+ const at = this.now();
+ this.cache.set(key, { at, result });
+ return { ...result, checkedAt: new Date(at).toISOString() };
+ }
+}
diff --git a/apps/server/src/evals/benchmark.eval.test.ts b/apps/server/src/evals/benchmark.eval.test.ts
new file mode 100644
index 0000000..174dfb8
--- /dev/null
+++ b/apps/server/src/evals/benchmark.eval.test.ts
@@ -0,0 +1,152 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { MockHypervisor } from "@locus/hypervisor";
+import type { CompleteRequest } from "@locus/providers";
+import { summarizeBenchmark } from "@locus/shared";
+import { Hub } from "../core/hub.js";
+import { JsonStore } from "../core/store.js";
+import { SessionService } from "../session/sessions.js";
+import { BenchmarkService } from "./benchmark.js";
+
+const cleanup: string[] = [];
+afterEach(async () => {
+ await Promise.all(cleanup.splice(0).map((d) => rm(d, { recursive: true, force: true })));
+});
+
+async function setup(engine: (req: CompleteRequest) => AsyncGenerator) {
+ const dir = await mkdtemp(join(tmpdir(), "locus-bench-"));
+ cleanup.push(dir);
+ const store = new JsonStore(dir);
+ await store.init();
+ const config = await store.loadConfig();
+ const provider = config.providers[0]!;
+ provider.apiKey = "sk-test";
+ provider.enabled = true;
+ await store.saveConfig(config);
+ const hypervisor = new MockHypervisor(dir);
+ const sessions = new SessionService(
+ store,
+ hypervisor,
+ new Hub(),
+ engine as ConstructorParameters[3],
+ );
+ const workRoot = await mkdtemp(join(tmpdir(), "locus-bench-work-"));
+ cleanup.push(workRoot);
+ const bench = new BenchmarkService(store, sessions, { pollMs: 20, workRoot });
+ return { bench, provider, hypervisor, sessions };
+}
+
+describe("model benchmark", () => {
+ it("scores a real Host turn: a fixing model passes, cost and tools recorded", async () => {
+ let round = 0;
+ async function* engine() {
+ round += 1;
+ if (round === 1) {
+ yield {
+ type: "tool_call" as const,
+ id: "p1",
+ name: "apply_patch",
+ arguments: {
+ patch: [
+ "--- a/src/add.js",
+ "+++ b/src/add.js",
+ "@@ -1,3 +1,3 @@",
+ " export function add(a, b) {",
+ "- return a;",
+ "+ return a + b;",
+ " }",
+ "",
+ ].join("\n"),
+ },
+ };
+ return;
+ }
+ if (round === 2) {
+ yield {
+ type: "tool_call" as const,
+ id: "t1",
+ name: "exec",
+ arguments: { command: "npm test" },
+ };
+ return;
+ }
+ yield { type: "text" as const, text: "Fixed add." };
+ }
+ const { bench, provider, hypervisor, sessions } = await setup(engine);
+ const run = await bench.start({
+ targets: [{ providerId: provider.id, model: provider.defaultModel }],
+ caseIds: ["fix-add"],
+ });
+ await bench.idle();
+ hypervisor.shutdown();
+ const done = (await bench.get(run.id))!;
+ expect(done.status).toBe("done");
+ const row = done.results[0]!;
+ expect(row.status, `${row.reason}\n${row.testOutput}`).toBe("passed");
+ expect(row.toolCalls).toBeGreaterThanOrEqual(2);
+ expect(row.durationMs).toBeGreaterThan(0);
+ expect(summarizeBenchmark(done)[0]).toMatchObject({ passed: 1, total: 1, passRate: 1 });
+ // The session is kept for reading, but archived out of the sidebar.
+ expect((await sessions.get(row.sessionId!))?.archived).toBe(true);
+ expect((await bench.list()).map((r) => r.id)).toContain(run.id);
+ }, 60_000);
+
+ it("fails a model that edits the tests to make them pass", async () => {
+ let round = 0;
+ async function* engine() {
+ round += 1;
+ if (round === 1) {
+ yield {
+ type: "tool_call" as const,
+ id: "w1",
+ name: "file_write",
+ arguments: {
+ path: "/workspace/src/add.test.js",
+ content: 'import { test } from "node:test";\ntest("ok", () => {});\n',
+ },
+ };
+ return;
+ }
+ yield { type: "text" as const, text: "Tests pass now." };
+ }
+ const { bench, provider, hypervisor } = await setup(engine);
+ const run = await bench.start({
+ targets: [{ providerId: provider.id, model: provider.defaultModel }],
+ caseIds: ["fix-add"],
+ });
+ await bench.idle();
+ hypervisor.shutdown();
+ const row = (await bench.get(run.id))!.results[0]!;
+ expect(row.status).toBe("failed");
+ expect(row.reason).toMatch(/tests edited/);
+ }, 60_000);
+
+ it("rejects unknown providers, unknown cases and a second concurrent run", async () => {
+ async function* idleEngine() {
+ await new Promise((r) => setTimeout(r, 200));
+ yield { type: "text" as const, text: "thinking" };
+ }
+ const { bench, provider, hypervisor } = await setup(idleEngine);
+ await expect(bench.start({ targets: [{ providerId: "nope", model: "x" }] })).rejects.toThrow(
+ /Unknown provider/,
+ );
+ await expect(
+ bench.start({ targets: [{ providerId: provider.id, model: "m" }], caseIds: ["nope"] }),
+ ).rejects.toThrow(/Unknown case/);
+ const run = await bench.start({
+ targets: [{ providerId: provider.id, model: provider.defaultModel }],
+ caseIds: ["fix-add", "off-by-one"],
+ });
+ await expect(
+ bench.start({ targets: [{ providerId: provider.id, model: provider.defaultModel }] }),
+ ).rejects.toThrow(/already running/);
+ await bench.cancel(run.id);
+ await bench.idle();
+ hypervisor.shutdown();
+ const done = (await bench.get(run.id))!;
+ expect(done.status).toBe("cancelled");
+ expect(done.results.some((r) => r.reason === "cancelled")).toBe(true);
+ }, 60_000);
+});
diff --git a/apps/server/src/evals/benchmark.ts b/apps/server/src/evals/benchmark.ts
new file mode 100644
index 0000000..f7b242e
--- /dev/null
+++ b/apps/server/src/evals/benchmark.ts
@@ -0,0 +1,293 @@
+/**
+ * Run the built-in benchmark cases as real Host agent turns against chosen models.
+ *
+ * Each case gets a fresh folder under `/locus-benchmarks//`, an autonomous Host
+ * session on that folder, and the case prompt. When the turn ends (or hits the time limit)
+ * we run `npm test` ourselves, check the test files were not edited, and record tokens,
+ * estimated cost and wall time. Sessions are archived, not deleted, so a failure can be
+ * opened and read. Runs are saved as `/benchmarks/.json`.
+ */
+import { randomUUID } from "node:crypto";
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import {
+ BENCHMARK_CASES,
+ benchmarkCase,
+ estimateUsd,
+ isSessionWorking,
+ resolveUsageRates,
+ scoreBenchmarkCase,
+ type AgentSession,
+ type BenchmarkCaseResult,
+ type BenchmarkRun,
+ type BenchmarkTarget,
+ type CreateSessionInput,
+} from "@locus/shared";
+import { runWorkspaceNpmScript } from "../core/hooks.js";
+import type { JsonStore } from "../core/store.js";
+
+/** The slice of SessionService the runner drives. */
+export interface BenchmarkSessions {
+ create(input: CreateSessionInput): Promise;
+ sendChat(id: string, content: string): Promise;
+ get(id: string): Promise;
+ abortChat(id: string): Promise;
+ patchMeta(id: string, patch: { archived?: boolean }): Promise;
+}
+
+export interface StartBenchmarkInput {
+ targets: BenchmarkTarget[];
+ caseIds?: string[];
+ repeat?: number;
+ /** Per case. Default 10 minutes. */
+ timeoutMs?: number;
+}
+
+const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
+const MAX_REPEAT = 5;
+const POLL_MS = 500;
+
+export class BenchmarkService {
+ private active?: { run: BenchmarkRun; cancelled: boolean; sessionId?: string };
+ private readonly dir: string;
+ private readonly workRoot: string;
+
+ constructor(
+ private readonly store: JsonStore,
+ private readonly sessions: BenchmarkSessions,
+ private readonly opts: {
+ pollMs?: number;
+ runTests?: (root: string) => Promise<{ ran: boolean; ok: boolean; output: string }>;
+ now?: () => number;
+ /** Case folders. Host agents may not work inside the data dir, so default to tmp. */
+ workRoot?: string;
+ } = {},
+ ) {
+ this.dir = join(store.dataDir, "benchmarks");
+ this.workRoot = opts.workRoot ?? join(tmpdir(), "locus-benchmarks");
+ }
+
+ cases() {
+ return BENCHMARK_CASES.map(({ id, title, prompt }) => ({ id, title, prompt }));
+ }
+
+ private path(id: string): string {
+ return join(this.dir, `${id.replace(/[^a-zA-Z0-9-]/g, "")}.json`);
+ }
+
+ private async save(run: BenchmarkRun): Promise {
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
+ await writeFile(this.path(run.id), `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 });
+ }
+
+ async get(id: string): Promise {
+ if (this.active?.run.id === id) return this.active.run;
+ try {
+ const run = JSON.parse(await readFile(this.path(id), "utf8")) as BenchmarkRun;
+ // A run still marked running on disk was interrupted by a restart.
+ if (run.status === "running") return { ...run, status: "error", error: "Interrupted" };
+ return run;
+ } catch {
+ return undefined;
+ }
+ }
+
+ async list(): Promise {
+ let names: string[] = [];
+ try {
+ names = await readdir(this.dir);
+ } catch {
+ return this.active ? [this.active.run] : [];
+ }
+ const runs = await Promise.all(
+ names.filter((n) => n.endsWith(".json")).map((n) => this.get(n.slice(0, -5))),
+ );
+ return runs
+ .filter((r): r is BenchmarkRun => Boolean(r))
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ }
+
+ async start(input: StartBenchmarkInput): Promise {
+ if (this.active) throw new Error("A benchmark is already running.");
+ const targets = (input.targets ?? []).filter((t) => t?.providerId && t?.model);
+ if (!targets.length) throw new Error("Pick at least one provider and model.");
+ const config = await this.store.loadConfig();
+ for (const target of targets) {
+ const provider = config.providers.find((p) => p.id === target.providerId);
+ if (!provider) throw new Error(`Unknown provider: ${target.providerId}`);
+ if (!provider.enabled)
+ throw new Error(`${provider.name} is not enabled in Settings → Providers.`);
+ }
+ const caseIds = input.caseIds?.length ? input.caseIds : BENCHMARK_CASES.map((c) => c.id);
+ for (const id of caseIds) if (!benchmarkCase(id)) throw new Error(`Unknown case: ${id}`);
+ const repeat = Math.max(1, Math.min(MAX_REPEAT, Math.floor(input.repeat ?? 1)));
+ const run: BenchmarkRun = {
+ id: `bench-${new Date().toISOString().replace(/[-:]/g, "").slice(0, 15)}-${randomUUID().slice(0, 6)}`,
+ createdAt: new Date().toISOString(),
+ status: "running",
+ targets,
+ caseIds,
+ repeat,
+ results: [],
+ };
+ for (const target of targets) {
+ for (let attempt = 1; attempt <= repeat; attempt += 1) {
+ for (const caseId of caseIds)
+ run.results.push({ caseId, target, attempt, status: "pending" });
+ }
+ }
+ await this.save(run);
+ this.active = { run, cancelled: false };
+ void this.execute(input.timeoutMs ?? DEFAULT_TIMEOUT_MS).catch(async (err) => {
+ run.status = "error";
+ run.error = err instanceof Error ? err.message : String(err);
+ run.finishedAt = new Date().toISOString();
+ await this.save(run).catch(() => undefined);
+ this.active = undefined;
+ });
+ return run;
+ }
+
+ async cancel(id: string): Promise {
+ const active = this.active;
+ if (!active || active.run.id !== id) return this.get(id);
+ active.cancelled = true;
+ if (active.sessionId) await this.sessions.abortChat(active.sessionId).catch(() => undefined);
+ return active.run;
+ }
+
+ /** Resolves when the active run (if any) has finished. For tests and the CLI's --wait. */
+ async idle(): Promise {
+ while (this.active) await new Promise((r) => setTimeout(r, this.opts.pollMs ?? POLL_MS));
+ }
+
+ private async execute(timeoutMs: number): Promise {
+ const active = this.active!;
+ const { run } = active;
+ for (let i = 0; i < run.results.length; i += 1) {
+ const row = run.results[i]!;
+ if (active.cancelled) break;
+ row.status = "running";
+ row.startedAt = new Date().toISOString();
+ await this.save(run);
+ Object.assign(
+ row,
+ await this.runCase(row, join(this.workRoot, run.id, `${i + 1}-${row.caseId}`), timeoutMs),
+ );
+ await this.save(run);
+ }
+ for (const row of run.results) {
+ if (row.status === "pending" || row.status === "running") {
+ row.status = "error";
+ row.reason = "cancelled";
+ }
+ }
+ run.status = active.cancelled ? "cancelled" : "done";
+ run.finishedAt = new Date().toISOString();
+ await this.save(run);
+ this.active = undefined;
+ }
+
+ private async runCase(
+ row: BenchmarkCaseResult,
+ root: string,
+ timeoutMs: number,
+ ): Promise> {
+ const spec = benchmarkCase(row.caseId)!;
+ const now = this.opts.now ?? Date.now;
+ const started = now();
+ for (const [rel, body] of Object.entries(spec.files)) {
+ const abs = join(root, ...rel.split("/"));
+ await mkdir(dirname(abs), { recursive: true });
+ await writeFile(abs, body);
+ }
+ const config = await this.store.loadConfig();
+ const template = config.templates[0];
+ if (!template) return { status: "error", reason: "No machine template configured" };
+ let session: AgentSession;
+ try {
+ session = await this.sessions.create({
+ title: `Benchmark · ${spec.id} · ${row.target.model}`,
+ os: template.os,
+ templateId: template.id,
+ runtime: "host",
+ hostDir: root,
+ providerId: row.target.providerId,
+ model: row.target.model,
+ autonomy: "autonomous",
+ startVm: false,
+ maxMs: timeoutMs,
+ });
+ } catch (err) {
+ return { status: "error", reason: err instanceof Error ? err.message : String(err) };
+ }
+ this.active!.sessionId = session.id;
+ let timedOut = false;
+ let turnError: string | undefined;
+ try {
+ await this.sessions.sendChat(session.id, spec.prompt);
+ timedOut = !(await this.waitForTurn(session.id, started, timeoutMs));
+ if (timedOut) await this.sessions.abortChat(session.id).catch(() => undefined);
+ } catch (err) {
+ turnError = err instanceof Error ? err.message : String(err);
+ }
+ this.active!.sessionId = undefined;
+ const fresh = (await this.sessions.get(session.id)) ?? session;
+ if (!turnError && fresh.status === "error" && fresh.error) turnError = fresh.error;
+
+ const edited: string[] = [];
+ for (const rel of spec.protectedFiles) {
+ const now = await readFile(join(root, ...rel.split("/")), "utf8").catch(() => "");
+ if (now !== spec.files[rel]) edited.push(rel);
+ }
+ const tests = await (
+ this.opts.runTests ?? ((dir) => runWorkspaceNpmScript(dir, "test", 120_000))
+ )(root);
+ const score = scoreBenchmarkCase({
+ testsRan: tests.ran,
+ testsOk: tests.ok,
+ editedProtected: edited,
+ timedOut,
+ error: turnError && !tests.ok ? turnError : undefined,
+ });
+ const provider = config.providers.find((p) => p.id === row.target.providerId);
+ const usage = fresh.usage;
+ const usd = usage
+ ? estimateUsd(usage, resolveUsageRates(provider, row.target.model))
+ : undefined;
+ await this.sessions.patchMeta(session.id, { archived: true }).catch(() => undefined);
+ return {
+ status: score.status,
+ reason:
+ fresh.needsHuman && score.status !== "passed"
+ ? `${score.reason}; needs you: ${fresh.needsHuman}`
+ : score.reason,
+ sessionId: session.id,
+ durationMs: now() - started,
+ inputTokens: usage?.inputTokens,
+ outputTokens: usage?.outputTokens,
+ usd,
+ toolCalls: fresh.messages.reduce((n, m) => n + (m.toolCalls?.length ?? 0), 0),
+ testOutput: tests.output.slice(-2000),
+ };
+ }
+
+ /** True when the turn finished, false on timeout or cancel. */
+ private async waitForTurn(id: string, started: number, timeoutMs: number): Promise {
+ const now = this.opts.now ?? Date.now;
+ const pollMs = this.opts.pollMs ?? POLL_MS;
+ let sawWork = false;
+ for (;;) {
+ if (this.active?.cancelled) return false;
+ const session = await this.sessions.get(id);
+ if (!session) return true;
+ const working = isSessionWorking(session.status) || Boolean(session.queue?.length);
+ if (working) sawWork = true;
+ const replied = session.messages.some((m) => m.role === "assistant");
+ if (!working && (sawWork || replied || now() - started > 5_000)) return true;
+ if (now() - started > timeoutMs) return false;
+ await new Promise((r) => setTimeout(r, pollMs));
+ }
+ }
+}
diff --git a/apps/server/src/git/forge.ts b/apps/server/src/git/forge.ts
index a20789e..942eb4c 100644
--- a/apps/server/src/git/forge.ts
+++ b/apps/server/src/git/forge.ts
@@ -171,6 +171,12 @@ export class ForgeClient {
}
}
+ /** Overview facts; undefined when this forge's adapter has none. */
+ async about(account: GitForgeAccount, owner: string, repo: string) {
+ const adapter = this.adapter(account);
+ return adapter.about ? adapter.about(account, owner, repo) : undefined;
+ }
+
async listBranches(account: GitForgeAccount, owner: string, repo: string) {
return this.adapter(account).listBranches(account, owner, repo);
}
diff --git a/apps/server/src/git/forgeAdapter.ts b/apps/server/src/git/forgeAdapter.ts
index ab4686a..4d53e7b 100644
--- a/apps/server/src/git/forgeAdapter.ts
+++ b/apps/server/src/git/forgeAdapter.ts
@@ -8,6 +8,7 @@ import type {
PullRequestDetail,
PullRequestRef,
RepoBranchList,
+ RepoAbout,
} from "@locus/shared";
export interface CreatePullInput {
@@ -51,6 +52,8 @@ export interface CreatedPull {
export interface ForgeAdapter {
listRepos(account: GitForgeAccount): Promise;
+ /** Description, license, stars, README excerpt. Optional: only GitHub-compatible forges. */
+ about?(account: GitForgeAccount, owner: string, repo: string): Promise;
listPulls(
account: GitForgeAccount,
owner: string,
diff --git a/apps/server/src/git/forgeHttp.ts b/apps/server/src/git/forgeHttp.ts
index c6d3a11..bdfe540 100644
--- a/apps/server/src/git/forgeHttp.ts
+++ b/apps/server/src/git/forgeHttp.ts
@@ -23,8 +23,9 @@ export class ForgeAccessError extends Error {
constructor(
readonly problem: ForgeAccessProblem,
forge: string,
+ readonly resetAt?: number,
) {
- super(forgeAccessMessage(forge, problem));
+ super(forgeAccessMessage(forge, problem, resetAt));
this.name = "ForgeAccessError";
}
}
@@ -40,6 +41,15 @@ function anonymousRefusal(res: Response, text: string): ForgeAccessProblem | und
return undefined;
}
+/** When an anonymous quota refills (ms epoch), from `x-ratelimit-reset` or `retry-after`. */
+export function rateLimitResetAt(res: Response, now = Date.now()): number | undefined {
+ const reset = Number(res.headers.get("x-ratelimit-reset"));
+ if (Number.isFinite(reset) && reset > 0) return reset * 1000;
+ const retry = Number(res.headers.get("retry-after"));
+ if (Number.isFinite(retry) && retry > 0) return now + retry * 1000;
+ return undefined;
+}
+
export function apiRoot(account: GitForgeAccount): string {
return account.apiBaseUrl.replace(/\/$/, "");
}
@@ -183,7 +193,13 @@ export async function forgeJson(
const text = await readBody(res);
if (!hasForgeToken(account)) {
const problem = anonymousRefusal(res, text);
- if (problem) throw new ForgeAccessError(problem, forgeLabel(account.kind));
+ if (problem) {
+ throw new ForgeAccessError(
+ problem,
+ forgeLabel(account.kind),
+ problem === "rate_limited" ? rateLimitResetAt(res) : undefined,
+ );
+ }
}
let json = {} as T;
try {
diff --git a/apps/server/src/git/githubForge.ts b/apps/server/src/git/githubForge.ts
index a3df654..7305e1b 100644
--- a/apps/server/src/git/githubForge.ts
+++ b/apps/server/src/git/githubForge.ts
@@ -1,5 +1,7 @@
import {
mergeRepoBranches,
+ readmeExcerpt,
+ type RepoAbout,
parseIssueState,
parsePullCheckRollup,
type ForgeRepoHint,
@@ -767,6 +769,56 @@ export async function listGithubBranches(
return mergeRepoBranches(names, meta.ok ? meta.json.default_branch : undefined);
}
+export async function githubRepoAbout(
+ fetchFn: ForgeFetcher,
+ account: GitForgeAccount,
+ owner: string,
+ repo: string,
+): Promise {
+ const base = `/repos/${owner}/${repo}`;
+ const meta = await githubJson<{
+ description?: string | null;
+ homepage?: string | null;
+ license?: { spdx_id?: string | null; name?: string | null } | null;
+ language?: string | null;
+ stargazers_count?: number;
+ stars_count?: number;
+ forks_count?: number;
+ topics?: string[];
+ }>(fetchFn, account, base);
+ if (!meta.ok) throw fail({ status: meta.status } as Response, meta.text);
+ const m = meta.json;
+ const spdx = m.license?.spdx_id && m.license.spdx_id !== "NOASSERTION" ? m.license.spdx_id : "";
+ const about: RepoAbout = {
+ description: m.description?.trim() || undefined,
+ homepage: m.homepage?.trim() || undefined,
+ license: spdx || m.license?.name?.trim() || undefined,
+ language: m.language?.trim() || undefined,
+ stars: m.stargazers_count ?? m.stars_count,
+ forks: m.forks_count,
+ topics: m.topics?.length ? m.topics.slice(0, 8) : undefined,
+ };
+ if (githubCompatible(account)) return about;
+ const [readme, firstIssues] = await Promise.all([
+ githubJson<{ content?: string; encoding?: string }>(fetchFn, account, `${base}/readme`).catch(
+ () => null,
+ ),
+ githubJson(
+ fetchFn,
+ account,
+ `${base}/issues?state=open&labels=${encodeURIComponent("good first issue")}&per_page=100`,
+ ).catch(() => null),
+ ]);
+ if (readme?.ok && readme.json.encoding === "base64" && readme.json.content) {
+ const text = Buffer.from(readme.json.content, "base64").toString("utf8");
+ about.readme = readmeExcerpt(text) || undefined;
+ }
+ if (firstIssues?.ok && Array.isArray(firstIssues.json)) {
+ about.goodFirstIssues = firstIssues.json.length;
+ }
+ return about;
+}
+
export async function githubLatestCommitSha(
fetchFn: ForgeFetcher,
account: GitForgeAccount,
@@ -978,5 +1030,6 @@ export function createGithubForgeAdapter(fetchFn: ForgeFetcher): ForgeAdapter {
latestOpenPullCursor: (account, owner, repo) =>
githubLatestOpenPullCursor(fetchFn, account, owner, repo),
listBranches: (account, owner, repo) => listGithubBranches(fetchFn, account, owner, repo),
+ about: (account, owner, repo) => githubRepoAbout(fetchFn, account, owner, repo),
};
}
diff --git a/apps/server/src/git/repos.ts b/apps/server/src/git/repos.ts
index 1ed43c4..2f54614 100644
--- a/apps/server/src/git/repos.ts
+++ b/apps/server/src/git/repos.ts
@@ -49,6 +49,7 @@ import {
type PullListState,
type PullRequestDetail,
type PullRequestRef,
+ type RepoAbout,
type RepoBranchList,
} from "@locus/shared";
import { ForgeClient, requireOwnerRepo, type ForgeFetcher } from "./forge.js";
@@ -239,6 +240,20 @@ export class RepoService {
return next;
}
+ private readonly aboutCache = new Map();
+
+ /** Description, license, stars, README excerpt. Cached 10 minutes: tokenless reads are scarce. */
+ async about(id: string): Promise {
+ const hit = this.aboutCache.get(id);
+ if (hit && Date.now() - hit.at < 10 * 60_000) return hit.about;
+ const repo = await this.require(id);
+ const account = await this.readAccountFor(repo);
+ const parsed = requireOwnerRepo(repo.checkout.url);
+ const about = (await this.forge.about(account, parsed.owner, parsed.repo)) ?? {};
+ this.aboutCache.set(id, { at: Date.now(), about });
+ return about;
+ }
+
async listBranches(id: string): Promise {
const repo = await this.require(id);
const account = await this.readAccountFor(repo);
diff --git a/apps/server/src/http/backupRoutes.ts b/apps/server/src/http/backupRoutes.ts
new file mode 100644
index 0000000..827213b
--- /dev/null
+++ b/apps/server/src/http/backupRoutes.ts
@@ -0,0 +1,97 @@
+import type { Context, Hono } from "hono";
+import { tmpdir } from "node:os";
+import { isLocalOperator } from "../core/localguard.js";
+import type { JsonStore } from "../core/store.js";
+import {
+ cancelPendingRestore,
+ createBackup,
+ inspectBackup,
+ pendingRestore,
+ stageRestore,
+} from "../core/backup.js";
+
+/**
+ * Backup and restore of the data directory. Desktop / CLI on this machine only:
+ * a paired phone or LAN peer never gets these routes (they read and write host paths
+ * and carry every stored secret).
+ */
+export function registerBackupRoutes(
+ app: Hono,
+ opts: {
+ store: JsonStore;
+ peerAddress?: (c: Context) => string | undefined;
+ },
+): void {
+ let busy: "backup" | "restore" | undefined;
+
+ function denied(c: Context) {
+ return c.json({ error: "Backup and restore are only available on this computer." }, 403);
+ }
+
+ async function exclusive(
+ c: Context,
+ kind: "backup" | "restore",
+ run: () => Promise,
+ ): Promise {
+ if (busy) return c.json({ error: `A ${busy} is already running.` }, 409);
+ busy = kind;
+ try {
+ return c.json(await run());
+ } catch (err) {
+ return c.json({ error: err instanceof Error ? err.message : `${kind} failed` }, 400);
+ } finally {
+ busy = undefined;
+ }
+ }
+
+ app.post("/api/backup", async (c) => {
+ if (!isLocalOperator(c, opts.peerAddress)) return denied(c);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ passphrase?: string;
+ out?: string;
+ includeDisks?: boolean;
+ appVersion?: string;
+ };
+ return exclusive(c, "backup", () =>
+ createBackup({
+ dataDir: opts.store.dataDir,
+ passphrase: body.passphrase ?? "",
+ outPath: body.out,
+ includeDisks: body.includeDisks,
+ appVersion: body.appVersion,
+ }),
+ );
+ });
+
+ app.post("/api/backup/inspect", async (c) => {
+ if (!isLocalOperator(c, opts.peerAddress)) return denied(c);
+ const body = (await c.req.json().catch(() => ({}))) as { path?: string; passphrase?: string };
+ const path = body.path?.trim();
+ if (!path) return c.json({ error: "Backup path is required" }, 400);
+ return exclusive(c, "restore", () =>
+ inspectBackup({ path, passphrase: body.passphrase ?? "", scratchDir: tmpdir() }),
+ );
+ });
+
+ app.get("/api/restore", async (c) => {
+ if (!isLocalOperator(c, opts.peerAddress)) return denied(c);
+ const pending = await pendingRestore(opts.store.dataDir);
+ return c.json({ pending: pending ?? null });
+ });
+
+ app.post("/api/restore", async (c) => {
+ if (!isLocalOperator(c, opts.peerAddress)) return denied(c);
+ const body = (await c.req.json().catch(() => ({}))) as { path?: string; passphrase?: string };
+ const path = body.path?.trim();
+ if (!path) return c.json({ error: "Backup path is required" }, 400);
+ return exclusive(c, "restore", () =>
+ stageRestore({ dataDir: opts.store.dataDir, path, passphrase: body.passphrase ?? "" }),
+ );
+ });
+
+ app.delete("/api/restore", async (c) => {
+ if (!isLocalOperator(c, opts.peerAddress)) return denied(c);
+ await cancelPendingRestore(opts.store.dataDir);
+ return c.json({ ok: true });
+ });
+}
diff --git a/apps/server/src/http/benchmarkRoutes.ts b/apps/server/src/http/benchmarkRoutes.ts
new file mode 100644
index 0000000..78dddbe
--- /dev/null
+++ b/apps/server/src/http/benchmarkRoutes.ts
@@ -0,0 +1,35 @@
+import type { Hono } from "hono";
+import { summarizeBenchmark, type BenchmarkRun } from "@locus/shared";
+import type { BenchmarkService, StartBenchmarkInput } from "../evals/benchmark.js";
+
+function withSummary(run: BenchmarkRun) {
+ return { ...run, summary: summarizeBenchmark(run) };
+}
+
+export function registerBenchmarkRoutes(app: Hono, benchmarks: BenchmarkService): void {
+ app.get("/api/benchmarks/cases", (c) => c.json({ cases: benchmarks.cases() }));
+
+ app.get("/api/benchmarks", async (c) =>
+ c.json({ runs: (await benchmarks.list()).map(withSummary) }),
+ );
+
+ app.post("/api/benchmarks", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as StartBenchmarkInput;
+ try {
+ return c.json(withSummary(await benchmarks.start(body)));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Could not start the benchmark";
+ return c.json({ error: message }, message.includes("already running") ? 409 : 400);
+ }
+ });
+
+ app.get("/api/benchmarks/:id", async (c) => {
+ const run = await benchmarks.get(c.req.param("id"));
+ return run ? c.json(withSummary(run)) : c.json({ error: "Not found" }, 404);
+ });
+
+ app.post("/api/benchmarks/:id/cancel", async (c) => {
+ const run = await benchmarks.cancel(c.req.param("id"));
+ return run ? c.json(withSummary(run)) : c.json({ error: "Not found" }, 404);
+ });
+}
diff --git a/apps/server/src/http/configRoutes.ts b/apps/server/src/http/configRoutes.ts
index 0b62a0c..536d87f 100644
--- a/apps/server/src/http/configRoutes.ts
+++ b/apps/server/src/http/configRoutes.ts
@@ -4,11 +4,7 @@ import { clearIntegrationCredentials, type LocusConfig } from "@locus/shared";
import type { AutomationService } from "../automations/automations.js";
import { redactConfig, redactVm } from "../core/redact.js";
import { prepareSavedConfig } from "../core/configSave.js";
-import {
- checkGithubRelease,
- downloadAndVerifyUpdate,
- openVerifiedUpdate,
-} from "../core/updates.js";
+import { UpdateChecker, downloadAndVerifyUpdate, openVerifiedUpdate } from "../core/updates.js";
import type { JsonStore } from "../core/store.js";
import type { SessionService } from "../session/sessions.js";
import type { HostCapabilities, Hypervisor } from "@locus/hypervisor";
@@ -108,13 +104,15 @@ export function registerConfigRoutes(
return c.json({ ok: true, config: redactConfig(await opts.store.loadConfig()) });
});
+ const updateChecker = new UpdateChecker();
app.get("/api/updates", async (c) => {
const current = (c.req.query("current") ?? "").trim() || "0.0.0";
return c.json(
- await checkGithubRelease({
+ await updateChecker.get({
current,
platform: process.platform,
fetchFn: opts.fetchFn,
+ refresh: c.req.query("refresh") === "1",
}),
);
});
diff --git a/apps/server/src/http/repoRoutes.ts b/apps/server/src/http/repoRoutes.ts
index f44ef6d..e2bae30 100644
--- a/apps/server/src/http/repoRoutes.ts
+++ b/apps/server/src/http/repoRoutes.ts
@@ -44,6 +44,16 @@ export function registerRepoRoutes(app: Hono, repos: RepoService | undefined): v
return c.json(repo);
});
+ app.get("/api/repos/:id/about", async (c) => {
+ if (!repos) return c.json({ error: "Repos unavailable" }, 503);
+ try {
+ return c.json(await repos.about(c.req.param("id")));
+ } catch (err) {
+ const { message, status } = repoError(err);
+ return c.json({ error: message }, status);
+ }
+ });
+
app.get("/api/repos/:id/branches", async (c) => {
if (!repos) return c.json({ error: "Repos unavailable" }, 503);
try {
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 76d0c12..2fbb082 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -2,7 +2,12 @@ import { join } from "node:path";
import { createServer as createHttpServer } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { getRequestListener } from "@hono/node-server";
-import { ensureHostKey, guestReadyTimeoutMs, waitForGuest } from "@locus/guest-agent";
+import {
+ guestReadyTimeoutMs,
+ hostKeyWarning,
+ tryEnsureHostKey,
+ waitForGuest,
+} from "@locus/guest-agent";
import {
createDockerEngine,
createHypervisor,
@@ -34,6 +39,7 @@ import {
} from "./runtime/chromeExtensionInstall.js";
import type { ControlPlane } from "./core/controlPlane.js";
import { JsonStore } from "./core/store.js";
+import { applyPendingRestore } from "./core/backup.js";
import { persistCursorCliModels } from "./integrations/cursorCliCatalog.js";
import { setPythonInterpreterOverride } from "./workspace/workspacePython.js";
import { BrainService } from "./brain/brain.js";
@@ -54,6 +60,13 @@ process.on("unhandledRejection", (err) => {
});
const dataDir = defaultDataDir();
+// A restore staged by POST /api/restore swaps in before anything reads the data dir.
+const restored = await applyPendingRestore(dataDir);
+if (restored.applied) {
+ console.log(`Restored a Locus backup. Previous files: ${restored.movedAsideTo}`);
+} else if (restored.error) {
+ console.error(`Restore failed: ${restored.error}. Previous files: ${restored.movedAsideTo}`);
+}
const store = new JsonStore(dataDir);
await store.init();
const config = await store.loadConfig();
@@ -61,7 +74,8 @@ await syncChromeExtensionFiles(dataDir, {
resourcesPath: electronResourcesPath(),
});
-const hostKey = await ensureHostKey(config.advanced.dataDir);
+const { key: hostKey, error: hostKeyError } = await tryEnsureHostKey(config.advanced.dataDir);
+if (hostKeyError) console.warn(hostKeyWarning(hostKeyError));
const ssh = {
sshPublicKey: hostKey.publicKey,
sshIdentityFile: hostKey.privateKeyPath,
@@ -160,7 +174,8 @@ const app = createApp({
return bundle;
},
onReset: async () => {
- const nextKey = await ensureHostKey(store.dataDir);
+ const { key: nextKey, error: nextKeyError } = await tryEnsureHostKey(store.dataDir);
+ if (nextKeyError) console.warn(hostKeyWarning(nextKeyError));
ssh.sshPublicKey = nextKey.publicKey;
ssh.sshIdentityFile = nextKey.privateKeyPath;
const nextConfig = await store.loadConfig();
diff --git a/apps/server/src/integrations/locusMcp.ts b/apps/server/src/integrations/locusMcp.ts
index cfb21a0..b952136 100644
--- a/apps/server/src/integrations/locusMcp.ts
+++ b/apps/server/src/integrations/locusMcp.ts
@@ -78,7 +78,7 @@ export async function handleLocusMcpRpc(
const result = {
protocolVersion: requested.trim() || "2024-11-05",
capabilities: { tools: { listChanged: false } },
- serverInfo: { name: "locus", version: "0.20.0" },
+ serverInfo: { name: "locus", version: "0.21.0" },
...(ctx.instructions?.trim() ? { instructions: ctx.instructions.trim() } : {}),
};
return {
diff --git a/apps/server/src/integrations/mcp.ts b/apps/server/src/integrations/mcp.ts
index 1a85693..e9a16aa 100644
--- a/apps/server/src/integrations/mcp.ts
+++ b/apps/server/src/integrations/mcp.ts
@@ -463,7 +463,7 @@ async function handshakeStdio(
await rpc(live, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
- clientInfo: { name: "locus", version: "0.20.0" },
+ clientInfo: { name: "locus", version: "0.21.0" },
});
notify(live, "notifications/initialized");
const listed = (await rpc(live, "tools/list", {})) as { tools?: ListedTool[] };
@@ -539,7 +539,7 @@ async function bootHttpServer(config: McpServerConfig): Promise {
await rpc(live, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
- clientInfo: { name: "locus", version: "0.20.0" },
+ clientInfo: { name: "locus", version: "0.21.0" },
});
const listed = (await rpc(live, "tools/list", {})) as { tools?: ListedTool[] };
addListedTools(live, listed.tools, live.config.name);
diff --git a/apps/server/src/session/checkpoint.ts b/apps/server/src/session/checkpoint.ts
index 0ea1562..18f125f 100644
--- a/apps/server/src/session/checkpoint.ts
+++ b/apps/server/src/session/checkpoint.ts
@@ -1,6 +1,7 @@
import { rm, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { execFile } from "node:child_process";
+import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import type { DockerEngine, Hypervisor } from "@locus/hypervisor";
import {
@@ -69,6 +70,15 @@ export function checkpointGitRef(id: string): string {
return `${LOCUS_CHECKPOINT_REF_PREFIX}${clean || "turn"}`;
}
+/**
+ * Scratch index for one checkpoint write or restore. Each call gets its own
+ * file: a shared name let a concurrent write unlink a restore's index
+ * mid-checkout, so the restore wrote nothing and still reported success.
+ */
+function checkpointIndexFile(workspace: string): string {
+ return join(workspace, ".git", `locus-checkpoint-${randomUUID()}.index`);
+}
+
async function git(
cwd: string,
args: string[],
@@ -88,7 +98,7 @@ export async function writeCheckpointRef(
workspace: string,
checkpointId: string,
): Promise {
- const indexFile = join(workspace, ".git", "locus-checkpoint.index");
+ const indexFile = checkpointIndexFile(workspace);
try {
const inside = await git(workspace, ["rev-parse", "--is-inside-work-tree"]);
if (inside.stdout.trim() !== "true") return undefined;
@@ -157,7 +167,7 @@ export async function checkpointNeedsFullSnapshot(workspace: string): Promise {
- const indexFile = join(workspace, ".git", "locus-checkpoint.index");
+ const indexFile = checkpointIndexFile(workspace);
try {
const inside = await git(workspace, ["rev-parse", "--is-inside-work-tree"]);
if (inside.stdout.trim() !== "true") return false;
diff --git a/apps/server/src/session/checkpointGit.test.ts b/apps/server/src/session/checkpointGit.test.ts
index 8ed4640..6f6a3ac 100644
--- a/apps/server/src/session/checkpointGit.test.ts
+++ b/apps/server/src/session/checkpointGit.test.ts
@@ -85,6 +85,36 @@ describe("checkpoint git refs", () => {
},
);
+ it.skipIf(process.platform === "win32")(
+ "restores correctly while another checkpoint is being written",
+ async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-ckpt-race-"));
+ cleanup.push(dir);
+ await execFileAsync("git", ["init"], { cwd: dir, env: gitTestEnv() });
+ await execFileAsync("git", ["config", "user.email", "locus@test"], {
+ cwd: dir,
+ env: gitTestEnv(),
+ });
+ await execFileAsync("git", ["config", "user.name", "Locus"], { cwd: dir, env: gitTestEnv() });
+ await writeFile(join(dir, "a.txt"), "one\n");
+ await execFileAsync("git", ["add", "."], { cwd: dir, env: gitTestEnv() });
+ await execFileAsync("git", ["commit", "-m", "one"], { cwd: dir, env: gitTestEnv() });
+ const ref = await writeCheckpointRef(dir, "c1");
+ expect(ref).toBeTruthy();
+ // A shared scratch index let the concurrent write unlink the restore's
+ // index mid-checkout, so the restore wrote nothing yet returned true.
+ for (let i = 0; i < 10; i++) {
+ await writeFile(join(dir, "a.txt"), "two\n");
+ const [restored] = await Promise.all([
+ restoreCheckpointRef(dir, ref!),
+ writeCheckpointRef(dir, `c-concurrent-${i}`),
+ ]);
+ expect(restored).toBe(true);
+ expect(await readFile(join(dir, "a.txt"), "utf8")).toBe("one\n");
+ }
+ },
+ );
+
it("needs a full snapshot when guest Chromium profile exists", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-ckpt-chrome-"));
cleanup.push(dir);
diff --git a/apps/server/src/session/sessions.ts b/apps/server/src/session/sessions.ts
index fbcba3c..4b934bb 100644
--- a/apps/server/src/session/sessions.ts
+++ b/apps/server/src/session/sessions.ts
@@ -468,8 +468,13 @@ import { persistIntegrationAccount } from "../integrations/googleOauth.js";
import { runMcpCode } from "../integrations/mcpCode.js";
import { IntegrationsClient } from "../integrations/integrations.js";
import { SessionTraceLog, saveFailedEval } from "./traces.js";
-import { indexCacheDir, indexWorkspace, updateIndexPaths } from "../workspace/repoIndex.js";
-import { grepIndexedWorkspace, grepContextLines } from "../workspace/grepIndex.js";
+import {
+ dropWorkspaceIndex,
+ indexCacheDir,
+ indexWorkspace,
+ updateIndexPaths,
+} from "../workspace/repoIndex.js";
+import { dropGrepIndex, grepIndexedWorkspace, grepContextLines } from "../workspace/grepIndex.js";
import { runIsolatedSubagent, subagentTimeoutMs } from "./subagent.js";
import { gateToolRequest, type ToolGateDecision } from "./toolGates.js";
import { sessionWorkflowCatalog } from "../workflows/catalog.js";
@@ -2321,6 +2326,8 @@ export class SessionService {
);
this.clearThinking(sessionId);
this.repoMapCache.delete(sessionId);
+ dropWorkspaceIndex(indexCacheDir(this.store.dataDir, sessionId));
+ dropGrepIndex(indexCacheDir(this.store.dataDir, sessionId));
await rm(indexCacheDir(this.store.dataDir, sessionId), { recursive: true, force: true }).catch(
() => undefined,
);
@@ -7791,6 +7798,11 @@ export class SessionService {
let wroteCanvas = false;
try {
const listed = await this.workspace.listWorkspace(sessionId);
+ if (listed.truncated) {
+ console.warn(
+ `Golden image ${id}: workspace listing hit ${listed.entries.length} entries; files past the cap were not copied.`,
+ );
+ }
for (const entry of listed.entries) {
if (entry.type !== "file") continue;
try {
diff --git a/apps/server/src/workspace/gitPorcelain.test.ts b/apps/server/src/workspace/gitPorcelain.test.ts
new file mode 100644
index 0000000..97ea055
--- /dev/null
+++ b/apps/server/src/workspace/gitPorcelain.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { parsePorcelainV2, porcelainPathSets } from "./gitPorcelain.js";
+
+const SHA = "0123456789abcdef0123456789abcdef01234567";
+
+describe("parsePorcelainV2", () => {
+ it("reads branch headers and every entry kind", () => {
+ const out = [
+ `# branch.oid ${SHA}`,
+ "# branch.head main",
+ "# branch.upstream origin/main",
+ "# branch.ab +2 -1",
+ `1 .M N... 100644 100644 100644 ${SHA} ${SHA} src/a file.ts`,
+ `1 D. N... 100644 000000 000000 ${SHA} ${SHA} gone.ts`,
+ `2 R. N... 100644 100644 100644 ${SHA} ${SHA} R100 src/new.ts`,
+ "src/old.ts",
+ `u UU N... 100644 100644 100644 100644 ${SHA} ${SHA} ${SHA} both.ts`,
+ "? fresh.ts",
+ "",
+ ].join("\0");
+ const status = parsePorcelainV2(out);
+ expect(status).toMatchObject({
+ head: SHA,
+ branch: "main",
+ upstream: "origin/main",
+ ahead: 2,
+ behind: 1,
+ });
+ expect(status.entries).toEqual([
+ { path: "src/a file.ts", index: " ", worktree: "M" },
+ { path: "gone.ts", index: "D", worktree: " " },
+ { path: "src/new.ts", index: "R", worktree: " ", from: "src/old.ts" },
+ { path: "both.ts", index: "U", worktree: "U", unmerged: true },
+ { path: "fresh.ts", index: "?", worktree: "?", untracked: true },
+ ]);
+ expect(porcelainPathSets(status)).toEqual({
+ present: ["src/a file.ts", "src/new.ts", "both.ts", "fresh.ts"],
+ gone: ["gone.ts", "src/old.ts"],
+ });
+ });
+
+ it("handles a fresh repo and a detached HEAD", () => {
+ expect(parsePorcelainV2("# branch.oid (initial)\0# branch.head main\0")).toMatchObject({
+ head: undefined,
+ branch: "main",
+ });
+ expect(parsePorcelainV2(`# branch.oid ${SHA}\0# branch.head (detached)\0`).branch).toBe(
+ "detached",
+ );
+ });
+});
diff --git a/apps/server/src/workspace/gitPorcelain.ts b/apps/server/src/workspace/gitPorcelain.ts
new file mode 100644
index 0000000..40bd75c
--- /dev/null
+++ b/apps/server/src/workspace/gitPorcelain.ts
@@ -0,0 +1,155 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { repoMapPathSkipped } from "@locus/shared";
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Parser for `git status --porcelain=v2 --branch -z`. One git process gives the branch,
+ * upstream, ahead/behind, HEAD sha, and every changed path.
+ */
+
+export type PorcelainEntry = {
+ path: string;
+ /** Index (staged) status; `" "` when unchanged. */
+ index: string;
+ /** Worktree status; `" "` when unchanged. */
+ worktree: string;
+ /** Source path of a rename or copy. */
+ from?: string;
+ untracked?: boolean;
+ unmerged?: boolean;
+};
+
+export type PorcelainStatus = {
+ /** HEAD commit, or undefined before the first commit. */
+ head?: string;
+ /** Branch name, `"detached"`, or `""` when git did not say. */
+ branch: string;
+ upstream?: string;
+ ahead: number;
+ behind: number;
+ entries: PorcelainEntry[];
+};
+
+export const PORCELAIN_V2_ARGS = [
+ "-c",
+ "core.quotepath=false",
+ "status",
+ "--porcelain=v2",
+ "--branch",
+ "-z",
+];
+
+function xy(code: string): { index: string; worktree: string } {
+ const at = (i: number) => {
+ const ch = code[i] ?? ".";
+ return ch === "." ? " " : ch;
+ };
+ return { index: at(0), worktree: at(1) };
+}
+
+/** Path after the first `n` space-separated fields (paths may contain spaces). */
+function afterFields(row: string, n: number): string {
+ let at = 0;
+ for (let i = 0; i < n; i += 1) {
+ at = row.indexOf(" ", at);
+ if (at < 0) return "";
+ at += 1;
+ }
+ return row.slice(at);
+}
+
+function cleanPath(raw: string): string {
+ return raw.replace(/\\/g, "/").replace(/^\/+/, "");
+}
+
+export function parsePorcelainV2(stdout: string): PorcelainStatus {
+ const out: PorcelainStatus = { branch: "", ahead: 0, behind: 0, entries: [] };
+ const parts = stdout.split("\0");
+ for (let i = 0; i < parts.length; i += 1) {
+ const row = parts[i];
+ if (!row) continue;
+ if (row.startsWith("# ")) {
+ const [, key, ...rest] = row.split(" ");
+ const value = rest.join(" ");
+ if (key === "branch.oid") out.head = /^[0-9a-f]{7,64}$/i.test(value) ? value : undefined;
+ else if (key === "branch.head") out.branch = value === "(detached)" ? "detached" : value;
+ else if (key === "branch.upstream") out.upstream = value || undefined;
+ else if (key === "branch.ab") {
+ const m = value.match(/^\+(\d+) -(\d+)$/);
+ if (m) {
+ out.ahead = Number(m[1]);
+ out.behind = Number(m[2]);
+ }
+ }
+ continue;
+ }
+ const kind = row[0];
+ if (kind === "1") {
+ const path = cleanPath(afterFields(row, 8));
+ if (path) out.entries.push({ path, ...xy(row.slice(2, 4)) });
+ } else if (kind === "2") {
+ const path = cleanPath(afterFields(row, 9));
+ const from = cleanPath(parts[i + 1] ?? "");
+ i += 1;
+ if (path) out.entries.push({ path, ...xy(row.slice(2, 4)), ...(from ? { from } : {}) });
+ } else if (kind === "u") {
+ const path = cleanPath(afterFields(row, 10));
+ if (path) out.entries.push({ path, ...xy(row.slice(2, 4)), unmerged: true });
+ } else if (kind === "?") {
+ const path = cleanPath(row.slice(2));
+ if (path) out.entries.push({ path, index: "?", worktree: "?", untracked: true });
+ }
+ }
+ return out;
+}
+
+/** Paths that exist in the worktree vs paths git reports as gone (deleted, rename sources). */
+export function porcelainPathSets(status: PorcelainStatus): {
+ present: string[];
+ gone: string[];
+} {
+ const present: string[] = [];
+ const gone: string[] = [];
+ for (const entry of status.entries) {
+ if (entry.from) gone.push(entry.from);
+ if (entry.worktree === "D" || (entry.index === "D" && entry.worktree === " ")) {
+ gone.push(entry.path);
+ } else {
+ present.push(entry.path);
+ }
+ }
+ return { present, gone };
+}
+
+export type WorkspaceGitSnapshot = {
+ head?: string;
+ /** Changed or untracked paths that exist on disk (untracked dirs end with `/`). */
+ dirty: string[];
+ /** Paths git reports as deleted, plus rename/copy sources. */
+ gone: string[];
+};
+
+/** HEAD plus every path that differs from it, from a single `git status`. */
+export async function workspaceGitSnapshot(
+ root: string,
+ timeout = 4000,
+): Promise {
+ try {
+ const { stdout } = await execFileAsync("git", ["-C", root, ...PORCELAIN_V2_ARGS], {
+ timeout,
+ maxBuffer: 8_000_000,
+ windowsHide: true,
+ });
+ const status = parsePorcelainV2(stdout);
+ const { present, gone } = porcelainPathSets(status);
+ return {
+ head: status.head,
+ dirty: present.filter((path) => !repoMapPathSkipped(path)),
+ gone,
+ };
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/server/src/workspace/gitWorkspaceChanges.ts b/apps/server/src/workspace/gitWorkspaceChanges.ts
index b95824c..fba96db 100644
--- a/apps/server/src/workspace/gitWorkspaceChanges.ts
+++ b/apps/server/src/workspace/gitWorkspaceChanges.ts
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
+import { workspaceGitSnapshot } from "./gitPorcelain.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -20,10 +21,14 @@ const changesCache = new Map>();
let changesGen = 0;
-async function git(cwd: string, args: string[]): Promise {
+/** Quick probes (rev-parse, show) keep a short timeout; whole-tree diffs get longer. */
+const QUICK_GIT_MS = 2_000;
+const TREE_GIT_MS = 10_000;
+
+async function git(cwd: string, args: string[], timeout = QUICK_GIT_MS): Promise {
const { stdout } = await execFileAsync("git", args, {
cwd,
- timeout: 2000,
+ timeout,
maxBuffer: 8_000_000,
windowsHide: true,
env: {
@@ -133,31 +138,37 @@ async function loadGitWorkspaceChanges(root: string): Promise();
try {
parseNumstat(
- await git(root, ["-c", "core.quotepath=false", "diff", "--numstat", "HEAD"]),
+ await git(root, ["-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], TREE_GIT_MS),
map,
ignore,
);
- } catch {
- try {
- parseNumstat(
- await git(root, ["-c", "core.quotepath=false", "diff", "--numstat"]),
- map,
- ignore,
- );
- } catch {
- /* empty repo or no commits */
+ } catch (err) {
+ if ((err as { killed?: boolean }).killed) {
+ // Line counts took too long (huge repo or cold disk): still list what changed.
+ for (const path of (await workspaceGitSnapshot(root, TREE_GIT_MS))?.dirty ?? []) {
+ if (!path.endsWith("/") && !skipPath(path, ignore)) {
+ map.set(path, { path, additions: 0, deletions: 0 });
+ }
+ }
+ } else {
+ try {
+ parseNumstat(
+ await git(root, ["-c", "core.quotepath=false", "diff", "--numstat"], TREE_GIT_MS),
+ map,
+ ignore,
+ );
+ } catch {
+ /* empty repo or no commits */
+ }
}
}
try {
- const others = await git(root, [
- "-c",
- "core.quotepath=false",
- "ls-files",
- "-z",
- "--others",
- "--exclude-standard",
- ]);
+ const others = await git(
+ root,
+ ["-c", "core.quotepath=false", "ls-files", "-z", "--others", "--exclude-standard"],
+ TREE_GIT_MS,
+ );
let extra = 0;
for (const file of others.split("\0")) {
const path = file.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
diff --git a/apps/server/src/workspace/grepIndex.test.ts b/apps/server/src/workspace/grepIndex.test.ts
index c816b82..f0906e2 100644
--- a/apps/server/src/workspace/grepIndex.test.ts
+++ b/apps/server/src/workspace/grepIndex.test.ts
@@ -8,13 +8,16 @@ import { rm } from "node:fs/promises";
import {
buildGrepIndex,
candidateFileIds,
+ chunkRgFiles,
containedGrepPath,
+ dropGrepIndex,
ensureGrepIndex,
grepIndexedWorkspace,
grepLiterals,
hostGrepPath,
isOpenGrepPattern,
parsePorcelainZ,
+ settleGrepIndexBuilds,
trigrams,
} from "./grepIndex.js";
import { resetRipgrepCache } from "./workspaceGrep.js";
@@ -33,6 +36,8 @@ if (process.env.CI && !HAS_RG) {
}
afterEach(async () => {
+ await settleGrepIndexBuilds();
+ dropGrepIndex();
resetRipgrepCache();
await Promise.all(cleanup.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
@@ -149,12 +154,9 @@ describe("grep index", () => {
it.skipIf(!HAS_RG)("does not full-scan when the index has no candidates", async () => {
const { root, cacheDir } = await fixture();
- await writeFile(join(root, "hidden.ts"), "zzzHiddenTokenQq = 1;\n");
- await execFileAsync("git", ["-C", root, "add", "hidden.ts"]);
- await execFileAsync("git", ["-C", root, "commit", "-m", "hidden", "--no-gpg-sign"]);
const missed = await grepIndexedWorkspace({
root,
- pattern: "zzzHiddenTokenQq",
+ pattern: "zzzNowhereTokenQq",
regex: false,
cacheDir,
});
@@ -162,13 +164,65 @@ describe("grep index", () => {
expect(missed?.output).toBe("");
});
+ it.skipIf(!HAS_RG)(
+ "full-scans while a moved HEAD rebuilds the index in the background",
+ async () => {
+ const { root, cacheDir } = await fixture();
+ await writeFile(join(root, "hidden.ts"), "zzzHiddenTokenQq = 1;\n");
+ await execFileAsync("git", ["-C", root, "add", "hidden.ts"]);
+ await execFileAsync("git", ["-C", root, "commit", "-m", "hidden", "--no-gpg-sign"]);
+ const first = await grepIndexedWorkspace({
+ root,
+ pattern: "zzzHiddenTokenQq",
+ regex: false,
+ cacheDir,
+ });
+ expect(first?.usedIndex).toBe(false);
+ expect(first?.output).toMatch(/hidden\.ts:1:zzzHiddenTokenQq/);
+ await settleGrepIndexBuilds();
+ const second = await grepIndexedWorkspace({
+ root,
+ pattern: "zzzHiddenTokenQq",
+ regex: false,
+ cacheDir,
+ });
+ expect(second?.usedIndex).toBe(true);
+ expect(second?.output).toMatch(/hidden\.ts:1:zzzHiddenTokenQq/);
+ },
+ );
+
+ it.skipIf(!HAS_RG)("searches renamed files and skips deleted ones", async () => {
+ const { root, cacheDir } = await fixture();
+ await execFileAsync("git", ["-C", root, "mv", "src/fold.ts", "src/folded.ts"]);
+ await execFileAsync("git", ["-C", root, "rm", "-q", "src/other.ts"]);
+ const hit = await grepIndexedWorkspace({
+ root,
+ pattern: "foldOldTurns",
+ regex: false,
+ cacheDir,
+ });
+ expect(hit?.usedIndex).toBe(true);
+ expect(hit?.output).toMatch(/src\/folded\.ts:1:.*foldOldTurns/);
+ expect(hit?.output).not.toMatch(/src\/fold\.ts:/);
+ });
+
+ it("chunks rg file arguments under the command-line limit", () => {
+ const files = Array.from({ length: 100 }, (_, i) => `src/file-${i}.ts`);
+ const chunks = chunkRgFiles(files, 50, 300);
+ expect(chunks.flat()).toEqual(files);
+ for (const chunk of chunks) {
+ expect(chunk.reduce((n, f) => n + f.length + 3, 50)).toBeLessThanOrEqual(300);
+ }
+ expect(chunkRgFiles([], 50, 300)).toEqual([]);
+ });
+
it("parses porcelain -z renames as the new path", () => {
- expect(parsePorcelainZ("R src/old.ts\0src/renamed.ts\0")).toEqual(["src/renamed.ts"]);
+ expect(parsePorcelainZ("R src/renamed.ts\0src/old.ts\0")).toEqual(["src/renamed.ts"]);
expect(parsePorcelainZ("?? src/fresh.ts\0")).toEqual(["src/fresh.ts"]);
expect(parsePorcelainZ(" M src/a.ts\0")).toEqual(["src/a.ts"]);
});
- it("rebuilds when files.jsonl changes and still greps oversized files", async () => {
+ it("keeps the index across edits and rebuilds when HEAD moves", async () => {
const { root, cacheDir, index } = await fixture();
await writeFile(
join(cacheDir, "files.jsonl"),
@@ -177,15 +231,26 @@ describe("grep index", () => {
const first = await ensureGrepIndex(root, cacheDir);
expect(first?.files).toContain("src/fold.ts");
+ // An agent edit rewrites files.jsonl; the index must not rebuild for that.
await writeFile(join(root, "src", "extra.ts"), "export const extraTokenQ = 1;\n");
await writeFile(
join(cacheDir, "files.jsonl"),
`${[...index.files, "src/extra.ts"].map((path) => JSON.stringify({ path })).join("\n")}\n`,
);
+ expect(await ensureGrepIndex(root, cacheDir)).toBe(first);
+
+ await execFileAsync("git", ["-C", root, "add", "src/extra.ts"]);
+ await execFileAsync("git", ["-C", root, "commit", "-m", "extra", "--no-gpg-sign"]);
const rebuilt = await ensureGrepIndex(root, cacheDir);
+ expect(rebuilt).not.toBe(first);
const ids = candidateFileIds(rebuilt!, "extraTokenQ", false);
expect(ids?.map((id) => rebuilt!.files[id])).toContain("src/extra.ts");
+ dropGrepIndex();
+ const fromDisk = await ensureGrepIndex(root, cacheDir);
+ expect(fromDisk?.files).toEqual(rebuilt?.files);
+ expect(candidateFileIds(fromDisk!, "extraTokenQ", false)).toEqual(ids);
+
const huge = `${"export const hugeTokenZed = 1;\n"}${"x".repeat(1_500_100)}`;
await writeFile(join(root, "src", "huge.ts"), huge);
const withHuge = await buildGrepIndex(root, cacheDir, [...index.files, "src/huge.ts"]);
diff --git a/apps/server/src/workspace/grepIndex.ts b/apps/server/src/workspace/grepIndex.ts
index f70e964..fb41697 100644
--- a/apps/server/src/workspace/grepIndex.ts
+++ b/apps/server/src/workspace/grepIndex.ts
@@ -1,39 +1,71 @@
import { spawn } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
+import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { repoMapPathSkipped } from "@locus/shared";
import { resolveRipgrep } from "./workspaceGrep.js";
+import { workspaceGitSnapshot } from "./gitPorcelain.js";
const execFileAsync = promisify(execFile);
-export const GREP_INDEX_VERSION = 1;
+export const GREP_INDEX_VERSION = 2;
export const GREP_NGRAM = 3;
export const AGENT_GREP_BYTE_CAP = 48_000;
const MAX_CANDIDATE_RATIO = 0.2;
const MAX_CANDIDATES = 4_000;
-const POSTINGS_FILE = "grep-postings.json";
+/** `{ files, grams, counts }`; posting ids live in the binary file, in gram order. */
+const INDEX_FILE = "grep-index.json";
+const POSTINGS_FILE = "grep-postings.bin";
const GREP_META_FILE = "grep-meta.json";
+/** v1 kept every posting list in one JSON file that had to be re-parsed on every grep. */
+const LEGACY_POSTINGS_FILE = "grep-postings.json";
const BUILD_FILE_CAP = 20_000;
const MAX_FILE_BYTES = 1_500_000;
+/** Yield to the event loop this often while building so HTTP and agent turns keep moving. */
+const BUILD_YIELD_EVERY = 16;
+/** Indexes kept in memory across greps (least recently used is dropped first). */
+const MEM_INDEX_MAX = 2;
+/** Windows caps a whole command line at 32,767 chars; stay well under it per rg call. */
+const MAX_RG_ARGV_CHARS = process.platform === "win32" ? 24_000 : 512_000;
export type GrepIndexMeta = {
version: number;
fileCount: number;
builtAt: string;
+ /** HEAD the index was built from. Dirty files are searched directly, so edits never stale it. */
head?: string;
- sourceMtimeMs?: number;
- sourceSize?: number;
unindexed?: string[];
};
export type GrepIndex = {
files: string[];
- postings: Map;
+ postings: Map;
meta: GrepIndexMeta;
};
+const memIndexes = new Map();
+const inflightBuilds = new Map>();
+
+function rememberIndex(cacheDir: string, index: GrepIndex): GrepIndex {
+ memIndexes.delete(cacheDir);
+ memIndexes.set(cacheDir, index);
+ while (memIndexes.size > MEM_INDEX_MAX) {
+ const oldest = memIndexes.keys().next().value;
+ if (oldest === undefined) break;
+ memIndexes.delete(oldest);
+ }
+ return index;
+}
+
+/** Drop the in-memory index for one session (or all, for tests). */
+export function dropGrepIndex(cacheDir?: string): void {
+ if (cacheDir) memIndexes.delete(cacheDir);
+ else memIndexes.clear();
+}
+
+const yieldLoop = () => new Promise((done) => setImmediate(done));
+
export function isOpenGrepPattern(pattern: string, regex = true): boolean {
const alts = splitTopLevelAlts(pattern, regex);
if (!alts.length) return true;
@@ -154,14 +186,15 @@ export function filesForLiteral(index: GrepIndex, literal: string): number[] | u
for (const gram of grams) {
const posting = index.postings.get(gram);
if (!posting?.length) return [];
- const next = new Set(posting);
if (!current) {
- current = next;
+ current = new Set(posting);
continue;
}
+ const next = new Set(posting);
for (const id of current) {
if (!next.has(id)) current.delete(id);
}
+ if (!current.size) return [];
}
return current ? [...current] : [];
}
@@ -181,7 +214,11 @@ export function candidateFileIds(
for (const lit of lits) {
const files = filesForLiteral(index, lit);
if (!files) return undefined;
- inter = inter ? inter.filter((id) => files.includes(id)) : files;
+ if (!inter) inter = files;
+ else {
+ const keep = new Set(files);
+ inter = inter.filter((id) => keep.has(id));
+ }
if (!inter.length) break;
}
for (const id of inter ?? []) union.add(id);
@@ -189,40 +226,64 @@ export function candidateFileIds(
return [...union];
}
+/** Load an index from memory, or from disk once per process. */
export async function loadGrepIndex(cacheDir?: string): Promise {
if (!cacheDir) return undefined;
+ const hit = memIndexes.get(cacheDir);
+ if (hit) return rememberIndex(cacheDir, hit);
try {
const meta = JSON.parse(
await readFile(join(cacheDir, GREP_META_FILE), "utf8"),
) as GrepIndexMeta;
if (meta?.version !== GREP_INDEX_VERSION || typeof meta.fileCount !== "number")
return undefined;
- const raw = JSON.parse(await readFile(join(cacheDir, POSTINGS_FILE), "utf8")) as {
+ const raw = JSON.parse(await readFile(join(cacheDir, INDEX_FILE), "utf8")) as {
files?: string[];
- postings?: Record;
+ grams?: string[];
+ counts?: number[];
};
- if (!Array.isArray(raw.files) || !raw.postings || typeof raw.postings !== "object") {
+ if (!Array.isArray(raw.files) || !Array.isArray(raw.grams) || !Array.isArray(raw.counts)) {
return undefined;
}
- const postings = new Map();
- for (const [gram, ids] of Object.entries(raw.postings)) {
- if (Array.isArray(ids)) postings.set(gram, ids);
+ const buf = await readFile(join(cacheDir, POSTINGS_FILE));
+ // Copy into an aligned buffer: pooled Buffers can start at an odd byte offset.
+ const ids = new Uint32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
+ const postings = new Map();
+ let at = 0;
+ for (let i = 0; i < raw.grams.length; i += 1) {
+ const n = raw.counts[i] ?? 0;
+ if (at + n > ids.length) return undefined;
+ postings.set(raw.grams[i]!, ids.subarray(at, at + n));
+ at += n;
}
- return { files: raw.files, postings, meta };
+ return rememberIndex(cacheDir, { files: raw.files, postings, meta });
} catch {
return undefined;
}
}
+async function writeAtomic(dir: string, name: string, data: string | Uint8Array): Promise {
+ const tmp = join(dir, `.${name}.tmp`);
+ await writeFile(tmp, data);
+ try {
+ await rename(tmp, join(dir, name));
+ } catch {
+ await unlink(join(dir, name)).catch(() => undefined);
+ await rename(tmp, join(dir, name));
+ }
+}
+
export async function buildGrepIndex(
root: string,
cacheDir: string,
paths: string[],
): Promise {
+ const headPromise = gitHead(root);
const files = paths.filter((path) => path && !repoMapPathSkipped(path)).slice(0, BUILD_FILE_CAP);
- const postings = new Map();
+ const lists = new Map();
const unindexed: string[] = [];
for (let i = 0; i < files.length; i += 1) {
+ if (i % BUILD_YIELD_EVERY === 0) await yieldLoop();
const rel = files[i]!;
let text = "";
try {
@@ -237,73 +298,89 @@ export async function buildGrepIndex(
continue;
}
for (const gram of trigrams(text)) {
- const row = postings.get(gram);
+ const row = lists.get(gram);
if (row) row.push(i);
- else postings.set(gram, [i]);
+ else lists.set(gram, [i]);
}
}
- const source = await filesJsonlStamp(cacheDir);
+ await yieldLoop();
+ const grams = [...lists.keys()];
+ const counts = grams.map((gram) => lists.get(gram)!.length);
+ const ids = new Uint32Array(counts.reduce((n, c) => n + c, 0));
+ const postings = new Map();
+ let at = 0;
+ for (const gram of grams) {
+ const row = lists.get(gram)!;
+ ids.set(row, at);
+ postings.set(gram, ids.subarray(at, at + row.length));
+ at += row.length;
+ }
+ lists.clear();
const meta: GrepIndexMeta = {
version: GREP_INDEX_VERSION,
fileCount: files.length,
builtAt: new Date().toISOString(),
- ...(source ? { sourceMtimeMs: source.mtimeMs, sourceSize: source.size } : {}),
+ head: await headPromise,
...(unindexed.length ? { unindexed } : {}),
};
await mkdir(cacheDir, { recursive: true });
- const tmp = join(cacheDir, `.${POSTINGS_FILE}.tmp`);
- const tmpMeta = join(cacheDir, `.${GREP_META_FILE}.tmp`);
- const payload = {
- files,
- postings: Object.fromEntries(postings),
- };
- await writeFile(tmp, JSON.stringify(payload));
- await writeFile(tmpMeta, `${JSON.stringify(meta)}\n`);
- try {
- await rename(tmp, join(cacheDir, POSTINGS_FILE));
- } catch {
- await unlink(join(cacheDir, POSTINGS_FILE)).catch(() => undefined);
- await rename(tmp, join(cacheDir, POSTINGS_FILE));
- }
- try {
- await rename(tmpMeta, join(cacheDir, GREP_META_FILE));
- } catch {
- await unlink(join(cacheDir, GREP_META_FILE)).catch(() => undefined);
- await rename(tmpMeta, join(cacheDir, GREP_META_FILE));
- }
- return { files, postings, meta };
+ await writeAtomic(cacheDir, POSTINGS_FILE, new Uint8Array(ids.buffer));
+ await writeAtomic(cacheDir, INDEX_FILE, JSON.stringify({ files, grams, counts }));
+ await writeAtomic(cacheDir, GREP_META_FILE, `${JSON.stringify(meta)}\n`);
+ await unlink(join(cacheDir, LEGACY_POSTINGS_FILE)).catch(() => undefined);
+ return rememberIndex(cacheDir, { files, postings, meta });
}
+/** Return an index built from the current HEAD, rebuilding (once, deduped) when HEAD moved. */
export async function ensureGrepIndex(
root: string,
cacheDir: string,
+ head?: string,
): Promise {
const existing = await loadGrepIndex(cacheDir);
- const stamp = await filesJsonlStamp(cacheDir);
- if (
- existing &&
- (!stamp ||
- (existing.meta.sourceMtimeMs === stamp.mtimeMs && existing.meta.sourceSize === stamp.size))
- ) {
- return existing;
+ const current = head ?? (await gitHead(root));
+ if (existing && existing.meta.head === current) return existing;
+ const inflight = inflightBuilds.get(cacheDir);
+ if (inflight) return inflight;
+ const pending = (async () => {
+ const paths = await indexPaths(root, cacheDir);
+ if (!paths?.length) return existing;
+ return buildGrepIndex(root, cacheDir, paths);
+ })().finally(() => inflightBuilds.delete(cacheDir));
+ inflightBuilds.set(cacheDir, pending);
+ return pending;
+}
+
+/** Fresh index right now, or undefined while a background (re)build runs. Never blocks a grep. */
+function freshGrepIndex(root: string, cacheDir: string, head?: string): GrepIndex | undefined {
+ const hit = memIndexes.get(cacheDir);
+ if (hit && head !== undefined && hit.meta.head === head) return rememberIndex(cacheDir, hit);
+ if (!inflightBuilds.has(cacheDir)) {
+ void ensureGrepIndex(root, cacheDir, head).catch(() => undefined);
}
- const paths = await loadIndexPaths(cacheDir);
- if (!paths?.length) return existing;
- return buildGrepIndex(root, cacheDir, paths);
+ return undefined;
}
-async function filesJsonlStamp(
- cacheDir: string,
-): Promise<{ mtimeMs: number; size: number } | undefined> {
+/** Test hook: wait for any background index build. */
+export async function settleGrepIndexBuilds(): Promise {
+ await Promise.all([...inflightBuilds.values()].map((p) => p.catch(() => undefined)));
+}
+
+async function gitHead(root: string): Promise {
try {
- const st = await stat(join(cacheDir, "files.jsonl"));
- return { mtimeMs: st.mtimeMs, size: st.size };
+ const { stdout } = await execFileAsync("git", ["-C", root, "rev-parse", "HEAD"], {
+ timeout: 2000,
+ windowsHide: true,
+ });
+ const sha = stdout.trim();
+ return /^[0-9a-f]{7,64}$/i.test(sha) ? sha : undefined;
} catch {
return undefined;
}
}
-async function loadIndexPaths(cacheDir: string): Promise {
+/** Paths from the repo index when it exists, else git's tracked + untracked list. */
+async function indexPaths(root: string, cacheDir: string): Promise {
try {
const raw = await readFile(join(cacheDir, "files.jsonl"), "utf8");
const paths: string[] = [];
@@ -312,12 +389,33 @@ async function loadIndexPaths(cacheDir: string): Promise {
const row = JSON.parse(line) as { path?: string };
if (row?.path) paths.push(row.path);
}
- return paths;
+ if (paths.length) return paths;
+ } catch {
+ /* fall through to git */
+ }
+ try {
+ const { stdout } = await execFileAsync(
+ "git",
+ [
+ "-C",
+ root,
+ "-c",
+ "core.quotepath=false",
+ "ls-files",
+ "-z",
+ "--cached",
+ "--others",
+ "--exclude-standard",
+ ],
+ { timeout: 8000, maxBuffer: 32_000_000, windowsHide: true },
+ );
+ return stdout.split("\0").filter(Boolean);
} catch {
return undefined;
}
}
+/** Porcelain v1 `-z` paths. Renames and copies list the new path first, then the source. */
export function parsePorcelainZ(stdout: string): string[] {
const parts = stdout.split("\0");
const out: string[] = [];
@@ -325,15 +423,9 @@ export function parsePorcelainZ(stdout: string): string[] {
const row = parts[i];
if (!row) continue;
const code = row.slice(0, 2);
- let path =
+ const path =
row.length >= 3 && row[2] === " " ? row.slice(3) : row.slice(Math.min(3, row.length));
- if (code.includes("R") || code.includes("C")) {
- const next = parts[i + 1];
- if (next) {
- path = next;
- i += 1;
- }
- }
+ if (code.includes("R") || code.includes("C")) i += 1;
const rel = path.replace(/\\/g, "/").trim();
if (rel && !repoMapPathSkipped(rel)) out.push(rel);
}
@@ -341,16 +433,7 @@ export function parsePorcelainZ(stdout: string): string[] {
}
export async function dirtyWorkspaceFiles(root: string): Promise {
- try {
- const { stdout } = await execFileAsync(
- "git",
- ["-C", root, "-c", "core.quotepath=false", "status", "--porcelain", "-z"],
- { timeout: 4000, maxBuffer: 8_000_000, windowsHide: true },
- );
- return parsePorcelainZ(stdout);
- } catch {
- return [];
- }
+ return (await workspaceGitSnapshot(root))?.dirty ?? [];
}
export function grepContextLines(args: Record, fallback = 3): number {
@@ -403,22 +486,31 @@ export async function grepIndexedWorkspace(input: {
if (rel == null) return { output: "", usedIndex: false };
const rg = await resolveRipgrep();
if (!rg) return undefined;
- const index = input.cacheDir ? await ensureGrepIndex(input.root, input.cacheDir) : undefined;
- const dirty = await dirtyWorkspaceFiles(input.root);
+ const regex = input.regex !== false;
let files: string[] | undefined;
let usedIndex = false;
- if (index && !isOpenGrepPattern(pattern, input.regex !== false)) {
- const ids = candidateFileIds(index, pattern, input.regex !== false);
- if (ids && !indexTooWide(ids, index.files.length)) {
- const set = new Set();
- for (const id of ids) {
- const path = index.files[id];
- if (path) set.add(path);
+ let index: GrepIndex | undefined;
+ if (input.cacheDir && !isOpenGrepPattern(pattern, regex)) {
+ // Narrow with whatever index is loaded first; git status (needed to prove the index is
+ // current and to add dirty files) only runs when the candidates will actually be used.
+ const loaded = memIndexes.get(input.cacheDir);
+ const ids = loaded ? candidateFileIds(loaded, pattern, regex) : undefined;
+ if (!loaded) freshGrepIndex(input.root, input.cacheDir);
+ else if (ids && !indexTooWide(ids, loaded.files.length)) {
+ const snapshot = await workspaceGitSnapshot(input.root);
+ index = snapshot ? freshGrepIndex(input.root, input.cacheDir, snapshot.head) : undefined;
+ if (index === loaded && snapshot) {
+ const set = new Set();
+ for (const id of ids) {
+ const path = index.files[id];
+ if (path) set.add(path);
+ }
+ for (const path of snapshot.dirty) set.add(path);
+ for (const path of index.meta.unindexed ?? []) set.add(path);
+ for (const path of snapshot.gone) set.delete(path);
+ files = [...set].filter((path) => pathMatchesPrefix(path, rel));
+ usedIndex = true;
}
- for (const path of dirty) set.add(path);
- for (const path of index.meta.unindexed ?? []) set.add(path);
- files = [...set].filter((path) => pathMatchesPrefix(path, rel));
- usedIndex = true;
}
}
if (usedIndex && !files?.length) {
@@ -431,7 +523,7 @@ export async function grepIndexedWorkspace(input: {
files,
glob: input.glob,
context: input.context ?? 3,
- regex: input.regex !== false,
+ regex,
searchPath: rel,
cap,
});
@@ -450,6 +542,25 @@ function pathMatchesPrefix(path: string, prefix: string): boolean {
return path === base || path.startsWith(`${base}/`);
}
+/** Split a candidate list so no single rg command line exceeds `maxChars`. */
+export function chunkRgFiles(files: string[], baseChars: number, maxChars: number): string[][] {
+ const chunks: string[][] = [];
+ let chunk: string[] = [];
+ let size = baseChars;
+ for (const file of files) {
+ const cost = file.length + 3;
+ if (chunk.length && size + cost > maxChars) {
+ chunks.push(chunk);
+ chunk = [];
+ size = baseChars;
+ }
+ chunk.push(file);
+ size += cost;
+ }
+ if (chunk.length) chunks.push(chunk);
+ return chunks;
+}
+
async function runAgentRipgrep(input: {
bin: string;
root: string;
@@ -477,13 +588,24 @@ async function runAgentRipgrep(input: {
if (glob) args.push("--glob", glob);
if (!input.files?.length) args.push("--max-filesize", String(MAX_FILE_BYTES));
args.push("-e", input.pattern, "--");
- if (input.files) {
- if (!input.files.length) return "";
- args.push(...input.files);
- } else {
- args.push(input.searchPath || ".");
+ if (!input.files) {
+ return spawnRg(input.bin, [...args, input.searchPath || "."], input.root, input.cap);
+ }
+ if (!input.files.length) return "";
+ const base = input.bin.length + args.reduce((n, arg) => n + arg.length + 3, 0);
+ let output = "";
+ for (const chunk of chunkRgFiles(input.files, base, MAX_RG_ARGV_CHARS)) {
+ const part = await spawnRg(
+ input.bin,
+ [...args, ...chunk],
+ input.root,
+ input.cap - output.length,
+ );
+ if (part == null) return output || undefined;
+ output += part && output && !output.endsWith("\n") ? `\n${part}` : part;
+ if (output.length >= input.cap) return output.slice(0, input.cap);
}
- return spawnRg(input.bin, args, input.root, input.cap);
+ return output;
}
function spawnRg(
diff --git a/apps/server/src/workspace/repoIndex.test.ts b/apps/server/src/workspace/repoIndex.test.ts
index 6b0e8c5..290f9e5 100644
--- a/apps/server/src/workspace/repoIndex.test.ts
+++ b/apps/server/src/workspace/repoIndex.test.ts
@@ -8,6 +8,8 @@ import { afterEach, describe, expect, it } from "vitest";
import { parseIgnore, pathIgnored } from "./repoIgnore.js";
import {
buildWorkspaceIndex,
+ dropWorkspaceIndex,
+ getWorkspaceIndex,
indexWorkspace,
peekWorkspaceIndex,
resetWorkspaceIndexCache,
@@ -121,6 +123,57 @@ describe("workspace index", () => {
expect(forced.map.files[1]?.symbols).toContain("beta");
});
+ it("does not re-read source files that have no symbols", async () => {
+ const { root, cache } = await fixture();
+ await writeFile(join(root, "a.ts"), "export function alpha() {}\n");
+ await writeFile(join(root, "empty.ts"), "// nothing exported here\n");
+ const first = await buildWorkspaceIndex(root, { cacheDir: cache });
+ expect(first.stats.read).toBe(2);
+ resetWorkspaceIndexCache();
+ const forced = await buildWorkspaceIndex(root, { cacheDir: cache, force: true });
+ expect(forced.stats.read).toBe(0);
+ expect(forced.map.files.find((file) => file.path === "a.ts")?.symbols).toContain("alpha");
+ });
+
+ it("skips per-file stats for clean tracked files and still sees edits", async () => {
+ const { root, cache } = await fixture();
+ await execFileAsync("git", ["init"], { cwd: root });
+ await execFileAsync("git", ["config", "user.email", "locus@test"], { cwd: root });
+ await execFileAsync("git", ["config", "user.name", "Locus"], { cwd: root });
+ await writeFile(join(root, "a.ts"), "export function alpha() {}\n");
+ await writeFile(join(root, "b.ts"), "export function beta() {}\n");
+ await execFileAsync("git", ["add", "."], { cwd: root });
+ await execFileAsync("git", ["commit", "-m", "init", "--no-gpg-sign"], { cwd: root });
+ await buildWorkspaceIndex(root, { cacheDir: cache });
+
+ await writeFile(join(root, "b.ts"), "export function betaTwo() {}\n");
+ await writeFile(join(root, "c.ts"), "export function gamma() {}\n");
+ resetWorkspaceIndexCache();
+ const rebuilt = await buildWorkspaceIndex(root, { cacheDir: cache, force: true });
+ const symbols = Object.fromEntries(rebuilt.map.files.map((f) => [f.path, f.symbols]));
+ expect(symbols["a.ts"]).toContain("alpha");
+ expect(symbols["b.ts"]).toContain("betaTwo");
+ expect(symbols["c.ts"]).toContain("gamma");
+ expect(rebuilt.stats.reused).toBe(1);
+
+ await execFileAsync("git", ["rm", "-q", "a.ts"], { cwd: root });
+ resetWorkspaceIndexCache();
+ const removed = await buildWorkspaceIndex(root, { cacheDir: cache, force: true });
+ expect(removed.map.files.map((f) => f.path)).toEqual(["b.ts", "c.ts"]);
+ });
+
+ it("drops one session's in-memory index and hydrates it again from disk", async () => {
+ const { root, cache } = await fixture();
+ await writeFile(join(root, "a.ts"), "export function alpha() {}\n");
+ const built = await buildWorkspaceIndex(root, { cacheDir: cache });
+ expect(await getWorkspaceIndex(root, { cacheDir: cache })).toBe(built);
+ dropWorkspaceIndex(cache);
+ const again = await getWorkspaceIndex(root, { cacheDir: cache });
+ expect(again).not.toBe(built);
+ expect(again.stats.read).toBe(0);
+ expect(again.map.files[0]?.symbols).toContain("alpha");
+ });
+
it("skips binary files and does not index symlink targets", async () => {
const { root } = await fixture();
await writeFile(join(root, "ok.ts"), "export function keep() {}\n");
diff --git a/apps/server/src/workspace/repoIndex.ts b/apps/server/src/workspace/repoIndex.ts
index 9aca603..78de27c 100644
--- a/apps/server/src/workspace/repoIndex.ts
+++ b/apps/server/src/workspace/repoIndex.ts
@@ -1,4 +1,4 @@
-import { execFile } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { extname, join, resolve } from "node:path";
import type { Dirent } from "node:fs";
@@ -17,6 +17,7 @@ import {
import { containedPath } from "@locus/guest-agent";
import { parseIgnore, pathIgnored, type IgnoreRule } from "./repoIgnore.js";
import { WORKSPACE_FILE_MAX_BYTES } from "./workspaceFiles.js";
+import { workspaceGitSnapshot } from "./gitPorcelain.js";
const execFileAsync = promisify(execFile);
const FILE_CONCURRENCY = 32;
@@ -135,6 +136,8 @@ export interface IndexedFile {
bytes: number;
mtimeMs: number;
symbols: string[];
+ /** Set once symbols were extracted, so a file with none is not re-read on every build. */
+ sx?: 1;
}
export interface IndexMeta {
@@ -228,8 +231,18 @@ function setIndexProgress(key: string, next: WorkspaceIndexProgress): void {
indexProgress.set(key, next);
}
+/** Ready indexes kept in memory; a 20k-file repo map is tens of MB of heap. */
+const MAX_READY_INDEXES = 6;
+
function rememberReadyIndex(key: string, result: IndexBuildResult): IndexBuildResult {
+ readyIndexes.delete(key);
readyIndexes.set(key, result);
+ while (readyIndexes.size > MAX_READY_INDEXES) {
+ const oldest = readyIndexes.keys().next().value;
+ if (oldest === undefined) break;
+ readyIndexes.delete(oldest);
+ indexProgress.delete(oldest);
+ }
setIndexProgress(key, {
status: "ready",
fileCount: result.meta.fileCount,
@@ -242,6 +255,12 @@ function rememberReadyIndex(key: string, result: IndexBuildResult): IndexBuildRe
return result;
}
+/** Forget one session's in-memory index (its disk cache is the caller's to delete). */
+export function dropWorkspaceIndex(cacheDir: string): void {
+ readyIndexes.delete(cacheDir);
+ indexProgress.delete(cacheDir);
+}
+
/** Test hook: drop in-memory / in-flight index state. */
export function resetWorkspaceIndexCache(): void {
readyIndexes.clear();
@@ -280,7 +299,7 @@ export async function getWorkspaceIndex(
const key = progressKey(root, opts.cacheDir);
if (!opts.force) {
const ready = readyIndexes.get(key);
- if (ready) return ready;
+ if (ready) return rememberReadyIndex(key, ready);
const inflight = indexBuilds.get(key);
if (inflight) return inflight;
const disk = await hydrateReadyIndex(opts.cacheDir);
@@ -347,6 +366,7 @@ async function runWorkspaceIndexBuild(
const heartbeat = setInterval(pulse, HEARTBEAT_MS);
const deadline = Date.now() + INDEX_BUILD_MS;
try {
+ const prevMeta = await loadIndexMeta(opts.cacheDir);
const prev = await loadIndex(opts.cacheDir);
const gitIgnore = await loadIgnoreFile(root, ".gitignore");
const locusIgnore = await loadIgnoreFile(root, ".locusignore");
@@ -373,7 +393,8 @@ async function runWorkspaceIndexBuild(
if (gitPlan) {
seen += gitPlan.files.length + gitPlan.extraDirs.length;
pulse();
- await indexListedFiles(root, gitPlan.files, files, prev, locusIgnore, stats, budget);
+ const clean = await cleanTrackedFiles(root, prev, prevMeta, gitPlan, await headPromise);
+ await indexListedFiles(root, gitPlan.files, files, prev, locusIgnore, stats, budget, clean);
for (const dir of gitPlan.extraDirs) {
if (files.length >= MAX_INDEX_FILES || budget.timedOut()) break;
await walkIndex(root, dir, files, prev, ignore, stats, budget);
@@ -463,12 +484,27 @@ async function indexListedFiles(
ignore: IgnoreRule[],
stats: IndexBuildStats,
budget: IndexBudget,
+ clean?: Set,
): Promise {
const limitFile = createLimiter(FILE_CONCURRENCY);
const noteProgress = () => {
if (out.length === 1 || out.length % PROGRESS_EVERY === 0) budget.onProgress?.();
};
let n = 0;
+ if (clean?.size) {
+ // Same HEAD and git reports no change: reuse the row without an lstat per file.
+ const rest: string[] = [];
+ for (const rel of rels) {
+ const row = clean.has(rel) ? prev.get(rel) : undefined;
+ if (row && out.length < MAX_INDEX_FILES) {
+ out.push(row);
+ stats.reused += 1;
+ budget.addSeen();
+ } else rest.push(rel);
+ }
+ rels = rest;
+ budget.onProgress?.();
+ }
await Promise.all(
rels.map((rel) =>
limitFile(async () => {
@@ -614,29 +650,55 @@ async function walkIndex(
await done;
}
-async function ctagsSymbolMap(root: string): Promise | undefined> {
+/** Run ctags over just the files that still need symbols (never `-R .` over the whole tree). */
+async function ctagsSymbolMap(
+ root: string,
+ files: string[],
+): Promise | undefined> {
+ if (!files.length) return undefined;
const bin = process.platform === "win32" ? "ctags.exe" : "ctags";
- try {
- const { stdout } = await execFileAsync(bin, ["-R", "-x", "."], {
+ const stdout = await new Promise((done) => {
+ let out = "";
+ let settled = false;
+ const finish = (value?: string) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ done(value);
+ };
+ const child = spawn(bin, ["-x", "-L", "-"], {
cwd: root,
- maxBuffer: 8 * 1024 * 1024,
- timeout: 8_000,
+ stdio: ["pipe", "pipe", "ignore"],
windowsHide: true,
});
- const parsed = parseCtagsXOutput(stdout);
- if (!parsed.size) return undefined;
- const out = new Map();
- for (const [file, rows] of parsed) {
- const names: string[] = [];
- for (const row of rows) {
- if (!names.includes(row.name)) names.push(row.name);
+ const timer = setTimeout(() => {
+ child.kill("SIGTERM");
+ finish(undefined);
+ }, 8_000);
+ child.on("error", () => finish(undefined));
+ child.stdout?.on("data", (buf: Buffer) => {
+ out += buf.toString("utf8");
+ if (out.length > 8 * 1024 * 1024) {
+ child.kill("SIGTERM");
+ finish(undefined);
}
- out.set(file, names);
+ });
+ child.on("close", (code) => finish(code === 0 ? out : undefined));
+ child.stdin?.on("error", () => undefined);
+ child.stdin?.end(`${files.join("\n")}\n`);
+ });
+ if (!stdout) return undefined;
+ const parsed = parseCtagsXOutput(stdout);
+ if (!parsed.size) return undefined;
+ const out = new Map();
+ for (const [file, rows] of parsed) {
+ const names: string[] = [];
+ for (const row of rows) {
+ if (!names.includes(row.name)) names.push(row.name);
}
- return out;
- } catch {
- return undefined;
+ out.set(file, names);
}
+ return out;
}
async function fillIndexSymbols(
@@ -655,7 +717,7 @@ async function fillIndexSymbols(
),
);
const targets = candidates.filter((file) => chosen.has(file.path));
- const missing = targets.filter((file) => !file.symbols.length);
+ const missing = targets.filter((file) => !file.symbols.length && !file.sx);
if (!missing.length) {
for (const file of targets) {
if (!file.symbols.length) continue;
@@ -664,13 +726,16 @@ async function fillIndexSymbols(
}
return;
}
- const fromCtags = await ctagsSymbolMap(root);
+ const fromCtags = await ctagsSymbolMap(
+ root,
+ missing.map((file) => file.path),
+ );
const limitFile = createLimiter(FILE_CONCURRENCY);
await Promise.all(
targets.map((file) =>
limitFile(async () => {
if (budget.timedOut()) return;
- if (file.symbols.length) {
+ if (file.symbols.length || file.sx) {
budget.noteSymbolFile();
budget.addSymbols(file.symbols.length);
return;
@@ -678,6 +743,7 @@ async function fillIndexSymbols(
const tagged = fromCtags?.get(file.path);
if (tagged?.length) {
file.symbols = tagged;
+ file.sx = 1;
budget.noteSymbolFile();
budget.addSymbols(file.symbols.length);
return;
@@ -687,6 +753,7 @@ async function fillIndexSymbols(
const buf = await readFile(abs);
if (buf.subarray(0, Math.min(BINARY_PEEK_BYTES, buf.length)).includes(0)) return;
file.symbols = extractSymbols(file.path, buf.toString("utf8"));
+ file.sx = 1;
stats.read += 1;
budget.noteSymbolFile();
budget.addSymbols(file.symbols.length);
@@ -736,6 +803,7 @@ async function readIndexedFile(
bytes,
mtimeMs,
symbols: extractSymbols(rel, buf.toString("utf8")),
+ sx: 1,
};
}
if (TEXT_META_EXT.has(ext) || SOURCE_EXT.has(ext) || bytes === 0) {
@@ -773,6 +841,39 @@ async function loadRootIgnore(root: string): Promise {
return rules;
}
+async function loadIndexMeta(cacheDir?: string): Promise {
+ if (!cacheDir) return undefined;
+ try {
+ const meta = JSON.parse(await readFile(join(cacheDir, "meta.json"), "utf8")) as IndexMeta;
+ return meta?.version === INDEX_VERSION ? meta : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Tracked paths that cannot have changed since the last build: HEAD is the same and
+ * `git status` does not list them. Undefined when that cannot be proven.
+ */
+async function cleanTrackedFiles(
+ root: string,
+ prev: Map,
+ prevMeta: IndexMeta | undefined,
+ plan: { files: string[]; trackedCount: number },
+ head: string | undefined,
+): Promise | undefined> {
+ if (!prev.size || !head || prevMeta?.head !== head) return undefined;
+ const snap = await workspaceGitSnapshot(root, GIT_INDEX_TIMEOUT_MS);
+ if (!snap || snap.head !== head) return undefined;
+ const changed = new Set([...snap.dirty, ...snap.gone]);
+ const clean = new Set();
+ for (let i = 0; i < plan.trackedCount; i += 1) {
+ const rel = plan.files[i]!;
+ if (!changed.has(rel)) clean.add(rel);
+ }
+ return clean;
+}
+
async function loadIndex(cacheDir?: string): Promise> {
const out = new Map();
if (!cacheDir) return out;
@@ -829,7 +930,7 @@ async function gitHead(root: string): Promise {
async function gitIndexPlan(
root: string,
ignore: IgnoreRule[],
-): Promise<{ files: string[]; extraDirs: string[] } | undefined> {
+): Promise<{ files: string[]; extraDirs: string[]; trackedCount: number } | undefined> {
try {
const { stdout: topRaw } = await execFileAsync(
"git",
@@ -852,6 +953,7 @@ async function gitIndexPlan(
if (repoMapPathSkipped(rel) || pathIgnored(rel, false, ignore)) continue;
files.push(rel);
}
+ const trackedCount = files.length;
for (const rel of others) {
if (repoMapPathSkipped(rel) || pathIgnored(rel, false, ignore)) continue;
try {
@@ -872,7 +974,7 @@ async function gitIndexPlan(
/* skip */
}
}
- return { files, extraDirs };
+ return { files, extraDirs, trackedCount };
} catch {
return undefined;
}
diff --git a/apps/server/src/workspace/workspaceAccess.test.ts b/apps/server/src/workspace/workspaceAccess.test.ts
index 9a97650..32b69fb 100644
--- a/apps/server/src/workspace/workspaceAccess.test.ts
+++ b/apps/server/src/workspace/workspaceAccess.test.ts
@@ -65,6 +65,42 @@ describe("listWorkspaceAccess", () => {
).rejects.toThrow(WORKSPACE_UNSUPPORTED);
});
+ it("flags a listing that stopped at the entry cap", async () => {
+ const many = Array.from({ length: 16_000 }, (_, i) => ({
+ name: `f${i}.ts`,
+ path: `f${i}.ts`,
+ type: "file" as const,
+ }));
+ const base = {
+ hasVm: true,
+ messageChanges: [],
+ listGuest: async () => undefined,
+ gitChanges: async () => [],
+ skipGit: true,
+ };
+ const full = await listWorkspaceAccess({
+ ...base,
+ kind: "host",
+ listWorker: async () => [],
+ listHost: async () => many,
+ });
+ expect(full.truncated).toBe(true);
+ const worker = await listWorkspaceAccess({
+ ...base,
+ kind: "worker",
+ listWorker: async () => many,
+ listHost: async () => [],
+ });
+ expect(worker.truncated).toBe(true);
+ const small = await listWorkspaceAccess({
+ ...base,
+ kind: "host",
+ listWorker: async () => [],
+ listHost: async () => many.slice(0, 3),
+ });
+ expect(small.truncated).toBeUndefined();
+ });
+
it("lists nothing on a worker session before the guest exists", async () => {
const listed = await listWorkspaceAccess({
kind: "worker",
@@ -387,7 +423,7 @@ describe("writeWorkspaceBytesAccess", () => {
describe("guest workspace adapters", () => {
it("lists via find, reads via head, writes via file_write", async () => {
const execute = vi.fn(async (req: { name: string; args: Record }) => {
- if (req.name === "exec" && String(req.args.command).startsWith("find ")) {
+ if (req.name === "exec" && String(req.args.command).includes("find '/workspace")) {
return { ok: true, output: "f\t/workspace/src/app.ts\n" };
}
if (req.name === "exec" && String(req.args.command).startsWith("head ")) {
diff --git a/apps/server/src/workspace/workspaceAccess.ts b/apps/server/src/workspace/workspaceAccess.ts
index bace7b2..23b9334 100644
--- a/apps/server/src/workspace/workspaceAccess.ts
+++ b/apps/server/src/workspace/workspaceAccess.ts
@@ -6,6 +6,7 @@ import {
workspaceFindScript,
WORKSPACE_FILE_MAX_BYTES,
type WorkspaceEntry,
+ WORKSPACE_LIST_MAX_ENTRIES,
} from "./workspaceFiles.js";
export type WorkspaceAccessKind = "unsupported" | "worker" | "host" | "docker" | "vm";
@@ -165,6 +166,19 @@ export async function deleteGuestWorkspacePath(
}
}
+export type WorkspaceListing = {
+ entries: WorkspaceEntry[];
+ changes: WorkspaceFileChange[];
+ /** The listing stopped at the entry cap, so some paths are missing. */
+ truncated?: boolean;
+};
+
+function withTruncation(listing: Omit): WorkspaceListing {
+ return listing.entries.length >= WORKSPACE_LIST_MAX_ENTRIES
+ ? { ...listing, truncated: true }
+ : listing;
+}
+
export async function listWorkspaceAccess(input: {
kind: WorkspaceAccessKind;
hasVm: boolean;
@@ -174,16 +188,16 @@ export async function listWorkspaceAccess(input: {
listHost: () => Promise;
gitChanges: () => Promise;
skipGit?: boolean;
-}): Promise<{ entries: WorkspaceEntry[]; changes: WorkspaceFileChange[] }> {
+}): Promise {
if (input.kind === "unsupported") throw new Error(WORKSPACE_UNSUPPORTED);
if (input.kind === "worker") {
if (!input.hasVm) return { entries: [], changes: input.messageChanges };
try {
const entries = await input.listWorker();
- return { entries, changes: input.messageChanges };
+ return withTruncation({ entries, changes: input.messageChanges });
} catch {
const entries = (await input.listGuest()) ?? [];
- return { entries, changes: input.messageChanges };
+ return withTruncation({ entries, changes: input.messageChanges });
}
}
const gitPromise = input.skipGit ? undefined : input.gitChanges();
@@ -195,12 +209,12 @@ export async function listWorkspaceAccess(input: {
} else if (workspaceUsesGuestFs(input.kind) && !hostEntries.length) {
entries = (await input.listGuest()) ?? hostEntries;
}
- return {
+ return withTruncation({
entries,
changes: gitPromise
? mergeWorkspaceChanges(input.messageChanges, await gitPromise)
: input.messageChanges,
- };
+ });
}
export async function readWorkspaceAccess(input: {
diff --git a/apps/server/src/workspace/workspaceFiles.test.ts b/apps/server/src/workspace/workspaceFiles.test.ts
index 787dbda..c0544b9 100644
--- a/apps/server/src/workspace/workspaceFiles.test.ts
+++ b/apps/server/src/workspace/workspaceFiles.test.ts
@@ -102,6 +102,13 @@ describe("workspace files", () => {
expect(workspaceFindScript()).toContain("head -n 16000");
});
+ it("never forks a process per listed entry", () => {
+ const script = workspaceFindScript("src");
+ expect(script).not.toContain("{} \\;");
+ expect(script).toContain("-printf 'f\\t%p\\n'");
+ expect(script).toContain("-exec printf 'f\\t%s\\n' {} +");
+ });
+
it("skips the Chromium profile directory the same way as node_modules", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-ws-chrome-"));
cleanup.push(dir);
diff --git a/apps/server/src/workspace/workspaceFiles.ts b/apps/server/src/workspace/workspaceFiles.ts
index 68014f8..63344c1 100644
--- a/apps/server/src/workspace/workspaceFiles.ts
+++ b/apps/server/src/workspace/workspaceFiles.ts
@@ -19,6 +19,8 @@ import { containedPath, ensureParentInside } from "@locus/guest-agent";
export const WORKSPACE_SKIP_DIRS = WORKSPACE_SKIP_DIR_SET;
export const WORKSPACE_LIST_MAX_ENTRIES = 16_000;
export const WORKSPACE_FILE_MAX_BYTES = 1_500_000;
+/** Files stat'ed concurrently per directory while listing. */
+const LIST_STAT_BATCH = 32;
const MAX_FILE_BYTES = WORKSPACE_FILE_MAX_BYTES;
/** PDFs in the Files pane; matches worker static / kit write cap. */
const MAX_RAW_BYTES = 8 * 1024 * 1024;
@@ -85,10 +87,18 @@ export function workspaceFindScript(rel = ""): string {
const hidden = [...WORKSPACE_SKIP_DIRS].map((name) => `-name ${name}`).join(" -o ");
const allow = INDEX_DIR_ALLOWLIST.map((name) => `! -name ${name}`).join(" ");
const shallow = `-type d -name '.*' ${allow}`;
+ const prune = `find ${quoted} -mindepth 1 \\( ${hidden} \\) -prune -o `;
+ // One process per entry (`-exec printf … {} \;`) took 31 s on a vscode-sized tree.
+ // GNU `-printf` keeps find's walk order at 0.05 s; `-exec … {} +` is the POSIX fallback.
+ const gnu =
+ `${prune}\\( ${shallow} \\) -prune -printf 'd\\t%p\\n' -o ` +
+ `-type d -printf 'd\\t%p\\n' -o -type f -printf 'f\\t%p\\n'`;
+ const posix =
+ `${prune}\\( ${shallow} \\) -prune -exec printf 'd\\t%s\\n' {} + -o ` +
+ `-type d -exec printf 'd\\t%s\\n' {} + -o -type f -exec printf 'f\\t%s\\n' {} +`;
return (
- `find ${quoted} -mindepth 1 \\( ${hidden} \\) -prune -o ` +
- `\\( ${shallow} \\) -prune -exec printf 'd\\t%s\\n' {} \\; -o ` +
- `-type d -exec printf 'd\\t%s\\n' {} \\; -o -type f -exec printf 'f\\t%s\\n' {} \\; | head -n ${WORKSPACE_LIST_MAX_ENTRIES}`
+ `{ if find / -maxdepth 0 -printf '' >/dev/null 2>&1; then ${gnu}; else ${posix}; fi; } ` +
+ `| head -n ${WORKSPACE_LIST_MAX_ENTRIES}`
);
}
@@ -230,6 +240,7 @@ async function walk(
} catch {
return;
}
+ const files: Array<{ name: string; abs: string; rel: string }> = [];
for (const ent of entries) {
if (out.length >= WORKSPACE_LIST_MAX_ENTRIES) return;
if (WORKSPACE_SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
@@ -242,14 +253,27 @@ async function walk(
visit(childAbs, childRel, depth + 1);
continue;
}
- if (!ent.isFile()) continue;
- let size: number | undefined;
- try {
- size = (await lstat(childAbs)).size;
- } catch {
- continue;
+ if (ent.isFile()) files.push({ name: ent.name, abs: childAbs, rel: childRel });
+ }
+ // Stat a directory's files together instead of one await per file.
+ for (let i = 0; i < files.length; i += LIST_STAT_BATCH) {
+ if (out.length >= WORKSPACE_LIST_MAX_ENTRIES) return;
+ const batch = files.slice(i, i + LIST_STAT_BATCH);
+ const sizes = await Promise.all(
+ batch.map((file) =>
+ lstat(file.abs).then(
+ (info) => info.size,
+ () => undefined,
+ ),
+ ),
+ );
+ for (let j = 0; j < batch.length; j += 1) {
+ if (out.length >= WORKSPACE_LIST_MAX_ENTRIES) return;
+ const size = sizes[j];
+ if (size === undefined) continue;
+ const file = batch[j]!;
+ out.push({ name: file.name, path: file.rel, type: "file", size });
}
- out.push({ name: ent.name, path: childRel, type: "file", size });
}
};
diff --git a/apps/server/src/workspace/workspaceGit.ts b/apps/server/src/workspace/workspaceGit.ts
index accd734..40e871f 100644
--- a/apps/server/src/workspace/workspaceGit.ts
+++ b/apps/server/src/workspace/workspaceGit.ts
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { sanitizeWorkspaceRel } from "./workspaceFiles.js";
+import { PORCELAIN_V2_ARGS, parsePorcelainV2, type PorcelainStatus } from "./gitPorcelain.js";
const execFileAsync = promisify(execFile);
@@ -131,76 +132,37 @@ export function gitCheckoutReady(root: string): boolean {
export async function workspaceGitStatus(root: string): Promise {
const empty = (): WorkspaceGitStatus => ({ branch: "", ahead: 0, behind: 0, files: [] });
+ let status: PorcelainStatus;
try {
- const inside = await git(root, ["rev-parse", "--is-inside-work-tree"]);
- if (inside.trim() !== "true") return empty();
+ // One process: branch, upstream, ahead/behind and every path (was four git calls).
+ status = parsePorcelainV2(await git(root, [...PORCELAIN_V2_ARGS, "-uall"]));
} catch {
return empty();
}
- try {
- const porcelain = await git(root, ["status", "--porcelain=v1", "-b", "-uall"]);
- let branch = "";
- let ahead = 0;
- let behind = 0;
- const files: WorkspaceGitFile[] = [];
- for (const line of porcelain.split("\n")) {
- if (!line) continue;
- if (line.startsWith("## ")) {
- const head = line.slice(3);
- const aheadMatch = head.match(/ahead (\d+)/);
- const behindMatch = head.match(/behind (\d+)/);
- ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
- behind = behindMatch ? Number(behindMatch[1]) : 0;
- branch = head.split("...")[0]?.trim() || "";
- const noCommits = branch.match(/^No commits yet on (.+)$/);
- if (noCommits?.[1]) branch = noCommits[1];
- if (branch === "HEAD (no branch)") branch = "detached";
- continue;
- }
- const index = line[0] ?? " ";
- const worktree = line[1] ?? " ";
- let path = line.slice(3).replace(/\\/g, "/");
- const renamed = path.match(/^.+ -> (.+)$/);
- if (renamed?.[1]) path = renamed[1];
- path = path.replace(/^"+|"+$/g, "");
- const clean = sanitizeWorkspaceRel(path);
- if (!clean) continue;
- files.push({
- path: clean,
- index,
- worktree,
- staged: index !== " " && index !== "?" && index !== "U",
- ...(isUnmerged(index, worktree) ? { conflicted: true } : {}),
- });
- }
- if (!branch) {
- try {
- branch = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
- } catch {
- branch = "";
- }
- }
- let upstream: string | undefined;
- try {
- const tracked = (await git(root, ["rev-parse", "--abbrev-ref", "@{upstream}"])).trim();
- if (tracked) upstream = tracked;
- } catch {
- /* no upstream */
- }
- const rebasing = rebaseInProgress(root);
- const merging = mergeInProgress(root);
- return {
- branch,
- ahead,
- behind,
- files,
- ...(upstream ? { upstream } : {}),
- ...(rebasing ? { rebasing } : {}),
- ...(merging ? { merging } : {}),
- };
- } catch {
- return empty();
+ const files: WorkspaceGitFile[] = [];
+ for (const entry of status.entries) {
+ const clean = sanitizeWorkspaceRel(entry.path);
+ if (!clean) continue;
+ const { index, worktree } = entry;
+ files.push({
+ path: clean,
+ index,
+ worktree,
+ staged: index !== " " && index !== "?" && index !== "U",
+ ...(isUnmerged(index, worktree) ? { conflicted: true } : {}),
+ });
}
+ const rebasing = rebaseInProgress(root);
+ const merging = mergeInProgress(root);
+ return {
+ branch: status.branch,
+ ahead: status.ahead,
+ behind: status.behind,
+ files,
+ ...(status.upstream ? { upstream: status.upstream } : {}),
+ ...(rebasing ? { rebasing } : {}),
+ ...(merging ? { merging } : {}),
+ };
}
export async function workspaceGitStage(
diff --git a/apps/server/src/workspace/workspaceGrep.ts b/apps/server/src/workspace/workspaceGrep.ts
index acce54e..fd78f6e 100644
--- a/apps/server/src/workspace/workspaceGrep.ts
+++ b/apps/server/src/workspace/workspaceGrep.ts
@@ -1,10 +1,12 @@
import { spawn } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { writeFileSync } from "node:fs";
+import { existsSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
import { lstat, readdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { basename, extname, join, relative, resolve, sep } from "node:path";
+import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import {
INDEX_DIR_ALLOWLIST,
isRepoMapExtraSkipDir,
@@ -22,6 +24,9 @@ const MAX_REGEX_CHARS = 200;
const GREP_LINE_CHARS = 240;
const GREP_TIMEOUT_MS = 8_000;
const FILE_CONCURRENCY = 32;
+/** Files scanned between event-loop yields in the no-ripgrep fallback. */
+const WALK_BATCH = 64;
+const yieldLoop = () => new Promise((done) => setImmediate(done));
/** Keep in lockstep with `WORKSPACE_FILE_MAX_BYTES` in workspaceFiles.ts. */
const MAX_FILE_BYTES = 1_500_000;
@@ -252,17 +257,50 @@ function compileMatcher(
let cachedRg: string | false | undefined;
+/** File name of the ripgrep binary on this platform. */
+export const RIPGREP_BINARY = process.platform === "win32" ? "rg.exe" : "rg";
+
+/**
+ * Where to look for ripgrep: an explicit override, then `rg` on PATH (distro builds measured
+ * ~1.5x faster than the static `@vscode/ripgrep` binary), then the binary the desktop build
+ * copies beside the bundled server (`bin/rg`), then the `@vscode/ripgrep` package (dev installs).
+ */
+export function ripgrepCandidates(): string[] {
+ const out: string[] = [];
+ const override = process.env.LOCUS_RG_PATH?.trim();
+ if (override) out.push(override);
+ out.push("rg");
+ try {
+ out.push(join(dirname(fileURLToPath(import.meta.url)), "bin", RIPGREP_BINARY));
+ } catch {
+ /* not a file: URL */
+ }
+ try {
+ const req = createRequire(import.meta.url);
+ out.push(
+ req.resolve(`@vscode/ripgrep-${process.platform}-${process.arch}/bin/${RIPGREP_BINARY}`),
+ );
+ } catch {
+ /* optional dependency not installed for this platform */
+ }
+ return out;
+}
+
export async function resolveRipgrep(): Promise {
if (cachedRg === false) return undefined;
if (cachedRg) return cachedRg;
- try {
- await execFileAsync("rg", ["--version"], { timeout: 2000, windowsHide: true });
- cachedRg = "rg";
- return cachedRg;
- } catch {
- cachedRg = false;
- return undefined;
+ for (const bin of ripgrepCandidates()) {
+ if (bin !== "rg" && !existsSync(bin)) continue;
+ try {
+ await execFileAsync(bin, ["--version"], { timeout: 2000, windowsHide: true });
+ cachedRg = bin;
+ return cachedRg;
+ } catch {
+ /* try the next candidate */
+ }
}
+ cachedRg = false;
+ return undefined;
}
/** Reset the rg lookup cache (tests). */
@@ -474,6 +512,14 @@ async function grepWithRipgrep(
};
}
+async function loadIgnoreFile(root: string, name: string): Promise {
+ try {
+ return parseIgnore(await readFile(join(root, name), "utf8"));
+ } catch {
+ return [];
+ }
+}
+
async function loadIgnore(root: string): Promise {
const rules: IgnoreRule[] = [];
for (const name of [".gitignore", ".locusignore"]) {
@@ -486,10 +532,7 @@ async function loadIgnore(root: string): Promise {
return rules;
}
-async function gitCandidateFiles(
- root: string,
- ignore: IgnoreRule[],
-): Promise {
+async function gitCandidateFiles(root: string): Promise {
try {
const { stdout: topRaw } = await execFileAsync(
"git",
@@ -510,15 +553,22 @@ async function gitCandidateFiles(
);
return stdout.split("\0").filter(Boolean);
};
+ // git applies .gitignore itself (matching it in JS cost ~7 s on a 20k-file repo);
+ // only .locusignore rules are evaluated here.
+ const [cached, trackedIgnored, others, locus] = await Promise.all([
+ list(["--cached"]),
+ list(["--cached", "--ignored", "--exclude-standard"]),
+ list(["--others", "--exclude-standard"]),
+ loadIgnoreFile(root, ".locusignore"),
+ ]);
+ const skip = new Set(trackedIgnored);
const files: string[] = [];
- for (const rel of [
- ...(await list(["--cached"])),
- ...(await list(["--others", "--exclude-standard"])),
- ]) {
+ for (const rel of [...cached, ...others]) {
+ if (skip.has(rel)) continue;
const path = rel.replace(/\\/g, "/").replace(/^\/+/, "");
if (!path || path.split("/").some((part) => part === "." || part === "..")) continue;
- if (repoMapPathSkipped(path) || pathIgnored(path, false, ignore) || isGeneratedGrepPath(path))
- continue;
+ if (repoMapPathSkipped(path) || isGeneratedGrepPath(path)) continue;
+ if (locus.length && pathIgnored(path, false, locus)) continue;
files.push(path);
if (files.length >= GREP_MAX_FILES) break;
}
@@ -619,8 +669,7 @@ async function grepByWalk(
if (compiled.error) return { items: [], error: compiled.error };
const ignore = await loadIgnore(root);
const replacing = opts.replace != null;
- const listed =
- (await gitCandidateFiles(root, ignore)) ?? (await walkCandidateFiles(root, ignore));
+ const listed = (await gitCandidateFiles(root)) ?? (await walkCandidateFiles(root, ignore));
const files = listed.filter((path) => pathMatchesGrepGlobs(path, opts.glob, opts.exclude));
const source: string[] = [];
const rest: string[] = [];
@@ -673,12 +722,26 @@ async function grepByWalk(
};
if (replacing) {
- for (const rel of ordered) take(await scanOne(rel));
+ for (let i = 0; i < ordered.length; i += 1) {
+ if (i % WALK_BATCH === 0) await yieldLoop();
+ take(await scanOne(ordered[i]!));
+ }
} else {
- const rows = await Promise.all(ordered.map((rel) => limitFile(() => scanOne(rel))));
- for (const row of rows) {
- take(row);
+ // Ordered batches: stop as soon as the cap is met and give the event loop a turn between
+ // batches, so a repo without ripgrep cannot freeze the control plane for seconds.
+ for (let i = 0; i < ordered.length; i += WALK_BATCH) {
+ const batch = ordered.slice(i, i + WALK_BATCH);
+ const rows = await Promise.all(batch.map((rel) => limitFile(() => scanOne(rel))));
+ for (const row of rows) {
+ take(row);
+ if (truncated) break;
+ }
if (truncated) break;
+ if (items.length >= cap) {
+ truncated = i + WALK_BATCH < ordered.length;
+ break;
+ }
+ await yieldLoop();
}
}
diff --git a/docs/cli.md b/docs/cli.md
index 5ac1239..ad8e4e6 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -102,6 +102,9 @@ Exit codes: `0` ok, `1` usage or API error, `2` a tool call is waiting for Appro
| Egress / approvals audit | `locus debug egress ` / `locus debug approvals [id]` |
| Managed policy | `locus config policy` |
| Revoke integration tokens | `locus config revoke-all --confirm REVOKE` |
+| Back up / restore `~/.locus` | `locus backup [file]` / `locus restore --yes` |
+| Check for updates | `locus update [download]` |
+| Settings → Benchmark | `locus eval run --model provider/model` / `list` / `show ` |
Shortcuts: `locus ls`, `locus new`, `locus chat …`, `locus session …`, `locus codebase …`, `locus auto …`.
@@ -343,6 +346,41 @@ npx locus workflow delete
`templates` lists Locus-native graphs (`locus-file`, `locus-agent`) plus ALB patterns (Research Agent, ReAct, Plan-and-Execute, Human-in-the-Loop, Magentic-One, Tree of Thoughts, and the rest). `add` materializes one of those templates. `import` stores a `.alb.json` bundle. `run` needs a live coding session; a human gate pauses until `resume --approve` or `--deny`.
+## Backup, restore and updates
+
+```bash
+export LOCUS_BACKUP_PASSPHRASE='a long passphrase'
+npx locus backup ~/locus-2026-11.locusbak # VM disks left out
+npx locus backup ~/locus-full.locusbak --include-disks
+npx locus restore ~/locus-2026-11.locusbak --inspect # check the passphrase, show the manifest
+npx locus restore ~/locus-2026-11.locusbak --yes # stage it; restart Locus to apply
+npx locus restore status
+npx locus restore cancel
+npx locus update
+npx locus update download
+```
+
+A backup is one AES-256-GCM file (scrypt-derived key) holding sessions, settings, boards, projects, Jobs, Brain, workflows, agent workspaces and the decrypted vault. Logs, caches, `node_modules`, `.venv`, the Host Chrome profile and the machine-bound `master.key` / `master.key.safe` / `secrets.enc` are never included. VM disks, images and media need `--include-disks`.
+
+The passphrase comes from `LOCUS_BACKUP_PASSPHRASE` (or `--passphrase-env NAME`), or a hidden prompt on a terminal. It is never a plain flag. Without it the backup cannot be opened; Locus does not keep a copy.
+
+Restore never touches live state. The server decrypts into `~/.locus/.restore-staging`, re-encrypts the vault with this machine's master key, rewrites old data-dir paths in JSON state, and writes `restore-pending.json`. The next control-plane start swaps the staged tree in before anything loads, and moves the files it replaced to `~/.locus/.before-restore-/`. Delete that folder once you are happy. Host agents that point at folders outside `~/.locus` keep their old paths. Backup and restore routes answer only on this machine, not to a paired phone.
+
+`locus update` asks the control plane, which caches GitHub Releases for an hour. `download` fetches the installer for this platform and verifies it against `SHA256SUMS-` into `~/.locus/updates`. The desktop checks at launch and every six hours (Settings → About → Check automatically). Install swaps a running Linux AppImage in place and relaunches, runs the Windows NSIS installer, and opens the dmg, deb or rpm elsewhere.
+
+## Model benchmark
+
+```bash
+npx locus eval cases
+npx locus eval run --model openai/gpt-5 --model ollama/qwen3-coder --wait
+npx locus eval run --model anthropic/claude-sonnet --case fix-add,off-by-one --repeat 3
+npx locus eval list
+npx locus eval show
+npx locus eval cancel
+```
+
+Each case is a small JavaScript project with failing tests (`fix-add`, `off-by-one`, `implement-slugify`, `missing-await`, `cross-file-feature`). Locus copies it to a temp folder, starts an autonomous Host agent there with the chosen provider and model, sends the task, then runs `npm test` itself. A case passes only when the tests pass and the test files are byte-identical. Results record tokens, estimated cost (provider rates), tool calls and wall time. One run at a time; cases run in order. The chats are archived so a failure can be opened. Results live in `~/.locus/benchmarks/.json`. Settings → Benchmark is the same runner.
+
## Events and debug
```bash
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index e8b2153..ada38f3 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -40,7 +40,7 @@ The composer queue holds 32 prompts (configurable in Settings → Advanced). Sen
## Image tag `locus/agent:0.1`
-The runtime tag stays `locus/agent:0.1` so existing guests keep working. `npm run images` also tags `locus/agent:0.20.0`. Rebuild after Dockerfile changes.
+The runtime tag stays `locus/agent:0.1` so existing guests keep working. `npm run images` also tags `locus/agent:0.21.0`. Rebuild after Dockerfile changes.
## Worker VNC is blank
diff --git a/docs/ux-review-2026-11-oss-repo.md b/docs/ux-review-2026-11-oss-repo.md
new file mode 100644
index 0000000..e262a57
--- /dev/null
+++ b/docs/ux-review-2026-11-oss-repo.md
@@ -0,0 +1,136 @@
+# UI/UX pass: a fresh setup with an open-source repo linked (November 2026)
+
+How this was checked: a clean `LOCUS_HOME`, `main` at `b94bb7f`, the server on the mock
+hypervisor, and Vite. Docker was installed but the daemon was not running. A fake
+OpenAI-compatible server listened on Ollama's port (`127.0.0.1:11434`). The app was driven with
+Playwright the way a new contributor would use it: the setup wizard (Ollama, Developer), then
+Codebase → Paste a URL → `https://github.com/Modsofthenation/Locus` with no token. Then
+every rail and More section, every Codebase tab, a merged PR (#923) and every Settings tab were
+checked at 1440px and at 390px. The same overlap, clipping and off-screen script as the earlier
+pass ran on each screen. It came back almost clean, so the items below come from reading the
+screenshots and following the flows.
+
+This pass is different from `ux-review-2026-11.md` in one way: a repo is linked. Most of the
+findings are places where the app knows about that repo and doesn't use it.
+
+Severity: **P1** broken or misleading, **P2** clearly wrong but has a workaround, **P3** polish.
+
+**Status:** every item below is addressed in the follow-up change, with these notes:
+
+- 24: the repo picker now opens upward when there is no room below. Over the chips with room
+ below it still opens downward; that is normal popover behaviour.
+- 25: the Codebase and PR tab strips are now `role="tab"` with `aria-selected`. The
+ `.git-account-remove` "no name" flag was a false positive: the button has text, but it sits
+ inside a collapsed ``.
+- 18: the About card (description, license, language, stars, topics, README excerpt, good first
+ issues) is for GitHub; Gitea and Forgejo get the metadata without README or issue count.
+
+## P1
+
+1. **Codebase → Start agent ignores `dockerReady`.** With the daemon down, it starts a Docker
+ agent straight away. The raw CLI error (`failed to connect to the docker API at
+ unix:///var/run/docker.sock…`) then shows twice, once in the repo header and once in the
+ PR pane. The button changes to **Continue**, which points at a dead agent. The Agents and
+ Design launchers already check `state.dockerReady` (`EmptyState.tsx`, `DesignEmpty.tsx`), but
+ `components/codebase/` never does. Fix: offer Host, or show the same "Docker isn't running"
+ callout before creating anything.
+2. **A failed Docker agent says "switch to Host or start Docker", but you can't switch.** The
+ runtime is fixed per agent. The header's primary action is **Start**, which fails the same
+ way. Fix: offer **Duplicate as Host agent** (same repo, same prompt) and **Open Docker
+ setup**, and make Start secondary until Docker is ready.
+3. **The server crashes on first run without `ssh-keygen`.** The message is `Locus control plane
+ crashed: ssh-keygen is required…`, and the UI only shows a dead connection. A minimal Linux or
+ container setup (the usual first try for an open-source contributor) hits this before
+ seeing anything. Fix: create the key lazily when the first VM or SSH guest needs it, or warn
+ and continue. Add `openssh-client` to the CONTRIBUTING prerequisites.
+
+## P2: the linked repo isn't used
+
+4. **The New agent and Design composers default to "No repo" / "No repo context"** even when
+ exactly one repo is linked. The Agents empty state's first-task chips then say "Explain
+ Modsofthenation/Locus" next to a picker that says No repo. Fix: preselect the repo
+ when only one is linked, or when the chip names it.
+5. **The "Connect GitHub · Add a GitHub token" banner stays at the top of Agents** after a
+ public repo is linked and working. It's the first thing above the task box. Fix: hide it
+ once a repo is linked, or move it inline in the repo picker ("Private repos need a token").
+6. **Git account state contradicts itself.** Linking with no token creates a GitHub row in
+ Settings → Git ("Not signed in · Used by 1 linked repo"). Settings → Integrations says "No
+ Git accounts connected yet", and Repo settings → Account shows a bare "GitHub" pill. Fix: call
+ it what it is, **Anonymous (public, 60 requests/hour)**, in all three places, with "Add token
+ to raise the limit".
+7. **The no-token rate limit is a dead end on first link.** The first link got "GitHub rate
+ limit reached for reads without a token" (the shared IP had used up the quota). Open PRs and
+ Open issues show "Rate limited" with no reset time. Fix: show the reset time from
+ `x-ratelimit-reset`, keep the repo usable, and retry when the limit resets.
+8. **Brain, IDE and Jobs don't offer the linked repo.** Brain's empty state asks for a
+ `/path/to/repo` with Browse. IDE has a generic Clone repo card. Jobs' "Review on git push"
+ template doesn't prefill the repo. Fix: show linked repos as one-click options ("Index
+ Locus", "Open Locus", "Review pushes to Locus").
+9. **Empty PR and Issues tabs are a list column plus "Select a pull request."** With 0 open PRs
+ but many merged ones, the tab shows "No pull requests." and an empty detail pane. The
+ history is behind a small "Open" filter. Fix: an empty state that says "No open PRs · View
+ merged (N)" or switches to Recent. The same applies to Issues.
+10. **Merged PRs still lead with Review with agent (primary) and Fix with agent.** Neither
+ makes sense after merge. Fix: hide or demote them on merged and closed PRs. Lead with "Open
+ on GitHub" or "View change stack".
+
+## P2: other
+
+11. **The wizard picks a model that might not exist.** Ollama saves as `llama3.2` without
+ asking the server. The fake server only had `fake-coder`, so every composer showed
+ "Ollama · llama3.2". Fix: fetch `/v1/models` (or `/api/tags`) when saving and pick a model
+ that is installed. If none are installed, say "No models pulled — run `ollama pull …`".
+12. **The wizard calls everything a key.** For Ollama and CLI providers the button still says
+ **Save key**, and the subtitle still says "Add a provider key" after setup is done. After
+ saving it says "An enabled provider can answer chat" without naming the provider or
+ testing it. Fix: say "Use Ollama" for keyless providers. After saving, show "Ollama ·
+ fake-coder · responded in 120 ms" from a real test call.
+13. **The wizard's provider list is flat (18 items)** with no grouping or "needs a key"
+ marker. Fix: group into Free/Local, Cloud (key) and CLI. The modal also jumps upward when
+ the provider card collapses.
+14. **Docker's empty state says "No containers on this Docker engine. Start an agent…"** with a
+ primary **New agent** while the daemon is down. Only the footer says "daemon down". Fix: if
+ `dockerReady` is false, the empty state should say Docker isn't running and offer Host or
+ the setup steps.
+15. **The Architecture tab before analysis is three columns of the same empty template**
+ (Not analysed yet / How it is shaped / Findings / Improvements, each "Nothing on this
+ pass"). The real action, **Analyse**, is a small ghost button. Fix: one empty state with a
+ primary **Analyse** that names the checkout it will read, or "Needs a local checkout →
+ Open in IDE".
+
+## P3
+
+16. **Codebase sidebar row:** "Modsofthenation/Local-a…" is cut off under the name that
+ already says Locus, and a bare **0** badge has no label. Show only the owner, and
+ hide the badge at 0 or label it "0 PRs".
+17. **Repo header subtitle "GitHub · 0 open"** is ambiguous (PRs? issues?), and the GitHub ↗
+ chip repeats it. Use "GitHub · 0 open PRs · main".
+18. **The Overview for an open-source repo is mostly empty boxes.** The Review inbox card
+ takes half the page to say "No open pull requests". The Brain card is empty. Show the
+ README excerpt, description, license, language, stars and "good first issue" count, and
+ collapse empty cards.
+19. **PR list rows cut the branch chips** to `cla… → m…`, which tells you nothing. Drop
+ `head → base` from the row (it's in the detail view) or show it only on hover.
+20. **The "Pull requests 0" tab count stays 0** while the list shows 10 merged PRs under All.
+ Label it as open-only ("0 open") or update it with the filter.
+21. **Repo Settings puts Unlink right next to Save.** Move Unlink to a separate danger zone,
+ as other settings pages do.
+22. **Grafana still has two "Add Grafana" buttons** (sidebar primary plus hero secondary).
+ Board and Projects put a "New board" / "New project" button inside the composer as well as
+ in the sidebar.
+23. **Slack's sidebar footer says "0 conversations"** while you're signed out.
+24. **The repo picker popover covers the first-task chips.** It opens downward over "Try a
+ first task". Open it upward, or close the chips' row.
+25. **The Codebase tab strip uses plain buttons** (no `role="tab"` / `aria-selected`), so
+ screen readers can't tell which tab is selected. Settings → Git also has an icon-only remove
+ button with no accessible name (`.git-account-remove`).
+26. **At 390px, a failed agent's header** cuts the status to "Docker isn't running — swi…"
+ and the Codebase tabs wrap to two rows. Put the error in the thread only, and make the tab
+ strip scroll sideways.
+
+## Suggested order
+
+- First PR: items 1, 2, 3 and 14 (Docker down, first run). They are the ones that break.
+- Second PR: items 4–10 ("use the linked repo"). This is the biggest change for an
+ open-source contributor's first hour.
+- Third PR: items 11–13 (wizard). Then the P3s.
diff --git a/images/agent/README.md b/images/agent/README.md
index 68178c1..15e8d9e 100644
--- a/images/agent/README.md
+++ b/images/agent/README.md
@@ -3,7 +3,7 @@
First-party Docker image for Docker-runtime agents: git, Node, Chromium, ripgrep, code-server.
```bash
-npm run images # docker build -t locus/agent:0.1 -t locus/agent:0.20.0 images/agent
+npm run images # docker build -t locus/agent:0.1 -t locus/agent:0.21.0 images/agent
```
`git-credential-locus` is the credential helper injected into the guest. Rebuild after Dockerfile changes; the tag in the root `package.json` `images` script must match what New agent expects.
diff --git a/package-lock.json b/package-lock.json
index 26237e9..d11d838 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "locus",
- "version": "0.20.0",
+ "version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "locus",
- "version": "0.20.0",
+ "version": "0.21.0",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -15,11 +15,11 @@
"devDependencies": {
"@eslint/js": "10.0.1",
"concurrently": "^10.0.6",
- "eslint": "10.12.0",
+ "eslint": "10.14.0",
"eslint-plugin-react-hooks": "7.1.1",
- "globals": "17.13.0",
- "prettier": "3.9.9",
- "typescript-eslint": "8.71.2",
+ "globals": "17.14.0",
+ "prettier": "3.9.10",
+ "typescript-eslint": "8.72.2",
"wait-on": "^9.5.1"
},
"engines": {
@@ -28,7 +28,7 @@
},
"apps/cli": {
"name": "@locus/cli",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@locus/shared": "*",
"tsx": "^4.23.15",
@@ -41,7 +41,7 @@
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"apps/cli/node_modules/typescript": {
@@ -81,7 +81,7 @@
},
"apps/desktop": {
"name": "@locus/desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"license": "MIT",
"dependencies": {
"@locus/shared": "*",
@@ -105,7 +105,7 @@
"jsdom": "^30.1.3",
"typescript": "^7.0.2",
"vite": "^8.3.5",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"apps/desktop/node_modules/@types/react-dom": {
@@ -185,7 +185,7 @@
},
"apps/server": {
"name": "@locus/server",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@hono/node-server": "^2.1.5",
"@locus/alb": "*",
@@ -195,6 +195,7 @@
"@locus/providers": "*",
"@locus/shared": "*",
"@locus/worker": "*",
+ "@vscode/ripgrep": "^1.18.0",
"hono": "^4.13.13",
"typescript-ls": "npm:typescript@^5.9.3",
"vscode-css-languageservice": "^6.3.10",
@@ -208,7 +209,7 @@
"@types/ws": "^8.18.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"apps/server/node_modules/typescript": {
@@ -2049,7 +2050,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2067,7 +2067,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2085,7 +2084,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2103,7 +2101,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2121,7 +2118,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2139,7 +2135,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2157,7 +2152,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2175,7 +2169,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2193,7 +2186,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2211,7 +2203,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2229,7 +2220,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2247,7 +2237,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2265,7 +2254,6 @@
"os": [
"openharmony"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2283,7 +2271,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2301,7 +2288,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^20.20.0 || >=22.12.0"
}
@@ -2497,17 +2483,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
- "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz",
+ "integrity": "sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.71.2",
- "@typescript-eslint/type-utils": "8.71.2",
- "@typescript-eslint/utils": "8.71.2",
- "@typescript-eslint/visitor-keys": "8.71.2",
+ "@typescript-eslint/scope-manager": "8.72.2",
+ "@typescript-eslint/type-utils": "8.72.2",
+ "@typescript-eslint/utils": "8.72.2",
+ "@typescript-eslint/visitor-keys": "8.72.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -2520,15 +2506,15 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.71.2",
+ "@typescript-eslint/parser": "^8.72.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
- "version": "7.0.12",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
- "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
+ "version": "7.0.14",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
+ "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2536,16 +2522,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
- "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz",
+ "integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.71.2",
- "@typescript-eslint/types": "8.71.2",
- "@typescript-eslint/typescript-estree": "8.71.2",
- "@typescript-eslint/visitor-keys": "8.71.2",
+ "@typescript-eslint/scope-manager": "8.72.2",
+ "@typescript-eslint/types": "8.72.2",
+ "@typescript-eslint/typescript-estree": "8.72.2",
+ "@typescript-eslint/visitor-keys": "8.72.2",
"debug": "^4.4.3"
},
"engines": {
@@ -2561,14 +2547,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
- "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz",
+ "integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.71.2",
- "@typescript-eslint/types": "^8.71.2",
+ "@typescript-eslint/tsconfig-utils": "^8.72.2",
+ "@typescript-eslint/types": "^8.72.2",
"debug": "^4.4.3"
},
"engines": {
@@ -2583,14 +2569,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
- "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz",
+ "integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.2",
- "@typescript-eslint/visitor-keys": "8.71.2"
+ "@typescript-eslint/types": "8.72.2",
+ "@typescript-eslint/visitor-keys": "8.72.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2601,9 +2587,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
- "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz",
+ "integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2618,15 +2604,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
- "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz",
+ "integrity": "sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.2",
- "@typescript-eslint/typescript-estree": "8.71.2",
- "@typescript-eslint/utils": "8.71.2",
+ "@typescript-eslint/types": "8.72.2",
+ "@typescript-eslint/typescript-estree": "8.72.2",
+ "@typescript-eslint/utils": "8.72.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -2643,9 +2629,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
- "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz",
+ "integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2657,16 +2643,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
- "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz",
+ "integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.71.2",
- "@typescript-eslint/tsconfig-utils": "8.71.2",
- "@typescript-eslint/types": "8.71.2",
- "@typescript-eslint/visitor-keys": "8.71.2",
+ "@typescript-eslint/project-service": "8.72.2",
+ "@typescript-eslint/tsconfig-utils": "8.72.2",
+ "@typescript-eslint/types": "8.72.2",
+ "@typescript-eslint/visitor-keys": "8.72.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2685,9 +2671,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "version": "7.8.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2698,16 +2684,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
- "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.2.tgz",
+ "integrity": "sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.71.2",
- "@typescript-eslint/types": "8.71.2",
- "@typescript-eslint/typescript-estree": "8.71.2"
+ "@typescript-eslint/scope-manager": "8.72.2",
+ "@typescript-eslint/types": "8.72.2",
+ "@typescript-eslint/typescript-estree": "8.72.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2722,13 +2708,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
- "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz",
+ "integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/types": "8.72.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3080,14 +3066,14 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
- "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz",
+ "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
- "@vitest/spy": "5.0.5",
+ "@vitest/spy": "5.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.4.3"
},
@@ -3108,9 +3094,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
- "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz",
+ "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3123,6 +3109,182 @@
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
+ "node_modules/@vscode/ripgrep": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz",
+ "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "@vscode/ripgrep-darwin-arm64": "1.18.0",
+ "@vscode/ripgrep-darwin-x64": "1.18.0",
+ "@vscode/ripgrep-linux-arm": "1.18.0",
+ "@vscode/ripgrep-linux-arm64": "1.18.0",
+ "@vscode/ripgrep-linux-ia32": "1.18.0",
+ "@vscode/ripgrep-linux-ppc64": "1.18.0",
+ "@vscode/ripgrep-linux-riscv64": "1.18.0",
+ "@vscode/ripgrep-linux-s390x": "1.18.0",
+ "@vscode/ripgrep-linux-x64": "1.18.0",
+ "@vscode/ripgrep-win32-arm64": "1.18.0",
+ "@vscode/ripgrep-win32-ia32": "1.18.0",
+ "@vscode/ripgrep-win32-x64": "1.18.0"
+ }
+ },
+ "node_modules/@vscode/ripgrep-darwin-arm64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz",
+ "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-darwin-x64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz",
+ "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-arm": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz",
+ "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-arm64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz",
+ "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-ia32": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz",
+ "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-ppc64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz",
+ "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-riscv64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz",
+ "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-s390x": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz",
+ "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-linux-x64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz",
+ "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-win32-arm64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz",
+ "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-win32-ia32": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz",
+ "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@vscode/ripgrep-win32-x64": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz",
+ "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
@@ -4868,9 +5030,9 @@
}
},
"node_modules/eslint": {
- "version": "10.12.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
- "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
+ "version": "10.14.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+ "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -4883,7 +5045,7 @@
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
- "@humanfs/node": "^0.16.6",
+ "@humanfs/node": "^0.16.8",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
@@ -4904,7 +5066,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.5",
+ "minimatch": "^10.2.6",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -4912,7 +5074,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^20.20.0 || ^22.13.0 || >=24"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
@@ -5446,9 +5608,9 @@
}
},
"node_modules/globals": {
- "version": "17.13.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
- "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
+ "version": "17.14.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
+ "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6123,7 +6285,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6145,7 +6306,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6167,7 +6327,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6189,7 +6348,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6211,7 +6369,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6233,7 +6390,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6255,7 +6411,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6277,7 +6432,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6299,7 +6453,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6321,7 +6474,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -6343,7 +6495,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -7079,9 +7230,9 @@
}
},
"node_modules/prettier": {
- "version": "3.9.9",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
- "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
+ "version": "3.9.10",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
+ "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
"dev": true,
"license": "MIT",
"bin": {
@@ -7980,16 +8131,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.71.2",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
- "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
+ "version": "8.72.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.2.tgz",
+ "integrity": "sha512-c/+AKMW1KB81r28Sp0AAdcyXj5dzNmAsZFcsrxKbZTKmcBK2xIwBR31dcSYSDu3bKmzXe8GKrSxO+eDQ401Uzg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.71.2",
- "@typescript-eslint/parser": "8.71.2",
- "@typescript-eslint/typescript-estree": "8.71.2",
- "@typescript-eslint/utils": "8.71.2"
+ "@typescript-eslint/eslint-plugin": "8.72.2",
+ "@typescript-eslint/parser": "8.72.2",
+ "@typescript-eslint/typescript-estree": "8.72.2",
+ "@typescript-eslint/utils": "8.72.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8214,14 +8365,14 @@
}
},
"node_modules/vitest": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
- "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz",
+ "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
- "@vitest/mocker": "5.0.5",
+ "@vitest/mocker": "5.1.0",
"chai": "^6.3.0",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
@@ -8247,12 +8398,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "5.0.5",
- "@vitest/browser-preview": "5.0.5",
+ "@vitest/browser-playwright": "5.1.0",
+ "@vitest/browser-preview": "5.1.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
- "@vitest/coverage-istanbul": "5.0.5",
- "@vitest/coverage-v8": "5.0.5",
- "@vitest/ui": "5.0.5",
+ "@vitest/coverage-istanbul": "5.1.0",
+ "@vitest/coverage-v8": "5.1.0",
+ "@vitest/ui": "5.1.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
@@ -8626,7 +8777,7 @@
},
"packages/alb": {
"name": "@locus/alb",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@dagrejs/dagre": "^3.1.1",
"nanoid": "^6.0.2",
@@ -8638,7 +8789,7 @@
"@types/node": "^26.6.5",
"@types/semver": "^7.8.0",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/alb/node_modules/nanoid": {
@@ -8708,14 +8859,14 @@
},
"packages/compute": {
"name": "@locus/compute",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@locus/shared": "*"
},
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/compute/node_modules/typescript": {
@@ -8755,7 +8906,7 @@
},
"packages/guest-agent": {
"name": "@locus/guest-agent",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@locus/shared": "*",
"ws": "^8.22.0"
@@ -8764,7 +8915,7 @@
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/guest-agent/node_modules/typescript": {
@@ -8804,14 +8955,14 @@
},
"packages/hypervisor": {
"name": "@locus/hypervisor",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@locus/shared": "*"
},
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/hypervisor/node_modules/typescript": {
@@ -8851,14 +9002,14 @@
},
"packages/providers": {
"name": "@locus/providers",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@locus/shared": "*"
},
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/providers/node_modules/typescript": {
@@ -8898,11 +9049,11 @@
},
"packages/shared": {
"name": "@locus/shared",
- "version": "0.20.0",
+ "version": "0.21.0",
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/shared/node_modules/typescript": {
@@ -8942,7 +9093,7 @@
},
"packages/worker": {
"name": "@locus/worker",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@hono/node-server": "^2.1.5",
"@locus/guest-agent": "*",
@@ -8956,7 +9107,7 @@
"@types/ws": "^8.18.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
},
"packages/worker/node_modules/typescript": {
diff --git a/package.json b/package.json
index 7005996..8e6ec3b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "locus",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Local AI coding agents that run in Ubuntu, Debian, Omarchy, and Windows VMs or Linux Docker containers on your machine",
"repository": {
@@ -38,7 +38,7 @@
"test:visual": "npm run test:visual --workspace=@locus/desktop",
"capture:readme": "npm run capture:readme --workspace=@locus/desktop",
"typecheck": "npm run typecheck --workspaces --if-present",
- "images": "docker build -t locus/agent:0.1 -t locus/agent:0.20.0 images/agent",
+ "images": "docker build -t locus/agent:0.1 -t locus/agent:0.21.0 images/agent",
"format:check": "prettier --check \"**/*.{ts,tsx,mjs,cjs,json,css}\"",
"format:fix": "prettier --write \"**/*.{ts,tsx,mjs,cjs,json,css}\""
},
@@ -49,11 +49,11 @@
"devDependencies": {
"@eslint/js": "10.0.1",
"concurrently": "^10.0.6",
- "eslint": "10.12.0",
+ "eslint": "10.14.0",
"eslint-plugin-react-hooks": "7.1.1",
- "globals": "17.13.0",
- "prettier": "3.9.9",
- "typescript-eslint": "8.71.2",
+ "globals": "17.14.0",
+ "prettier": "3.9.10",
+ "typescript-eslint": "8.72.2",
"wait-on": "^9.5.1"
},
"overrides": {
diff --git a/packages/alb/package.json b/packages/alb/package.json
index 6a617d5..34e9790 100644
--- a/packages/alb/package.json
+++ b/packages/alb/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/alb",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Agent Loop Builder workflow runtime, built into Locus",
"type": "module",
@@ -24,6 +24,6 @@
"@types/node": "^26.6.5",
"@types/semver": "^7.8.0",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/compute/package.json b/packages/compute/package.json
index 7f41bc1..dc7ab28 100644
--- a/packages/compute/package.json
+++ b/packages/compute/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/compute",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Fargate placement quotes and RunTask adapter",
"type": "module",
@@ -19,6 +19,6 @@
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/guest-agent/package.json b/packages/guest-agent/package.json
index 5aa730a..b56b5a1 100644
--- a/packages/guest-agent/package.json
+++ b/packages/guest-agent/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/guest-agent",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Guest tool catalog and SSH/WinRM/docker-exec adapters",
"type": "module",
@@ -22,6 +22,6 @@
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/guest-agent/src/index.ts b/packages/guest-agent/src/index.ts
index 702eff8..44c55dd 100644
--- a/packages/guest-agent/src/index.ts
+++ b/packages/guest-agent/src/index.ts
@@ -68,7 +68,7 @@ export {
} from "./sshCli.js";
export type { SshCliForward, SshCliHandle } from "./sshCli.js";
export { authenticatedCloneUrl, gitAuthHeader } from "./git.js";
-export { ensureHostKey } from "./keys.js";
+export { ensureHostKey, hostKeyWarning, tryEnsureHostKey } from "./keys.js";
export type { HostSshKey } from "./keys.js";
export { waitForGuest, guestReadyTimeoutMs } from "./ready.js";
export type { GuestWaitTarget } from "./ready.js";
diff --git a/packages/guest-agent/src/keys.test.ts b/packages/guest-agent/src/keys.test.ts
index 7a447c4..462a546 100644
--- a/packages/guest-agent/src/keys.test.ts
+++ b/packages/guest-agent/src/keys.test.ts
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-import { ensureHostKey, type Keygen } from "./keys.js";
+import { ensureHostKey, hostKeyWarning, tryEnsureHostKey, type Keygen } from "./keys.js";
describe("ensureHostKey", () => {
const dirs: string[] = [];
@@ -52,4 +52,16 @@ describe("ensureHostKey", () => {
expect(calls).toBe(0);
expect(key.publicKey).toBe("ssh-ed25519 AAAAC3 existing");
});
+
+ it("keeps going without ssh-keygen and reports why", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-keys-"));
+ dirs.push(dir);
+ const result = await tryEnsureHostKey(dir, async () => {
+ throw new Error("ssh-keygen is required to create ~/.locus/ssh (spawn ssh-keygen ENOENT)");
+ });
+ expect(result.error).toMatch(/ssh-keygen is required/);
+ expect(result.key.publicKey).toBe("");
+ expect(result.key.privateKeyPath).toBe(join(dir, "ssh", "id_ed25519"));
+ expect(hostKeyWarning(result.error!)).toMatch(/Host and Docker agents still work/);
+ });
});
diff --git a/packages/guest-agent/src/keys.ts b/packages/guest-agent/src/keys.ts
index fdc97c1..4804c8a 100644
--- a/packages/guest-agent/src/keys.ts
+++ b/packages/guest-agent/src/keys.ts
@@ -28,6 +28,32 @@ export async function ensureHostKey(
return { privateKeyPath, publicKey };
}
+/**
+ * Like ensureHostKey, but a missing `ssh-keygen` (minimal Linux installs,
+ * containers) does not stop the control plane: Host and Docker agents never use
+ * this key. VM guests do, so the caller logs the reason and VMs can't be reached
+ * over SSH until the key exists.
+ */
+export async function tryEnsureHostKey(
+ dataDir: string,
+ keygen: Keygen = sshKeygen,
+): Promise<{ key: HostSshKey; error?: undefined } | { key: HostSshKey; error: string }> {
+ try {
+ return { key: await ensureHostKey(dataDir, keygen) };
+ } catch (err) {
+ const privateKeyPath = join(dataDir, "ssh", "id_ed25519");
+ return {
+ key: { privateKeyPath, publicKey: "" },
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
+
+/** One line for the server log when tryEnsureHostKey could not make the key. */
+export function hostKeyWarning(error: string): string {
+ return `Warning: ${error}. Host and Docker agents still work; VM agents need ssh-keygen (install openssh-client, then restart Locus).`;
+}
+
async function exists(path: string): Promise {
try {
await stat(path);
diff --git a/packages/hypervisor/package.json b/packages/hypervisor/package.json
index f26f0cd..44da9c9 100644
--- a/packages/hypervisor/package.json
+++ b/packages/hypervisor/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/hypervisor",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "QEMU and Docker guest engines for Locus, with mock fallbacks",
"type": "module",
@@ -19,6 +19,6 @@
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/hypervisor/src/images.test.ts b/packages/hypervisor/src/images.test.ts
index 4cf7c41..1cb428d 100644
--- a/packages/hypervisor/src/images.test.ts
+++ b/packages/hypervisor/src/images.test.ts
@@ -35,7 +35,7 @@ describe("locus agent images", () => {
await ensureLocusAgentImage(runner, "/usr/bin/docker", "locus/agent", undefined, dir);
expect(calls).toEqual([
["image", "inspect", LOCUS_AGENT_IMAGE],
- ["build", "--progress=plain", "-t", LOCUS_AGENT_IMAGE, "-t", "locus/agent:0.20.0", dir],
+ ["build", "--progress=plain", "-t", LOCUS_AGENT_IMAGE, "-t", "locus/agent:0.21.0", dir],
]);
calls.length = 0;
const present: CommandRunner = {
diff --git a/packages/providers/package.json b/packages/providers/package.json
index 8cc1576..9c37af3 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/providers",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "LLM provider adapters and the native agent loop",
"type": "module",
@@ -19,6 +19,6 @@
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 0f42d4b..01c0465 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/shared",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "Shared types and pure helpers used by the Locus apps and packages",
"type": "module",
@@ -20,6 +20,6 @@
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/shared/src/benchmark.test.ts b/packages/shared/src/benchmark.test.ts
new file mode 100644
index 0000000..27327d4
--- /dev/null
+++ b/packages/shared/src/benchmark.test.ts
@@ -0,0 +1,120 @@
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ BENCHMARK_CASES,
+ parseBenchmarkTarget,
+ scoreBenchmarkCase,
+ summarizeBenchmark,
+} from "./benchmark.js";
+
+/** Known-good fixes, so a broken fixture fails here and not as a model's "failure". */
+const REFERENCE: Record> = {
+ "fix-add": { "src/add.js": "export function add(a, b) {\n return a + b;\n}\n" },
+ "off-by-one": {
+ "src/paginate.js":
+ "export function page(items, number, size) {\n const start = (number - 1) * size;\n return items.slice(start, start + size);\n}\nexport function pageCount(items, size) {\n return Math.ceil(items.length / size);\n}\n",
+ },
+ "implement-slugify": {
+ "src/slugify.js":
+ 'export function slugify(title) {\n return title.normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n}\n',
+ },
+ "missing-await": {
+ "src/loader.js":
+ "export async function loadAll(ids, fetchOne) {\n const out = [];\n for (const id of ids) out.push(await fetchOne(id));\n return out;\n}\nexport async function total(ids, fetchOne) {\n const rows = await loadAll(ids, fetchOne);\n return rows.reduce((sum, row) => sum + row.amount, 0);\n}\n",
+ },
+ "cross-file-feature": {
+ "src/price.js":
+ 'const SYMBOL = { USD: "$", EUR: "€", GBP: "£" };\nexport function formatPrice(cents, opts = {}) {\n return `${SYMBOL[opts.currency ?? "USD"]}${(cents / 100).toFixed(2)}`;\n}\n',
+ "src/cart.js":
+ 'import { formatPrice } from "./price.js";\nexport function cartTotal(cart) {\n const cents = cart.items.reduce((sum, item) => sum + item.cents * item.qty, 0);\n return formatPrice(cents, { currency: cart.currency });\n}\n',
+ },
+};
+
+function write(root: string, files: Record) {
+ for (const [rel, body] of Object.entries(files)) {
+ const abs = join(root, rel);
+ mkdirSync(dirname(abs), { recursive: true });
+ writeFileSync(abs, body);
+ }
+}
+
+function testsPass(root: string): boolean {
+ try {
+ execFileSync("npm", ["test", "--silent"], {
+ cwd: root,
+ stdio: "pipe",
+ shell: process.platform === "win32",
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+describe("benchmark cases", () => {
+ it("have unique ids, protected tests that exist, and a reference fix", () => {
+ const ids = BENCHMARK_CASES.map((row) => row.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const row of BENCHMARK_CASES) {
+ for (const path of row.protectedFiles)
+ expect(row.files[path], `${row.id}:${path}`).toBeDefined();
+ expect(REFERENCE[row.id], row.id).toBeDefined();
+ }
+ });
+
+ for (const row of BENCHMARK_CASES) {
+ it(`${row.id}: fails as shipped, passes with the reference fix`, () => {
+ const root = mkdtempSync(join(tmpdir(), `locus-bench-${row.id}-`));
+ try {
+ write(root, row.files);
+ expect(testsPass(root)).toBe(false);
+ write(root, REFERENCE[row.id]!);
+ expect(testsPass(root)).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }, 30_000);
+ }
+});
+
+describe("benchmark scoring", () => {
+ it("fails edited tests even when they pass", () => {
+ expect(
+ scoreBenchmarkCase({ testsRan: true, testsOk: true, editedProtected: ["src/a.test.js"] }),
+ ).toEqual({ status: "failed", reason: "tests edited: src/a.test.js" });
+ expect(scoreBenchmarkCase({ testsRan: true, testsOk: true, editedProtected: [] }).status).toBe(
+ "passed",
+ );
+ expect(
+ scoreBenchmarkCase({ testsRan: true, testsOk: false, editedProtected: [], timedOut: true })
+ .reason,
+ ).toMatch(/timed out/);
+ });
+
+ it("parses provider/model with slashes in the model", () => {
+ expect(parseBenchmarkTarget("openrouter/anthropic/claude")).toEqual({
+ providerId: "openrouter",
+ model: "anthropic/claude",
+ });
+ expect(parseBenchmarkTarget("nomodel")).toBeUndefined();
+ });
+
+ it("summarizes pass rate, cost and median time per target", () => {
+ const a = { providerId: "p", model: "a" };
+ const b = { providerId: "p", model: "b" };
+ const [sa, sb] = summarizeBenchmark({
+ targets: [a, b],
+ results: [
+ { caseId: "x", target: a, attempt: 1, status: "passed", usd: 0.01, durationMs: 1000 },
+ { caseId: "y", target: a, attempt: 1, status: "failed", usd: 0.03, durationMs: 3000 },
+ { caseId: "x", target: b, attempt: 1, status: "running" },
+ ],
+ });
+ expect(sa).toMatchObject({ passed: 1, total: 2, passRate: 0.5, medianMs: 2000 });
+ expect(sa!.usd).toBeCloseTo(0.04);
+ expect(sb).toMatchObject({ passed: 0, total: 0, passRate: 0 });
+ });
+});
diff --git a/packages/shared/src/benchmark.ts b/packages/shared/src/benchmark.ts
new file mode 100644
index 0000000..d83536e
--- /dev/null
+++ b/packages/shared/src/benchmark.ts
@@ -0,0 +1,325 @@
+/**
+ * Model benchmark: small, dependency-free coding tasks run as real Host agent turns against
+ * a chosen provider and model. Each case starts with failing tests; it passes when
+ * `npm test` passes afterwards and the test files are unchanged.
+ */
+
+export interface BenchmarkCase {
+ id: string;
+ title: string;
+ prompt: string;
+ /** Workspace files, path → contents. */
+ files: Record;
+ /** Files the agent must not modify (the tests). */
+ protectedFiles: string[];
+}
+
+export interface BenchmarkTarget {
+ providerId: string;
+ model: string;
+}
+
+export type BenchmarkCaseStatus = "pending" | "running" | "passed" | "failed" | "error";
+
+export interface BenchmarkCaseResult {
+ caseId: string;
+ target: BenchmarkTarget;
+ attempt: number;
+ status: BenchmarkCaseStatus;
+ /** Why it failed: "tests failed", "tests edited", "timed out", "needs a human: …". */
+ reason?: string;
+ sessionId?: string;
+ startedAt?: string;
+ durationMs?: number;
+ inputTokens?: number;
+ outputTokens?: number;
+ /** Estimated from the provider's published or configured rates. */
+ usd?: number;
+ toolCalls?: number;
+ testOutput?: string;
+}
+
+export type BenchmarkRunStatus = "running" | "done" | "cancelled" | "error";
+
+export interface BenchmarkRun {
+ id: string;
+ createdAt: string;
+ finishedAt?: string;
+ status: BenchmarkRunStatus;
+ error?: string;
+ targets: BenchmarkTarget[];
+ caseIds: string[];
+ repeat: number;
+ results: BenchmarkCaseResult[];
+}
+
+export interface BenchmarkTargetSummary {
+ target: BenchmarkTarget;
+ passed: number;
+ total: number;
+ /** 0–1. Finished cases only. */
+ passRate: number;
+ usd?: number;
+ medianMs?: number;
+ tokens: number;
+}
+
+const PKG = (name: string, test: string) =>
+ `${JSON.stringify(
+ { name: `locus-bench-${name}`, private: true, type: "module", scripts: { test } },
+ null,
+ 2,
+ )}\n`;
+
+export const BENCHMARK_CASES: BenchmarkCase[] = [
+ {
+ id: "fix-add",
+ title: "Fix a one-line bug",
+ prompt: "The tests in src/add.test.js fail. Make add(2, 3) return 5. Do not change the tests.",
+ files: {
+ "package.json": PKG("fix-add", "node --test src/add.test.js"),
+ "src/add.js": "export function add(a, b) {\n return a;\n}\n",
+ "src/add.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { add } from "./add.js";',
+ "",
+ 'test("adds two numbers", () => {',
+ " assert.equal(add(2, 3), 5);",
+ " assert.equal(add(-1, 1), 0);",
+ "});",
+ "",
+ ].join("\n"),
+ },
+ protectedFiles: ["src/add.test.js"],
+ },
+ {
+ id: "off-by-one",
+ title: "Find an off-by-one",
+ prompt: "`npm test` fails. Find and fix the bug in src/paginate.js. Do not change the tests.",
+ files: {
+ "package.json": PKG("off-by-one", "node --test src/paginate.test.js"),
+ "src/paginate.js": [
+ "/** Split items into pages of `size`. Page numbers start at 1. */",
+ "export function page(items, number, size) {",
+ " const start = number * size;",
+ " return items.slice(start, start + size);",
+ "}",
+ "",
+ "export function pageCount(items, size) {",
+ " return Math.floor(items.length / size);",
+ "}",
+ "",
+ ].join("\n"),
+ "src/paginate.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { page, pageCount } from "./paginate.js";',
+ "",
+ "const items = [1, 2, 3, 4, 5, 6, 7];",
+ "",
+ 'test("first page", () => assert.deepEqual(page(items, 1, 3), [1, 2, 3]));',
+ 'test("last partial page", () => assert.deepEqual(page(items, 3, 3), [7]));',
+ 'test("page count rounds up", () => assert.equal(pageCount(items, 3), 3));',
+ 'test("exact pages", () => assert.equal(pageCount([1, 2, 3, 4], 2), 2));',
+ "",
+ ].join("\n"),
+ },
+ protectedFiles: ["src/paginate.test.js"],
+ },
+ {
+ id: "implement-slugify",
+ title: "Implement from tests",
+ prompt:
+ "Implement slugify in src/slugify.js so the tests in src/slugify.test.js pass. Do not change the tests.",
+ files: {
+ "package.json": PKG("slugify", "node --test src/slugify.test.js"),
+ "src/slugify.js": [
+ "/** Turn a title into a URL slug. */",
+ "export function slugify(title) {",
+ ' throw new Error("not implemented");',
+ "}",
+ "",
+ ].join("\n"),
+ "src/slugify.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { slugify } from "./slugify.js";',
+ "",
+ 'test("lowercases and joins words", () => assert.equal(slugify("Hello World"), "hello-world"));',
+ 'test("drops punctuation", () => assert.equal(slugify("Ship it, now!"), "ship-it-now"));',
+ 'test("collapses separators", () => assert.equal(slugify(" a -- b "), "a-b"));',
+ 'test("strips accents", () => assert.equal(slugify("Crème Brûlée"), "creme-brulee"));',
+ 'test("keeps digits", () => assert.equal(slugify("Top 10 Tips"), "top-10-tips"));',
+ "",
+ ].join("\n"),
+ },
+ protectedFiles: ["src/slugify.test.js"],
+ },
+ {
+ id: "missing-await",
+ title: "Fix an async bug",
+ prompt:
+ "The tests for src/loader.js fail intermittently and return the wrong totals. Fix the bug. Do not change the tests.",
+ files: {
+ "package.json": PKG("missing-await", "node --test src/loader.test.js"),
+ "src/loader.js": [
+ "export async function loadAll(ids, fetchOne) {",
+ " const out = [];",
+ " for (const id of ids) {",
+ " out.push(fetchOne(id));",
+ " }",
+ " return out;",
+ "}",
+ "",
+ "export async function total(ids, fetchOne) {",
+ " const rows = await loadAll(ids, fetchOne);",
+ " return rows.reduce((sum, row) => sum + row.amount, 0);",
+ "}",
+ "",
+ ].join("\n"),
+ "src/loader.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { loadAll, total } from "./loader.js";',
+ "",
+ "const fetchOne = async (id) => ({ id, amount: id * 10 });",
+ "",
+ 'test("loads rows in order", async () => {',
+ " assert.deepEqual(await loadAll([1, 2], fetchOne), [",
+ " { id: 1, amount: 10 },",
+ " { id: 2, amount: 20 },",
+ " ]);",
+ "});",
+ "",
+ 'test("sums amounts", async () => assert.equal(await total([1, 2, 3], fetchOne), 60));',
+ "",
+ ].join("\n"),
+ },
+ protectedFiles: ["src/loader.test.js"],
+ },
+ {
+ id: "cross-file-feature",
+ title: "Add a feature across files",
+ prompt:
+ 'Add a `currency` option to formatPrice in src/price.js (default "USD") and make src/cart.js pass the cart\'s currency through. The tests describe the behaviour. Do not change the tests.',
+ files: {
+ "package.json": PKG("cross-file", "node --test src/price.test.js src/cart.test.js"),
+ "src/price.js": [
+ "export function formatPrice(cents) {",
+ " return `$${(cents / 100).toFixed(2)}`;",
+ "}",
+ "",
+ ].join("\n"),
+ "src/cart.js": [
+ 'import { formatPrice } from "./price.js";',
+ "",
+ "export function cartTotal(cart) {",
+ " const cents = cart.items.reduce((sum, item) => sum + item.cents * item.qty, 0);",
+ " return formatPrice(cents);",
+ "}",
+ "",
+ ].join("\n"),
+ "src/price.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { formatPrice } from "./price.js";',
+ "",
+ 'test("defaults to USD", () => assert.equal(formatPrice(1234), "$12.34"));',
+ 'test("euros", () => assert.equal(formatPrice(500, { currency: "EUR" }), "€5.00"));',
+ 'test("pounds", () => assert.equal(formatPrice(99, { currency: "GBP" }), "£0.99"));',
+ "",
+ ].join("\n"),
+ "src/cart.test.js": [
+ 'import assert from "node:assert/strict";',
+ 'import { test } from "node:test";',
+ 'import { cartTotal } from "./cart.js";',
+ "",
+ 'test("uses the cart currency", () => {',
+ " const cart = {",
+ ' currency: "EUR",',
+ " items: [",
+ " { cents: 250, qty: 2 },",
+ " { cents: 100, qty: 1 },",
+ " ],",
+ " };",
+ ' assert.equal(cartTotal(cart), "€6.00");',
+ "});",
+ "",
+ 'test("defaults to USD", () => assert.equal(cartTotal({ items: [{ cents: 5, qty: 3 }] }), "$0.15"));',
+ "",
+ ].join("\n"),
+ },
+ protectedFiles: ["src/price.test.js", "src/cart.test.js"],
+ },
+];
+
+export function benchmarkCase(id: string): BenchmarkCase | undefined {
+ return BENCHMARK_CASES.find((row) => row.id === id);
+}
+
+export function benchmarkTargetKey(target: BenchmarkTarget): string {
+ return `${target.providerId}/${target.model}`;
+}
+
+/** Parse `provider/model` (model may itself contain slashes). */
+export function parseBenchmarkTarget(raw: string): BenchmarkTarget | undefined {
+ const text = raw.trim();
+ const slash = text.indexOf("/");
+ if (slash <= 0 || slash === text.length - 1) return undefined;
+ return { providerId: text.slice(0, slash), model: text.slice(slash + 1) };
+}
+
+export function scoreBenchmarkCase(input: {
+ testsRan: boolean;
+ testsOk: boolean;
+ editedProtected: string[];
+ timedOut?: boolean;
+ needsHuman?: string;
+ error?: string;
+}): { status: "passed" | "failed" | "error"; reason: string } {
+ if (input.error) return { status: "error", reason: input.error };
+ if (input.editedProtected.length) {
+ return { status: "failed", reason: `tests edited: ${input.editedProtected.join(", ")}` };
+ }
+ if (!input.testsRan) return { status: "failed", reason: "tests did not run" };
+ if (!input.testsOk) {
+ if (input.timedOut) return { status: "failed", reason: "timed out with failing tests" };
+ return { status: "failed", reason: "tests failed" };
+ }
+ return {
+ status: "passed",
+ reason: input.timedOut ? "passed (hit the time limit)" : "tests passed",
+ };
+}
+
+function median(values: number[]): number | undefined {
+ if (!values.length) return undefined;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1]! + sorted[mid]!) / 2;
+}
+
+export function summarizeBenchmark(
+ run: Pick,
+): BenchmarkTargetSummary[] {
+ return run.targets.map((target) => {
+ const key = benchmarkTargetKey(target);
+ const rows = run.results.filter(
+ (row) =>
+ benchmarkTargetKey(row.target) === key &&
+ (row.status === "passed" || row.status === "failed" || row.status === "error"),
+ );
+ const passed = rows.filter((row) => row.status === "passed").length;
+ const costs = rows.map((row) => row.usd).filter((v): v is number => typeof v === "number");
+ return {
+ target,
+ passed,
+ total: rows.length,
+ passRate: rows.length ? passed / rows.length : 0,
+ usd: costs.length ? costs.reduce((a, b) => a + b, 0) : undefined,
+ medianMs: median(rows.map((row) => row.durationMs ?? 0).filter((ms) => ms > 0)),
+ tokens: rows.reduce((sum, row) => sum + (row.inputTokens ?? 0) + (row.outputTokens ?? 0), 0),
+ };
+ });
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index d561f9a..df9c50b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -52,6 +52,7 @@ export * from "./models/subagentKind.js";
export * from "./models/queue.js";
export * from "./compact.js";
export * from "./codingEval.js";
+export * from "./benchmark.js";
export * from "./generationEval.js";
export * from "./contextWindow.js";
export * from "./repoMap.js";
diff --git a/packages/shared/src/models/config.ts b/packages/shared/src/models/config.ts
index 98fd043..f376809 100644
--- a/packages/shared/src/models/config.ts
+++ b/packages/shared/src/models/config.ts
@@ -156,6 +156,8 @@ export interface LocusConfig {
notifyQuietStart?: string;
/** Quiet-hours end `HH:MM` local. Wraps overnight when start is later than end. */
notifyQuietEnd?: string;
+ /** Check GitHub Releases for a newer Locus in the background. Default true. */
+ checkForUpdates?: boolean;
/** Codebase / Stack diff layout. Default unified. */
diffLayout?: DiffLayout;
/** Shared knowledge brain (distiller model, auto-index, pause ingest). */
@@ -213,6 +215,11 @@ export function parseNotifyPref(raw: unknown): boolean {
return raw !== false;
}
+/** Background update checks are on unless turned off. */
+export function parseCheckForUpdates(raw: unknown): boolean {
+ return raw !== false;
+}
+
/** Accepts `H:MM` or `HH:MM`. Junk or empty is no quiet bound. */
export function parseQuietTime(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
diff --git a/packages/shared/src/models/git.ts b/packages/shared/src/models/git.ts
index 59111b9..05cefdf 100644
--- a/packages/shared/src/models/git.ts
+++ b/packages/shared/src/models/git.ts
@@ -214,6 +214,50 @@ export function forgeAccountSubtitle(account: Pick/g, "")
+ .split(/\r?\n/)
+ .filter((line) => !/^\s*<\/?[a-z][^>]*>\s*$/i.test(line))
+ .filter(
+ (line) => !/^\s*(\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*|!\[[^\]]*\]\([^)]*\)\s*)+$/.test(line),
+ );
+ const text = lines
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ if (text.length <= max) return text;
+ const cut = text.slice(0, max);
+ const para = cut.lastIndexOf("\n\n");
+ return `${(para > max / 2 ? cut.slice(0, para) : cut).trimEnd()}\n\n…`;
+}
+
+/**
+ * What a forge account without a token can do. A public repo links and loads
+ * through it, so "Not signed in" alone reads like it is broken.
+ */
+export function anonymousForgeLabel(kind: GitForgeAccount["kind"]): string {
+ return kind === "github"
+ ? "No token · public repos only (60 requests/hour)"
+ : "No token · public repos only";
+}
+
export type ForgeAccountStatusKind = "signed-in" | "expiring" | "expired" | "signed-out";
const TOKEN_EXPIRY_WARN_MS = 7 * 24 * 60 * 60 * 1000;
diff --git a/packages/shared/src/models/repoAbout.test.ts b/packages/shared/src/models/repoAbout.test.ts
new file mode 100644
index 0000000..c421fe5
--- /dev/null
+++ b/packages/shared/src/models/repoAbout.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { anonymousForgeLabel, readmeExcerpt } from "./git.js";
+
+describe("readmeExcerpt", () => {
+ it("drops badge rows and HTML wrappers and keeps the prose", () => {
+ const md = [
+ '',
+ ' ',
+ "
",
+ "[](https://x) [](https://y)",
+ "",
+ "# Locus",
+ "",
+ "Local coding agents on your machine.",
+ ].join("\n");
+ expect(readmeExcerpt(md)).toBe("# Locus\n\nLocal coding agents on your machine.");
+ });
+
+ it("clips long READMEs at a paragraph break", () => {
+ const para = "word ".repeat(100).trim();
+ const out = readmeExcerpt([para, para, para, para].join("\n\n"), 1200);
+ expect(out.length).toBeLessThan(1210);
+ expect(out.endsWith("…")).toBe(true);
+ });
+});
+
+describe("anonymousForgeLabel", () => {
+ it("names the GitHub anonymous quota", () => {
+ expect(anonymousForgeLabel("github")).toMatch(/60 requests\/hour/);
+ expect(anonymousForgeLabel("gitlab")).toBe("No token · public repos only");
+ });
+});
diff --git a/packages/shared/src/models/vm.ts b/packages/shared/src/models/vm.ts
index d8673e2..f76720f 100644
--- a/packages/shared/src/models/vm.ts
+++ b/packages/shared/src/models/vm.ts
@@ -21,7 +21,7 @@ export function runtimeNeedsGuest(runtime: AgentRuntime): boolean {
/** Compat tag. `npm run images` also tags `locus/agent:`. */
export const LOCUS_AGENT_IMAGE_TAG = "0.1";
export const LOCUS_AGENT_IMAGE = `locus/agent:${LOCUS_AGENT_IMAGE_TAG}`;
-export const LOCUS_AGENT_IMAGE_VERSION = "0.20.0";
+export const LOCUS_AGENT_IMAGE_VERSION = "0.21.0";
export const DEFAULT_DOCKER_IMAGE = "locus/agent";
/** In-guest code-server bind. Host reaches it through a loopback docker-exec tunnel, never a published port. */
export const LOCUS_CODE_SERVER_PORT = 3999;
diff --git a/packages/shared/src/remoteError.test.ts b/packages/shared/src/remoteError.test.ts
index 0b1d06e..21a16ca 100644
--- a/packages/shared/src/remoteError.test.ts
+++ b/packages/shared/src/remoteError.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
forgeAccessMessage,
forgeAccessProblem,
+ forgeRateLimitResetMinutes,
formatRemoteError,
isForgeAuthError,
} from "./remoteError.js";
@@ -71,3 +72,16 @@ describe("forgeAccessProblem", () => {
expect(forgeAccessProblem(undefined)).toBeNull();
});
});
+
+describe("forgeRateLimitResetMinutes", () => {
+ it("says when an anonymous rate limit resets and reads it back", () => {
+ const now = Date.UTC(2026, 10, 13, 12, 0, 0);
+ const limited = forgeAccessMessage("GitHub", "rate_limited", now + 22.5 * 60_000, now);
+ expect(limited).toContain("Resets in 23 minutes.");
+ expect(forgeAccessProblem(limited)).toBe("rate_limited");
+ expect(forgeRateLimitResetMinutes(limited)).toBe(23);
+ const unknown = forgeAccessMessage("GitHub", "rate_limited");
+ expect(unknown).toContain("Try again later.");
+ expect(forgeRateLimitResetMinutes(unknown)).toBeNull();
+ });
+});
diff --git a/packages/shared/src/remoteError.ts b/packages/shared/src/remoteError.ts
index 528ec26..3f29a9e 100644
--- a/packages/shared/src/remoteError.ts
+++ b/packages/shared/src/remoteError.ts
@@ -21,12 +21,31 @@ const NEEDS_TOKEN_PHRASE = "needs a token to read this repo";
const RATE_LIMIT_PHRASE = "rate limit reached for reads without a token";
/** One sentence the server sends and the UI matches with `forgeAccessProblem`. */
-export function forgeAccessMessage(forge: string, problem: ForgeAccessProblem): string {
+export function forgeAccessMessage(
+ forge: string,
+ problem: ForgeAccessProblem,
+ resetAt?: number,
+ now = Date.now(),
+): string {
return problem === "rate_limited"
- ? `${forge} ${RATE_LIMIT_PHRASE}. Add a token in Settings → Git, or try again later.`
+ ? `${forge} ${RATE_LIMIT_PHRASE}. ${rateLimitResetPhrase(resetAt, now)} Add a token in Settings → Git to raise the limit.`
: `${forge} ${NEEDS_TOKEN_PHRASE}. It may be private — add a token in Settings → Git.`;
}
+const RESET_RE = /Resets in (\d+) minutes?\./;
+
+function rateLimitResetPhrase(resetAt: number | undefined, now: number): string {
+ if (!resetAt || !Number.isFinite(resetAt)) return "Try again later.";
+ const minutes = Math.max(1, Math.ceil((resetAt - now) / 60_000));
+ return `Resets in ${minutes} minute${minutes === 1 ? "" : "s"}.`;
+}
+
+/** Minutes until an anonymous rate limit resets, read back out of `forgeAccessMessage`. */
+export function forgeRateLimitResetMinutes(message: string | null | undefined): number | null {
+ const match = message ? RESET_RE.exec(message) : null;
+ return match ? Number(match[1]) : null;
+}
+
/** Classify a forge error as a missing-token or anonymous rate-limit refusal. */
export function forgeAccessProblem(message: string | null | undefined): ForgeAccessProblem | null {
if (!message) return null;
diff --git a/packages/shared/src/repoMap.test.ts b/packages/shared/src/repoMap.test.ts
new file mode 100644
index 0000000..10e3e17
--- /dev/null
+++ b/packages/shared/src/repoMap.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { formatRepoMap, searchRepoMap, type RepoMap } from "./repoMap.js";
+
+function sampleMap(): RepoMap {
+ const files = [];
+ for (let i = 0; i < 300; i += 1) {
+ files.push({
+ path: `src/module${i}/handler${i % 7}.ts`,
+ bytes: 100,
+ symbols: [`handle${i}`, `ConfigLoader${i % 5}`],
+ });
+ }
+ files.push({ path: "src/workspaceGrep.ts", bytes: 10, symbols: ["grepWorkspace"] });
+ files.push({ path: "README.md", bytes: 10, symbols: [] });
+ return { files };
+}
+
+describe("searchRepoMap", () => {
+ it("returns the same hits when narrowing from the previous keystroke", () => {
+ const query = "hndlr3cfg";
+ const typed = sampleMap();
+ let narrowed = searchRepoMap(typed, "");
+ for (let k = 1; k <= query.length; k += 1) narrowed = searchRepoMap(typed, query.slice(0, k));
+ const fresh = searchRepoMap(sampleMap(), query);
+ expect(narrowed).toEqual(fresh);
+
+ // Backspacing then typing a different letter must not reuse the narrowed pool.
+ searchRepoMap(typed, "wg");
+ expect(searchRepoMap(typed, "wgr")).toEqual(searchRepoMap(sampleMap(), "wgr"));
+ expect(searchRepoMap(typed, "rea")).toEqual(searchRepoMap(sampleMap(), "rea"));
+ });
+});
+
+describe("formatRepoMap", () => {
+ it("lists query matches first and stays within the character budget", () => {
+ const text = formatRepoMap(sampleMap(), {
+ query: "workspace grep",
+ rank: true,
+ maxChars: 2_000,
+ });
+ const lines = text.split("\n");
+ expect(lines[1]).toMatch(/^src\/workspaceGrep\.ts/);
+ expect(lines.at(-1)).toBe("…[repo map truncated]");
+ expect(lines.slice(0, -1).join("\n").length).toBeLessThanOrEqual(2_000);
+ });
+
+ it("does not truncate when everything fits", () => {
+ const map: RepoMap = { files: [{ path: "a.ts", symbols: ["alpha"] }] };
+ expect(formatRepoMap(map)).toBe(
+ "Repo map (1 files). Prefer grep/file_read on these paths instead of dumping files.\na.ts alpha",
+ );
+ });
+});
diff --git a/packages/shared/src/repoMap.ts b/packages/shared/src/repoMap.ts
index 9d85f38..f77ec11 100644
--- a/packages/shared/src/repoMap.ts
+++ b/packages/shared/src/repoMap.ts
@@ -307,10 +307,11 @@ export function formatRepoMap(
return true;
});
const matched = tokens.length ? scoped.filter((file) => fileMatchesQuery(file, tokens)) : scoped;
- let rows =
- opts.rank && tokens.length
- ? [...matched, ...scoped.filter((file) => !matched.includes(file))]
- : matched;
+ let rows: RepoMapFile[] = matched;
+ if (opts.rank && tokens.length) {
+ const hit = new Set(matched);
+ rows = [...matched, ...scoped.filter((file) => !hit.has(file))];
+ }
if (focus) rows = boostFocus(rows, focus);
const lines: string[] = [];
const focusNote = focus ? ` Open file ${focus} listed first.` : "";
@@ -329,19 +330,26 @@ export function formatRepoMap(
`Repo map (${rows.length} files${map.truncated ? ", truncated" : ""}).${focusNote} Prefer grep/file_read on these paths instead of dumping files.`,
);
}
+ // Running length of lines.join("\n"), instead of re-joining every line on every row.
+ let length = lines.reduce((n, line) => n + line.length, 0) + Math.max(0, lines.length - 1);
for (const file of rows) {
const symbols = file.symbols.length ? ` ${file.symbols.slice(0, 12).join(", ")}` : "";
- lines.push(`${file.path}${symbols}`);
- if (lines.join("\n").length > maxChars) {
- lines.pop();
+ const line = `${file.path}${symbols}`;
+ const next = length + (lines.length ? 1 : 0) + line.length;
+ if (next > maxChars) {
lines.push("…[repo map truncated]");
break;
}
+ lines.push(line);
+ length = next;
}
if (rows.length === 0) lines.push("(no matching files)");
return lines.join("\n");
}
+/** Last Quick Open query per map, so each keystroke only rescans what still matched. */
+const lastSearch = new WeakMap();
+
/** Ranked file and symbol hits for Quick Open / Go to file. */
export function searchRepoMap(
map: RepoMap,
@@ -360,7 +368,15 @@ export function searchRepoMap(
.map((file) => ({ path: file.path, kind: "file" as const, score: 1 }));
}
const hits: RepoIndexHit[] = [];
- for (const file of map.files) {
+ // fuzzyScore > 0 means the query is a subsequence of the text, so files that matched a
+ // shorter prefix of this query are a superset of the files that can match it now.
+ const norm = q.toLowerCase().replace(/\s+/g, "");
+ const last = lastSearch.get(map);
+ const pool =
+ last && norm.startsWith(last.query) && norm.length > last.query.length ? last.files : map.files;
+ const matchedFiles: RepoMapFile[] = [];
+ for (const file of pool) {
+ const before = hits.length;
const pathScore = fuzzyScore(file.path, q);
const baseScore = fuzzyScore(basename(file.path), q);
const fileScore = Math.max(pathScore, baseScore > 0 ? baseScore + 8 : 0);
@@ -377,7 +393,9 @@ export function searchRepoMap(
score: symbolScore + 4 + searchPathBonus(file.path),
});
}
+ if (hits.length > before) matchedFiles.push(file);
}
+ lastSearch.set(map, { query: norm, files: matchedFiles });
hits.sort(
(a, b) =>
b.score - a.score ||
diff --git a/packages/worker/package.json b/packages/worker/package.json
index b110631..73ac294 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/worker",
- "version": "0.20.0",
+ "version": "0.21.0",
"private": true,
"description": "LAN/cloud worker that runs Docker, Host, and QEMU guests for a Locus control plane",
"type": "module",
@@ -28,6 +28,6 @@
"@types/ws": "^8.18.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
- "vitest": "^5.0.5"
+ "vitest": "^5.1.0"
}
}
diff --git a/packages/worker/src/main.ts b/packages/worker/src/main.ts
index 91b1d00..8301e2d 100644
--- a/packages/worker/src/main.ts
+++ b/packages/worker/src/main.ts
@@ -4,7 +4,12 @@ import { join } from "node:path";
import { serve } from "@hono/node-server";
import { DEFAULT_ADVANCED, DEFAULT_WORKER_PORT } from "@locus/shared";
import { createDockerEngine, createHypervisor } from "@locus/hypervisor";
-import { ensureHostKey, guestReadyTimeoutMs, waitForGuest } from "@locus/guest-agent";
+import {
+ guestReadyTimeoutMs,
+ hostKeyWarning,
+ tryEnsureHostKey,
+ waitForGuest,
+} from "@locus/guest-agent";
import type { GuestOs, VmInstance } from "@locus/shared";
import { createWorkerApp } from "./app.js";
import { WorkerBrowserHub } from "./browserHub.js";
@@ -24,7 +29,8 @@ const loaded = await loadOrCreateWorkerToken(dataDir);
const token = loaded.token;
const tls = await loadOrCreateWorkerTls(dataDir);
const engine = await createDockerEngine(dataDir);
-const hostKey = await ensureHostKey(dataDir);
+const { key: hostKey, error: hostKeyError } = await tryEnsureHostKey(dataDir);
+if (hostKeyError) console.warn(hostKeyWarning(hostKeyError));
const { hypervisor } = await createHypervisor({ ...DEFAULT_ADVANCED, dataDir }, undefined, {
sshPublicKey: hostKey.publicKey,
sshIdentityFile: hostKey.privateKeyPath,