diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 88df005..d8d64bf 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.17.0).
- placeholder: 0.17.0
+ description: Settings → About, or the GitHub Release tag (for example 0.18.0).
+ placeholder: 0.18.0
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ee18dcb..9ceae59 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,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 images/agent
+ - run: docker build -t locus/agent:0.1 -t locus/agent:0.18.0 images/agent
android:
runs-on: ubuntu-latest
diff --git a/.github/workflows/dist-linux.yml b/.github/workflows/dist-linux.yml
index c1a84fa..1ab84ba 100644
--- a/.github/workflows/dist-linux.yml
+++ b/.github/workflows/dist-linux.yml
@@ -68,9 +68,11 @@ jobs:
run: node scripts/release-checksums.mjs apps/desktop/dist-electron SHA256SUMS-linux
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
- name: locus-linux-appimage
+ name: locus-linux
path: |
apps/desktop/dist-electron/*.AppImage
+ apps/desktop/dist-electron/*.deb
+ apps/desktop/dist-electron/*.rpm
apps/desktop/dist-electron/SHA256SUMS-linux
if-no-files-found: error
retention-days: 30
@@ -80,6 +82,8 @@ jobs:
with:
files: |
apps/desktop/dist-electron/*.AppImage
+ apps/desktop/dist-electron/*.deb
+ apps/desktop/dist-electron/*.rpm
apps/desktop/dist-electron/SHA256SUMS-linux
generate_release_notes: true
# Only this job generates notes. macOS/Windows attach files with
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78f655d..1e680f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
## Unreleased
+## 0.18.0 — 2026-11-09
+
+Worker VMs with proxied displays and golden disks, Azure and GCP placement, Slack Approve, Windows guest CLIs, and Linux installers. Settings → About is **0.18.0**.
+
+**Workers.** QEMU guests on a LAN or cloud worker stream the display to the laptop over a pinned WebSocket instead of treating worker loopback as local. Phone threads get a view-only pane; control stays on the desktop. Golden VM disks upload once to the worker and reuse. Azure Container Instances and Google Compute Engine sit next to Fargate for placement. Worker planes namespace guests under one token.
+
+**Slack.** Posting and uploading from an agent can be approved with Slack action buttons. Forge writes still confirm in Locus.
+
+**Guests.** Cursor, Claude, Codex, and Kiro spawn over SSH into Windows VMs via PowerShell. Linux guests stay POSIX.
+
+**Desktop.** The composer Explore / Review chip matches neighboring controls. Opening the model menu no longer flashes above the trigger. A usage hint shows combined LLM and VM spend. Setup offers a first Host task from the empty Agents state. On the phone, More opens Settings, New automation, and the command palette; threads gain Find in chat.
+
+**Coding CLIs.** Kiro CLI stream-json envelopes map into the chat thread instead of finishing with no reply, and duplicate tool chips are gone. A turn that already ran the project's tests is not flagged Needs you for a scratch write or a trailing delete-only cleanup. Codex keeps file-change details in the thread.
+
+**Packaging.** Linux ships AppImage, deb, and rpm. The agent image is tagged with both a stable alias and this version. About → Download and verify checks SHA256SUMS before opening the installer.
+
## 0.17.0 — 2026-11-08
Quieter desktop shell, more guest runtimes, extra forges, and more reliable installer builds. Settings → About is **0.17.0**.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d4b4607..356da67 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,8 +48,8 @@ First agent without Docker: Settings → Providers (Ollama needs no key) then Ne
After merge to `main`:
```bash
-git tag v0.17.0
-git push origin v0.17.0
+git tag v0.18.0
+git push origin v0.18.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 e860041..44741a2 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,13 @@
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.17.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.
+This is **v0.18.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.
## Install
Download the latest [GitHub Release](https://github.com/Modsofthenation/Locus/releases). Each `v*` tag attaches:
-- Linux AppImage
+- Linux AppImage, deb, and rpm
- macOS dmg (signed when `CSC_LINK` is set on the repo; otherwise unsigned — right-click → Open the first time)
- Windows NSIS installer (unsigned builds show SmartScreen)
- Per-platform checksums (`SHA256SUMS-linux`, `SHA256SUMS-macos`, `SHA256SUMS-windows`)
@@ -38,7 +38,7 @@ You describe a task. Locus runs the agent in a guest you own. Review diffs, stee
The runtime is chosen per agent and stays fixed. New agents default to Docker when the engine is present, otherwise Host.
-**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker, Host, or QEMU VM** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. Worker VMs resolve cloud images from `LOCUS_WORKER_HOME/media`; live VNC stays on the worker localhost in v1 (screenshots and tools still work). Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement.
+**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker, Host, or QEMU VM** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. Worker VMs resolve cloud images from `LOCUS_WORKER_HOME/media`; live VNC is proxied to the laptop over pinned WSS (the worker still binds VNC to localhost). Golden VM disks upload to the worker once. Azure and GCP workers use the same recorded-HTTP placement as Fargate. Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement.
## Tour
@@ -210,7 +210,7 @@ Git includes **Sign in with GitHub** / **Sign in with GitLab** / **Sign in with
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.17.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.18.0**.
@@ -303,7 +303,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.** Ninety-seven themes (Ember default). Settings → About is **0.17.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.** Ninety-seven themes (Ember default). Settings → About is **0.18.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 e0b9c1c..278afab 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,7 +11,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c
- Bind the HTTP/WebSocket control plane to loopback (`127.0.0.1` / `::1` / `localhost` only) unless Settings → Phone is enabled. Saving a non-loopback bind host in Advanced is still rejected. Phone remote binds `0.0.0.0` and requires a vaulted pairing token for every non-loopback client; public internet peers are rejected even with a token. The same port multiplexes loopback HTTP with LAN HTTPS (self-signed cert in `~/.locus/tls/`, fingerprint on the pairing QR). Minting, rotating, and revealing that token is loopback-only. Restart after disabling phone remote so 7420 leaves `0.0.0.0`. The `locus` CLI (`apps/cli`) is another loopback client of that API — same bind, redaction, and approval gates as the desktop.
- Reject browser requests whose `Origin` is not loopback.
- Keep QEMU display, SSH, RDP, WinRM, QMP, and serial forwards on loopback. VNC has no password and SPICE uses `disable-ticketing`; that is accepted for a single-operator loopback display, not as a network service.
-- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. Worker QEMU guests keep VNC/SPICE on the **worker** loopback in v1 (not forwarded to the laptop). Cursor / Claude / Codex / Kiro on a Linux VM spawn over SSH with `-R` of the control-plane MCP port into the guest; Windows VMs stay Docker/Host-only for those CLIs. The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only.
+- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. Worker QEMU guests keep VNC/SPICE on the **worker** loopback; the laptop reaches VNC through a pinned WSS proxy (`/v1/guests/:id/display`), never by publishing VNC. Cursor / Claude / Codex / Kiro on Linux or Windows VMs spawn over SSH with `-R` of the control-plane MCP port into the guest (PowerShell on Windows). The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only.
- Redact provider keys, forge tokens, guest passwords, and matching vault secrets in API responses, traces, session JSON, and tool output sent to the model. Tool-argument **values** are pattern-scanned, not only secret-named keys. GitLab `glpat-`, phone `ph_`, Chrome extension `cx_`, and Atlassian `ATATT` formats are included. `GET /api/automations` masks `webhookSecret` / `slackSigningSecret`; create/rotate still returns the secret once.
- Store those secrets in `~/.locus/secrets.enc` (AES-256-GCM). The packaged app wraps the master key with Electron `safeStorage` when the OS keychain is available, then deletes plaintext `master.key`.
- MCP stdio servers inherit a minimal host env (`PATH`, locale, temp). They do not receive `LOCUS_MASTER_KEY`.
@@ -34,7 +34,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c
- Forge **webhooks** (and Slack / generic HTTP webhooks) are reachable only if something can POST to loopback or to a Tailscale Funnel that proxies **only** `/api/hooks`. An Amazon API Gateway HTTP integration may use the same `/api/hooks` path; set the webhook base URL to that origin. Locus unwraps REST/HTTP API proxy events. GitHub.com and Slack.com cannot reach `127.0.0.1`. Generic `X-Locus-Secret` or `x-api-key` requests also need `X-Locus-Timestamp` (unix seconds, ±5 minutes) as a **real HTTP header**. A timestamp only inside an API Gateway JSON envelope is ignored, so Funnel/API Gateway integrations must forward that header. GitHub `X-Hub-Signature-256` stays body-HMAC. Slack signing secrets can be set per automation (else Settings → Advanced). A leaked secret on a forwarded port would still be enough to queue a run within the timestamp window. Webhook bodies are wrapped as a nonce-tagged `` block, size-capped (bytes, streamed), and must not be followed as instructions. First-party Jira/Confluence/Linear/Slack/Grafana/Miro/Figma/incident.io/AWS IAM/Portainer/Gmail/Calendar/Drive/Kubernetes/Sentry tools are off on automations unless listed on that job's allowlist.
- First-party Jira / Confluence / Linear / Slack / Grafana / Miro / Figma / incident.io / AWS IAM / Portainer / Gmail / Google Calendar / Google Drive / Sentry / Kubernetes tools run on the **host** with vaulted tokens, like forge PR tools. They are omitted from the agent catalog until Settings → Integrations has a token for that product (AWS IAM also becomes ready when instance metadata is enabled; Kubernetes becomes ready with a token or a loopback `kubectl proxy` URL). Mutating Jira comments, creates, updates, transitions, Confluence pages, Linear comments/creates/updates, Slack posts, incident.io creates/updates, Miro export, Figma comments, Portainer start/stop, Gmail sends, Calendar creates, Drive uploads, and Sentry updates, Kubernetes apply/delete/scale wait for Approve / Deny. AWS IAM tools in this slice are reads (`GetCallerIdentity`, list/get/simulate). Kubernetes `k8s_get` of Secret objects redacts `data` / `stringData`. **Cursor CLI** sessions get the same tools through a loopback MCP server (`POST /mcp`); `agent --approve-mcps` is the operator opt-in for those calls. **Claude Code** sessions get the same server via project `.mcp.json` (`type: http`) and `--mcp-config .mcp.json`. The MCP config file stores only the loopback URL, not tokens. Worker Cursor CLI and Claude Code use a loopback proxy on the worker host that tunnels JSON-RPC back to this control plane — it does not bind the laptop MCP port on the LAN. AWS IAM/STS calls use signed `fetch` on the host (no AWS SDK), same as Fargate placement. Optional IAM/STS/ECS endpoint URLs and an AWS gateway URL keep their path; `execute-api` hosts sign as `execute-api`. HTTP MCP and OpenAI-compatible Bedrock / API Gateway URLs sign the same way when AWS keys are configured. The control plane bind host stays loopback. Instance metadata is IMDSv2 or the ECS credentials URI; `AWS_CONTAINER_CREDENTIALS_FULL_URI` must be link-local or loopback. Kubernetes tools talk to the API server (or loopback kubectl proxy) from the host; they do not run kubectl in the guest.
- **Integration plugins** are the same host-side HTTP exception, authored by `manage_integration_plugin` (Approve / Deny). Secrets belong in Settings → Integrations, not in the manage tool. Updating a plugin keeps vaulted secrets; changing a secret field to a non-secret type drops the value instead of writing it to `config.json`. The request host must come from a plugin URL field or a hardcoded https host; argument values cannot retarget the host. Redirects are refused. Plugin responses are nonce-fenced. GET tools with `write: false` skip Approve / Deny (they are treated as reads); non-GET tools always wait. Automations only see listed `plugin_*` names.
-- **Slack identity.** Messages Locus sends default to **you** (composer or approved `slack_post`). Pass `as="bot"` or use the composer toggle to post as the Locus app when a bot token is stored. The Slack app is OAuth + Socket Mode plumbing. Socket Mode is outbound WSS; Slack.com still cannot reach loopback. Funnel stays `/api/hooks` only. Do not Approve / Deny from Slack. Thread excerpts are nonce-fenced (`kind="slack-message"`). The cache under `~/.locus/slack/` stays on this machine and is encrypted with the vault master key. Slack file GIFs and videos are fetched with the user token through `/api/slack/media` (Slack file hosts only). HTML and CSV files stay `Content-Disposition: attachment` on that route unless `preview=1`, which serves HTML with a strict CSP for a `sandbox=""` iframe (no scripts) and CSV as inline text for the table preview. Video is served inline for the in-pane player. GIF search is outbound to api.giphy.com when `LOCUS_GIPHY_API_KEY` is set and managed policy allows it; otherwise a local catalog.
+- **Slack identity.** Messages Locus sends default to **you** (composer or approved `slack_post`). Pass `as="bot"` or use the composer toggle to post as the Locus app when a bot token is stored. The Slack app is OAuth + Socket Mode plumbing. Socket Mode is outbound WSS; Slack.com still cannot reach loopback. Funnel stays `/api/hooks` only. Slack action buttons may Approve / Deny **`slack_post` and `slack_upload` only** — `git_push` and other forge writes stay in Locus. Thread excerpts are nonce-fenced (`kind="slack-message"`). The cache under `~/.locus/slack/` stays on this machine and is encrypted with the vault master key. Slack file GIFs and videos are fetched with the user token through `/api/slack/media` (Slack file hosts only). HTML and CSV files stay `Content-Disposition: attachment` on that route unless `preview=1`, which serves HTML with a strict CSP for a `sandbox=""` iframe (no scripts) and CSV as inline text for the table preview. Video is served inline for the in-pane player. GIF search is outbound to api.giphy.com when `LOCUS_GIPHY_API_KEY` is set and managed policy allows it; otherwise a local catalog.
- Host **web search** also runs on the operator machine. Leave it off unless you want that agent to query the public web.
- **Use my Chrome** is a second Host-runtime exception: an unpacked Chrome/Edge extension on loopback can drive the operator's real tabs (cookies and logins included) for Host agents that opt in. Isolated `host-chrome` stays the default. Pairing tokens (`cx_`) are vaulted like phone `ph_`. The websocket is loopback-only; navigation is an **http(s) allowlist** (`file:`, `data:`, `chrome://`, and other schemes are refused). Workers cannot use live Chrome.
- **Android pairing** loads HTTPS only (cleartext disabled). The TLS fingerprint is required; a `locus://pair` deep link prefills the form and asks for confirmation before connecting. The token is stored as a cookie for the WebView, not appended to the HTTPS URL. Third-party cookies are off.
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 253459a..be19755 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 = 31
- versionName = "0.17.0"
+ versionCode = 32
+ versionName = "0.18.0"
}
buildTypes {
release {
diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json
index 3bb19f5..c15610b 100644
--- a/apps/chrome-extension/manifest.json
+++ b/apps/chrome-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Locus",
- "version": "0.17.0",
+ "version": "0.18.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 350f08d..8a7db4d 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/cli",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Locus CLI — control plane client for agents, Slack, Codebase, Docker, and automations",
"type": "module",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 1bb223d..f3e397c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,10 +1,11 @@
{
"name": "@locus/desktop",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"author": "Locus contributors",
"license": "MIT",
"description": "Locus — local AI coding agents in real guest VMs",
+ "homepage": "https://github.com/Modsofthenation/Locus#readme",
"desktopName": "locus",
"type": "module",
"main": "electron/main.cjs",
@@ -19,7 +20,8 @@
"test:visual:update": "playwright test --update-snapshots",
"capture:readme": "node ./scripts/capture-readme-screens.mjs",
"electron": "electron .",
- "dist": "npm run build && electron-builder --publish never"
+ "dist": "npm run build && electron-builder --publish never",
+ "dist:linux:arm64": "npm run build && electron-builder --linux --arm64 --publish never"
},
"dependencies": {
"@locus/shared": "*",
@@ -85,12 +87,22 @@
],
"linux": {
"target": [
- "AppImage"
+ "AppImage",
+ "deb",
+ "rpm"
],
"category": "Development",
"maintainer": "Locus",
"executableName": "locus"
},
+ "deb": {
+ "packageName": "locus",
+ "artifactName": "${productName}-${version}-${arch}.${ext}"
+ },
+ "rpm": {
+ "packageName": "locus",
+ "artifactName": "${productName}-${version}-${arch}.${ext}"
+ },
"mac": {
"target": [
"dmg"
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 43276d5..f1e900c 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.17.0" })),
+ checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.18.0" })),
workersStatus: vi.fn(async () => []),
tailscale: vi.fn(async () => ({
installed: false,
diff --git a/apps/desktop/src/components/chat/ChatPane.test.tsx b/apps/desktop/src/components/chat/ChatPane.test.tsx
index 73718e0..ae0e407 100644
--- a/apps/desktop/src/components/chat/ChatPane.test.tsx
+++ b/apps/desktop/src/components/chat/ChatPane.test.tsx
@@ -2974,7 +2974,11 @@ describe("ChatPane", () => {
expect(css).toMatch(/\[data-chat-density="comfortable"\][\s\S]*?--chat-font: 14\.5px;/);
expect(css).toMatch(/\[data-chat-density="dense"\][\s\S]*?--chat-font: 12px;/);
expect(css).toMatch(/\[data-chat-density="dense"\][\s\S]*?--chat-msg-gap: 8px;/);
- expect(css).toMatch(/\.mobile-thread \{[\s\S]*?padding: var\(--chat-mobile-thread-pad\)/);
+ const phone = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "../../styles/phone.css"),
+ "utf8",
+ );
+ expect(phone).toMatch(/\.mobile-thread \{[\s\S]*?padding: var\(--chat-mobile-thread-pad\)/);
});
it("lets developer thinking snippets fill the message column", () => {
@@ -3273,7 +3277,7 @@ describe("ChatPane", () => {
{
id: "c1",
name: "exec",
- arguments: { command: "git show develop:siq8/batch/test.py" },
+ arguments: { command: "git show develop:app/batch/test.py" },
result: "ok",
startedAt: "2020-01-01T00:00:00.000Z",
finishedAt: "2020-01-01T00:00:01.000Z",
@@ -3290,7 +3294,7 @@ describe("ChatPane", () => {
{
id: "c1",
name: "exec",
- arguments: { command: "git show develop:siq8/batch/test.py" },
+ arguments: { command: "git show develop:app/batch/test.py" },
result: "ok",
startedAt: "2020-01-01T00:00:00.000Z",
finishedAt: "2020-01-01T00:00:01.000Z",
diff --git a/apps/desktop/src/components/chat/PatchCard.test.tsx b/apps/desktop/src/components/chat/PatchCard.test.tsx
index 4d1500d..a76b1f1 100644
--- a/apps/desktop/src/components/chat/PatchCard.test.tsx
+++ b/apps/desktop/src/components/chat/PatchCard.test.tsx
@@ -123,8 +123,8 @@ describe("PatchCard", () => {
({
const web: DockerContainer = {
id: "c1",
- name: "siq8-dev_webpack.abc",
+ name: "app-dev_webpack.abc",
image: "node:22",
state: "running",
status: "Up 2 hours",
@@ -34,7 +34,7 @@ const web: DockerContainer = {
const db: DockerContainer = {
id: "c2",
- name: "siq8-dev_nginx.xyz",
+ name: "app-dev_nginx.xyz",
image: "nginx:alpine",
state: "exited",
status: "Exited (0) 3 minutes ago",
@@ -105,21 +105,21 @@ describe("DockerExplorer", () => {
expect(host.querySelector('[data-testid="docker-explorer"]')).toBeTruthy();
expect(host.querySelector(".docker-explorer-head")?.getAttribute("aria-expanded")).toBe("true");
expect(host.querySelector(".docker-explorer-count")?.textContent).toBe("2");
- expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("siq8-dev_webpack.abc");
+ expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("app-dev_webpack.abc");
expect(host.querySelector(".dot.running")).toBeTruthy();
await act(async () => {
(host.querySelector(".docker-explorer-open") as HTMLButtonElement).click();
});
expect(inspected.map((row) => row.id)).toEqual(["c1"]);
await act(async () => {
- (host.querySelector('[aria-label="Stop siq8-dev_webpack.abc"]') as HTMLButtonElement).click();
+ (host.querySelector('[aria-label="Stop app-dev_webpack.abc"]') as HTMLButtonElement).click();
await Promise.resolve();
await Promise.resolve();
});
expect(api.dockerContainerAction).toHaveBeenCalledWith("c1", "stop");
await act(async () => {
(
- host.querySelector('[aria-label="Restart siq8-dev_webpack.abc"]') as HTMLButtonElement
+ host.querySelector('[aria-label="Restart app-dev_webpack.abc"]') as HTMLButtonElement
).click();
await Promise.resolve();
await Promise.resolve();
@@ -130,9 +130,7 @@ describe("DockerExplorer", () => {
it("starts a stopped container without a confirm", async () => {
render();
await flush();
- const start = host.querySelector(
- '[aria-label="Start siq8-dev_nginx.xyz"]',
- ) as HTMLButtonElement;
+ const start = host.querySelector('[aria-label="Start app-dev_nginx.xyz"]') as HTMLButtonElement;
expect(start).toBeTruthy();
await act(async () => {
start.click();
@@ -157,7 +155,7 @@ describe("DockerExplorer", () => {
await act(async () => {
(document.querySelector('.workspace-ctx [role="menuitem"]') as HTMLButtonElement).click();
});
- expect(inspected.map((row) => row.name)).toEqual(["siq8-dev_webpack.abc"]);
+ expect(inspected.map((row) => row.name)).toEqual(["app-dev_webpack.abc"]);
expect(document.querySelector(".workspace-ctx")).toBeNull();
});
diff --git a/apps/desktop/src/components/guest/VncPane.tsx b/apps/desktop/src/components/guest/VncPane.tsx
index b2a6343..83317e2 100644
--- a/apps/desktop/src/components/guest/VncPane.tsx
+++ b/apps/desktop/src/components/guest/VncPane.tsx
@@ -2,7 +2,15 @@ import { useEffect, useRef } from "react";
import type RFB from "@novnc/novnc";
import { displayWsUrl } from "@/lib/api";
-export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void }) {
+export function VncPane({
+ vmId,
+ onFail,
+ viewOnly = false,
+}: {
+ vmId: string;
+ onFail: () => void;
+ viewOnly?: boolean;
+}) {
const host = useRef(null);
const fail = useRef(onFail);
fail.current = onFail;
@@ -27,6 +35,7 @@ export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void })
rfb = session;
session.scaleViewport = true;
session.clipViewport = true;
+ session.viewOnly = viewOnly;
session.background =
getComputedStyle(document.documentElement).getPropertyValue("--desktop").trim() ||
"#0b0d12";
@@ -50,7 +59,7 @@ export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void })
/* already torn down */
}
};
- }, [vmId]);
+ }, [vmId, viewOnly]);
return
;
}
diff --git a/apps/desktop/src/components/layout/EmptyState.test.tsx b/apps/desktop/src/components/layout/EmptyState.test.tsx
index b398b9f..a1c6d63 100644
--- a/apps/desktop/src/components/layout/EmptyState.test.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.test.tsx
@@ -347,6 +347,22 @@ describe("EmptyState recipes", () => {
);
});
+ it("shows a guided first-task card after setup until skip or fill", async () => {
+ render(undefined, { config: { ...config, setupFirstTask: "pending" } });
+ expect(host.querySelector("[data-testid='first-task-card']")).toBeTruthy();
+ const fill = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.includes("Fill the composer"),
+ );
+ await act(async () => {
+ fill!.click();
+ await Promise.resolve();
+ });
+ expect((host.querySelector("textarea") as HTMLTextAreaElement).value).toMatch(/hello\.txt/);
+ expect(api.saveConfig).toHaveBeenCalledWith(
+ expect.objectContaining({ setupFirstTask: "done" }),
+ );
+ });
+
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();
diff --git a/apps/desktop/src/components/layout/EmptyState.tsx b/apps/desktop/src/components/layout/EmptyState.tsx
index 75efa6f..d926c6a 100644
--- a/apps/desktop/src/components/layout/EmptyState.tsx
+++ b/apps/desktop/src/components/layout/EmptyState.tsx
@@ -17,6 +17,7 @@ import {
sessionVision,
setupHasRemainingTodos,
showFirstTaskPills,
+ t,
visibleRecipes,
workerIsMetered,
workerWhereLabel,
@@ -206,6 +207,17 @@ export function EmptyState() {
clearRecipeExtras();
setPrompt(task.prompt);
setError(null);
+ if (state.config?.setupFirstTask === "pending") {
+ void api.saveConfig({ ...state.config, setupFirstTask: "done" }).then((saved) => {
+ dispatch({ type: "config", config: saved });
+ });
+ }
+ }
+
+ async function skipFirstTask() {
+ if (!state.config) return;
+ const saved = await api.saveConfig({ ...state.config, setupFirstTask: "dismissed" });
+ dispatch({ type: "config", config: saved });
}
function applyRecipe(recipe: AgentRecipe) {
@@ -518,6 +530,28 @@ export function EmptyState() {
) : null}
+ {state.config?.setupFirstTask === "pending" && hasProvider ? (
+
+
{t("empty.firstTask.title")}
+
{t("empty.firstTask.body")}
+
+ applyFirstTask(FIRST_TASKS[0]!)}
+ >
+ {t("empty.firstTask.cta")}
+
+ void skipFirstTask()}
+ >
+ {t("onboarding.skip")}
+
+
+
+ ) : null}
{setup ? (
({
+ Settings: () => Settings body
,
+}));
+vi.mock("@/components/automations/NewAutomation", () => ({
+ NewAutomationModal: () => New automation modal
,
+}));
+vi.mock("@/components/layout/SessionSwitcher", () => ({
+ SessionSwitcher: ({ startOpen }: { startOpen?: boolean }) =>
+ startOpen ? Command palette
: null,
+}));
+vi.mock("@/components/guest/VncPane", () => ({
+ VncPane: () => VNC
,
+}));
+
vi.mock("@/lib/api", () => ({
api: {
chat: vi.fn(async () => ({ ok: true })),
@@ -1357,7 +1371,10 @@ describe("MobileShell", () => {
).toContain("Promote");
expect(
[...host.querySelectorAll(".mobile-tags.is-muted li")].map((el) => el.textContent),
- ).toEqual(["Live VNC", "Settings", "New automation", "Command palette", "Find in chat"]);
+ ).toEqual(["Live VNC control still needs the desktop."]);
+ expect(host.textContent).toContain("Settings");
+ expect(host.textContent).toContain("New automation");
+ expect(host.textContent).toContain("Command palette");
const desktop = [...host.querySelectorAll("button")].find(
(btn) => btn.textContent === "Desktop",
);
@@ -2125,4 +2142,76 @@ describe("MobileShell", () => {
expect(host.querySelector("h1")?.textContent).toBe("Code");
expect(host.querySelector("[data-testid='mobile-code']")).toBeTruthy();
});
+
+ it("opens Settings, new automation, and the command palette from More", async () => {
+ render();
+ const more = [...host.querySelectorAll(".mobile-tab")].find((btn) =>
+ btn.textContent?.includes("More"),
+ );
+ act(() => {
+ more!.click();
+ });
+ const settings = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.includes("Settings"),
+ );
+ await act(async () => {
+ settings!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(host.querySelector("[data-testid='mobile-settings']")).toBeTruthy();
+ act(() => {
+ host.querySelector(".mobile-overlay .mobile-back")!.click();
+ });
+ const automation = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.includes("New automation"),
+ );
+ await act(async () => {
+ automation!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(host.querySelector("[data-testid='new-automation-stub']")).toBeTruthy();
+ const palette = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.includes("Command palette"),
+ );
+ await act(async () => {
+ palette!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(host.querySelector("[data-testid='palette-stub']")).toBeTruthy();
+ });
+
+ it("opens view-only VNC and find-in-chat on a VM thread", async () => {
+ render({
+ sessions: [session({ runtime: "vm", vmId: "vm-1" })],
+ });
+ act(() => {
+ host.querySelector(".mobile-row")!.click();
+ });
+ expect(host.querySelector("[data-testid='mobile-vnc-toggle']")).toBeTruthy();
+ act(() => {
+ host.querySelector("[data-testid='mobile-vnc-toggle']")!.click();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(host.querySelector("[data-testid='mobile-vnc']")).toBeTruthy();
+ act(() => {
+ host.querySelector("[data-testid='mobile-vnc-toggle']")!.click();
+ });
+ const more = host.querySelector("[title='Thread']");
+ act(() => {
+ more!.click();
+ });
+ const find = [...host.querySelectorAll("button")].find((btn) =>
+ btn.textContent?.includes("Find in chat"),
+ );
+ act(() => {
+ find!.click();
+ });
+ expect(host.querySelector("[data-testid='mobile-find']")).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/components/layout/MobileShell.tsx b/apps/desktop/src/components/layout/MobileShell.tsx
index 733447e..04efe1d 100644
--- a/apps/desktop/src/components/layout/MobileShell.tsx
+++ b/apps/desktop/src/components/layout/MobileShell.tsx
@@ -1,4 +1,13 @@
-import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import {
+ Fragment,
+ lazy,
+ Suspense,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
import { AgentCanvasPane, useAgentCanvasFocus } from "@/components/workspace/AgentCanvasPane";
import {
DEFAULT_DOCKER_IMAGE,
@@ -36,6 +45,7 @@ import {
subagentStatusLabel,
subagentViewMessages,
showThoughtTraces,
+ t,
themeOption,
isFoldableToolActivity,
toolActivityGroups,
@@ -78,6 +88,7 @@ import {
sessionMetaLine,
sessionNeedsYou,
sessionShowsBrowser,
+ sessionShowsVnc,
sessionStatusLabel,
setUiShell,
bindMobileViewport,
@@ -88,6 +99,20 @@ import { loadMobileAgentFilter, saveMobileAgentFilter } from "@/lib/sidebarPrefs
import { MobileBoardList, MobileBoardView } from "@/components/board/MobileBoard";
import { MobileProjectList } from "@/components/projects/MobileProject";
import { APP_VERSION } from "@/lib/appInfo";
+import { chatFindHits } from "@/lib/chatFind";
+
+const SettingsPane = lazy(async () => ({
+ default: (await import("@/components/settings/Settings")).Settings,
+}));
+const NewAutomationModal = lazy(async () => ({
+ default: (await import("@/components/automations/NewAutomation")).NewAutomationModal,
+}));
+const SessionSwitcher = lazy(async () => ({
+ default: (await import("@/components/layout/SessionSwitcher")).SessionSwitcher,
+}));
+const VncPane = lazy(async () => ({
+ default: (await import("@/components/guest/VncPane")).VncPane,
+}));
import { batchableApprovals, needsYouHumanItems, type NeedsYouItem } from "@/lib/needsYou";
import { applyChatDensity, applyTheme } from "@/lib/theme";
import { useStore } from "@/state/store";
@@ -362,6 +387,7 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) {
const [codePull, setCodePull] = useState(null);
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState("");
+ const [overlay, setOverlay] = useState(null);
const sessions = mobileSessions(state.sessions);
const designs = mobileDesigns(state.sessions);
const games = mobileGames(state.sessions);
@@ -579,6 +605,9 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) {
onOpenCanvas={(id) => setDesignId(id)}
onNewGame={() => setCompose("game")}
onNewAsset={() => setCompose("assets")}
+ onSettings={() => setOverlay("settings")}
+ onNewAutomation={() => dispatch({ type: "newAutomation", open: true })}
+ onPalette={() => setOverlay("palette")}
/>
) : null}
@@ -624,6 +653,37 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) {
>
)}
+ {overlay === "settings" ? (
+
+ ) : null}
+ {overlay === "palette" ? (
+
+ setOverlay(null)} />
+
+ ) : null}
+ {state.newAutomationOpen ? (
+
+
+
+ ) : null}
);
}
@@ -918,6 +978,9 @@ function MobileMore(props: {
onOpenCanvas: (sessionId: string) => void;
onNewGame?: () => void;
onNewAsset?: () => void;
+ onSettings?: () => void;
+ onNewAutomation?: () => void;
+ onPalette?: () => void;
}) {
const { state, dispatch } = useStore();
const theme = state.config?.theme ?? "ember";
@@ -1346,14 +1409,29 @@ function MobileMore(props: {
))}
+
+ On this phone
+ props.onSettings?.()}>
+ {t("phone.more.settings")}
+ Providers · workers · about
+
+ props.onNewAutomation?.()}
+ >
+ {t("phone.more.automation")}
+ Schedule a job
+
+ props.onPalette?.()}>
+ {t("phone.more.palette")}
+ Jump anywhere
+
+
Stays on the desktop
- {["Live VNC", "Settings", "New automation", "Command palette", "Find in chat"].map(
- (label) => (
- {label}
- ),
- )}
+ {t("phone.desktopOnly")}
@@ -1503,6 +1581,9 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
const [outbound, setOutbound] = useState(null);
const [openSubagent, setOpenSubagent] = useState(null);
const [browser, setBrowser] = useState(false);
+ const [vnc, setVnc] = useState(false);
+ const [findOpen, setFindOpen] = useState(false);
+ const [findQuery, setFindQuery] = useState("");
const [pane, setPane] = useState<"chat" | "diff" | "files" | "canvas">("chat");
const canvasFocus = useAgentCanvasFocus(session.id, session.agentCanvases);
useEffect(() => {
@@ -1518,6 +1599,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
productMode: state.config?.productMode,
});
const canWatchBrowser = sessionShowsBrowser(session);
+ const canWatchVnc = sessionShowsVnc(session);
const pending = viewing ? [] : pendingApprovals([session]);
const outboundVisible =
viewing || !(outbound && !chatPromptLanded(session, outbound.content)) ? null : outbound;
@@ -1528,6 +1610,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
? [...session.messages, outboundVisible]
: session.messages
).filter((row) => !isHiddenChatMessage(row));
+ const findHits = findOpen ? chatFindHits(messages, findQuery) : [];
const newMarkerId = viewing ? undefined : visitMarkerId(state.chatNewVisit, session.id);
const cloneStartId = viewing ? undefined : latestCloneStartMessageId(messages);
const cloneProgressLive = state.cloneProgress[session.id];
@@ -1564,6 +1647,9 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
setOutbound(null);
setOpenSubagent(null);
setBrowser(false);
+ setVnc(false);
+ setFindOpen(false);
+ setFindQuery("");
setPane("chat");
}, [session.id]);
@@ -1640,12 +1726,31 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
className={`mobile-icon-btn${browser ? " on" : ""}`}
aria-label={browser ? "Hide browser" : "Open browser"}
aria-pressed={browser}
- onClick={() => setBrowser((open) => !open)}
+ onClick={() => {
+ setVnc(false);
+ setBrowser((open) => !open);
+ }}
>
{browser ? "Hide browser" : "Open browser"}
) : null}
+ {canWatchVnc && !viewing ? (
+ {
+ setBrowser(false);
+ setVnc((open) => !open);
+ }}
+ >
+
+ {vnc ? "Hide VM display" : t("phone.more.vnc")}
+
+ ) : null}
{turning && !viewing ? (
void }) {
) : !viewing ? (
} title="Thread" toggleClassName="mobile-icon-btn">
+ {
+ setFindOpen((open) => !open);
+ if (findOpen) setFindQuery("");
+ }}
+ >
+ {t("phone.more.find")}
+
{
void api
@@ -1709,7 +1822,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
void }) {
/>
) : null}
+ {vnc && session.vmId ? (
+
+ Connecting…}>
+ setVnc(false)} />
+
+
+ ) : null}
+ {findOpen && !browser && !vnc && !viewing ? (
+
+ setFindQuery(event.target.value)}
+ />
+
+ {findQuery.trim() ? `${findHits.length} match${findHits.length === 1 ? "" : "es"}` : ""}
+
+
+ ) : null}
{session.needsHuman?.trim() && !viewing && !browser ? (
Needs you — {session.needsHuman.trim()}
@@ -1768,7 +1904,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {
) : null}
-
+
{!state.connected || error ? (
{error ?? "Reconnecting…"}
diff --git a/apps/desktop/src/components/layout/OverflowMenu.test.tsx b/apps/desktop/src/components/layout/OverflowMenu.test.tsx
index f4e2296..52924b9 100644
--- a/apps/desktop/src/components/layout/OverflowMenu.test.tsx
+++ b/apps/desktop/src/components/layout/OverflowMenu.test.tsx
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
MenuItem,
OverflowMenu,
@@ -186,6 +186,35 @@ describe("fixedContainingOrigin", () => {
parent.remove();
}
});
+
+ it("does not treat container-type alone as a fixed containing block", () => {
+ const parent = document.createElement("div");
+ const child = document.createElement("span");
+ parent.appendChild(child);
+ document.body.appendChild(parent);
+ const orig = window.getComputedStyle;
+ window.getComputedStyle = ((el: Element) => {
+ if (el === parent) {
+ return {
+ transform: "none",
+ filter: "none",
+ perspective: "none",
+ backdropFilter: "none",
+ contain: "none",
+ containerType: "inline-size",
+ willChange: "auto",
+ getPropertyValue: (name: string) => (name === "container-type" ? "inline-size" : "none"),
+ } as unknown as CSSStyleDeclaration;
+ }
+ return orig.call(window, el);
+ }) as typeof getComputedStyle;
+ try {
+ expect(fixedContainingOrigin(child)).toMatchObject({ top: 0, left: 0 });
+ } finally {
+ window.getComputedStyle = orig;
+ parent.remove();
+ }
+ });
});
describe("OverflowMenu CSS", () => {
@@ -194,11 +223,15 @@ describe("OverflowMenu CSS", () => {
expect(css).toMatch(
/\.menu\.up \.menu-pop:not\(\.placed\) \{[\s\S]*?bottom:\s*calc\(100% \+ 6px\)/,
);
+ expect(css).toMatch(/\.menu-pop:not\(\.placed\) \{\s*visibility:\s*hidden/);
expect(css).toMatch(/\.menu\.right \.menu-pop:not\(\.placed\) \{[\s\S]*?right:\s*0/);
expect(css).toMatch(/\.menu \.menu-pop\.placed \{[\s\S]*?position:\s*fixed/);
expect(css).toMatch(/\.menu \.menu-pop\.placed \{[\s\S]*?inset:\s*auto/);
expect(css).toMatch(/\.menu \.menu-pop\.placed \{[\s\S]*?width:\s*max-content/);
expect(css).toMatch(/\.model-picker-more \.menu-pop \{[\s\S]*?min-width:\s*168px/);
+ expect(css).toMatch(
+ /\.model-picker-combined \.menu-pop:not\(\.placed\),[\s\S]*?bottom:\s*calc\(100% \+ 6px\)/,
+ );
expect(css).toMatch(
/\.model-picker-more \.menu-pop:not\(\.placed\) \{[\s\S]*?bottom:\s*calc\(100% \+ 6px\)/,
);
@@ -267,6 +300,38 @@ describe("OverflowMenu", () => {
expect(host.textContent).toContain("Unlink");
});
+ it("opens an upward pop in place and focuses without scrolling", () => {
+ const origFocus = HTMLElement.prototype.focus;
+ let placedAtItemFocus = false;
+ const focus = vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function (
+ this: HTMLElement,
+ options?: FocusOptions,
+ ) {
+ if (this.getAttribute("role") === "menuitem") {
+ placedAtItemFocus = Boolean(this.closest(".menu-pop")?.classList.contains("placed"));
+ }
+ return origFocus.call(this, options);
+ });
+ render(
+
+ Composer
+ Other
+ ,
+ );
+ try {
+ clickTrigger();
+ const details = host.querySelector("details.menu") as HTMLDetailsElement;
+ const menu = host.querySelector(".menu-pop") as HTMLElement;
+ expect(details.open).toBe(true);
+ expect(details.className).toContain("up");
+ expect(menu.className).toContain("placed");
+ expect(placedAtItemFocus).toBe(true);
+ expect(focus.mock.calls.some((call) => call[0]?.preventScroll === true)).toBe(true);
+ } finally {
+ focus.mockRestore();
+ }
+ });
+
it("stays open after a pointer press in a flex toolbar and parent rerender", () => {
function Harness({ label }: { label: string }) {
return (
diff --git a/apps/desktop/src/components/layout/OverflowMenu.tsx b/apps/desktop/src/components/layout/OverflowMenu.tsx
index ee5800f..3e2336b 100644
--- a/apps/desktop/src/components/layout/OverflowMenu.tsx
+++ b/apps/desktop/src/components/layout/OverflowMenu.tsx
@@ -32,6 +32,10 @@ function styleCreatesFixedContainingBlock(style: CSSStyleDeclaration): boolean {
return style.willChange.split(",").some((value) => FIXED_CB_WILL_CHANGE.has(value.trim()));
}
+function focusMenuEl(el: HTMLElement | undefined | null): void {
+ el?.focus({ preventScroll: true });
+}
+
/** Viewport-space origin of the containing block for `position: fixed` (window if none). */
export function fixedContainingOrigin(
el: Element | null,
@@ -166,13 +170,14 @@ export function OverflowMenu(props: {
const popSize = useRef<{ width: number; height: number } | null>(null);
const typeahead = useRef({ query: "", at: 0 });
const pendingFocus = useRef<"first" | "last" | "active">("active");
+ const focusedOpen = useRef(false);
const [open, setOpen] = useState(false);
const [place, setPlace] = useState<{ up: boolean; top: number; left: number } | null>(null);
const align = props.align === "left" ? "left" : props.align === "after" ? "after" : "right";
const focusTrigger = useCallback(() => {
const summary = root.current?.querySelector(":scope > summary");
- if (summary instanceof HTMLElement) summary.focus();
+ if (summary instanceof HTMLElement) focusMenuEl(summary);
}, []);
const close = useCallback(() => {
@@ -191,7 +196,7 @@ export function OverflowMenu(props: {
}
const focusItem = useCallback((index: number) => {
- menuItemButtons(pop.current)[index]?.focus();
+ focusMenuEl(menuItemButtons(pop.current)[index]);
}, []);
function openMenu(focus: "first" | "last" | "active" = "active") {
@@ -213,20 +218,6 @@ export function OverflowMenu(props: {
return () => summary.removeEventListener("click", suppressNativeToggle, true);
}, []);
- useLayoutEffect(() => {
- if (!open) return;
- const list = items();
- if (!list.length) return;
- const active = list.findIndex(
- (el) => el.getAttribute("aria-current") === "true" || el.classList.contains("active"),
- );
- const want = pendingFocus.current;
- const index =
- want === "last" ? list.length - 1 : want === "first" ? 0 : active >= 0 ? active : 0;
- pendingFocus.current = "active";
- list[index]?.focus();
- }, [open]);
-
useEffect(() => {
if (!open) return;
function onPointer(event: PointerEvent) {
@@ -334,6 +325,25 @@ export function OverflowMenu(props: {
};
}, [open, align, props.prefer, props.wide, props.children]);
+ useLayoutEffect(() => {
+ if (!open) {
+ focusedOpen.current = false;
+ return;
+ }
+ if (!place || focusedOpen.current) return;
+ const list = items();
+ if (!list.length) return;
+ const active = list.findIndex(
+ (el) => el.getAttribute("aria-current") === "true" || el.classList.contains("active"),
+ );
+ const want = pendingFocus.current;
+ const index =
+ want === "last" ? list.length - 1 : want === "first" ? 0 : active >= 0 ? active : 0;
+ pendingFocus.current = "active";
+ focusedOpen.current = true;
+ focusMenuEl(list[index]);
+ }, [open, place]);
+
const popStyle: CSSProperties | undefined = place
? { top: place.top, left: place.left }
: undefined;
@@ -342,7 +352,7 @@ export function OverflowMenu(props: {
void;
+} = {}) {
const { state, dispatch } = useStore();
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(startOpen);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
@@ -566,7 +572,14 @@ export function SessionSwitcher() {
let lastGroup = "";
return (
- setOpen(false)}>
+ {
+ setOpen(false);
+ onClosed?.();
+ }}
+ >
({
rotateChromeExtension: vi.fn(),
checkUpdates: vi.fn(async () => ({
status: "unavailable",
- current: "0.17.0",
+ current: "0.18.0",
message: "No GitHub release found yet. Check for updates works after a v* tag is published.",
})),
refreshCursorCli: vi.fn(async () => ({ changed: false, models: [] })),
diff --git a/apps/desktop/src/components/settings/Settings.tsx b/apps/desktop/src/components/settings/Settings.tsx
index 37d0fa3..a154ac8 100644
--- a/apps/desktop/src/components/settings/Settings.tsx
+++ b/apps/desktop/src/components/settings/Settings.tsx
@@ -188,9 +188,14 @@ export function Settings() {
notes?: string;
htmlUrl?: string;
asset?: { name: string; url: string };
+ checksums?: { name: string; url: string };
message?: string;
} | null>(null);
const [updateBusy, setUpdateBusy] = useState(false);
+ const [updateDownload, setUpdateDownload] = useState<{
+ path: string;
+ sha256: string;
+ } | null>(null);
const [find, setFind] = useState("");
useEffect(() => {
const pending = takePendingSettingsQuery();
@@ -959,6 +964,7 @@ export function Settings() {
try {
const result = await api.checkUpdates(APP_VERSION);
setUpdate(result);
+ setUpdateDownload(null);
} catch (err) {
setUpdate({
status: "unavailable",
@@ -2754,6 +2760,35 @@ export function Settings() {
Download {update.asset.name}
) : null}
+ {update.asset && update.checksums ? (
+ {
+ void (async () => {
+ setUpdateBusy(true);
+ try {
+ const result = await api.downloadUpdate(
+ update.asset!,
+ update.checksums!,
+ );
+ setUpdateDownload(result);
+ } catch (err) {
+ setUpdate({
+ ...update,
+ message: err instanceof Error ? err.message : "Download failed",
+ });
+ } finally {
+ setUpdateBusy(false);
+ }
+ })();
+ }}
+ >
+ Download and verify
+
+ ) : null}
{update.htmlUrl ? (
Open release notes
@@ -2761,6 +2796,18 @@ export function Settings() {
) : null}
) : null}
+ {updateDownload ? (
+
+ Verified {updateDownload.sha256.slice(0, 12)}… Saved to {updateDownload.path}.
+ void api.openUpdate(updateDownload.path)}
+ >
+ Open installer
+
+
+ ) : null}
Privacy: provider keys and forge tokens stay in the encrypted vault and are injected
diff --git a/apps/desktop/src/components/settings/SettingsCards.tsx b/apps/desktop/src/components/settings/SettingsCards.tsx
index c632bd6..3ebdb89 100644
--- a/apps/desktop/src/components/settings/SettingsCards.tsx
+++ b/apps/desktop/src/components/settings/SettingsCards.tsx
@@ -47,6 +47,7 @@ import {
workerDestroyOnStop,
workerIsMetered,
workerKind,
+ workerProvisionsCompute,
type AgentSkill,
type AiProvider,
type GitForgeAccount,
@@ -1202,6 +1203,8 @@ export function parseEnvLines(text: string): Record {
export function workerKindLabel(kind: ReturnType): string {
if (kind === "fargate") return "Fargate";
+ if (kind === "azure") return "Azure";
+ if (kind === "gcp") return "GCP";
if (kind === "cloud") return "Cloud VM";
return "LAN";
}
@@ -1210,6 +1213,12 @@ export function workerWhereHint(row: WorkerBackend): string {
if (workerKind(row) === "fargate") {
return [row.region, row.cluster].filter(Boolean).join(" · ") || "AWS";
}
+ if (workerKind(row) === "azure") {
+ return [row.region, row.resourceGroup].filter(Boolean).join(" · ") || "Azure";
+ }
+ if (workerKind(row) === "gcp") {
+ return [row.zone, row.project].filter(Boolean).join(" · ") || "GCP";
+ }
try {
return new URL(row.url).host;
} catch {
@@ -1471,7 +1480,7 @@ export function WorkerCard({
Name
onPatch({ name: e.target.value })} />
- {kind === "fargate" ? null : (
+ {workerProvisionsCompute(worker) ? null : (
URL
Fargate
+
+ onPatch((row) => {
+ const next = { ...row, kind: "azure" as const };
+ if (next.idleTtlMs === 0) delete next.idleTtlMs;
+ if (next.destroyOnStop === false) delete next.destroyOnStop;
+ if (!next.allowedImages?.length) next.allowedImages = [DEFAULT_DOCKER_IMAGE];
+ if (!next.allowedSizes?.length) next.allowedSizes = [DEFAULT_FARGATE_SIZE];
+ return next;
+ })
+ }
+ >
+ Azure
+
+
+ onPatch((row) => {
+ const next = { ...row, kind: "gcp" as const };
+ if (next.idleTtlMs === 0) delete next.idleTtlMs;
+ if (next.destroyOnStop === false) delete next.destroyOnStop;
+ if (!next.allowedImages?.length) next.allowedImages = [DEFAULT_DOCKER_IMAGE];
+ if (!next.allowedSizes?.length) next.allowedSizes = [DEFAULT_FARGATE_SIZE];
+ return next;
+ })
+ }
+ >
+ GCP
+
- {kind === "fargate" ? "Secret access key" : "Token"}
+
+ {kind === "fargate"
+ ? "Secret access key"
+ : kind === "azure"
+ ? "Client secret"
+ : kind === "gcp"
+ ? "Access token"
+ : "Token"}
+
onPatch({ token: e.target.value })}
/>
- {kind === "fargate" ? (
+ {kind === "azure" ? (
+ <>
+
+
+
+ >
+ ) : kind === "gcp" ? (
+ <>
+
+
+ Instance template
+
+ onPatch({ instanceTemplate: e.target.value.trim() || undefined })
+ }
+ />
+
+ >
+ ) : kind === "fargate" ? (
<>
Access key ID
@@ -1725,7 +1878,7 @@ export function WorkerCard({
/>
- {kind === "fargate" ? null : (
+ {workerProvisionsCompute(worker) ? null : (
Hourly USD
- {kind === "fargate" ? (
+ {workerProvisionsCompute(worker) ? (
diff --git a/apps/desktop/src/components/workspace/WorkspaceEditor.test.tsx b/apps/desktop/src/components/workspace/WorkspaceEditor.test.tsx
index 3237217..3640e91 100644
--- a/apps/desktop/src/components/workspace/WorkspaceEditor.test.tsx
+++ b/apps/desktop/src/components/workspace/WorkspaceEditor.test.tsx
@@ -3948,7 +3948,7 @@ describe("WorkspaceEditor", () => {
containers: [
{
id: "c1",
- name: "siq8-dev_webpack.abc",
+ name: "app-dev_webpack.abc",
image: "node:22",
state: "running",
status: "Up 2 hours",
@@ -3967,7 +3967,7 @@ describe("WorkspaceEditor", () => {
containers: [
{
id: "c1",
- name: "siq8-dev_webpack.abc",
+ name: "app-dev_webpack.abc",
image: "node:22",
state: "running",
status: "Up 2 hours",
@@ -3985,12 +3985,12 @@ describe("WorkspaceEditor", () => {
);
await flush();
expect(host.querySelector('[data-testid="docker-explorer"]')).toBeTruthy();
- expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("siq8-dev_webpack.abc");
+ expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("app-dev_webpack.abc");
await act(async () => {
(host.querySelector(".docker-explorer-open") as HTMLButtonElement).click();
});
expect(host.querySelector(".workspace-docker-inspect")?.textContent).toMatch(
- /siq8-dev_webpack\.abc/,
+ /app-dev_webpack\.abc/,
);
expect(host.querySelector(".workspace-docker-inspect-meta")?.textContent).toMatch(
/abc123def456/,
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css
index fc27ee4..e9d3e7d 100644
--- a/apps/desktop/src/index.css
+++ b/apps/desktop/src/index.css
@@ -4429,22 +4429,31 @@ button.patch-path-open:hover {
.composer-head .subagent-select {
max-width: 118px;
min-width: 0;
+}
+.composer-head .ui-select.compact.subagent-select .ui-select-toggle {
+ min-height: 0;
+ padding: var(--space-0-5) var(--space-2);
background: var(--bg-2);
- border: 1px solid var(--line);
+ border-color: var(--line);
border-radius: var(--radius-md);
- padding: 5px 22px 5px var(--space-2-5);
font-size: var(--type-sm);
color: var(--text);
}
+.composer-head .ui-select.compact.subagent-select .ui-select-toggle:hover {
+ background: var(--bg-3);
+}
.model-picker-more {
flex: 0 0 auto;
}
.model-picker-more .menu-pop {
min-width: 168px;
}
+.model-picker-combined .menu-pop:not(.placed),
.model-picker-more .menu-pop:not(.placed) {
top: auto;
bottom: calc(100% + 6px);
+}
+.model-picker-more .menu-pop:not(.placed) {
right: 0;
}
.composer-toolbar-end {
@@ -14858,6 +14867,10 @@ button.pill {
top: auto;
bottom: calc(100% + 6px);
}
+/* Hide the CSS fallback until JS pins `top`/`left` so the first paint is aligned. */
+.menu-pop:not(.placed) {
+ visibility: hidden;
+}
/* Beat `.menu.right .menu-pop` (0,3,0) so a placed pop is not stretched
between inline `left` and `right: 0` on chat scroll. */
.menu .menu-pop.placed {
@@ -22929,4015 +22942,3 @@ img.slack-emoji {
.slack-thread-list {
padding-top: var(--space-2);
}
-
-/* Phone remote shell — pairing URL and ?shell=mobile */
-html[data-shell="mobile"],
-html[data-shell="mobile"] body,
-html[data-shell="mobile"] #root {
- width: 100%;
- max-width: 100%;
- height: 100%;
- max-height: 100%;
- overflow: hidden;
- overflow-x: clip;
-}
-.mobile-shell {
- position: fixed;
- top: var(--mobile-vt, 0px);
- left: 0;
- width: 100%;
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: auto minmax(0, 1fr) auto;
- height: var(--mobile-vh, 100dvh);
- max-height: var(--mobile-vh, 100dvh);
- min-width: 0;
- min-height: 0;
- overflow: hidden;
- background: var(--bg);
- color: var(--text);
- font-family: var(--font);
-}
-.mobile-shell.is-chat {
- grid-template-rows: minmax(0, 1fr);
-}
-.mobile-screen {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: auto minmax(0, 1fr) auto auto;
- width: 100%;
- height: 100%;
- max-height: 100%;
- min-width: 0;
- max-width: 100%;
- min-height: 0;
- overflow: hidden;
- background: var(--bg);
-}
-.mobile-top {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: var(--space-2);
- padding: calc(10px + env(safe-area-inset-top)) 16px 8px;
- border-bottom: 0;
- background: var(--bg);
- z-index: 2;
-}
-.mobile-top-row {
- display: flex;
- align-items: flex-start;
- gap: var(--space-2-5);
-}
-.mobile-top-copy {
- flex: 1;
- min-width: 0;
-}
-.mobile-brand {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- margin: 0 0 var(--space-1-5);
- color: color-mix(in srgb, var(--text) 72%, var(--muted));
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
- letter-spacing: -0.01em;
-}
-.mobile-brand svg {
- flex: none;
-}
-.mobile-top h1 {
- margin: 0;
- font-size: 2rem;
- font-weight: var(--weight-bold);
- letter-spacing: -0.045em;
- line-height: 1.05;
-}
-.mobile-top.is-thread {
- flex-direction: row;
- align-items: center;
- padding-bottom: var(--space-2-5);
- border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
-}
-.mobile-top.is-thread h1 {
- font-size: 1.02rem;
- font-weight: var(--weight-semibold);
-}
-.mobile-top-title {
- flex: 1;
- min-width: 0;
- text-align: center;
-}
-.mobile-top-title.is-start {
- text-align: left;
-}
-.mobile-top-title h1 {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.mobile-top-meta {
- margin: var(--space-1) 0 0;
- color: var(--muted);
- font-family: var(--mono);
- font-size: var(--type-xs);
- font-weight: var(--weight-medium);
- letter-spacing: 0.01em;
-}
-.mobile-top-meta.is-live {
- color: var(--accent);
-}
-.mobile-slack-channel {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-1-5);
- color: var(--accent);
-}
-.mobile-slack-channel svg {
- flex: none;
-}
-.mobile-top-actions {
- display: flex;
- align-items: center;
- gap: var(--space-1);
- flex: none;
-}
-.mobile-icon-btn.on {
- color: var(--accent);
- background: color-mix(in srgb, var(--accent) 16%, transparent);
-}
-.mobile-top-spacer {
- width: 64px;
- height: 40px;
- flex: none;
- visibility: hidden;
- pointer-events: none;
-}
-.mobile-screen.has-tabs {
- grid-template-rows: auto auto minmax(0, 1fr) auto auto;
-}
-.mobile-screen.has-tabs .mobile-top {
- border-bottom: 0;
- padding-bottom: var(--space-1);
-}
-.mobile-chat-tabs {
- display: flex;
- gap: var(--space-1);
- padding: 0 var(--space-3-5) var(--space-2-5);
- background: color-mix(in srgb, var(--bg-1) 82%, transparent);
- border-bottom: 1px solid var(--line);
-}
-.mobile-chat-tab {
- margin: 0;
- padding: var(--space-1-5) var(--space-3-5);
- border: 0;
- border-radius: var(--radius-pill);
- background: transparent;
- color: var(--muted);
- font: inherit;
- font-size: var(--type-body);
- font-weight: var(--weight-semibold);
-}
-.mobile-chat-tab[aria-selected="true"] {
- background: var(--bg-2);
- color: var(--text);
-}
-.mobile-files {
- display: flex;
- flex-direction: column;
- gap: var(--space-2-5);
- min-height: 0;
- padding: 0 var(--space-3-5) var(--space-3);
-}
-.mobile-file-tree {
- list-style: none;
- margin: 0;
- padding: 0;
- max-height: 36%;
- overflow: auto;
- border: 1px solid var(--line);
- border-radius: var(--radius);
-}
-.mobile-file-tree button {
- display: block;
- width: 100%;
- margin: 0;
- padding: var(--space-2) var(--space-2-5);
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- font-size: var(--type-md);
- text-align: left;
-}
-.mobile-file-tree button.active {
- background: var(--bg-2);
- color: var(--accent);
-}
-.mobile-file-body {
- margin: 0;
- padding: var(--space-2-5);
- overflow: auto;
- min-height: 0;
- flex: 1;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
- font-size: var(--type-sm);
- background: var(--bg-2);
- border-radius: var(--radius);
-}
-.mobile-screen.is-browser {
- grid-template-rows: auto minmax(0, 1fr);
-}
-.mobile-screen.is-canvas {
- grid-template-rows: auto auto minmax(0, 1fr) auto;
-}
-.mobile-canvas-toolbar {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--space-2);
- padding: 0 var(--space-3) var(--space-2-5);
- background: color-mix(in srgb, var(--bg-1) 82%, transparent);
- border-bottom: 1px solid var(--line);
-}
-.mobile-promote-scaffold {
- flex: 1 1 8rem;
- min-width: 0;
- min-height: 36px;
- padding: 0 var(--space-2-5);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-2);
- color: var(--text);
- font: inherit;
- font-size: var(--type-md);
-}
-.mobile-inbox-batch {
- display: grid;
- gap: var(--space-2-5);
- padding: var(--space-3-5) var(--space-3-5) 0;
-}
-.mobile-inbox-batch .hint {
- margin: 0;
-}
-.mobile-canvas-frame {
- min-height: 0;
- width: 100%;
- height: 100%;
- border: 0;
- background: #fff;
-}
-.mobile-row.is-design .design-thumb {
- grid-area: mark;
- flex: none;
- width: 56px;
- height: 36px;
- border-radius: var(--radius-md);
-}
-.mobile-row.is-design .design-thumb iframe {
- transform: scale(0.636);
- transform-origin: 0 0;
-}
-.mobile-list li:not(:last-child) .mobile-row.is-design::after {
- left: 84px;
-}
-.mobile-pr {
- display: grid;
- gap: var(--space-3);
- align-content: start;
- padding: var(--space-4) var(--space-4) var(--space-8);
-}
-.mobile-pr-title {
- margin: 0;
- font-size: 1.15rem;
- font-weight: var(--weight-semibold);
- letter-spacing: -0.03em;
- line-height: 1.25;
-}
-.mobile-pr-meta {
- margin: 0;
- color: var(--muted);
- font-size: var(--type-md);
-}
-.mobile-pr-heading {
- margin: var(--space-2) 0 0;
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.02em;
- color: var(--faint);
- text-transform: uppercase;
-}
-.mobile-pr-body {
- line-height: 1.5;
- overflow-wrap: anywhere;
-}
-.mobile-pr-body p {
- margin: 0 0 0.55em;
-}
-.mobile-pr-comments {
- list-style: none;
- margin: 0;
- padding: 0;
- display: grid;
- gap: var(--space-3);
-}
-.mobile-pr-comment {
- padding: var(--space-3) var(--space-3-5);
- border-radius: var(--radius-xl);
- background: var(--bg-2);
- border: 1px solid var(--line);
-}
-.mobile-pr-write,
-.mobile-canvas-comment {
- display: grid;
- gap: var(--space-2);
-}
-.mobile-pr-write textarea,
-.mobile-canvas-comment textarea {
- width: 100%;
- resize: none;
- min-height: 40px;
- padding: var(--space-2-5) var(--space-3);
- border-radius: var(--radius-pill);
- border: 1px solid var(--line);
- background: var(--bg-2);
- color: var(--text);
- font: inherit;
-}
-.mobile-canvas-comment {
- padding: 10px 16px calc(12px + env(safe-area-inset-bottom));
- border-top: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
- background: var(--bg);
- display: grid;
- gap: var(--space-2);
-}
-.mobile-canvas-compose {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: var(--space-2);
- align-items: end;
-}
-.mobile-canvas-live {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- margin: 0 var(--space-4) var(--space-2);
- padding: var(--space-2-5) var(--space-3);
- border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
- border-radius: var(--radius-lg);
- color: var(--accent);
- font-size: var(--type-md);
-}
-.mobile-canvas-live .mobile-status-dot {
- margin: 0;
-}
-.mobile-canvas-log {
- list-style: none;
- margin: 0;
- padding: 0 var(--space-4) var(--space-2-5);
- display: grid;
- gap: var(--space-2-5);
-}
-.mobile-canvas-log li {
- display: grid;
- grid-template-columns: 28px minmax(0, 1fr);
- gap: var(--space-2);
- align-items: start;
- color: var(--text);
- font-size: var(--type-md);
-}
-.mobile-canvas-log li.is-agent {
- color: var(--muted);
-}
-.mobile-inbox {
- width: 100%;
- min-width: 0;
-}
-.mobile-inbox-section {
- margin-top: var(--space-4);
-}
-.mobile-inbox-section .mobile-pr-heading {
- padding: 0 var(--space-4);
-}
-.mobile-browser {
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- background: var(--bg-1);
-}
-.mobile-browser .browser-pane {
- flex: 1;
- min-height: 0;
- border: 0;
- border-radius: 0;
- background: var(--bg);
-}
-.mobile-browser .browser-stage {
- min-height: 0;
-}
-.mobile-browser .browser-chrome {
- border-radius: 0;
-}
-.mobile-thread[hidden],
-.mobile-pending[hidden],
-.mobile-composer[hidden],
-.mobile-chat-tabs[hidden] {
- display: none !important;
-}
-.mobile-kicker {
- margin: 0 0 var(--space-0-5);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.04em;
- color: var(--muted);
-}
-.mobile-icon-btn {
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-1-5);
- min-height: 36px;
- min-width: 36px;
- padding: 0 var(--space-2-5);
- border: 0;
- border-radius: 50%;
- background: var(--bg-2);
- color: var(--text);
- font: inherit;
- font-size: var(--type-lg);
- font-weight: var(--weight-semibold);
-}
-.mobile-icon-btn:active {
- background: var(--hover);
-}
-.mobile-search {
- width: 36px;
- height: 36px;
- padding: 0;
- color: var(--muted);
- background: color-mix(in srgb, var(--text) 8%, transparent);
-}
-.mobile-search svg {
- width: 16px;
- height: 16px;
-}
-.mobile-back {
- color: var(--accent);
- padding: 0 var(--space-2) 0 var(--space-0-5);
- background: transparent;
- border-radius: var(--radius-pill);
-}
-.mobile-back::before {
- content: "";
- width: 8px;
- height: 8px;
- margin-right: var(--space-0-5);
- border-left: 2px solid currentColor;
- border-bottom: 2px solid currentColor;
- transform: rotate(45deg);
- flex: none;
-}
-.mobile-new {
- width: 40px;
- height: 40px;
- padding: 0;
- background: var(--accent);
- color: var(--accent-ink, #111);
- box-shadow: none;
-}
-.mobile-new svg {
- width: 16px;
- height: 16px;
- stroke-width: 2.4;
-}
-.mobile-search-bar {
- padding: 0;
-}
-.mobile-search-input {
- width: 100%;
- min-height: 40px;
- padding: var(--space-2) var(--space-3-5);
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- background: var(--bg-2);
- color: var(--text);
- font: inherit;
- font-size: var(--type-lg);
-}
-.mobile-stop {
- color: var(--danger);
- font-size: var(--type-md);
-}
-.mobile-sr {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-.mobile-body {
- min-height: 0;
- overflow: auto;
- overflow-x: hidden;
- overscroll-behavior: contain;
- -webkit-overflow-scrolling: touch;
- touch-action: pan-y;
-}
-.mobile-list {
- list-style: none;
- margin: 0;
- padding: 0 0 var(--space-3);
-}
-.mobile-day h2 {
- margin: var(--space-3-5) var(--space-4) var(--space-0-5);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--faint);
-}
-.mobile-chips {
- display: flex;
- gap: var(--space-2);
- padding: var(--space-1) var(--space-4) var(--space-2);
- overflow-x: auto;
- scrollbar-width: none;
-}
-.mobile-chips::-webkit-scrollbar {
- display: none;
-}
-.mobile-chip {
- flex: none;
- margin: 0;
- padding: 7px var(--space-3);
- border: 0;
- border-radius: var(--radius-pill);
- background: color-mix(in srgb, var(--text) 8%, transparent);
- color: var(--text);
- font: inherit;
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
- white-space: nowrap;
-}
-.mobile-chip.on {
- background: var(--accent);
- color: var(--accent-ink, #111);
-}
-.mobile-chip-count {
- margin-left: var(--space-1-5);
- color: var(--danger);
- font-variant-numeric: tabular-nums;
-}
-.mobile-chip.on .mobile-chip-count {
- color: #c62828;
-}
-.mobile-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- grid-template-areas: "main";
- gap: var(--space-2-5);
- align-items: start;
- width: 100%;
- text-align: left;
- padding: 9px var(--space-4);
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- min-height: 56px;
- position: relative;
-}
-.mobile-row:has(.mobile-status-dot),
-.mobile-row:has(.mobile-avatar),
-.mobile-row.is-design {
- grid-template-columns: auto minmax(0, 1fr) auto;
- grid-template-areas: "mark main extra";
-}
-.mobile-row:active {
- background: var(--hover);
-}
-.mobile-list li:not(:last-child) .mobile-row::after {
- content: "";
- position: absolute;
- left: 16px;
- right: 16px;
- bottom: 0;
- height: 1px;
- background: color-mix(in srgb, var(--text) 10%, transparent);
-}
-.mobile-list li:not(:last-child) .mobile-row:has(.mobile-status-dot)::after,
-.mobile-list li:not(:last-child) .mobile-row:has(.mobile-avatar)::after {
- left: 42px;
-}
-.mobile-status-dot {
- grid-area: mark;
- width: 10px;
- height: 10px;
- margin-top: var(--space-1-5);
- border-radius: var(--radius-pill);
- background: var(--accent);
- flex: none;
-}
-.mobile-status-dot.is-needs {
- background: #ff5a3d;
-}
-.mobile-status-dot.is-ok {
- background: #3dce6a;
-}
-.mobile-status-dot.is-idle {
- background: transparent;
- box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text) 28%, transparent);
-}
-.mobile-status-dot.is-running {
- background: var(--accent);
-}
-.mobile-avatar {
- grid-area: mark;
- width: 32px;
- height: 32px;
- border-radius: var(--radius-md);
- display: grid;
- place-items: center;
- font-size: var(--type-sm);
- font-weight: var(--weight-bold);
- letter-spacing: -0.02em;
- color: var(--text);
- background: var(--bg-2);
-}
-.mobile-row-main {
- grid-area: main;
- min-width: 0;
- display: grid;
- gap: var(--space-0-5);
-}
-.mobile-row-head {
- display: flex;
- align-items: baseline;
- gap: var(--space-2-5);
- min-width: 0;
-}
-.mobile-row-title {
- display: flex;
- align-items: center;
- gap: var(--space-1-5);
- min-width: 0;
- font-weight: var(--weight-semibold);
- letter-spacing: -0.02em;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.mobile-lock {
- display: inline-flex;
- color: var(--accent);
- flex: none;
-}
-.mobile-row-sub {
- color: var(--muted);
- font-size: var(--type-md);
- line-height: 1.35;
- overflow: hidden;
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
- overflow-wrap: break-word;
- word-break: normal;
-}
-.mobile-row-mono {
- color: var(--faint);
- font-family: var(--mono);
- font-size: var(--type-xs);
- font-weight: var(--weight-medium);
- letter-spacing: 0.01em;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.mobile-row-mono.is-wait {
- color: #ff5a3d;
-}
-.mobile-row-mono.is-live {
- color: var(--accent);
-}
-.mobile-row-meta {
- grid-area: extra;
- display: grid;
- justify-items: end;
- align-content: start;
- gap: var(--space-1-5);
-}
-.mobile-row-chevron {
- grid-area: extra;
- color: var(--faint);
- margin-top: var(--space-1);
-}
-.mobile-row-time {
- margin-left: auto;
- color: var(--faint);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- font-variant-numeric: tabular-nums;
- flex: none;
-}
-.mobile-row-time.is-now {
- color: var(--accent);
-}
-.mobile-row-time.is-wait {
- color: #ff5a3d;
-}
-.mobile-pill {
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
- padding: 3px var(--space-2);
- border-radius: var(--radius-pill);
- background: color-mix(in srgb, var(--text) 6%, transparent);
- color: var(--muted);
-}
-.mobile-pill.warn {
- color: var(--warn);
- background: color-mix(in srgb, var(--warn) 16%, transparent);
-}
-.mobile-dot {
- width: 7px;
- height: 7px;
- border-radius: var(--radius-pill);
- background: var(--accent);
- box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 70%, transparent);
- flex: none;
-}
-.mobile-empty {
- display: grid;
- justify-items: center;
- text-align: center;
- gap: var(--space-2);
- padding: 64px var(--space-7) var(--space-10);
-}
-.mobile-empty h2 {
- margin: var(--space-2) 0 0;
- font-size: 1.25rem;
- letter-spacing: -0.03em;
-}
-.mobile-empty p,
-.mobile-compose .hint,
-.mobile-more .hint {
- margin: 0;
- color: var(--muted);
- line-height: 1.5;
- max-width: 22rem;
-}
-.mobile-cards {
- list-style: none;
- margin: 0;
- padding: var(--space-3-5) var(--space-3-5) var(--space-7);
- display: grid;
- gap: var(--space-3);
-}
-.mobile-card {
- padding: var(--space-4);
- border: 1px solid var(--line);
- border-radius: var(--radius-xl);
- background: color-mix(in srgb, var(--bg-1) 88%, transparent);
- box-shadow: var(--elev-2);
-}
-.mobile-card.tight {
- padding: var(--space-3) var(--space-3-5);
-}
-.mobile-card h2 {
- margin: var(--space-1) 0 var(--space-2-5);
- font-size: 1.05rem;
- letter-spacing: -0.02em;
-}
-.mobile-pre {
- margin: 0 0 var(--space-3);
- max-height: 8rem;
- overflow: auto;
- padding: var(--space-2-5) var(--space-3);
- border-radius: var(--radius-lg);
- background: var(--bg);
- border: 1px solid var(--line);
- font-size: var(--type-sm);
- white-space: pre-wrap;
- word-break: break-word;
-}
-.mobile-actions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-.mobile-actions .btn,
-.mobile-composer .btn,
-.mobile-more .btn {
- min-height: 44px;
- padding: 0 var(--space-4);
- border-radius: var(--radius-pill);
-}
-.mobile-tabbar {
- display: grid;
- grid-template-columns: repeat(6, minmax(0, 1fr));
- border-top: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
- background: var(--bg);
- padding: 6px 4px calc(8px + env(safe-area-inset-bottom));
- z-index: 2;
-}
-.mobile-tab {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 3px;
- min-height: 48px;
- padding: var(--space-1) 1px var(--space-0-5);
- border: 0;
- background: transparent;
- color: var(--faint);
- font: inherit;
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0;
-}
-.mobile-tab.on {
- color: var(--accent);
-}
-.mobile-tab-icon {
- position: relative;
- display: grid;
- place-items: center;
- width: 28px;
- height: 22px;
-}
-.mobile-tab.on .mobile-tab-icon {
- background: transparent;
- color: var(--accent);
-}
-.mobile-tab-icon svg {
- width: 18px;
- height: 18px;
-}
-.mobile-tab-label {
- line-height: 1.15;
-}
-.mobile-tab-dot {
- position: absolute;
- top: -2px;
- right: 0;
- width: 7px;
- height: 7px;
- border-radius: var(--radius-pill);
- background: #ff5a3d;
- box-shadow: 0 0 0 2px var(--bg);
-}
-.mobile-badge {
- position: absolute;
- top: -2px;
- right: 4px;
- min-width: 16px;
- height: 16px;
- padding: 0 var(--space-1);
- border-radius: var(--radius-pill);
- background: var(--danger);
- color: #fff;
- font-size: var(--type-2xs);
- line-height: 16px;
- text-align: center;
- box-shadow: 0 0 0 2px var(--bg);
-}
-.mobile-more,
-.mobile-compose {
- display: grid;
- gap: 18px;
- padding: var(--space-2) var(--space-4) var(--space-8);
- align-content: start;
-}
-.mobile-link-row {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: var(--space-2-5);
- align-items: start;
- padding: var(--space-1) 0 var(--space-3);
- border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
-}
-.mobile-link-copy {
- min-width: 0;
-}
-.mobile-text-btn {
- border: 0;
- padding: 0;
- background: transparent;
- color: var(--muted);
- font: inherit;
- font-size: var(--type-body);
- font-weight: var(--weight-semibold);
-}
-.mobile-signout {
- color: var(--text);
- justify-self: start;
- margin-top: var(--space-2);
-}
-.mobile-grafana {
- display: flex;
- flex-direction: column;
- min-height: 0;
- flex: 1;
-}
-.mobile-grafana .grafana-pane {
- min-height: 60vh;
-}
-.mobile-settings-block,
-.mobile-cap-block {
- display: grid;
- gap: var(--space-0-5);
-}
-.mobile-settings-block h2,
-.mobile-cap-block h2 {
- margin: var(--space-2) 0 var(--space-2);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--faint);
-}
-.mobile-setting-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-3);
- min-height: 44px;
- color: var(--text);
- font-size: var(--type-lg);
-}
-.mobile-setting-value {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1-5);
- color: var(--muted);
- font-size: var(--type-body);
-}
-.mobile-setting-value select {
- appearance: none;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- text-align: right;
- padding: 0;
-}
-.mobile-seg {
- display: inline-flex;
- padding: var(--space-0-5);
- border-radius: var(--radius-pill);
- background: color-mix(in srgb, var(--text) 8%, transparent);
-}
-.mobile-seg button {
- margin: 0;
- min-height: 28px;
- padding: 0 var(--space-3);
- border: 0;
- border-radius: var(--radius-pill);
- background: transparent;
- color: var(--muted);
- font: inherit;
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
-}
-.mobile-seg button.on {
- background: var(--accent);
- color: var(--accent-ink, #111);
-}
-.mobile-tags {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-.mobile-tags li {
- padding: var(--space-1-5) var(--space-2-5);
- border: 1px solid color-mix(in srgb, var(--text) 28%, transparent);
- border-radius: var(--radius-pill);
- font-size: var(--type-sm);
- color: var(--text);
-}
-.mobile-tags.is-muted li {
- border-color: color-mix(in srgb, var(--text) 12%, transparent);
- color: var(--faint);
-}
-.mobile-version {
- margin: 0;
- color: var(--faint);
- font-family: var(--mono);
- font-size: var(--type-xs);
-}
-.mobile-board {
- display: flex;
- flex-direction: column;
- gap: var(--space-3);
- min-height: 0;
- flex: 1;
-}
-.mobile-board-head {
- display: flex;
- align-items: center;
- gap: var(--space-2-5);
-}
-.mobile-board-head h2 {
- flex: 1;
- margin: 0;
- font-size: var(--type-lg);
-}
-.mobile-board-zoom {
- display: flex;
- gap: var(--space-1);
-}
-.mobile-board-stage {
- position: relative;
- flex: 1;
- min-height: 280px;
- border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
- border-radius: var(--radius-lg);
- overflow: hidden;
- background:
- radial-gradient(
- circle at 1px 1px,
- color-mix(in srgb, var(--text) 10%, transparent) 1px,
- transparent 0
- )
- 0 0 / 18px 18px,
- var(--bg-1);
- touch-action: none;
-}
-.mobile-board-world {
- position: absolute;
- inset: 0;
- transform-origin: 0 0;
-}
-.mobile-board-obj {
- position: absolute;
- border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
- border-radius: var(--radius-md);
- background: var(--bg-2);
- color: var(--text);
- padding: var(--space-2);
- text-align: left;
- overflow: hidden;
-}
-.mobile-board-obj.sticky {
- background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
-}
-.mobile-board-obj.frame {
- background: color-mix(in srgb, var(--text) 4%, transparent);
- border-style: dashed;
-}
-.mobile-board-obj-label {
- display: block;
- font-size: var(--type-sm);
- line-height: 1.3;
- max-height: 100%;
- overflow: hidden;
-}
-.mobile-board-frames {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
-}
-.mobile-board-frame-row {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-.mobile-board-frame-chip {
- border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
- background: var(--bg-2);
- color: var(--text);
- border-radius: var(--radius-pill);
- padding: var(--space-1-5) var(--space-3);
- font-size: var(--type-sm);
-}
-.mobile-board-sheet {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
- padding: var(--space-3);
- border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
- border-radius: var(--radius-lg);
- background: var(--bg-1);
-}
-.mobile-board-sheet textarea {
- width: 100%;
- resize: vertical;
- min-height: 72px;
- border-radius: var(--radius-md);
- border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
- background: var(--bg);
- color: var(--text);
- padding: var(--space-2);
-}
-.mobile-board-sheet-actions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-.mobile-filter-empty {
- padding: var(--space-6) var(--space-4);
-}
-.mobile-status-card {
- display: flex;
- align-items: center;
- gap: var(--space-3);
-}
-.mobile-live {
- width: 10px;
- height: 10px;
- border-radius: var(--radius-pill);
- background: var(--faint);
- flex: none;
-}
-.mobile-live.on {
- background: var(--ok);
- box-shadow: 0 0 10px color-mix(in srgb, var(--ok) 55%, transparent);
-}
-.mobile-status {
- margin: 0;
- font-weight: var(--weight-semibold);
- letter-spacing: -0.02em;
-}
-.mobile-field {
- display: grid;
- gap: var(--space-2);
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
- color: var(--muted);
-}
-.mobile-field select,
-.mobile-input {
- width: 100%;
- font: inherit;
- font-size: var(--type-lg);
- color: var(--text);
- background: var(--bg-1);
- border: 1px solid var(--line);
- border-radius: var(--radius-xl);
- padding: var(--space-3) var(--space-3-5);
-}
-.mobile-field select {
- min-height: 46px;
- appearance: none;
- background-image:
- linear-gradient(45deg, transparent 50%, var(--muted) 50%),
- linear-gradient(135deg, var(--muted) 50%, transparent 50%);
- background-position:
- calc(100% - 18px) 50%,
- calc(100% - 12px) 50%;
- background-size: 6px 6px;
- background-repeat: no-repeat;
- padding-right: var(--space-8);
-}
-.mobile-thread {
- min-height: 0;
- min-width: 0;
- width: 100%;
- max-width: 100%;
- overflow: auto;
- overflow-x: hidden;
- overflow-anchor: none;
- overscroll-behavior: contain;
- -webkit-overflow-scrolling: touch;
- touch-action: pan-y;
- padding: var(--chat-mobile-thread-pad);
- display: flex;
- flex-direction: column;
- gap: var(--chat-mobile-thread-gap);
-}
-.mobile-thread .chat-new-divider {
- max-width: none;
- margin: var(--space-1-5) 0 var(--space-2);
-}
-.mobile-fit {
- display: flex;
- flex-direction: column;
- gap: inherit;
- min-width: 0;
- width: 100%;
- max-width: 100%;
- overflow-x: hidden;
- transform-origin: top left;
-}
-.mobile-msg {
- display: grid;
- gap: var(--chat-mobile-msg-gap);
- min-width: 0;
- width: 100%;
- max-width: 100%;
-}
-.mobile-msg.user {
- justify-items: stretch;
-}
-.mobile-bubble {
- padding: var(--chat-mobile-bubble-pad);
- border-radius: var(--radius-xl);
- background: var(--bg-2);
- border: 1px solid var(--line);
- font-size: var(--chat-mobile-bubble-font);
- line-height: var(--chat-mobile-bubble-line);
- min-width: 0;
- max-width: 100%;
- overflow-x: hidden;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.mobile-bubble > *,
-.mobile-bubble p,
-.mobile-bubble li,
-.mobile-bubble h1,
-.mobile-bubble h2,
-.mobile-bubble h3,
-.mobile-bubble h4,
-.mobile-bubble td,
-.mobile-bubble th,
-.mobile-bubble code {
- min-width: 0;
- max-width: 100%;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.mobile-bubble p {
- margin: 0 0 var(--chat-mobile-p-gap);
-}
-.mobile-bubble p:last-child {
- margin: 0;
-}
-.mobile-bubble h1,
-.mobile-bubble h2,
-.mobile-bubble h3,
-.mobile-bubble h4 {
- margin: var(--chat-mobile-h-margin);
- line-height: 1.25;
-}
-.mobile-bubble h1:first-child,
-.mobile-bubble h2:first-child,
-.mobile-bubble h3:first-child,
-.mobile-bubble h4:first-child {
- margin-top: 0;
-}
-.mobile-bubble ul,
-.mobile-bubble ol {
- margin: 0.4em 0;
- padding-left: 1.2em;
- max-width: 100%;
- white-space: normal;
-}
-.mobile-bubble .md-table {
- table-layout: fixed;
- width: 100%;
- max-width: 100%;
-}
-.mobile-bubble .md-table th,
-.mobile-bubble .md-table td {
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.mobile-bubble pre,
-.mobile-bubble .md-code {
- max-width: 100%;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- word-break: break-word;
- overflow-x: hidden;
-}
-.mobile-bubble img,
-.mobile-bubble .md-img {
- max-width: 100%;
- height: auto;
-}
-.mobile-msg.user .mobile-bubble {
- background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
- border-color: color-mix(in srgb, var(--accent) 22%, var(--line));
-}
-.mobile-msg .thinking-trace {
- margin: 0;
- max-width: 100%;
- padding: 0 0 0 var(--space-2-5);
- border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line));
- background: none;
-}
-.mobile-msg .thinking-trace > summary {
- min-height: 22px;
- align-items: center;
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- letter-spacing: 0;
- text-transform: none;
- color: var(--muted);
-}
-.mobile-msg .thinking-trace > summary::before {
- content: none;
-}
-.mobile-msg .thinking-trace-body {
- max-height: 9.5rem;
- margin: var(--space-0-5) 0 0;
- padding: 0;
- border: 0;
- font-family: var(--font);
- font-size: var(--type-md);
- font-style: italic;
- line-height: 1.45;
- color: color-mix(in srgb, var(--text) 78%, var(--muted));
-}
-.mobile-tool,
-.mobile-working {
- margin: 0;
- font-size: var(--type-sm);
- color: var(--muted);
- max-width: 100%;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.mobile-tool {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: var(--space-0-5);
- padding: 7px var(--space-2-5);
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--text) 5%, transparent);
- border: 0;
- border-left: 3px solid color-mix(in srgb, var(--accent-2) 50%, var(--line));
-}
-.mobile-tool-name {
- font-family: var(--font);
- font-size: var(--type-xs);
- font-weight: var(--weight-bold);
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--accent-2);
-}
-.mobile-tool-detail {
- font-family: var(--mono);
- font-size: var(--type-sm);
- line-height: 1.35;
- color: var(--muted);
- overflow-wrap: anywhere;
- word-break: break-word;
- white-space: normal;
-}
-.mobile-pr-card {
- display: block;
- padding: var(--space-2) var(--space-3);
- border-radius: var(--radius-lg);
- background: var(--bg-2);
- border: 1px solid var(--line);
- font-family: var(--mono);
- color: var(--accent-2);
- text-decoration: none;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.mobile-working {
- align-self: stretch;
- padding: 0 0 0 var(--space-2-5);
- border-radius: 0;
- background: none;
- border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line));
- font-family: var(--font);
- font-size: var(--type-md);
- font-style: italic;
- line-height: 1.45;
- color: color-mix(in srgb, var(--text) 78%, var(--muted));
-}
-.mobile-working-label {
- display: block;
- margin-bottom: var(--space-1);
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- font-style: normal;
- letter-spacing: 0;
- text-transform: none;
- color: var(--muted);
-}
-.mobile-thread .subagent-card {
- max-width: 100%;
-}
-.mobile-subagent-readonly {
- margin: 0;
- padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
- border-top: 1px solid var(--line);
- font-size: var(--type-md);
- color: var(--muted);
-}
-.mobile-shot {
- max-width: 100%;
- border-radius: var(--radius-xl);
-}
-.mobile-pending {
- padding: var(--space-2) var(--space-3) 0;
- border-top: 1px solid var(--line);
- background: var(--chrome);
- backdrop-filter: saturate(1.2) blur(12px);
- -webkit-backdrop-filter: saturate(1.2) blur(12px);
-}
-.mobile-pending:empty {
- display: none;
-}
-.mobile-composer {
- display: grid;
- grid-template-columns: 1fr auto;
- gap: var(--space-2);
- align-items: end;
- padding: var(--chat-mobile-composer-pad);
- padding-bottom: calc(var(--chat-mobile-composer-bottom) + env(safe-area-inset-bottom));
- border-top: 1px solid var(--line);
- background: var(--chrome);
- backdrop-filter: saturate(1.35) blur(18px);
- -webkit-backdrop-filter: saturate(1.35) blur(18px);
-}
-.mobile-composer.is-solo {
- grid-template-columns: 1fr;
-}
-.mobile-composer.is-thread {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: var(--space-2);
-}
-.mobile-composer .mobile-input {
- resize: none;
- min-height: 44px;
- max-height: 7.5rem;
- border-radius: 22px;
- padding: var(--chat-mobile-input-pad);
- background: var(--bg-2);
-}
-.mobile-composer.is-thread .mobile-input {
- min-height: var(--chat-mobile-input-min);
- border-radius: var(--radius-xl);
-}
-.mobile-composer-bar {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: end;
- gap: var(--space-2);
- min-width: 0;
-}
-.mobile-composer.is-slack {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: var(--space-3);
- padding-top: var(--space-3);
-}
-.mobile-composer.is-slack .mobile-send-as {
- flex: none;
- padding: 0 var(--space-1);
- border-top: 0;
- background: transparent;
-}
-.mobile-composer.is-slack .mobile-composer-bar {
- flex: none;
-}
-.mobile-model-pill {
- min-width: 0;
- margin-right: auto;
- padding: var(--space-1-5) var(--space-2-5);
- border-radius: var(--radius-pill);
- background: var(--bg-2);
- border: 1px solid var(--line);
- color: var(--muted);
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.mobile-send {
- min-width: 72px;
- border-radius: var(--radius-pill) !important;
- box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 24%, transparent);
-}
-.mobile-composer .mobile-icon-btn.mobile-send {
- min-width: 40px;
- width: 40px;
- height: 40px;
- padding: 0;
- border-radius: 50% !important;
- box-shadow: none;
-}
-.mobile-composer.is-thread .mobile-send {
- min-width: 40px;
- width: 40px;
- height: 40px;
- padding: 0;
- border-radius: 50% !important;
-}
-.mobile-composer.is-thread .mobile-send:disabled {
- opacity: 0.4;
- box-shadow: none;
-}
-.mobile-screen .patch-card,
-.mobile-screen .patch-file {
- max-width: 100%;
- min-width: 0;
-}
-.mobile-screen .patch-kind,
-.mobile-screen .patch-file-head .btn.icon,
-.mobile-screen .patch-ln {
- display: none;
-}
-.mobile-screen .patch-line {
- grid-template-columns: 1.4em minmax(0, 1fr);
- width: max-content;
- min-width: 100%;
- white-space: pre;
- overflow-wrap: normal;
- word-break: normal;
-}
-.mobile-screen .patch-text {
- min-width: max-content;
-}
-.mobile-screen .patch-hunk {
- overflow-x: auto;
-}
-.mobile-banner,
-.mobile-error {
- margin: 0;
- padding: var(--space-2) var(--space-4);
- background: color-mix(in srgb, var(--warn) 16%, transparent);
- color: var(--warn);
- font-size: var(--type-md);
- border-radius: var(--radius-lg);
-}
-.mobile-error {
- background: transparent;
- color: var(--danger);
- padding: 0;
-}
-.mobile-avatar img,
-.mobile-slack-face img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- border-radius: inherit;
-}
-.mobile-slack-hint {
- padding: var(--space-6) var(--space-4);
-}
-.mobile-slack-workspace {
- margin: 0;
- padding: var(--space-3-5) var(--space-4) 0;
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- color: var(--muted);
-}
-.mobile-slack-group h2 {
- margin: var(--space-4) var(--space-4) 0;
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--faint);
-}
-.mobile-slack-group .mobile-list {
- padding-top: var(--space-1);
-}
-.mobile-slack-msg {
- display: grid;
- grid-template-columns: 36px minmax(0, 1fr);
- gap: var(--space-2-5);
- max-width: 100%;
- align-items: start;
-}
-.mobile-slack-face {
- width: 28px;
- height: 28px;
- border-radius: var(--radius-md);
- display: grid;
- place-items: center;
- font-size: var(--type-sm);
- font-weight: var(--weight-bold);
- color: var(--text);
- background: var(--bg-2);
- overflow: hidden;
-}
-.mobile-slack-copy {
- min-width: 0;
- display: grid;
- gap: var(--space-1);
- overflow-wrap: anywhere;
-}
-.mobile-slack-copy .md-table {
- table-layout: fixed;
- width: 100%;
- max-width: 100%;
-}
-.mobile-slack-copy .md-table th,
-.mobile-slack-copy .md-table td {
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.mobile-slack-meta {
- display: flex;
- align-items: baseline;
- gap: var(--space-2);
- flex-wrap: wrap;
-}
-.mobile-slack-meta strong {
- font-size: var(--type-md);
- letter-spacing: -0.02em;
-}
-.mobile-slack-meta time {
- font-size: var(--type-xs);
- color: var(--faint);
-}
-.mobile-slack-copy .slack-md,
-.mobile-slack-copy .slack-media,
-.mobile-slack-copy .slack-htmls,
-.mobile-slack-copy .slack-pdfs,
-.mobile-slack-copy .slack-videos,
-.mobile-slack-copy .slack-csvs,
-.mobile-slack-copy .slack-files {
- max-width: 100%;
-}
-.mobile-slack-copy .slack-html,
-.mobile-slack-copy .slack-pdf,
-.mobile-slack-copy .slack-video,
-.mobile-slack-copy .slack-csv {
- max-width: 100%;
-}
-.mobile-slack-copy .slack-html-frame,
-.mobile-slack-copy .slack-pdf-frame,
-.mobile-slack-copy .slack-video-player {
- width: 100%;
- max-width: 100%;
-}
-.mobile-slack-copy .slack-img,
-.mobile-slack-copy .slack-gif {
- max-width: 100%;
- border-radius: var(--radius-lg);
-}
-.mobile-slack-reactions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1-5);
-}
-.mobile-slack-reaction {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1);
- min-height: 28px;
- padding: 0 var(--space-2);
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- background: var(--bg-2);
- color: inherit;
- font: inherit;
- font-size: var(--type-sm);
-}
-.mobile-slack-reaction.mine {
- border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
- background: color-mix(in srgb, var(--accent) 14%, transparent);
-}
-.mobile-slack-msg-actions {
- display: flex;
- gap: var(--space-3);
-}
-.mobile-slack-link,
-.mobile-slack-broadcast {
- border: 0;
- padding: 0;
- background: transparent;
- color: var(--accent);
- font: inherit;
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- text-align: left;
-}
-.mobile-send-as {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- min-height: 32px;
- padding: var(--space-2-5) var(--space-4) 0;
- border-top: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
- background: var(--bg);
- color: var(--faint);
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-.mobile-send-as-label {
- flex: none;
- color: var(--faint);
-}
-.mobile-send-as button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-1-5);
- margin: 0;
- flex: none;
- min-height: 32px;
- height: 32px;
- padding: 0 var(--space-3);
- border: 0;
- border-radius: var(--radius-pill);
- background: color-mix(in srgb, var(--text) 16%, var(--bg-2));
- color: var(--text);
- font: inherit;
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
- letter-spacing: 0;
- text-transform: none;
-}
-.mobile-send-as button .mascot {
- flex: none;
- width: 14px;
- height: 14px;
- color: currentColor;
-}
-.mobile-send-as button.on {
- background: var(--accent);
- color: var(--accent-ink, #111);
-}
-.mobile-slack-picker {
- padding: var(--space-2-5) var(--space-3);
- border-top: 1px solid var(--line);
- background: var(--bg-1);
-}
-.mobile-slack-picker-head {
- display: flex;
- gap: var(--space-2);
- align-items: center;
- margin-bottom: var(--space-2);
-}
-.mobile-slack-picker-head input {
- flex: 1;
- min-height: 40px;
- font: inherit;
- font-size: var(--type-lg);
- color: var(--text);
- background: var(--bg-2);
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- padding: var(--space-2) var(--space-3-5);
-}
-.mobile-slack-picker-grid {
- display: grid;
- grid-template-columns: repeat(8, 1fr);
- gap: var(--space-1);
- max-height: 8rem;
- overflow: auto;
-}
-.mobile-slack-picker-grid button {
- min-height: 40px;
- border: 0;
- border-radius: var(--radius);
- background: transparent;
- font-size: var(--type-xl);
-}
-.mobile-slack-picker-grid button:active {
- background: var(--hover);
-}
-
-.board-workspace {
- display: flex;
- flex-direction: column;
- min-height: 0;
- height: 100%;
- position: relative;
- background:
- radial-gradient(
- circle at 1px 1px,
- color-mix(in srgb, var(--text) 12%, transparent) 1px,
- transparent 0
- )
- 0 0 / 20px 20px,
- var(--bg);
-}
-.board-toolbar {
- position: relative;
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1);
- align-items: center;
- padding: var(--space-2) var(--space-2-5);
- border-bottom: 1px solid var(--line);
- background: var(--bg-1);
-}
-.board-toolbar button {
- min-height: 32px;
- padding: 0 var(--space-2-5);
- border: 0;
- border-radius: var(--radius-md);
- background: transparent;
- color: var(--text);
- font: inherit;
- font-size: var(--type-sm);
-}
-.board-toolbar button.active,
-.board-toolbar button:hover {
- background: var(--hover);
-}
-.board-toolbar-gap {
- flex: 1;
-}
-.board-insert {
- display: inline-flex;
- align-items: center;
- gap: var(--space-0-5);
- margin-left: var(--space-1-5);
- padding-left: var(--space-1-5);
- border-left: 1px solid var(--line);
-}
-.board-toolbar button:disabled {
- opacity: 0.4;
-}
-.board-zoom {
- display: flex;
- align-items: center;
- gap: var(--space-0-5);
- font-size: var(--type-sm);
- color: var(--text);
- opacity: 0.85;
-}
-.board-zoom span {
- min-width: 2.6em;
- text-align: center;
-}
-.board-frames {
- position: absolute;
- left: 12px;
- top: 8px;
- z-index: var(--z-chrome);
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
- max-height: 40%;
- overflow: auto;
- padding: var(--space-1);
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--bg-1) 88%, transparent);
- border: 1px solid var(--line);
-}
-.board-frames button {
- text-align: left;
- border: 0;
- background: transparent;
- color: var(--text);
- font: inherit;
- font-size: var(--type-sm);
- padding: var(--space-1) var(--space-2);
- border-radius: var(--radius-sm);
-}
-.board-frames button.active,
-.board-frames button:hover {
- background: var(--hover);
-}
-.board-toolbar select {
- font: inherit;
- font-size: var(--type-sm);
- color: var(--text);
- background: var(--bg-2);
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- padding: var(--space-1) var(--space-2);
-}
-.board-stage {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- position: relative;
- cursor: default;
- outline: none;
-}
-.board-stage.tool-pan,
-.board-stage.tool-pan * {
- cursor: grab;
-}
-.board-stage.tool-sticky,
-.board-stage.tool-text,
-.board-stage.tool-shape,
-.board-stage.tool-draw,
-.board-stage.tool-frame,
-.board-stage.tool-table,
-.board-stage.tool-timeline,
-.board-stage.tool-card,
-.board-stage.tool-comment,
-.board-stage.tool-connector {
- cursor: crosshair;
-}
-.board-stage.tool-draw .board-obj {
- pointer-events: none;
-}
-.board-ink-colors {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1);
- margin-left: var(--space-1);
- padding-left: var(--space-2);
- border-left: 1px solid var(--line);
-}
-.board-ink-colors .board-swatch.active {
- outline: 2px solid var(--accent);
- outline-offset: 1px;
-}
-.board-world {
- position: absolute;
- transform-origin: 0 0;
- top: 0;
- left: 0;
-}
-.board-obj {
- position: absolute;
- box-sizing: border-box;
- border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
- border-radius: var(--radius-md);
- background: var(--bg-2);
- color: var(--text);
- padding: var(--space-2);
- overflow: hidden;
- user-select: none;
- font-size: var(--type-md);
-}
-.board-obj.selected {
- outline: none;
-}
-.board-obj.sticky {
- box-shadow: 2px 3px 0 color-mix(in srgb, var(--text) 12%, transparent);
-}
-.board-obj.sticky.stacked {
- box-shadow:
- 3px 4px 0 color-mix(in srgb, var(--text) 16%, transparent),
- 6px 8px 0 color-mix(in srgb, var(--text) 8%, transparent);
-}
-.board-obj.sticky.color-amber {
- background: #f5d48a;
- color: #3a2a00;
-}
-.board-obj.sticky.color-mint {
- background: #b7e0c3;
- color: #143322;
-}
-.board-obj.sticky.color-sky {
- background: #b7d4ea;
- color: #10283c;
-}
-.board-obj.sticky.color-rose {
- background: #f0c1c8;
- color: #4a1520;
-}
-.board-obj.sticky.color-violet {
- background: #d4c2ea;
- color: #2c1844;
-}
-.board-obj.sticky.color-paper {
- background: #f4efe6;
- color: #2b2418;
-}
-.board-obj.frame {
- background: color-mix(in srgb, var(--bg-1) 55%, transparent);
- border: 1.5px dashed color-mix(in srgb, var(--text) 35%, transparent);
- border-radius: var(--radius-lg);
- padding: var(--space-7) var(--space-2-5) var(--space-2-5);
-}
-.board-frame-title {
- position: absolute;
- top: 8px;
- left: 12px;
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
-}
-.board-run {
- position: absolute;
- top: 6px;
- right: 8px;
- font: inherit;
- font-size: var(--type-xs);
- padding: var(--space-0-5) var(--space-2);
- border-radius: var(--radius-pill);
- border: 1px solid var(--line);
- background: var(--bg-1);
- color: var(--text);
-}
-.board-obj.card {
- background: var(--bg-1);
- border-left: 3px solid var(--accent);
-}
-.board-obj.card.stale {
- border-left-color: color-mix(in srgb, var(--danger, #c45c6a) 80%, var(--text));
- opacity: 0.88;
-}
-.board-card-kind {
- display: block;
- font-size: var(--type-2xs);
- letter-spacing: 0.04em;
- text-transform: uppercase;
- opacity: 0.7;
- margin-bottom: var(--space-1);
-}
-.board-card-detail {
- display: block;
- margin-top: var(--space-1);
- font-size: var(--type-xs);
- opacity: 0.65;
-}
-.board-obj.image {
- padding: 0;
- background: var(--bg-1);
-}
-.board-obj.mermaid {
- padding: var(--space-2);
- background: var(--bg-1);
- overflow: auto;
-}
-.board-mermaid-face {
- width: 100%;
- height: 100%;
- overflow: auto;
-}
-.board-mermaid-face svg {
- max-width: 100%;
- height: auto;
-}
-.board-mermaid-source {
- margin: 0;
- font-size: var(--type-xs);
- white-space: pre-wrap;
- font-family: var(--mono);
-}
-.board-present-hint {
- position: absolute;
- left: 12px;
- bottom: 12px;
- z-index: 5;
- margin: 0;
- padding: var(--space-1-5) var(--space-2-5);
- border-radius: var(--radius-md);
- background: color-mix(in srgb, var(--bg-1) 88%, transparent);
- border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
-}
-.board-pin-plan-list {
- flex-wrap: wrap;
- max-height: 240px;
- overflow: auto;
-}
-.board-obj.ink {
- background: transparent;
- border: 0;
- padding: 0;
- overflow: visible;
- box-shadow: none;
-}
-.board-ink {
- display: block;
- width: 100%;
- height: 100%;
- overflow: visible;
- pointer-events: none;
-}
-.board-ink polyline {
- fill: none;
- stroke: currentColor;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-.board-obj.ink.color-amber {
- color: #c48a00;
-}
-.board-obj.ink.color-mint {
- color: #1f7a45;
-}
-.board-obj.ink.color-sky {
- color: #1a5f8a;
-}
-.board-obj.ink.color-rose {
- color: #a12d45;
-}
-.board-obj.ink.color-violet {
- color: #6b3fa0;
-}
-.board-obj.ink.color-paper {
- color: var(--text);
-}
-.board-obj-image {
- display: block;
- width: 100%;
- height: 100%;
- object-fit: contain;
- pointer-events: none;
-}
-.board-obj.shape {
- background: transparent;
- border: 0;
- padding: 0;
-}
-.board-shape-svg {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- overflow: visible;
- pointer-events: none;
-}
-.board-shape-svg path {
- fill: var(--bg-2);
- stroke: color-mix(in srgb, var(--text) 35%, transparent);
- stroke-width: 1.8;
- vector-effect: non-scaling-stroke;
-}
-.board-shape-label {
- position: relative;
- z-index: 1;
- display: grid;
- place-items: center;
- box-sizing: border-box;
- height: 100%;
- padding: var(--space-3);
- text-align: center;
-}
-.board-obj.shape textarea,
-.board-obj.shape .board-obj-text {
- width: 100%;
- text-align: center;
-}
-.board-shape-pop {
- position: absolute;
- z-index: var(--z-float);
- top: calc(100% + 6px);
- left: 12px;
- width: 320px;
- max-height: min(420px, 70vh);
- overflow: auto;
- padding: var(--space-2-5);
- border: 1px solid var(--line);
- border-radius: var(--radius-lg);
- background: var(--bg-1);
- box-shadow: var(--shadow);
-}
-.board-shape-overlay {
- position: absolute;
- z-index: var(--z-float);
- pointer-events: auto;
-}
-.board-shape-overlay .board-shape-pop {
- position: relative;
- top: 0;
- left: 0;
-}
-.board-shape-pop-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: var(--space-2);
-}
-.board-shape-pop-head p {
- margin: 0;
- font-weight: var(--weight-semibold);
-}
-.board-shape-group {
- margin-bottom: var(--space-2-5);
-}
-.board-shape-group h4 {
- margin: 0 0 var(--space-1-5);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- color: var(--faint);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-.board-shape-grid {
- display: grid;
- grid-template-columns: repeat(5, 1fr);
- gap: var(--space-1);
-}
-.board-shape-grid button {
- width: 100%;
- aspect-ratio: 1;
- min-height: 40px;
- padding: var(--space-1-5);
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- background: var(--bg-2);
- color: var(--text);
-}
-.board-shape-grid button:hover,
-.board-shape-grid button.active {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 12%, var(--bg-2));
-}
-.board-shape-glyph {
- display: block;
- width: 100%;
- height: 100%;
-}
-.board-shape-glyph path {
- fill: color-mix(in srgb, var(--text) 12%, var(--bg-2));
- stroke: var(--text);
- stroke-width: 3;
-}
-.board-obj.table,
-.board-obj.timeline {
- padding: 0;
- overflow: hidden;
- background: var(--bg-1);
-}
-.board-table,
-.board-timeline {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
-}
-.board-widget-tab {
- display: flex;
- align-items: center;
- gap: var(--space-1-5);
- flex: 0 0 auto;
- padding: var(--space-1-5) var(--space-2);
- border-bottom: 1px solid var(--line);
- background: var(--bg-2);
-}
-.board-widget-kind {
- font-size: var(--type-sm);
- opacity: 0.7;
-}
-.board-widget-title {
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- font-weight: var(--weight-semibold);
- font-size: var(--type-sm);
- padding: 0;
- text-align: left;
-}
-.board-widget-tab input {
- flex: 1;
- min-width: 0;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- font-weight: var(--weight-semibold);
- font-size: var(--type-sm);
-}
-.board-table-scroll {
- flex: 1;
- min-height: 0;
- overflow: auto;
-}
-.board-table table {
- width: max-content;
- min-width: 100%;
- border-collapse: collapse;
- font-size: var(--type-sm);
-}
-.board-table th,
-.board-table td {
- border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
- padding: 0;
- min-width: 88px;
- height: 28px;
- vertical-align: stretch;
-}
-.board-table-index {
- min-width: 36px !important;
- width: 36px;
- text-align: center;
- color: var(--faint);
- font-size: var(--type-xs);
-}
-.board-table-kind {
- display: inline-block;
- margin-right: var(--space-1);
- opacity: 0.55;
- font-size: var(--type-xs);
-}
-.board-table th button,
-.board-table-cell {
- width: 100%;
- height: 100%;
- min-height: 28px;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- text-align: left;
- padding: var(--space-1) var(--space-2);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.board-table th {
- background: color-mix(in srgb, var(--bg-2) 80%, var(--bg-1));
- font-weight: var(--weight-semibold);
-}
-.board-table input {
- width: 100%;
- height: 100%;
- border: 0;
- background: var(--bg);
- color: inherit;
- font: inherit;
- padding: var(--space-1) var(--space-2);
-}
-.board-table-add {
- min-width: 36px !important;
- width: 36px;
-}
-.board-table-add button,
-.board-table-add-row {
- width: 28px;
- height: 28px;
- border: 0;
- border-radius: var(--radius-sm);
- background: transparent;
- color: var(--text);
- font: inherit;
-}
-.board-table-add-row {
- margin: var(--space-1);
-}
-.board-timeline-scale select {
- font: inherit;
- font-size: var(--type-xs);
- color: var(--text);
- background: var(--bg-1);
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
-}
-.board-timeline-body {
- display: flex;
- flex: 1;
- min-height: 0;
-}
-.board-timeline-list {
- width: 140px;
- flex: 0 0 140px;
- overflow: auto;
- border-right: 1px solid var(--line);
- padding: var(--space-1-5);
-}
-.board-timeline-list p {
- margin: 0 0 var(--space-1-5);
- font-size: var(--type-xs);
- color: var(--faint);
-}
-.board-timeline-list button,
-.board-timeline-list input {
- display: block;
- width: 100%;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- font-size: var(--type-sm);
- text-align: left;
- padding: var(--space-1) var(--space-1-5);
- border-radius: var(--radius-sm);
-}
-.board-timeline-list button:hover {
- background: var(--hover);
-}
-.board-timeline-chart {
- position: relative;
- flex: 1;
- min-width: 0;
- overflow: auto;
-}
-.board-timeline-head {
- display: flex;
- min-width: 480px;
- border-bottom: 1px solid var(--line);
-}
-.board-timeline-col {
- flex: 1;
- min-width: 56px;
- padding: var(--space-1-5) var(--space-1);
- font-size: var(--type-xs);
- color: var(--faint);
- text-align: center;
-}
-.board-timeline-col span {
- display: block;
- font-size: var(--type-2xs);
-}
-.board-timeline-today {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 0;
- border-left: 1px dashed var(--text);
- pointer-events: none;
- z-index: 1;
-}
-.board-timeline-today span {
- position: absolute;
- top: 2px;
- left: 4px;
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
-}
-.board-timeline-lanes {
- position: relative;
- min-width: 480px;
- padding: var(--space-2) 0;
-}
-.board-timeline-lane {
- position: relative;
- height: 28px;
- margin: var(--space-1) 0;
-}
-.board-timeline-bar {
- position: absolute;
- top: 2px;
- height: 24px;
- border: 0;
- border-radius: var(--radius-md);
- padding: 0 var(--space-2);
- font: inherit;
- font-size: var(--type-xs);
- text-align: left;
- overflow: hidden;
- white-space: nowrap;
- cursor: grab;
-}
-.board-timeline-bar.color-amber {
- background: #f5d48a;
- color: #3a2a00;
-}
-.board-timeline-bar.color-mint {
- background: #b7e0c3;
- color: #143322;
-}
-.board-timeline-bar.color-sky {
- background: #b7d4ea;
- color: #10283c;
-}
-.board-timeline-bar.color-rose {
- background: #f0c1c8;
- color: #4a1520;
-}
-.board-timeline-bar.color-violet {
- background: #d4c2ea;
- color: #2c1844;
-}
-.board-timeline-bar.color-paper {
- background: #f4efe6;
- color: #2b2418;
-}
-.board-obj textarea {
- width: 100%;
- height: 100%;
- resize: none;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
-}
-.board-obj-text {
- display: block;
- white-space: pre-wrap;
- word-break: break-word;
-}
-.board-wires {
- position: absolute;
- overflow: visible;
- inset: 0;
- width: 4000px;
- height: 4000px;
- pointer-events: none;
-}
-.board-wires line,
-.board-wires polyline {
- fill: none;
- stroke: color-mix(in srgb, var(--text) 55%, transparent);
- stroke-width: 2;
- stroke-linejoin: round;
- stroke-linecap: round;
-}
-.board-wires line.selected,
-.board-wires polyline.selected {
- stroke: var(--accent);
- stroke-width: 3;
-}
-.board-wires polyline.ink {
- stroke: var(--text);
- stroke-width: 2.5;
-}
-.board-wires polyline.ink.color-amber {
- stroke: #c48a00;
-}
-.board-wires polyline.ink.color-mint {
- stroke: #1f7a45;
-}
-.board-wires polyline.ink.color-sky {
- stroke: #1a5f8a;
-}
-.board-wires polyline.ink.color-rose {
- stroke: #a12d45;
-}
-.board-wires polyline.ink.color-violet {
- stroke: #6b3fa0;
-}
-.board-wires polyline.ink.color-paper {
- stroke: var(--text);
-}
-.board-marquee {
- position: absolute;
- box-sizing: border-box;
- border: 1px dashed var(--accent);
- background: color-mix(in srgb, var(--accent) 12%, transparent);
- pointer-events: none;
-}
-.board-resize {
- position: absolute;
- width: 10px;
- height: 10px;
- padding: 0;
- border: 2px solid var(--bg);
- border-radius: 2px;
- background: var(--accent);
- z-index: 3;
- pointer-events: auto;
- touch-action: none;
-}
-.board-resize.n {
- top: -6px;
- left: 50%;
- margin-left: -5px;
- cursor: ns-resize;
-}
-.board-resize.s {
- bottom: -6px;
- left: 50%;
- margin-left: -5px;
- cursor: ns-resize;
-}
-.board-resize.e {
- right: -6px;
- top: 50%;
- margin-top: -5px;
- cursor: ew-resize;
-}
-.board-resize.w {
- left: -6px;
- top: 50%;
- margin-top: -5px;
- cursor: ew-resize;
-}
-.board-resize.ne {
- top: -6px;
- right: -6px;
- cursor: nesw-resize;
-}
-.board-resize.nw {
- top: -6px;
- left: -6px;
- cursor: nwse-resize;
-}
-.board-resize.se {
- right: -6px;
- bottom: -6px;
- cursor: nwse-resize;
-}
-.board-resize.sw {
- left: -6px;
- bottom: -6px;
- cursor: nesw-resize;
-}
-.board-port {
- position: absolute;
- width: 14px;
- height: 14px;
- padding: 0;
- border: 2px solid var(--bg);
- border-radius: var(--radius-pill);
- background: var(--accent);
- z-index: 5;
- pointer-events: auto;
- touch-action: none;
-}
-.board-port.n {
- top: -20px;
- left: 50%;
- margin-left: -7px;
- cursor: crosshair;
-}
-.board-port.s {
- bottom: -20px;
- left: 50%;
- margin-left: -7px;
- cursor: crosshair;
-}
-.board-port.e {
- right: -20px;
- top: 50%;
- margin-top: -7px;
- cursor: crosshair;
-}
-.board-port.w {
- left: -20px;
- top: 50%;
- margin-top: -7px;
- cursor: crosshair;
-}
-.board-overlay {
- position: absolute;
- z-index: 6;
- box-sizing: border-box;
- border: 2px solid var(--accent);
- pointer-events: none;
-}
-.board-overlay-bar {
- position: absolute;
- left: 50%;
- bottom: calc(100% + 10px);
- z-index: 7;
- display: flex;
- flex-wrap: nowrap;
- flex-direction: row;
- gap: var(--space-0-5);
- align-items: center;
- width: max-content;
- max-width: none;
- padding: var(--space-1);
- transform: translateX(-50%);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-1);
- box-shadow: var(--shadow);
- pointer-events: auto;
- white-space: nowrap;
-}
-.board-overlay-bar.below {
- bottom: auto;
- top: calc(100% + 10px);
-}
-.board-overlay-size {
- padding: 0 var(--space-1-5);
- color: var(--faint);
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
- white-space: nowrap;
-}
-.board-overlay-sep {
- width: 1px;
- height: 16px;
- margin: 0 var(--space-1);
- background: var(--line);
-}
-.board-overlay-bar .board-overlay-btn {
- width: 28px;
- height: 28px;
- min-height: 28px;
- padding: 0;
- display: grid;
- place-items: center;
- border: 0;
- border-radius: var(--radius-md);
- background: transparent;
- color: var(--text);
- font-size: var(--type-xs);
- font-weight: var(--weight-bold);
-}
-.board-overlay-bar .board-overlay-btn:hover {
- background: var(--hover);
-}
-.board-overlay-bar .board-overlay-btn.ask {
- color: var(--accent);
-}
-.board-overlay-bar .board-overlay-btn.danger {
- color: var(--danger);
-}
-.board-overlay-bar .board-swatch {
- width: 14px;
- height: 14px;
- min-height: 14px;
- padding: 0;
- border: 1px solid var(--line);
- border-radius: 50%;
-}
-.board-toolbar .board-ask {
- color: var(--accent);
- font-weight: var(--weight-semibold);
-}
-.board-ctx-backdrop {
- position: fixed;
- inset: 0;
- z-index: var(--z-menu);
- border: 0;
- background: transparent;
-}
-.board-ctx {
- position: fixed;
- z-index: var(--z-menu-raised);
- min-width: 220px;
-}
-.board-wires text {
- fill: var(--text);
- font-size: var(--type-sm);
-}
-.board-comment-dot {
- position: absolute;
- top: 6px;
- right: 6px;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--accent);
-}
-.board-swatch {
- width: 18px;
- height: 18px;
- border-radius: 50%;
- border: 1px solid var(--line);
-}
-.board-swatch.color-amber {
- background: #f5d48a;
-}
-.board-swatch.color-mint {
- background: #b7e0c3;
-}
-.board-swatch.color-sky {
- background: #b7d4ea;
-}
-.board-swatch.color-rose {
- background: #f0c1c8;
-}
-.board-swatch.color-violet {
- background: #d4c2ea;
-}
-.board-swatch.color-paper {
- background: #f4efe6;
-}
-.board-pop {
- position: absolute;
- z-index: 4;
- right: 16px;
- bottom: 16px;
- width: min(360px, calc(100% - 32px));
- padding: var(--space-3);
- border: 1px solid var(--line);
- border-radius: var(--radius-lg);
- background: var(--bg-1);
- box-shadow: 0 8px 24px color-mix(in srgb, var(--text) 12%, transparent);
-}
-.board-pop p {
- margin: 0 0 var(--space-2);
- font-weight: var(--weight-semibold);
-}
-.board-pop textarea,
-.board-pop input,
-.board-pop select {
- width: 100%;
- font: inherit;
- color: var(--text);
- background: var(--bg-2);
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- padding: var(--space-2);
- margin-bottom: var(--space-2);
-}
-.board-pop-actions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1-5);
-}
-.board-card-list {
- max-height: 180px;
- overflow: auto;
- margin: var(--space-2) 0;
- padding: 0;
- list-style: none;
-}
-.board-card-list button {
- width: 100%;
- text-align: left;
- border: 0;
- background: transparent;
- color: var(--text);
- font: inherit;
- padding: var(--space-1-5) var(--space-1);
- border-radius: var(--radius-sm);
-}
-.board-card-list button:hover {
- background: var(--hover);
-}
-.board-comments {
- position: absolute;
- left: 12px;
- bottom: 12px;
- margin: 0;
- padding: var(--space-2) var(--space-2-5);
- max-width: 280px;
- list-style: none;
- background: var(--bg-1);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- font-size: var(--type-sm);
-}
-.board-comments li {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--space-1-5);
- margin: var(--space-1) 0;
-}
-
-.project-fleet {
- display: flex;
- flex-direction: column;
- gap: var(--space-4);
- height: 100%;
- overflow: auto;
- padding: var(--space-3-5) var(--space-4) 18px;
-}
-.project-fleet-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: var(--space-2-5);
-}
-.project-fleet-actions {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-.project-fleet h3 {
- margin: 0 0 var(--space-1-5);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.04em;
- text-transform: uppercase;
- opacity: 0.72;
-}
-.project-fleet .meta {
- margin: 0;
- font-size: var(--type-sm);
- opacity: 0.72;
-}
-.project-fleet-column {
- min-width: 0;
-}
-.project-fleet-column h3 .meta {
- font-weight: var(--weight-medium);
- letter-spacing: 0;
- text-transform: none;
- margin-left: var(--space-1);
-}
-.project-fleet-empty {
- margin: 0 0 var(--space-1);
-}
-.project-fleet-row {
- display: flex;
- align-items: flex-start;
- gap: var(--space-2);
- width: 100%;
- margin: 0 0 var(--space-1-5);
- padding: var(--space-2) var(--space-2);
- border: 0;
- border-radius: var(--radius-md);
- background: transparent;
- color: inherit;
- text-align: left;
- cursor: pointer;
-}
-.project-fleet-row:hover {
- background: var(--bg-2);
-}
-.project-fleet-row.on {
- background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
- box-shadow: inset 2px 0 0 var(--accent);
-}
-.project-fleet-row .title {
- font-weight: var(--weight-semibold);
-}
-.project-fleet-title {
- margin: 0 0 var(--space-1);
- font-size: var(--type-lg);
- font-weight: var(--weight-semibold);
- letter-spacing: 0;
- text-transform: none;
- opacity: 1;
-}
-.project-roster {
- display: flex;
- flex-direction: column;
- gap: var(--space-0-5);
- min-width: 0;
-}
-.project-teammate-strip {
- padding: var(--space-2-5) var(--space-3);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--accent) 6%, var(--bg));
-}
-.project-teammate-strip .title {
- margin: 0 0 var(--space-1);
- font-weight: var(--weight-semibold);
-}
-.project-teammate-brief {
- margin: var(--space-2) 0 0;
- font-size: var(--type-sm);
- line-height: 1.45;
- white-space: pre-wrap;
-}
-.project-briefing-list {
- list-style: none;
- padding-left: 0;
-}
-.project-briefing-file {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: var(--space-2);
- width: 100%;
- margin: 0;
- padding: var(--space-1-5) var(--space-2);
- border: 0;
- border-radius: var(--radius-md);
- background: transparent;
- color: inherit;
- font: inherit;
- text-align: left;
- cursor: pointer;
-}
-.project-briefing-file:hover,
-.project-briefing-file.on {
- background: var(--bg-2);
-}
-.project-briefing-viewer {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
- margin-top: var(--space-2);
- padding: var(--space-2-5) var(--space-2-5) var(--space-3);
- border: 1px solid var(--line);
- border-radius: var(--radius-lg);
- background: var(--bg-1);
-}
-.project-briefing-viewer:focus-within {
- border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
-}
-.project-briefing-viewer-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-2);
- margin: 0;
-}
-.project-briefing-editor {
- display: block;
- box-sizing: border-box;
- width: 100%;
- min-height: 12rem;
- max-height: 42vh;
- margin: 0;
- padding: var(--space-2-5) var(--space-3);
- border: 0;
- border-radius: var(--radius-md);
- background: var(--bg);
- color: var(--text);
- font: inherit;
- font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
- font-size: var(--type-md);
- line-height: 1.5;
- resize: vertical;
- outline: none;
- caret-color: var(--accent);
-}
-.project-briefing-editor:focus,
-.project-briefing-editor:focus-visible {
- outline: none;
-}
-.project-briefing-viewer > .btn {
- align-self: flex-start;
-}
-.project-briefing-body {
- margin: 0;
- max-height: 220px;
- overflow: auto;
- white-space: pre-wrap;
- font-size: var(--type-sm);
- line-height: 1.45;
- font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
-}
-.project-fleet-more {
- border-top: 1px solid var(--line);
- padding-top: var(--space-2);
-}
-.project-fleet-more > summary {
- cursor: pointer;
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.04em;
- text-transform: uppercase;
- opacity: 0.72;
- list-style: none;
-}
-.project-fleet-more > summary::-webkit-details-marker {
- display: none;
-}
-.project-fleet-more-body {
- display: flex;
- flex-direction: column;
- gap: var(--space-4);
- margin-top: var(--space-3);
-}
-.project-fleet-files {
- margin: 0;
- padding-left: var(--space-4);
- font-size: var(--type-sm);
-}
-.project-fleet-files li {
- margin: 3px 0;
-}
-.project-need-card {
- padding: var(--space-2-5);
- border: 1px solid color-mix(in srgb, var(--warn, #d99b3d) 45%, var(--line));
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--warn, #d99b3d) 8%, var(--bg));
-}
-.project-need-card + .project-need-card {
- margin-top: var(--space-2);
-}
-.project-need-card p {
- margin: 5px 0 9px;
- font-size: var(--type-sm);
- line-height: 1.4;
-}
-.project-need-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
-}
-.project-team-switcher {
- display: flex;
- align-items: end;
- gap: var(--space-2);
- padding-bottom: var(--space-0-5);
-}
-.project-team-switcher label,
-.project-handoff-compose {
- display: flex;
- flex: 1;
- flex-direction: column;
- gap: var(--space-1-5);
- min-width: 0;
-}
-.project-team-switcher select,
-.project-handoff-compose select,
-.project-handoff-compose textarea,
-.project-team-name-input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- background: var(--bg-2);
- color: inherit;
- font: inherit;
-}
-.project-team-switcher select,
-.project-handoff-compose select,
-.project-team-name-input,
-.team-editor select {
- min-height: 32px;
- padding: var(--space-1-5) var(--space-2);
-}
-.team-editor {
- display: flex;
- flex-direction: column;
- gap: var(--space-3-5);
-}
-.team-editor form {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
-}
-.team-editor label {
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
- font-size: var(--type-sm);
-}
-.team-editor select,
-.team-editor textarea.project-team-name-input {
- width: 100%;
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- background: var(--bg-2);
- color: inherit;
- font: inherit;
-}
-.team-editor textarea.project-team-name-input {
- min-height: 62px;
- padding: var(--space-2);
- resize: vertical;
-}
-.project-handoff-compose textarea {
- min-height: 62px;
- padding: var(--space-2);
- resize: vertical;
-}
-.project-handoff-list {
- display: grid;
- gap: var(--space-2);
- margin: var(--space-2-5) 0 0;
- padding: 0;
- list-style: none;
-}
-.project-handoff-card {
- padding: 9px;
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: color-mix(in srgb, var(--accent) 5%, var(--bg));
-}
-.project-handoff-card p {
- margin: 5px 0;
- font-size: var(--type-sm);
- line-height: 1.4;
-}
-.project-handoff-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
- margin-top: 7px;
-}
-
-/* Locus Swarm product mode */
-.persona-avatar {
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- border-radius: 50%;
- background: color-mix(in srgb, var(--persona-accent, var(--accent)) 22%, transparent);
- color: var(--persona-accent, var(--text));
-}
-.persona-avatar-picker {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1-5);
-}
-.persona-avatar-picker > button {
- padding: var(--space-0-5);
- border: 1px solid transparent;
- border-radius: var(--radius-pill);
- background: transparent;
- cursor: pointer;
-}
-.persona-avatar-picker > button.on,
-.persona-avatar-picker > button[aria-checked="true"] {
- border-color: var(--accent);
-}
-.product-mode-toggle {
- display: flex;
- gap: var(--space-0-5);
- padding: var(--space-0-5);
- border-radius: var(--radius-md);
- background: var(--bg-2);
-}
-.product-mode-toggle.compact {
- flex-direction: column;
- width: calc(100% - 8px);
- max-width: 52px;
- margin: 0 auto var(--space-1-5);
- box-sizing: border-box;
-}
-.product-mode-toggle > button {
- flex: 1;
- border: 0;
- border-radius: var(--radius-sm);
- padding: var(--space-1) var(--space-1-5);
- font-size: var(--type-2xs);
- line-height: 1.2;
- background: transparent;
- color: var(--muted);
- cursor: pointer;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.product-mode-toggle.compact > button {
- padding: 5px var(--space-0-5);
- font-size: var(--type-2xs);
- letter-spacing: 0.01em;
-}
-.product-mode-toggle > button.on {
- background: var(--bg);
- color: var(--text);
- font-weight: var(--weight-semibold);
-}
-.hub-persona-fields {
- display: grid;
- gap: var(--space-2-5);
- margin: var(--space-3) 0;
-}
-.hub-persona-avatar {
- padding: var(--space-2);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-2, var(--bg));
-}
-.team-event-chip {
- font-size: var(--type-2xs);
- font-weight: var(--weight-semibold);
- letter-spacing: 0.03em;
- text-transform: uppercase;
- color: var(--muted);
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- padding: 1px var(--space-1-5);
-}
-.msg.team-event .bubble,
-.msg.team-event .markdown {
- opacity: 0.92;
-}
-.hub-computer-mode-meta {
- margin: var(--space-0-5) 0 0;
- font-size: var(--type-xs);
- color: var(--muted);
- font-weight: var(--weight-medium);
-}
-.team-peers {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1-5);
- margin-bottom: var(--space-2-5);
-}
-.team-peer-chip {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1-5);
- padding: var(--space-1) var(--space-2) var(--space-1) var(--space-1);
- border: 1px solid var(--line);
- border-radius: var(--radius-pill);
- background: var(--bg);
- color: inherit;
- font-size: var(--type-sm);
- cursor: pointer;
-}
-.team-peer-chip:hover {
- background: var(--bg-2);
-}
-.project-fleet-row .title {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1-5);
-}
-.subagent-fold-label {
- margin: 0 0 var(--space-1-5);
- font-size: var(--type-xs);
- font-weight: var(--weight-semibold);
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-.session-brain-list {
- margin: var(--space-1) 0 0;
- padding-left: var(--space-4);
- font-size: var(--type-sm);
- color: var(--muted);
-}
-.desktop-shell[data-product-mode="swarm"] .side-rail-nav .rail-label {
- font-size: var(--type-2xs);
-}
-
-.desktop-shell[data-product-mode="swarm"] .sidebar.swarm-sidebar {
- display: flex;
-}
-.desktop-shell[data-product-mode="swarm"] .app.collapsed {
- /* No icon rail in Swarm — collapsing would hide the only nav column. */
- grid-template-columns: 300px 1fr;
-}
-.desktop-shell[data-product-mode="swarm"] .app.collapsed .side-main {
- /* Keep Swarm list usable even if collapsed flag is set. */
- visibility: visible;
- pointer-events: auto;
-}
-.desktop-shell[data-product-mode="swarm"] .brand {
- display: flex;
- align-items: center;
- gap: var(--space-2-5);
-}
-.swarm-shell-nav {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--space-1);
- margin: 0 var(--space-3) var(--space-2);
- padding: 3px;
- border-radius: var(--radius);
- background: var(--bg-2);
-}
-.swarm-shell-nav button {
- border: 0;
- border-radius: var(--radius-md);
- background: transparent;
- color: inherit;
- font: inherit;
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- min-height: 28px;
- cursor: pointer;
-}
-.swarm-shell-nav button.active {
- background: var(--bg);
- box-shadow: 0 0 0 1px var(--line);
-}
-
-/* Locus Swarm messenger shell */
-.desktop-shell[data-product-mode="swarm"] .app:not(.collapsed) {
- grid-template-columns: 300px 1fr;
-}
-.desktop-shell[data-product-mode="swarm"] .session-item.hub-session-item {
- border-radius: var(--radius-lg);
- margin: var(--space-0-5) var(--space-1-5);
- padding: var(--space-2) var(--space-2-5);
-}
-.desktop-shell[data-product-mode="swarm"] .session-item.hub-session-item.active {
- background: color-mix(in srgb, var(--text) 8%, var(--bg-1));
-}
-.desktop-shell[data-product-mode="swarm"] .hub-session-item .session-item-open {
- display: flex;
- align-items: flex-start;
- gap: var(--space-3);
- width: 100%;
- text-align: left;
- position: relative;
-}
-.desktop-shell[data-product-mode="swarm"] .hub-session-item .session-item-copy {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: var(--space-0-5);
-}
-.desktop-shell[data-product-mode="swarm"] .session-row-main {
- display: flex;
- align-items: baseline;
- gap: var(--space-2);
- width: 100%;
-}
-.desktop-shell[data-product-mode="swarm"] .session-row-main .title {
- flex: 1;
- min-width: 0;
-}
-.desktop-shell[data-product-mode="swarm"] .session-stamp {
- flex-shrink: 0;
- font-size: var(--type-xs);
- color: var(--muted);
-}
-.desktop-shell[data-product-mode="swarm"] .hub-session-item .meta {
- color: var(--muted);
- font-size: var(--type-sm);
- line-height: 1.35;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-.desktop-shell[data-product-mode="swarm"] .hub-session-item .dot {
- position: absolute;
- left: 34px;
- top: 34px;
- width: 8px;
- height: 8px;
-}
-.desktop-shell[data-product-mode="swarm"] .hub-topbar .hub-session-title {
- margin: 0;
- font-size: var(--type-lg);
- font-weight: var(--weight-semibold);
- border: 0;
- background: transparent;
- color: inherit;
-}
-
-/* Messenger bubbles — WhatsApp / iMessage style */
-.desktop-shell[data-product-mode="swarm"] .chat .thread {
- padding: 18px 22px;
- display: flex;
- flex-direction: column;
- gap: var(--space-0-5);
-}
-.desktop-shell[data-product-mode="swarm"] .chat-new-divider {
- max-width: none;
- margin: var(--space-2-5) 0 var(--space-3);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg {
- max-width: none;
- width: 100%;
- margin: 0 0 var(--space-2-5);
- display: flex;
- flex-direction: column;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user {
- align-items: flex-end;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant,
-.desktop-shell[data-product-mode="swarm"] .chat .msg.system,
-.desktop-shell[data-product-mode="swarm"] .chat .msg.tool {
- align-items: flex-start;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg .who {
- margin: 0 var(--space-1) var(--space-1);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .who {
- width: 100%;
- justify-content: flex-end;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg .bubble {
- max-width: min(78%, 540px);
- padding: var(--space-2-5) var(--space-3-5);
- border: 0;
- box-shadow: none;
- white-space: pre-wrap;
- word-break: break-word;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg .bubble.working {
- display: inline-flex;
- width: auto;
-}
-.desktop-shell[data-product-mode="swarm"] .working-copy {
- flex: 0 1 auto;
-}
-.desktop-shell[data-product-mode="swarm"] .working-thought {
- width: auto;
- max-width: 46ch;
- -webkit-line-clamp: 2;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble {
- background: #0a84ff;
- color: #ffffff;
- border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xs) var(--radius-xl);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble,
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble * {
- color: #ffffff !important;
- --text: #ffffff;
- --muted: color-mix(in srgb, #ffffff 72%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble a {
- color: #e8f3ff !important;
- text-decoration: underline;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user .msg-actions .btn {
- color: #ffffff;
- border-color: color-mix(in srgb, #fff 35%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble {
- background: color-mix(in srgb, var(--text) 12%, var(--bg-1));
- color: var(--text);
- border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xl) var(--radius-xs);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble em {
- color: var(--accent);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble strong {
- color: var(--text);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.continued {
- margin-top: -4px;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.continued .bubble {
- border-radius: var(--radius-xl);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.user.continued .bubble {
- border-radius: var(--radius-xl) var(--radius-xs) var(--radius-xs) var(--radius-xl);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant.continued .bubble {
- border-radius: var(--radius-xs) var(--radius-xl) var(--radius-xl) var(--radius-xs);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg .who {
- font-size: var(--type-sm);
- font-weight: var(--weight-semibold);
- color: var(--muted);
- opacity: 0.9;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg .msg-actions {
- opacity: 0;
- transition: opacity var(--dur-fast) var(--ease-standard);
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg:hover .msg-actions,
-.desktop-shell[data-product-mode="swarm"] .chat .msg:focus-within .msg-actions {
- opacity: 1;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .msg.team-event .bubble {
- background: transparent;
- border: 0;
- color: var(--muted);
- font-size: var(--type-md);
- padding: var(--space-1) var(--space-2);
- max-width: 100%;
-}
-.desktop-shell[data-product-mode="swarm"] .chat .composer {
- border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
- background: color-mix(in srgb, var(--bg-1) 88%, transparent);
-}
-
-/* Grok Bot–inspired Swarm atmosphere (messenger, not control plane) */
-.desktop-shell[data-product-mode="swarm"] {
- --swarm-roster-bg: color-mix(in srgb, var(--bg) 88%, var(--text) 4%);
- --swarm-thread-bg: color-mix(in srgb, var(--bg) 94%, var(--accent, var(--text)) 3%);
-}
-.desktop-shell[data-product-mode="swarm"] .sidebar {
- background:
- radial-gradient(
- 120% 80% at 0% 0%,
- color-mix(in srgb, var(--accent, #6b8cff) 12%, transparent),
- transparent 55%
- ),
- var(--swarm-roster-bg);
- border-right: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .workspace {
- background: linear-gradient(
- 180deg,
- color-mix(in srgb, var(--bg) 92%, transparent),
- var(--swarm-thread-bg)
- );
-}
-.desktop-shell[data-product-mode="swarm"] .hub-persona-fields {
- display: grid;
- grid-template-columns: minmax(220px, 0.9fr) minmax(320px, 1.3fr);
- gap: var(--space-4) var(--space-5);
- align-items: start;
- margin: 0 0 var(--space-3);
- padding: var(--space-3-5) var(--space-4);
- border-radius: var(--radius-xl);
- background: color-mix(in srgb, var(--text) 5%, var(--bg));
- border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .hub-persona-identity {
- display: grid;
- gap: var(--space-2-5);
- min-width: 0;
-}
-.desktop-shell[data-product-mode="swarm"] .hub-persona-avatar {
- grid-column: auto;
- min-width: 0;
- padding: 0;
- border: 0;
- background: transparent;
-}
-.desktop-shell[data-product-mode="swarm"] .hub-persona-avatar > span:first-child {
- display: block;
- margin-bottom: var(--space-2);
- font-size: var(--type-sm);
- color: var(--muted);
-}
-.desktop-shell[data-product-mode="swarm"] .empty.hub-start {
- padding: var(--space-5) var(--space-7);
- align-items: stretch;
- justify-content: center;
- overflow: auto;
-}
-.desktop-shell[data-product-mode="swarm"] .empty.hub-start .empty-card {
- width: min(960px, 100%);
- max-width: none;
- text-align: left;
- margin: 0 auto;
-}
-.desktop-shell[data-product-mode="swarm"] .empty.hub-start h2 {
- font-size: 1.35rem;
- letter-spacing: -0.02em;
- margin: 0 0 var(--space-1);
-}
-.desktop-shell[data-product-mode="swarm"] .empty.hub-start .empty-card > p {
- margin: 0 0 var(--space-3-5);
- max-width: 62ch;
-}
-@media (max-width: 900px) {
- .desktop-shell[data-product-mode="swarm"] .hub-persona-fields {
- grid-template-columns: 1fr;
- }
-}
-.desktop-shell[data-product-mode="swarm"] .msg.has-speaker .who {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1-5);
- font-weight: var(--weight-semibold);
-}
-.desktop-shell[data-product-mode="swarm"] .hub-computer-panel {
- background: linear-gradient(165deg, color-mix(in srgb, var(--text) 4%, var(--bg-1)), var(--bg-1));
- border-left: 1px solid color-mix(in srgb, var(--line) 75%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"]
- .hub-computer-panel[data-computer-mode="preview"]
- .hub-computer-screen-body,
-.desktop-shell[data-product-mode="swarm"]
- .hub-computer-panel[data-computer-mode="takeover"]
- .hub-computer-screen-body {
- box-shadow: 0 12px 40px color-mix(in srgb, var(--text) 12%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .team-peers {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
- padding: var(--space-2) 0 var(--space-3);
-}
-.desktop-shell[data-product-mode="swarm"] .team-peer-chip {
- border-radius: var(--radius-pill);
- padding: var(--space-1-5) var(--space-2-5) var(--space-1-5) var(--space-1-5);
- background: color-mix(in srgb, var(--text) 6%, var(--bg));
-}
-.desktop-shell[data-product-mode="swarm"] .project-briefing-viewer {
- border-radius: var(--radius-xl);
- background: color-mix(in srgb, var(--text) 5%, var(--bg));
- border-color: color-mix(in srgb, var(--line) 80%, transparent);
-}
-.desktop-shell[data-product-mode="swarm"] .project-briefing-editor {
- background: var(--bg);
- border-radius: var(--radius);
-}
-.hub-computer-panel {
- display: flex;
- flex-direction: column;
- gap: var(--space-3-5);
- padding: var(--space-3-5) var(--space-3-5) 18px;
- min-width: 0;
- min-height: 0;
- overflow: auto;
- background: var(--bg-1);
-}
-.hub-computer-head {
- display: flex;
- align-items: center;
- gap: var(--space-2);
-}
-.hub-computer-title {
- flex: 1;
- min-width: 0;
-}
-.hub-computer-title h2 {
- margin: 0;
- font-size: var(--type-lg);
- font-weight: var(--weight-semibold);
-}
-.hub-computer-head-actions {
- display: flex;
- gap: var(--space-0-5);
-}
-.hub-computer-status {
- padding: var(--space-3) var(--space-3-5);
- border-radius: var(--radius-xl);
- background: color-mix(in srgb, var(--text) 6%, var(--bg));
- border: 1px solid var(--line);
-}
-.hub-computer-status-host {
- margin: 0 0 var(--space-1);
- font-size: var(--type-xs);
- color: var(--muted);
-}
-.hub-computer-status-title {
- margin: 0;
- font-size: var(--type-body);
- font-weight: var(--weight-semibold);
-}
-.hub-computer-status-detail {
- margin: var(--space-1-5) 0 0;
- font-size: var(--type-sm);
- color: var(--muted);
- line-height: 1.4;
-}
-.hub-computer-screen-block {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
-}
-.hub-computer-screen-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-2-5);
-}
-.hub-computer-screen-label {
- margin: 0;
- font-size: var(--type-md);
- color: var(--muted);
-}
-.hub-computer-screen-body {
- min-height: 180px;
- max-height: 42vh;
- overflow: auto;
- border-radius: var(--radius-lg);
- border: 1px solid var(--line);
- background: var(--bg);
-}
-.hub-embedded-workspace {
- display: flex;
- flex-direction: column;
- min-height: 180px;
- height: 100%;
-}
-.hub-routines h3 {
- margin: 0 0 var(--space-2);
- font-size: var(--type-md);
- font-weight: var(--weight-semibold);
-}
-.hub-routines-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: var(--space-0-5);
-}
-.hub-routine-row {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- width: 100%;
- padding: var(--space-2) var(--space-1-5);
- border: 0;
- border-radius: var(--radius);
- background: transparent;
- color: inherit;
- cursor: pointer;
- text-align: left;
-}
-.hub-routine-row:hover {
- background: color-mix(in srgb, var(--text) 6%, transparent);
-}
-.hub-routine-icon {
- display: inline-flex;
- color: color-mix(in srgb, #e8a317 80%, var(--text));
- flex-shrink: 0;
-}
-.hub-routine-name {
- flex: 1;
- min-width: 0;
- font-size: var(--type-md);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.hub-routine-when {
- flex-shrink: 0;
- font-size: var(--type-sm);
- color: var(--muted);
-}
-.hub-routine-new,
-.hub-routines-empty {
- margin: var(--space-1-5) 0 0;
- padding: 0;
- border: 0;
- background: transparent;
- color: var(--muted);
- font-size: var(--type-md);
- cursor: pointer;
-}
-.hub-routine-new:hover {
- color: var(--text);
-}
-
-.persona-avatar-editor {
- display: grid;
- gap: var(--space-2-5);
-}
-.persona-avatar-editor .persona-avatar-controls {
- display: grid;
- gap: var(--space-2-5);
-}
-.persona-avatar-editor .persona-avatar-option-row {
- display: grid;
- gap: var(--space-2-5);
-}
-.persona-avatar-editor.panel {
- grid-template-columns: auto minmax(0, 1fr);
- gap: var(--space-3) 18px;
- align-items: start;
-}
-.persona-avatar-editor.panel .persona-avatar-preview-row {
- flex-direction: column;
- align-items: center;
- gap: var(--space-2);
- text-align: center;
- min-width: 96px;
-}
-.persona-avatar-editor.panel .persona-avatar-caption {
- margin: 0;
- font-size: var(--type-sm);
- line-height: 1.3;
- max-width: 9rem;
-}
-.persona-avatar-editor.panel .persona-avatar-controls {
- gap: var(--space-2);
-}
-.persona-avatar-editor.panel .persona-avatar-option-row {
- grid-template-columns: 1fr 1fr;
- gap: var(--space-2) var(--space-3);
-}
-.persona-avatar-editor.panel .field {
- margin: 0;
-}
-.persona-avatar-editor.panel .field > span,
-.persona-avatar-editor.panel .field > label {
- display: block;
- margin-bottom: var(--space-1);
- font-size: var(--type-xs);
- color: var(--muted);
-}
-.persona-avatar-editor.panel .theme-filter-pills {
- gap: var(--space-1);
-}
-.persona-avatar-editor.panel .theme-filter-pills > button {
- padding: var(--space-1) var(--space-2);
- font-size: var(--type-sm);
-}
-.persona-avatar-preview-row {
- display: flex;
- align-items: center;
- gap: var(--space-3);
-}
-.persona-avatar-caption {
- margin: 0;
-}
-.persona-accent-row {
- display: flex;
- align-items: center;
- gap: var(--space-2);
-}
-.hub-teammate-modal {
- width: min(780px, calc(100vw - 32px));
- max-height: min(90vh, 820px);
- overflow-x: hidden;
- overflow-y: auto;
- box-sizing: border-box;
-}
-.hub-teammate-modal .hub-persona-fields,
-.desktop-shell[data-product-mode="swarm"] .hub-teammate-modal .hub-persona-fields {
- display: grid;
- grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr);
- gap: var(--space-3-5) var(--space-4);
- align-items: start;
- min-width: 0;
- margin: 0 0 var(--space-3);
- padding: var(--space-3) var(--space-3-5);
- box-sizing: border-box;
-}
-.hub-teammate-modal .hub-persona-identity {
- display: grid;
- gap: var(--space-2-5);
- min-width: 0;
-}
-.hub-teammate-modal .hub-persona-avatar {
- padding: 0;
- border: 0;
- background: transparent;
- min-width: 0;
- max-width: 100%;
-}
-.hub-teammate-modal .persona-avatar-editor,
-.hub-teammate-modal .persona-avatar-editor.panel .persona-avatar-controls {
- min-width: 0;
- max-width: 100%;
-}
-.hub-teammate-modal .persona-avatar-editor.panel {
- grid-template-columns: auto minmax(0, 1fr);
- gap: var(--space-2-5) var(--space-3-5);
-}
-.hub-teammate-modal .persona-avatar-editor.panel .persona-avatar-option-row {
- grid-template-columns: 1fr;
- gap: var(--space-2);
-}
-.hub-teammate-modal .persona-avatar-editor.panel .theme-filter-pills {
- flex-wrap: wrap;
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- border-radius: var(--radius-xl);
- row-gap: var(--space-1);
-}
-.hub-teammate-modal .persona-avatar-editor.panel .theme-filter-pills > button {
- flex: 0 1 auto;
-}
-.hub-teammate-modal .persona-accent-row {
- width: 100%;
- min-width: 0;
-}
-@media (max-width: 560px) {
- .persona-avatar-editor.panel {
- grid-template-columns: 1fr;
- }
- .persona-avatar-editor.panel .persona-avatar-option-row,
- .hub-teammate-modal .hub-persona-fields,
- .desktop-shell[data-product-mode="swarm"] .hub-teammate-modal .hub-persona-fields {
- grid-template-columns: 1fr;
- }
-}
-.persona-accent-row input[type="color"] {
- width: 36px;
- height: 32px;
- padding: 0;
- border: 1px solid var(--line);
- border-radius: var(--radius-sm);
- background: transparent;
-}
-.persona-accent-row input[type="text"] {
- flex: 1;
- min-width: 0;
-}
-.persona-acc {
- position: absolute;
- pointer-events: none;
-}
-.persona-acc-antenna {
- top: 2%;
- left: 50%;
- width: 2px;
- height: 28%;
- transform: translateX(-50%);
- background: var(--persona-accent, var(--accent));
- border-radius: 1px;
-}
-.persona-acc-antenna::after {
- content: "";
- position: absolute;
- top: -3px;
- left: 50%;
- width: 7px;
- height: 7px;
- transform: translateX(-50%);
- border-radius: 50%;
- background: var(--persona-accent, var(--accent));
-}
-.persona-acc-badge {
- right: 4%;
- bottom: 8%;
- width: 28%;
- height: 28%;
- border-radius: 50%;
- background: var(--persona-accent, var(--accent));
- box-shadow: 0 0 0 2px var(--bg);
-}
-.persona-acc-bow {
- top: -2%;
- left: 50%;
- width: 42%;
- height: 18%;
- transform: translateX(-50%);
- background: var(--persona-accent, var(--accent));
- clip-path: polygon(0 50%, 35% 0, 50% 35%, 65% 0, 100% 50%, 65% 100%, 50% 65%, 35% 100%);
-}
-.codebase-start-cards {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
- gap: var(--space-3);
- margin-top: var(--space-3);
-}
-.codebase-start-card {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
- padding: var(--space-3);
- border: 1px solid var(--line);
- border-radius: var(--radius);
- background: var(--bg-1);
-}
-.codebase-start-card h3 {
- margin: 0;
- font-size: var(--type-body);
-}
-button.linkish {
- display: inline;
- padding: 0;
- border: 0;
- background: none;
- color: var(--accent);
- text-decoration: underline;
- cursor: pointer;
- font: inherit;
-}
-.workspace-docker-inspect-meta {
- display: grid;
- gap: var(--space-2);
- margin: 0;
- padding: var(--space-3);
-}
-.workspace-docker-inspect-meta div {
- display: grid;
- gap: var(--space-0-5);
-}
-.workspace-docker-inspect-meta dt {
- font-size: var(--type-xs);
- color: var(--muted);
-}
-.workspace-docker-inspect-meta dd {
- margin: 0;
- font-size: var(--type-md);
-}
-.theme-recents {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
- margin-bottom: var(--space-3);
-}
diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index cda1400..d5e6409 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -767,8 +767,22 @@ export const api = {
notes?: string;
htmlUrl?: string;
asset?: { name: string; url: string };
+ checksums?: { name: string; url: string };
message?: string;
}>(`/api/updates?current=${encodeURIComponent(current)}`),
+ downloadUpdate: (
+ asset: { name: string; url: string },
+ checksums: { name: string; url: string },
+ ) =>
+ json<{ path: string; sha256: string; verified: true }>("/api/updates/download", {
+ method: "POST",
+ body: JSON.stringify({ asset, checksums }),
+ }),
+ openUpdate: (path: string) =>
+ json<{ ok: true }>("/api/updates/open", {
+ method: "POST",
+ body: JSON.stringify({ path }),
+ }),
testProvider: (id: string, patch: { apiKey?: string; baseUrl?: string } = {}) =>
json<{
ok: boolean;
diff --git a/apps/desktop/src/lib/cssTokens.test.ts b/apps/desktop/src/lib/cssTokens.test.ts
index d6c8d52..59b38d4 100644
--- a/apps/desktop/src/lib/cssTokens.test.ts
+++ b/apps/desktop/src/lib/cssTokens.test.ts
@@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const src = join(dirname(fileURLToPath(import.meta.url)), "..");
-const css = readFileSync(join(src, "index.css"), "utf8");
+const css = [
+ readFileSync(join(src, "index.css"), "utf8"),
+ readFileSync(join(src, "styles/phone.css"), "utf8"),
+ readFileSync(join(src, "styles/swarm.css"), "utf8"),
+].join("\n");
function definedTokens(): Set {
const names = new Set();
@@ -38,6 +42,20 @@ describe("css tokens", () => {
}
});
+ it("keeps the composer subagent chip as short as neighboring head chips", () => {
+ const wrapperStart = css.indexOf(".composer-head .subagent-select {");
+ expect(wrapperStart).toBeGreaterThan(-1);
+ const wrapper = css.slice(wrapperStart, css.indexOf("}", wrapperStart));
+ expect(wrapper).not.toMatch(/padding:/);
+ expect(wrapper).not.toMatch(/min-height:/);
+ expect(css).toMatch(
+ /\.composer-head \.ui-select\.compact\.subagent-select \.ui-select-toggle \{[\s\S]*?min-height:\s*0/,
+ );
+ expect(css).toMatch(
+ /\.composer-head \.ui-select\.compact\.subagent-select \.ui-select-toggle \{[\s\S]*?padding:\s*var\(--space-0-5\) var\(--space-2\)/,
+ );
+ });
+
it("draws font weights from the scale so they match the loaded Outfit weights", () => {
expect([...css.matchAll(/font-weight:\s*(\d+)/g)].map((m) => m[0])).toEqual([]);
});
diff --git a/apps/desktop/src/lib/highlight.test.ts b/apps/desktop/src/lib/highlight.test.ts
index 4b8bfac..7092b89 100644
--- a/apps/desktop/src/lib/highlight.test.ts
+++ b/apps/desktop/src/lib/highlight.test.ts
@@ -17,7 +17,7 @@ describe("highlightSource", () => {
expect(languageFromPath("pkg/main.go")).toBe("go");
expect(languageFromPath("Dockerfile")).toBe("docker");
expect(languageFromPath("a.yml")).toBe("yaml");
- expect(languageFromPath("siq8/mcp/tests/test_tool_forensics.py")).toBe("python");
+ expect(languageFromPath("app/mcp/tests/test_tool_forensics.py")).toBe("python");
expect(resolveLanguage("tsx")).toBe("tsx");
expect(languageFromPath("src/App.tsx")).toBe("tsx");
expect(languageFromPath("src/App.jsx")).toBe("jsx");
diff --git a/apps/desktop/src/lib/mobileShell.test.ts b/apps/desktop/src/lib/mobileShell.test.ts
index b88eccb..9c95566 100644
--- a/apps/desktop/src/lib/mobileShell.test.ts
+++ b/apps/desktop/src/lib/mobileShell.test.ts
@@ -22,6 +22,7 @@ import {
sessionMetaLine,
sessionNeedsYou,
sessionShowsBrowser,
+ sessionShowsVnc,
sessionStatusLabel,
UI_SHELL_KEY,
} from "./mobileShell";
@@ -179,6 +180,12 @@ describe("mobile session helpers", () => {
expect(sessionShowsBrowser(session({ runtime: "docker", kind: "assets" }))).toBe(false);
});
+ it("offers view-only VNC on QEMU guests with a vmId", () => {
+ expect(sessionShowsVnc(session({ runtime: "vm", vmId: "vm1" }))).toBe(true);
+ expect(sessionShowsVnc(session({ runtime: "vm" }))).toBe(false);
+ expect(sessionShowsVnc(session({ runtime: "docker", vmId: "c1" }))).toBe(false);
+ });
+
it("collects pending approvals once per tool call", () => {
const needed = call();
const rows = pendingApprovals([
@@ -326,7 +333,10 @@ describe("bindMobileViewport", () => {
});
describe("phone shell CSS", () => {
- const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../index.css"), "utf8");
+ const css = readFileSync(
+ join(dirname(fileURLToPath(import.meta.url)), "../styles/phone.css"),
+ "utf8",
+ );
it("keeps the chat list from pushing the tab bar off screen", () => {
expect(css).toMatch(
diff --git a/apps/desktop/src/lib/mobileShell.ts b/apps/desktop/src/lib/mobileShell.ts
index 1e6c132..abe7687 100644
--- a/apps/desktop/src/lib/mobileShell.ts
+++ b/apps/desktop/src/lib/mobileShell.ts
@@ -123,6 +123,11 @@ export function sessionShowsBrowser(session: AgentSession): boolean {
return sessionRuntime(session) === "docker" && !isCanvasSession(session);
}
+/** QEMU guests expose a view-only VNC pane on the phone. Control stays on the desktop. */
+export function sessionShowsVnc(session: AgentSession): boolean {
+ return sessionRuntime(session) === "vm" && Boolean(session.vmId);
+}
+
export function pendingApprovals(sessions: AgentSession[]): MobileApproval[] {
const out: MobileApproval[] = [];
const seen = new Set();
diff --git a/apps/desktop/src/lib/threadScroll.test.ts b/apps/desktop/src/lib/threadScroll.test.ts
index 4700ebf..d98a098 100644
--- a/apps/desktop/src/lib/threadScroll.test.ts
+++ b/apps/desktop/src/lib/threadScroll.test.ts
@@ -10,7 +10,11 @@ import {
STICK_BOTTOM_PX,
} from "./threadScroll";
-const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../index.css"), "utf8");
+const dir = dirname(fileURLToPath(import.meta.url));
+const css = [
+ readFileSync(join(dir, "../index.css"), "utf8"),
+ readFileSync(join(dir, "../styles/phone.css"), "utf8"),
+].join("\n");
describe("isStuckToBottom", () => {
it("treats a small gap as stuck and a larger gap as pinned", () => {
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index b572ded..4bc80a6 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -5,6 +5,8 @@ import { ErrorBoundary } from "@/components/layout/CrashScreen";
import { initPhoneAuth } from "@/lib/phoneAuth";
import { initUiShell } from "@/lib/mobileShell";
import "./index.css";
+import "./styles/phone.css";
+import "./styles/swarm.css";
import "./styles/ux.css";
initPhoneAuth();
diff --git a/apps/desktop/src/styles/phone.css b/apps/desktop/src/styles/phone.css
new file mode 100644
index 0000000..f17a8e0
--- /dev/null
+++ b/apps/desktop/src/styles/phone.css
@@ -0,0 +1,3141 @@
+/* Phone remote shell — pairing URL and ?shell=mobile */
+html[data-shell="mobile"],
+html[data-shell="mobile"] body,
+html[data-shell="mobile"] #root {
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ max-height: 100%;
+ overflow: hidden;
+ overflow-x: clip;
+}
+.mobile-shell {
+ position: fixed;
+ top: var(--mobile-vt, 0px);
+ left: 0;
+ width: 100%;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ height: var(--mobile-vh, 100dvh);
+ max-height: var(--mobile-vh, 100dvh);
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+}
+.mobile-shell.is-chat {
+ grid-template-rows: minmax(0, 1fr);
+}
+.mobile-screen {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ min-width: 0;
+ max-width: 100%;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--bg);
+}
+.mobile-top {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-2);
+ padding: calc(10px + env(safe-area-inset-top)) 16px 8px;
+ border-bottom: 0;
+ background: var(--bg);
+ z-index: 2;
+}
+.mobile-top-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2-5);
+}
+.mobile-top-copy {
+ flex: 1;
+ min-width: 0;
+}
+.mobile-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: 0 0 var(--space-1-5);
+ color: color-mix(in srgb, var(--text) 72%, var(--muted));
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+ letter-spacing: -0.01em;
+}
+.mobile-brand svg {
+ flex: none;
+}
+.mobile-top h1 {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: var(--weight-bold);
+ letter-spacing: -0.045em;
+ line-height: 1.05;
+}
+.mobile-top.is-thread {
+ flex-direction: row;
+ align-items: center;
+ padding-bottom: var(--space-2-5);
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
+}
+.mobile-top.is-thread h1 {
+ font-size: 1.02rem;
+ font-weight: var(--weight-semibold);
+}
+.mobile-top-title {
+ flex: 1;
+ min-width: 0;
+ text-align: center;
+}
+.mobile-top-title.is-start {
+ text-align: left;
+}
+.mobile-top-title h1 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-top-meta {
+ margin: var(--space-1) 0 0;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-medium);
+ letter-spacing: 0.01em;
+}
+.mobile-top-meta.is-live {
+ color: var(--accent);
+}
+.mobile-slack-channel {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1-5);
+ color: var(--accent);
+}
+.mobile-slack-channel svg {
+ flex: none;
+}
+.mobile-top-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex: none;
+}
+.mobile-icon-btn.on {
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+}
+.mobile-top-spacer {
+ width: 64px;
+ height: 40px;
+ flex: none;
+ visibility: hidden;
+ pointer-events: none;
+}
+.mobile-screen.has-tabs {
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+}
+.mobile-screen.has-tabs .mobile-top {
+ border-bottom: 0;
+ padding-bottom: var(--space-1);
+}
+.mobile-chat-tabs {
+ display: flex;
+ gap: var(--space-1);
+ padding: 0 var(--space-3-5) var(--space-2-5);
+ background: color-mix(in srgb, var(--bg-1) 82%, transparent);
+ border-bottom: 1px solid var(--line);
+}
+.mobile-chat-tab {
+ margin: 0;
+ padding: var(--space-1-5) var(--space-3-5);
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: var(--type-body);
+ font-weight: var(--weight-semibold);
+}
+.mobile-chat-tab[aria-selected="true"] {
+ background: var(--bg-2);
+ color: var(--text);
+}
+.mobile-files {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2-5);
+ min-height: 0;
+ padding: 0 var(--space-3-5) var(--space-3);
+}
+.mobile-file-tree {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 36%;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+.mobile-file-tree button {
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: var(--space-2) var(--space-2-5);
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-size: var(--type-md);
+ text-align: left;
+}
+.mobile-file-tree button.active {
+ background: var(--bg-2);
+ color: var(--accent);
+}
+.mobile-file-body {
+ margin: 0;
+ padding: var(--space-2-5);
+ overflow: auto;
+ min-height: 0;
+ flex: 1;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: var(--type-sm);
+ background: var(--bg-2);
+ border-radius: var(--radius);
+}
+.mobile-screen.is-browser {
+ grid-template-rows: auto minmax(0, 1fr);
+}
+.mobile-screen.is-canvas {
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+}
+.mobile-canvas-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 0 var(--space-3) var(--space-2-5);
+ background: color-mix(in srgb, var(--bg-1) 82%, transparent);
+ border-bottom: 1px solid var(--line);
+}
+.mobile-promote-scaffold {
+ flex: 1 1 8rem;
+ min-width: 0;
+ min-height: 36px;
+ padding: 0 var(--space-2-5);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-2);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-md);
+}
+.mobile-inbox-batch {
+ display: grid;
+ gap: var(--space-2-5);
+ padding: var(--space-3-5) var(--space-3-5) 0;
+}
+.mobile-inbox-batch .hint {
+ margin: 0;
+}
+.mobile-canvas-frame {
+ min-height: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #fff;
+}
+.mobile-row.is-design .design-thumb {
+ grid-area: mark;
+ flex: none;
+ width: 56px;
+ height: 36px;
+ border-radius: var(--radius-md);
+}
+.mobile-row.is-design .design-thumb iframe {
+ transform: scale(0.636);
+ transform-origin: 0 0;
+}
+.mobile-list li:not(:last-child) .mobile-row.is-design::after {
+ left: 84px;
+}
+.mobile-pr {
+ display: grid;
+ gap: var(--space-3);
+ align-content: start;
+ padding: var(--space-4) var(--space-4) var(--space-8);
+}
+.mobile-pr-title {
+ margin: 0;
+ font-size: 1.15rem;
+ font-weight: var(--weight-semibold);
+ letter-spacing: -0.03em;
+ line-height: 1.25;
+}
+.mobile-pr-meta {
+ margin: 0;
+ color: var(--muted);
+ font-size: var(--type-md);
+}
+.mobile-pr-heading {
+ margin: var(--space-2) 0 0;
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.02em;
+ color: var(--faint);
+ text-transform: uppercase;
+}
+.mobile-pr-body {
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+.mobile-pr-body p {
+ margin: 0 0 0.55em;
+}
+.mobile-pr-comments {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--space-3);
+}
+.mobile-pr-comment {
+ padding: var(--space-3) var(--space-3-5);
+ border-radius: var(--radius-xl);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+}
+.mobile-pr-write,
+.mobile-canvas-comment {
+ display: grid;
+ gap: var(--space-2);
+}
+.mobile-pr-write textarea,
+.mobile-canvas-comment textarea {
+ width: 100%;
+ resize: none;
+ min-height: 40px;
+ padding: var(--space-2-5) var(--space-3);
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--line);
+ background: var(--bg-2);
+ color: var(--text);
+ font: inherit;
+}
+.mobile-canvas-comment {
+ padding: 10px 16px calc(12px + env(safe-area-inset-bottom));
+ border-top: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
+ background: var(--bg);
+ display: grid;
+ gap: var(--space-2);
+}
+.mobile-canvas-compose {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--space-2);
+ align-items: end;
+}
+.mobile-canvas-live {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: 0 var(--space-4) var(--space-2);
+ padding: var(--space-2-5) var(--space-3);
+ border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
+ border-radius: var(--radius-lg);
+ color: var(--accent);
+ font-size: var(--type-md);
+}
+.mobile-canvas-live .mobile-status-dot {
+ margin: 0;
+}
+.mobile-canvas-log {
+ list-style: none;
+ margin: 0;
+ padding: 0 var(--space-4) var(--space-2-5);
+ display: grid;
+ gap: var(--space-2-5);
+}
+.mobile-canvas-log li {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ gap: var(--space-2);
+ align-items: start;
+ color: var(--text);
+ font-size: var(--type-md);
+}
+.mobile-canvas-log li.is-agent {
+ color: var(--muted);
+}
+.mobile-inbox {
+ width: 100%;
+ min-width: 0;
+}
+.mobile-inbox-section {
+ margin-top: var(--space-4);
+}
+.mobile-inbox-section .mobile-pr-heading {
+ padding: 0 var(--space-4);
+}
+.mobile-browser {
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-1);
+}
+.mobile-browser .browser-pane {
+ flex: 1;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ background: var(--bg);
+}
+.mobile-browser .browser-stage {
+ min-height: 0;
+}
+.mobile-browser .browser-chrome {
+ border-radius: 0;
+}
+.mobile-thread[hidden],
+.mobile-pending[hidden],
+.mobile-composer[hidden],
+.mobile-chat-tabs[hidden] {
+ display: none !important;
+}
+.mobile-kicker {
+ margin: 0 0 var(--space-0-5);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.04em;
+ color: var(--muted);
+}
+.mobile-icon-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1-5);
+ min-height: 36px;
+ min-width: 36px;
+ padding: 0 var(--space-2-5);
+ border: 0;
+ border-radius: 50%;
+ background: var(--bg-2);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-lg);
+ font-weight: var(--weight-semibold);
+}
+.mobile-icon-btn:active {
+ background: var(--hover);
+}
+.mobile-search {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ color: var(--muted);
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+}
+.mobile-search svg {
+ width: 16px;
+ height: 16px;
+}
+.mobile-back {
+ color: var(--accent);
+ padding: 0 var(--space-2) 0 var(--space-0-5);
+ background: transparent;
+ border-radius: var(--radius-pill);
+}
+.mobile-back::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ margin-right: var(--space-0-5);
+ border-left: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ transform: rotate(45deg);
+ flex: none;
+}
+.mobile-new {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ background: var(--accent);
+ color: var(--accent-ink, #111);
+ box-shadow: none;
+}
+.mobile-new svg {
+ width: 16px;
+ height: 16px;
+ stroke-width: 2.4;
+}
+.mobile-search-bar {
+ padding: 0;
+}
+.mobile-search-input {
+ width: 100%;
+ min-height: 40px;
+ padding: var(--space-2) var(--space-3-5);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--bg-2);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-lg);
+}
+.mobile-stop {
+ color: var(--danger);
+ font-size: var(--type-md);
+}
+.mobile-sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.mobile-body {
+ min-height: 0;
+ overflow: auto;
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-y;
+}
+.mobile-list {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 var(--space-3);
+}
+.mobile-day h2 {
+ margin: var(--space-3-5) var(--space-4) var(--space-0-5);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+.mobile-chips {
+ display: flex;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-4) var(--space-2);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.mobile-chips::-webkit-scrollbar {
+ display: none;
+}
+.mobile-chip {
+ flex: none;
+ margin: 0;
+ padding: 7px var(--space-3);
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+ white-space: nowrap;
+}
+.mobile-chip.on {
+ background: var(--accent);
+ color: var(--accent-ink, #111);
+}
+.mobile-chip-count {
+ margin-left: var(--space-1-5);
+ color: var(--danger);
+ font-variant-numeric: tabular-nums;
+}
+.mobile-chip.on .mobile-chip-count {
+ color: #c62828;
+}
+.mobile-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas: "main";
+ gap: var(--space-2-5);
+ align-items: start;
+ width: 100%;
+ text-align: left;
+ padding: 9px var(--space-4);
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ min-height: 56px;
+ position: relative;
+}
+.mobile-row:has(.mobile-status-dot),
+.mobile-row:has(.mobile-avatar),
+.mobile-row.is-design {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-areas: "mark main extra";
+}
+.mobile-row:active {
+ background: var(--hover);
+}
+.mobile-list li:not(:last-child) .mobile-row::after {
+ content: "";
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ bottom: 0;
+ height: 1px;
+ background: color-mix(in srgb, var(--text) 10%, transparent);
+}
+.mobile-list li:not(:last-child) .mobile-row:has(.mobile-status-dot)::after,
+.mobile-list li:not(:last-child) .mobile-row:has(.mobile-avatar)::after {
+ left: 42px;
+}
+.mobile-status-dot {
+ grid-area: mark;
+ width: 10px;
+ height: 10px;
+ margin-top: var(--space-1-5);
+ border-radius: var(--radius-pill);
+ background: var(--accent);
+ flex: none;
+}
+.mobile-status-dot.is-needs {
+ background: #ff5a3d;
+}
+.mobile-status-dot.is-ok {
+ background: #3dce6a;
+}
+.mobile-status-dot.is-idle {
+ background: transparent;
+ box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--text) 28%, transparent);
+}
+.mobile-status-dot.is-running {
+ background: var(--accent);
+}
+.mobile-avatar {
+ grid-area: mark;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-md);
+ display: grid;
+ place-items: center;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-bold);
+ letter-spacing: -0.02em;
+ color: var(--text);
+ background: var(--bg-2);
+}
+.mobile-row-main {
+ grid-area: main;
+ min-width: 0;
+ display: grid;
+ gap: var(--space-0-5);
+}
+.mobile-row-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2-5);
+ min-width: 0;
+}
+.mobile-row-title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1-5);
+ min-width: 0;
+ font-weight: var(--weight-semibold);
+ letter-spacing: -0.02em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-lock {
+ display: inline-flex;
+ color: var(--accent);
+ flex: none;
+}
+.mobile-row-sub {
+ color: var(--muted);
+ font-size: var(--type-md);
+ line-height: 1.35;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow-wrap: break-word;
+ word-break: normal;
+}
+.mobile-row-mono {
+ color: var(--faint);
+ font-family: var(--mono);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-medium);
+ letter-spacing: 0.01em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-row-mono.is-wait {
+ color: #ff5a3d;
+}
+.mobile-row-mono.is-live {
+ color: var(--accent);
+}
+.mobile-row-meta {
+ grid-area: extra;
+ display: grid;
+ justify-items: end;
+ align-content: start;
+ gap: var(--space-1-5);
+}
+.mobile-row-chevron {
+ grid-area: extra;
+ color: var(--faint);
+ margin-top: var(--space-1);
+}
+.mobile-row-time {
+ margin-left: auto;
+ color: var(--faint);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ font-variant-numeric: tabular-nums;
+ flex: none;
+}
+.mobile-row-time.is-now {
+ color: var(--accent);
+}
+.mobile-row-time.is-wait {
+ color: #ff5a3d;
+}
+.mobile-pill {
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ padding: 3px var(--space-2);
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--text) 6%, transparent);
+ color: var(--muted);
+}
+.mobile-pill.warn {
+ color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 16%, transparent);
+}
+.mobile-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-pill);
+ background: var(--accent);
+ box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 70%, transparent);
+ flex: none;
+}
+.mobile-empty {
+ display: grid;
+ justify-items: center;
+ text-align: center;
+ gap: var(--space-2);
+ padding: 64px var(--space-7) var(--space-10);
+}
+.mobile-empty h2 {
+ margin: var(--space-2) 0 0;
+ font-size: 1.25rem;
+ letter-spacing: -0.03em;
+}
+.mobile-empty p,
+.mobile-compose .hint,
+.mobile-more .hint {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.5;
+ max-width: 22rem;
+}
+.mobile-cards {
+ list-style: none;
+ margin: 0;
+ padding: var(--space-3-5) var(--space-3-5) var(--space-7);
+ display: grid;
+ gap: var(--space-3);
+}
+.mobile-card {
+ padding: var(--space-4);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ background: color-mix(in srgb, var(--bg-1) 88%, transparent);
+ box-shadow: var(--elev-2);
+}
+.mobile-card.tight {
+ padding: var(--space-3) var(--space-3-5);
+}
+.mobile-card h2 {
+ margin: var(--space-1) 0 var(--space-2-5);
+ font-size: 1.05rem;
+ letter-spacing: -0.02em;
+}
+.mobile-pre {
+ margin: 0 0 var(--space-3);
+ max-height: 8rem;
+ overflow: auto;
+ padding: var(--space-2-5) var(--space-3);
+ border-radius: var(--radius-lg);
+ background: var(--bg);
+ border: 1px solid var(--line);
+ font-size: var(--type-sm);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.mobile-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.mobile-actions .btn,
+.mobile-composer .btn,
+.mobile-more .btn {
+ min-height: 44px;
+ padding: 0 var(--space-4);
+ border-radius: var(--radius-pill);
+}
+.mobile-tabbar {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ border-top: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
+ background: var(--bg);
+ padding: 6px 4px calc(8px + env(safe-area-inset-bottom));
+ z-index: 2;
+}
+.mobile-tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ min-height: 48px;
+ padding: var(--space-1) 1px var(--space-0-5);
+ border: 0;
+ background: transparent;
+ color: var(--faint);
+ font: inherit;
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0;
+}
+.mobile-tab.on {
+ color: var(--accent);
+}
+.mobile-tab-icon {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 22px;
+}
+.mobile-tab.on .mobile-tab-icon {
+ background: transparent;
+ color: var(--accent);
+}
+.mobile-tab-icon svg {
+ width: 18px;
+ height: 18px;
+}
+.mobile-tab-label {
+ line-height: 1.15;
+}
+.mobile-tab-dot {
+ position: absolute;
+ top: -2px;
+ right: 0;
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-pill);
+ background: #ff5a3d;
+ box-shadow: 0 0 0 2px var(--bg);
+}
+.mobile-badge {
+ position: absolute;
+ top: -2px;
+ right: 4px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 var(--space-1);
+ border-radius: var(--radius-pill);
+ background: var(--danger);
+ color: #fff;
+ font-size: var(--type-2xs);
+ line-height: 16px;
+ text-align: center;
+ box-shadow: 0 0 0 2px var(--bg);
+}
+.mobile-more,
+.mobile-compose {
+ display: grid;
+ gap: 18px;
+ padding: var(--space-2) var(--space-4) var(--space-8);
+ align-content: start;
+}
+.mobile-link-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: var(--space-2-5);
+ align-items: start;
+ padding: var(--space-1) 0 var(--space-3);
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
+}
+.mobile-link-copy {
+ min-width: 0;
+}
+.mobile-text-btn {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: var(--type-body);
+ font-weight: var(--weight-semibold);
+}
+.mobile-signout {
+ color: var(--text);
+ justify-self: start;
+ margin-top: var(--space-2);
+}
+.mobile-grafana {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1;
+}
+.mobile-grafana .grafana-pane {
+ min-height: 60vh;
+}
+.mobile-settings-block,
+.mobile-cap-block {
+ display: grid;
+ gap: var(--space-0-5);
+}
+.mobile-settings-block h2,
+.mobile-cap-block h2 {
+ margin: var(--space-2) 0 var(--space-2);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+.mobile-setting-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ min-height: 44px;
+ color: var(--text);
+ font-size: var(--type-lg);
+}
+.mobile-setting-value {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1-5);
+ color: var(--muted);
+ font-size: var(--type-body);
+}
+.mobile-setting-value select {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: right;
+ padding: 0;
+}
+.mobile-seg {
+ display: inline-flex;
+ padding: var(--space-0-5);
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+}
+.mobile-seg button {
+ margin: 0;
+ min-height: 28px;
+ padding: 0 var(--space-3);
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+}
+.mobile-seg button.on {
+ background: var(--accent);
+ color: var(--accent-ink, #111);
+}
+.mobile-tags {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.mobile-tags li {
+ padding: var(--space-1-5) var(--space-2-5);
+ border: 1px solid color-mix(in srgb, var(--text) 28%, transparent);
+ border-radius: var(--radius-pill);
+ font-size: var(--type-sm);
+ color: var(--text);
+}
+.mobile-tags.is-muted li {
+ border-color: color-mix(in srgb, var(--text) 12%, transparent);
+ color: var(--faint);
+}
+.mobile-version {
+ margin: 0;
+ color: var(--faint);
+ font-family: var(--mono);
+ font-size: var(--type-xs);
+}
+.mobile-board {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ min-height: 0;
+ flex: 1;
+}
+.mobile-board-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2-5);
+}
+.mobile-board-head h2 {
+ flex: 1;
+ margin: 0;
+ font-size: var(--type-lg);
+}
+.mobile-board-zoom {
+ display: flex;
+ gap: var(--space-1);
+}
+.mobile-board-stage {
+ position: relative;
+ flex: 1;
+ min-height: 280px;
+ border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background:
+ radial-gradient(
+ circle at 1px 1px,
+ color-mix(in srgb, var(--text) 10%, transparent) 1px,
+ transparent 0
+ )
+ 0 0 / 18px 18px,
+ var(--bg-1);
+ touch-action: none;
+}
+.mobile-board-world {
+ position: absolute;
+ inset: 0;
+ transform-origin: 0 0;
+}
+.mobile-board-obj {
+ position: absolute;
+ border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+ color: var(--text);
+ padding: var(--space-2);
+ text-align: left;
+ overflow: hidden;
+}
+.mobile-board-obj.sticky {
+ background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
+}
+.mobile-board-obj.frame {
+ background: color-mix(in srgb, var(--text) 4%, transparent);
+ border-style: dashed;
+}
+.mobile-board-obj-label {
+ display: block;
+ font-size: var(--type-sm);
+ line-height: 1.3;
+ max-height: 100%;
+ overflow: hidden;
+}
+.mobile-board-frames {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.mobile-board-frame-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.mobile-board-frame-chip {
+ border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
+ background: var(--bg-2);
+ color: var(--text);
+ border-radius: var(--radius-pill);
+ padding: var(--space-1-5) var(--space-3);
+ font-size: var(--type-sm);
+}
+.mobile-board-sheet {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
+ border-radius: var(--radius-lg);
+ background: var(--bg-1);
+}
+.mobile-board-sheet textarea {
+ width: 100%;
+ resize: vertical;
+ min-height: 72px;
+ border-radius: var(--radius-md);
+ border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
+ background: var(--bg);
+ color: var(--text);
+ padding: var(--space-2);
+}
+.mobile-board-sheet-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.mobile-filter-empty {
+ padding: var(--space-6) var(--space-4);
+}
+.mobile-status-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+.mobile-live {
+ width: 10px;
+ height: 10px;
+ border-radius: var(--radius-pill);
+ background: var(--faint);
+ flex: none;
+}
+.mobile-live.on {
+ background: var(--ok);
+ box-shadow: 0 0 10px color-mix(in srgb, var(--ok) 55%, transparent);
+}
+.mobile-status {
+ margin: 0;
+ font-weight: var(--weight-semibold);
+ letter-spacing: -0.02em;
+}
+.mobile-field {
+ display: grid;
+ gap: var(--space-2);
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+ color: var(--muted);
+}
+.mobile-field select,
+.mobile-input {
+ width: 100%;
+ font: inherit;
+ font-size: var(--type-lg);
+ color: var(--text);
+ background: var(--bg-1);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ padding: var(--space-3) var(--space-3-5);
+}
+.mobile-field select {
+ min-height: 46px;
+ appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, var(--muted) 50%),
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+ background-position:
+ calc(100% - 18px) 50%,
+ calc(100% - 12px) 50%;
+ background-size: 6px 6px;
+ background-repeat: no-repeat;
+ padding-right: var(--space-8);
+}
+.mobile-thread {
+ min-height: 0;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ overflow: auto;
+ overflow-x: hidden;
+ overflow-anchor: none;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-y;
+ padding: var(--chat-mobile-thread-pad);
+ display: flex;
+ flex-direction: column;
+ gap: var(--chat-mobile-thread-gap);
+}
+.mobile-thread .chat-new-divider {
+ max-width: none;
+ margin: var(--space-1-5) 0 var(--space-2);
+}
+.mobile-fit {
+ display: flex;
+ flex-direction: column;
+ gap: inherit;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+ overflow-x: hidden;
+ transform-origin: top left;
+}
+.mobile-msg {
+ display: grid;
+ gap: var(--chat-mobile-msg-gap);
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+}
+.mobile-msg.user {
+ justify-items: stretch;
+}
+.mobile-bubble {
+ padding: var(--chat-mobile-bubble-pad);
+ border-radius: var(--radius-xl);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ font-size: var(--chat-mobile-bubble-font);
+ line-height: var(--chat-mobile-bubble-line);
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: hidden;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.mobile-bubble > *,
+.mobile-bubble p,
+.mobile-bubble li,
+.mobile-bubble h1,
+.mobile-bubble h2,
+.mobile-bubble h3,
+.mobile-bubble h4,
+.mobile-bubble td,
+.mobile-bubble th,
+.mobile-bubble code {
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.mobile-bubble p {
+ margin: 0 0 var(--chat-mobile-p-gap);
+}
+.mobile-bubble p:last-child {
+ margin: 0;
+}
+.mobile-bubble h1,
+.mobile-bubble h2,
+.mobile-bubble h3,
+.mobile-bubble h4 {
+ margin: var(--chat-mobile-h-margin);
+ line-height: 1.25;
+}
+.mobile-bubble h1:first-child,
+.mobile-bubble h2:first-child,
+.mobile-bubble h3:first-child,
+.mobile-bubble h4:first-child {
+ margin-top: 0;
+}
+.mobile-bubble ul,
+.mobile-bubble ol {
+ margin: 0.4em 0;
+ padding-left: 1.2em;
+ max-width: 100%;
+ white-space: normal;
+}
+.mobile-bubble .md-table {
+ table-layout: fixed;
+ width: 100%;
+ max-width: 100%;
+}
+.mobile-bubble .md-table th,
+.mobile-bubble .md-table td {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.mobile-bubble pre,
+.mobile-bubble .md-code {
+ max-width: 100%;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ overflow-x: hidden;
+}
+.mobile-bubble img,
+.mobile-bubble .md-img {
+ max-width: 100%;
+ height: auto;
+}
+.mobile-msg.user .mobile-bubble {
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
+ border-color: color-mix(in srgb, var(--accent) 22%, var(--line));
+}
+.mobile-msg .thinking-trace {
+ margin: 0;
+ max-width: 100%;
+ padding: 0 0 0 var(--space-2-5);
+ border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line));
+ background: none;
+}
+.mobile-msg .thinking-trace > summary {
+ min-height: 22px;
+ align-items: center;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--muted);
+}
+.mobile-msg .thinking-trace > summary::before {
+ content: none;
+}
+.mobile-msg .thinking-trace-body {
+ max-height: 9.5rem;
+ margin: var(--space-0-5) 0 0;
+ padding: 0;
+ border: 0;
+ font-family: var(--font);
+ font-size: var(--type-md);
+ font-style: italic;
+ line-height: 1.45;
+ color: color-mix(in srgb, var(--text) 78%, var(--muted));
+}
+.mobile-tool,
+.mobile-working {
+ margin: 0;
+ font-size: var(--type-sm);
+ color: var(--muted);
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.mobile-tool {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-0-5);
+ padding: 7px var(--space-2-5);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--text) 5%, transparent);
+ border: 0;
+ border-left: 3px solid color-mix(in srgb, var(--accent-2) 50%, var(--line));
+}
+.mobile-tool-name {
+ font-family: var(--font);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-bold);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--accent-2);
+}
+.mobile-tool-detail {
+ font-family: var(--mono);
+ font-size: var(--type-sm);
+ line-height: 1.35;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ white-space: normal;
+}
+.mobile-pr-card {
+ display: block;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-lg);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ font-family: var(--mono);
+ color: var(--accent-2);
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-working {
+ align-self: stretch;
+ padding: 0 0 0 var(--space-2-5);
+ border-radius: 0;
+ background: none;
+ border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line));
+ font-family: var(--font);
+ font-size: var(--type-md);
+ font-style: italic;
+ line-height: 1.45;
+ color: color-mix(in srgb, var(--text) 78%, var(--muted));
+}
+.mobile-working-label {
+ display: block;
+ margin-bottom: var(--space-1);
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ font-style: normal;
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--muted);
+}
+.mobile-thread .subagent-card {
+ max-width: 100%;
+}
+.mobile-subagent-readonly {
+ margin: 0;
+ padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
+ border-top: 1px solid var(--line);
+ font-size: var(--type-md);
+ color: var(--muted);
+}
+.mobile-shot {
+ max-width: 100%;
+ border-radius: var(--radius-xl);
+}
+.mobile-pending {
+ padding: var(--space-2) var(--space-3) 0;
+ border-top: 1px solid var(--line);
+ background: var(--chrome);
+ backdrop-filter: saturate(1.2) blur(12px);
+ -webkit-backdrop-filter: saturate(1.2) blur(12px);
+}
+.mobile-pending:empty {
+ display: none;
+}
+.mobile-composer {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: var(--space-2);
+ align-items: end;
+ padding: var(--chat-mobile-composer-pad);
+ padding-bottom: calc(var(--chat-mobile-composer-bottom) + env(safe-area-inset-bottom));
+ border-top: 1px solid var(--line);
+ background: var(--chrome);
+ backdrop-filter: saturate(1.35) blur(18px);
+ -webkit-backdrop-filter: saturate(1.35) blur(18px);
+}
+.mobile-composer.is-solo {
+ grid-template-columns: 1fr;
+}
+.mobile-composer.is-thread {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-2);
+}
+.mobile-composer .mobile-input {
+ resize: none;
+ min-height: 44px;
+ max-height: 7.5rem;
+ border-radius: 22px;
+ padding: var(--chat-mobile-input-pad);
+ background: var(--bg-2);
+}
+.mobile-composer.is-thread .mobile-input {
+ min-height: var(--chat-mobile-input-min);
+ border-radius: var(--radius-xl);
+}
+.mobile-composer-bar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.mobile-composer.is-slack {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-3);
+ padding-top: var(--space-3);
+}
+.mobile-composer.is-slack .mobile-send-as {
+ flex: none;
+ padding: 0 var(--space-1);
+ border-top: 0;
+ background: transparent;
+}
+.mobile-composer.is-slack .mobile-composer-bar {
+ flex: none;
+}
+.mobile-model-pill {
+ min-width: 0;
+ margin-right: auto;
+ padding: var(--space-1-5) var(--space-2-5);
+ border-radius: var(--radius-pill);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ color: var(--muted);
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mobile-send {
+ min-width: 72px;
+ border-radius: var(--radius-pill) !important;
+ box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 24%, transparent);
+}
+.mobile-composer .mobile-icon-btn.mobile-send {
+ min-width: 40px;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 50% !important;
+ box-shadow: none;
+}
+.mobile-composer.is-thread .mobile-send {
+ min-width: 40px;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 50% !important;
+}
+.mobile-composer.is-thread .mobile-send:disabled {
+ opacity: 0.4;
+ box-shadow: none;
+}
+.mobile-screen .patch-card,
+.mobile-screen .patch-file {
+ max-width: 100%;
+ min-width: 0;
+}
+.mobile-screen .patch-kind,
+.mobile-screen .patch-file-head .btn.icon,
+.mobile-screen .patch-ln {
+ display: none;
+}
+.mobile-screen .patch-line {
+ grid-template-columns: 1.4em minmax(0, 1fr);
+ width: max-content;
+ min-width: 100%;
+ white-space: pre;
+ overflow-wrap: normal;
+ word-break: normal;
+}
+.mobile-screen .patch-text {
+ min-width: max-content;
+}
+.mobile-screen .patch-hunk {
+ overflow-x: auto;
+}
+.mobile-banner,
+.mobile-error {
+ margin: 0;
+ padding: var(--space-2) var(--space-4);
+ background: color-mix(in srgb, var(--warn) 16%, transparent);
+ color: var(--warn);
+ font-size: var(--type-md);
+ border-radius: var(--radius-lg);
+}
+.mobile-error {
+ background: transparent;
+ color: var(--danger);
+ padding: 0;
+}
+.mobile-avatar img,
+.mobile-slack-face img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: inherit;
+}
+.mobile-slack-hint {
+ padding: var(--space-6) var(--space-4);
+}
+.mobile-slack-workspace {
+ margin: 0;
+ padding: var(--space-3-5) var(--space-4) 0;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ color: var(--muted);
+}
+.mobile-slack-group h2 {
+ margin: var(--space-4) var(--space-4) 0;
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--faint);
+}
+.mobile-slack-group .mobile-list {
+ padding-top: var(--space-1);
+}
+.mobile-slack-msg {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr);
+ gap: var(--space-2-5);
+ max-width: 100%;
+ align-items: start;
+}
+.mobile-slack-face {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-md);
+ display: grid;
+ place-items: center;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-bold);
+ color: var(--text);
+ background: var(--bg-2);
+ overflow: hidden;
+}
+.mobile-slack-copy {
+ min-width: 0;
+ display: grid;
+ gap: var(--space-1);
+ overflow-wrap: anywhere;
+}
+.mobile-slack-copy .md-table {
+ table-layout: fixed;
+ width: 100%;
+ max-width: 100%;
+}
+.mobile-slack-copy .md-table th,
+.mobile-slack-copy .md-table td {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.mobile-slack-meta {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.mobile-slack-meta strong {
+ font-size: var(--type-md);
+ letter-spacing: -0.02em;
+}
+.mobile-slack-meta time {
+ font-size: var(--type-xs);
+ color: var(--faint);
+}
+.mobile-slack-copy .slack-md,
+.mobile-slack-copy .slack-media,
+.mobile-slack-copy .slack-htmls,
+.mobile-slack-copy .slack-pdfs,
+.mobile-slack-copy .slack-videos,
+.mobile-slack-copy .slack-csvs,
+.mobile-slack-copy .slack-files {
+ max-width: 100%;
+}
+.mobile-slack-copy .slack-html,
+.mobile-slack-copy .slack-pdf,
+.mobile-slack-copy .slack-video,
+.mobile-slack-copy .slack-csv {
+ max-width: 100%;
+}
+.mobile-slack-copy .slack-html-frame,
+.mobile-slack-copy .slack-pdf-frame,
+.mobile-slack-copy .slack-video-player {
+ width: 100%;
+ max-width: 100%;
+}
+.mobile-slack-copy .slack-img,
+.mobile-slack-copy .slack-gif {
+ max-width: 100%;
+ border-radius: var(--radius-lg);
+}
+.mobile-slack-reactions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1-5);
+}
+.mobile-slack-reaction {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ min-height: 28px;
+ padding: 0 var(--space-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--bg-2);
+ color: inherit;
+ font: inherit;
+ font-size: var(--type-sm);
+}
+.mobile-slack-reaction.mine {
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+}
+.mobile-slack-msg-actions {
+ display: flex;
+ gap: var(--space-3);
+}
+.mobile-slack-link,
+.mobile-slack-broadcast {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--accent);
+ font: inherit;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ text-align: left;
+}
+.mobile-send-as {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 32px;
+ padding: var(--space-2-5) var(--space-4) 0;
+ border-top: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
+ background: var(--bg);
+ color: var(--faint);
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.mobile-send-as-label {
+ flex: none;
+ color: var(--faint);
+}
+.mobile-send-as button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1-5);
+ margin: 0;
+ flex: none;
+ min-height: 32px;
+ height: 32px;
+ padding: 0 var(--space-3);
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: color-mix(in srgb, var(--text) 16%, var(--bg-2));
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0;
+ text-transform: none;
+}
+.mobile-send-as button .mascot {
+ flex: none;
+ width: 14px;
+ height: 14px;
+ color: currentColor;
+}
+.mobile-send-as button.on {
+ background: var(--accent);
+ color: var(--accent-ink, #111);
+}
+.mobile-slack-picker {
+ padding: var(--space-2-5) var(--space-3);
+ border-top: 1px solid var(--line);
+ background: var(--bg-1);
+}
+.mobile-slack-picker-head {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+ margin-bottom: var(--space-2);
+}
+.mobile-slack-picker-head input {
+ flex: 1;
+ min-height: 40px;
+ font: inherit;
+ font-size: var(--type-lg);
+ color: var(--text);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ padding: var(--space-2) var(--space-3-5);
+}
+.mobile-slack-picker-grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: var(--space-1);
+ max-height: 8rem;
+ overflow: auto;
+}
+.mobile-slack-picker-grid button {
+ min-height: 40px;
+ border: 0;
+ border-radius: var(--radius);
+ background: transparent;
+ font-size: var(--type-xl);
+}
+.mobile-slack-picker-grid button:active {
+ background: var(--hover);
+}
+
+.board-workspace {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ position: relative;
+ background:
+ radial-gradient(
+ circle at 1px 1px,
+ color-mix(in srgb, var(--text) 12%, transparent) 1px,
+ transparent 0
+ )
+ 0 0 / 20px 20px,
+ var(--bg);
+}
+.board-toolbar {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+ align-items: center;
+ padding: var(--space-2) var(--space-2-5);
+ border-bottom: 1px solid var(--line);
+ background: var(--bg-1);
+}
+.board-toolbar button {
+ min-height: 32px;
+ padding: 0 var(--space-2-5);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-sm);
+}
+.board-toolbar button.active,
+.board-toolbar button:hover {
+ background: var(--hover);
+}
+.board-toolbar-gap {
+ flex: 1;
+}
+.board-insert {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-0-5);
+ margin-left: var(--space-1-5);
+ padding-left: var(--space-1-5);
+ border-left: 1px solid var(--line);
+}
+.board-toolbar button:disabled {
+ opacity: 0.4;
+}
+.board-zoom {
+ display: flex;
+ align-items: center;
+ gap: var(--space-0-5);
+ font-size: var(--type-sm);
+ color: var(--text);
+ opacity: 0.85;
+}
+.board-zoom span {
+ min-width: 2.6em;
+ text-align: center;
+}
+.board-frames {
+ position: absolute;
+ left: 12px;
+ top: 8px;
+ z-index: var(--z-chrome);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ max-height: 40%;
+ overflow: auto;
+ padding: var(--space-1);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--bg-1) 88%, transparent);
+ border: 1px solid var(--line);
+}
+.board-frames button {
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ font-size: var(--type-sm);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+}
+.board-frames button.active,
+.board-frames button:hover {
+ background: var(--hover);
+}
+.board-toolbar select {
+ font: inherit;
+ font-size: var(--type-sm);
+ color: var(--text);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: var(--space-1) var(--space-2);
+}
+.board-stage {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+ cursor: default;
+ outline: none;
+}
+.board-stage.tool-pan,
+.board-stage.tool-pan * {
+ cursor: grab;
+}
+.board-stage.tool-sticky,
+.board-stage.tool-text,
+.board-stage.tool-shape,
+.board-stage.tool-draw,
+.board-stage.tool-frame,
+.board-stage.tool-table,
+.board-stage.tool-timeline,
+.board-stage.tool-card,
+.board-stage.tool-comment,
+.board-stage.tool-connector {
+ cursor: crosshair;
+}
+.board-stage.tool-draw .board-obj {
+ pointer-events: none;
+}
+.board-ink-colors {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin-left: var(--space-1);
+ padding-left: var(--space-2);
+ border-left: 1px solid var(--line);
+}
+.board-ink-colors .board-swatch.active {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+.board-world {
+ position: absolute;
+ transform-origin: 0 0;
+ top: 0;
+ left: 0;
+}
+.board-obj {
+ position: absolute;
+ box-sizing: border-box;
+ border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+ color: var(--text);
+ padding: var(--space-2);
+ overflow: hidden;
+ user-select: none;
+ font-size: var(--type-md);
+}
+.board-obj.selected {
+ outline: none;
+}
+.board-obj.sticky {
+ box-shadow: 2px 3px 0 color-mix(in srgb, var(--text) 12%, transparent);
+}
+.board-obj.sticky.stacked {
+ box-shadow:
+ 3px 4px 0 color-mix(in srgb, var(--text) 16%, transparent),
+ 6px 8px 0 color-mix(in srgb, var(--text) 8%, transparent);
+}
+.board-obj.sticky.color-amber {
+ background: #f5d48a;
+ color: #3a2a00;
+}
+.board-obj.sticky.color-mint {
+ background: #b7e0c3;
+ color: #143322;
+}
+.board-obj.sticky.color-sky {
+ background: #b7d4ea;
+ color: #10283c;
+}
+.board-obj.sticky.color-rose {
+ background: #f0c1c8;
+ color: #4a1520;
+}
+.board-obj.sticky.color-violet {
+ background: #d4c2ea;
+ color: #2c1844;
+}
+.board-obj.sticky.color-paper {
+ background: #f4efe6;
+ color: #2b2418;
+}
+.board-obj.frame {
+ background: color-mix(in srgb, var(--bg-1) 55%, transparent);
+ border: 1.5px dashed color-mix(in srgb, var(--text) 35%, transparent);
+ border-radius: var(--radius-lg);
+ padding: var(--space-7) var(--space-2-5) var(--space-2-5);
+}
+.board-frame-title {
+ position: absolute;
+ top: 8px;
+ left: 12px;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+}
+.board-run {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ font: inherit;
+ font-size: var(--type-xs);
+ padding: var(--space-0-5) var(--space-2);
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--line);
+ background: var(--bg-1);
+ color: var(--text);
+}
+.board-obj.card {
+ background: var(--bg-1);
+ border-left: 3px solid var(--accent);
+}
+.board-obj.card.stale {
+ border-left-color: color-mix(in srgb, var(--danger, #c45c6a) 80%, var(--text));
+ opacity: 0.88;
+}
+.board-card-kind {
+ display: block;
+ font-size: var(--type-2xs);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ opacity: 0.7;
+ margin-bottom: var(--space-1);
+}
+.board-card-detail {
+ display: block;
+ margin-top: var(--space-1);
+ font-size: var(--type-xs);
+ opacity: 0.65;
+}
+.board-obj.image {
+ padding: 0;
+ background: var(--bg-1);
+}
+.board-obj.mermaid {
+ padding: var(--space-2);
+ background: var(--bg-1);
+ overflow: auto;
+}
+.board-mermaid-face {
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+}
+.board-mermaid-face svg {
+ max-width: 100%;
+ height: auto;
+}
+.board-mermaid-source {
+ margin: 0;
+ font-size: var(--type-xs);
+ white-space: pre-wrap;
+ font-family: var(--mono);
+}
+.board-present-hint {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ z-index: 5;
+ margin: 0;
+ padding: var(--space-1-5) var(--space-2-5);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--bg-1) 88%, transparent);
+ border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
+}
+.board-pin-plan-list {
+ flex-wrap: wrap;
+ max-height: 240px;
+ overflow: auto;
+}
+.board-obj.ink {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ overflow: visible;
+ box-shadow: none;
+}
+.board-ink {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+.board-ink polyline {
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.board-obj.ink.color-amber {
+ color: #c48a00;
+}
+.board-obj.ink.color-mint {
+ color: #1f7a45;
+}
+.board-obj.ink.color-sky {
+ color: #1a5f8a;
+}
+.board-obj.ink.color-rose {
+ color: #a12d45;
+}
+.board-obj.ink.color-violet {
+ color: #6b3fa0;
+}
+.board-obj.ink.color-paper {
+ color: var(--text);
+}
+.board-obj-image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ pointer-events: none;
+}
+.board-obj.shape {
+ background: transparent;
+ border: 0;
+ padding: 0;
+}
+.board-shape-svg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+.board-shape-svg path {
+ fill: var(--bg-2);
+ stroke: color-mix(in srgb, var(--text) 35%, transparent);
+ stroke-width: 1.8;
+ vector-effect: non-scaling-stroke;
+}
+.board-shape-label {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ box-sizing: border-box;
+ height: 100%;
+ padding: var(--space-3);
+ text-align: center;
+}
+.board-obj.shape textarea,
+.board-obj.shape .board-obj-text {
+ width: 100%;
+ text-align: center;
+}
+.board-shape-pop {
+ position: absolute;
+ z-index: var(--z-float);
+ top: calc(100% + 6px);
+ left: 12px;
+ width: 320px;
+ max-height: min(420px, 70vh);
+ overflow: auto;
+ padding: var(--space-2-5);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--bg-1);
+ box-shadow: var(--shadow);
+}
+.board-shape-overlay {
+ position: absolute;
+ z-index: var(--z-float);
+ pointer-events: auto;
+}
+.board-shape-overlay .board-shape-pop {
+ position: relative;
+ top: 0;
+ left: 0;
+}
+.board-shape-pop-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-2);
+}
+.board-shape-pop-head p {
+ margin: 0;
+ font-weight: var(--weight-semibold);
+}
+.board-shape-group {
+ margin-bottom: var(--space-2-5);
+}
+.board-shape-group h4 {
+ margin: 0 0 var(--space-1-5);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ color: var(--faint);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.board-shape-grid {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: var(--space-1);
+}
+.board-shape-grid button {
+ width: 100%;
+ aspect-ratio: 1;
+ min-height: 40px;
+ padding: var(--space-1-5);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+ color: var(--text);
+}
+.board-shape-grid button:hover,
+.board-shape-grid button.active {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--bg-2));
+}
+.board-shape-glyph {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+.board-shape-glyph path {
+ fill: color-mix(in srgb, var(--text) 12%, var(--bg-2));
+ stroke: var(--text);
+ stroke-width: 3;
+}
+.board-obj.table,
+.board-obj.timeline {
+ padding: 0;
+ overflow: hidden;
+ background: var(--bg-1);
+}
+.board-table,
+.board-timeline {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+.board-widget-tab {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1-5);
+ flex: 0 0 auto;
+ padding: var(--space-1-5) var(--space-2);
+ border-bottom: 1px solid var(--line);
+ background: var(--bg-2);
+}
+.board-widget-kind {
+ font-size: var(--type-sm);
+ opacity: 0.7;
+}
+.board-widget-title {
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: var(--weight-semibold);
+ font-size: var(--type-sm);
+ padding: 0;
+ text-align: left;
+}
+.board-widget-tab input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: var(--weight-semibold);
+ font-size: var(--type-sm);
+}
+.board-table-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+.board-table table {
+ width: max-content;
+ min-width: 100%;
+ border-collapse: collapse;
+ font-size: var(--type-sm);
+}
+.board-table th,
+.board-table td {
+ border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
+ padding: 0;
+ min-width: 88px;
+ height: 28px;
+ vertical-align: stretch;
+}
+.board-table-index {
+ min-width: 36px !important;
+ width: 36px;
+ text-align: center;
+ color: var(--faint);
+ font-size: var(--type-xs);
+}
+.board-table-kind {
+ display: inline-block;
+ margin-right: var(--space-1);
+ opacity: 0.55;
+ font-size: var(--type-xs);
+}
+.board-table th button,
+.board-table-cell {
+ width: 100%;
+ height: 100%;
+ min-height: 28px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ padding: var(--space-1) var(--space-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.board-table th {
+ background: color-mix(in srgb, var(--bg-2) 80%, var(--bg-1));
+ font-weight: var(--weight-semibold);
+}
+.board-table input {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: var(--bg);
+ color: inherit;
+ font: inherit;
+ padding: var(--space-1) var(--space-2);
+}
+.board-table-add {
+ min-width: 36px !important;
+ width: 36px;
+}
+.board-table-add button,
+.board-table-add-row {
+ width: 28px;
+ height: 28px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+}
+.board-table-add-row {
+ margin: var(--space-1);
+}
+.board-timeline-scale select {
+ font: inherit;
+ font-size: var(--type-xs);
+ color: var(--text);
+ background: var(--bg-1);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+.board-timeline-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+.board-timeline-list {
+ width: 140px;
+ flex: 0 0 140px;
+ overflow: auto;
+ border-right: 1px solid var(--line);
+ padding: var(--space-1-5);
+}
+.board-timeline-list p {
+ margin: 0 0 var(--space-1-5);
+ font-size: var(--type-xs);
+ color: var(--faint);
+}
+.board-timeline-list button,
+.board-timeline-list input {
+ display: block;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-size: var(--type-sm);
+ text-align: left;
+ padding: var(--space-1) var(--space-1-5);
+ border-radius: var(--radius-sm);
+}
+.board-timeline-list button:hover {
+ background: var(--hover);
+}
+.board-timeline-chart {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+}
+.board-timeline-head {
+ display: flex;
+ min-width: 480px;
+ border-bottom: 1px solid var(--line);
+}
+.board-timeline-col {
+ flex: 1;
+ min-width: 56px;
+ padding: var(--space-1-5) var(--space-1);
+ font-size: var(--type-xs);
+ color: var(--faint);
+ text-align: center;
+}
+.board-timeline-col span {
+ display: block;
+ font-size: var(--type-2xs);
+}
+.board-timeline-today {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 0;
+ border-left: 1px dashed var(--text);
+ pointer-events: none;
+ z-index: 1;
+}
+.board-timeline-today span {
+ position: absolute;
+ top: 2px;
+ left: 4px;
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+}
+.board-timeline-lanes {
+ position: relative;
+ min-width: 480px;
+ padding: var(--space-2) 0;
+}
+.board-timeline-lane {
+ position: relative;
+ height: 28px;
+ margin: var(--space-1) 0;
+}
+.board-timeline-bar {
+ position: absolute;
+ top: 2px;
+ height: 24px;
+ border: 0;
+ border-radius: var(--radius-md);
+ padding: 0 var(--space-2);
+ font: inherit;
+ font-size: var(--type-xs);
+ text-align: left;
+ overflow: hidden;
+ white-space: nowrap;
+ cursor: grab;
+}
+.board-timeline-bar.color-amber {
+ background: #f5d48a;
+ color: #3a2a00;
+}
+.board-timeline-bar.color-mint {
+ background: #b7e0c3;
+ color: #143322;
+}
+.board-timeline-bar.color-sky {
+ background: #b7d4ea;
+ color: #10283c;
+}
+.board-timeline-bar.color-rose {
+ background: #f0c1c8;
+ color: #4a1520;
+}
+.board-timeline-bar.color-violet {
+ background: #d4c2ea;
+ color: #2c1844;
+}
+.board-timeline-bar.color-paper {
+ background: #f4efe6;
+ color: #2b2418;
+}
+.board-obj textarea {
+ width: 100%;
+ height: 100%;
+ resize: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+}
+.board-obj-text {
+ display: block;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.board-wires {
+ position: absolute;
+ overflow: visible;
+ inset: 0;
+ width: 4000px;
+ height: 4000px;
+ pointer-events: none;
+}
+.board-wires line,
+.board-wires polyline {
+ fill: none;
+ stroke: color-mix(in srgb, var(--text) 55%, transparent);
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+.board-wires line.selected,
+.board-wires polyline.selected {
+ stroke: var(--accent);
+ stroke-width: 3;
+}
+.board-wires polyline.ink {
+ stroke: var(--text);
+ stroke-width: 2.5;
+}
+.board-wires polyline.ink.color-amber {
+ stroke: #c48a00;
+}
+.board-wires polyline.ink.color-mint {
+ stroke: #1f7a45;
+}
+.board-wires polyline.ink.color-sky {
+ stroke: #1a5f8a;
+}
+.board-wires polyline.ink.color-rose {
+ stroke: #a12d45;
+}
+.board-wires polyline.ink.color-violet {
+ stroke: #6b3fa0;
+}
+.board-wires polyline.ink.color-paper {
+ stroke: var(--text);
+}
+.board-marquee {
+ position: absolute;
+ box-sizing: border-box;
+ border: 1px dashed var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ pointer-events: none;
+}
+.board-resize {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ padding: 0;
+ border: 2px solid var(--bg);
+ border-radius: 2px;
+ background: var(--accent);
+ z-index: 3;
+ pointer-events: auto;
+ touch-action: none;
+}
+.board-resize.n {
+ top: -6px;
+ left: 50%;
+ margin-left: -5px;
+ cursor: ns-resize;
+}
+.board-resize.s {
+ bottom: -6px;
+ left: 50%;
+ margin-left: -5px;
+ cursor: ns-resize;
+}
+.board-resize.e {
+ right: -6px;
+ top: 50%;
+ margin-top: -5px;
+ cursor: ew-resize;
+}
+.board-resize.w {
+ left: -6px;
+ top: 50%;
+ margin-top: -5px;
+ cursor: ew-resize;
+}
+.board-resize.ne {
+ top: -6px;
+ right: -6px;
+ cursor: nesw-resize;
+}
+.board-resize.nw {
+ top: -6px;
+ left: -6px;
+ cursor: nwse-resize;
+}
+.board-resize.se {
+ right: -6px;
+ bottom: -6px;
+ cursor: nwse-resize;
+}
+.board-resize.sw {
+ left: -6px;
+ bottom: -6px;
+ cursor: nesw-resize;
+}
+.board-port {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ padding: 0;
+ border: 2px solid var(--bg);
+ border-radius: var(--radius-pill);
+ background: var(--accent);
+ z-index: 5;
+ pointer-events: auto;
+ touch-action: none;
+}
+.board-port.n {
+ top: -20px;
+ left: 50%;
+ margin-left: -7px;
+ cursor: crosshair;
+}
+.board-port.s {
+ bottom: -20px;
+ left: 50%;
+ margin-left: -7px;
+ cursor: crosshair;
+}
+.board-port.e {
+ right: -20px;
+ top: 50%;
+ margin-top: -7px;
+ cursor: crosshair;
+}
+.board-port.w {
+ left: -20px;
+ top: 50%;
+ margin-top: -7px;
+ cursor: crosshair;
+}
+.board-overlay {
+ position: absolute;
+ z-index: 6;
+ box-sizing: border-box;
+ border: 2px solid var(--accent);
+ pointer-events: none;
+}
+.board-overlay-bar {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 10px);
+ z-index: 7;
+ display: flex;
+ flex-wrap: nowrap;
+ flex-direction: row;
+ gap: var(--space-0-5);
+ align-items: center;
+ width: max-content;
+ max-width: none;
+ padding: var(--space-1);
+ transform: translateX(-50%);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-1);
+ box-shadow: var(--shadow);
+ pointer-events: auto;
+ white-space: nowrap;
+}
+.board-overlay-bar.below {
+ bottom: auto;
+ top: calc(100% + 10px);
+}
+.board-overlay-size {
+ padding: 0 var(--space-1-5);
+ color: var(--faint);
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ white-space: nowrap;
+}
+.board-overlay-sep {
+ width: 1px;
+ height: 16px;
+ margin: 0 var(--space-1);
+ background: var(--line);
+}
+.board-overlay-bar .board-overlay-btn {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-bold);
+}
+.board-overlay-bar .board-overlay-btn:hover {
+ background: var(--hover);
+}
+.board-overlay-bar .board-overlay-btn.ask {
+ color: var(--accent);
+}
+.board-overlay-bar .board-overlay-btn.danger {
+ color: var(--danger);
+}
+.board-overlay-bar .board-swatch {
+ width: 14px;
+ height: 14px;
+ min-height: 14px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+}
+.board-toolbar .board-ask {
+ color: var(--accent);
+ font-weight: var(--weight-semibold);
+}
+.board-ctx-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-menu);
+ border: 0;
+ background: transparent;
+}
+.board-ctx {
+ position: fixed;
+ z-index: var(--z-menu-raised);
+ min-width: 220px;
+}
+.board-wires text {
+ fill: var(--text);
+ font-size: var(--type-sm);
+}
+.board-comment-dot {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+}
+.board-swatch {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 1px solid var(--line);
+}
+.board-swatch.color-amber {
+ background: #f5d48a;
+}
+.board-swatch.color-mint {
+ background: #b7e0c3;
+}
+.board-swatch.color-sky {
+ background: #b7d4ea;
+}
+.board-swatch.color-rose {
+ background: #f0c1c8;
+}
+.board-swatch.color-violet {
+ background: #d4c2ea;
+}
+.board-swatch.color-paper {
+ background: #f4efe6;
+}
+.board-pop {
+ position: absolute;
+ z-index: 4;
+ right: 16px;
+ bottom: 16px;
+ width: min(360px, calc(100% - 32px));
+ padding: var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--bg-1);
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--text) 12%, transparent);
+}
+.board-pop p {
+ margin: 0 0 var(--space-2);
+ font-weight: var(--weight-semibold);
+}
+.board-pop textarea,
+.board-pop input,
+.board-pop select {
+ width: 100%;
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+.board-pop-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1-5);
+}
+.board-card-list {
+ max-height: 180px;
+ overflow: auto;
+ margin: var(--space-2) 0;
+ padding: 0;
+ list-style: none;
+}
+.board-card-list button {
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ padding: var(--space-1-5) var(--space-1);
+ border-radius: var(--radius-sm);
+}
+.board-card-list button:hover {
+ background: var(--hover);
+}
+.board-comments {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ margin: 0;
+ padding: var(--space-2) var(--space-2-5);
+ max-width: 280px;
+ list-style: none;
+ background: var(--bg-1);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ font-size: var(--type-sm);
+}
+.board-comments li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-1-5);
+ margin: var(--space-1) 0;
+}
+
+.project-fleet {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ height: 100%;
+ overflow: auto;
+ padding: var(--space-3-5) var(--space-4) 18px;
+}
+.project-fleet-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-2-5);
+}
+.project-fleet-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.project-fleet h3 {
+ margin: 0 0 var(--space-1-5);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ opacity: 0.72;
+}
+.project-fleet .meta {
+ margin: 0;
+ font-size: var(--type-sm);
+ opacity: 0.72;
+}
+.project-fleet-column {
+ min-width: 0;
+}
+.project-fleet-column h3 .meta {
+ font-weight: var(--weight-medium);
+ letter-spacing: 0;
+ text-transform: none;
+ margin-left: var(--space-1);
+}
+.project-fleet-empty {
+ margin: 0 0 var(--space-1);
+}
+.project-fleet-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ width: 100%;
+ margin: 0 0 var(--space-1-5);
+ padding: var(--space-2) var(--space-2);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.project-fleet-row:hover {
+ background: var(--bg-2);
+}
+.project-fleet-row.on {
+ background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+.project-fleet-row .title {
+ font-weight: var(--weight-semibold);
+}
+.project-fleet-title {
+ margin: 0 0 var(--space-1);
+ font-size: var(--type-lg);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0;
+ text-transform: none;
+ opacity: 1;
+}
+.project-roster {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-0-5);
+ min-width: 0;
+}
+.project-teammate-strip {
+ padding: var(--space-2-5) var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg));
+}
+.project-teammate-strip .title {
+ margin: 0 0 var(--space-1);
+ font-weight: var(--weight-semibold);
+}
+.project-teammate-brief {
+ margin: var(--space-2) 0 0;
+ font-size: var(--type-sm);
+ line-height: 1.45;
+ white-space: pre-wrap;
+}
+.project-briefing-list {
+ list-style: none;
+ padding-left: 0;
+}
+.project-briefing-file {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-2);
+ width: 100%;
+ margin: 0;
+ padding: var(--space-1-5) var(--space-2);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.project-briefing-file:hover,
+.project-briefing-file.on {
+ background: var(--bg-2);
+}
+.project-briefing-viewer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+ padding: var(--space-2-5) var(--space-2-5) var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--bg-1);
+}
+.project-briefing-viewer:focus-within {
+ border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
+}
+.project-briefing-viewer-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ margin: 0;
+}
+.project-briefing-editor {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 12rem;
+ max-height: 42vh;
+ margin: 0;
+ padding: var(--space-2-5) var(--space-3);
+ border: 0;
+ border-radius: var(--radius-md);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
+ font-size: var(--type-md);
+ line-height: 1.5;
+ resize: vertical;
+ outline: none;
+ caret-color: var(--accent);
+}
+.project-briefing-editor:focus,
+.project-briefing-editor:focus-visible {
+ outline: none;
+}
+.project-briefing-viewer > .btn {
+ align-self: flex-start;
+}
+.project-briefing-body {
+ margin: 0;
+ max-height: 220px;
+ overflow: auto;
+ white-space: pre-wrap;
+ font-size: var(--type-sm);
+ line-height: 1.45;
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
+}
+.project-fleet-more {
+ border-top: 1px solid var(--line);
+ padding-top: var(--space-2);
+}
+.project-fleet-more > summary {
+ cursor: pointer;
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ opacity: 0.72;
+ list-style: none;
+}
+.project-fleet-more > summary::-webkit-details-marker {
+ display: none;
+}
+.project-fleet-more-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ margin-top: var(--space-3);
+}
+.project-fleet-files {
+ margin: 0;
+ padding-left: var(--space-4);
+ font-size: var(--type-sm);
+}
+.project-fleet-files li {
+ margin: 3px 0;
+}
+.project-need-card {
+ padding: var(--space-2-5);
+ border: 1px solid color-mix(in srgb, var(--warn, #d99b3d) 45%, var(--line));
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--warn, #d99b3d) 8%, var(--bg));
+}
+.project-need-card + .project-need-card {
+ margin-top: var(--space-2);
+}
+.project-need-card p {
+ margin: 5px 0 9px;
+ font-size: var(--type-sm);
+ line-height: 1.4;
+}
+.project-need-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+.project-team-switcher {
+ display: flex;
+ align-items: end;
+ gap: var(--space-2);
+ padding-bottom: var(--space-0-5);
+}
+.project-team-switcher label,
+.project-handoff-compose {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--space-1-5);
+ min-width: 0;
+}
+.project-team-switcher select,
+.project-handoff-compose select,
+.project-handoff-compose textarea,
+.project-team-name-input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+ color: inherit;
+ font: inherit;
+}
+.project-team-switcher select,
+.project-handoff-compose select,
+.project-team-name-input,
+.team-editor select {
+ min-height: 32px;
+ padding: var(--space-1-5) var(--space-2);
+}
+.team-editor {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3-5);
+}
+.team-editor form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.team-editor label {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ font-size: var(--type-sm);
+}
+.team-editor select,
+.team-editor textarea.project-team-name-input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+ color: inherit;
+ font: inherit;
+}
+.team-editor textarea.project-team-name-input {
+ min-height: 62px;
+ padding: var(--space-2);
+ resize: vertical;
+}
+.project-handoff-compose textarea {
+ min-height: 62px;
+ padding: var(--space-2);
+ resize: vertical;
+}
+.project-handoff-list {
+ display: grid;
+ gap: var(--space-2);
+ margin: var(--space-2-5) 0 0;
+ padding: 0;
+ list-style: none;
+}
+.project-handoff-card {
+ padding: 9px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--accent) 5%, var(--bg));
+}
+.project-handoff-card p {
+ margin: 5px 0;
+ font-size: var(--type-sm);
+ line-height: 1.4;
+}
+.project-handoff-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-top: 7px;
+}
diff --git a/apps/desktop/src/styles/swarm.css b/apps/desktop/src/styles/swarm.css
new file mode 100644
index 0000000..2cb997b
--- /dev/null
+++ b/apps/desktop/src/styles/swarm.css
@@ -0,0 +1,869 @@
+/* Locus Swarm product mode */
+.persona-avatar {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--persona-accent, var(--accent)) 22%, transparent);
+ color: var(--persona-accent, var(--text));
+}
+.persona-avatar-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1-5);
+}
+.persona-avatar-picker > button {
+ padding: var(--space-0-5);
+ border: 1px solid transparent;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ cursor: pointer;
+}
+.persona-avatar-picker > button.on,
+.persona-avatar-picker > button[aria-checked="true"] {
+ border-color: var(--accent);
+}
+.product-mode-toggle {
+ display: flex;
+ gap: var(--space-0-5);
+ padding: var(--space-0-5);
+ border-radius: var(--radius-md);
+ background: var(--bg-2);
+}
+.product-mode-toggle.compact {
+ flex-direction: column;
+ width: calc(100% - 8px);
+ max-width: 52px;
+ margin: 0 auto var(--space-1-5);
+ box-sizing: border-box;
+}
+.product-mode-toggle > button {
+ flex: 1;
+ border: 0;
+ border-radius: var(--radius-sm);
+ padding: var(--space-1) var(--space-1-5);
+ font-size: var(--type-2xs);
+ line-height: 1.2;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.product-mode-toggle.compact > button {
+ padding: 5px var(--space-0-5);
+ font-size: var(--type-2xs);
+ letter-spacing: 0.01em;
+}
+.product-mode-toggle > button.on {
+ background: var(--bg);
+ color: var(--text);
+ font-weight: var(--weight-semibold);
+}
+.hub-persona-fields {
+ display: grid;
+ gap: var(--space-2-5);
+ margin: var(--space-3) 0;
+}
+.hub-persona-avatar {
+ padding: var(--space-2);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-2, var(--bg));
+}
+.team-event-chip {
+ font-size: var(--type-2xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--muted);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ padding: 1px var(--space-1-5);
+}
+.msg.team-event .bubble,
+.msg.team-event .markdown {
+ opacity: 0.92;
+}
+.hub-computer-mode-meta {
+ margin: var(--space-0-5) 0 0;
+ font-size: var(--type-xs);
+ color: var(--muted);
+ font-weight: var(--weight-medium);
+}
+.team-peers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1-5);
+ margin-bottom: var(--space-2-5);
+}
+.team-peer-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1-5);
+ padding: var(--space-1) var(--space-2) var(--space-1) var(--space-1);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-pill);
+ background: var(--bg);
+ color: inherit;
+ font-size: var(--type-sm);
+ cursor: pointer;
+}
+.team-peer-chip:hover {
+ background: var(--bg-2);
+}
+.project-fleet-row .title {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1-5);
+}
+.subagent-fold-label {
+ margin: 0 0 var(--space-1-5);
+ font-size: var(--type-xs);
+ font-weight: var(--weight-semibold);
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.session-brain-list {
+ margin: var(--space-1) 0 0;
+ padding-left: var(--space-4);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.desktop-shell[data-product-mode="swarm"] .side-rail-nav .rail-label {
+ font-size: var(--type-2xs);
+}
+
+.desktop-shell[data-product-mode="swarm"] .sidebar.swarm-sidebar {
+ display: flex;
+}
+.desktop-shell[data-product-mode="swarm"] .app.collapsed {
+ /* No icon rail in Swarm — collapsing would hide the only nav column. */
+ grid-template-columns: 300px 1fr;
+}
+.desktop-shell[data-product-mode="swarm"] .app.collapsed .side-main {
+ /* Keep Swarm list usable even if collapsed flag is set. */
+ visibility: visible;
+ pointer-events: auto;
+}
+.desktop-shell[data-product-mode="swarm"] .brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2-5);
+}
+.swarm-shell-nav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-1);
+ margin: 0 var(--space-3) var(--space-2);
+ padding: 3px;
+ border-radius: var(--radius);
+ background: var(--bg-2);
+}
+.swarm-shell-nav button {
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ min-height: 28px;
+ cursor: pointer;
+}
+.swarm-shell-nav button.active {
+ background: var(--bg);
+ box-shadow: 0 0 0 1px var(--line);
+}
+
+/* Locus Swarm messenger shell */
+.desktop-shell[data-product-mode="swarm"] .app:not(.collapsed) {
+ grid-template-columns: 300px 1fr;
+}
+.desktop-shell[data-product-mode="swarm"] .session-item.hub-session-item {
+ border-radius: var(--radius-lg);
+ margin: var(--space-0-5) var(--space-1-5);
+ padding: var(--space-2) var(--space-2-5);
+}
+.desktop-shell[data-product-mode="swarm"] .session-item.hub-session-item.active {
+ background: color-mix(in srgb, var(--text) 8%, var(--bg-1));
+}
+.desktop-shell[data-product-mode="swarm"] .hub-session-item .session-item-open {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ width: 100%;
+ text-align: left;
+ position: relative;
+}
+.desktop-shell[data-product-mode="swarm"] .hub-session-item .session-item-copy {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-0-5);
+}
+.desktop-shell[data-product-mode="swarm"] .session-row-main {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ width: 100%;
+}
+.desktop-shell[data-product-mode="swarm"] .session-row-main .title {
+ flex: 1;
+ min-width: 0;
+}
+.desktop-shell[data-product-mode="swarm"] .session-stamp {
+ flex-shrink: 0;
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+.desktop-shell[data-product-mode="swarm"] .hub-session-item .meta {
+ color: var(--muted);
+ font-size: var(--type-sm);
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.desktop-shell[data-product-mode="swarm"] .hub-session-item .dot {
+ position: absolute;
+ left: 34px;
+ top: 34px;
+ width: 8px;
+ height: 8px;
+}
+.desktop-shell[data-product-mode="swarm"] .hub-topbar .hub-session-title {
+ margin: 0;
+ font-size: var(--type-lg);
+ font-weight: var(--weight-semibold);
+ border: 0;
+ background: transparent;
+ color: inherit;
+}
+
+/* Messenger bubbles — WhatsApp / iMessage style */
+.desktop-shell[data-product-mode="swarm"] .chat .thread {
+ padding: 18px 22px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-0-5);
+}
+.desktop-shell[data-product-mode="swarm"] .chat-new-divider {
+ max-width: none;
+ margin: var(--space-2-5) 0 var(--space-3);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg {
+ max-width: none;
+ width: 100%;
+ margin: 0 0 var(--space-2-5);
+ display: flex;
+ flex-direction: column;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user {
+ align-items: flex-end;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant,
+.desktop-shell[data-product-mode="swarm"] .chat .msg.system,
+.desktop-shell[data-product-mode="swarm"] .chat .msg.tool {
+ align-items: flex-start;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg .who {
+ margin: 0 var(--space-1) var(--space-1);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .who {
+ width: 100%;
+ justify-content: flex-end;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg .bubble {
+ max-width: min(78%, 540px);
+ padding: var(--space-2-5) var(--space-3-5);
+ border: 0;
+ box-shadow: none;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg .bubble.working {
+ display: inline-flex;
+ width: auto;
+}
+.desktop-shell[data-product-mode="swarm"] .working-copy {
+ flex: 0 1 auto;
+}
+.desktop-shell[data-product-mode="swarm"] .working-thought {
+ width: auto;
+ max-width: 46ch;
+ -webkit-line-clamp: 2;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble {
+ background: #0a84ff;
+ color: #ffffff;
+ border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xs) var(--radius-xl);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble,
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble * {
+ color: #ffffff !important;
+ --text: #ffffff;
+ --muted: color-mix(in srgb, #ffffff 72%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .bubble a {
+ color: #e8f3ff !important;
+ text-decoration: underline;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user .msg-actions .btn {
+ color: #ffffff;
+ border-color: color-mix(in srgb, #fff 35%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble {
+ background: color-mix(in srgb, var(--text) 12%, var(--bg-1));
+ color: var(--text);
+ border-radius: var(--radius-xl) var(--radius-xl) var(--radius-xl) var(--radius-xs);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble em {
+ color: var(--accent);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant .bubble strong {
+ color: var(--text);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.continued {
+ margin-top: -4px;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.continued .bubble {
+ border-radius: var(--radius-xl);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.user.continued .bubble {
+ border-radius: var(--radius-xl) var(--radius-xs) var(--radius-xs) var(--radius-xl);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.assistant.continued .bubble {
+ border-radius: var(--radius-xs) var(--radius-xl) var(--radius-xl) var(--radius-xs);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg .who {
+ font-size: var(--type-sm);
+ font-weight: var(--weight-semibold);
+ color: var(--muted);
+ opacity: 0.9;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg .msg-actions {
+ opacity: 0;
+ transition: opacity var(--dur-fast) var(--ease-standard);
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg:hover .msg-actions,
+.desktop-shell[data-product-mode="swarm"] .chat .msg:focus-within .msg-actions {
+ opacity: 1;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .msg.team-event .bubble {
+ background: transparent;
+ border: 0;
+ color: var(--muted);
+ font-size: var(--type-md);
+ padding: var(--space-1) var(--space-2);
+ max-width: 100%;
+}
+.desktop-shell[data-product-mode="swarm"] .chat .composer {
+ border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
+ background: color-mix(in srgb, var(--bg-1) 88%, transparent);
+}
+
+/* Grok Bot–inspired Swarm atmosphere (messenger, not control plane) */
+.desktop-shell[data-product-mode="swarm"] {
+ --swarm-roster-bg: color-mix(in srgb, var(--bg) 88%, var(--text) 4%);
+ --swarm-thread-bg: color-mix(in srgb, var(--bg) 94%, var(--accent, var(--text)) 3%);
+}
+.desktop-shell[data-product-mode="swarm"] .sidebar {
+ background:
+ radial-gradient(
+ 120% 80% at 0% 0%,
+ color-mix(in srgb, var(--accent, #6b8cff) 12%, transparent),
+ transparent 55%
+ ),
+ var(--swarm-roster-bg);
+ border-right: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .workspace {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg) 92%, transparent),
+ var(--swarm-thread-bg)
+ );
+}
+.desktop-shell[data-product-mode="swarm"] .hub-persona-fields {
+ display: grid;
+ grid-template-columns: minmax(220px, 0.9fr) minmax(320px, 1.3fr);
+ gap: var(--space-4) var(--space-5);
+ align-items: start;
+ margin: 0 0 var(--space-3);
+ padding: var(--space-3-5) var(--space-4);
+ border-radius: var(--radius-xl);
+ background: color-mix(in srgb, var(--text) 5%, var(--bg));
+ border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .hub-persona-identity {
+ display: grid;
+ gap: var(--space-2-5);
+ min-width: 0;
+}
+.desktop-shell[data-product-mode="swarm"] .hub-persona-avatar {
+ grid-column: auto;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+.desktop-shell[data-product-mode="swarm"] .hub-persona-avatar > span:first-child {
+ display: block;
+ margin-bottom: var(--space-2);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.desktop-shell[data-product-mode="swarm"] .empty.hub-start {
+ padding: var(--space-5) var(--space-7);
+ align-items: stretch;
+ justify-content: center;
+ overflow: auto;
+}
+.desktop-shell[data-product-mode="swarm"] .empty.hub-start .empty-card {
+ width: min(960px, 100%);
+ max-width: none;
+ text-align: left;
+ margin: 0 auto;
+}
+.desktop-shell[data-product-mode="swarm"] .empty.hub-start h2 {
+ font-size: 1.35rem;
+ letter-spacing: -0.02em;
+ margin: 0 0 var(--space-1);
+}
+.desktop-shell[data-product-mode="swarm"] .empty.hub-start .empty-card > p {
+ margin: 0 0 var(--space-3-5);
+ max-width: 62ch;
+}
+@media (max-width: 900px) {
+ .desktop-shell[data-product-mode="swarm"] .hub-persona-fields {
+ grid-template-columns: 1fr;
+ }
+}
+.desktop-shell[data-product-mode="swarm"] .msg.has-speaker .who {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1-5);
+ font-weight: var(--weight-semibold);
+}
+.desktop-shell[data-product-mode="swarm"] .hub-computer-panel {
+ background: linear-gradient(165deg, color-mix(in srgb, var(--text) 4%, var(--bg-1)), var(--bg-1));
+ border-left: 1px solid color-mix(in srgb, var(--line) 75%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"]
+ .hub-computer-panel[data-computer-mode="preview"]
+ .hub-computer-screen-body,
+.desktop-shell[data-product-mode="swarm"]
+ .hub-computer-panel[data-computer-mode="takeover"]
+ .hub-computer-screen-body {
+ box-shadow: 0 12px 40px color-mix(in srgb, var(--text) 12%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .team-peers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ padding: var(--space-2) 0 var(--space-3);
+}
+.desktop-shell[data-product-mode="swarm"] .team-peer-chip {
+ border-radius: var(--radius-pill);
+ padding: var(--space-1-5) var(--space-2-5) var(--space-1-5) var(--space-1-5);
+ background: color-mix(in srgb, var(--text) 6%, var(--bg));
+}
+.desktop-shell[data-product-mode="swarm"] .project-briefing-viewer {
+ border-radius: var(--radius-xl);
+ background: color-mix(in srgb, var(--text) 5%, var(--bg));
+ border-color: color-mix(in srgb, var(--line) 80%, transparent);
+}
+.desktop-shell[data-product-mode="swarm"] .project-briefing-editor {
+ background: var(--bg);
+ border-radius: var(--radius);
+}
+.hub-computer-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3-5);
+ padding: var(--space-3-5) var(--space-3-5) 18px;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ background: var(--bg-1);
+}
+.hub-computer-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+.hub-computer-title {
+ flex: 1;
+ min-width: 0;
+}
+.hub-computer-title h2 {
+ margin: 0;
+ font-size: var(--type-lg);
+ font-weight: var(--weight-semibold);
+}
+.hub-computer-head-actions {
+ display: flex;
+ gap: var(--space-0-5);
+}
+.hub-computer-status {
+ padding: var(--space-3) var(--space-3-5);
+ border-radius: var(--radius-xl);
+ background: color-mix(in srgb, var(--text) 6%, var(--bg));
+ border: 1px solid var(--line);
+}
+.hub-computer-status-host {
+ margin: 0 0 var(--space-1);
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+.hub-computer-status-title {
+ margin: 0;
+ font-size: var(--type-body);
+ font-weight: var(--weight-semibold);
+}
+.hub-computer-status-detail {
+ margin: var(--space-1-5) 0 0;
+ font-size: var(--type-sm);
+ color: var(--muted);
+ line-height: 1.4;
+}
+.hub-computer-screen-block {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.hub-computer-screen-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2-5);
+}
+.hub-computer-screen-label {
+ margin: 0;
+ font-size: var(--type-md);
+ color: var(--muted);
+}
+.hub-computer-screen-body {
+ min-height: 180px;
+ max-height: 42vh;
+ overflow: auto;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: var(--bg);
+}
+.hub-embedded-workspace {
+ display: flex;
+ flex-direction: column;
+ min-height: 180px;
+ height: 100%;
+}
+.hub-routines h3 {
+ margin: 0 0 var(--space-2);
+ font-size: var(--type-md);
+ font-weight: var(--weight-semibold);
+}
+.hub-routines-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-0-5);
+}
+.hub-routine-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-1-5);
+ border: 0;
+ border-radius: var(--radius);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ text-align: left;
+}
+.hub-routine-row:hover {
+ background: color-mix(in srgb, var(--text) 6%, transparent);
+}
+.hub-routine-icon {
+ display: inline-flex;
+ color: color-mix(in srgb, #e8a317 80%, var(--text));
+ flex-shrink: 0;
+}
+.hub-routine-name {
+ flex: 1;
+ min-width: 0;
+ font-size: var(--type-md);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.hub-routine-when {
+ flex-shrink: 0;
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.hub-routine-new,
+.hub-routines-empty {
+ margin: var(--space-1-5) 0 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: var(--type-md);
+ cursor: pointer;
+}
+.hub-routine-new:hover {
+ color: var(--text);
+}
+
+.persona-avatar-editor {
+ display: grid;
+ gap: var(--space-2-5);
+}
+.persona-avatar-editor .persona-avatar-controls {
+ display: grid;
+ gap: var(--space-2-5);
+}
+.persona-avatar-editor .persona-avatar-option-row {
+ display: grid;
+ gap: var(--space-2-5);
+}
+.persona-avatar-editor.panel {
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: var(--space-3) 18px;
+ align-items: start;
+}
+.persona-avatar-editor.panel .persona-avatar-preview-row {
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ text-align: center;
+ min-width: 96px;
+}
+.persona-avatar-editor.panel .persona-avatar-caption {
+ margin: 0;
+ font-size: var(--type-sm);
+ line-height: 1.3;
+ max-width: 9rem;
+}
+.persona-avatar-editor.panel .persona-avatar-controls {
+ gap: var(--space-2);
+}
+.persona-avatar-editor.panel .persona-avatar-option-row {
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-2) var(--space-3);
+}
+.persona-avatar-editor.panel .field {
+ margin: 0;
+}
+.persona-avatar-editor.panel .field > span,
+.persona-avatar-editor.panel .field > label {
+ display: block;
+ margin-bottom: var(--space-1);
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+.persona-avatar-editor.panel .theme-filter-pills {
+ gap: var(--space-1);
+}
+.persona-avatar-editor.panel .theme-filter-pills > button {
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--type-sm);
+}
+.persona-avatar-preview-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+.persona-avatar-caption {
+ margin: 0;
+}
+.persona-accent-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+.hub-teammate-modal {
+ width: min(780px, calc(100vw - 32px));
+ max-height: min(90vh, 820px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+.hub-teammate-modal .hub-persona-fields,
+.desktop-shell[data-product-mode="swarm"] .hub-teammate-modal .hub-persona-fields {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr);
+ gap: var(--space-3-5) var(--space-4);
+ align-items: start;
+ min-width: 0;
+ margin: 0 0 var(--space-3);
+ padding: var(--space-3) var(--space-3-5);
+ box-sizing: border-box;
+}
+.hub-teammate-modal .hub-persona-identity {
+ display: grid;
+ gap: var(--space-2-5);
+ min-width: 0;
+}
+.hub-teammate-modal .hub-persona-avatar {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ min-width: 0;
+ max-width: 100%;
+}
+.hub-teammate-modal .persona-avatar-editor,
+.hub-teammate-modal .persona-avatar-editor.panel .persona-avatar-controls {
+ min-width: 0;
+ max-width: 100%;
+}
+.hub-teammate-modal .persona-avatar-editor.panel {
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: var(--space-2-5) var(--space-3-5);
+}
+.hub-teammate-modal .persona-avatar-editor.panel .persona-avatar-option-row {
+ grid-template-columns: 1fr;
+ gap: var(--space-2);
+}
+.hub-teammate-modal .persona-avatar-editor.panel .theme-filter-pills {
+ flex-wrap: wrap;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ border-radius: var(--radius-xl);
+ row-gap: var(--space-1);
+}
+.hub-teammate-modal .persona-avatar-editor.panel .theme-filter-pills > button {
+ flex: 0 1 auto;
+}
+.hub-teammate-modal .persona-accent-row {
+ width: 100%;
+ min-width: 0;
+}
+@media (max-width: 560px) {
+ .persona-avatar-editor.panel {
+ grid-template-columns: 1fr;
+ }
+ .persona-avatar-editor.panel .persona-avatar-option-row,
+ .hub-teammate-modal .hub-persona-fields,
+ .desktop-shell[data-product-mode="swarm"] .hub-teammate-modal .hub-persona-fields {
+ grid-template-columns: 1fr;
+ }
+}
+.persona-accent-row input[type="color"] {
+ width: 36px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: transparent;
+}
+.persona-accent-row input[type="text"] {
+ flex: 1;
+ min-width: 0;
+}
+.persona-acc {
+ position: absolute;
+ pointer-events: none;
+}
+.persona-acc-antenna {
+ top: 2%;
+ left: 50%;
+ width: 2px;
+ height: 28%;
+ transform: translateX(-50%);
+ background: var(--persona-accent, var(--accent));
+ border-radius: 1px;
+}
+.persona-acc-antenna::after {
+ content: "";
+ position: absolute;
+ top: -3px;
+ left: 50%;
+ width: 7px;
+ height: 7px;
+ transform: translateX(-50%);
+ border-radius: 50%;
+ background: var(--persona-accent, var(--accent));
+}
+.persona-acc-badge {
+ right: 4%;
+ bottom: 8%;
+ width: 28%;
+ height: 28%;
+ border-radius: 50%;
+ background: var(--persona-accent, var(--accent));
+ box-shadow: 0 0 0 2px var(--bg);
+}
+.persona-acc-bow {
+ top: -2%;
+ left: 50%;
+ width: 42%;
+ height: 18%;
+ transform: translateX(-50%);
+ background: var(--persona-accent, var(--accent));
+ clip-path: polygon(0 50%, 35% 0, 50% 35%, 65% 0, 100% 50%, 65% 100%, 50% 65%, 35% 100%);
+}
+.codebase-start-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+}
+.codebase-start-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-1);
+}
+.codebase-start-card h3 {
+ margin: 0;
+ font-size: var(--type-body);
+}
+button.linkish {
+ display: inline;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--accent);
+ text-decoration: underline;
+ cursor: pointer;
+ font: inherit;
+}
+.workspace-docker-inspect-meta {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ padding: var(--space-3);
+}
+.workspace-docker-inspect-meta div {
+ display: grid;
+ gap: var(--space-0-5);
+}
+.workspace-docker-inspect-meta dt {
+ font-size: var(--type-xs);
+ color: var(--muted);
+}
+.workspace-docker-inspect-meta dd {
+ margin: 0;
+ font-size: var(--type-md);
+}
+.theme-recents {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
diff --git a/apps/desktop/src/styles/ux.css b/apps/desktop/src/styles/ux.css
index f778d10..4cbb715 100644
--- a/apps/desktop/src/styles/ux.css
+++ b/apps/desktop/src/styles/ux.css
@@ -540,3 +540,50 @@ mark {
grid-template-columns: minmax(0, 1fr) auto 28px;
}
}
+
+.first-task-card {
+ margin: 0 0 var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg));
+}
+.first-task-card h3 {
+ margin: 0 0 var(--space-1);
+ font-size: var(--type-md);
+}
+.first-task-card p {
+ margin: 0 0 var(--space-2);
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
+.first-task-card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.mobile-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-modal);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+}
+.mobile-overlay-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+.mobile-find-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-bottom: 1px solid var(--border);
+}
+.mobile-find-count {
+ flex-shrink: 0;
+ font-size: var(--type-sm);
+ color: var(--muted);
+}
diff --git a/apps/desktop/src/visual.tsx b/apps/desktop/src/visual.tsx
index 7c0a549..5e293ea 100644
--- a/apps/desktop/src/visual.tsx
+++ b/apps/desktop/src/visual.tsx
@@ -30,6 +30,8 @@ import { showUndoNotice } from "@/lib/notice";
import { applyTheme } from "@/lib/theme";
import { Store, initialState, type Action, type UiState } from "@/state/store";
import "./index.css";
+import "./styles/phone.css";
+import "./styles/swarm.css";
import "./styles/ux.css";
const SCREENS = [
@@ -124,7 +126,7 @@ function stubApi(): void {
api.checkUpdates = async () =>
({
status: "unavailable",
- current: "0.17.0",
+ current: "0.18.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 3dfbb74..29ca447 100644
--- a/apps/desktop/test/pack.test.ts
+++ b/apps/desktop/test/pack.test.ts
@@ -12,6 +12,7 @@ const desktopRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const SERVER_BUNDLE_BANNER =
"import { createRequire as __locusCreateRequire } from 'node:module'; const require = __locusCreateRequire(import.meta.url);";
const pkg = JSON.parse(readFileSync(join(desktopRoot, "package.json"), "utf8")) as {
+ homepage?: string;
devDependencies?: { electron?: string };
build?: {
productName?: string;
@@ -19,7 +20,9 @@ const pkg = JSON.parse(readFileSync(join(desktopRoot, "package.json"), "utf8"))
executableName?: string;
icon?: string;
directories?: { output?: string; buildResources?: string };
- linux?: { target?: string[]; executableName?: string };
+ linux?: { target?: string[]; executableName?: string; packageName?: string };
+ deb?: { artifactName?: string; packageName?: string };
+ rpm?: { artifactName?: string; packageName?: string };
mac?: { target?: string[] };
win?: { target?: string[] };
extraResources?: Array<{ from?: string; to?: string }>;
@@ -50,8 +53,14 @@ describe("electron packaging", () => {
expect(pkg.build?.executableName).toBe("locus");
expect(pkg.build?.electronVersion).toBe("45.2.2");
expect(pkg.devDependencies?.electron).toBe(pkg.build?.electronVersion);
- expect(pkg.build?.linux?.target).toEqual(["AppImage"]);
+ expect(pkg.build?.linux?.target).toEqual(["AppImage", "deb", "rpm"]);
expect(pkg.build?.linux?.executableName).toBe("locus");
+ expect(pkg.homepage).toMatch(/^https:\/\/github\.com\/Modsofthenation\/Locus/);
+ expect(pkg.build?.linux?.packageName).toBeUndefined();
+ expect(pkg.build?.deb?.packageName).toBe("locus");
+ expect(pkg.build?.rpm?.packageName).toBe("locus");
+ expect(pkg.build?.deb?.artifactName).toBe("${productName}-${version}-${arch}.${ext}");
+ expect(pkg.build?.rpm?.artifactName).toBe("${productName}-${version}-${arch}.${ext}");
expect(pkg.build?.mac?.target).toEqual(["dmg"]);
expect(pkg.build?.win?.target).toEqual(["nsis"]);
expect(pkg.build?.directories?.output).toBe("dist-electron");
@@ -95,6 +104,19 @@ describe("electron packaging", () => {
);
});
+ it("passes the electron-builder 26 configuration schema", () => {
+ const requireFromTest = createRequire(import.meta.url);
+ const schema = requireFromTest("app-builder-lib/scheme.json") as unknown;
+ const { validateSchema } = requireFromTest(
+ "app-builder-lib/out/util/config/schemaValidator.js",
+ ) as {
+ validateSchema: (schema: unknown, data: unknown, config?: { name?: string }) => void;
+ };
+ expect(() =>
+ validateSchema(schema, pkg.build, { name: "electron-builder 26.18.0" }),
+ ).not.toThrow();
+ });
+
it("spawns the bundled control plane when packaged", () => {
const launch = resolveLaunch({
packaged: true,
@@ -256,6 +278,13 @@ describe("electron packaging", () => {
}
expect(yml, file).toContain(`SHA256SUMS-${file.replace("dist-", "").replace(".yml", "")}`);
expect(yml, file).toContain(artifact);
+ if (file === "dist-linux.yml") {
+ expect(yml, file).toContain("*.deb");
+ expect(yml, file).toContain("*.rpm");
+ expect(
+ readFileSync(join(desktopRoot, "../../scripts/release-checksums.mjs"), "utf8"),
+ ).toMatch(/AppImage\|deb\|rpm/);
+ }
if (file === "dist-linux.yml" || file === "dist-macos.yml") {
expect(yml, file).toContain("if: github.ref != 'refs/tags/v0.4.1'");
}
diff --git a/apps/server/package.json b/apps/server/package.json
index dfd3716..81508b6 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/server",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Locus control plane — loopback Hono API, WebSocket hub, and session services",
"type": "module",
diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts
index f7ea86c..883955f 100644
--- a/apps/server/src/app.test.ts
+++ b/apps/server/src/app.test.ts
@@ -6683,7 +6683,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.17.0");
+ const res = await app.request("/api/updates?current=0.18.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/core/controlPlane.ts b/apps/server/src/core/controlPlane.ts
index 39058a2..544b621 100644
--- a/apps/server/src/core/controlPlane.ts
+++ b/apps/server/src/core/controlPlane.ts
@@ -9,4 +9,5 @@ export type ControlPlane = {
projects?: ProjectService;
brain?: BrainService;
workflows?: WorkflowService;
+ onSlackApproval?: (sessionId: string, toolName: string, callId: string) => void;
};
diff --git a/apps/server/src/core/sockets.ts b/apps/server/src/core/sockets.ts
index 38db23d..e072fe8 100644
--- a/apps/server/src/core/sockets.ts
+++ b/apps/server/src/core/sockets.ts
@@ -34,6 +34,7 @@ export interface DisplaySource {
ssh?: { host: string; port: number; username: string; identityFile?: string };
}
| undefined;
+ connectRemote?: (id: string, ws: WebSocket) => boolean | Promise;
}
export type PtyFactory = (ssh: {
@@ -422,11 +423,19 @@ export function attachSockets(opts: {
export function bindDisplay(ws: WebSocket, vmId: string, displays: DisplaySource): void {
const endpoint = displays.get(vmId)?.display;
- if (!endpoint?.host || !endpoint.port || !isLoopback(endpoint.host)) {
- ws.close(1011, "no display");
+ if (endpoint?.host && endpoint.port && isLoopback(endpoint.host)) {
+ pipeVnc(ws, endpoint.host, endpoint.port);
return;
}
- pipeVnc(ws, endpoint.host, endpoint.port);
+ const remote = displays.connectRemote?.(vmId, ws);
+ if (remote === true) return;
+ if (remote && typeof (remote as Promise).then === "function") {
+ void Promise.resolve(remote).then((ok) => {
+ if (!ok && ws.readyState === WebSocket.OPEN) ws.close(1011, "no display");
+ });
+ return;
+ }
+ ws.close(1011, "no display");
}
export function bindBrowser(
diff --git a/apps/server/src/core/store.ts b/apps/server/src/core/store.ts
index 127bb4b..1185029 100644
--- a/apps/server/src/core/store.ts
+++ b/apps/server/src/core/store.ts
@@ -24,6 +24,9 @@ import {
parsePullListCache,
parsePullListState,
parseWorkerBackends,
+ parseWorkerPlaneId,
+ createWorkerPlaneId,
+ parseLocale,
parseNamedEnvironments,
parseDesktopNotifyPrefs,
editorFontSize,
@@ -238,7 +241,9 @@ export class JsonStore {
const vault = await readVaultFile(this.dataDir, key);
const result = vault ? applySecrets(merged, vault) : merged;
const withPolicy = withTokenHygiene(await configWithManagedPolicy(result, this.dataDir));
- if (hasExtractableSecrets(merged)) await this.saveConfig(withPolicy);
+ if (hasExtractableSecrets(merged) || !parseWorkerPlaneId(raw.workerPlaneId)) {
+ await this.saveConfig(withPolicy);
+ }
if (this.configSave) await this.configSave;
if (this.configCache) return structuredClone(this.configCache);
if (this.configEpoch !== epoch) return this.loadConfig();
@@ -284,6 +289,14 @@ export class JsonStore {
integrations: parseIntegrationAccounts(raw.integrations),
integrationPlugins: normalizePlugins(raw.integrationPlugins),
workers: parseWorkerBackends(raw.workers),
+ workerPlaneId: parseWorkerPlaneId(raw.workerPlaneId) ?? createWorkerPlaneId(),
+ locale: parseLocale(raw.locale),
+ setupFirstTask:
+ raw.setupFirstTask === "pending" ||
+ raw.setupFirstTask === "done" ||
+ raw.setupFirstTask === "dismissed"
+ ? raw.setupFirstTask
+ : undefined,
namedEnvironments: parseNamedEnvironments(raw.namedEnvironments),
setupCompleted: Boolean(raw.setupCompleted),
editorFontSize: editorFontSize(raw.editorFontSize),
diff --git a/apps/server/src/core/updates.test.ts b/apps/server/src/core/updates.test.ts
index 5ebed59..7e86389 100644
--- a/apps/server/src/core/updates.test.ts
+++ b/apps/server/src/core/updates.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
-import { checkGithubRelease, githubReleaseToUpdate } from "./updates.js";
+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";
const release = {
tag_name: "v0.4.3",
@@ -12,6 +16,10 @@ const release = {
browser_download_url: "https://example/Locus-0.4.3.AppImage",
},
{ name: "Locus-0.4.3.dmg", browser_download_url: "https://example/Locus-0.4.3.dmg" },
+ {
+ name: "SHA256SUMS-linux",
+ browser_download_url: "https://example/SHA256SUMS-linux",
+ },
],
};
@@ -24,6 +32,31 @@ describe("githubReleaseToUpdate", () => {
name: "Locus-0.4.3.AppImage",
url: "https://example/Locus-0.4.3.AppImage",
});
+ expect(update.checksums).toEqual({
+ name: "SHA256SUMS-linux",
+ url: "https://example/SHA256SUMS-linux",
+ });
+ });
+
+ it("downloads an installer only when SHA-256 matches SHA256SUMS", async () => {
+ const bytes = Buffer.from("installer");
+ const sha = createHash("sha256").update(bytes).digest("hex");
+ const dir = await mkdtemp(join(tmpdir(), "locus-update-"));
+ const result = await downloadAndVerifyUpdate({
+ asset: { name: "Locus.AppImage", url: "https://example/Locus.AppImage" },
+ checksums: { name: "SHA256SUMS-linux", url: "https://example/SHA256SUMS-linux" },
+ destDir: dir,
+ fetchFn: (async (input) => {
+ const url = String(input);
+ if (url.endsWith("SHA256SUMS-linux")) {
+ return new Response(`${sha} Locus.AppImage\n`, { status: 200 });
+ }
+ return new Response(bytes, { status: 200 });
+ }) as typeof fetch,
+ });
+ expect(result.verified).toBe(true);
+ expect(result.sha256).toBe(sha);
+ expect(await readFile(result.path)).toEqual(bytes);
});
it("reports up-to-date when the tag matches", () => {
diff --git a/apps/server/src/core/updates.ts b/apps/server/src/core/updates.ts
index 1b6c7d2..88321bd 100644
--- a/apps/server/src/core/updates.ts
+++ b/apps/server/src/core/updates.ts
@@ -1,7 +1,13 @@
+import { createHash } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { spawn } from "node:child_process";
import {
LOCUS_RELEASES_API_URL,
LOCUS_RELEASES_URL,
+ checksumLineFor,
isNewerVersion,
+ pickChecksumsAsset,
pickReleaseAsset,
type ReleaseAsset,
} from "@locus/shared";
@@ -16,6 +22,7 @@ export interface AppUpdate {
notes?: string;
htmlUrl?: string;
asset?: ReleaseAsset;
+ checksums?: ReleaseAsset;
message?: string;
}
@@ -60,9 +67,39 @@ export function githubReleaseToUpdate(
notes,
htmlUrl,
asset: pickReleaseAsset(assets, platform),
+ checksums: pickChecksumsAsset(assets, platform),
};
}
+export async function downloadAndVerifyUpdate(opts: {
+ asset: ReleaseAsset;
+ checksums: ReleaseAsset;
+ destDir: string;
+ fetchFn?: typeof fetch;
+}): Promise<{ path: string; sha256: string; verified: true }> {
+ const fetchFn = opts.fetchFn ?? fetch;
+ const sumsRes = await fetchFn(opts.checksums.url);
+ if (!sumsRes.ok) throw new Error(`Could not download ${opts.checksums.name}`);
+ const sums = await sumsRes.text();
+ const expected = checksumLineFor(sums, opts.asset.name);
+ if (!expected) throw new Error(`${opts.asset.name} is not in ${opts.checksums.name}`);
+ const fileRes = await fetchFn(opts.asset.url);
+ if (!fileRes.ok) throw new Error(`Could not download ${opts.asset.name}`);
+ const buf = Buffer.from(await fileRes.arrayBuffer());
+ const sha256 = createHash("sha256").update(buf).digest("hex");
+ if (sha256 !== expected) throw new Error("SHA-256 did not match SHA256SUMS");
+ await mkdir(opts.destDir, { recursive: true, mode: 0o700 });
+ const dest = join(opts.destDir, opts.asset.name);
+ await writeFile(dest, buf, { mode: 0o700 });
+ return { path: dest, sha256, verified: true };
+}
+
+export function openVerifiedUpdate(path: string, platform = process.platform): void {
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? path : "xdg-open";
+ const args = platform === "win32" ? [] : [path];
+ spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
+}
+
export async function checkGithubRelease(opts: {
current: string;
platform: string;
diff --git a/apps/server/src/git/forge.test.ts b/apps/server/src/git/forge.test.ts
index d34d9a4..d3334ad 100644
--- a/apps/server/src/git/forge.test.ts
+++ b/apps/server/src/git/forge.test.ts
@@ -550,18 +550,18 @@ describe("ForgeClient", () => {
it("attaches Bitbucket unified diffs to PR files", async () => {
const unified = [
- "diff --git a/siq8/batch/assignment.py b/siq8/batch/assignment.py",
+ "diff --git a/app/batch/assignment.py b/app/batch/assignment.py",
"index 111..222 100644",
- "--- a/siq8/batch/assignment.py",
- "+++ b/siq8/batch/assignment.py",
+ "--- a/app/batch/assignment.py",
+ "+++ b/app/batch/assignment.py",
"@@ -1,1 +1,2 @@",
" keep",
"+appear",
- "diff --git a/siq8/static/src/batch/batch.ts b/siq8/static/src/batch/batch.ts",
+ "diff --git a/app/static/src/batch/batch.ts b/app/static/src/batch/batch.ts",
"new file mode 100644",
"index 0000000..abc",
"--- /dev/null",
- "+++ b/siq8/static/src/batch/batch.ts",
+ "+++ b/app/static/src/batch/batch.ts",
"@@ -0,0 +1,1 @@",
"+export {}",
].join("\n");
@@ -583,10 +583,10 @@ describe("ForgeClient", () => {
values: [
{
status: "modified",
- old: { path: "siq8/batch/assignment.py" },
- new: { path: "siq8/batch/assignment.py" },
+ old: { path: "app/batch/assignment.py" },
+ new: { path: "app/batch/assignment.py" },
},
- { status: "added", old: null, new: { path: "siq8/static/src/batch/batch.ts" } },
+ { status: "added", old: null, new: { path: "app/static/src/batch/batch.ts" } },
{ status: "modified", old: { path: "binary.bin" }, new: { path: "binary.bin" } },
],
});
@@ -607,12 +607,12 @@ describe("ForgeClient", () => {
);
expect(pull.files).toHaveLength(3);
expect(pull.files[0]).toMatchObject({
- path: "siq8/batch/assignment.py",
+ path: "app/batch/assignment.py",
status: "modified",
});
expect(pull.files[0]?.patch).toContain("+appear");
expect(pull.files[1]).toMatchObject({
- path: "siq8/static/src/batch/batch.ts",
+ path: "app/static/src/batch/batch.ts",
status: "added",
});
expect(pull.files[1]?.patch).toContain("+export {}");
diff --git a/apps/server/src/http/configRoutes.ts b/apps/server/src/http/configRoutes.ts
index 2076645..e9eba9d 100644
--- a/apps/server/src/http/configRoutes.ts
+++ b/apps/server/src/http/configRoutes.ts
@@ -1,9 +1,14 @@
+import { join } from "node:path";
import type { Hono } from "hono";
import { clearIntegrationCredentials, type LocusConfig } from "@locus/shared";
import type { AutomationService } from "../automations/automations.js";
import { mergeSecrets, redactConfig, redactVm } from "../core/redact.js";
import { normalizeSavedConfig } from "../core/configSave.js";
-import { checkGithubRelease } from "../core/updates.js";
+import {
+ checkGithubRelease,
+ 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";
@@ -115,4 +120,42 @@ export function registerConfigRoutes(
}),
);
});
+
+ app.post("/api/updates/download", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as {
+ asset?: { name?: string; url?: string };
+ checksums?: { name?: string; url?: string };
+ };
+ if (!body.asset?.name || !body.asset.url || !body.checksums?.name || !body.checksums.url) {
+ return c.json({ error: "Asset and SHA256SUMS URLs are required" }, 400);
+ }
+ try {
+ const result = await downloadAndVerifyUpdate({
+ asset: { name: body.asset.name, url: body.asset.url },
+ checksums: { name: body.checksums.name, url: body.checksums.url },
+ destDir: join(opts.store.dataDir, "updates"),
+ fetchFn: opts.fetchFn,
+ });
+ return c.json(result);
+ } catch (err) {
+ return c.json({ error: err instanceof Error ? err.message : "Download failed" }, 400);
+ }
+ });
+
+ app.post("/api/updates/open", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as { path?: string };
+ const path = body.path?.trim() ?? "";
+ if (!path.startsWith(opts.store.dataDir)) {
+ return c.json({ error: "Installer path is not in the data directory" }, 400);
+ }
+ try {
+ openVerifiedUpdate(path);
+ return c.json({ ok: true });
+ } catch (err) {
+ return c.json(
+ { error: err instanceof Error ? err.message : "Could not open installer" },
+ 400,
+ );
+ }
+ });
}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 0d9c06d..76d0c12 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -1,3 +1,4 @@
+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";
@@ -8,7 +9,13 @@ import {
type DockerEngine,
type Hypervisor,
} from "@locus/hypervisor";
-import { computeTasksPath, FargateComputeBackend } from "@locus/compute";
+import {
+ AzureComputeBackend,
+ CompositeComputeBackend,
+ FargateComputeBackend,
+ GcpComputeBackend,
+ computeTasksPath,
+} from "@locus/compute";
import { loadOrCreateWorkerTls } from "@locus/worker";
import { createApp } from "./app.js";
import { defaultDataDir } from "./defaults.js";
@@ -80,7 +87,11 @@ const sessions = new SessionService(
undefined,
guests,
docker,
- new FargateComputeBackend(fetch, undefined, computeTasksPath(dataDir)),
+ new CompositeComputeBackend(
+ new FargateComputeBackend(fetch, undefined, computeTasksPath(dataDir)),
+ new AzureComputeBackend(fetch, undefined, join(dataDir, "compute-azure.json")),
+ new GcpComputeBackend(fetch, undefined, join(dataDir, "compute-gcp.json")),
+ ),
plane,
);
sessions.chromeExtension.setToken(
@@ -105,6 +116,9 @@ plane.brain = brain;
const workflows = new WorkflowService(dataDir, sessions);
plane.workflows = workflows;
const slack = new SlackGateway(store, sessions, hub, fetch, slackSocketConnect, brain);
+plane.onSlackApproval = (sessionId, toolName, callId) => {
+ void slack.notifyApproval(sessionId, toolName, callId).catch(() => undefined);
+};
brain.setCorpusCollectors([slackCorpusCollector(store, slack), jiraCorpusCollector(store)]);
const subscriptions = new SubscriptionService(store, sessions, { slack, hub, repos, projects });
let liveConfig = config;
@@ -177,6 +191,7 @@ const socketOpts = {
hub,
displays: {
get: (id: string) => hypervisor.get(id),
+ connectRemote: (id: string, ws: import("ws").WebSocket) => sessions.pipeWorkerDisplay(id, ws),
},
onCommand: handleCommand,
phoneRemote: () => liveConfig.phoneRemote,
diff --git a/apps/server/src/integrations/cliGitMcp.ts b/apps/server/src/integrations/cliGitMcp.ts
index 18bf32c..bdfdb03 100644
--- a/apps/server/src/integrations/cliGitMcp.ts
+++ b/apps/server/src/integrations/cliGitMcp.ts
@@ -29,6 +29,10 @@ Some Codex CLI/model combinations fail to register a dynamically configured MCP
The native shell is sandboxed and does not inherit Locus forge credentials. Do not retry failed shell pushes, remove sandbox proxies, inspect credential stores, or create replacement Locus sessions to publish changes. If a Locus Git tool fails, report its specific error and address that cause. Do not claim a push or PR succeeded without its tool result.
If shell tests fail with spawn EPERM, report that validation limit; the Locus push/PR test gate runs on the host and must pass before publishing.`;
+export const KIRO_GIT_MCP_INSTRUCTIONS = `Locus Git:
+The locus MCP server for this turn exposes git_status, git_diff, git_log, git_branch, git_commit, git_fetch, git_push, and git_pr. Use those for Git and pull requests so Locus can apply saved forge credentials and its test gate. Do not git push, create the PR with gh/glab, or curl GitHub/GitLab/Bitbucket with tokens from the environment.
+If a native locus MCP call is unavailable, POST tools/call JSON-RPC to LOCUS_GIT_MCP_URL. For example: curl -fsS -X POST "$LOCUS_GIT_MCP_URL" -H "content-type: application/json" --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"git_status","arguments":{}}}'. Replace only the tool name and arguments. Do not claim a push or PR succeeded without that tool result.`;
+
export const CLI_CANVAS_MCP_INSTRUCTIONS = `Locus canvas MCP:
The locus_git MCP server for this turn includes playwright against the live operator canvas. After a write, call playwright (click, then screenshot). Do not npm i Playwright or write spec files. If a native MCP call is unavailable, unregistered, or "not a function", POST tools/call JSON-RPC to LOCUS_GIT_MCP_URL with name "playwright". Git tools on the same server still go through Locus gates.`;
@@ -40,3 +44,9 @@ export function codexMcpPrompt(kind: "git" | "canvas" | "review"): string {
if (kind === "review") return CLI_REVIEW_MCP_INSTRUCTIONS;
return CLI_GIT_INSTRUCTIONS;
}
+
+export function kiroMcpPrompt(kind: "git" | "canvas" | "review"): string {
+ if (kind === "canvas") return `${CLI_CANVAS_MCP_INSTRUCTIONS}\n\n${KIRO_GIT_MCP_INSTRUCTIONS}`;
+ if (kind === "review") return CLI_REVIEW_MCP_INSTRUCTIONS;
+ return KIRO_GIT_MCP_INSTRUCTIONS;
+}
diff --git a/apps/server/src/integrations/locusMcp.ts b/apps/server/src/integrations/locusMcp.ts
index daae41e..2add7fc 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.17.0" },
+ serverInfo: { name: "locus", version: "0.18.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 b3fdbbc..65f11b4 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.17.0" },
+ clientInfo: { name: "locus", version: "0.18.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.17.0" },
+ clientInfo: { name: "locus", version: "0.18.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/codexCliTurn.test.ts b/apps/server/src/session/codexCliTurn.test.ts
index 800bb8e..831c314 100644
--- a/apps/server/src/session/codexCliTurn.test.ts
+++ b/apps/server/src/session/codexCliTurn.test.ts
@@ -320,6 +320,85 @@ describe("codex CLI chat loop", () => {
});
});
+ it("does not flag Needs you after tests plus a delete-only Codex cleanup", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-codex-verify-cleanup-"));
+ cleanup.push(dir);
+ const store = new JsonStore(dir);
+ await store.init();
+ const config = await store.loadConfig();
+ const codex = config.providers.find((provider) => provider.kind === "codex-cli")!;
+ codex.enabled = true;
+ await store.saveConfig(config);
+ const sessions = new SessionService(store, new MockHypervisor(dir), new Hub());
+ sessions.codexCliSpawner = linesSpawner(
+ [],
+ [
+ JSON.stringify({ type: "thread.started", thread_id: "cx-verify" }),
+ JSON.stringify({
+ type: "item.completed",
+ item: {
+ id: "patch-1",
+ type: "file_change",
+ changes: [{ path: "src/add.ts", kind: "update" }],
+ status: "completed",
+ },
+ }),
+ JSON.stringify({
+ type: "item.completed",
+ item: {
+ id: "cmd-1",
+ type: "command_execution",
+ command: "/bin/bash -lc 'npm run test --ignore-scripts -- --testPathPattern=add'",
+ aggregated_output: "6 suites passed\n78 tests passed",
+ exit_code: 0,
+ status: "completed",
+ },
+ }),
+ JSON.stringify({
+ type: "item.completed",
+ item: {
+ id: "patch-2",
+ type: "file_change",
+ changes: [{ path: "src/react-dom-shim.js", kind: "delete" }],
+ status: "completed",
+ },
+ }),
+ JSON.stringify({
+ type: "item.completed",
+ item: { id: "msg-1", type: "agent_message", text: "Tests passed; shim removed." },
+ }),
+ JSON.stringify({ type: "turn.completed", usage: { input_tokens: 8, output_tokens: 4 } }),
+ ],
+ );
+
+ const created = await sessions.create({
+ os: "ubuntu",
+ templateId: config.templates[0]!.id,
+ runtime: "host",
+ providerId: codex.id,
+ model: "gpt-5.6-sol",
+ startVm: false,
+ });
+ await sessions.sendChat(created.id, "Fix the frontend tests");
+ const fresh = await waitFor(sessions, created.id);
+ expect(fresh?.needsHuman).toBeUndefined();
+ expect(
+ fresh?.messages.some((message) =>
+ message.toolCalls?.some(
+ (call) =>
+ call.name === "apply_patch" &&
+ Array.isArray(call.arguments?.changes) &&
+ call.arguments.changes.some(
+ (row) =>
+ row &&
+ typeof row === "object" &&
+ (row as { path?: string; kind?: string }).kind === "delete",
+ ),
+ ),
+ ),
+ ).toBe(true);
+ });
+
it("creates a VM runtime session for Codex CLI", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-codex-vm-"));
cleanup.push(dir);
diff --git a/apps/server/src/session/hostCliTurn.ts b/apps/server/src/session/hostCliTurn.ts
index 5d5343e..1eddb62 100644
--- a/apps/server/src/session/hostCliTurn.ts
+++ b/apps/server/src/session/hostCliTurn.ts
@@ -19,6 +19,7 @@ import {
isWindows,
lastUserMessage,
LINUX_WORKSPACE,
+ WINDOWS_WORKSPACE,
normalizeCodexCliModel,
sessionRuntime,
sessionWebSearch,
@@ -41,7 +42,7 @@ import {
type CursorCloudEvent,
} from "@locus/providers";
import { workspaceReadinessNote } from "../core/hooks.js";
-import { codexMcpPrompt } from "../integrations/cliGitMcp.js";
+import { codexMcpPrompt, kiroMcpPrompt } from "../integrations/cliGitMcp.js";
import {
controlPlaneMcpSessionUrl,
controlPlaneMcpUrl,
@@ -105,7 +106,10 @@ export type HostCliTurnPorts = {
spawnOverSsh?: (
target: SshTarget,
req: CursorCliSpawnRequest,
- extra?: { reverseForwards?: { listenPort: number; hostPort: number }[] },
+ extra?: {
+ reverseForwards?: { listenPort: number; hostPort: number }[];
+ shell?: "posix" | "powershell";
+ },
) => SshCliHandle;
primeGameTurn: (sessionId: string, userText: string) => Promise;
snapshotStudioBaseline: (sessionId: string) => Promise;
@@ -160,27 +164,27 @@ const LABELS: Record<
> = {
cursor: {
missing: "Cursor CLI provider is not configured. Enable it in Settings → Providers.",
- vm: "Cursor CLI on a VM needs a running Linux guest with SSH. Windows VMs are not supported — use Docker or Host, or Cursor Cloud.",
+ vm: "Cursor CLI on a VM needs a running guest with SSH.",
workspace: "Cursor CLI needs a workspace folder on this machine.",
worker:
"Cursor CLI on a worker needs a running Docker, Host, or Linux VM guest. Start it first.",
},
claude: {
missing: "Claude Code provider is not configured. Enable it in Settings → Providers.",
- vm: "Claude Code on a VM needs a running Linux guest with SSH. Windows VMs are not supported — switch the runtime, then retry.",
+ vm: "Claude Code on a VM needs a running guest with SSH.",
workspace: "Claude Code needs a workspace folder on this machine.",
worker:
"Claude Code on a worker needs a running Docker, Host, or Linux VM guest. Start it first.",
},
kiro: {
missing: "Kiro CLI provider is not configured. Enable it in Settings → Providers.",
- vm: "Kiro CLI on a VM needs a running Linux guest with SSH. Windows VMs are not supported — switch the runtime, then retry.",
+ vm: "Kiro CLI on a VM needs a running guest with SSH.",
workspace: "Kiro CLI needs a workspace folder on this machine.",
worker: "Kiro CLI on a worker needs a running Docker, Host, or Linux VM guest. Start it first.",
},
codex: {
missing: "Codex CLI provider is not configured. Enable it in Settings → Providers.",
- vm: "Codex CLI on a VM needs a running Linux guest with SSH. Windows VMs are not supported — switch the runtime, then retry.",
+ vm: "Codex CLI on a VM needs a running guest with SSH.",
workspace: "Codex CLI needs a workspace folder on this machine.",
worker:
"Codex CLI on a worker needs a running Docker, Host, or Linux VM guest. Start it first.",
@@ -223,7 +227,7 @@ export async function runHostCliTurn(
const worker = ports.isWorkerSession(session);
const localVm = sessionRuntime(session) === "vm" && !worker;
const ssh = localVm ? await ports.guestSsh?.(session) : undefined;
- if (localVm && (!ssh || isWindows(session.os))) {
+ if (localVm && !ssh) {
await ports.appendMessage(input.sessionId, {
id: randomUUID(),
role: "system",
@@ -235,7 +239,9 @@ export async function runHostCliTurn(
const workspace = worker
? "/workspace"
: localVm
- ? LINUX_WORKSPACE
+ ? isWindows(session.os)
+ ? WINDOWS_WORKSPACE
+ : LINUX_WORKSPACE
: ports.workspaceRoot(session);
if (!workspace) {
await ports.appendMessage(input.sessionId, {
@@ -323,8 +329,11 @@ export async function runHostCliTurn(
spawnFn = (req) =>
overSsh(
ssh,
- { ...req, cwd: LINUX_WORKSPACE },
- { reverseForwards: [{ listenPort: mcpPort, hostPort: mcpPort }] },
+ { ...req, cwd: isWindows(session.os) ? WINDOWS_WORKSPACE : LINUX_WORKSPACE },
+ {
+ reverseForwards: [{ listenPort: mcpPort, hostPort: mcpPort }],
+ shell: isWindows(session.os) ? "powershell" : "posix",
+ },
);
} else {
spawnFn =
@@ -339,7 +348,7 @@ export async function runHostCliTurn(
const reviewing = isReviewSession(session);
const mcpKind: CodexMcpKind = reviewing ? "review" : isCanvasSession(session) ? "canvas" : "git";
const gitMcpUrl =
- kind === "codex" && !input.planTurn
+ (kind === "codex" || kind === "kiro") && !input.planTurn
? controlPlaneMcpSessionUrl(session.id, config.advanced.bindPort)
: undefined;
let promptText =
@@ -433,7 +442,11 @@ export async function runHostCliTurn(
worker,
)}`;
const cliPrompt =
- kind === "codex" && gitMcpUrl ? `${codexMcpPrompt(mcpKind)}\n\n${promptText}` : promptText;
+ kind === "codex" && gitMcpUrl
+ ? `${codexMcpPrompt(mcpKind)}\n\n${promptText}`
+ : kind === "kiro" && gitMcpUrl
+ ? `${kiroMcpPrompt(mcpKind)}\n\n${promptText}`
+ : promptText;
await ports.recordCursorCliContext(input.sessionId, cliPrompt);
let sawText = false;
let sawUsage = false;
@@ -441,7 +454,7 @@ export async function runHostCliTurn(
ports.spawnAborts.set(input.sessionId, spawnAbort);
const onTurnAbort = () => spawnAbort.abort();
input.abort.signal.addEventListener("abort", onTurnAbort);
- if (kind === "codex" && gitMcpUrl && mcpKind !== "review") {
+ if ((kind === "codex" || kind === "kiro") && gitMcpUrl && mcpKind !== "review") {
ports.cliGitTurns.set(input.sessionId, spawnAbort.signal);
}
const resume =
@@ -511,13 +524,14 @@ export async function runHostCliTurn(
: kind === "kiro"
? streamKiroCli({
provider,
- prompt: promptText,
+ prompt: cliPrompt,
workspace,
model: current.model,
resume,
mode: input.planTurn ? "plan" : "agent",
webSearch: sessionWebSearch(current) && !input.planTurn,
review: reviewing,
+ gitMcpUrl,
signal: spawnAbort.signal,
spawnFn,
})
@@ -583,7 +597,10 @@ export async function runHostCliTurn(
await ports.appendDelta(input.sessionId, replyId, `\n\n${formatProviderError(err)}`);
}
} finally {
- if (kind === "codex" && ports.cliGitTurns.get(input.sessionId) === spawnAbort.signal) {
+ if (
+ (kind === "codex" || kind === "kiro") &&
+ ports.cliGitTurns.get(input.sessionId) === spawnAbort.signal
+ ) {
ports.cliGitTurns.delete(input.sessionId);
}
if (kind === "codex") spawnAbort.abort();
@@ -658,7 +675,7 @@ export async function runHostCliTurn(
wroteCanvasThisTurn,
calledPlaywrightThisTurn,
messages: afterCli.messages,
- traceNudges: kind === "cursor",
+ traceNudges: kind === "cursor" || kind === "kiro",
});
if (cont.stop) break;
promptText = cont.prompt;
diff --git a/apps/server/src/session/kiroCliTurn.test.ts b/apps/server/src/session/kiroCliTurn.test.ts
index 10bc23a..6da448d 100644
--- a/apps/server/src/session/kiroCliTurn.test.ts
+++ b/apps/server/src/session/kiroCliTurn.test.ts
@@ -34,11 +34,11 @@ async function waitFor(
}
function linesSpawner(
- captured: Array<{ args: string[]; cwd: string }>,
+ captured: Array<{ args: string[]; cwd: string; env?: NodeJS.ProcessEnv }>,
lines: string[],
): KiroCliSpawner {
return (req) => {
- captured.push({ args: req.args, cwd: req.cwd });
+ captured.push({ args: req.args, cwd: req.cwd, env: req.env });
return {
lines: (async function* () {
for (const line of lines) yield line;
@@ -60,7 +60,7 @@ describe("kiro cli chat loop", () => {
await store.saveConfig(config);
const hypervisor = new MockHypervisor(dir);
const sessions = new SessionService(store, hypervisor, new Hub());
- const captured: Array<{ args: string[]; cwd: string }> = [];
+ const captured: Array<{ args: string[]; cwd: string; env?: NodeJS.ProcessEnv }> = [];
sessions.fetchFn = async () => {
throw new Error("Kiro CLI must not call chat completions");
};
@@ -100,7 +100,7 @@ describe("kiro cli chat loop", () => {
expect(assistant?.content).toContain("Updated README.md.");
expect(assistant?.toolCalls?.some((call) => call.name === "file_read")).toBe(true);
expect(captured[0]?.cwd).toContain("host-workspaces");
- expect(captured[0]?.args.slice(0, 3)).toEqual(["--v3", "chat", "--no-interactive"]);
+ expect(captured[0]?.args.slice(0, 3)).toEqual(["chat", "--v3", "--no-interactive"]);
expect(captured[0]?.args).toEqual(
expect.arrayContaining([
"--v3",
@@ -109,9 +109,11 @@ describe("kiro cli chat loop", () => {
"--trust-all-tools",
"--output-format",
"stream-json",
- "Add a README",
]),
);
+ expect(captured[0]?.args.at(-1)).toContain("Add a README");
+ expect(captured[0]?.args.at(-1)).toContain("git_pr");
+ expect(captured[0]?.env?.LOCUS_GIT_MCP_URL).toMatch(new RegExp(`/mcp/sessions/${created.id}$`));
expect(captured[0]?.args).not.toContain("--require-mcp-startup");
expect(captured[0]?.args).not.toContain("--resume-id");
expect(captured[0]?.args).not.toContain("--model");
@@ -145,6 +147,91 @@ describe("kiro cli chat loop", () => {
expect(captured[1]?.args).toEqual(expect.arrayContaining(["--resume-id", "kiro-sess-1"]));
});
+ it("records one exec chip when V3 emits tool_call then tool_call_update", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-dup-"));
+ cleanup.push(dir);
+ const store = new JsonStore(dir);
+ await store.init();
+ const config = await store.loadConfig();
+ const kiro = config.providers.find((p) => p.kind === "kiro-cli")!;
+ kiro.enabled = true;
+ await store.saveConfig(config);
+ const sessions = new SessionService(store, new MockHypervisor(dir), new Hub());
+ sessions.fetchFn = async () => {
+ throw new Error("Kiro CLI must not call chat completions");
+ };
+ sessions.kiroCliSpawner = linesSpawner(
+ [],
+ [
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ sessionId: "kiro-dup",
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "e1",
+ kind: "execute",
+ status: "pending",
+ rawInput: { command: "python3 ./manage.py test shop.tests.test_models" },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "e1",
+ status: "in_progress",
+ rawInput: {
+ command: "python3 ./manage.py test shop.tests.test_models",
+ description: "Run Django tests",
+ },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "e1",
+ status: "completed",
+ content: [{ type: "text", text: "OK" }],
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "agent_message_chunk",
+ content: { type: "text", text: "Tests passed." },
+ }),
+ JSON.stringify({
+ type: "runFinished",
+ data: { status: "success", sessionId: "kiro-dup", finalText: "Tests passed." },
+ }),
+ ],
+ );
+ const created = await sessions.create({
+ os: "ubuntu",
+ templateId: config.templates[0]!.id,
+ runtime: "host",
+ providerId: kiro.id,
+ model: "auto",
+ startVm: false,
+ });
+ await sessions.sendChat(created.id, "run the tests");
+ const fresh = await waitFor(sessions, created.id, (session) =>
+ Boolean(session.status !== "running" && session.messages.some((m) => m.role === "assistant")),
+ );
+ const assistant = fresh?.messages.find((m) => m.role === "assistant");
+ expect(assistant?.toolCalls?.map((call) => call.name)).toEqual(["exec"]);
+ expect(assistant?.toolCalls?.[0]?.arguments).toMatchObject({
+ command: "python3 ./manage.py test shop.tests.test_models",
+ description: "Run Django tests",
+ });
+ expect(assistant?.content).toContain("Tests passed.");
+ });
+
it("shows native Kiro stream-json text in chat instead of a blank turn", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-kiro-native-chat-"));
cleanup.push(dir);
@@ -187,6 +274,71 @@ describe("kiro cli chat loop", () => {
expect(fresh?.kiroCli).toEqual({ sessionId: "kiro-native-hello" });
});
+ it("shows stream-json envelope text in chat instead of a blank turn", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-kiro-envelope-chat-"));
+ cleanup.push(dir);
+ const store = new JsonStore(dir);
+ await store.init();
+ const config = await store.loadConfig();
+ const kiro = config.providers.find((p) => p.kind === "kiro-cli")!;
+ kiro.enabled = true;
+ await store.saveConfig(config);
+ const sessions = new SessionService(store, new MockHypervisor(dir), new Hub());
+ sessions.fetchFn = async () => {
+ throw new Error("Kiro CLI must not call chat completions");
+ };
+ sessions.kiroCliSpawner = linesSpawner(
+ [],
+ [
+ JSON.stringify({
+ type: "runStarted",
+ data: { payloadSchema: "acp", acpProtocolVersion: 1, engine: "v3" },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ sessionId: "sess_envelope",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Hello from Kiro." },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "runFinished",
+ data: {
+ status: "success",
+ sessionId: "sess_envelope",
+ finalText: "Hello from Kiro.",
+ },
+ }),
+ ],
+ );
+ const created = await sessions.create({
+ os: "ubuntu",
+ templateId: config.templates[0]!.id,
+ runtime: "host",
+ providerId: kiro.id,
+ model: "auto",
+ startVm: false,
+ });
+ await sessions.sendChat(created.id, "Hi, what model are you?");
+ const fresh = await waitFor(sessions, created.id, (session) =>
+ Boolean(
+ session.messages.some(
+ (m) => m.role === "assistant" && m.content.includes("Hello from Kiro."),
+ ),
+ ),
+ );
+ expect(fresh?.messages.find((m) => m.role === "assistant")?.content).toContain(
+ "Hello from Kiro.",
+ );
+ expect(fresh?.kiroCli).toEqual({ sessionId: "sess_envelope" });
+ expect(fresh?.messages.find((m) => m.role === "assistant")?.content).not.toMatch(
+ /finished without a reply/,
+ );
+ });
+
it("shows V3 agent_message_chunk text in chat", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-kiro-v3-chat-"));
cleanup.push(dir);
diff --git a/apps/server/src/session/sessions.ts b/apps/server/src/session/sessions.ts
index 1562b11..9690530 100644
--- a/apps/server/src/session/sessions.ts
+++ b/apps/server/src/session/sessions.ts
@@ -101,7 +101,7 @@ import {
workerComputeUsd,
sessionComputeMs,
workerImageAllowed,
- workerKind,
+ workerProvisionsCompute,
workerPickSize,
runtimeNeedsGuest,
toolsForRuntime,
@@ -996,9 +996,9 @@ export class SessionService {
const config = await this.store.loadConfig();
const backend = (config.workers ?? []).find((row) => row.id === session.backendId);
if (!backend) throw new Error("Worker was removed from Settings → Workers");
- if (workerKind(backend) === "fargate") {
+ if (workerProvisionsCompute(backend)) {
const handle = session.remoteGuestId ? this.compute.get(session.remoteGuestId) : undefined;
- if (!handle?.url) throw new Error("Fargate task is not provisioned");
+ if (!handle?.url) throw new Error("Compute worker is not provisioned");
const row = {
...backend,
url: handle.url,
@@ -1013,14 +1013,14 @@ export class SessionService {
existing.backend.token === row.token &&
existing.backend.fingerprint === row.fingerprint
) {
- return existing;
+ return this.withPlane(existing, config.workerPlaneId);
}
const client = new WorkerClient(
row,
this.fetchFn === fetch ? undefined : (input, init) => this.fetchFn(input, init),
);
this.workers.set(cacheKey, client);
- return client;
+ return this.withPlane(client, config.workerPlaneId);
}
if (!backend.token?.trim()) throw new Error("Add a worker token in Settings → Workers");
if (backend.url.startsWith("https:") && !backend.fingerprint) {
@@ -1033,16 +1033,58 @@ export class SessionService {
existing.backend.token === backend.token &&
existing.backend.fingerprint === backend.fingerprint
) {
- return existing;
+ return this.withPlane(existing, config.workerPlaneId);
}
const client = new WorkerClient(
backend,
this.fetchFn === fetch ? undefined : (input, init) => this.fetchFn(input, init),
);
this.workers.set(backend.id, client);
+ return this.withPlane(client, config.workerPlaneId);
+ }
+
+ private withPlane(client: WorkerClient, planeId?: string): WorkerClient {
+ client.planeId = planeId;
return client;
}
+ pipeWorkerDisplay(vmId: string, ws: import("ws").WebSocket): boolean {
+ const client = [...this.workers.values()].find((row) => row.get(vmId));
+ if (!client) return false;
+ void this.store
+ .loadConfig()
+ .then((config) => {
+ this.withPlane(client, config.workerPlaneId);
+ return client.openDisplaySocket(vmId, (raw) => {
+ if (ws.readyState === 1) ws.send(raw);
+ });
+ })
+ .then((remote) => {
+ ws.on("message", (data) => {
+ if (remote.readyState === 1) remote.send(data);
+ });
+ const close = () => {
+ try {
+ remote.close();
+ } catch {
+ /* already closed */
+ }
+ };
+ ws.on("close", close);
+ ws.on("error", close);
+ remote.on("close", () => {
+ if (ws.readyState === 1) ws.close();
+ });
+ remote.on("error", () => {
+ if (ws.readyState === 1) ws.close(1011, "display unreachable");
+ });
+ })
+ .catch(() => {
+ if (ws.readyState === 1) ws.close(1011, "no display");
+ });
+ return true;
+ }
+
startWorkerReaper(intervalMs = 30_000): void {
if (this.workerReaper) return;
this.workerReaper = setInterval(() => {
@@ -1664,7 +1706,7 @@ export class SessionService {
const config = await this.store.loadConfig();
const backend = (config.workers ?? []).find((row) => row.id === session.backendId);
if (!backend) throw new Error("Worker was removed from Settings → Workers");
- const fargate = workerKind(backend) === "fargate";
+ const fargate = workerProvisionsCompute(backend);
if (!session.vmId) {
if (!workerImageAllowed(backend, dockerImage)) {
throw new Error(`Worker ${backend.name} does not allow image ${dockerImage}`);
@@ -1745,6 +1787,7 @@ export class SessionService {
? forgeProviderAllowlist({ ...config, extraHosts: session.allowHosts })
: undefined,
env: Object.keys(guestEnv).length ? guestEnv : undefined,
+ planeId: config.workerPlaneId,
});
if (seed?.path) await client.uploadTree(vm.id, "workspace-seed", seed.path);
if (share) await client.uploadTree(vm.id, "host-share", share);
@@ -1777,7 +1820,7 @@ export class SessionService {
const config = await this.store.loadConfig();
const backend = (config.workers ?? []).find((row) => row.id === session.backendId);
if (!backend) throw new Error("Worker was removed from Settings → Workers");
- if (workerKind(backend) === "fargate") {
+ if (workerProvisionsCompute(backend)) {
session = await this.ensureFargateHandle(session, backend);
}
const client = await this.requireWorker(session);
@@ -1890,7 +1933,7 @@ export class SessionService {
if (!backend) throw new Error("Worker was removed from Settings → Workers");
const template = config.templates.find((row) => row.id === session.templateId);
if (!template) throw new Error("Template missing");
- if (workerKind(backend) === "fargate") {
+ if (workerProvisionsCompute(backend)) {
session = await this.ensureFargateHandle(session, backend);
}
const client = await this.requireWorker(session);
@@ -1909,6 +1952,22 @@ export class SessionService {
}
}
const hardware = session.hardware ?? template.hardware;
+ const seed = session.imageId
+ ? (await this.store.loadImages()).find((i) => i.id === session.imageId)
+ : undefined;
+ if (session.imageId && !seed) throw new Error("Golden image not found");
+ if (seed && imageRuntime(seed) !== "vm") {
+ throw new Error("That golden image is a container workspace, not a VM disk");
+ }
+ let imagePath = seed?.path;
+ if (seed?.path && session.imageId) {
+ const remote = await client.hasGoldenDisk(session.imageId);
+ if (remote?.path) imagePath = remote.path;
+ else {
+ const uploaded = await client.uploadGoldenDisk(session.imageId, seed.path);
+ imagePath = uploaded.path;
+ }
+ }
vm = await client.create({
sessionId,
name: session.title,
@@ -1918,6 +1977,8 @@ export class SessionService {
runtime: "vm",
template: { ...template, hardware },
network: session.network ?? defaultNetworkJail(session.imageId),
+ imagePath,
+ planeId: config.workerPlaneId,
});
}
this.attachWorkerGuest(session, client, vm);
@@ -5072,19 +5133,23 @@ export class SessionService {
startedAt,
finishedAt: ev.status === "completed" ? now() : undefined,
};
+ let inserted = false;
await this.store.mutateSession(sessionId, (current) => {
const msg = current.messages.find((message) => message.id === replyId);
if (!msg) return;
const existing = (msg.toolCalls ?? []).find((item) => item.id === ev.id);
if (existing) {
- existing.name = canonical.name as ComputerUseToolName;
- existing.arguments = persistedArgs;
+ if (canonical.name !== "tool" || existing.name === "tool") {
+ existing.name = canonical.name as ComputerUseToolName;
+ }
+ existing.arguments = { ...existing.arguments, ...persistedArgs };
if (persistedResult != null) existing.result = persistedResult;
if (ev.status === "completed") {
existing.finishedAt = now();
existing.error = failed ? persistedResult : undefined;
}
} else {
+ inserted = true;
msg.toolCalls = [...(msg.toolCalls ?? []), call];
}
});
@@ -5094,17 +5159,19 @@ export class SessionService {
?.toolCalls?.find((item) => item.id === ev.id);
if (live) this.hub.broadcast({ type: "tool.updated", sessionId, toolCall: live });
this.hub.broadcast({ type: "session.updated", session });
- await this.traces.write({
- t: now(),
- kind: ev.status === "completed" ? "tool.result" : "tool.call",
- sessionId,
- turnId,
- round,
- name: canonical.name,
- detail: ev.status === "completed" && ev.result ? clipTraceDetail(ev.result) : ev.status,
- args: redactedTraceArgs(canonical.arguments ?? {}),
- ok: cursorCliToolCallOk(ev.status, ev.result),
- });
+ if (inserted || ev.status === "completed") {
+ await this.traces.write({
+ t: now(),
+ kind: ev.status === "completed" ? "tool.result" : "tool.call",
+ sessionId,
+ turnId,
+ round,
+ name: live?.name ?? canonical.name,
+ detail: ev.status === "completed" && ev.result ? clipTraceDetail(ev.result) : ev.status,
+ args: redactedTraceArgs(live?.arguments ?? canonical.arguments ?? {}),
+ ok: cursorCliToolCallOk(ev.status, ev.result),
+ });
+ }
if (ev.status === "completed" && isCursorCreatePlanTool(canonical.name)) {
await this.applyCursorCreatePlan(sessionId, replyId, canonical.arguments);
}
@@ -5557,6 +5624,19 @@ export class SessionService {
}
abort.addEventListener("abort", onAbort);
this.approvals.set(key, done);
+ void this.require(sessionId)
+ .then((session) => {
+ let toolName = "";
+ for (const message of session.messages) {
+ const call = message.toolCalls?.find((item) => item.id === toolCallId);
+ if (call) {
+ toolName = call.name;
+ break;
+ }
+ }
+ this.plane.onSlackApproval?.(sessionId, toolName, toolCallId);
+ })
+ .catch(() => undefined);
void this.store.loadConfig().then((config) => {
if (!this.approvals.has(key)) return;
const ms = approvalTimeoutMs(config.advanced.approvalTimeoutMs);
@@ -7131,7 +7211,7 @@ export class SessionService {
if (!isCliGitTool(name) || !signal || signal.aborted) {
return {
ok: false,
- output: "Locus Git tools require an active Codex coding turn on this host.",
+ output: "Locus Git tools require an active CLI coding turn on this host.",
};
}
const session = await this.require(sessionId);
@@ -7191,7 +7271,7 @@ export class SessionService {
return finish({ ok: false, output: `Git operation ${decision}.` });
}
if (signal.aborted || this.cliGitTurns.get(sessionId) !== signal) {
- return finish({ ok: false, output: "Codex turn has ended." });
+ return finish({ ok: false, output: "CLI turn has ended." });
}
try {
return await finish(await this.executeTool(sessionId, { name, args }));
diff --git a/apps/server/src/slack/slackApi.ts b/apps/server/src/slack/slackApi.ts
index dacf7a8..9a7e1a0 100644
--- a/apps/server/src/slack/slackApi.ts
+++ b/apps/server/src/slack/slackApi.ts
@@ -665,6 +665,7 @@ export class SlackApi {
gif?: { url: string; title?: string };
as?: SlackPostAs;
replyBroadcast?: boolean;
+ blocks?: unknown[];
},
): Promise {
const as = parseSlackPostAs(input.as);
@@ -710,6 +711,7 @@ export class SlackApi {
};
if (input.threadTs) payload.thread_ts = input.threadTs;
if (input.threadTs && input.replyBroadcast) payload.reply_broadcast = true;
+ if (input.blocks?.length) payload.blocks = input.blocks;
if (gif) {
payload.blocks = [
{
diff --git a/apps/server/src/slack/slackGateway.ts b/apps/server/src/slack/slackGateway.ts
index 9d79ec7..e4f9cee 100644
--- a/apps/server/src/slack/slackGateway.ts
+++ b/apps/server/src/slack/slackGateway.ts
@@ -1,4 +1,8 @@
import {
+ formatSlackApprovalValue,
+ slackApprovalActionId,
+ slackCanApproveTool,
+ parseSlackApprovalValue,
DEFAULT_DOCKER_IMAGE,
formatSlackExcerpt,
formatSlackRunPrompt,
@@ -230,6 +234,44 @@ export class SlackGateway {
return allow?.length ? rows.filter((row) => allow.includes(row.channelId)) : rows;
}
+ async notifyApproval(sessionId: string, toolName: string, callId: string): Promise {
+ if (!slackCanApproveTool(toolName)) return;
+ const session = await this.sessions.get(sessionId);
+ if (!session) return;
+ const channelId = session.slack?.channelId;
+ if (!channelId) return;
+ const value = formatSlackApprovalValue(sessionId, callId);
+ await this.post(
+ channelId,
+ `Locus needs you: approve \`${toolName}\` in this thread. git_push still stays in Locus.`,
+ session.slack?.threadTs,
+ undefined,
+ "bot",
+ false,
+ [
+ {
+ type: "actions",
+ elements: [
+ {
+ type: "button",
+ text: { type: "plain_text", text: "Approve" },
+ action_id: slackApprovalActionId("approved"),
+ value,
+ style: "primary",
+ },
+ {
+ type: "button",
+ text: { type: "plain_text", text: "Deny" },
+ action_id: slackApprovalActionId("denied"),
+ value,
+ style: "danger",
+ },
+ ],
+ },
+ ],
+ );
+ }
+
async post(
channelId: string,
text: string,
@@ -237,6 +279,7 @@ export class SlackGateway {
gif?: { url: string; title?: string },
as?: SlackPostAs,
replyBroadcast?: boolean,
+ blocks?: unknown[],
): Promise {
const account = await this.requireClient();
this.assertChannelAllowed(account, channelId);
@@ -247,6 +290,7 @@ export class SlackGateway {
gif,
as,
replyBroadcast: Boolean(threadTs && replyBroadcast),
+ blocks,
});
const teamId = account.teamId || "default";
const cached = await this.cache.loadMessages(teamId, channelId);
@@ -522,6 +566,46 @@ export class SlackGateway {
});
return;
}
+ if (type === "block_actions") {
+ const action = (
+ payload.actions as Array<{ action_id?: string; value?: string }> | undefined
+ )?.[0];
+ const decision =
+ action?.action_id === slackApprovalActionId("approved")
+ ? "approved"
+ : action?.action_id === slackApprovalActionId("denied")
+ ? "denied"
+ : undefined;
+ const parsed = action?.value ? parseSlackApprovalValue(action.value) : undefined;
+ if (!decision || !parsed) return;
+ const session = await this.sessions.get(parsed.sessionId);
+ if (!session) return;
+ let toolName = "";
+ for (const message of session.messages) {
+ const call = message.toolCalls?.find((item) => item.id === parsed.callId);
+ if (call) {
+ toolName = call.name;
+ break;
+ }
+ }
+ if (!slackCanApproveTool(toolName)) {
+ const channelId = String(
+ (payload.channel as { id?: string } | undefined)?.id ?? session.slack?.channelId ?? "",
+ );
+ if (channelId) {
+ await this.post(
+ channelId,
+ "That tool cannot be approved from Slack. Open Locus for git_push and other forge writes.",
+ session.slack?.threadTs,
+ undefined,
+ "bot",
+ );
+ }
+ return;
+ }
+ await this.sessions.resolveApproval(parsed.sessionId, parsed.callId, decision);
+ return;
+ }
if (type === "shortcut" || type === "message_action") {
const channelId = String(
(payload.channel as { id?: string } | undefined)?.id ?? payload.channel_id ?? "",
diff --git a/apps/server/src/workspace/repoIndex.test.ts b/apps/server/src/workspace/repoIndex.test.ts
index 07366a4..6b0e8c5 100644
--- a/apps/server/src/workspace/repoIndex.test.ts
+++ b/apps/server/src/workspace/repoIndex.test.ts
@@ -273,14 +273,17 @@ describe("workspace index", () => {
await writeFile(join(root, "root.ts"), "export function rootFn() {}\n");
await execFileAsync("git", ["add", "."], { cwd: root });
await execFileAsync("git", ["commit", "-m", "root"], { cwd: root });
- await mkdir(join(root, "siq8", "src"), { recursive: true });
- await writeFile(join(root, "siq8", "src", "app.ts"), "export function nestedFn() {}\n");
- await execFileAsync("git", ["init"], { cwd: join(root, "siq8") });
+ await mkdir(join(root, "nested", "src"), { recursive: true });
+ await writeFile(join(root, "nested", "src", "app.ts"), "export function nestedFn() {}\n");
+ await execFileAsync("git", ["init"], { cwd: join(root, "nested") });
await mkdir(join(root, ".cache"), { recursive: true });
await writeFile(join(root, ".cache", "noise.ts"), "export function leaked() {}\n");
const built = await buildWorkspaceIndex(root);
- expect(built.map.files.map((file) => file.path).sort()).toEqual(["root.ts", "siq8/src/app.ts"]);
- expect(built.map.files.find((file) => file.path === "siq8/src/app.ts")?.symbols).toContain(
+ expect(built.map.files.map((file) => file.path).sort()).toEqual([
+ "nested/src/app.ts",
+ "root.ts",
+ ]);
+ expect(built.map.files.find((file) => file.path === "nested/src/app.ts")?.symbols).toContain(
"nestedFn",
);
});
diff --git a/docs/agent-backends.md b/docs/agent-backends.md
index 7221f84..1d34029 100644
--- a/docs/agent-backends.md
+++ b/docs/agent-backends.md
@@ -2,7 +2,7 @@
Goal: pick **where** a guest runs without moving the Locus control plane off this machine.
-First slice (this tree): spin up Docker agents from the laptop onto **other machines running `locus-worker`** — a LAN box, a cloud VM you created, or a Fargate task whose definition already runs that binary. Placement stays `local` | `worker`. Cloud paste-URL is `WorkerBackend.kind === "cloud"`. Native Fargate is `kind === "fargate"` (size allowlist, recorded HTTP to ECS, no AWS SDK). Azure / GCP adapters are not in this slice.
+First slice (this tree): spin up Docker agents from the laptop onto **other machines running `locus-worker`** — a LAN box, a cloud VM you created, or a Fargate / Azure / GCP worker whose definition already runs that binary. Placement stays `local` | `worker`. Cloud paste-URL is `WorkerBackend.kind === "cloud"`. Native Fargate is `kind === "fargate"` (size allowlist, recorded HTTP to ECS, no AWS SDK). Azure (`kind: azure`) and GCP (`kind: gcp`) reuse the same recorded-HTTP seam.
Related: [SECURITY.md](../SECURITY.md) (loopback control plane), [builtin-browser.md](builtin-browser.md) (live guest Chromium pane, including LAN/cloud workers), `SessionService` in `apps/server/src/session/sessions.ts`, `@locus/worker` in `packages/worker`, `@locus/compute` in `packages/compute`.
@@ -36,7 +36,7 @@ POST /api/sessions
cursorCloud? skip bootGuest
else bootGuest()
placement worker → WorkerClient HTTPS → locus-worker Docker, Host, **or QEMU**
- fargate: ComputeBackend.quote/provision (RunTask) then WorkerClient
+ fargate/azure/gcp: ComputeBackend.quote/provision then WorkerClient
stop: destroy guest (default) unless LAN destroyOnStop=false
fargate stop: destroy guest, then StopTask
idle reaper: destroy guest after idleTtlMs (cloud/fargate cannot disable)
@@ -51,7 +51,7 @@ POST /api/sessions
checkBudget(llm usd + worker compute usd, time, rounds, tokens, subagents)
```
-`runAgentLoop` does not grow `if (aws)` branches. Remote tools are HTTP `POST /v1/guests/:id/tools`. The composer usage hint shows token USD and `$n VM` when compute USD is accruing (operator hourly rate, or the Fargate quote locked at provision).
+`runAgentLoop` does not grow `if (aws)` branches. Remote tools are HTTP `POST /v1/guests/:id/tools`. The composer usage hint shows token USD, `$n VM`, and a combined total when both LLM and compute USD are accruing (operator hourly rate, or the Fargate/Azure/GCP quote locked at provision).
## Operator flow (LAN or cloud VM)
@@ -59,7 +59,7 @@ POST /api/sessions
2. On the laptop: paste that line in Settings → Workers (or `locus config workers add ''`). Add tests the token, pins the cert, and lists the host. New agent **Where** shows live / unreachable. Locus does not scan the LAN. Mark **Cloud VM** when the host is metered (forces destroy-on-stop, default idle TTL 15 minutes, max concurrent 1, requires hourly USD and an image allowlist). Mark **Fargate** when ECS should RunTask an allowed size (region, task definition, size allowlist, image allowlist; no paste URL; hourly USD comes from the dated list-price quote). Save remaining fields (kind, allowlists) if you need them.
3. New agent → **Where** → that worker. Runtime is Docker, Host, or VM. For VMs, place the cloud image in `~/.locus-worker/media` (or `LOCUS_WORKER_HOME/media`) on the worker.
-Tokens live in the vault, not `config.json`. The control plane bind host stays loopback. Fargate talks to ECS with signed `fetch` (recorded JSON fixtures). There is no AWS/Azure/GCP SDK in `package.json`.
+Tokens live in the vault, not `config.json`. The control plane bind host stays loopback. Fargate talks to ECS with signed `fetch` (recorded JSON fixtures). Azure uses ARM + client-credentials; GCP uses a Bearer access token against Compute Engine. There is no AWS/Azure/GCP SDK in `package.json`.
## Multiple Locus apps, one worker host
@@ -74,7 +74,7 @@ One process, default port. Register the same URL and token in each app:
1. On the host: `npm run dev:worker` (`https://0.0.0.0:7421`, token in `~/.locus-worker/token`).
2. On each Locus app: Settings → Workers → that URL and token → Test connection.
-Fine when the apps may share Docker guests. The worker is not multi-tenant: `GET /v1/guests` returns every guest on that process. Anyone with the token can start or destroy any of them. When a concurrent-guest cap is set (cloud default 1), create lists every live guest on that process, so the other app’s guests count against the cap and can appear in this app’s machine list. Settings → Advanced → **Reset all data** then tries to destroy those cached guests.
+Fine when the apps may share Docker guests. Each Locus app stamps an `X-Locus-Plane` id so `GET /v1/guests` (and mutate/display) only returns that plane’s guests. Legacy guests without a plane id stay visible. Anyone with the token can still mint another plane id; isolated workers remain the strong tenancy boundary. When a concurrent-guest cap is set (cloud default 1), create lists live guests **on this plane**. Settings → Advanced → **Reset all data** then tries to destroy those cached guests.
### 2. Isolated workers on the same machine
@@ -110,14 +110,14 @@ There is no “this worker belongs only to app X” setting. Isolation is a diff
## Native Fargate
-`@locus/compute` quotes dated Fargate Linux/x86 US-East-1 list prices (`2026-08`) and POSTs `RunTask` / `DescribeTasks` / `StopTask` to `ecs.{region}.amazonaws.com`, or to the worker **ECS endpoint** when set (PrivateLink or API Gateway; path kept; execute-api hosts sign as `execute-api`). Empty `allowedSizes` cannot provision. Known sizes: `0.25-0.5`, `0.5-1`, `1-2`, `2-4`. The handle is `https://:7421` after `DescribeTasks` returns an ENI address (no loopback fallback), plus a generated `LOCUS_WORKER_TOKEN` injected as a container env override. Existing `WorkerClient` then creates the Docker guest on that worker. Operator registers a task definition that already runs `locus-worker` and pastes the ARN. Locus does not create VPCs, NAT gateways, or GPU nodes. Destroying a Docker guest does not stop a paste-URL VM; Fargate session stop does `StopTask`. Azure / GCP adapters are not in this slice.
+`@locus/compute` quotes dated Fargate Linux/x86 US-East-1 list prices (`2026-08`) and POSTs `RunTask` / `DescribeTasks` / `StopTask` to `ecs.{region}.amazonaws.com`, or to the worker **ECS endpoint** when set (PrivateLink or API Gateway; path kept; execute-api hosts sign as `execute-api`). Empty `allowedSizes` cannot provision. Known sizes: `0.25-0.5`, `0.5-1`, `1-2`, `2-4`. The handle is `https://:7421` after `DescribeTasks` returns an ENI address (no loopback fallback), plus a generated `LOCUS_WORKER_TOKEN` injected as a container env override. Existing `WorkerClient` then creates the Docker guest on that worker. Operator registers a task definition that already runs `locus-worker` and pastes the ARN. Locus does not create VPCs, NAT gateways, or GPU nodes. Destroying a Docker guest does not stop a paste-URL VM; Fargate session stop does `StopTask`. Azure Container Instances (`kind: azure`) and GCP Compute Engine (`kind: gcp`) follow the same quote → provision → WorkerClient path with recorded `fetch` (no SDKs).
## v1 limits
- HTTPS with a self-signed cert (fingerprint pin). Set `LOCUS_WORKER_TLS_CERT` / `LOCUS_WORKER_TLS_KEY` for your own cert.
- Restricted jail: the allowlist CONNECT proxy listens on the worker (not the laptop). Bind is `0.0.0.0` on a random port so `host.docker.internal` from the guest can reach it; do not publish that port.
-- Docker golden-image seed and host share upload over worker HTTPS (`PUT /v1/guests/:id/workspace-seed` and `PUT /v1/guests/:id/host-share`). VM golden disks stay on the worker; laptop qcow2 is not uploaded.
-- Worker QEMU display (VNC/SPICE) binds 127.0.0.1 on the worker. The laptop does not proxy live VNC in v1; screenshots, guest tools, and CLI-over-SSH still work.
+- Docker golden-image seed and host share upload over worker HTTPS (`PUT /v1/guests/:id/workspace-seed` and `PUT /v1/guests/:id/host-share`). VM golden disks upload once to `PUT /v1/images/:id` (8 GiB cap) and live under the worker data dir.
+- Worker QEMU VNC stays bound to the worker loopback; the laptop noVNC pane reaches it through pinned WSS `/v1/guests/:id/display`. Docker Preview and the live Chromium pane already used worker HTTP tunnels.
- Design sessions use worker Files + Preview (canvas is `/workspace/index.html`).
- code-server / IDE tunnels stay on this machine. Preview is the guest HTTP port (or static `index.html`) proxied through the worker.
- Worker Files reads the worker host bind-mount (`docker-workspaces/`), not a laptop copy.
diff --git a/docs/builtin-browser.md b/docs/builtin-browser.md
index 152ab45..ca044bc 100644
--- a/docs/builtin-browser.md
+++ b/docs/builtin-browser.md
@@ -4,7 +4,7 @@ Goal: a **shared Chromium** in the agent workspace — the agent drives it, you
Related: [SECURITY.md](../SECURITY.md) (loopback, network jail, untrusted web), [agent-backends.md](agent-backends.md) (where the guest and its Chromium run), `CdpChromiumDriver` in `packages/guest-agent/src/chromium.ts`, `DockerWorkspace` in `apps/desktop/src/components/docker/DockerWorkspace.tsx`, takeover / handback in `apps/server/src/session/sessions.ts`.
-This is an implementation plan. Do not ship the pane until PR 1 lands.
+The live pane ships for Docker (local and worker). Worker screencast, tabs, operator input, and Preview HTTP already tunnel over the pinned worker HTTPS connection. VM desktops use the separate noVNC display (worker VNC is WSS-proxied).
## Why
diff --git a/docs/harness.md b/docs/harness.md
index cc341fa..7220faf 100644
--- a/docs/harness.md
+++ b/docs/harness.md
@@ -37,10 +37,10 @@ Minimum walls for Locus agents. Prompt text is not a substitute for these checks
| 31 | Assets chat loop | Assets session MCP is playwright-only (no `orient`) and returns the screenshot as an MCP image. After a studio write, HUD-only `expect_text` / clip clicks count as a fake playtest; unwired 3D (no canvas/WebGL/Three), a shrinking Write, and one live `#btnWire` shot get the same continue walls as Games. Playwright evaluates `window.__locusAsset.stats` and fails `mode !== "3d"` or `verts === 0` while still returning the PNG. Attached photos land as `source.png`. |
| 32 | Games iterate loop | Live-paint of Cursor CLI `streamContent` requires a document draft (`` / `` fragment. Games/Assets wipe detection reads the canvas on disk, restores the last good complete file, and continues (no CLI abort on a self-inflicted paint). `wroteLiveCanvas` counts path+`streamContent` patches; a follow-up whose named control is missing gets `GAME_MUTATE_NUDGE`. A Soften/Crunch/Juice/Teach/Fail-state/Balance chip, playtest fix, GAME.md apply, or free-form change ask such as “make it faster” whose authored canvas is still identical to turn start after a look also gets `GAME_MUTATE_NUDGE`; Assets Retopo/Texture/Rig/Animate/Low poly/Split/Printability, ASSET.md apply, or “make the mesh smoother” get the design mutate note. Talking instead of patching is nudged again (cap 2). After that mutate cap, a written canvas that still lacks the named control or race/takedown/burnout mode sets `needsHuman` — see row 51. A Soften/Crunch/Juice/Teach/Fail-state/Balance chip, playtest fix, or GAME.md apply whose canvas is still identical to turn start also sets `needsHuman` — see row 52. HUD `expect road #` / Restart-only clicks are a fake playtest. Drive steps wait until `__locusGame.stats().time` ≥ 2. Talking instead of looking gets one more playwright note (cap 2), same as Design. A playwright look at an unplayable game or unwired 3D canvas still resumes the unplayable/unwired note (cap 2); talking after that look is nudged again and native `toolChoice` stays required. Compact continue keeps that note. `turn.end` `selfCorrects` includes canvas nudge traces. |
| 33 | Coding search wander | After three workspace-search rounds on a coding turn without `apply_patch` / `file_write`, inject a hidden note to edit now. Talking instead of editing is nudged again (cap 2) and native `toolChoice` is required while the wander counter is live. Ops and review routing still win when those sessions apply. `exec` of `npm test` / `pytest` / similar is not a search round. |
-| 34 | Verify before done | A coding turn that mutated files and then stopped without running tests/typecheck/lint (or `git_push` / `git_pr`) gets a hidden verify note (cap 2). Native after-tool resumes verify after a mutate without tests, including on the last round (that slot is refunded so the note can land). Talking instead of testing is nudged again and native `toolChoice` is required after the first verify note. After that verify cap, turn end sets `needsHuman` unless this turn ran tests/typecheck/lint or `git_push` / `git_pr` **after the last edit**. Edits after a passing verify still count as unverified. Running those commands is not enough if the last one failed — see row 49. Follow-up chatter that does not edit is not re-gated. |
+| 34 | Verify before done | A coding turn that mutated files and then stopped without running tests/typecheck/lint (or `git_push` / `git_pr`) gets a hidden verify note (cap 2). Native after-tool resumes verify after a mutate without tests, including on the last round (that slot is refunded so the note can land). Talking instead of testing is nudged again and native `toolChoice` is required after the first verify note. After that verify cap, turn end sets `needsHuman` unless this turn ran tests/typecheck/lint or `git_push` / `git_pr` **after the last edit**. Edits after a passing verify still count as unverified, except a delete-only cleanup (Codex `file_change` `kind: delete`, or a patch that only removes files). Running those commands is not enough if the last one failed — see row 49. Follow-up chatter that does not edit is not re-gated. |
| 35 | Design look-at-canvas | Design first paint is not done. After a successful canvas write, native and CLI turns nudge `playwright` and continue so the next round sees the screenshot. Cursor/Claude/Codex Design session MCP catalogs `playwright` (same as Games/Assets); omitting it is a leak — look walls cannot fire. Each Design turn injects a short canvas snapshot (bytes, headline, type, tokens, CDN, generic fingerprint) so native hidden notes and CLI prompts patch in place instead of re-reading `index.html`; omitting that snapshot is a leak — iterate and quality walls see a model that rewrites slop. CLI Cursor/Claude/Codex continue rounds (quality/look/mutate/juice/sculpt/adherence/unused/restore and canvas steers) re-inject a fresh snapshot with the wall note, without resetting the turn-start mutate baseline; a resume that only sends the wall text is a leak — the model re-reads `index.html` after the first spawn. Harvesting chat HTML / a misplaced `index.html` onto the canvas is a write: CLI recover continues the look (then quality/juice/sculpt) instead of stopping; omitting that continue is a leak — slop ships without a screenshot. Talking instead of looking gets one more look note (cap 2). A leftover generic, host-fallback, dry, placeholder, unused-pack, unplayable, unwired, or kit-adherence canvas still looks even when this round did not write — talk-only mutate cannot skip the screenshot and rewrite. After that look (or the look cap), quality / juice / sculpt / adherence still win over talk-only mutate. After that look cap, HTML quality / juice / sculpt / adherence / unused / unplayable walls still run — refusing to screenshot cannot skip them. After the incomplete-document cap, look still runs (row 58) — refusing to finish `` cannot skip the screenshot. Quality / juice / sculpt still require a complete document. CLI write/playwright flags persist across resumes. Games/Assets share the same after-model look retry so a talk-only round cannot skip the screenshot. A follow-up that talks instead of editing is nudged again (cap 2); native `toolChoice` stays required while the mutate counter is live. A look at a change request whose authored canvas is still identical to turn start also resumes that mutate note (cap 2); generic quality and leftover kit adherence still win first. CLI Design follow-ups that talk without writing resume the same mutate note; generic quality still wins when the look budget is spent. CLI Games follow-ups that talk without looking or writing also resume that mutate note (Soften / “make it faster” / missing `#id`); leftover unused / unplayable / dry still look first. Native after-tool leftover iterate/change asks resume mutate without a write or screenshot this round; a write without a look still looks first. |
| 36 | Typed self-correct | Fixable tool errors pick a patch / test / missing-path / CORS nudge instead of one generic retry. `Harness blocked git_push/git_pr: tests failed` is fixable (fix the tests; do not retry the push). After the self-correct cap, a last verify that still failed cannot ship (row 49). |
-| 37 | CLI coding wander/verify | A finished Cursor/Claude/Codex spawn is one inner loop. Three or more workspace-search calls without `apply_patch` / `file_write` resume until an edit (cap 2). A mutate without tests/typecheck/lint/`git_push`/`git_pr` resumes until tests run (cap 2). Mutate/verify/wander flags persist across resumes so a talk-only retry still has to edit or test. `canonicalToolName` maps StrReplace / searchReplace / Edit / Shell / Bash / Grep / Glob / LS onto catalog names so those walls see CLI tools. |
+| 37 | CLI coding wander/verify | A finished Cursor/Claude/Codex spawn is one inner loop. Three or more workspace-search calls without `apply_patch` / `file_write` resume until an edit (cap 2). A mutate without tests/typecheck/lint/`git_push`/`git_pr` resumes until tests run (cap 2). Mutate/verify/wander flags persist across resumes so a talk-only retry still has to edit or test. `canonicalToolName` maps StrReplace / searchReplace / Edit / Shell / Bash / Grep / Glob / LS onto catalog names so those walls see CLI tools. Codex `file_change` items keep `changes[]` (path + add/update/delete) instead of wrapping the array as `{ value }`, so a post-test delete of a temp shim does not re-gate a turn that already ran `npm test`. |
| 38 | Cursor coding routing card | Coding Cursor CLI sessions write `.cursor/rules/locus.mdc` (`alwaysApply`) with `CURSOR_CODING_ROUTING_CARD` (edit now, then Shell tests), including Worker Cursor CLI via the worker workspace write. Plan / review / canvas sessions do not get that card. |
| 39 | CLI ops/review routing | A search-heavy CLI spawn that named Grafana/Slack/Jira without calling those tools resumes once with ops routing. A review spawn that searched twice without `git_pr_get` resumes once with review routing. A JSON verdict without inspect does not skip that look; after the inspect cap, a guessed PASS still cannot ship. |
| 40 | CLI self-correct | Cursor CLI tool results keep failure envelopes (`error` / `isError` / nonzero `exitCode`) instead of unwrapping them to plain text. A completed spawn with a fixable tool error (patch apply, missing path, failed tests) stores `ToolCall.error` and resumes once with the typed self-correct note. Mutate/verify flags persist across CLI resumes so a talk-only retry still has to run tests. Plain-text results that merely mention “fail” stay ok. |
diff --git a/docs/phone.md b/docs/phone.md
index 03ceec8..01e51cc 100644
--- a/docs/phone.md
+++ b/docs/phone.md
@@ -9,7 +9,7 @@ This is not a hosted Locus. Agents, keys, and approvals still run on the compute
1. Settings → Phone → **Enable phone remote** mints a pairing token (vaulted, not written to `config.json`).
2. Restart Locus. While enabled, the control plane binds `0.0.0.0` instead of loopback.
3. Non-loopback clients must present the token (`Authorization: Bearer`, cookie `locus_phone`, or `?token=` on the first UI load) for `/api` and `/ws`. Public internet peers are rejected even with a token. The desktop UI shell (HTML/JS/CSS) is served to LAN clients so Android Chrome can load the app even when it drops the pairing cookie on HTTP IP origins. Pairing, rotate, and token reveal stay on the loopback operator.
-4. Open the Locus Android app (`apps/android`), scan the **QR code** on Settings → Phone, or open `https://:7420/?token=…&fp=…` on the phone. The QR prefers HTTPS and pins the self-signed cert fingerprint. The page keeps the token (the address bar is cleaned after load) and sends it on API and WebSocket requests. The app only loads RFC1918 / Tailscale addresses (including MagicDNS). Pairing opens a **phone shell**: **Agents** (status dots, All / Needs you / Running / Unseen), Slack (read and send as you or as Locus), a large composer, a **Needs you** inbox for Approve / Deny (including Approve all / Deny all across chats) plus Design comments and PR review, **Design** canvases (comment and Promote into a Docker agent), a **Code** PR inbox (reply / approve / request changes / merge, plus a compact Change Stack from GET), a **Files** tab on Docker chats, and **Open browser** on Docker chats (Take over / Hand back for logins). **More** lists Games, Assets, Boards, Projects, Grafana explorer, Automations (pause / enable), IDE workspaces (Open on desktop), and **Notifications** (Needs you / agent turns / Slack / automations and quiet hours); pairing status is read-only (token mint/rotate stays on the desktop). VNC stays on the desktop. Use **More → Use desktop layout** (or `?shell=desktop`) for the full chrome. `?shell=mobile` forces the phone shell when testing. Loopback clients (desktop, CLI, Vite) still talk **HTTP** on the same port.
+4. Open the Locus Android app (`apps/android`), scan the **QR code** on Settings → Phone, or open `https://:7420/?token=…&fp=…` on the phone. The QR prefers HTTPS and pins the self-signed cert fingerprint. The page keeps the token (the address bar is cleaned after load) and sends it on API and WebSocket requests. The app only loads RFC1918 / Tailscale addresses (including MagicDNS). Pairing opens a **phone shell**: **Agents** (status dots, All / Needs you / Running / Unseen), Slack (read and send as you or as Locus), a large composer, a **Needs you** inbox for Approve / Deny (including Approve all / Deny all across chats) plus Design comments and PR review, **Design** canvases (comment and Promote into a Docker agent), a **Code** PR inbox (reply / approve / request changes / merge, plus a compact Change Stack from GET), a **Files** tab on Docker chats, and **Open browser** on Docker chats (Take over / Hand back for logins). **More** lists Games, Assets, Boards, Projects, Grafana explorer, Automations (pause / enable), **Settings**, **New automation**, **Command palette**, IDE workspaces (Open on desktop), and **Notifications** (Needs you / agent turns / Slack / automations and quiet hours); pairing status is read-only (token mint/rotate stays on the desktop). VM threads offer **view-only VNC** and **Find in chat**. Interactive VNC control stays on the desktop. Use **More → Use desktop layout** (or `?shell=desktop`) for the full chrome. `?shell=mobile` forces the phone shell when testing. Loopback clients (desktop, CLI, Vite) still talk **HTTP** on the same port.
5. Disable phone remote, then restart Locus, to bind loopback only again. The listen address is chosen at startup.
## 503 on the pairing URL
diff --git a/docs/slack.md b/docs/slack.md
index 308533e..dba501d 100644
--- a/docs/slack.md
+++ b/docs/slack.md
@@ -58,7 +58,7 @@ Empty state (same voice as Codebase): **Sign in with Slack** in Settings → Int
7. **Catch me up, locally.** A per-channel digest of messages since the last catch-up (capped excerpt). The run stays on this machine; it does not `slack_post` unless you ask. History never leaves for Slack’s cloud.
8. **Subscribe from the Slack pane.** Pin a channel or thread onto an agent from the conversation row (or the thread header). The row shows **Listening**. Same `subscribe` path as a coding chat — Locus does not post a “now listening” notice.
9. **Cite and jump.** When a Slack message mentions a `/workspace/…` file, a GitHub/GitLab/Bitbucket PR URL, or a Locus session id, those become chips that open Files, Codebase, or the bound chat.
-10. Jump from the Locus agent chip to the Agents section. Approvals stay in Locus chat. Do not approve `git_push` from Slack in v1.
+10. Jump from the Locus agent chip to the Agents section. Slack action buttons may Approve / Deny `slack_post` and `slack_upload` only. Do not approve `git_push` from Slack.
There is no @Locus bot to invite, mention, or DM. Teammates ask *you*; you run Locus. `/locus` is something **you** type.
@@ -311,7 +311,7 @@ Composer: Enter sends, Shift+Enter newline, no slash-menu of guest commands (tho
Own messages: `user === status.userId` render as “You”. If `session.slack.lastPostTs` matches an agent-approved post, show an agent chip **in Locus only**.
-Markdown: Slack mrkdwn (`*bold*`, `<@U123>`, `<#C123>`) converted before `formatMarkdown`. Unknown tokens stay as text. Images/files: filename + Slack permalink in v1; no `files.upload` yet.
+Markdown: Slack mrkdwn (`*bold*`, `<@U123>`, `<#C123>`) converted before `formatMarkdown`. Unknown tokens stay as text. Images/files: `files.upload` (or the current files API) plus Slack permalinks; `slack_upload` waits for Approve / Deny (Slack buttons allowed for that tool).
## PR sequence
@@ -430,7 +430,7 @@ Do not rewrite these; extend them.
- Hosting a Locus Slack app that all workspaces install (no Locus-operated OAuth).
- Embedding Slack’s JS SDK in the renderer; all Slack HTTP stays on the server.
- Feeding full channel history into the model (capped excerpt only).
-- Slack-based Approve / Deny, or mapping emoji to autonomy.
+- Mapping emoji to autonomy. Slack buttons already Approve / Deny `slack_post` / `slack_upload` only.
- Status / tool-chip messages into the channel as the operator.
- Replacing first-party `jira_*` / `grafana_*` with a generic “chat integrations” framework in this work.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index fbb74de..3cd8507 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -40,7 +40,15 @@ The composer queue holds 32 prompts (configurable in Settings → Advanced). Sen
## Image tag `locus/agent:0.1`
-The Docker image tag is independent of the app semver (`0.10.x`). Rebuild with `npm run images` after Dockerfile changes.
+The runtime tag stays `locus/agent:0.1` so existing guests keep working. `npm run images` also tags `locus/agent:0.18.0`. Rebuild after Dockerfile changes.
+
+## Worker VNC is blank
+
+Worker QEMU VNC listens on the worker loopback only. The laptop pane uses WSS `/v1/guests/:id/display`. If the pane fails, Test connection the worker (TLS pin), confirm the guest has a display, and check that the control plane can reach the worker over HTTPS. Phone VNC is view-only.
+
+## Slack Approve does nothing for git_push
+
+That is intentional. Slack buttons may approve `slack_post` / `slack_upload` only. Open Locus for forge writes.
## Windows opens a stack of terminal windows on each prompt
diff --git a/images/agent/README.md b/images/agent/README.md
index 30e4fee..2fee18f 100644
--- a/images/agent/README.md
+++ b/images/agent/README.md
@@ -3,9 +3,9 @@
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 images/agent
+npm run images # docker build -t locus/agent:0.1 -t locus/agent:0.18.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.
-The image tag (`locus/agent:0.1`) is independent of the app semver (`0.10.x`). Do not retag it when bumping the desktop version.
+The runtime tag (`locus/agent:0.1`) stays stable so existing guests keep working. Builds also apply `locus/agent:`.
diff --git a/package-lock.json b/package-lock.json
index 9ceba5b..f86b49d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "locus",
- "version": "0.17.0",
+ "version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "locus",
- "version": "0.17.0",
+ "version": "0.18.0",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -28,7 +28,7 @@
},
"apps/cli": {
"name": "@locus/cli",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@locus/shared": "*",
"tsx": "^4.23.15",
@@ -81,7 +81,7 @@
},
"apps/desktop": {
"name": "@locus/desktop",
- "version": "0.17.0",
+ "version": "0.18.0",
"license": "MIT",
"dependencies": {
"@locus/shared": "*",
@@ -185,7 +185,7 @@
},
"apps/server": {
"name": "@locus/server",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@hono/node-server": "^2.1.5",
"@locus/alb": "*",
@@ -8626,7 +8626,7 @@
},
"packages/alb": {
"name": "@locus/alb",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@dagrejs/dagre": "^3.1.1",
"nanoid": "^6.0.2",
@@ -8708,7 +8708,7 @@
},
"packages/compute": {
"name": "@locus/compute",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@locus/shared": "*"
},
@@ -8755,7 +8755,7 @@
},
"packages/guest-agent": {
"name": "@locus/guest-agent",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@locus/shared": "*",
"ws": "^8.22.0"
@@ -8804,7 +8804,7 @@
},
"packages/hypervisor": {
"name": "@locus/hypervisor",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@locus/shared": "*"
},
@@ -8851,7 +8851,7 @@
},
"packages/providers": {
"name": "@locus/providers",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@locus/shared": "*"
},
@@ -8898,7 +8898,7 @@
},
"packages/shared": {
"name": "@locus/shared",
- "version": "0.17.0",
+ "version": "0.18.0",
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2",
@@ -8942,7 +8942,7 @@
},
"packages/worker": {
"name": "@locus/worker",
- "version": "0.17.0",
+ "version": "0.18.0",
"dependencies": {
"@hono/node-server": "^2.1.5",
"@locus/guest-agent": "*",
diff --git a/package.json b/package.json
index 0b981dd..a5e2daa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "locus",
- "version": "0.17.0",
+ "version": "0.18.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 images/agent",
+ "images": "docker build -t locus/agent:0.1 -t locus/agent:0.18.0 images/agent",
"format:check": "prettier --check \"**/*.{ts,tsx,mjs,cjs,json,css}\"",
"format:fix": "prettier --write \"**/*.{ts,tsx,mjs,cjs,json,css}\""
},
diff --git a/packages/alb/package.json b/packages/alb/package.json
index 5454c15..d84d225 100644
--- a/packages/alb/package.json
+++ b/packages/alb/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/alb",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Agent Loop Builder workflow runtime, built into Locus",
"type": "module",
diff --git a/packages/compute/package.json b/packages/compute/package.json
index f1b02cb..b24deb6 100644
--- a/packages/compute/package.json
+++ b/packages/compute/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/compute",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Fargate placement quotes and RunTask adapter",
"type": "module",
diff --git a/packages/compute/src/azure.test.ts b/packages/compute/src/azure.test.ts
new file mode 100644
index 0000000..5c7b168
--- /dev/null
+++ b/packages/compute/src/azure.test.ts
@@ -0,0 +1,130 @@
+import { mkdtemp } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import type { WorkerBackend } from "@locus/shared";
+import { AzureComputeBackend } from "./azure.js";
+import { CompositeComputeBackend } from "./composite.js";
+import { GcpComputeBackend } from "./gcp.js";
+import { MockComputeBackend } from "./mock.js";
+
+const azure: WorkerBackend = {
+ id: "az1",
+ name: "Azure",
+ url: "",
+ kind: "azure",
+ token: "client-secret",
+ tenantId: "tenant",
+ clientId: "app",
+ subscriptionId: "sub",
+ resourceGroup: "rg",
+ containerGroup: "locus",
+ region: "eastus",
+ allowedSizes: ["0.25-0.5"],
+ allowedImages: ["locus/agent"],
+};
+
+function azureFetch(): typeof fetch {
+ let described = 0;
+ return (async (input, init) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ if (url.includes("login.microsoftonline.com")) {
+ return new Response(JSON.stringify({ access_token: "arm-token" }), { status: 200 });
+ }
+ if (method === "PUT") {
+ return new Response(JSON.stringify({ properties: {} }), { status: 200 });
+ }
+ if (method === "GET") {
+ described += 1;
+ const ip = described > 1 ? "203.0.113.20" : undefined;
+ return new Response(JSON.stringify({ properties: { ipAddress: { ip } } }), { status: 200 });
+ }
+ if (method === "DELETE") return new Response(null, { status: 204 });
+ return new Response(`unexpected ${method} ${url}`, { status: 500 });
+ }) as typeof fetch;
+}
+
+describe("Azure recorded HTTP", () => {
+ const wait = async () => {};
+
+ it("puts an ACI group then waits for a public IP", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-az-"));
+ const urls: string[] = [];
+ const fetchFn = azureFetch();
+ const compute = new AzureComputeBackend(
+ ((input, init) => {
+ urls.push(`${init?.method ?? "GET"} ${String(input)}`);
+ return fetchFn(input, init);
+ }) as typeof fetch,
+ wait,
+ join(dir, "compute-azure.json"),
+ );
+ const handle = await compute.provision({ backend: azure, sessionId: "s1", size: "0.25-0.5" });
+ expect(handle.url).toBe("https://203.0.113.20:7421");
+ expect(urls[0]).toContain("login.microsoftonline.com");
+ expect(urls.some((row) => row.startsWith("PUT "))).toBe(true);
+ await compute.destroy(handle.id, azure);
+ expect(compute.get(handle.id)).toBeUndefined();
+ });
+});
+
+describe("composite compute", () => {
+ it("routes Azure and GCP kinds away from Fargate", () => {
+ const fargate = new MockComputeBackend({ url: "https://fargate", token: "f" });
+ const azureBackend = new MockComputeBackend({ url: "https://azure", token: "a" });
+ const gcpBackend = new MockComputeBackend({ url: "https://gcp", token: "g" });
+ const composite = new CompositeComputeBackend(fargate, azureBackend, gcpBackend);
+ expect(composite.quote({ ...azure, kind: "azure" }, "0.25-0.5").usdPerHour).toBe(
+ azureBackend.quote({ ...azure, kind: "azure" }, "0.25-0.5").usdPerHour,
+ );
+ expect(
+ composite.quote({ ...azure, kind: "gcp", allowedSizes: ["0.25-0.5"] }, "0.25-0.5"),
+ ).toEqual(gcpBackend.quote({ ...azure, kind: "gcp", allowedSizes: ["0.25-0.5"] }, "0.25-0.5"));
+ });
+});
+
+describe("GCP recorded HTTP", () => {
+ const wait = async () => {};
+ const gcp: WorkerBackend = {
+ id: "g1",
+ name: "GCP",
+ url: "",
+ kind: "gcp",
+ token: "ya29.token",
+ project: "proj",
+ zone: "us-central1-a",
+ instanceTemplate: "projects/proj/global/instanceTemplates/locus",
+ allowedSizes: ["0.25-0.5"],
+ allowedImages: ["locus/agent"],
+ };
+
+ it("inserts from a template then waits for a NAT IP", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-gcp-"));
+ let described = 0;
+ const compute = new GcpComputeBackend(
+ (async (input, init) => {
+ const method = init?.method ?? "GET";
+ if (method === "POST") {
+ return new Response(JSON.stringify({ name: "locus-s1" }), { status: 200 });
+ }
+ if (method === "GET") {
+ described += 1;
+ const natIP = described > 1 ? "203.0.113.30" : undefined;
+ return new Response(
+ JSON.stringify({ networkInterfaces: [{ accessConfigs: [{ natIP }] }] }),
+ { status: 200 },
+ );
+ }
+ if (method === "DELETE") return new Response(null, { status: 204 });
+ return new Response("nope", { status: 500 });
+ }) as typeof fetch,
+ wait,
+ join(dir, "compute-gcp.json"),
+ );
+ const handle = await compute.provision({ backend: gcp, sessionId: "s1", size: "0.25-0.5" });
+ expect(handle.url).toBe("https://203.0.113.30:7421");
+ await compute.destroy(handle.id, gcp);
+ expect(compute.get(handle.id)).toBeUndefined();
+ });
+});
diff --git a/packages/compute/src/azure.ts b/packages/compute/src/azure.ts
new file mode 100644
index 0000000..84d6c12
--- /dev/null
+++ b/packages/compute/src/azure.ts
@@ -0,0 +1,216 @@
+import { randomBytes } from "node:crypto";
+import { fargateResources, fargateUsdPerHour } from "./prices.js";
+import { handleFromRecord, loadComputeTaskRegistry, type ComputeTaskRegistry } from "./registry.js";
+import type {
+ ComputeBackend,
+ ComputeHandle,
+ ComputeOrphan,
+ CostQuote,
+ ProvisionRequest,
+} from "./types.js";
+import { workerAllowedSizes, workerPickSize, type WorkerBackend } from "@locus/shared";
+
+const DESCRIBE_ATTEMPTS = 8;
+
+export class AzureComputeBackend implements ComputeBackend {
+ private handles = new Map();
+ private readonly fetchFn: typeof fetch;
+ private readonly wait: (ms: number) => Promise;
+ private readonly registry: ComputeTaskRegistry;
+
+ constructor(
+ fetchFn: typeof fetch = fetch,
+ wait?: (ms: number) => Promise,
+ registryPath?: string,
+ ) {
+ this.fetchFn = fetchFn;
+ this.wait = wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ this.registry = loadComputeTaskRegistry(registryPath);
+ for (const row of this.registry.list()) {
+ const handle = handleFromRecord(row);
+ if (handle) this.handles.set(handle.id, handle);
+ }
+ }
+
+ quote(backend: WorkerBackend, size: string): CostQuote {
+ const resolved = workerPickSize(backend, size);
+ if (!workerAllowedSizes(backend)?.length) {
+ throw new Error("Azure workers need a size allowlist so a runaway agent cannot pick 4 vCPU");
+ }
+ return { size: resolved, usdPerHour: fargateUsdPerHour(resolved) };
+ }
+
+ async provision(req: ProvisionRequest): Promise {
+ const quoted = this.quote(req.backend, req.size);
+ const resources = fargateResources(quoted.size);
+ const token = randomWorkerToken();
+ const access = await this.accessToken(req.backend);
+ const name = req.backend.containerGroup?.trim();
+ if (!name) throw new Error("Azure workers need a container group name");
+ const subscription = req.backend.subscriptionId?.trim();
+ const resourceGroup = req.backend.resourceGroup?.trim();
+ const region = req.backend.region?.trim();
+ const image = req.backend.allowedImages?.[0]?.trim();
+ if (!subscription || !resourceGroup || !region || !image) {
+ throw new Error("Azure workers need subscription, resource group, region, and an image");
+ }
+ const id = `/subscriptions/${subscription}/resourceGroups/${resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/${name}`;
+ this.registry.upsert({ id, backendId: req.backend.id, sessionId: req.sessionId });
+ await this.arm(req.backend, access, "PUT", `${id}?api-version=2023-05-01`, {
+ location: region,
+ properties: {
+ osType: "Linux",
+ restartPolicy: "Never",
+ ipAddress: { type: "Public", ports: [{ protocol: "TCP", port: 7421 }] },
+ containers: [
+ {
+ name: "worker",
+ properties: {
+ image,
+ ports: [{ port: 7421 }],
+ environmentVariables: [{ name: "LOCUS_WORKER_TOKEN", value: token }],
+ resources: {
+ requests: {
+ cpu: Number(resources.cpu) / 1024,
+ memoryInGB: Number(resources.memory) / 1024,
+ },
+ },
+ },
+ },
+ ],
+ },
+ tags: { "locus:managed": "true", "locus:session": req.sessionId },
+ });
+ try {
+ const ip = await this.waitForIp(req.backend, access, id);
+ const handle: ComputeHandle = {
+ id,
+ url: `https://${ip}:7421`,
+ token,
+ size: quoted.size,
+ };
+ this.handles.set(handle.id, handle);
+ this.registry.upsert({
+ id: handle.id,
+ backendId: req.backend.id,
+ sessionId: req.sessionId,
+ url: handle.url,
+ token: handle.token,
+ size: handle.size,
+ });
+ return handle;
+ } catch (err) {
+ await this.destroy(id, req.backend).catch(() => undefined);
+ throw err;
+ }
+ }
+
+ async destroy(id: string, backend?: WorkerBackend): Promise {
+ if (backend) {
+ try {
+ const access = await this.accessToken(backend);
+ await this.arm(backend, access, "DELETE", `${id}?api-version=2023-05-01`);
+ } catch {
+ /* already gone */
+ }
+ }
+ this.handles.delete(id);
+ this.registry.remove(id);
+ }
+
+ get(id: string): ComputeHandle | undefined {
+ const live = this.handles.get(id);
+ if (live) return live;
+ const restored = handleFromRecord(this.registry.get(id) ?? { id: "", backendId: "" });
+ if (!restored) return undefined;
+ this.handles.set(id, restored);
+ return restored;
+ }
+
+ async listOrphans(liveIds: Set): Promise {
+ const seen = new Set();
+ const out: ComputeOrphan[] = [];
+ for (const row of this.registry.list()) {
+ if (liveIds.has(row.id) || seen.has(row.id)) continue;
+ seen.add(row.id);
+ out.push({ id: row.id, backendId: row.backendId });
+ }
+ return out.filter((row) => row.backendId);
+ }
+
+ private async waitForIp(backend: WorkerBackend, access: string, id: string): Promise {
+ for (let attempt = 0; attempt < DESCRIBE_ATTEMPTS; attempt += 1) {
+ if (attempt) await this.wait(1_000);
+ const described = await this.arm>(
+ backend,
+ access,
+ "GET",
+ `${id}?api-version=2023-05-01`,
+ );
+ const ip = ipFromAci(described);
+ if (ip) return ip;
+ }
+ throw new Error("Azure container group did not get a public IP");
+ }
+
+ private async accessToken(backend: WorkerBackend): Promise {
+ const tenant = backend.tenantId?.trim();
+ const clientId = backend.clientId?.trim();
+ const secret = backend.token?.trim();
+ if (!tenant || !clientId || !secret) {
+ throw new Error("Azure workers need a tenant, client id, and client secret");
+ }
+ const url = `https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/token`;
+ const body = new URLSearchParams({
+ grant_type: "client_credentials",
+ client_id: clientId,
+ client_secret: secret,
+ scope: "https://management.azure.com/.default",
+ });
+ const res = await this.fetchFn(url, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body,
+ });
+ const json = (await res.json().catch(() => ({}))) as { access_token?: string };
+ if (!res.ok || !json.access_token) throw new Error("Azure token request failed");
+ return json.access_token;
+ }
+
+ private async arm(
+ backend: WorkerBackend,
+ access: string,
+ method: string,
+ path: string,
+ body?: Record,
+ ): Promise {
+ const base = (backend.armUrl?.trim() || "https://management.azure.com").replace(/\/+$/, "");
+ const url = path.startsWith("http")
+ ? path
+ : `${base}${path.startsWith("/") ? path : `/${path}`}`;
+ const res = await this.fetchFn(url, {
+ method,
+ headers: {
+ authorization: `Bearer ${access}`,
+ "content-type": "application/json",
+ },
+ body: body && method !== "GET" && method !== "DELETE" ? JSON.stringify(body) : undefined,
+ });
+ if (method === "DELETE" && (res.status === 204 || res.status === 404)) return {} as T;
+ const json = (await res.json().catch(() => ({}))) as T & { error?: { message?: string } };
+ if (!res.ok) {
+ throw new Error(json.error?.message || `Azure ARM ${method} failed (${res.status})`);
+ }
+ return json;
+ }
+}
+
+function randomWorkerToken(): string {
+ return randomBytes(24).toString("hex");
+}
+
+function ipFromAci(row: Record): string | undefined {
+ const properties = row.properties as { ipAddress?: { ip?: string } } | undefined;
+ const ip = properties?.ipAddress?.ip?.trim();
+ return ip || undefined;
+}
diff --git a/packages/compute/src/composite.ts b/packages/compute/src/composite.ts
new file mode 100644
index 0000000..c3e8383
--- /dev/null
+++ b/packages/compute/src/composite.ts
@@ -0,0 +1,60 @@
+import { workerKind, type WorkerBackend } from "@locus/shared";
+import type {
+ ComputeBackend,
+ ComputeHandle,
+ ComputeOrphan,
+ CostQuote,
+ ProvisionRequest,
+} from "./types.js";
+
+export class CompositeComputeBackend implements ComputeBackend {
+ constructor(
+ private readonly fargate: ComputeBackend,
+ private readonly azure: ComputeBackend,
+ private readonly gcp: ComputeBackend,
+ ) {}
+
+ private forBackend(backend: WorkerBackend): ComputeBackend {
+ const kind = workerKind(backend);
+ if (kind === "azure") return this.azure;
+ if (kind === "gcp") return this.gcp;
+ return this.fargate;
+ }
+
+ quote(backend: WorkerBackend, size: string): CostQuote {
+ return this.forBackend(backend).quote(backend, size);
+ }
+
+ provision(req: ProvisionRequest): Promise {
+ return this.forBackend(req.backend).provision(req);
+ }
+
+ destroy(id: string, backend?: WorkerBackend): Promise {
+ if (backend) return this.forBackend(backend).destroy(id, backend);
+ return Promise.all([
+ this.fargate.destroy(id),
+ this.azure.destroy(id),
+ this.gcp.destroy(id),
+ ]).then(() => undefined);
+ }
+
+ get(id: string): ComputeHandle | undefined {
+ return this.fargate.get(id) ?? this.azure.get(id) ?? this.gcp.get(id);
+ }
+
+ async listOrphans(liveIds: Set): Promise {
+ const rows = await Promise.all([
+ this.fargate.listOrphans(liveIds),
+ this.azure.listOrphans(liveIds),
+ this.gcp.listOrphans(liveIds),
+ ]);
+ const seen = new Set();
+ const out: ComputeOrphan[] = [];
+ for (const row of rows.flat()) {
+ if (seen.has(row.id)) continue;
+ seen.add(row.id);
+ out.push(row);
+ }
+ return out;
+ }
+}
diff --git a/packages/compute/src/gcp.ts b/packages/compute/src/gcp.ts
new file mode 100644
index 0000000..8784543
--- /dev/null
+++ b/packages/compute/src/gcp.ts
@@ -0,0 +1,187 @@
+import { randomBytes } from "node:crypto";
+import { workerAllowedSizes, workerPickSize, type WorkerBackend } from "@locus/shared";
+import { fargateUsdPerHour } from "./prices.js";
+import { handleFromRecord, loadComputeTaskRegistry, type ComputeTaskRegistry } from "./registry.js";
+import type {
+ ComputeBackend,
+ ComputeHandle,
+ ComputeOrphan,
+ CostQuote,
+ ProvisionRequest,
+} from "./types.js";
+
+const DESCRIBE_ATTEMPTS = 8;
+
+export class GcpComputeBackend implements ComputeBackend {
+ private handles = new Map();
+ private readonly fetchFn: typeof fetch;
+ private readonly wait: (ms: number) => Promise;
+ private readonly registry: ComputeTaskRegistry;
+
+ constructor(
+ fetchFn: typeof fetch = fetch,
+ wait?: (ms: number) => Promise,
+ registryPath?: string,
+ ) {
+ this.fetchFn = fetchFn;
+ this.wait = wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ this.registry = loadComputeTaskRegistry(registryPath);
+ for (const row of this.registry.list()) {
+ const handle = handleFromRecord(row);
+ if (handle) this.handles.set(handle.id, handle);
+ }
+ }
+
+ quote(backend: WorkerBackend, size: string): CostQuote {
+ const resolved = workerPickSize(backend, size);
+ if (!workerAllowedSizes(backend)?.length) {
+ throw new Error("GCP workers need a size allowlist so a runaway agent cannot pick 4 vCPU");
+ }
+ return { size: resolved, usdPerHour: fargateUsdPerHour(resolved) };
+ }
+
+ async provision(req: ProvisionRequest): Promise {
+ const quoted = this.quote(req.backend, req.size);
+ const token = randomWorkerToken();
+ const project = req.backend.project?.trim();
+ const zone = req.backend.zone?.trim();
+ const template = req.backend.instanceTemplate?.trim();
+ const access = req.backend.token?.trim();
+ if (!project || !zone || !template || !access) {
+ throw new Error("GCP workers need a project, zone, instance template, and access token");
+ }
+ const name = `locus-${req.sessionId.replace(/[^a-z0-9]/gi, "").slice(0, 20) || "worker"}`;
+ const id = `projects/${project}/zones/${zone}/instances/${name}`;
+ this.registry.upsert({ id, backendId: req.backend.id, sessionId: req.sessionId });
+ const created = await this.compute>(
+ req.backend,
+ "POST",
+ `/compute/v1/projects/${project}/zones/${zone}/instances?sourceInstanceTemplate=${encodeURIComponent(template)}`,
+ {
+ name,
+ metadata: {
+ items: [{ key: "locus-worker-token", value: token }],
+ },
+ labels: { "locus-managed": "true", "locus-session": req.sessionId.slice(0, 63) },
+ },
+ );
+ const instanceName = instanceNameFrom(created) || name;
+ const liveId = `projects/${project}/zones/${zone}/instances/${instanceName}`;
+ try {
+ const ip = await this.waitForIp(req.backend, liveId);
+ const handle: ComputeHandle = {
+ id: liveId,
+ url: `https://${ip}:7421`,
+ token,
+ size: quoted.size,
+ };
+ this.handles.set(handle.id, handle);
+ this.registry.upsert({
+ id: handle.id,
+ backendId: req.backend.id,
+ sessionId: req.sessionId,
+ url: handle.url,
+ token: handle.token,
+ size: handle.size,
+ });
+ return handle;
+ } catch (err) {
+ await this.destroy(liveId, req.backend).catch(() => undefined);
+ throw err;
+ }
+ }
+
+ async destroy(id: string, backend?: WorkerBackend): Promise {
+ if (backend) {
+ try {
+ await this.compute(backend, "DELETE", `/compute/v1/${id}`);
+ } catch {
+ /* already gone */
+ }
+ }
+ this.handles.delete(id);
+ this.registry.remove(id);
+ }
+
+ get(id: string): ComputeHandle | undefined {
+ const live = this.handles.get(id);
+ if (live) return live;
+ const restored = handleFromRecord(this.registry.get(id) ?? { id: "", backendId: "" });
+ if (!restored) return undefined;
+ this.handles.set(id, restored);
+ return restored;
+ }
+
+ async listOrphans(liveIds: Set): Promise {
+ const out: ComputeOrphan[] = [];
+ const seen = new Set();
+ for (const row of this.registry.list()) {
+ if (liveIds.has(row.id) || seen.has(row.id)) continue;
+ seen.add(row.id);
+ out.push({ id: row.id, backendId: row.backendId });
+ }
+ return out.filter((row) => row.backendId);
+ }
+
+ private async waitForIp(backend: WorkerBackend, id: string): Promise {
+ for (let attempt = 0; attempt < DESCRIBE_ATTEMPTS; attempt += 1) {
+ if (attempt) await this.wait(1_000);
+ const described = await this.compute>(
+ backend,
+ "GET",
+ `/compute/v1/${id}`,
+ );
+ const ip = natIpFrom(described);
+ if (ip) return ip;
+ }
+ throw new Error("GCP instance did not get a public IP");
+ }
+
+ private async compute(
+ backend: WorkerBackend,
+ method: string,
+ path: string,
+ body?: Record,
+ ): Promise {
+ const access = backend.token?.trim();
+ if (!access) throw new Error("GCP workers need an access token");
+ const base = (backend.gcpUrl?.trim() || "https://compute.googleapis.com").replace(/\/+$/, "");
+ const url = path.startsWith("http")
+ ? path
+ : `${base}${path.startsWith("/") ? path : `/${path}`}`;
+ const res = await this.fetchFn(url, {
+ method,
+ headers: {
+ authorization: `Bearer ${access}`,
+ "content-type": "application/json",
+ },
+ body: body && method !== "GET" && method !== "DELETE" ? JSON.stringify(body) : undefined,
+ });
+ if (method === "DELETE" && (res.status === 204 || res.status === 404)) return {} as T;
+ const json = (await res.json().catch(() => ({}))) as T & { error?: { message?: string } };
+ if (!res.ok) {
+ throw new Error(json.error?.message || `GCP compute ${method} failed (${res.status})`);
+ }
+ return json;
+ }
+}
+
+function randomWorkerToken(): string {
+ return randomBytes(24).toString("hex");
+}
+
+function instanceNameFrom(row: Record): string | undefined {
+ if (typeof row.name === "string" && row.name.trim()) return row.name.trim();
+ const target = typeof row.targetLink === "string" ? row.targetLink : "";
+ const match = /instances\/([^/]+)$/.exec(target);
+ return match?.[1];
+}
+
+function natIpFrom(row: Record): string | undefined {
+ const nics = row.networkInterfaces as
+ | Array<{
+ accessConfigs?: Array<{ natIP?: string }>;
+ }>
+ | undefined;
+ return nics?.[0]?.accessConfigs?.[0]?.natIP?.trim() || undefined;
+}
diff --git a/packages/compute/src/index.ts b/packages/compute/src/index.ts
index c43cb35..d6a8495 100644
--- a/packages/compute/src/index.ts
+++ b/packages/compute/src/index.ts
@@ -7,6 +7,9 @@ export type {
} from "./types.js";
export { MockComputeBackend } from "./mock.js";
export { FargateComputeBackend } from "./fargate.js";
+export { AzureComputeBackend } from "./azure.js";
+export { GcpComputeBackend } from "./gcp.js";
+export { CompositeComputeBackend } from "./composite.js";
export { computeTasksPath, loadComputeTaskRegistry } from "./registry.js";
export type { ComputeTaskRecord, ComputeTaskRegistry } from "./registry.js";
export {
diff --git a/packages/guest-agent/package.json b/packages/guest-agent/package.json
index d98e313..dfa11c7 100644
--- a/packages/guest-agent/package.json
+++ b/packages/guest-agent/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/guest-agent",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Guest tool catalog and SSH/WinRM/docker-exec adapters",
"type": "module",
diff --git a/packages/guest-agent/src/containedPath.test.ts b/packages/guest-agent/src/containedPath.test.ts
index 1b20976..ac55c3f 100644
--- a/packages/guest-agent/src/containedPath.test.ts
+++ b/packages/guest-agent/src/containedPath.test.ts
@@ -6,7 +6,11 @@ import { containedPath, ensureParentInside } from "./containedPath.js";
let cleanup: string[] = [];
afterEach(async () => {
- await Promise.all(cleanup.map((d) => rm(d, { recursive: true, force: true })));
+ await Promise.all(
+ cleanup.map((d) =>
+ rm(d, { recursive: true, force: true, maxRetries: 8, retryDelay: 50 }).catch(() => undefined),
+ ),
+ );
cleanup = [];
});
diff --git a/packages/guest-agent/src/hostPath.test.ts b/packages/guest-agent/src/hostPath.test.ts
index e21d885..bc62b93 100644
--- a/packages/guest-agent/src/hostPath.test.ts
+++ b/packages/guest-agent/src/hostPath.test.ts
@@ -6,7 +6,11 @@ import { lexicalHostPath, resolveReadPath, resolveWritePath } from "./hostPath.j
let cleanup: string[] = [];
afterEach(async () => {
- await Promise.all(cleanup.map((d) => rm(d, { recursive: true, force: true })));
+ await Promise.all(
+ cleanup.map((d) =>
+ rm(d, { recursive: true, force: true, maxRetries: 8, retryDelay: 50 }).catch(() => undefined),
+ ),
+ );
cleanup = [];
});
diff --git a/packages/guest-agent/src/index.ts b/packages/guest-agent/src/index.ts
index 3d9fc87..1563d8a 100644
--- a/packages/guest-agent/src/index.ts
+++ b/packages/guest-agent/src/index.ts
@@ -59,6 +59,7 @@ export {
sshCliEnv,
sshTargetFromInstance,
posixShellQuote,
+ powershellQuote,
} from "./sshCli.js";
export type { SshCliForward, SshCliHandle } from "./sshCli.js";
export { authenticatedCloneUrl, gitAuthHeader } from "./git.js";
diff --git a/packages/guest-agent/src/sshCli.test.ts b/packages/guest-agent/src/sshCli.test.ts
index 12662b4..8e885a3 100644
--- a/packages/guest-agent/src/sshCli.test.ts
+++ b/packages/guest-agent/src/sshCli.test.ts
@@ -34,6 +34,22 @@ describe("ssh CLI spawn helpers", () => {
).toBe("CURSOR_API_KEY='secret' 'claude'");
});
+ it("builds a PowerShell remote command for Windows guests", () => {
+ expect(
+ sshRemoteCommand(
+ {
+ command: "agent.exe",
+ args: ["-p", "hi"],
+ cwd: "C:\\Users\\locus\\workspace",
+ env: { CURSOR_API_KEY: "secret" },
+ },
+ "powershell",
+ ),
+ ).toBe(
+ "powershell -NoProfile -NonInteractive -Command 'Set-Location ''C:\\Users\\locus\\workspace''; $env:CURSOR_API_KEY=''secret''; & ''agent.exe'' ''-p'' ''hi'''",
+ );
+ });
+
it("reads SSH from a VM instance", () => {
expect(sshTargetFromInstance({})).toBeUndefined();
expect(
diff --git a/packages/guest-agent/src/sshCli.ts b/packages/guest-agent/src/sshCli.ts
index 6e15363..8e12af4 100644
--- a/packages/guest-agent/src/sshCli.ts
+++ b/packages/guest-agent/src/sshCli.ts
@@ -18,12 +18,29 @@ export function sshCliEnv(env: NodeJS.ProcessEnv | undefined): Record `$env:${key}=${powershellQuote(value)};`)
+ .join(" ");
+ const run = `${env}${env ? " " : ""}& ${parts.join(" ")}`;
+ const cwd = req.cwd?.trim();
+ const body = cwd ? `Set-Location ${powershellQuote(cwd)}; ${run}` : run;
+ return `powershell -NoProfile -NonInteractive -Command ${powershellQuote(body)}`;
+ }
const cmd = [req.command, ...(req.args ?? [])].map(posixShellQuote).join(" ");
const env = Object.entries(sshCliEnv(req.env))
.map(([key, value]) => `${key}=${posixShellQuote(value)}`)
@@ -67,14 +84,14 @@ export function spawnCliOverSsh(
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
},
- extra: { reverseForwards?: SshCliForward[] } = {},
+ extra: { reverseForwards?: SshCliForward[]; shell?: "posix" | "powershell" } = {},
): SshCliHandle {
const forwards: string[] = [];
for (const fwd of extra.reverseForwards ?? []) {
const host = fwd.host?.trim() || "127.0.0.1";
forwards.push("-R", `${fwd.listenPort}:${host}:${fwd.hostPort}`);
}
- const argv = sshArgv(target, sshRemoteCommand(req), forwards);
+ const argv = sshArgv(target, sshRemoteCommand(req, extra.shell ?? "posix"), forwards);
const child = spawn(argv[0]!, argv.slice(1), {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
diff --git a/packages/hypervisor/package.json b/packages/hypervisor/package.json
index 8bc47c3..f50de2b 100644
--- a/packages/hypervisor/package.json
+++ b/packages/hypervisor/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/hypervisor",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "QEMU and Docker guest engines for Locus, with mock fallbacks",
"type": "module",
diff --git a/packages/hypervisor/src/docker.ts b/packages/hypervisor/src/docker.ts
index 016f48d..437fe0b 100644
--- a/packages/hypervisor/src/docker.ts
+++ b/packages/hypervisor/src/docker.ts
@@ -68,6 +68,8 @@ export interface CreateContainerRequest {
template?: VmTemplate;
/** Worker QEMU: optional golden overlay path on the worker. */
imagePath?: string;
+ /** Control-plane namespace on a shared worker. */
+ planeId?: string;
}
export type ImageBuildListener = (id: string, progress: ImageBuildProgress) => void;
@@ -233,6 +235,7 @@ function instanceFrom(req: CreateContainerRequest, id: string, dataDir: string):
runtime: "docker",
dockerImage: req.image,
network: req.network,
+ planeId: req.planeId,
createdAt: now(),
updatedAt: now(),
};
diff --git a/packages/hypervisor/src/images.test.ts b/packages/hypervisor/src/images.test.ts
index ef525f6..6cc6604 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, dir],
+ ["build", "--progress=plain", "-t", LOCUS_AGENT_IMAGE, "-t", "locus/agent:0.18.0", dir],
]);
calls.length = 0;
const present: CommandRunner = {
diff --git a/packages/hypervisor/src/images.ts b/packages/hypervisor/src/images.ts
index b571d28..11ca1f3 100644
--- a/packages/hypervisor/src/images.ts
+++ b/packages/hypervisor/src/images.ts
@@ -7,6 +7,7 @@ import {
imageBuildStartProgress,
isLocusAgentImage,
LOCUS_AGENT_IMAGE,
+ LOCUS_AGENT_IMAGE_VERSION,
parseDockerBuildProgress,
type ImageBuildProgress,
} from "@locus/shared";
@@ -59,7 +60,15 @@ export async function ensureLocusAgentImage(
};
const built = await runner.exec(
dockerBin,
- ["build", "--progress=plain", "-t", LOCUS_AGENT_IMAGE, context],
+ [
+ "build",
+ "--progress=plain",
+ "-t",
+ LOCUS_AGENT_IMAGE,
+ "-t",
+ `locus/agent:${LOCUS_AGENT_IMAGE_VERSION}`,
+ context,
+ ],
{ onOutput: emitChunk },
);
if (!streamed) {
diff --git a/packages/providers/package.json b/packages/providers/package.json
index 7a537a4..c5788cc 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/providers",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "LLM provider adapters and the native agent loop",
"type": "module",
diff --git a/packages/providers/src/codexCli.test.ts b/packages/providers/src/codexCli.test.ts
index c6933e1..91f0482 100644
--- a/packages/providers/src/codexCli.test.ts
+++ b/packages/providers/src/codexCli.test.ts
@@ -210,6 +210,51 @@ describe("codex cli adapter", () => {
arguments: { command: "npm test" },
},
]);
+ expect(
+ parseCodexCliEvents(
+ '{"type":"item.completed","item":{"id":"item_4","type":"file_change","changes":[{"path":"src/react-dom-shim.js","kind":"delete"}],"status":"completed"}}',
+ ),
+ ).toEqual([
+ {
+ type: "tool_call",
+ id: "item_4",
+ name: "apply_patch",
+ status: "completed",
+ arguments: {
+ changes: [{ path: "src/react-dom-shim.js", kind: "delete" }],
+ path: "src/react-dom-shim.js",
+ },
+ result: JSON.stringify(
+ {
+ changes: [{ path: "src/react-dom-shim.js", kind: "delete" }],
+ status: "completed",
+ },
+ null,
+ 2,
+ ),
+ },
+ ]);
+ expect(
+ parseCodexCliEvents(
+ '{"type":"item.completed","item":{"type":"command_execution","id":"cmd-2","command":["/bin/bash","-lc","npm run test --ignore-scripts"],"aggregated_output":"ok","exit_code":0,"status":"completed"}}',
+ ),
+ ).toEqual([
+ {
+ type: "tool_call",
+ id: "cmd-2",
+ name: "exec",
+ status: "completed",
+ arguments: { command: "/bin/bash -lc npm run test --ignore-scripts" },
+ result: JSON.stringify(
+ {
+ output: "ok",
+ exit_code: 0,
+ },
+ null,
+ 2,
+ ),
+ },
+ ]);
expect(parseCodexCliEvents('{"type":"turn.failed","error":{"message":"failed"}}')).toEqual([
{ type: "error", message: "failed", terminal: true },
]);
diff --git a/packages/providers/src/codexCli.ts b/packages/providers/src/codexCli.ts
index 457c5d6..fd59a3e 100644
--- a/packages/providers/src/codexCli.ts
+++ b/packages/providers/src/codexCli.ts
@@ -302,10 +302,7 @@ function codexToolCall(
const name = codexToolName(itemType, item);
if (!name) return undefined;
const id = stringValue(item.id) ?? stringValue(item.call_id) ?? `${itemType}:${name}`;
- const argumentsValue =
- itemType === "command_execution"
- ? { command: stringValue(item.command) ?? "" }
- : asObject(parseMaybeJson(item.arguments ?? item.input ?? item.params ?? item.changes));
+ const argumentsValue = codexToolArguments(itemType, item);
const result = completed ? codexToolResult(item) : undefined;
return {
type: "tool_call",
@@ -328,14 +325,65 @@ function codexToolName(itemType: string, item: Record): string
return undefined;
}
+function codexToolArguments(
+ itemType: string,
+ item: Record,
+): Record {
+ if (itemType === "command_execution") {
+ return { command: commandValue(item.command) };
+ }
+ if (itemType === "file_change") {
+ const changes = normalizeCodexFileChanges(item.changes);
+ const path = changes[0]?.path;
+ return {
+ changes,
+ ...(path ? { path } : {}),
+ };
+ }
+ return asObject(parseMaybeJson(item.arguments ?? item.input ?? item.params ?? item.changes));
+}
+
+function normalizeCodexFileChanges(
+ value: unknown,
+): Array<{ path: string; kind: "add" | "update" | "delete" }> {
+ if (!Array.isArray(value)) return [];
+ const out: Array<{ path: string; kind: "add" | "update" | "delete" }> = [];
+ for (const item of value) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const rec = item as Record;
+ const path = stringValue(rec.path);
+ const kind = stringValue(rec.kind)?.toLowerCase();
+ if (!path || (kind !== "add" && kind !== "update" && kind !== "delete")) continue;
+ out.push({ path, kind });
+ }
+ return out;
+}
+
+function commandValue(value: unknown): string {
+ if (typeof value === "string") return value.trim();
+ if (Array.isArray(value)) {
+ return value
+ .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+ .map((item) => item.trim())
+ .join(" ");
+ }
+ return "";
+}
+
function codexToolResult(item: Record): string | undefined {
- const value =
- item.type === "command_execution"
- ? {
- output: item.aggregated_output ?? item.output,
- exit_code: item.exit_code ?? item.exitCode,
- }
- : (item.result ?? item.output ?? item.error);
+ if (item.type === "command_execution") {
+ return stringify({
+ output: item.aggregated_output ?? item.output,
+ exit_code: item.exit_code ?? item.exitCode,
+ });
+ }
+ if (item.type === "file_change") {
+ return stringify({
+ changes: normalizeCodexFileChanges(item.changes),
+ status: stringValue(item.status) ?? "completed",
+ });
+ }
+ const value = item.result ?? item.output ?? item.error;
if (value == null) return undefined;
if (typeof value === "string") return value;
return stringify(value);
diff --git a/packages/providers/src/kiroCli.test.ts b/packages/providers/src/kiroCli.test.ts
index 57baba1..dbf3cc7 100644
--- a/packages/providers/src/kiroCli.test.ts
+++ b/packages/providers/src/kiroCli.test.ts
@@ -44,8 +44,8 @@ describe("kiro cli adapter", () => {
expect(argv.command).toBe("kiro-cli");
expect(argv.cwd).toBe("/tmp/app");
expect(argv.args.slice(0, 6)).toEqual([
- "--v3",
"chat",
+ "--v3",
"--no-interactive",
"--trust-all-tools",
"--output-format",
@@ -69,6 +69,16 @@ describe("kiro cli adapter", () => {
expect(argv.env.KIRO_API_KEY).toBe("kiro-secret");
});
+ it("maps a session MCP URL onto LOCUS_GIT_MCP_URL", () => {
+ const argv = buildKiroCliArgv({
+ provider,
+ prompt: "open a PR",
+ workspace: "/tmp/app",
+ gitMcpUrl: "http://127.0.0.1:7420/mcp/sessions/s1",
+ });
+ expect(argv.env.LOCUS_GIT_MCP_URL).toBe("http://127.0.0.1:7420/mcp/sessions/s1");
+ });
+
it("omits --model auto so V3 uses its default instead of a missing id", () => {
const argv = buildKiroCliArgv({
provider,
@@ -76,7 +86,7 @@ describe("kiro cli adapter", () => {
workspace: "/tmp/app",
model: "auto",
});
- expect(argv.args.slice(0, 3)).toEqual(["--v3", "chat", "--no-interactive"]);
+ expect(argv.args.slice(0, 3)).toEqual(["chat", "--v3", "--no-interactive"]);
expect(argv.args).toContain("--output-format");
expect(argv.args).toContain("stream-json");
expect(argv.args).not.toContain("--model");
@@ -297,6 +307,46 @@ describe("kiro cli adapter", () => {
arguments: { path: "README.md" },
},
]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "tool_call",
+ toolCallId: "s1",
+ kind: "search",
+ status: "pending",
+ rawInput: { query: "UserProfile" },
+ }),
+ ),
+ ).toEqual([
+ {
+ type: "tool_call",
+ id: "s1",
+ name: "grep",
+ status: "running",
+ arguments: { query: "UserProfile" },
+ },
+ ]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "tool_call",
+ toolCallId: "g1",
+ status: "pending",
+ rawInput: {
+ name: "context-gatherer",
+ prompt: "Find the lookup helpers",
+ },
+ }),
+ ),
+ ).toEqual([
+ {
+ type: "tool_call",
+ id: "g1",
+ name: "context-gatherer",
+ status: "running",
+ arguments: { name: "context-gatherer", prompt: "Find the lookup helpers" },
+ },
+ ]);
expect(
parseKiroCliEvents(
JSON.stringify({
@@ -319,6 +369,121 @@ describe("kiro cli adapter", () => {
).toEqual([]);
});
+ it("maps stream-json {type, data} envelopes so chat is not blank", () => {
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "runStarted",
+ data: { payloadSchema: "acp", acpProtocolVersion: 1, engine: "v2" },
+ }),
+ ),
+ ).toEqual([]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ sessionId: "sess_hello",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "Hello from Kiro." },
+ },
+ },
+ }),
+ ),
+ ).toEqual([
+ { type: "session", sessionId: "sess_hello" },
+ { type: "text", text: "Hello from Kiro." },
+ ]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ content: { type: "text", text: "thinking" },
+ },
+ },
+ }),
+ ),
+ ).toEqual([{ type: "reasoning", text: "thinking" }]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "c1",
+ status: "pending",
+ rawInput: { path: "README.md" },
+ _meta: { kiro: { toolName: "fsRead" } },
+ },
+ },
+ }),
+ ),
+ ).toEqual([
+ {
+ type: "tool_call",
+ id: "c1",
+ name: "file_read",
+ status: "running",
+ arguments: { path: "README.md" },
+ },
+ ]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "metadata",
+ data: { sessionId: "sess_hello", contextUsagePercentage: 12 },
+ }),
+ ),
+ ).toEqual([{ type: "session", sessionId: "sess_hello" }]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "runFinished",
+ data: {
+ status: "success",
+ sessionId: "sess_hello",
+ finalText: "Hello from Kiro.",
+ finalTextTruncated: false,
+ },
+ }),
+ ),
+ ).toEqual([
+ { type: "session", sessionId: "sess_hello" },
+ {
+ type: "result",
+ status: "FINISHED",
+ text: "Hello from Kiro.",
+ durationMs: undefined,
+ },
+ ]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "runError",
+ data: { stage: "auth", message: "not logged in" },
+ }),
+ ),
+ ).toEqual([{ type: "error", message: "not logged in" }]);
+ expect(
+ parseKiroCliEvents(
+ JSON.stringify({
+ type: "runFinished",
+ status: "success",
+ sessionId: "flat-1",
+ finalText: "Hi",
+ }),
+ ),
+ ).toEqual([
+ { type: "session", sessionId: "flat-1" },
+ { type: "result", status: "FINISHED", text: "Hi", durationMs: undefined },
+ ]);
+ });
+
it("streams mapped events from a fake CLI", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-"));
const events: string[] = [];
@@ -451,6 +616,150 @@ describe("kiro cli adapter", () => {
]);
});
+ it("streams envelope sessionUpdate text and runFinished instead of a blank turn", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-envelope-"));
+ const events: Array<{ type: string; text?: string; sessionId?: string }> = [];
+ for await (const ev of streamKiroCli({
+ provider,
+ prompt: "Hi, what model are you?",
+ workspace: dir,
+ spawnFn: spawner([
+ JSON.stringify({
+ type: "runStarted",
+ data: { payloadSchema: "acp", acpProtocolVersion: 1, engine: "v3" },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ sessionId: "sess_model",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "I am Auto, Kiro's default model." },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "metadata",
+ data: { sessionId: "sess_model" },
+ }),
+ JSON.stringify({
+ type: "runFinished",
+ data: {
+ status: "success",
+ sessionId: "sess_model",
+ finalText: "I am Auto, Kiro's default model.",
+ },
+ }),
+ ]),
+ })) {
+ events.push({
+ type: ev.type,
+ ...((ev.type === "text" || ev.type === "result") && ev.text ? { text: ev.text } : {}),
+ ...(ev.type === "session" ? { sessionId: ev.sessionId } : {}),
+ });
+ }
+ expect(events).toEqual([
+ { type: "session", sessionId: "sess_model" },
+ { type: "text", text: "I am Auto, Kiro's default model." },
+ { type: "session", sessionId: "sess_model" },
+ { type: "session", sessionId: "sess_model" },
+ { type: "result" },
+ { type: "done" },
+ ]);
+ });
+
+ it("uses runFinished.finalText when the stream had no message chunks", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-final-text-"));
+ const events: Array<{ type: string; text?: string }> = [];
+ for await (const ev of streamKiroCli({
+ provider,
+ prompt: "Hello",
+ workspace: dir,
+ spawnFn: spawner([
+ JSON.stringify({
+ type: "runStarted",
+ data: { payloadSchema: "acp", acpProtocolVersion: 1, engine: "v2" },
+ }),
+ JSON.stringify({
+ type: "runFinished",
+ data: { status: "success", sessionId: "sess_final", finalText: "Hello" },
+ }),
+ ]),
+ })) {
+ events.push({
+ type: ev.type,
+ ...((ev.type === "text" || ev.type === "result") && ev.text ? { text: ev.text } : {}),
+ });
+ }
+ expect(events).toEqual([
+ { type: "session" },
+ { type: "result", text: "Hello" },
+ { type: "done" },
+ ]);
+ });
+
+ it("collapses V3 tool_call_update onto one running event and keeps the name", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-dup-"));
+ const events: Array<{ status?: string; name?: string; id?: string }> = [];
+ for await (const ev of streamKiroCli({
+ provider,
+ prompt: "test",
+ workspace: dir,
+ spawnFn: spawner([
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "e1",
+ kind: "execute",
+ status: "pending",
+ rawInput: { command: "python3 ./manage.py test shop.tests.test_models" },
+ _meta: { kiro: { toolName: "executeCommand" } },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "e1",
+ status: "in_progress",
+ rawInput: {
+ command: "python3 ./manage.py test shop.tests.test_models",
+ description: "Run Django tests",
+ run_in_background: false,
+ },
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "sessionUpdate",
+ data: {
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "e1",
+ status: "completed",
+ content: [{ type: "text", text: "OK" }],
+ },
+ },
+ }),
+ JSON.stringify({
+ type: "runFinished",
+ data: { status: "success", sessionId: "sess-e1" },
+ }),
+ ]),
+ })) {
+ if (ev.type === "tool_call") events.push({ status: ev.status, name: ev.name, id: ev.id });
+ }
+ expect(events).toEqual([
+ { status: "running", name: "exec", id: "e1" },
+ { status: "running", name: "exec", id: "e1" },
+ { status: "completed", name: "exec", id: "e1" },
+ ]);
+ });
+
it("keeps the running tool name on completed tool_result events", async () => {
const dir = await mkdtemp(join(tmpdir(), "locus-kiro-cli-"));
const names: string[] = [];
diff --git a/packages/providers/src/kiroCli.ts b/packages/providers/src/kiroCli.ts
index e7f3863..6938b87 100644
--- a/packages/providers/src/kiroCli.ts
+++ b/packages/providers/src/kiroCli.ts
@@ -31,6 +31,11 @@ const CLI_TOOL_NAMES: Record = {
Grep: "grep",
grep: "grep",
grep_search: "grep",
+ search: "grep",
+ codebaseSearch: "grep",
+ codebase_search: "grep",
+ semanticSearch: "grep",
+ fsSearch: "grep",
Glob: "glob",
glob: "glob",
glob_search: "glob",
@@ -97,18 +102,20 @@ export function buildKiroCliArgv(input: {
workspace: string;
model?: string;
resume?: string;
+ /** Session-scoped Locus Git bridge; credentials remain in the control plane. */
+ gitMcpUrl?: string;
env?: NodeJS.ProcessEnv;
}): KiroCliArgv {
const env = input.env ?? process.env;
const { command, prefixArgs } = resolveKiroCliCommand(input.provider, env);
- // `--v3` is a global harness flag (`kiro-cli --v3 chat …`). Putting it after
- // `chat` is accepted on some builds and ignored on others. Do not pass
+ // `--v3` / `--agent-engine` belong on `kiro-cli chat`, not the root launcher
+ // (`kiro-cli --v3 chat` can swallow `chat` as a flag value). Do not pass
// `--require-mcp-startup`: Locus MCP is POST JSON-RPC and Kiro V3 may wait
// 30s then exit with no stream-json after KAS boots.
const args = [
...prefixArgs,
- "--v3",
"chat",
+ "--v3",
"--no-interactive",
"--trust-all-tools",
"--output-format",
@@ -121,6 +128,7 @@ export function buildKiroCliArgv(input: {
if (resume) args.push("--resume-id", resume);
args.push(input.prompt);
const apiKey = input.provider.apiKey?.trim();
+ const gitMcpUrl = input.gitMcpUrl?.trim();
return {
command,
args,
@@ -128,6 +136,7 @@ export function buildKiroCliArgv(input: {
env: {
...env,
...(apiKey && !apiKey.includes("•") ? { KIRO_API_KEY: apiKey } : {}),
+ ...(gitMcpUrl ? { LOCUS_GIT_MCP_URL: gitMcpUrl } : {}),
},
};
}
@@ -165,6 +174,8 @@ function parseKiroCliEventsRaw(line: string): ParsedKiroCliEvent[] {
if (typeof row.jsonrpc === "string" || typeof row.method === "string") {
return parseAcpRow(row);
}
+ const envelope = parseStreamJsonEnvelope(row);
+ if (envelope) return envelope;
const type = rowKind(row);
if (type === "streamevent" || type === "contentblockdelta") {
return parseStreamEvent(row);
@@ -250,6 +261,65 @@ function isV3SessionUpdate(type: string): boolean {
);
}
+function isStreamJsonEnvelopeType(type: string): boolean {
+ return (
+ type === "runstarted" ||
+ type === "sessionupdate" ||
+ type === "metadata" ||
+ type === "runfinished" ||
+ type === "runerror"
+ );
+}
+
+/** Headless `--output-format stream-json` (kiro-cli 2.21.4+) wraps ACP events. */
+function parseStreamJsonEnvelope(row: Record): ParsedKiroCliEvent[] | undefined {
+ const type = rowKind(row);
+ if (!isStreamJsonEnvelopeType(type)) return undefined;
+ const data = asRecord(row.data) ?? asRecord(row.payload) ?? row;
+ const sessionId = sessionIdFromRow(data) || sessionIdFromRow(row);
+ const sessionEvent: ParsedKiroCliEvent[] = sessionId ? [{ type: "session", sessionId }] : [];
+ if (type === "runstarted") return sessionEvent;
+ if (type === "sessionupdate") {
+ const update = asRecord(data.update) ?? data;
+ return parseSessionUpdate(update, sessionId);
+ }
+ if (type === "metadata") {
+ const usage = readUsage(data) ?? readUsage(row);
+ return usage ? [...sessionEvent, { type: "usage", ...usage }] : sessionEvent;
+ }
+ if (type === "runerror") {
+ const stage = typeof data.stage === "string" ? data.stage.trim() : "";
+ const message =
+ (typeof data.message === "string" && data.message.trim()) ||
+ (stage && `Kiro CLI run error (${stage})`) ||
+ "Kiro CLI run error";
+ return [...sessionEvent, { type: "error", message }];
+ }
+ const statusRaw = typeof data.status === "string" ? data.status.trim().toLowerCase() : "";
+ const text =
+ (typeof data.finalText === "string" && data.finalText) ||
+ (typeof data.text === "string" && data.text) ||
+ (typeof data.result === "string" && data.result) ||
+ undefined;
+ if (statusRaw && statusRaw !== "success" && statusRaw !== "ok" && statusRaw !== "finished") {
+ return [
+ ...sessionEvent,
+ { type: "error", message: text?.trim() || `Kiro CLI finished with status ${data.status}` },
+ ];
+ }
+ const usage = readUsage(data) ?? readUsage(row);
+ return [
+ ...sessionEvent,
+ {
+ type: "result",
+ status: "FINISHED",
+ text,
+ durationMs: num(data.turnDurationMs) ?? num(data.duration_ms) ?? num(data.durationMs),
+ ...(usage ? { usage } : {}),
+ },
+ ];
+}
+
function parseAcpRow(row: Record): ParsedKiroCliEvent[] {
const method = typeof row.method === "string" ? row.method : "";
const params = asRecord(row.params);
@@ -329,10 +399,7 @@ function parseV3ToolCall(
statusRaw !== "pending" &&
statusRaw !== "inprogress" &&
statusRaw !== "running");
- const name =
- (typeof update.kind === "string" && update.kind) ||
- (typeof update.name === "string" && update.name) ||
- "tool";
+ const name = kiroToolName(update);
const id =
(typeof update.toolCallId === "string" && update.toolCallId) ||
(typeof update.tool_call_id === "string" && update.tool_call_id) ||
@@ -350,6 +417,26 @@ function parseV3ToolCall(
};
}
+function kiroToolName(update: Record): string {
+ const meta = asRecord(asRecord(update._meta)?.kiro) ?? asRecord(update._meta);
+ const raw = asRecord(update.rawInput) ?? asRecord(update.input) ?? asRecord(update.arguments);
+ const fromMeta = typeof meta?.toolName === "string" ? meta.toolName.trim() : "";
+ const fromUpdate =
+ (typeof update.name === "string" && update.name.trim()) ||
+ (typeof update.kind === "string" && update.kind.trim()) ||
+ "";
+ // Subagents such as context-gatherer put the tool id on rawInput.name.
+ const fromRaw =
+ raw &&
+ typeof raw.name === "string" &&
+ raw.name.trim() &&
+ typeof raw.path !== "string" &&
+ typeof raw.command !== "string"
+ ? raw.name.trim()
+ : "";
+ return fromMeta || fromUpdate || fromRaw || "tool";
+}
+
function parseSessionInfoUpdate(update: Record): ParsedKiroCliEvent[] {
const meta = asRecord(asRecord(update._meta)?.kiro) ?? asRecord(update._meta);
const displayError = asRecord(meta?.displayError) ?? asRecord(update.displayError);
@@ -530,6 +617,7 @@ export async function* streamKiroCli(input: {
mode?: "agent" | "plan";
webSearch?: boolean;
review?: boolean;
+ gitMcpUrl?: string;
signal?: AbortSignal;
spawnFn?: KiroCliSpawner;
}): AsyncGenerator {
@@ -555,9 +643,12 @@ export async function* streamKiroCli(input: {
const publish = (event: ParsedKiroCliEvent): KiroCliEvent | undefined => {
if (event.type === "tool_call" && event.id) {
streamedTools = true;
- if (event.status === "running" && event.name && event.name !== "tool") {
+ if (event.name && event.name !== "tool") {
toolNames.set(event.id, event.name);
- } else if (event.status === "completed") {
+ } else {
+ event.name = toolNames.get(event.id) ?? event.name;
+ }
+ if (event.status === "completed") {
event.name = toolNames.get(event.id) ?? event.name;
}
}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index b73034e..e316f50 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/shared",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "Shared types and pure helpers used by the Locus apps and packages",
"type": "module",
diff --git a/packages/shared/src/codingLoop.test.ts b/packages/shared/src/codingLoop.test.ts
index f7df86b..fd97e43 100644
--- a/packages/shared/src/codingLoop.test.ts
+++ b/packages/shared/src/codingLoop.test.ts
@@ -3,6 +3,8 @@ import {
CODING_LOOP_HABIT,
CODING_SEARCH_WANDER_ROUNDS,
isCodingMutateTool,
+ isDeleteOnlyCodingMutate,
+ isScratchCodingMutate,
isCodingVerifyCall,
foldCodingVerifyFlags,
wroteAgentCanvas,
@@ -28,10 +30,26 @@ describe("coding loop walls", () => {
expect(isCodingMutateTool("file_read")).toBe(false);
expect(looksLikeVerifyCommand("npm test")).toBe(true);
expect(looksLikeVerifyCommand("pnpm test -- src/add.test.ts")).toBe(true);
+ expect(
+ looksLikeVerifyCommand(
+ "/bin/bash -lc 'npm run test --ignore-scripts -- --testPathPattern=Template'",
+ ),
+ ).toBe(true);
expect(looksLikeVerifyCommand("pytest -q")).toBe(true);
+ expect(
+ looksLikeVerifyCommand("docker exec web python3 ./manage.py test shop.tests.test_models"),
+ ).toBe(true);
+ expect(looksLikeVerifyCommand("python3 -m pytest -q")).toBe(true);
+ expect(looksLikeVerifyCommand("python3 -m flake8 src/store.py")).toBe(true);
expect(looksLikeVerifyCommand("rg FAIL src")).toBe(false);
expect(isCodingVerifyCall("exec", { command: "npm test" })).toBe(true);
expect(isCodingVerifyCall("Shell", { command: "npm test" })).toBe(true);
+ expect(
+ isCodingVerifyCall("tool", {
+ command: "docker exec web python3 ./manage.py test shop.tests.test_models",
+ }),
+ ).toBe(true);
+ expect(isCodingVerifyCall("tool", { path: "README.md" })).toBe(false);
expect(isCodingVerifyCall("git_push")).toBe(true);
expect(isCodingVerifyCall("exec", { command: "rg boom" })).toBe(false);
expect(
@@ -46,8 +64,68 @@ describe("coding loop walls", () => {
verified: true,
}),
).toEqual({ mutated: true, verified: false });
+ expect(
+ foldCodingVerifyFlags(
+ [
+ {
+ name: "apply_patch",
+ arguments: { changes: [{ path: "src/react-dom-shim.js", kind: "delete" }] },
+ },
+ ],
+ { mutated: true, verified: true },
+ ),
+ ).toEqual({ mutated: true, verified: true });
+ expect(
+ isDeleteOnlyCodingMutate({
+ name: "apply_patch",
+ arguments: { changes: [{ path: "src/react-dom-shim.js", kind: "delete" }] },
+ }),
+ ).toBe(true);
+ expect(
+ isDeleteOnlyCodingMutate({
+ name: "apply_patch",
+ arguments: { value: [{ path: "src/react-dom-shim.js", kind: "delete" }] },
+ }),
+ ).toBe(true);
+ expect(
+ isDeleteOnlyCodingMutate({
+ name: "apply_patch",
+ arguments: { changes: [{ path: "src/add.ts", kind: "update" }] },
+ }),
+ ).toBe(false);
+ expect(
+ isDeleteOnlyCodingMutate({
+ name: "apply_patch",
+ arguments: { patch: "*** Delete File: src/react-dom-shim.js\n" },
+ }),
+ ).toBe(true);
+ expect(
+ isScratchCodingMutate({
+ name: "apply_patch",
+ arguments: { path: "/tmp/pr-body.md", text: "## Summary\n" },
+ }),
+ ).toBe(true);
+ expect(
+ isScratchCodingMutate({
+ name: "apply_patch",
+ arguments: { path: "src/store.py", oldStr: "a", newStr: "b" },
+ }),
+ ).toBe(false);
+ expect(
+ foldCodingVerifyFlags([
+ { name: "apply_patch", arguments: { path: "src/store.py" } },
+ {
+ name: "exec",
+ arguments: {
+ command: "docker exec web python3 ./manage.py test shop.tests.test_models",
+ },
+ },
+ { name: "apply_patch", arguments: { path: "/tmp/pr-body.md", text: "body" } },
+ ]),
+ ).toEqual({ mutated: true, verified: true });
expect(isWorkspaceSearchTool("grep")).toBe(true);
expect(isWorkspaceSearchTool("Grep")).toBe(true);
+ expect(isWorkspaceSearchTool("search")).toBe(true);
expect(isWorkspaceSearchTool("exec")).toBe(true);
expect(isWorkspaceSearchTool("exec", { command: "cat src/a.ts" })).toBe(true);
expect(isWorkspaceSearchTool("exec", { command: "npm test" })).toBe(false);
@@ -143,6 +221,27 @@ describe("coding loop walls", () => {
},
]),
).toBe(true);
+ expect(
+ codingTurnNeedsHuman([
+ { role: "user", content: "fix add" },
+ {
+ role: "assistant",
+ toolCalls: [
+ { name: "apply_patch", arguments: { path: "src/add.ts" } },
+ {
+ name: "exec",
+ arguments: {
+ command: "/bin/bash -lc 'npm run test --ignore-scripts -- --testPathPattern=add'",
+ },
+ },
+ {
+ name: "apply_patch",
+ arguments: { changes: [{ path: "src/react-dom-shim.js", kind: "delete" }] },
+ },
+ ],
+ },
+ ]),
+ ).toBeUndefined();
});
it("does not ship a coding turn whose last tests failed", () => {
diff --git a/packages/shared/src/codingLoop.ts b/packages/shared/src/codingLoop.ts
index 3cf3fed..0cbd23b 100644
--- a/packages/shared/src/codingLoop.ts
+++ b/packages/shared/src/codingLoop.ts
@@ -1,5 +1,6 @@
import { canonicalToolName } from "./models/toolChip.js";
import { execArgCommands } from "./models/tools.js";
+import { patchFilesFromTool } from "./patch.js";
/** Workspace inspect rounds before we tell a coding agent to edit. */
export const CODING_SEARCH_WANDER_ROUNDS = 3;
@@ -7,13 +8,13 @@ export const MAX_CODING_WANDER_NUDGES = 2;
export const MAX_VERIFY_NUDGES = 2;
export const CODING_LOOP_HABIT =
- "After you edit, run the project's tests before claiming done (prefer the dirty package's npm test / pytest / cargo test / go test). If there is no test script, run typecheck or lint. If that fails, read the FAIL lines, apply a focused patch, and re-run the same command. Prefer the smallest patch that preserves behaviour. Do not stop after the first compile error, and do not grep for more than a couple of rounds once you know the edit.";
+ "After you edit, run the project's tests before claiming done (prefer the dirty package's npm test / pytest / cargo test / go test). If there is no test script, run typecheck or lint. If that fails, read the FAIL lines, apply a focused patch, and re-run the same command. Prefer the smallest patch that preserves behaviour. Do not stop after the first compile error, do not grep for more than a couple of rounds once you know the edit, and do not edit files after the last test run.";
export const SEARCH_WANDER_NUDGE =
"You have searched for several rounds without editing. If you already know the change, call apply_patch or file_write now. Batch remaining reads in one round. Do not grep, glob, or file_read the same files again.";
export const VERIFY_NUDGE =
- "You edited files this turn but did not run tests or typecheck. Call exec with the project's test command (prefer the dirty package). If there is no test script, run lint or typecheck. Do not claim the work is done until it passes, or say the repo has no test command.";
+ "You edited files this turn but did not run tests or typecheck after the last edit. Call exec with the project's test command (prefer the dirty package). If there is no test script, run lint or typecheck. Do not edit files after that command. Do not claim the work is done until it passes, or say the repo has no test command.";
export const CODING_VERIFY_NEEDS_HUMAN =
"Files were edited this turn but tests/typecheck never ran. Needs a human.";
@@ -21,17 +22,26 @@ export const CODING_VERIFY_NEEDS_HUMAN =
export const CODING_FAILED_VERIFY_NEEDS_HUMAN =
"Tests/typecheck failed this turn and never passed. Needs a human.";
+export interface CodingLoopToolCall {
+ name: string;
+ arguments?: Record;
+ error?: string;
+}
+
export interface CodingLoopMessage {
role: string;
content?: string;
- toolCalls?: Array<{ name: string; arguments?: Record; error?: string }>;
+ toolCalls?: CodingLoopToolCall[];
}
const MUTATE_TOOLS = new Set(["apply_patch", "file_write"]);
const VERIFY_TOOLS = new Set(["git_push", "git_pr"]);
const VERIFY_COMMAND =
- /\b(npm test|npm run test|npm run typecheck|pnpm(?:\s+run)?\s+test|yarn(?:\s+run)?\s+test|npx vitest|npx jest|pytest|python -m pytest|cargo test|go test|mvn test|gradlew test|rake test|make test|npm run lint|npx tsc|\btypecheck\b|\bvitest\b|\bjest\b)\b/i;
+ /\b(npm test|npm run test|npm run typecheck|pnpm(?:\s+run)?\s+test|yarn(?:\s+run)?\s+test|npx vitest|npx jest|pytest|python3?\s+-m\s+(?:pytest|unittest|py_compile)|manage\.py test|django-admin test|cargo test|go test|mvn test|gradlew test|rake test|make test|npm run lint|npx tsc|flake8|mypy|ruff (?:check|test)|black --check|\btypecheck\b|\bvitest\b|\bjest\b)\b/i;
+
+const SCRATCH_PATH =
+ /^(?:\/tmp\/|\/var\/tmp\/|\/private\/tmp\/|(?:[A-Za-z]:)?\\(?:Users\\[^\\]+\\AppData\\Local\\Temp\\|Windows\\Temp\\))/i;
function catalogName(name: string): string {
return canonicalToolName(name).replace(/^locus-/, "");
@@ -45,6 +55,53 @@ export function isCodingMutateTool(name: string): boolean {
return MUTATE_TOOLS.has(catalogName(name));
}
+const FILE_CHANGE_KIND = /^(add|update|delete)$/i;
+
+function fileChangeEntries(
+ args?: Record,
+): Array<{ path: string; kind: "add" | "update" | "delete" }> {
+ if (!args) return [];
+ const buckets = [args.changes, args.value];
+ for (const value of buckets) {
+ if (!Array.isArray(value)) continue;
+ const out: Array<{ path: string; kind: "add" | "update" | "delete" }> = [];
+ for (const item of value) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const rec = item as Record;
+ const path = typeof rec.path === "string" ? rec.path.trim() : "";
+ const kind = typeof rec.kind === "string" ? rec.kind.trim().toLowerCase() : "";
+ if (!path || !FILE_CHANGE_KIND.test(kind)) continue;
+ out.push({ path, kind: kind as "add" | "update" | "delete" });
+ }
+ if (out.length) return out;
+ }
+ return [];
+}
+
+/** Codex cleanup (delete a temp shim) should not re-gate a turn that already ran tests. */
+export function isDeleteOnlyCodingMutate(call: CodingLoopToolCall): boolean {
+ if (!isCodingMutateTool(call.name)) return false;
+ const changes = fileChangeEntries(call.arguments);
+ if (changes.length) return changes.every((row) => row.kind === "delete");
+ const files = patchFilesFromTool(catalogName(call.name), call.arguments ?? {});
+ return files.length > 0 && files.every((file) => file.kind === "delete");
+}
+
+function mutatePaths(call: CodingLoopToolCall): string[] {
+ const args = call.arguments ?? {};
+ const files = patchFilesFromTool(catalogName(call.name), args).map((file) => file.path);
+ const direct = typeof args.path === "string" ? args.path.trim() : "";
+ if (direct && !files.includes(direct)) files.push(direct);
+ return files;
+}
+
+/** A scratch-dir write (PR body under /tmp) is not a source edit that needs tests. */
+export function isScratchCodingMutate(call: CodingLoopToolCall): boolean {
+ if (!isCodingMutateTool(call.name)) return false;
+ const paths = mutatePaths(call);
+ return paths.length > 0 && paths.every((path) => SCRATCH_PATH.test(path));
+}
+
/** A canvas beside the chat is the deliverable, so search-wander should not demand a repo patch. */
export function wroteAgentCanvas(calls: Array<{ name: string; error?: string }>): boolean {
return calls.some((call) => !call.error && catalogName(call.name) === "canvas_write");
@@ -53,21 +110,24 @@ export function wroteAgentCanvas(calls: Array<{ name: string; error?: string }>)
export function isCodingVerifyCall(name: string, args?: Record): boolean {
const n = catalogName(name);
if (VERIFY_TOOLS.has(n)) return true;
- if (n !== "exec" && n !== "powershell") return false;
- return execArgCommands(args).some((cmd) => looksLikeVerifyCommand(cmd));
+ const cmds = execArgCommands(args);
+ // Kiro V3 tool_call_update often lands as name "tool" with a command payload.
+ if (n !== "exec" && n !== "powershell" && n !== "tool") return false;
+ if (n === "tool" && !cmds.length) return false;
+ return cmds.some((cmd) => looksLikeVerifyCommand(cmd));
}
/** Mutate clears verify; a later test/push in the same turn restores it. */
export function foldCodingVerifyFlags(
- calls: Array<{ name: string; arguments?: Record }>,
+ calls: Array,
prior: { mutated?: boolean; verified?: boolean } = {},
): { mutated: boolean; verified: boolean } {
let mutated = Boolean(prior.mutated);
let verified = Boolean(prior.verified);
for (const call of calls) {
- if (isCodingMutateTool(call.name)) {
+ if (isCodingMutateTool(call.name) && !isScratchCodingMutate(call)) {
mutated = true;
- verified = false;
+ if (!isDeleteOnlyCodingMutate(call)) verified = false;
}
if (isCodingVerifyCall(call.name, call.arguments)) verified = true;
}
diff --git a/packages/shared/src/i18n.test.ts b/packages/shared/src/i18n.test.ts
new file mode 100644
index 0000000..cd50fd1
--- /dev/null
+++ b/packages/shared/src/i18n.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { parseLocale, t } from "./i18n.js";
+
+describe("i18n", () => {
+ it("substitutes named placeholders from the English catalog", () => {
+ expect(parseLocale("en")).toBe("en");
+ expect(parseLocale("nope")).toBe("en");
+ expect(t("empty.firstTask.cta")).toBe("Fill the composer");
+ expect(t("usage.total", { total: "$1.00", llm: "$0.80", vm: "$0.20" })).toBe(
+ "$1.00 total ($0.80 LLM · $0.20 VM)",
+ );
+ expect(t("missing.key")).toBe("missing.key");
+ });
+});
diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts
new file mode 100644
index 0000000..4e464b4
--- /dev/null
+++ b/packages/shared/src/i18n.ts
@@ -0,0 +1,43 @@
+export const LOCALES = ["en"] as const;
+export type Locale = (typeof LOCALES)[number];
+export const DEFAULT_LOCALE: Locale = "en";
+
+const EN: Record = {
+ "empty.firstTask.title": "Try a first task",
+ "empty.firstTask.body":
+ "List files in /workspace and write hello.txt. Works on Host with no repo.",
+ "empty.firstTask.cta": "Fill the composer",
+ "setup.firstTask.hint": "After setup, the Agents empty state offers a first Host task.",
+ "phone.more.settings": "Settings",
+ "phone.more.automation": "New automation",
+ "phone.more.palette": "Command palette",
+ "phone.more.find": "Find in chat",
+ "phone.more.vnc": "VM display (view only)",
+ "phone.desktopOnly": "Live VNC control still needs the desktop.",
+ "usage.total": "{total} total ({llm} LLM · {vm} VM)",
+ "updates.downloadVerify": "Download and verify",
+ "updates.verified": "Verified {name} ({sha256})",
+ "updates.open": "Open installer",
+ "workers.azure": "Azure",
+ "workers.gcp": "GCP",
+ "onboarding.skip": "Skip for now",
+};
+
+const CATALOG: Record> = { en: EN };
+
+export function parseLocale(raw: unknown): Locale {
+ return raw === "en" ? "en" : DEFAULT_LOCALE;
+}
+
+export function t(
+ key: string,
+ vars: Record = {},
+ locale: Locale = DEFAULT_LOCALE,
+): string {
+ const table = CATALOG[locale] ?? EN;
+ let out = table[key] ?? EN[key] ?? key;
+ for (const [name, value] of Object.entries(vars)) {
+ out = out.replaceAll(`{${name}}`, String(value));
+ }
+ return out;
+}
diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts
index cee80ea..d11d937 100644
--- a/packages/shared/src/index.test.ts
+++ b/packages/shared/src/index.test.ts
@@ -2700,7 +2700,7 @@ describe("formatUsageHint", () => {
{ inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
0.12,
),
- ).toBe("1,000,000 in · 500,000 out · $10.50 · $0.12 VM");
+ ).toBe("1,000,000 in · 500,000 out · $10.62 total ($10.50 LLM · $0.12 VM)");
});
});
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 28ea2f4..d561f9a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -5,6 +5,9 @@ export * from "./preMergeBoard.js";
export * from "./models/vm.js";
export * from "./models/placement.js";
export * from "./models/worker.js";
+export * from "./models/workerPlane.js";
+export * from "./models/slackApprove.js";
+export * from "./i18n.js";
export * from "./models/workerAdd.js";
export * from "./models/fargate.js";
export * from "./models/docker.js";
diff --git a/packages/shared/src/loadpath.test.ts b/packages/shared/src/loadpath.test.ts
index f629e3c..3181247 100644
--- a/packages/shared/src/loadpath.test.ts
+++ b/packages/shared/src/loadpath.test.ts
@@ -307,12 +307,12 @@ describe("buildLoadPath", () => {
describe("shortLoadPath", () => {
it("uses the basename until two files share a name", () => {
expect(
- shortLoadPath("siq8/static/src/batch/item_form.tsx", ["siq8/static/src/batch/item_form.tsx"]),
+ shortLoadPath("app/static/src/batch/item_form.tsx", ["app/static/src/batch/item_form.tsx"]),
).toBe("item_form.tsx");
expect(
- shortLoadPath("siq8/static/src/batch/index.ts", [
- "siq8/static/src/batch/index.ts",
- "siq8/static/src/preview/index.ts",
+ shortLoadPath("app/static/src/batch/index.ts", [
+ "app/static/src/batch/index.ts",
+ "app/static/src/preview/index.ts",
]),
).toBe("batch/index.ts");
});
diff --git a/packages/shared/src/models/appRelease.test.ts b/packages/shared/src/models/appRelease.test.ts
index 147156a..8ab8bb6 100644
--- a/packages/shared/src/models/appRelease.test.ts
+++ b/packages/shared/src/models/appRelease.test.ts
@@ -6,6 +6,8 @@ import {
compareVersions,
isNewerVersion,
parseVersionTag,
+ checksumLineFor,
+ pickChecksumsAsset,
pickReleaseAsset,
} from "./appRelease.js";
@@ -35,6 +37,29 @@ describe("app release helpers", () => {
expect(pickReleaseAsset(assets, "linux")?.url).toBe("https://example/linux");
expect(pickReleaseAsset(assets, "darwin")?.url).toBe("https://example/mac");
expect(pickReleaseAsset(assets, "win32")?.url).toBe("https://example/win");
+ expect(
+ pickReleaseAsset(
+ [
+ { name: "Locus-arm64.AppImage", url: "https://example/arm" },
+ { name: "Locus.AppImage", url: "https://example/x64" },
+ ],
+ "linux",
+ "arm64",
+ )?.url,
+ ).toBe("https://example/arm");
+ expect(
+ pickChecksumsAsset(
+ [{ name: "SHA256SUMS-linux", url: "https://example/sums" }, ...assets],
+ "linux",
+ )?.url,
+ ).toBe("https://example/sums");
+ expect(checksumLineFor("abc123 Locus.AppImage\n", "Locus.AppImage")).toBeUndefined();
+ expect(
+ checksumLineFor(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Locus.AppImage\n",
+ "Locus.AppImage",
+ ),
+ ).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
});
it("points at the public GitHub releases feed", () => {
diff --git a/packages/shared/src/models/appRelease.ts b/packages/shared/src/models/appRelease.ts
index 07efa3b..70d7023 100644
--- a/packages/shared/src/models/appRelease.ts
+++ b/packages/shared/src/models/appRelease.ts
@@ -37,13 +37,48 @@ export function isNewerVersion(latest: string, current: string): boolean {
export function pickReleaseAsset(
assets: ReleaseAsset[],
platform: string,
+ arch: string = process.arch,
): ReleaseAsset | undefined {
- const names = assets.filter((row) => row.name && row.url);
+ const names = assets.filter((row) => row.name && row.url && !/blockmap/i.test(row.name));
+ const preferArm = arch === "arm64";
+ const ranked = (rows: ReleaseAsset[]) => {
+ if (!rows.length) return undefined;
+ const arm = rows.filter((row) => /arm64|aarch64/i.test(row.name));
+ const rest = rows.filter((row) => !/arm64|aarch64/i.test(row.name));
+ return preferArm ? (arm[0] ?? rest[0]) : (rest[0] ?? arm[0]);
+ };
if (platform === "darwin") {
- return names.find((row) => /\.dmg$/i.test(row.name) && !/blockmap/i.test(row.name));
+ return ranked(names.filter((row) => /\.dmg$/i.test(row.name)));
}
if (platform === "win32") {
- return names.find((row) => /\.exe$/i.test(row.name) && !/blockmap/i.test(row.name));
+ return ranked(names.filter((row) => /\.exe$/i.test(row.name)));
+ }
+ return (
+ ranked(names.filter((row) => /\.AppImage$/i.test(row.name))) ??
+ ranked(names.filter((row) => /\.deb$/i.test(row.name))) ??
+ ranked(names.filter((row) => /\.rpm$/i.test(row.name)))
+ );
+}
+
+export function pickChecksumsAsset(
+ assets: ReleaseAsset[],
+ platform: string,
+): ReleaseAsset | undefined {
+ const names = assets.filter((row) => row.name && row.url);
+ const want =
+ platform === "darwin"
+ ? /^SHA256SUMS-macos$/i
+ : platform === "win32"
+ ? /^SHA256SUMS-windows$/i
+ : /^SHA256SUMS-linux$/i;
+ return names.find((row) => want.test(row.name));
+}
+
+export function checksumLineFor(sumFile: string, fileName: string): string | undefined {
+ for (const line of sumFile.split(/\r?\n/)) {
+ const match = /^([a-fA-F0-9]{64})\s+(\S+)$/.exec(line.trim());
+ const hex = match?.[1];
+ if (match && hex && match[2] === fileName) return hex.toLowerCase();
}
- return names.find((row) => /\.AppImage$/i.test(row.name));
+ return undefined;
}
diff --git a/packages/shared/src/models/config.ts b/packages/shared/src/models/config.ts
index da0447c..98fd043 100644
--- a/packages/shared/src/models/config.ts
+++ b/packages/shared/src/models/config.ts
@@ -122,6 +122,12 @@ export interface LocusConfig {
skills?: AgentSkill[];
/** LAN (or later cloud) machines running locus-worker. */
workers?: WorkerBackend[];
+ /** Stable id this control plane sends as `X-Locus-Plane`. */
+ workerPlaneId?: string;
+ /** UI locale. Default `en`. */
+ locale?: string;
+ /** Guided first Host task after setup. */
+ setupFirstTask?: "pending" | "done" | "dismissed";
/** Named guest presets (golden + jail + recipe + spend + Where). */
namedEnvironments?: NamedEnvironment[];
/** Opt-in Android / phone remote. Token is vaulted. */
diff --git a/packages/shared/src/models/placement.test.ts b/packages/shared/src/models/placement.test.ts
index 6765aad..7858918 100644
--- a/packages/shared/src/models/placement.test.ts
+++ b/packages/shared/src/models/placement.test.ts
@@ -15,6 +15,7 @@ import {
workerPickSize,
workerWhereLabel,
workerHourlyUsd,
+ workerProvisionsCompute,
computeUsdForMs,
sessionComputeMs,
DEFAULT_WORKER_IDLE_TTL_MS,
@@ -221,6 +222,41 @@ describe("worker backends", () => {
).toMatch(/size allowlist/);
});
+ it("parses Azure and GCP workers without a paste URL", () => {
+ const azure = parseWorkerBackend({
+ id: "az-1",
+ name: "aci",
+ kind: "azure",
+ token: "secret",
+ tenantId: "ten",
+ clientId: "app",
+ subscriptionId: "sub",
+ resourceGroup: "rg",
+ containerGroup: "locus",
+ region: "eastus",
+ allowedImages: ["locus/agent"],
+ allowedSizes: ["0.25-0.5"],
+ });
+ expect(azure).toMatchObject({ id: "az-1", kind: "azure", url: "" });
+ expect(workerProvisionsCompute(azure!)).toBe(true);
+ expect(workerIsMetered(azure!)).toBe(true);
+ expect(workerWhereLabel(azure!)).toBe("aci · azure");
+ const gcp = parseWorkerBackend({
+ id: "g-1",
+ name: "gce",
+ kind: "gcp",
+ token: "ya29.token",
+ project: "proj",
+ zone: "us-central1-a",
+ instanceTemplate: "locus-worker",
+ allowedImages: ["locus/agent"],
+ allowedSizes: ["0.25-0.5"],
+ });
+ expect(gcp).toMatchObject({ id: "g-1", kind: "gcp", url: "" });
+ expect(workerProvisionsCompute(gcp!)).toBe(true);
+ expect(workerWhereLabel(gcp!)).toBe("gce · gcp");
+ });
+
it("rejects a fargate worker without an access key id", () => {
expect(
workerBackendError({
diff --git a/packages/shared/src/models/recipes.ts b/packages/shared/src/models/recipes.ts
index 3060eea..97b74e0 100644
--- a/packages/shared/src/models/recipes.ts
+++ b/packages/shared/src/models/recipes.ts
@@ -989,6 +989,13 @@ export const FIRST_TASKS: FirstTask[] = [
prompt:
"List the files in /workspace, then write /workspace/hello.txt with a one-line greeting. Stay in /workspace. Do not clone a repository.",
},
+ {
+ id: "workspace-readme",
+ title: "Write a short README for this folder",
+ blurb: "Describe what is in /workspace in README.md.",
+ prompt:
+ "Look at /workspace, then write /workspace/README.md with a three-sentence summary of what is there. Stay in /workspace. Do not clone a repository.",
+ },
];
export function agentSessionCount(sessions: readonly { kind?: SessionKind }[]): number {
diff --git a/packages/shared/src/models/session.ts b/packages/shared/src/models/session.ts
index 69afd11..89328fb 100644
--- a/packages/shared/src/models/session.ts
+++ b/packages/shared/src/models/session.ts
@@ -1118,8 +1118,12 @@ export function formatUsageHint(
? `${usage!.inputTokens.toLocaleString("en-US")} in · ${usage!.outputTokens.toLocaleString("en-US")} out`
: undefined;
const usd = hasTokens && usage ? estimateUsd(usage, rates) : undefined;
- const vm =
- typeof computeUsd === "number" && computeUsd > 0 ? `${formatUsd(computeUsd)} VM` : undefined;
+ const vmAmount = typeof computeUsd === "number" && computeUsd > 0 ? computeUsd : 0;
+ const vm = vmAmount > 0 ? `${formatUsd(vmAmount)} VM` : undefined;
+ if (usd != null && vmAmount > 0) {
+ const total = `${formatUsd(usd + vmAmount)} total (${formatUsd(usd)} LLM · ${vm})`;
+ return [tokens, total].filter(Boolean).join(" · ");
+ }
const parts = [tokens, usd != null ? formatUsd(usd) : undefined, vm].filter(Boolean);
return parts.length ? parts.join(" · ") : undefined;
}
diff --git a/packages/shared/src/models/slackApprove.test.ts b/packages/shared/src/models/slackApprove.test.ts
new file mode 100644
index 0000000..8f5884a
--- /dev/null
+++ b/packages/shared/src/models/slackApprove.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatSlackApprovalValue,
+ parseSlackApprovalValue,
+ slackApprovalActionId,
+ slackCanApproveTool,
+} from "./slackApprove.js";
+
+describe("slack approvals", () => {
+ it("allows only slack_post and slack_upload, never git writes", () => {
+ expect(slackCanApproveTool("slack_post")).toBe(true);
+ expect(slackCanApproveTool("slack_upload")).toBe(true);
+ expect(slackCanApproveTool("git_push")).toBe(false);
+ expect(slackCanApproveTool("git_pr")).toBe(false);
+ expect(slackCanApproveTool("exec")).toBe(false);
+ expect(slackApprovalActionId("approved")).toBe("locus_approve");
+ expect(slackApprovalActionId("denied")).toBe("locus_deny");
+ expect(formatSlackApprovalValue("session01", "call-id-1")).toBe("session01:call-id-1");
+ expect(parseSlackApprovalValue("session01:call-id-1")).toEqual({
+ sessionId: "session01",
+ callId: "call-id-1",
+ });
+ expect(parseSlackApprovalValue("bad")).toBeUndefined();
+ });
+});
diff --git a/packages/shared/src/models/slackApprove.ts b/packages/shared/src/models/slackApprove.ts
new file mode 100644
index 0000000..5e8c1d5
--- /dev/null
+++ b/packages/shared/src/models/slackApprove.ts
@@ -0,0 +1,33 @@
+const GIT_WRITE = new Set([
+ "git_push",
+ "git_pr",
+ "git_pr_merge",
+ "git_pr_review",
+ "git_pr_comment",
+ "git_issue_comment",
+]);
+
+/** Tools an operator may Approve / Deny from Slack. `git_push` and other forge writes stay in Locus. */
+export const SLACK_APPROVABLE_TOOLS = new Set(["slack_post", "slack_upload"]);
+
+export function slackCanApproveTool(name: string): boolean {
+ const tool = name.trim();
+ if (!tool || GIT_WRITE.has(tool)) return false;
+ return SLACK_APPROVABLE_TOOLS.has(tool);
+}
+
+export function slackApprovalActionId(decision: "approved" | "denied"): string {
+ return decision === "approved" ? "locus_approve" : "locus_deny";
+}
+
+export function parseSlackApprovalValue(
+ raw: string,
+): { sessionId: string; callId: string } | undefined {
+ const match = /^([a-zA-Z0-9_-]{8,64}):([a-zA-Z0-9_-]{8,64})$/.exec(raw.trim());
+ if (!match?.[1] || !match[2]) return undefined;
+ return { sessionId: match[1], callId: match[2] };
+}
+
+export function formatSlackApprovalValue(sessionId: string, callId: string): string {
+ return `${sessionId}:${callId}`;
+}
diff --git a/packages/shared/src/models/toolChip.test.ts b/packages/shared/src/models/toolChip.test.ts
index b765787..cddd2dc 100644
--- a/packages/shared/src/models/toolChip.test.ts
+++ b/packages/shared/src/models/toolChip.test.ts
@@ -170,6 +170,8 @@ describe("tool chips", () => {
name: "file_read",
arguments: { path: "/workspace/index.html" },
});
+ expect(canonicalToolCall("search", { query: "UserProfile" }).name).toBe("grep");
+ expect(canonicalToolCall("executeCommand", { command: "git status" }).name).toBe("exec");
expect(
canonicalToolCall("askQuestion", {
title: "Ubuntu scope",
@@ -242,7 +244,7 @@ describe("tool activity labels", () => {
command: "cd /workspace/local-agents && sed -n '1949,1985p' apps/server/src/app.ts",
}),
).toBe("Read app.ts L1949-1985");
- expect(toolActivityLabel("exec", { command: "git show develop:siq8/batch/test.py" })).toBe(
+ expect(toolActivityLabel("exec", { command: "git show develop:app/batch/test.py" })).toBe(
"Read test.py",
);
expect(toolActivityLabel("exec", { commands: ["git show a", "git grep foo"] })).toBe(
@@ -268,6 +270,11 @@ describe("tool activity labels", () => {
expect(toolActivityLabel("apply_patch", { path: "/workspace/src/app.ts" })).toBe(
"Edited app.ts",
);
+ expect(
+ toolActivityLabel("apply_patch", {
+ changes: [{ path: "src/react-dom-shim.js", kind: "delete" }],
+ }),
+ ).toBe("Edited react-dom-shim.js");
expect(toolActivityLabel("window_focus", { title: "Firefox" })).toBe("Focused Firefox");
});
diff --git a/packages/shared/src/models/toolChip.ts b/packages/shared/src/models/toolChip.ts
index 2900743..5db210c 100644
--- a/packages/shared/src/models/toolChip.ts
+++ b/packages/shared/src/models/toolChip.ts
@@ -30,6 +30,19 @@ function strArg(args: Record, key: string): string {
return typeof value === "string" ? value.trim() : "";
}
+function fileChangePath(args: Record): string {
+ const buckets = [args.changes, args.value];
+ for (const value of buckets) {
+ if (!Array.isArray(value)) continue;
+ for (const item of value) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
+ const path = strArg(item as Record, "path");
+ if (path) return path;
+ }
+ }
+ return "";
+}
+
/** Map Cursor/Claude aliases onto Locus catalog names for chips, activity, and execution. */
export function canonicalToolName(name: string): string {
if (name === "read" || name === "Read" || name === "read_file") return "file_read";
@@ -64,8 +77,16 @@ export function canonicalToolName(name: string): string {
) {
return "exec";
}
- if (name === "Grep") return "grep";
+ if (name === "Grep" || name === "search" || name === "codebase_search") return "grep";
if (name === "Glob") return "glob";
+ if (
+ name === "execute" ||
+ name === "executeCommand" ||
+ name === "execute_command" ||
+ name === "execute_bash"
+ ) {
+ return "exec";
+ }
if (name === "LS" || name === "ls") return "file_list";
if (name === "WriteCanvas" || name === "write_canvas") return "canvas_write";
if (name === "ReadCanvas" || name === "read_canvas") return "canvas_read";
@@ -81,7 +102,8 @@ export function toolArgsForChip(args: Record): Record`. */
+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.18.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;
@@ -378,6 +381,8 @@ export interface VmInstance {
runtime?: AgentRuntime;
/** Settings → Workers id when this guest lives on a remote worker. */
backendId?: string;
+ /** Control-plane namespace on a shared worker (`X-Locus-Plane`). */
+ planeId?: string;
dockerImage?: string;
containerId?: string;
network?: NetworkJail;
diff --git a/packages/shared/src/models/worker.ts b/packages/shared/src/models/worker.ts
index a39f0b6..8eda49b 100644
--- a/packages/shared/src/models/worker.ts
+++ b/packages/shared/src/models/worker.ts
@@ -4,22 +4,22 @@ import { parseDockerImage } from "./vm.js";
import { DEFAULT_WORKER_PORT } from "./placement.js";
import { parseWorkerAllowedSizes } from "./fargate.js";
-export type WorkerKind = "lan" | "cloud" | "fargate";
+export type WorkerKind = "lan" | "cloud" | "fargate" | "azure" | "gcp";
-export const WORKER_KINDS: WorkerKind[] = ["lan", "cloud", "fargate"];
+export const WORKER_KINDS: WorkerKind[] = ["lan", "cloud", "fargate", "azure", "gcp"];
export const DEFAULT_WORKER_IDLE_TTL_MS = 15 * 60 * 1000;
-/** A LAN box, paste-URL cloud VM, or Fargate task running `locus-worker`. */
+/** A LAN box, paste-URL cloud VM, or a provisioned Fargate / Azure / GCP worker. */
export interface WorkerBackend {
id: string;
name: string;
- /** Required for LAN/cloud. Fargate fills this after provision. */
+ /** Required for LAN/cloud. Provisioned kinds fill this after create. */
url: string;
- /** Vaulted. Worker token, or Fargate secret access key. */
+ /** Vaulted. Worker token, Fargate secret, Azure client secret, or GCP access token. */
token?: string;
/** SHA-256 of the worker TLS cert (`sha256:` + hex). Not a secret. */
fingerprint?: string;
- /** Default `lan`. Cloud VMs and Fargate get idle TTL + concurrent cap. */
+ /** Default `lan`. Cloud VMs and provisioned kinds get idle TTL + concurrent cap. */
kind?: WorkerKind;
/** Destroy the Docker guest on Stop. Default true. */
destroyOnStop?: boolean;
@@ -33,7 +33,7 @@ export interface WorkerBackend {
allowedImages?: string[];
/** Fargate vCPU-GB pairs. Empty cannot provision. */
allowedSizes?: string[];
- /** AWS region for Fargate. */
+ /** AWS region for Fargate, Azure location, or GCP region. */
region?: string;
/** ECS cluster. Default `locus`. */
cluster?: string;
@@ -45,6 +45,26 @@ export interface WorkerBackend {
accessKeyId?: string;
/** Optional ECS endpoint (PrivateLink or API Gateway). Empty uses `ecs.{region}.amazonaws.com`. */
ecsUrl?: string;
+ /** Azure AD tenant id. */
+ tenantId?: string;
+ /** Azure AD application (client) id. */
+ clientId?: string;
+ /** Azure subscription id. */
+ subscriptionId?: string;
+ /** Azure resource group. */
+ resourceGroup?: string;
+ /** Existing Azure Container Instances group name. */
+ containerGroup?: string;
+ /** Optional ARM endpoint (PrivateLink). Empty uses `management.azure.com`. */
+ armUrl?: string;
+ /** GCP project id. */
+ project?: string;
+ /** GCP zone (for example `us-central1-a`). */
+ zone?: string;
+ /** Existing Compute Engine instance template that already runs locus-worker. */
+ instanceTemplate?: string;
+ /** Optional Compute Engine endpoint. Empty uses `compute.googleapis.com`. */
+ gcpUrl?: string;
}
export function parseWorkerBackends(raw: unknown): WorkerBackend[] {
@@ -62,11 +82,10 @@ export function parseWorkerBackend(raw: unknown): WorkerBackend | undefined {
const value = raw as Record;
const id = typeof value.id === "string" ? value.id.trim() : "";
const name = typeof value.name === "string" ? value.name.trim() : "";
- const kind: WorkerKind | undefined =
- value.kind === "fargate" ? "fargate" : value.kind === "cloud" ? "cloud" : undefined;
+ const kind = parseWorkerKind(value.kind);
const url = normalizeWorkerUrl(typeof value.url === "string" ? value.url : "") ?? "";
if (!id || !name) return undefined;
- if (kind !== "fargate" && !url) return undefined;
+ if (!workerProvisionsCompute({ kind }) && !url) return undefined;
const token =
typeof value.token === "string" && value.token.trim() ? value.token.trim() : undefined;
const fingerprint = parseWorkerFingerprint(
@@ -107,6 +126,10 @@ export function parseWorkerBackend(raw: unknown): WorkerBackend | undefined {
: undefined;
const ecsUrl =
typeof value.ecsUrl === "string" && value.ecsUrl.trim() ? value.ecsUrl.trim() : undefined;
+ const optional = (key: string): string | undefined => {
+ const raw = value[key];
+ return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
+ };
return {
id,
name,
@@ -126,6 +149,16 @@ export function parseWorkerBackend(raw: unknown): WorkerBackend | undefined {
...(subnet ? { subnet } : {}),
...(accessKeyId ? { accessKeyId } : {}),
...(ecsUrl ? { ecsUrl } : {}),
+ ...(optional("tenantId") ? { tenantId: optional("tenantId") } : {}),
+ ...(optional("clientId") ? { clientId: optional("clientId") } : {}),
+ ...(optional("subscriptionId") ? { subscriptionId: optional("subscriptionId") } : {}),
+ ...(optional("resourceGroup") ? { resourceGroup: optional("resourceGroup") } : {}),
+ ...(optional("containerGroup") ? { containerGroup: optional("containerGroup") } : {}),
+ ...(optional("armUrl") ? { armUrl: optional("armUrl") } : {}),
+ ...(optional("project") ? { project: optional("project") } : {}),
+ ...(optional("zone") ? { zone: optional("zone") } : {}),
+ ...(optional("instanceTemplate") ? { instanceTemplate: optional("instanceTemplate") } : {}),
+ ...(optional("gcpUrl") ? { gcpUrl: optional("gcpUrl") } : {}),
};
}
@@ -169,16 +202,25 @@ export function parseWorkerFingerprint(raw: string | undefined): string | undefi
return `sha256:${hex}`;
}
+export function parseWorkerKind(raw: unknown): WorkerKind | undefined {
+ if (raw === "fargate" || raw === "cloud" || raw === "azure" || raw === "gcp") return raw;
+ return undefined;
+}
+
export function workerKind(backend: { kind?: WorkerKind }): WorkerKind {
- if (backend.kind === "fargate") return "fargate";
- if (backend.kind === "cloud") return "cloud";
- return "lan";
+ return parseWorkerKind(backend.kind) ?? "lan";
}
-/** Cloud VM (paste URL) or Fargate: idle TTL, destroy-on-stop, guest cap. */
+/** Cloud VM (paste URL) or a provisioned Fargate / Azure / GCP worker. */
export function workerIsMetered(backend: { kind?: WorkerKind }): boolean {
const kind = workerKind(backend);
- return kind === "cloud" || kind === "fargate";
+ return kind === "cloud" || workerProvisionsCompute(backend);
+}
+
+/** Fargate, Azure, and GCP: Locus starts a worker VM, then talks to it over HTTPS. */
+export function workerProvisionsCompute(backend: { kind?: WorkerKind }): boolean {
+ const kind = workerKind(backend);
+ return kind === "fargate" || kind === "azure" || kind === "gcp";
}
export function workerDestroyOnStop(backend: { destroyOnStop?: boolean }): boolean {
@@ -206,6 +248,8 @@ export function workerMaxConcurrent(backend: {
export function workerWhereLabel(backend: WorkerBackend): string {
const kind = workerKind(backend);
if (kind === "fargate") return `${backend.name} · fargate`;
+ if (kind === "azure") return `${backend.name} · azure`;
+ if (kind === "gcp") return `${backend.name} · gcp`;
if (kind === "cloud") return `${backend.name} · cloud`;
return backend.name;
}
@@ -264,7 +308,7 @@ export function workerAllowedSizes(backend: {
allowedSizes?: string[];
}): string[] | undefined {
if (backend.allowedSizes !== undefined) return backend.allowedSizes;
- return workerKind(backend) === "fargate" ? [] : undefined;
+ return workerProvisionsCompute(backend) ? [] : undefined;
}
export function workerSizeAllowed(
@@ -282,7 +326,9 @@ export function workerPickSize(
): string {
const list = workerAllowedSizes(backend) ?? [];
if (!list.length) {
- throw new Error("Fargate workers need a size allowlist so a runaway agent cannot pick 4 vCPU");
+ throw new Error(
+ "Provisioned workers need a size allowlist so a runaway agent cannot pick 4 vCPU",
+ );
}
if (requested) {
if (!list.includes(requested)) throw new Error(`Size ${requested} is not on the allowlist`);
@@ -305,7 +351,7 @@ export function workerImageAllowed(
export function workerBackendError(backend: WorkerBackend): string | undefined {
if (!backend.name.trim()) return "Worker name is required";
const kind = workerKind(backend);
- if (kind !== "fargate") {
+ if (!workerProvisionsCompute(backend)) {
const url = normalizeWorkerUrl(backend.url);
if (!url) return "Worker URL must be http or https";
try {
@@ -344,7 +390,29 @@ export function workerBackendError(backend: WorkerBackend): string | undefined {
if (kind === "fargate" && !workerAllowedSizes(backend)?.length) {
return "Fargate workers need a size allowlist so a runaway agent cannot pick 4 vCPU";
}
- const ecsError = awsEndpointUrlError(backend.ecsUrl, "ECS endpoint");
+ if (kind === "azure") {
+ if (!backend.tenantId?.trim()) return "Azure workers need a tenant id";
+ if (!backend.clientId?.trim()) return "Azure workers need a client id";
+ if (!backend.subscriptionId?.trim()) return "Azure workers need a subscription id";
+ if (!backend.resourceGroup?.trim()) return "Azure workers need a resource group";
+ if (!backend.containerGroup?.trim()) return "Azure workers need a container group name";
+ if (!backend.region?.trim()) return "Azure workers need a region";
+ if (!workerAllowedSizes(backend)?.length) {
+ return "Azure workers need a size allowlist so a runaway agent cannot pick 4 vCPU";
+ }
+ }
+ if (kind === "gcp") {
+ if (!backend.project?.trim()) return "GCP workers need a project id";
+ if (!backend.zone?.trim()) return "GCP workers need a zone";
+ if (!backend.instanceTemplate?.trim()) {
+ return "GCP workers need an instance template that already runs locus-worker";
+ }
+ if (!workerAllowedSizes(backend)?.length) {
+ return "GCP workers need a size allowlist so a runaway agent cannot pick 4 vCPU";
+ }
+ }
+ const ecsError =
+ kind === "fargate" ? awsEndpointUrlError(backend.ecsUrl, "ECS endpoint") : undefined;
if (ecsError) return ecsError;
return undefined;
}
diff --git a/packages/shared/src/models/workerPlane.test.ts b/packages/shared/src/models/workerPlane.test.ts
new file mode 100644
index 0000000..47a4208
--- /dev/null
+++ b/packages/shared/src/models/workerPlane.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import {
+ createWorkerPlaneId,
+ guestVisibleToPlane,
+ parseWorkerPlaneId,
+ WORKER_PLANE_HEADER,
+} from "./workerPlane.js";
+
+describe("worker plane ids", () => {
+ it("parses ids and treats untagged guests as visible", () => {
+ expect(WORKER_PLANE_HEADER).toBe("x-locus-plane");
+ expect(parseWorkerPlaneId("plane-12")).toBe("plane-12");
+ expect(parseWorkerPlaneId("short")).toBeUndefined();
+ expect(createWorkerPlaneId().length).toBeGreaterThanOrEqual(8);
+ expect(guestVisibleToPlane({}, "plane-12")).toBe(true);
+ expect(guestVisibleToPlane({ planeId: "plane-12" }, "plane-12")).toBe(true);
+ expect(guestVisibleToPlane({ planeId: "plane-12" }, "plane-99")).toBe(false);
+ expect(guestVisibleToPlane({ planeId: "plane-12" }, undefined)).toBe(false);
+ });
+});
diff --git a/packages/shared/src/models/workerPlane.ts b/packages/shared/src/models/workerPlane.ts
new file mode 100644
index 0000000..0efd3a6
--- /dev/null
+++ b/packages/shared/src/models/workerPlane.ts
@@ -0,0 +1,23 @@
+/** Control-plane id sent as `X-Locus-Plane` so one worker token can namespace guests. */
+export const WORKER_PLANE_HEADER = "x-locus-plane";
+
+const PLANE_ID_RE = /^[a-zA-Z0-9_-]{8,64}$/;
+
+export function parseWorkerPlaneId(raw: unknown): string | undefined {
+ if (typeof raw !== "string") return undefined;
+ const id = raw.trim();
+ return PLANE_ID_RE.test(id) ? id : undefined;
+}
+
+export function createWorkerPlaneId(): string {
+ return crypto.randomUUID();
+}
+
+export function guestVisibleToPlane(
+ guest: { planeId?: string },
+ planeId: string | undefined,
+): boolean {
+ if (!guest.planeId) return true;
+ if (!planeId) return false;
+ return guest.planeId === planeId;
+}
diff --git a/packages/shared/src/opsRouting.test.ts b/packages/shared/src/opsRouting.test.ts
index 7269efd..593db45 100644
--- a/packages/shared/src/opsRouting.test.ts
+++ b/packages/shared/src/opsRouting.test.ts
@@ -33,6 +33,7 @@ describe("ops routing", () => {
expect(looksLikeOpsPrompt("list docker swarm services")).toBe(true);
expect(isOpsProgressTool("docker_swarm")).toBe(true);
expect(isWorkspaceSearchTool("grep")).toBe(true);
+ expect(isWorkspaceSearchTool("search")).toBe(true);
expect(isWorkspaceSearchTool("exec")).toBe(true);
expect(isOpsProgressTool("grafana_query")).toBe(true);
expect(isOpsProgressTool("mcp_call")).toBe(true);
diff --git a/packages/shared/src/patch.test.ts b/packages/shared/src/patch.test.ts
index 3310865..69caba9 100644
--- a/packages/shared/src/patch.test.ts
+++ b/packages/shared/src/patch.test.ts
@@ -274,6 +274,13 @@ describe("patchTextFromTool", () => {
new_string: "nope",
}),
).toBeUndefined();
+ expect(
+ patchFilesFromTool("apply_patch", {
+ path: "src/store.py",
+ oldStr: " value = items[0]",
+ newStr: " value = items[-1]",
+ })[0]?.path,
+ ).toBe("src/store.py");
});
});
diff --git a/packages/shared/src/patch.ts b/packages/shared/src/patch.ts
index 7e48ac2..9a4ce0d 100644
--- a/packages/shared/src/patch.ts
+++ b/packages/shared/src/patch.ts
@@ -378,8 +378,8 @@ function collectReplaces(args: Record): { oldText: string; newT
}
}
push(
- args.oldText ?? args.old_string ?? args.old_str,
- args.newText ?? args.new_string ?? args.new_str,
+ args.oldText ?? args.old_string ?? args.old_str ?? args.oldStr,
+ args.newText ?? args.new_string ?? args.new_str ?? args.newStr,
);
return out;
}
diff --git a/packages/shared/src/recipes.test.ts b/packages/shared/src/recipes.test.ts
index 90a1375..ccede1f 100644
--- a/packages/shared/src/recipes.test.ts
+++ b/packages/shared/src/recipes.test.ts
@@ -385,7 +385,7 @@ describe("design recipes", () => {
describe("first-task samples", () => {
it("ships one local workspace prompt, not a repo tour", () => {
- expect(FIRST_TASKS).toHaveLength(1);
+ expect(FIRST_TASKS).toHaveLength(2);
expect(FIRST_TASKS[0]?.title).toMatch(/hello\.txt/);
expect(FIRST_TASKS[0]?.prompt).toMatch(/\/workspace\/hello\.txt/);
expect(FIRST_TASKS[0]?.prompt).toMatch(/Do not clone/);
diff --git a/packages/shared/src/windowsCliSpawn.test.ts b/packages/shared/src/windowsCliSpawn.test.ts
index 6d5feac..ea5974e 100644
--- a/packages/shared/src/windowsCliSpawn.test.ts
+++ b/packages/shared/src/windowsCliSpawn.test.ts
@@ -110,6 +110,21 @@ describe("resolveWindowsCliSpawn", () => {
).toEqual({ command: "C:\\bin\\agent.exe", args: ["-p"] });
});
+ it("wraps the resolved target in conhost --headless when present", () => {
+ const conhost = "C:\\Windows\\System32\\conhost.exe";
+ const target = resolveWindowsCliSpawn("C:\\bin\\agent.exe", ["-p"], {
+ platform: "win32",
+ env: { SystemRoot: "C:\\Windows" },
+ exists: existsSet(["C:\\bin\\agent.exe", conhost]),
+ listDirs: () => [],
+ });
+ expect(target).toEqual({
+ command: conhost,
+ args: ["--headless", "C:\\bin\\agent.exe", "-p"],
+ windowsVerbatimArguments: false,
+ });
+ });
+
it("launches a .ps1 via powershell.exe -File when unwrap is impossible", () => {
const ps1 = `${INSTALL}\\cursor-agent.ps1`;
const target = resolveWindowsCliSpawn(ps1, ["models"], {
diff --git a/packages/shared/src/windowsCliSpawn.ts b/packages/shared/src/windowsCliSpawn.ts
index cca8946..4bc5f07 100644
--- a/packages/shared/src/windowsCliSpawn.ts
+++ b/packages/shared/src/windowsCliSpawn.ts
@@ -38,7 +38,29 @@ export function resolveWindowsCliSpawn(
const exists = opts.exists ?? existsSync;
const listDirs = opts.listDirs ?? defaultListDirs;
const resolved = resolveWin32CommandPath(command, env, exists) ?? command;
- return spawnTargetForResolved(resolved, args, env, exists, listDirs);
+ const target = spawnTargetForResolved(resolved, args, env, exists, listDirs);
+ return wrapHiddenConsoleSpawn(target, { platform, env, exists });
+}
+
+/** Hide the CLI process tree by giving it a headless console children can inherit. */
+export function wrapHiddenConsoleSpawn(
+ target: WindowsCliSpawnTarget,
+ opts: Pick = {},
+): WindowsCliSpawnTarget {
+ const platform = opts.platform ?? process.platform;
+ if (platform !== "win32") return target;
+ if (/(^|[\\/])conhost\.exe$/i.test(target.command)) return target;
+ const env = opts.env ?? {};
+ const exists = opts.exists ?? existsSync;
+ const root = env.SystemRoot || env.windir || "C:\\Windows";
+ const conhost = win32.join(root, "System32", "conhost.exe");
+ if (!exists(conhost)) return target;
+ return {
+ command: conhost,
+ args: ["--headless", target.command, ...target.args],
+ windowsVerbatimArguments: false,
+ envPatch: target.envPatch,
+ };
}
export function quoteWindowsCmdArg(value: string): string {
diff --git a/packages/worker/README.md b/packages/worker/README.md
index e38e0af..7d1b346 100644
--- a/packages/worker/README.md
+++ b/packages/worker/README.md
@@ -6,4 +6,4 @@ HTTPS service that runs Docker, Host, and QEMU guests on another machine. The la
npm run dev:worker
```
-Paste the printed `locus-worker:add` line in Settings → Workers. QEMU cloud images go in `LOCUS_WORKER_HOME/media`. See [docs/agent-backends.md](../../docs/agent-backends.md).
+Paste the printed `locus-worker:add` line in Settings → Workers. QEMU cloud images go in `LOCUS_WORKER_HOME/media`. Live VNC is loopback on the worker and proxied to the laptop over WSS `/v1/guests/:id/display`. Golden VM disks are `PUT /v1/images/:id`. Control planes send `X-Locus-Plane` so one token can namespace guests. See [docs/agent-backends.md](../../docs/agent-backends.md).
diff --git a/packages/worker/package.json b/packages/worker/package.json
index ef4a6d9..8bf0025 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/worker",
- "version": "0.17.0",
+ "version": "0.18.0",
"private": true,
"description": "LAN/cloud worker that runs Docker, Host, and QEMU guests for a Locus control plane",
"type": "module",
diff --git a/packages/worker/src/app.test.ts b/packages/worker/src/app.test.ts
index 82688e4..7cf6260 100644
--- a/packages/worker/src/app.test.ts
+++ b/packages/worker/src/app.test.ts
@@ -592,4 +592,46 @@ console.log(JSON.stringify({ type: "turn.completed", usage: {} }));
await client.execute(vm.id, { name: "exec", args: { command: "echo hi" } }, abort.signal);
expect(seen).toBe(abort.signal);
});
+
+ it("namespaces guests per control-plane header", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-worker-plane-"));
+ const engine = new MockDockerEngine(dir);
+ const app = createWorkerApp({ engine, token: "secret", name: "studio" });
+ const fetchFn: typeof fetch = async (input, init) =>
+ app.request(String(input).replace("http://127.0.0.1:7421", ""), init);
+ const a = new WorkerClient(
+ { id: "w1", name: "studio", url: "http://127.0.0.1:7421", token: "secret" },
+ fetchFn,
+ );
+ const b = new WorkerClient(
+ { id: "w1", name: "studio", url: "http://127.0.0.1:7421", token: "secret" },
+ fetchFn,
+ );
+ a.planeId = "plane-aaa";
+ b.planeId = "plane-bbb";
+ const first = await a.create({ sessionId: "s-a", name: "a", image: "locus/agent" });
+ expect(first.planeId).toBe("plane-aaa");
+ const listedA = await a.listGuests();
+ const listedB = await b.listGuests();
+ expect(listedA.map((row) => row.id)).toContain(first.id);
+ expect(listedB.map((row) => row.id)).not.toContain(first.id);
+ expect(await b.refresh(first.id)).toBeUndefined();
+ });
+
+ it("stores a golden VM disk on the worker", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-worker-disk-"));
+ const engine = new MockDockerEngine(dir);
+ const app = createWorkerApp({ engine, token: "secret", name: "studio", dataDir: dir });
+ const fetchFn: typeof fetch = async (input, init) =>
+ app.request(String(input).replace("http://127.0.0.1:7421", ""), init);
+ const client = new WorkerClient(
+ { id: "w1", name: "studio", url: "http://127.0.0.1:7421", token: "secret" },
+ fetchFn,
+ );
+ const disk = join(dir, "seed.qcow2");
+ await writeFile(disk, Buffer.from("qcow"));
+ const uploaded = await client.uploadGoldenDisk("img-12345", disk);
+ expect(uploaded.bytes).toBe(4);
+ expect(await client.hasGoldenDisk("img-12345")).toMatchObject({ bytes: 4 });
+ });
});
diff --git a/packages/worker/src/app.ts b/packages/worker/src/app.ts
index aab2839..34e267e 100644
--- a/packages/worker/src/app.ts
+++ b/packages/worker/src/app.ts
@@ -8,6 +8,7 @@ import {
isGuestReady,
isWindows,
LINUX_WORKSPACE,
+ WINDOWS_WORKSPACE,
parsePreviewPort,
WORKER_PROTOCOL,
type WorkerHealth,
@@ -48,6 +49,9 @@ import {
deleteWorkerWorkspacePath,
} from "./workspaceFs.js";
import { resolveWorkerHostDir } from "./hostDir.js";
+import { planeFromHeaders, planeFromRecord } from "./plane.js";
+import { saveWorkerGoldenDisk, safeWorkerImageId, workerGoldenDiskInfo } from "./goldenDisk.js";
+import { guestVisibleToPlane } from "@locus/shared";
export interface WorkerAppOptions {
engine: DockerEngine;
@@ -77,13 +81,27 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
guests.setLiveDocker(Boolean(opts.engine.liveEngine));
guests.setLiveQemu(Boolean(opts.hypervisor?.liveEngine));
const hostGuests = opts.hostGuests ?? new Map();
- const getGuest = (id: string) =>
+ const lookupGuest = (id: string) =>
hostGuests.get(id) ?? opts.engine.get(id) ?? opts.hypervisor?.get(id);
const listGuests = () => [
...hostGuests.values(),
...opts.engine.list(),
...(opts.hypervisor?.list() ?? []),
];
+ const getGuest = (c: import("hono").Context, id: string) => {
+ const vm = lookupGuest(id);
+ if (!vm || !guestVisibleToPlane(vm, planeFromHeaders(c.req))) return undefined;
+ return vm;
+ };
+ const stampPlane = (
+ vm: T,
+ c: import("hono").Context,
+ body?: { planeId?: unknown },
+ ): T => {
+ const plane = planeFromHeaders(c.req) ?? planeFromRecord(body);
+ if (plane) vm.planeId = plane;
+ return vm;
+ };
const app = new Hono();
const jailProxies = new Map();
const cliMcpPending = new Map<
@@ -158,6 +176,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
createdAt: now,
updatedAt: now,
};
+ stampPlane(vm, c, body);
hostGuests.set(vm.id, vm);
return c.json(vm, 201);
} catch (err) {
@@ -178,6 +197,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
imagePath: body.imagePath,
});
vm.runtime = "vm";
+ stampPlane(vm, c, body);
return c.json(vm, 201);
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : "Failed" }, 400);
@@ -209,7 +229,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
req.proxyPort = proxy.port;
req.proxyAuth = proxyAuth;
}
- const vm = await opts.engine.create(req);
+ const vm = stampPlane(await opts.engine.create(req), c, body);
if (proxy) jailProxies.set(vm.id, proxy);
return c.json(vm, 201);
} catch (err) {
@@ -218,16 +238,20 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
}
});
- app.get("/v1/guests", (c) => c.json(listGuests()));
+ app.get("/v1/guests", (c) => {
+ const plane = planeFromHeaders(c.req);
+ return c.json(listGuests().filter((vm) => guestVisibleToPlane(vm, plane)));
+ });
app.get("/v1/guests/:id", (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
return c.json(vm);
});
app.post("/v1/guests/:id/start", async (c) => {
const id = c.req.param("id");
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const host = hostGuests.get(id);
if (host) {
host.status = "running";
@@ -250,6 +274,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.post("/v1/guests/:id/stop", async (c) => {
const id = c.req.param("id");
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const host = hostGuests.get(id);
if (host) {
host.status = "stopped";
@@ -267,7 +292,8 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.delete("/v1/guests/:id", async (c) => {
const id = c.req.param("id");
try {
- const vm = getGuest(id);
+ const vm = getGuest(c, id);
+ if (!vm) return c.json({ error: "Not found" }, 404);
guests.drop(id);
jailProxies.get(id)?.close();
jailProxies.delete(id);
@@ -301,7 +327,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.get("/v1/guests/:id/logs", (c) => {
const id = c.req.param("id");
- if (!getGuest(id)) return c.json({ error: "Not found" }, 404);
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const host = hostGuests.get(id);
if (host) return c.json({ logs: "" });
if (opts.hypervisor?.get(id)) return c.json({ logs: opts.hypervisor.logs(id) });
@@ -310,7 +336,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.post("/v1/guests/:id/tools", async (c) => {
const id = c.req.param("id");
- const vm = getGuest(id);
+ const vm = getGuest(c, id);
if (!vm) return c.json({ error: "Not found" }, 404);
const body = (await c.req.json()) as { name?: string; args?: Record };
if (!body.name) return c.json({ error: "Tool name is required" }, 400);
@@ -327,14 +353,14 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.get("/v1/guests/:id/ports", async (c) => {
const id = c.req.param("id");
- if (!getGuest(id)) return c.json({ error: "Not found" }, 404);
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const ports = opts.engine.listListeningPorts ? await opts.engine.listListeningPorts(id) : [];
return c.json({ ports });
});
app.post("/v1/guests/:id/ports", async (c) => {
const id = c.req.param("id");
- if (!getGuest(id)) return c.json({ error: "Not found" }, 404);
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as { ports?: unknown };
const ports = Array.isArray(body.ports)
? body.ports.map((row) => parsePreviewPort(row)).filter((row): row is number => !!row)
@@ -347,7 +373,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.get("/v1/guests/:id/workspace", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
try {
@@ -365,7 +391,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.get("/v1/guests/:id/workspace/file", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
try {
@@ -388,7 +414,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.get("/v1/guests/:id/workspace/raw", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
return c.json({ error: "Raw workspace bytes are not served for VM guests" }, 400);
@@ -409,7 +435,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.put("/v1/guests/:id/workspace/file", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
const body = (await c.req.json().catch(() => ({}))) as {
@@ -443,7 +469,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.post("/v1/guests/:id/workspace/mkdir", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
const body = (await c.req.json().catch(() => ({}))) as { path?: string };
@@ -471,7 +497,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.delete("/v1/guests/:id/workspace/file", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
try {
@@ -514,7 +540,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
};
app.put("/v1/guests/:id/workspace-seed", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
return c.json({ error: "VM golden disks stay on the worker media folder" }, 400);
@@ -523,7 +549,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.get("/v1/guests/:id/workspace.tgz", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
if (isWorkerVmGuest(vm)) {
return c.json({ error: "VM workspace is inside the guest, not a host folder" }, 400);
@@ -543,7 +569,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
});
app.put("/v1/guests/:id/host-share", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
const sessionId = safeWorkerSessionId(vm.sessionId);
if (!opts.dataDir || !sessionId) {
@@ -557,7 +583,7 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
const port = c.req.param("port") ?? "";
const parsed = parsePreviewPort(Number(port));
if (!parsed) return c.json({ error: "Invalid preview port" }, 400);
- if (!getGuest(id)) return c.json({ error: "Not found" }, 404);
+ if (!getGuest(c, id)) return c.json({ error: "Not found" }, 404);
const url = new URL(c.req.url);
const body =
c.req.method === "GET" || c.req.method === "HEAD"
@@ -577,15 +603,35 @@ export function createWorkerApp(opts: WorkerAppOptions): Hono {
app.all("/v1/guests/:id/preview/:port", proxyPreview);
app.all("/v1/guests/:id/preview/:port/*", proxyPreview);
+ app.get("/v1/images/:imageId", async (c) => {
+ const imageId = safeWorkerImageId(c.req.param("imageId"));
+ if (!imageId || !opts.dataDir) return c.json({ error: "Not found" }, 404);
+ const info = await workerGoldenDiskInfo(opts.dataDir, imageId);
+ if (!info) return c.json({ error: "Not found" }, 404);
+ return c.json({ ok: true, ...info });
+ });
+
+ app.put("/v1/images/:imageId", async (c) => {
+ const imageId = safeWorkerImageId(c.req.param("imageId"));
+ if (!imageId || !opts.dataDir) return c.json({ error: "Invalid image id" }, 400);
+ try {
+ const saved = await saveWorkerGoldenDisk(opts.dataDir, imageId, c.req.raw.body);
+ return c.json({ ok: true, ...saved });
+ } catch (err) {
+ return c.json({ error: err instanceof Error ? err.message : "Failed" }, 400);
+ }
+ });
+
app.post("/v1/guests/:id/cursor-cli", async (c) => {
- const vm = getGuest(c.req.param("id"));
+ const vm = getGuest(c, c.req.param("id"));
if (!vm) return c.json({ error: "Not found" }, 404);
const vmGuest = isWorkerVmGuest(vm);
- if (vmGuest && isWindows(vm.os)) {
- return c.json({ error: "CLI providers on a Windows worker VM are not supported" }, 400);
- }
if (vmGuest && !vm.ssh) return c.json({ error: "VM has no SSH" }, 400);
- const workspace = vmGuest ? LINUX_WORKSPACE : vm.diskPath?.trim();
+ const workspace = vmGuest
+ ? isWindows(vm.os)
+ ? WINDOWS_WORKSPACE
+ : LINUX_WORKSPACE
+ : vm.diskPath?.trim();
if (!workspace) return c.json({ error: "No workspace on this worker guest" }, 404);
const sessionId = vm.sessionId?.trim();
if (!sessionId) return c.json({ error: "No session on this worker guest" }, 404);
@@ -842,27 +888,39 @@ async function spawnVmWorkerCursorCli(input: {
}): Promise<{ stream: ReadableStream; kill: () => void }> {
const ssh = sshTargetFromInstance(input.vm);
if (!ssh) throw new Error("VM has no SSH");
- const args = workerCliArgs(input.command, input.args, LINUX_WORKSPACE, input.gitMcpUrl);
+ const windows = isWindows(input.vm.os);
+ const workspace = windows ? WINDOWS_WORKSPACE : LINUX_WORKSPACE;
+ const args = workerCliArgs(input.command, input.args, workspace, input.gitMcpUrl);
const env = workerCliEnv(input.command, input.apiKey, workerGitMcpUrl(args) ?? input.gitMcpUrl);
const mcpJson = `${JSON.stringify({ mcpServers: { locus: { url: input.gitMcpUrl } } }, null, 2)}\n`;
const claudeJson = `${JSON.stringify({ mcpServers: { locus: { type: "http", url: input.gitMcpUrl } } }, null, 2)}\n`;
const kiroJson = `${JSON.stringify({ mcpServers: { locus: { url: input.gitMcpUrl, autoApprove: ["*"] } } }, null, 2)}\n`;
+ const cursorMcp = windows
+ ? `${WINDOWS_WORKSPACE}\\.cursor\\mcp.json`
+ : `${LINUX_WORKSPACE}/.cursor/mcp.json`;
+ const claudeMcp = windows ? `${WINDOWS_WORKSPACE}\\.mcp.json` : `${LINUX_WORKSPACE}/.mcp.json`;
+ const kiroMcp = windows
+ ? `${WINDOWS_WORKSPACE}\\.kiro\\settings\\mcp.json`
+ : `${LINUX_WORKSPACE}/.kiro/settings/mcp.json`;
await input.guests.execute({
name: "file_write",
- args: { path: `${LINUX_WORKSPACE}/.cursor/mcp.json`, content: mcpJson },
+ args: { path: cursorMcp, content: mcpJson },
});
await input.guests.execute({
name: "file_write",
- args: { path: `${LINUX_WORKSPACE}/.mcp.json`, content: claudeJson },
+ args: { path: claudeMcp, content: claudeJson },
});
await input.guests.execute({
name: "file_write",
- args: { path: `${LINUX_WORKSPACE}/.kiro/settings/mcp.json`, content: kiroJson },
+ args: { path: kiroMcp, content: kiroJson },
});
const handle = spawnCliOverSsh(
ssh,
- { command: input.command, args, cwd: LINUX_WORKSPACE, env, signal: input.signal },
- { reverseForwards: [{ listenPort: input.mcpPort, hostPort: input.mcpPort }] },
+ { command: input.command, args, cwd: workspace, env, signal: input.signal },
+ {
+ reverseForwards: [{ listenPort: input.mcpPort, hostPort: input.mcpPort }],
+ shell: windows ? "powershell" : "posix",
+ },
);
const encoder = new TextEncoder();
const stream = new ReadableStream({
diff --git a/packages/worker/src/client.ts b/packages/worker/src/client.ts
index 7c2f8fd..edaf5b9 100644
--- a/packages/worker/src/client.ts
+++ b/packages/worker/src/client.ts
@@ -1,4 +1,5 @@
import type { GuestOs, VmInstance, WorkerBackend } from "@locus/shared";
+import { WORKER_PLANE_HEADER } from "@locus/shared";
import type { CreateContainerRequest } from "@locus/hypervisor";
import type { ChromiumDriver, GuestRuntime, ToolRequest, ToolResult } from "@locus/guest-agent";
import { isWorkerHealth, parseWorkerFingerprint } from "@locus/shared";
@@ -11,6 +12,7 @@ const HEALTH_TIMEOUT_MS = 8_000;
export class WorkerClient {
readonly kind = "docker" as const;
+ planeId?: string;
private cache = new Map();
private resolvedFingerprint?: string;
private observedFingerprint?: string;
@@ -38,6 +40,7 @@ export class WorkerClient {
return {
authorization: `Bearer ${this.backend.token ?? ""}`,
"content-type": "application/json",
+ ...(this.planeId ? { [WORKER_PLANE_HEADER]: this.planeId } : {}),
};
}
@@ -211,6 +214,45 @@ export class WorkerClient {
);
}
+ async hasGoldenDisk(imageId: string): Promise<{ path: string; bytes: number } | undefined> {
+ try {
+ const body = await this.json<{ path?: string; bytes?: number }>(
+ "GET",
+ `/v1/images/${encodeURIComponent(imageId)}`,
+ );
+ if (typeof body.path === "string" && body.path && typeof body.bytes === "number") {
+ return { path: body.path, bytes: body.bytes };
+ }
+ } catch {
+ return undefined;
+ }
+ return undefined;
+ }
+
+ async uploadGoldenDisk(
+ imageId: string,
+ filePath: string,
+ ): Promise<{ path: string; bytes: number }> {
+ const { readFile } = await import("node:fs/promises");
+ const buf = await readFile(filePath);
+ const res = await this.request(this.url(`/v1/images/${encodeURIComponent(imageId)}`), {
+ method: "PUT",
+ headers: {
+ authorization: `Bearer ${this.backend.token ?? ""}`,
+ "content-type": "application/octet-stream",
+ ...(this.planeId ? { [WORKER_PLANE_HEADER]: this.planeId } : {}),
+ },
+ body: buf,
+ });
+ const data = (await res.json().catch(() => ({}))) as {
+ error?: string;
+ path?: string;
+ bytes?: number;
+ };
+ if (!res.ok) throw new Error(data.error || `Worker golden disk upload failed (${res.status})`);
+ return { path: data.path ?? "", bytes: data.bytes ?? buf.length };
+ }
+
async uploadTree(id: string, kind: "workspace-seed" | "host-share", dir: string): Promise {
const archive = await packDirectory(dir);
const res = await this.request(this.url(this.guestPath(id, `/${kind}`)), {
@@ -346,6 +388,22 @@ export class WorkerClient {
return new WorkerBrowserDriver(this, id);
}
+ async openDisplaySocket(
+ id: string,
+ onMessage: (raw: Buffer | string) => void,
+ ): Promise {
+ const path = this.guestPath(id, "/display");
+ const wsUrl = this.url(path)
+ .replace(/^https:/, "wss:")
+ .replace(/^http:/, "ws:");
+ return pinnedHttpsWebSocket(wsUrl, {
+ headers: this.headers(),
+ fingerprint: this.pin(),
+ binary: true,
+ onMessage,
+ });
+ }
+
async openBrowserSocket(id: string, onMessage: (raw: string) => void): Promise {
if (this.wsConnect) return this.wsConnect(id, onMessage);
const path = this.guestPath(id, "/browser");
@@ -355,7 +413,7 @@ export class WorkerClient {
return pinnedHttpsWebSocket(wsUrl, {
headers: this.headers(),
fingerprint: this.pin(),
- onMessage,
+ onMessage: (raw) => onMessage(String(raw)),
});
}
@@ -364,6 +422,7 @@ export class WorkerClient {
...vm,
backendId: this.backend.id,
diskPath: "",
+ display: vm.display ? { ...vm.display, host: "" } : undefined,
});
}
diff --git a/packages/worker/src/cursorCli.test.ts b/packages/worker/src/cursorCli.test.ts
index a76a807..5ddf907 100644
--- a/packages/worker/src/cursorCli.test.ts
+++ b/packages/worker/src/cursorCli.test.ts
@@ -39,6 +39,9 @@ describe("workerCliEnv", () => {
expect(
workerCliEnv("codex", "key", "http://127.0.0.1:17420/mcp/sessions/s1").LOCUS_GIT_MCP_URL,
).toBe("http://127.0.0.1:17420/mcp/sessions/s1");
+ expect(
+ workerCliEnv("kiro-cli", "key", "http://127.0.0.1:17420/mcp/sessions/s1").LOCUS_GIT_MCP_URL,
+ ).toBe("http://127.0.0.1:17420/mcp/sessions/s1");
expect(workerCliEnv("codex", "key").LOCUS_GIT_MCP_URL).toBeUndefined();
});
diff --git a/packages/worker/src/cursorCli.ts b/packages/worker/src/cursorCli.ts
index 23a1f53..275564f 100644
--- a/packages/worker/src/cursorCli.ts
+++ b/packages/worker/src/cursorCli.ts
@@ -141,7 +141,7 @@ export function spawnWorkerCursorCli(
): { stream: ReadableStream; kill: () => void } {
const command = req.command.trim() || "agent";
const args = workerCliArgs(command, req.args, workspace, req.gitMcpUrl);
- const env = workerCliEnv(command, req.apiKey, workerGitMcpUrl(args));
+ const env = workerCliEnv(command, req.apiKey, workerGitMcpUrl(args) ?? req.gitMcpUrl);
const resolved = resolveWindowsCliSpawn(command, args, { env });
let child: ReturnType;
try {
diff --git a/packages/worker/src/displaySockets.test.ts b/packages/worker/src/displaySockets.test.ts
new file mode 100644
index 0000000..50ddcfe
--- /dev/null
+++ b/packages/worker/src/displaySockets.test.ts
@@ -0,0 +1,49 @@
+import { createServer } from "node:net";
+import { describe, expect, it } from "vitest";
+import { WebSocket } from "ws";
+import { pipeVnc } from "./displaySockets.js";
+
+class FakeWs {
+ readyState: number = WebSocket.OPEN;
+ sent: Buffer[] = [];
+ private listeners = new Map void>>();
+
+ send(data: Buffer | string) {
+ this.sent.push(Buffer.from(data));
+ }
+
+ close() {
+ this.readyState = WebSocket.CLOSED;
+ }
+
+ on(event: string, fn: (...args: unknown[]) => void) {
+ const rows = this.listeners.get(event) ?? [];
+ rows.push(fn);
+ this.listeners.set(event, rows);
+ }
+
+ emit(event: string, ...args: unknown[]) {
+ for (const fn of this.listeners.get(event) ?? []) fn(...args);
+ }
+}
+
+describe("worker VNC pipe", () => {
+ it("copies bytes between a websocket and loopback VNC", async () => {
+ const tcp = createServer((socket) => {
+ socket.on("data", (buf) => {
+ socket.write(Buffer.concat([Buffer.from("RFB "), Buffer.from(buf)]));
+ });
+ });
+ await new Promise((resolve) => tcp.listen(0, "127.0.0.1", resolve));
+ const port = (tcp.address() as { port: number }).port;
+ const ws = new FakeWs();
+ pipeVnc(ws as unknown as WebSocket, "127.0.0.1", port);
+ await new Promise((r) => setTimeout(r, 40));
+ ws.emit("message", Buffer.from("hello"));
+ await new Promise((r) => setTimeout(r, 40));
+ expect(Buffer.concat(ws.sent).toString()).toContain("RFB");
+ expect(Buffer.concat(ws.sent).toString()).toContain("hello");
+ ws.emit("close");
+ await new Promise((resolve) => tcp.close(() => resolve()));
+ });
+});
diff --git a/packages/worker/src/displaySockets.ts b/packages/worker/src/displaySockets.ts
new file mode 100644
index 0000000..aa0dec2
--- /dev/null
+++ b/packages/worker/src/displaySockets.ts
@@ -0,0 +1,97 @@
+import type { IncomingMessage } from "node:http";
+import { createConnection } from "node:net";
+import type { Duplex } from "node:stream";
+import type { VmInstance } from "@locus/shared";
+import { WebSocket, WebSocketServer } from "ws";
+import { guestVisibleToPlane, parseWorkerPlaneId, WORKER_PLANE_HEADER } from "@locus/shared";
+import { workerTokenMatches } from "./token.js";
+
+export type WorkerUpgradeServer = {
+ on(
+ event: "upgrade",
+ listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void,
+ ): unknown;
+};
+
+export function attachWorkerDisplay(
+ server: WorkerUpgradeServer,
+ opts: {
+ token: string;
+ lookupGuest: (id: string) => VmInstance | undefined;
+ },
+): void {
+ const display = new WebSocketServer({ noServer: true, perMessageDeflate: false });
+ server.on("upgrade", (req, socket, head) => {
+ const pathname = pathnameOf(req);
+ const match = /^\/v1\/guests\/([^/]+)\/display$/.exec(pathname);
+ if (!match?.[1]) return;
+ const provided = bearer(req);
+ if (!workerTokenMatches(provided, opts.token)) {
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ const vmId = decodeURIComponent(match[1]);
+ const vm = opts.lookupGuest(vmId);
+ const plane = parseWorkerPlaneId(header(req, WORKER_PLANE_HEADER));
+ if (!vm || !guestVisibleToPlane(vm, plane)) {
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ const host = vm.display?.host?.trim();
+ const port = vm.display?.port;
+ if (!host || !port || (host !== "127.0.0.1" && host !== "::1" && host !== "localhost")) {
+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ display.handleUpgrade(req, socket, head, (ws) => {
+ pipeVnc(ws, host, port);
+ });
+ });
+}
+
+export function pipeVnc(ws: WebSocket, host: string, port: number): void {
+ const tcp = createConnection({ host, port });
+ tcp.on("data", (buf) => {
+ if (ws.readyState === WebSocket.OPEN) ws.send(buf);
+ });
+ tcp.on("error", () => {
+ if (ws.readyState === WebSocket.OPEN) ws.close(1011, "display unreachable");
+ });
+ tcp.on("close", () => {
+ if (ws.readyState === WebSocket.OPEN) ws.close();
+ });
+ ws.on("message", (data) => {
+ if (tcp.destroyed) return;
+ tcp.write(toBuffer(data));
+ });
+ ws.on("close", () => tcp.destroy());
+ ws.on("error", () => tcp.destroy());
+}
+
+function bearer(req: IncomingMessage): string {
+ const headerValue = req.headers.authorization ?? "";
+ return headerValue.toLowerCase().startsWith("bearer ") ? headerValue.slice(7).trim() : "";
+}
+
+function header(req: IncomingMessage, name: string): string | undefined {
+ const value = req.headers[name];
+ if (Array.isArray(value)) return value[0];
+ return value;
+}
+
+function pathnameOf(req: IncomingMessage): string {
+ try {
+ return new URL(req.url ?? "/", "https://worker.local").pathname;
+ } catch {
+ return "/";
+ }
+}
+
+function toBuffer(data: WebSocket.RawData): Buffer {
+ if (Buffer.isBuffer(data)) return data;
+ if (Array.isArray(data)) return Buffer.concat(data);
+ return Buffer.from(data);
+}
diff --git a/packages/worker/src/goldenDisk.test.ts b/packages/worker/src/goldenDisk.test.ts
new file mode 100644
index 0000000..4c1e3b5
--- /dev/null
+++ b/packages/worker/src/goldenDisk.test.ts
@@ -0,0 +1,29 @@
+import { mkdtemp, readFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ MAX_WORKER_DISK_BYTES,
+ safeWorkerImageId,
+ saveWorkerGoldenDisk,
+ workerGoldenDiskInfo,
+ workerGoldenDiskPath,
+} from "./goldenDisk.js";
+
+describe("worker golden disks", () => {
+ it("stores a qcow2 under dataDir/images and rejects oversized uploads", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "locus-golden-"));
+ expect(safeWorkerImageId("img-12345")).toBe("img-12345");
+ expect(safeWorkerImageId("no")).toBeUndefined();
+ expect(workerGoldenDiskPath(dir, "img-12345")).toBe(join(dir, "images", "img-12345.qcow2"));
+ const body = new Blob([new Uint8Array([1, 2, 3, 4])]).stream();
+ const saved = await saveWorkerGoldenDisk(dir, "img-12345", body);
+ expect(saved.bytes).toBe(4);
+ expect(await readFile(saved.path)).toEqual(Buffer.from([1, 2, 3, 4]));
+ expect(await workerGoldenDiskInfo(dir, "img-12345")).toEqual(saved);
+ await expect(
+ saveWorkerGoldenDisk(dir, "img-12345", new Blob([new Uint8Array(8)]).stream(), 4),
+ ).rejects.toThrow(/too large/);
+ expect(MAX_WORKER_DISK_BYTES).toBe(8 * 1024 * 1024 * 1024);
+ });
+});
diff --git a/packages/worker/src/goldenDisk.ts b/packages/worker/src/goldenDisk.ts
new file mode 100644
index 0000000..fa92302
--- /dev/null
+++ b/packages/worker/src/goldenDisk.ts
@@ -0,0 +1,58 @@
+import { createWriteStream } from "node:fs";
+import { mkdir, stat } from "node:fs/promises";
+import { join } from "node:path";
+import { pipeline } from "node:stream/promises";
+import { Readable } from "node:stream";
+
+export const MAX_WORKER_DISK_BYTES = 8 * 1024 * 1024 * 1024;
+const IMAGE_ID_RE = /^[a-zA-Z0-9_-]{8,64}$/;
+
+export function safeWorkerImageId(raw: string | undefined): string | undefined {
+ const id = raw?.trim() ?? "";
+ return IMAGE_ID_RE.test(id) ? id : undefined;
+}
+
+export function workerGoldenDiskPath(dataDir: string, imageId: string): string {
+ return join(dataDir, "images", `${imageId}.qcow2`);
+}
+
+export async function workerGoldenDiskInfo(
+ dataDir: string,
+ imageId: string,
+): Promise<{ path: string; bytes: number } | undefined> {
+ const path = workerGoldenDiskPath(dataDir, imageId);
+ try {
+ const info = await stat(path);
+ if (!info.isFile() || info.size <= 0) return undefined;
+ return { path, bytes: info.size };
+ } catch {
+ return undefined;
+ }
+}
+
+export async function saveWorkerGoldenDisk(
+ dataDir: string,
+ imageId: string,
+ body: ReadableStream | null,
+ maxBytes = MAX_WORKER_DISK_BYTES,
+): Promise<{ path: string; bytes: number }> {
+ if (!body) throw new Error("Disk upload is empty");
+ const dest = workerGoldenDiskPath(dataDir, imageId);
+ await mkdir(join(dataDir, "images"), { recursive: true, mode: 0o700 });
+ let bytes = 0;
+ const limiter = new TransformStream({
+ transform(chunk, controller) {
+ bytes += chunk.byteLength;
+ if (bytes > maxBytes) {
+ controller.error(new Error("Golden disk is too large"));
+ return;
+ }
+ controller.enqueue(chunk);
+ },
+ });
+ await pipeline(
+ Readable.fromWeb(body.pipeThrough(limiter)),
+ createWriteStream(dest, { mode: 0o600 }),
+ );
+ return { path: dest, bytes };
+}
diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts
index 831c4c8..2b48225 100644
--- a/packages/worker/src/index.ts
+++ b/packages/worker/src/index.ts
@@ -11,6 +11,7 @@ export {
export { WorkerGuestHub, isWorkerVmGuest } from "./guests.js";
export { WorkerBrowserHub } from "./browserHub.js";
export { attachWorkerBrowser } from "./browserSockets.js";
+export { attachWorkerDisplay, pipeVnc as pipeWorkerVnc } from "./displaySockets.js";
export { WorkerBrowserDriver } from "./browserDriver.js";
export {
certFingerprint,
diff --git a/packages/worker/src/main.ts b/packages/worker/src/main.ts
index 9e0986e..91b1d00 100644
--- a/packages/worker/src/main.ts
+++ b/packages/worker/src/main.ts
@@ -9,6 +9,7 @@ import type { GuestOs, VmInstance } from "@locus/shared";
import { createWorkerApp } from "./app.js";
import { WorkerBrowserHub } from "./browserHub.js";
import { attachWorkerBrowser } from "./browserSockets.js";
+import { attachWorkerDisplay } from "./displaySockets.js";
import { WorkerGuestHub } from "./guests.js";
import { workerAddStdout } from "./advertise.js";
import { loadOrCreateWorkerTls } from "./tls.js";
@@ -86,3 +87,7 @@ attachWorkerBrowser(server, {
browsers,
lookupGuest,
});
+attachWorkerDisplay(server, {
+ token,
+ lookupGuest: (id) => lookupGuest(id) ?? engine.get(id),
+});
diff --git a/packages/worker/src/plane.ts b/packages/worker/src/plane.ts
new file mode 100644
index 0000000..062501f
--- /dev/null
+++ b/packages/worker/src/plane.ts
@@ -0,0 +1,13 @@
+import { parseWorkerPlaneId, WORKER_PLANE_HEADER } from "@locus/shared";
+
+export function planeFromHeaders(headers: {
+ get?(name: string): string | undefined | null;
+ header?(name: string): string | undefined | null;
+}): string | undefined {
+ const read = (name: string) => headers.header?.(name) ?? headers.get?.(name) ?? undefined;
+ return parseWorkerPlaneId(read(WORKER_PLANE_HEADER) ?? read("X-Locus-Plane"));
+}
+
+export function planeFromRecord(body: { planeId?: unknown } | undefined): string | undefined {
+ return parseWorkerPlaneId(body?.planeId);
+}
diff --git a/packages/worker/src/tls.ts b/packages/worker/src/tls.ts
index 89bb542..5cc8016 100644
--- a/packages/worker/src/tls.ts
+++ b/packages/worker/src/tls.ts
@@ -80,7 +80,8 @@ export async function pinnedHttpsWebSocket(
opts: {
headers?: Record;
fingerprint?: string;
- onMessage?: (raw: string) => void;
+ binary?: boolean;
+ onMessage?: (raw: string | Buffer) => void;
},
): Promise {
const parsed = new URL(url);
@@ -100,7 +101,13 @@ export async function pinnedHttpsWebSocket(
headers: opts.headers,
});
if (opts.onMessage) {
- ws.on("message", (raw) => opts.onMessage?.(String(raw)));
+ ws.on("message", (raw) => {
+ if (opts.binary) {
+ opts.onMessage?.(Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw)));
+ return;
+ }
+ opts.onMessage?.(String(raw));
+ });
}
const fail = (err: Error) => {
if (done) return;
diff --git a/scripts/release-checksums.mjs b/scripts/release-checksums.mjs
index 7bc8304..64e43e7 100644
--- a/scripts/release-checksums.mjs
+++ b/scripts/release-checksums.mjs
@@ -7,7 +7,9 @@ const outName = process.argv[3] ?? "SHA256SUMS";
if (outName.includes("/") || outName.includes("\\") || outName === "." || outName === "..") {
throw new Error("checksum filename must be a basename");
}
-const files = (await readdir(dir)).filter((name) => /\.(AppImage|dmg|exe|zip)$/i.test(name)).sort();
+const files = (await readdir(dir))
+ .filter((name) => /\.(AppImage|deb|rpm|dmg|exe|zip)$/i.test(name))
+ .sort();
const lines = [];
for (const file of files) {
const buf = await readFile(join(dir, file));