diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml
index 7c21b5e7..8cb2ca7a 100644
--- a/.github/workflows/desktop-ci.yml
+++ b/.github/workflows/desktop-ci.yml
@@ -1,6 +1,9 @@
name: Desktop CI
on:
+ push:
+ tags:
+ - "desktop-v*"
pull_request:
paths:
- "apps/desktop/**"
@@ -52,6 +55,7 @@ jobs:
run: |
npm run typecheck --workspace @blockrun/franklin-desktop
npm run lint --workspace @blockrun/franklin-desktop
+ npm test --workspace @blockrun/franklin-desktop
- name: Build unsigned test installer
run: npm run ${{ matrix.script }} --workspace @blockrun/franklin-desktop
@@ -61,7 +65,36 @@ jobs:
- name: Upload test installer
uses: actions/upload-artifact@v4
with:
- name: franklin-desktop-${{ matrix.name }}-unsigned-test
+ name: franklin-desktop-${{ matrix.name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 7
+
+ release:
+ if: startsWith(github.ref, 'refs/tags/desktop-v')
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download installers
+ uses: actions/download-artifact@v4
+ with:
+ pattern: franklin-desktop-*
+ path: release
+ merge-multiple: true
+
+ - name: Generate checksums
+ run: sha256sum release/* > release/SHA256SUMS.txt
+
+ - name: Publish Desktop prerelease
+ uses: softprops/action-gh-release@v2
+ with:
+ name: Franklin Desktop ${{ github.ref_name }}
+ prerelease: true
+ generate_release_notes: true
+ body: |
+ Franklin Desktop beta for macOS Apple Silicon and Windows x64.
+
+ These beta installers are currently unsigned. On first launch, your operating system may ask you to confirm that you want to open the app.
+ files: release/*
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index b68e4d74..6142f63b 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -1,83 +1,83 @@
# Franklin Desktop
-Franklin Desktop is the native macOS and Windows workspace for the
-[Franklin agent](https://github.com/BlockRunAI/Franklin). It packages the real
-Franklin runtime inside an Electron shell; it is not a hosted web client or a
-mock of the CLI.
+Franklin Desktop is the native Electron interface for the Franklin agent. It
+lives in the main Franklin repository so every Desktop release is built from a
+reviewed Franklin runtime and the matching UI.
-> **Status: Beta.** CI produces unsigned test installers for macOS Apple silicon
-> and Windows x64. Signed public downloads and automatic updates are not live yet.
-> Team Mode and Studio adapters for additional agent CLIs are being developed
-> separately and are not included in this stable integration.
+## Current beta
-## What is included
+- Franklin chat with streaming tool activity and inline permission requests
+- local Base and Solana wallets with in-app network switching
+- model catalog, skills, MCP, media generation, wallet activity, and market tools
+- Agent Studio for discovering and importing supported local agent runtimes
+- personal and Team conversation spaces
+- Team projects with members, shared conversations, and versioned files
+- local Team sidecar protected by per-launch tokens and strict loopback access
-- Franklin chat with streaming tool activity and permission requests
-- Persistent local conversations and search
-- Model selection through the BlockRun router
-- Local wallet, spend, media gallery, tools, skills, and CLI panels
-- Collapsible navigation and light/dark themes
-- The same Franklin agent loop and tool registry used by the CLI
-
-## Workspace and security
-
-Packaged builds use `Documents/Franklin` as the default workspace. Workspace
-files are available to Franklin normally. A direct or symlinked path outside the
-workspace is still usable, but the app asks for explicit approval first. Shell
-commands also ask for approval in the current beta.
-
-The Desktop shell creates a private credential for each local agent launch,
-restricts local service origins and file URLs, and does not silently inherit
-ambient API keys from the launching shell. Cloud session sync is disabled in
-packaged Desktop builds unless the user explicitly enables it.
+Private keys remain in the local Franklin process. The renderer receives only
+the wallet address, balance, network, and the narrow operations exposed by the
+Electron preload bridge.
## Development
-Use Node.js 22 LTS (22.12 or newer is recommended), then run these commands from
-the repository root:
+Run commands from the Franklin repository root:
```bash
-npm install
+npm ci
npm run build
-npm run desktop:real --workspace @blockrun/franklin-desktop
+npm run desktop:real
```
-For visual work that does not need a live agent, start the Electron app with the
-mock backend:
+For UI development with the mock agent backend:
```bash
npm run desktop:dev
```
-## Check and package
+The Vite renderer, Franklin agent, and Team sidecar use loopback-only services.
+Packaged builds select ephemeral ports and pass unguessable credentials through
+the isolated preload bridge.
+
+## Validation
+
+```bash
+npm run typecheck --workspace @blockrun/franklin-desktop
+npm run lint --workspace @blockrun/franklin-desktop
+npm test --workspace @blockrun/franklin-desktop
+npm run build --workspace @blockrun/franklin-desktop
+```
+
+The test suite covers Electron URL and IPC boundaries, hostile WebSocket
+origins, the Team control plane, sandbox staging, SIWE authentication, and the
+Team-to-Franklin agent proxy.
+
+## Packaging
```bash
-npm run desktop:build
npm run desktop:package:mac
npm run desktop:package:win
```
-Installers are written to `apps/desktop/release/`. Local and CI packages are
-unsigned test builds, so operating systems may show a developer verification
-warning. The CI workflow uploads its installers for seven days.
+`scripts/prepare-runtime.mjs` copies the main repository's built Franklin
+runtime into the application before `electron-builder` creates the installer.
+The release workflow builds macOS Apple Silicon and Windows x64 installers from
+tags matching `desktop-v*`, then publishes them as a GitHub prerelease with
+SHA-256 checksums.
-## Repository structure
+Beta installers are currently unsigned. Users may need to confirm the first
+launch through their operating system's security prompt.
-```text
-apps/desktop/
-├── electron/ Electron main process and preload bridge
-├── franklin-agent/ Packaged launcher for the Franklin runtime
-├── src/ React renderer, panels, hooks, and styles
-├── build/ Desktop icons and packaging assets
-└── release/ Local packaging output (not committed)
+## Team service
-src/serve/ Authenticated local Franklin service shared by Desktop
-```
+Electron starts `cloud-server/server.mjs` as a local sidecar. It accepts only an
+explicit action allowlist and requires the per-process Desktop token. A private
+remote Team endpoint can be configured with `FRANKLIN_TEAM_CLOUD_URL` or
+`~/.blockrun/franklin-team-cloud-url`.
-The renderer communicates with the local service over an authenticated WebSocket
-protocol. Shared message types live in `apps/desktop/src/lib/wire.ts` and the
-server implementation lives in `src/serve/`.
+The included standalone control-plane and sandbox provider are development
+testbeds. Production Team execution must use an isolated remote worker and
+wallet broker rather than exposing a local runtime or Docker socket.
## License
-Apache-2.0, the same license as Franklin.
+Apache-2.0
diff --git a/apps/desktop/cloud-server/Dockerfile b/apps/desktop/cloud-server/Dockerfile
new file mode 100644
index 00000000..2387b974
--- /dev/null
+++ b/apps/desktop/cloud-server/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:22-alpine
+
+WORKDIR /app
+COPY server.mjs /app/server.mjs
+
+ENV FRANKLIN_CLOUD_HOST=0.0.0.0 \
+ FRANKLIN_CLOUD_PORT=3740 \
+ FRANKLIN_CLOUD_DATA_DIR=/data \
+ FRANKLIN_CLOUD_SANDBOX_PROVIDER=directory
+
+RUN mkdir -p /data && chown -R node:node /data /app
+USER node
+
+EXPOSE 3740
+VOLUME ["/data"]
+
+HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
+ CMD node -e "fetch('http://127.0.0.1:3740/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
+
+CMD ["node", "/app/server.mjs"]
diff --git a/apps/desktop/cloud-server/Dockerfile.sandbox b/apps/desktop/cloud-server/Dockerfile.sandbox
new file mode 100644
index 00000000..bf4a824f
--- /dev/null
+++ b/apps/desktop/cloud-server/Dockerfile.sandbox
@@ -0,0 +1,6 @@
+FROM node:22-alpine
+
+WORKDIR /app
+COPY sandbox-worker.mjs /app/sandbox-worker.mjs
+
+ENTRYPOINT ["node", "/app/sandbox-worker.mjs"]
diff --git a/apps/desktop/cloud-server/compose.yml b/apps/desktop/cloud-server/compose.yml
new file mode 100644
index 00000000..e9abf8d6
--- /dev/null
+++ b/apps/desktop/cloud-server/compose.yml
@@ -0,0 +1,20 @@
+services:
+ control-plane:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "${FRANKLIN_CLOUD_BIND:-127.0.0.1}:3740:3740"
+ environment:
+ FRANKLIN_CLOUD_HOST: 0.0.0.0
+ FRANKLIN_CLOUD_PORT: 3740
+ FRANKLIN_CLOUD_DATA_DIR: /data
+ FRANKLIN_CLOUD_BOOTSTRAP_KEY: "${FRANKLIN_CLOUD_BOOTSTRAP_KEY:?Set FRANKLIN_CLOUD_BOOTSTRAP_KEY}"
+ FRANKLIN_CLOUD_ALLOWED_ORIGINS: "${FRANKLIN_CLOUD_ALLOWED_ORIGINS:-https://desktop.blockrun.ai}"
+ FRANKLIN_CLOUD_SANDBOX_PROVIDER: directory
+ volumes:
+ - franklin-cloud-data:/data
+
+volumes:
+ franklin-cloud-data:
diff --git a/apps/desktop/cloud-server/e2e.mjs b/apps/desktop/cloud-server/e2e.mjs
new file mode 100644
index 00000000..f29a30c3
--- /dev/null
+++ b/apps/desktop/cloud-server/e2e.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+
+import fsp from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+const base = process.env.FRANKLIN_CLOUD_URL || "http://127.0.0.1:3740";
+const expectArtifact = process.env.FRANKLIN_CLOUD_EXPECT_ARTIFACT !== "0";
+const bootstrapKey = process.env.FRANKLIN_CLOUD_BOOTSTRAP_KEY || "";
+const allowedOrigin = process.env.FRANKLIN_CLOUD_TEST_ORIGIN || "http://localhost:5174";
+
+function assert(value, message) {
+ if (!value) throw new Error(`Assertion failed: ${message}`);
+}
+
+async function call(path, { token, method = "GET", body, expected } = {}) {
+ const response = await fetch(`${base}${path}`, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(bootstrapKey ? { "X-Franklin-Bootstrap-Key": bootstrapKey } : {}),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const result = await response.json();
+ if (expected !== undefined) {
+ assert(response.status === expected, `${method} ${path}: expected ${expected}, got ${response.status}`);
+ return result;
+ }
+ if (!response.ok) throw new Error(`${method} ${path}: ${result.error || response.status}`);
+ return result;
+}
+
+const stamp = Date.now().toString(36);
+const allowedCors = await fetch(`${base}/health`, { headers: { Origin: allowedOrigin } });
+assert(allowedCors.ok && allowedCors.headers.get("access-control-allow-origin") === allowedOrigin, "configured browser origin should be allowed");
+const deniedCors = await fetch(`${base}/health`, { headers: { Origin: "https://untrusted.example" } });
+assert(deniedCors.status === 403, "unknown browser origin should be rejected");
+const disabledDesktopProxy = await fetch(`${base}/v1/franklin-team`, {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "workspace.list" }),
+});
+assert(disabledDesktopProxy.status === 404, "desktop wallet proxy must be disabled without a per-process token");
+if (bootstrapKey) {
+ const deniedBootstrap = await fetch(`${base}/v1/demo/devices`, {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Unprovisioned" }),
+ });
+ assert(deniedBootstrap.status === 401, "remote device provisioning should require the private-preview key");
+}
+const owner = await call("/v1/demo/devices", { method: "POST", body: { name: `Owner-${stamp}` } });
+const guest = await call("/v1/demo/devices", { method: "POST", body: { name: `Member-${stamp}` } });
+const sameNameDevice = await call("/v1/demo/devices", { method: "POST", body: { name: `Owner-${stamp}` } });
+const created = await call("/v1/workspaces", { token: owner.token, method: "POST", body: { name: `E2E Workspace ${stamp}` } });
+const workspaceId = created.workspace.id;
+const sameNameWorkspaces = await call("/v1/workspaces", { token: sameNameDevice.token });
+assert(sameNameWorkspaces.workspaces.length === 0, "display names must not be usable for identity impersonation");
+
+const firstWrite = await call(`/v1/workspaces/${workspaceId}/files`, {
+ token: owner.token, method: "PUT", body: { path: "brief.md", content: "# Shared brief\n\nVisible to every member.\n" },
+});
+await call(`/v1/workspaces/${workspaceId}/files`, {
+ token: owner.token, method: "PUT", body: { path: "brief.md", content: "stale overwrite", expectedVersion: firstWrite.version - 1 }, expected: 409,
+});
+const dataDir = process.env.FRANKLIN_CLOUD_DATA_DIR;
+if (dataDir) {
+ const link = path.join(dataDir, "workspaces", workspaceId, "shared", "escape-link");
+ await fsp.symlink(os.tmpdir(), link);
+ await call(`/v1/workspaces/${workspaceId}/files`, {
+ token: owner.token, method: "PUT", body: { path: "escape-link/franklin-cloud-escape-probe.txt", content: "must not escape" }, expected: 400,
+ });
+ await fsp.unlink(link);
+}
+const invite = await call(`/v1/workspaces/${workspaceId}/invites`, { token: owner.token, method: "POST", body: { role: "member" } });
+await call("/v1/workspaces/join", { token: guest.token, method: "POST", body: { code: invite.invite.code } });
+
+const joined = await call(`/v1/workspaces/${workspaceId}`, { token: guest.token });
+assert(joined.workspace.members.length === 2, "owner and member should share the workspace");
+assert(joined.workspace.role === "member", "joined user should receive member role");
+
+await call(`/v1/workspaces/${workspaceId}/invites`, { token: guest.token, method: "POST", body: {}, expected: 403 });
+
+const firstTurn = await call(`/v1/workspaces/${workspaceId}/messages`, {
+ token: guest.token, method: "POST", body: { content: "Read the shared brief and prepare a status artifact." },
+});
+assert(firstTurn.task.status === "completed", "member task should complete");
+if (expectArtifact) assert(firstTurn.task.changes.length === 1, "sandbox should contain one staged artifact");
+
+const beforeApply = await call(`/v1/workspaces/${workspaceId}/files`, { token: owner.token });
+let afterApply = beforeApply;
+if (expectArtifact) {
+ assert(!beforeApply.files.some((file) => file.path === firstTurn.task.changes[0].path), "sandbox artifact must not leak into shared files before approval");
+ await call(`/v1/workspaces/${workspaceId}/tasks/${firstTurn.task.id}/apply`, { token: owner.token, method: "POST", body: {} });
+ afterApply = await call(`/v1/workspaces/${workspaceId}/files`, { token: guest.token });
+ assert(afterApply.files.some((file) => file.path === firstTurn.task.changes[0].path), "approved artifact should become shared");
+}
+
+const secondTurn = await call(`/v1/workspaces/${workspaceId}/messages`, {
+ token: owner.token, method: "POST", body: { content: "Create a second independent workspace artifact." },
+});
+assert(secondTurn.task.id !== firstTurn.task.id, "every turn should have a distinct sandbox task id");
+if (expectArtifact) {
+ const conflictingPath = secondTurn.task.changes[0].path;
+ await call(`/v1/workspaces/${workspaceId}/files`, {
+ token: guest.token, method: "PUT", body: { path: conflictingPath, content: "Member-authored content wins until a conflict is resolved.\n" },
+ });
+ const conflict = await call(`/v1/workspaces/${workspaceId}/tasks/${secondTurn.task.id}/apply`, {
+ token: owner.token, method: "POST", body: {}, expected: 409,
+ });
+ assert(conflict.error.includes(conflictingPath), "stale sandbox apply should report the conflicting shared file");
+ const preserved = await call(`/v1/workspaces/${workspaceId}/files?path=${encodeURIComponent(conflictingPath)}`, { token: owner.token });
+ assert(preserved.content.startsWith("Member-authored content"), "conflicting shared content must not be overwritten");
+}
+const messages = await call(`/v1/workspaces/${workspaceId}/messages`, { token: guest.token });
+assert(messages.messages.length === 4, "both members should see both user and Franklin messages");
+
+console.log(JSON.stringify({
+ ok: true,
+ workspaceId,
+ memberCount: joined.workspace.members.length,
+ messageCount: messages.messages.length,
+ sandboxTasks: [firstTurn.task.id, secondTurn.task.id],
+ stagedThenApplied: firstTurn.task.changes?.[0]?.path || null,
+ sharedVersion: afterApply.version,
+}, null, 2));
diff --git a/apps/desktop/cloud-server/sandbox-worker.mjs b/apps/desktop/cloud-server/sandbox-worker.mjs
new file mode 100644
index 00000000..ee894a6d
--- /dev/null
+++ b/apps/desktop/cloud-server/sandbox-worker.mjs
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const chunks = [];
+let bytes = 0;
+for await (const chunk of process.stdin) {
+ bytes += chunk.length;
+ if (bytes > 256 * 1024) throw new Error("Sandbox task input exceeds 256 KiB");
+ chunks.push(chunk);
+}
+const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
+const taskId = String(input.taskId || "").replace(/[^a-zA-Z0-9_-]/g, "");
+const memberName = String(input.memberName || "Member").slice(0, 80);
+const prompt = String(input.prompt || "").slice(0, 20_000);
+if (!taskId || !prompt) throw new Error("taskId and prompt are required");
+
+const relative = `artifacts/${taskId}.md`;
+const target = path.join("/workspace", relative);
+await fs.mkdir(path.dirname(target), { recursive: true });
+await fs.writeFile(target, `# Franklin Cloud task\n\nMember: ${memberName}\n\nPrompt: ${prompt}\n`, { encoding: "utf8", mode: 0o600 });
+
+process.stdout.write(JSON.stringify({
+ reply: `Cloud Franklin received the task from ${memberName}. I worked inside isolated container ${taskId} and prepared ${relative}.`,
+ artifact: relative,
+}));
diff --git a/apps/desktop/cloud-server/server.mjs b/apps/desktop/cloud-server/server.mjs
new file mode 100644
index 00000000..27e7d8c7
--- /dev/null
+++ b/apps/desktop/cloud-server/server.mjs
@@ -0,0 +1,824 @@
+#!/usr/bin/env node
+
+import crypto from "node:crypto";
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import http from "node:http";
+import os from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const port = parsePort(process.env.FRANKLIN_CLOUD_PORT || 3740);
+const host = process.env.FRANKLIN_CLOUD_HOST || "127.0.0.1";
+const dataRoot = path.resolve(
+ process.env.FRANKLIN_CLOUD_DATA_DIR || path.join(os.homedir(), ".blockrun", "franklin-cloud-demo"),
+);
+const statePath = path.join(dataRoot, "state.json");
+const runtimeEntry = process.env.FRANKLIN_RUNTIME_ENTRY || path.resolve(here, "..", "..", "..", "dist", "index.js");
+const runtimeModel = process.env.FRANKLIN_CLOUD_MODEL || "nvidia/nemotron-nano-9b-v2";
+const teamCloudConfigPath = path.join(os.homedir(), ".blockrun", "franklin-team-cloud-url");
+const configuredTeamCloud = (() => {
+ try { return fs.readFileSync(teamCloudConfigPath, "utf8").trim(); }
+ catch { return ""; }
+})();
+const teamCloudUrl = parseTeamCloudUrl(process.env.FRANKLIN_TEAM_CLOUD_URL || configuredTeamCloud || process.env.FRANKLIN_CLOUD_URL || "https://franklin.run");
+const teamCloudBase = teamCloudUrl.href.replace(/\/$/, "");
+const desktopToken = process.env.FRANKLIN_CLOUD_TOKEN || "";
+const teamFakeAgent = process.env.FRANKLIN_TEAM_FAKE_AGENT === "1";
+// Safe by default: the packaged prototype uses the deterministic runtime and
+// never touches a local wallet. A production worker must opt in explicitly
+// after wiring an isolated identity + wallet broker.
+const realAgent = process.env.FRANKLIN_CLOUD_ENABLE_REAL_AGENT === "1";
+const fakeAgent = !realAgent || process.env.FRANKLIN_CLOUD_FAKE_AGENT === "1";
+const sandboxProvider = process.env.FRANKLIN_CLOUD_SANDBOX_PROVIDER === "docker" ? "docker" : "directory";
+const sandboxImage = process.env.FRANKLIN_CLOUD_SANDBOX_IMAGE || "franklin-cloud-sandbox:local";
+const bootstrapKey = process.env.FRANKLIN_CLOUD_BOOTSTRAP_KEY || "";
+const allowedOrigins = new Set(
+ (process.env.FRANKLIN_CLOUD_ALLOWED_ORIGINS || "http://localhost:5173,http://localhost:5174")
+ .split(",").map((value) => value.trim()).filter(Boolean),
+);
+
+if (!isLoopbackHost(host) && bootstrapKey.length < 32) {
+ throw new Error("A non-loopback cloud bind requires FRANKLIN_CLOUD_BOOTSTRAP_KEY with at least 32 characters");
+}
+
+function parsePort(value) {
+ const parsed = Number(value);
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) throw new Error("FRANKLIN_CLOUD_PORT must be an integer from 0 to 65535");
+ return parsed;
+}
+
+function isLoopbackHost(value) {
+ return value === "127.0.0.1" || value === "localhost" || value === "::1";
+}
+
+function parseTeamCloudUrl(value) {
+ let url;
+ try { url = new URL(String(value)); }
+ catch { throw new Error("Franklin Team Cloud URL is invalid"); }
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]";
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
+ throw new Error("Franklin Team Cloud URL must use HTTPS (HTTP is allowed only on loopback)");
+ }
+ if (url.username || url.password || url.search || url.hash) throw new Error("Franklin Team Cloud URL must not include credentials, query, or fragment");
+ return url;
+}
+
+const now = () => new Date().toISOString();
+const id = (prefix) => `${prefix}_${crypto.randomBytes(10).toString("hex")}`;
+const tokenHash = (value) => crypto.createHash("sha256").update(value).digest("hex");
+const inviteCode = () => `FW-${crypto.randomBytes(12).toString("base64url").toUpperCase()}`;
+const rateBuckets = new Map();
+
+function rateLimit(req, scope, limit, windowMs) {
+ const remote = req.socket.remoteAddress || "unknown";
+ const key = `${scope}:${remote}`;
+ const time = Date.now();
+ let bucket = rateBuckets.get(key);
+ if (!bucket || time - bucket.startedAt >= windowMs) {
+ bucket = { startedAt: time, count: 0 };
+ rateBuckets.set(key, bucket);
+ }
+ bucket.count += 1;
+ if (bucket.count > limit) throw new HttpError(429, "Too many requests; try again later");
+ if (rateBuckets.size > 10_000) {
+ for (const [bucketKey, value] of rateBuckets) {
+ if (time - value.startedAt >= windowMs) rateBuckets.delete(bucketKey);
+ }
+ }
+}
+
+function enforceCount(items, predicate, limit, message) {
+ if (items.filter(predicate).length >= limit) throw new HttpError(507, message);
+}
+
+function initialState() {
+ return { version: 1, users: [], devices: [], workspaces: [], memberships: [], invites: [], messages: [], tasks: [] };
+}
+
+await fsp.mkdir(dataRoot, { recursive: true, mode: 0o700 });
+await fsp.chmod(dataRoot, 0o700);
+let state = initialState();
+try {
+ const parsed = JSON.parse(await fsp.readFile(statePath, "utf8"));
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Cloud state root must be an object");
+ state = { ...initialState(), ...parsed };
+ for (const key of ["users", "devices", "workspaces", "memberships", "invites", "messages", "tasks"]) {
+ if (!Array.isArray(state[key])) throw new Error(`Cloud state ${key} must be an array`);
+ }
+ await fsp.chmod(statePath, 0o600);
+} catch (error) {
+ if (error?.code !== "ENOENT") throw new Error(`Refusing to discard invalid Franklin Cloud state: ${error.message || error}`);
+}
+
+let writeQueue = Promise.resolve();
+function saveState() {
+ const write = async () => {
+ const tmp = `${statePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
+ try {
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600, flag: "wx" });
+ await fsp.rename(tmp, statePath);
+ await fsp.chmod(statePath, 0o600);
+ } catch (error) {
+ await fsp.rm(tmp, { force: true }).catch(() => {});
+ throw error;
+ }
+ };
+ writeQueue = writeQueue.then(write, write);
+ return writeQueue;
+}
+
+function workspaceRoot(workspaceId) {
+ return path.join(dataRoot, "workspaces", workspaceId);
+}
+function sharedRoot(workspaceId) {
+ return path.join(workspaceRoot(workspaceId), "shared");
+}
+function sandboxRoot(workspaceId, taskId) {
+ return path.join(workspaceRoot(workspaceId), "sandboxes", taskId, "work");
+}
+function cleanRelative(value) {
+ const normalized = path.posix.normalize(String(value || "").replaceAll("\\", "/")).replace(/^\/+/, "");
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
+ throw new HttpError(400, "A safe relative file path is required");
+ }
+ return normalized;
+}
+function within(root, relative) {
+ const resolved = path.resolve(root, relative);
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new HttpError(400, "Path escapes workspace");
+ return resolved;
+}
+
+async function safeWritablePath(root, relative) {
+ const absolute = within(root, relative);
+ await fsp.mkdir(path.dirname(absolute), { recursive: true, mode: 0o700 });
+ const [realRoot, realParent] = await Promise.all([fsp.realpath(root), fsp.realpath(path.dirname(absolute))]);
+ if (realParent !== realRoot && !realParent.startsWith(`${realRoot}${path.sep}`)) {
+ throw new HttpError(400, "Path traverses a symbolic link outside the workspace");
+ }
+ const existing = await fsp.lstat(absolute).catch(() => null);
+ if (existing?.isSymbolicLink()) throw new HttpError(400, "Symbolic-link files are not supported");
+ return absolute;
+}
+
+async function safeReadableFile(root, relative) {
+ const absolute = within(root, relative);
+ const [realRoot, realFile] = await Promise.all([fsp.realpath(root), fsp.realpath(absolute).catch(() => null)]);
+ if (!realFile || (realFile !== realRoot && !realFile.startsWith(`${realRoot}${path.sep}`))) {
+ throw new HttpError(realFile ? 400 : 404, realFile ? "Path escapes workspace through a symbolic link" : "File not found");
+ }
+ const stat = await fsp.stat(realFile);
+ if (!stat.isFile()) throw new HttpError(404, "File not found");
+ return { absolute: realFile, stat };
+}
+
+class HttpError extends Error {
+ constructor(status, message) {
+ super(message);
+ this.status = status;
+ }
+}
+
+function auth(req) {
+ const raw = req.headers.authorization || "";
+ const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
+ const device = state.devices.find((item) => item.tokenHash === tokenHash(token) && !item.revokedAt);
+ if (!device) throw new HttpError(401, "Invalid or expired device session");
+ const user = state.users.find((item) => item.id === device.userId);
+ if (!user) throw new HttpError(401, "Device user not found");
+ return { user, device };
+}
+function workspaceById(workspaceId) {
+ const workspace = state.workspaces.find((item) => item.id === workspaceId);
+ if (!workspace) throw new HttpError(404, "Workspace not found");
+ return workspace;
+}
+function membership(workspaceId, userId) {
+ const member = state.memberships.find((item) => item.workspaceId === workspaceId && item.userId === userId && !item.revokedAt);
+ if (!member) throw new HttpError(403, "You are not a member of this workspace");
+ return member;
+}
+function publicWorkspace(workspace, userId) {
+ const members = state.memberships
+ .filter((item) => item.workspaceId === workspace.id && !item.revokedAt)
+ .map((item) => ({
+ userId: item.userId,
+ name: state.users.find((user) => user.id === item.userId)?.name || "Unknown",
+ role: item.role,
+ joinedAt: item.joinedAt,
+ }));
+ return { ...workspace, role: membership(workspace.id, userId).role, members };
+}
+
+async function body(req) {
+ const chunks = [];
+ let bytes = 0;
+ for await (const chunk of req) {
+ bytes += chunk.length;
+ if (bytes > 2_000_000) throw new HttpError(413, "Request body is too large");
+ chunks.push(chunk);
+ }
+ if (!chunks.length) return {};
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
+ catch { throw new HttpError(400, "Invalid JSON body"); }
+}
+
+function json(res, status, value) {
+ res.writeHead(status, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Franklin-Bootstrap-Key, X-Franklin-Desktop-Token",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Cross-Origin-Resource-Policy": "same-origin",
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
+ "Referrer-Policy": "no-referrer",
+ });
+ res.end(JSON.stringify(value));
+}
+
+function secureEqual(actual, expected) {
+ const left = Buffer.from(String(actual || ""));
+ const right = Buffer.from(String(expected || ""));
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
+}
+
+function desktopAuth(req) {
+ // Browser-only development can run without a token. Electron always injects
+ // one into both preload and this process, making wallet operations private to
+ // this Franklin window instead of merely trusting the loopback interface.
+ if (!desktopToken) throw new HttpError(404, "Route not found");
+ if (!secureEqual(req.headers["x-franklin-desktop-token"], desktopToken)) {
+ throw new HttpError(401, "Invalid Franklin Desktop session");
+ }
+}
+
+function originAllowed(req, origin) {
+ if (allowedOrigins.has(origin)) return true;
+ if (origin !== "null" || !desktopToken) return false;
+ if (req.method === "OPTIONS") {
+ return String(req.headers["access-control-request-headers"] || "").toLowerCase().split(",").map((value) => value.trim()).includes("x-franklin-desktop-token");
+ }
+ return secureEqual(req.headers["x-franklin-desktop-token"], desktopToken);
+}
+
+const NONCE_COOKIE = "franklin_try_nonce";
+const SESSION_COOKIE = "franklin_try_session";
+const TEAM_TIMEOUT = 20_000;
+let teamSessionCookie = null;
+let runtimeModulesPromise = null;
+let signingModulesPromise = null;
+const runningTeamWorkspaces = new Set();
+const TEAM_ACTIONS = new Set([
+ "workspace.list", "workspace.create", "workspace.get", "workspace.snapshot", "workspace.invite", "workspace.join",
+ "member.role", "message.list", "message.append", "file.list", "file.read", "file.save",
+]);
+
+function setCookie(res, name) {
+ const values = res.headers.getSetCookie?.() || [];
+ const value = values.find((item) => item.startsWith(`${name}=`));
+ if (value) return value.split(";")[0];
+ const fallback = res.headers.get("set-cookie");
+ if (!fallback) return null;
+ const match = fallback.match(new RegExp(`(?:^|,\\s*)(${name}=[^;]+)`));
+ return match?.[1] || null;
+}
+
+async function responseJson(response, maxBytes) {
+ const declared = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declared) && declared > maxBytes) throw new HttpError(502, "Franklin Cloud response is too large");
+ if (!response.body) return {};
+ const reader = response.body.getReader();
+ const chunks = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > maxBytes) {
+ await reader.cancel();
+ throw new HttpError(502, "Franklin Cloud response is too large");
+ }
+ chunks.push(Buffer.from(value));
+ }
+ try { return JSON.parse(Buffer.concat(chunks, total).toString("utf8") || "{}"); }
+ catch { throw new HttpError(502, "Franklin Cloud returned invalid JSON"); }
+}
+
+function teamPayload(input) {
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new HttpError(400, "Team request must be an object");
+ const action = String(input.action || "");
+ if (!TEAM_ACTIONS.has(action)) throw new HttpError(400, "Unknown Franklin Team action");
+ return { ...input, action };
+}
+
+async function runtimeModules() {
+ if (!runtimeModulesPromise) runtimeModulesPromise = (async () => {
+ if (!runtimeEntry || !fs.existsSync(runtimeEntry)) throw new Error("Franklin runtime is not installed");
+ const distRoot = path.dirname(runtimeEntry);
+ const [llm, config] = await Promise.all([
+ import(pathToFileURL(path.join(distRoot, "agent", "llm.js")).href),
+ import(pathToFileURL(path.join(distRoot, "config.js")).href),
+ ]);
+ return { llm, config };
+ })();
+ return runtimeModulesPromise;
+}
+
+async function signingModules() {
+ if (!signingModulesPromise) {
+ const packageRoot = path.dirname(path.dirname(runtimeEntry));
+ const modulesRoot = path.join(packageRoot, "node_modules", "@noble");
+ signingModulesPromise = Promise.all([
+ import(pathToFileURL(path.join(modulesRoot, "curves", "secp256k1.js")).href),
+ import(pathToFileURL(path.join(modulesRoot, "hashes", "sha3.js")).href),
+ ]).then(([curves, hashes]) => ({ secp256k1: curves.secp256k1, keccak256: hashes.keccak_256 }));
+ }
+ return signingModulesPromise;
+}
+
+async function localWallet() {
+ const { secp256k1, keccak256 } = await signingModules();
+ const walletDir = path.join(os.homedir(), ".blockrun");
+ const walletFile = path.join(walletDir, ".session");
+ const legacyFile = path.join(walletDir, "wallet.key");
+ let privateKey = process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY || "";
+ if (!privateKey) privateKey = await fsp.readFile(walletFile, "utf8").then((value) => value.trim()).catch(() => "");
+ if (!privateKey) privateKey = await fsp.readFile(legacyFile, "utf8").then((value) => value.trim()).catch(() => "");
+ if (!privateKey) throw new Error("No local Franklin wallet is configured; create one in Franklin before connecting Team Cloud");
+ const privateBytes = Buffer.from(privateKey.replace(/^0x/, ""), "hex");
+ if (privateBytes.length !== 32) throw new Error("Franklin wallet key is invalid");
+ const publicKey = secp256k1.getPublicKey(privateBytes, false);
+ const address = `0x${Buffer.from(keccak256(publicKey.slice(1))).subarray(-20).toString("hex")}`;
+ const signMessage = (message) => {
+ const messageBytes = Buffer.from(message, "utf8");
+ const prefix = Buffer.from(`\x19Ethereum Signed Message:\n${messageBytes.length}`, "utf8");
+ const digest = keccak256(Buffer.concat([prefix, messageBytes]));
+ // Noble v1 returns a Signature object; v2 returns bytes and requires the
+ // recovered format to include the recovery id. Support both because the
+ // Desktop app can reuse a Franklin runtime installed by an older release.
+ const signed = secp256k1.sign(digest, privateBytes, { format: "recovered", prehash: false });
+ const byteResult = signed instanceof Uint8Array;
+ const recoveryId = byteResult ? (signed.length === 65 ? signed[0] : 0) : (signed.recovery ?? 0);
+ const compact = byteResult
+ ? Buffer.from(signed.length === 65 ? signed.slice(1) : signed).toString("hex")
+ : signed.toCompactHex();
+ return `0x${compact}${(recoveryId + 27).toString(16).padStart(2, "0")}`;
+ };
+ return { address, signMessage };
+}
+
+async function teamLogin() {
+ const nonceRes = await fetch(`${teamCloudBase}/api/try/auth/nonce`, { redirect: "manual", signal: AbortSignal.timeout(TEAM_TIMEOUT) });
+ if (!nonceRes.ok) throw new HttpError(502, `Franklin Cloud nonce failed (${nonceRes.status})`);
+ const nonceCookie = setCookie(nonceRes, NONCE_COOKIE);
+ const { nonce } = await responseJson(nonceRes, 64 * 1024);
+ if (!/^[a-f0-9]{32}$/i.test(String(nonce || "")) || !nonceCookie) throw new HttpError(502, "Franklin Cloud did not return a valid nonce");
+
+ const { address, signMessage } = await localWallet();
+ const message =
+ `${teamCloudUrl.hostname} wants you to sign in with your Ethereum account:\n${address}\n\n` +
+ `Sign in to Franklin Desktop Team Workspace.\n\n` +
+ `URI: ${teamCloudUrl.origin}\nVersion: 1\nChain ID: 8453\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
+ const signature = signMessage(message);
+ const verifyRes = await fetch(`${teamCloudBase}/api/try/auth/verify`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: nonceCookie },
+ body: JSON.stringify({ address, message, signature }),
+ redirect: "manual",
+ signal: AbortSignal.timeout(TEAM_TIMEOUT),
+ });
+ if (!verifyRes.ok) throw new HttpError(502, `Franklin Cloud wallet verification failed (${verifyRes.status})`);
+ const verified = await responseJson(verifyRes, 64 * 1024);
+ if (!secureEqual(String(verified.address || "").toLowerCase(), address.toLowerCase())) throw new HttpError(502, "Franklin Cloud verified an unexpected wallet");
+ teamSessionCookie = setCookie(verifyRes, SESSION_COOKIE);
+ if (!teamSessionCookie) throw new HttpError(502, "Franklin Cloud did not return a session");
+}
+
+async function teamFetch(payload) {
+ payload = teamPayload(payload);
+ if (!teamSessionCookie) await teamLogin();
+ const request = () => fetch(`${teamCloudBase}/api/try/team`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: teamSessionCookie },
+ body: JSON.stringify(payload),
+ redirect: "manual",
+ signal: AbortSignal.timeout(TEAM_TIMEOUT),
+ });
+ let response = await request();
+ if (response.status === 401) {
+ teamSessionCookie = null;
+ await teamLogin();
+ response = await request();
+ }
+ const result = await responseJson(response, 4 * 1024 * 1024);
+ if (!response.ok) {
+ const message = response.status === 404
+ ? "Franklin Cloud Team API is not deployed on this server yet"
+ : (result.error || `Franklin Cloud request failed (${response.status})`);
+ throw new HttpError(response.status, message);
+ }
+ return result;
+}
+
+async function runTeamAgentTurn(input) {
+ const workspaceId = String(input.workspaceId || "");
+ const content = String(input.content || "").trim().slice(0, 20_000);
+ if (!workspaceId || !content) throw new HttpError(400, "Workspace and message are required");
+ if (runningTeamWorkspaces.has(workspaceId)) throw new HttpError(409, "Team Franklin is already working in this workspace");
+ runningTeamWorkspaces.add(workspaceId);
+ try {
+ const snapshot = await teamFetch({ action: "workspace.snapshot", workspaceId });
+ const userResult = await teamFetch({ action: "message.append", workspaceId, role: "user", content });
+ let reply;
+ if (teamFakeAgent) {
+ reply = `Team Franklin received your message in ${snapshot.workspace.name}. This test turn used ${snapshot.files.length} shared file(s) and ${snapshot.messages.length} earlier message(s).`;
+ } else {
+ const { llm, config } = await runtimeModules();
+ const chain = config.loadChain();
+ const client = new llm.ModelClient({ apiUrl: config.API_URLS[chain], chain });
+ const sharedFiles = snapshot.files.slice(0, 30).map((file) => `--- ${file.path} ---\n${String(file.content || "").slice(0, 12_000)}`).join("\n\n");
+ const recent = snapshot.messages.slice(-20).map((message) => ({
+ role: message.role === "assistant" ? "assistant" : "user",
+ content: `${message.authorName}: ${message.content}`,
+ }));
+ const response = await client.complete({
+ model: runtimeModel,
+ system: [
+ `You are Team Franklin for the shared workspace \"${snapshot.workspace.name}\".`,
+ "Answer for the whole team. Use the shared files as context and clearly state when information is missing.",
+ sharedFiles ? `Shared workspace files:\n${sharedFiles}` : "There are no shared files yet.",
+ ].join("\n\n"),
+ messages: [...recent, { role: "user", content }],
+ max_tokens: 4096,
+ stream: false,
+ });
+ reply = response.content.filter((part) => part.type === "text").map((part) => part.text).join("").trim();
+ if (!reply) throw new Error("Team Franklin returned no text response");
+ }
+ const assistantResult = await teamFetch({ action: "message.append", workspaceId, role: "assistant", content: reply });
+ return { userMessage: userResult.message, assistant: assistantResult.message, workspaceVersion: assistantResult.version };
+ } finally {
+ runningTeamWorkspaces.delete(workspaceId);
+ }
+}
+
+async function listFiles(root) {
+ const files = [];
+ let totalBytes = 0;
+ async function walk(dir, prefix = "") {
+ let entries = [];
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
+ for (const entry of entries) {
+ if (entry.name === ".git") continue;
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const absolute = path.join(dir, entry.name);
+ if (entry.isDirectory()) await walk(absolute, rel);
+ if (entry.isFile()) {
+ if (files.length >= 2_000) throw new HttpError(413, "Workspace contains too many files");
+ const stat = await fsp.stat(absolute);
+ if (stat.size > 10 * 1024 * 1024) throw new HttpError(413, `Workspace file is too large: ${rel}`);
+ totalBytes += stat.size;
+ if (totalBytes > 100 * 1024 * 1024) throw new HttpError(413, "Workspace exceeds 100 MiB");
+ const data = await fsp.readFile(absolute);
+ files.push({ path: rel, bytes: data.length, sha256: crypto.createHash("sha256").update(data).digest("hex") });
+ }
+ }
+ }
+ await walk(root);
+ return files.sort((a, b) => a.path.localeCompare(b.path));
+}
+
+async function snapshotWorkspace(workspaceId, taskId) {
+ const from = sharedRoot(workspaceId);
+ const to = sandboxRoot(workspaceId, taskId);
+ await listFiles(from);
+ await fsp.mkdir(path.dirname(to), { recursive: true });
+ await fsp.cp(from, to, { recursive: true, force: false, errorOnExist: false });
+ return to;
+}
+
+function stripAnsi(value) {
+ return value.replace(/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, "").trim();
+}
+
+async function runProcess(command, args, options) {
+ return new Promise((resolve, reject) => {
+ const { input, ...spawnOptions } = options;
+ const child = spawn(command, args, spawnOptions);
+ let stdout = "";
+ let stderr = "";
+ let settled = false;
+ const maxOutput = 2 * 1024 * 1024;
+ const finish = (error, result) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (error) reject(error);
+ else resolve(result);
+ };
+ const remember = (kind, chunk) => {
+ const text = chunk.toString();
+ if (kind === "stdout") stdout += text;
+ else stderr += text;
+ if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxOutput) {
+ child.kill("SIGKILL");
+ finish(new Error("Cloud Franklin output exceeded 2 MiB"));
+ }
+ };
+ const timer = setTimeout(() => {
+ child.kill("SIGTERM");
+ setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 2_000).unref();
+ finish(new Error("Cloud Franklin task exceeded 120 seconds"));
+ }, 120_000);
+ child.stdout.on("data", (chunk) => remember("stdout", chunk));
+ child.stderr.on("data", (chunk) => remember("stderr", chunk));
+ child.on("error", (error) => finish(error));
+ child.on("exit", (code) => {
+ if (code === 0) finish(null, { stdout, stderr });
+ else finish(new Error(stripAnsi(stderr || stdout).slice(0, 4_000) || `Franklin exited with code ${code}`));
+ });
+ if (input !== undefined && child.stdin) child.stdin.end(input);
+ });
+}
+
+async function runDockerSandbox({ root, task, user, content }) {
+ if (realAgent) throw new Error("Real Franklin requires a dedicated remote worker and wallet broker; it cannot run in the preview container");
+ const mountRoot = await fsp.realpath(root);
+ const uid = typeof process.getuid === "function" ? process.getuid() : 1000;
+ const gid = typeof process.getgid === "function" ? process.getgid() : 1000;
+ const result = await runProcess("docker", [
+ "run", "--rm", "-i", "--network", "none", "--read-only",
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
+ "--memory", "512m", "--cpus", "1", "--pids-limit", "128",
+ "--user", `${uid}:${gid}`,
+ "--mount", `type=bind,src=${mountRoot},dst=/workspace`,
+ "--workdir", "/workspace", sandboxImage,
+ ], {
+ cwd: root,
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
+ stdio: ["pipe", "pipe", "pipe"],
+ input: JSON.stringify({ taskId: task.id, memberName: user.name, prompt: content }),
+ });
+ const parsed = JSON.parse(result.stdout || "{}");
+ if (!parsed.reply) throw new Error("Sandbox worker returned no reply");
+ return parsed.reply;
+}
+
+async function runAgentTurn({ workspace, user, content, task }) {
+ const root = sandboxRoot(workspace.id, task.id);
+ if (sandboxProvider === "docker") return runDockerSandbox({ root, task, user, content });
+ if (fakeAgent) {
+ const proofName = `artifacts/${task.id}.md`;
+ const proofPath = within(root, proofName);
+ await fsp.mkdir(path.dirname(proofPath), { recursive: true });
+ await fsp.writeFile(proofPath, `# Franklin Cloud task\n\nMember: ${user.name}\n\nPrompt: ${content}\n`, { encoding: "utf8", mode: 0o600 });
+ return `Cloud Franklin received the task from ${user.name}. I worked inside isolated sandbox ${task.id} and prepared ${proofName}.`;
+ }
+ throw new Error("In-process real-agent execution is disabled; use a dedicated remote worker with an isolated identity and wallet broker");
+}
+
+async function taskChanges(workspaceId, taskId, baseFiles = []) {
+ const before = new Map(baseFiles.map((file) => [file.path, file]));
+ const after = await listFiles(sandboxRoot(workspaceId, taskId));
+ return after.filter((file) => before.get(file.path)?.sha256 !== file.sha256);
+}
+
+async function handle(req, res) {
+ const origin = String(req.headers.origin || "");
+ if (origin) {
+ if (!originAllowed(req, origin)) throw new HttpError(403, "Origin is not allowed");
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Vary", "Origin");
+ }
+ if (req.method === "OPTIONS") return json(res, 204, {});
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
+ const parts = url.pathname.split("/").filter(Boolean);
+
+ if (req.method === "GET" && url.pathname === "/health") {
+ return json(res, 200, {
+ ok: true, service: "franklin-cloud-workspace", mode: fakeAgent ? "test" : "franklin",
+ sandboxProvider, authMode: bootstrapKey ? "private-preview" : "demo-device",
+ desktopProtected: Boolean(desktopToken),
+ });
+ }
+
+ // Product Team Mode: Desktop owns wallet access and proxies only this
+ // explicit command surface to the existing SIWE-authenticated Franklin Cloud.
+ // The legacy demo routes below remain for the standalone cloud-server tests.
+ if (req.method === "POST" && url.pathname === "/v1/franklin-team") {
+ rateLimit(req, "team-proxy", 240, 60_000);
+ desktopAuth(req);
+ return json(res, 200, await teamFetch(await body(req)));
+ }
+ if (req.method === "POST" && url.pathname === "/v1/franklin-team/agent-turn") {
+ rateLimit(req, "team-turn", 20, 60_000);
+ desktopAuth(req);
+ return json(res, 201, await runTeamAgentTurn(await body(req)));
+ }
+
+ // Development device bootstrap. The token is random and only its hash is persisted.
+ // Production replaces this route with browser OAuth + PKCE/DPoP.
+ if (req.method === "POST" && url.pathname === "/v1/demo/devices") {
+ rateLimit(req, "device-bootstrap", 20, 60 * 60_000);
+ if (bootstrapKey && !secureEqual(req.headers["x-franklin-bootstrap-key"], bootstrapKey)) {
+ throw new HttpError(401, "A valid private-preview access key is required");
+ }
+ const input = await body(req);
+ const name = String(input.name || "").trim().slice(0, 80);
+ if (!name) throw new HttpError(400, "Member name is required");
+ enforceCount(state.devices, () => true, 10_000, "This preview has reached its device limit");
+ // A display name is not an identity proof. Always create a distinct demo
+ // principal so entering somebody else's name cannot impersonate them.
+ const user = { id: id("usr"), name, createdAt: now() };
+ state.users.push(user);
+ const token = crypto.randomBytes(32).toString("base64url");
+ const device = { id: id("dev"), userId: user.id, name: String(input.deviceName || "Franklin Desktop").slice(0, 120), tokenHash: tokenHash(token), createdAt: now() };
+ state.devices.push(device);
+ await saveState();
+ return json(res, 201, { user, device: { ...device, tokenHash: undefined }, token, authMode: "demo-device" });
+ }
+
+ const { user, device } = auth(req);
+
+ if (req.method === "GET" && url.pathname === "/v1/me") {
+ return json(res, 200, { user, device: { id: device.id, name: device.name, createdAt: device.createdAt } });
+ }
+ if (req.method === "GET" && url.pathname === "/v1/workspaces") {
+ const memberships = state.memberships.filter((item) => item.userId === user.id && !item.revokedAt);
+ return json(res, 200, { workspaces: memberships.map((item) => publicWorkspace(workspaceById(item.workspaceId), user.id)) });
+ }
+ if (req.method === "POST" && url.pathname === "/v1/workspaces") {
+ const input = await body(req);
+ const name = String(input.name || "").trim().slice(0, 100);
+ if (!name) throw new HttpError(400, "Workspace name is required");
+ enforceCount(state.memberships, (item) => item.userId === user.id && !item.revokedAt, 100, "Workspace limit reached");
+ const workspace = { id: id("ws"), name, createdBy: user.id, createdAt: now(), version: 1, runtime: "isolated-directory" };
+ state.workspaces.push(workspace);
+ state.memberships.push({ workspaceId: workspace.id, userId: user.id, role: "owner", joinedAt: now() });
+ await fsp.mkdir(sharedRoot(workspace.id), { recursive: true, mode: 0o700 });
+ await fsp.writeFile(path.join(sharedRoot(workspace.id), "README.md"), `# ${name}\n\nShared Franklin Cloud workspace.\n`, { encoding: "utf8", mode: 0o600 });
+ await saveState();
+ return json(res, 201, { workspace: publicWorkspace(workspace, user.id) });
+ }
+ if (req.method === "POST" && url.pathname === "/v1/workspaces/join") {
+ rateLimit(req, `workspace-join:${device.id}`, 60, 60 * 60_000);
+ const input = await body(req);
+ const code = String(input.code || "").trim().toUpperCase();
+ const invite = state.invites.find((item) => item.code === code && !item.usedBy && Date.parse(item.expiresAt) > Date.now());
+ if (!invite) throw new HttpError(404, "Invite code is invalid, used, or expired");
+ if (!state.memberships.some((item) => item.workspaceId === invite.workspaceId && item.userId === user.id && !item.revokedAt)) {
+ state.memberships.push({ workspaceId: invite.workspaceId, userId: user.id, role: invite.role, joinedAt: now() });
+ }
+ invite.usedBy = user.id;
+ invite.usedAt = now();
+ await saveState();
+ return json(res, 200, { workspace: publicWorkspace(workspaceById(invite.workspaceId), user.id) });
+ }
+
+ if (parts[0] !== "v1" || parts[1] !== "workspaces" || !parts[2]) throw new HttpError(404, "Route not found");
+ const workspaceId = parts[2];
+ const workspace = workspaceById(workspaceId);
+ const member = membership(workspaceId, user.id);
+
+ if (req.method === "GET" && parts.length === 3) {
+ return json(res, 200, { workspace: publicWorkspace(workspace, user.id) });
+ }
+ if (req.method === "POST" && parts[3] === "invites") {
+ if (member.role !== "owner" && member.role !== "admin") throw new HttpError(403, "Only owners and admins can invite members");
+ rateLimit(req, `workspace-invite:${device.id}`, 30, 60 * 60_000);
+ enforceCount(state.invites, (item) => item.workspaceId === workspaceId && !item.usedBy && Date.parse(item.expiresAt) > Date.now(), 100, "Too many active invites");
+ const input = await body(req);
+ const invite = {
+ id: id("inv"), workspaceId, code: inviteCode(), role: input.role === "viewer" ? "viewer" : "member",
+ createdBy: user.id, createdAt: now(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+ };
+ state.invites.push(invite);
+ await saveState();
+ return json(res, 201, { invite });
+ }
+ if (req.method === "GET" && parts[3] === "messages") {
+ return json(res, 200, { messages: state.messages.filter((item) => item.workspaceId === workspaceId) });
+ }
+ if (req.method === "POST" && parts[3] === "messages") {
+ if (member.role === "viewer") throw new HttpError(403, "Viewers cannot send messages");
+ rateLimit(req, `workspace-message:${device.id}`, 30, 60_000);
+ enforceCount(state.messages, (item) => item.workspaceId === workspaceId, 500, "Workspace message limit reached; archive this preview before continuing");
+ enforceCount(state.tasks, (item) => item.workspaceId === workspaceId, 100, "Workspace task limit reached; archive this preview before continuing");
+ const input = await body(req);
+ const content = String(input.content || "").trim().slice(0, 20_000);
+ if (!content) throw new HttpError(400, "Message is required");
+ const userMessage = { id: id("msg"), workspaceId, role: "user", authorId: user.id, authorName: user.name, content, createdAt: now() };
+ state.messages.push(userMessage);
+ const task = { id: id("task"), workspaceId, createdBy: user.id, prompt: content, status: "running", createdAt: now(), baseVersion: workspace.version };
+ state.tasks.push(task);
+ await snapshotWorkspace(workspaceId, task.id);
+ task.baseFiles = await listFiles(sandboxRoot(workspaceId, task.id));
+ await saveState();
+ try {
+ const reply = await runAgentTurn({ workspace, user, content, task });
+ task.status = "completed";
+ task.completedAt = now();
+ task.changes = await taskChanges(workspaceId, task.id, task.baseFiles);
+ const assistant = { id: id("msg"), workspaceId, role: "assistant", authorId: "franklin-cloud", authorName: "Franklin Cloud", content: reply, taskId: task.id, createdAt: now() };
+ state.messages.push(assistant);
+ await saveState();
+ return json(res, 201, { userMessage, assistant, task });
+ } catch (error) {
+ task.status = "failed";
+ task.completedAt = now();
+ task.error = error instanceof Error ? error.message : String(error);
+ await saveState();
+ throw new HttpError(502, `Cloud Franklin failed: ${task.error}`);
+ }
+ }
+ if (req.method === "GET" && parts[3] === "files") {
+ const requested = url.searchParams.get("path");
+ if (!requested) return json(res, 200, { files: await listFiles(sharedRoot(workspaceId)), version: workspace.version });
+ const rel = cleanRelative(requested);
+ const { absolute, stat } = await safeReadableFile(sharedRoot(workspaceId), rel);
+ if (stat.size > 500_000) throw new HttpError(413, "File is too large to preview");
+ return json(res, 200, { path: rel, content: await fsp.readFile(absolute, "utf8"), bytes: stat.size, version: workspace.version });
+ }
+ if (req.method === "PUT" && parts[3] === "files") {
+ if (member.role === "viewer") throw new HttpError(403, "Viewers cannot edit files");
+ rateLimit(req, `workspace-file:${device.id}`, 120, 60_000);
+ const input = await body(req);
+ if (input.expectedVersion !== undefined && (!Number.isInteger(input.expectedVersion) || input.expectedVersion !== workspace.version)) {
+ throw new HttpError(409, "Workspace changed since this file was opened; refresh before saving");
+ }
+ const rel = cleanRelative(input.path);
+ const content = String(input.content ?? "");
+ if (Buffer.byteLength(content) > 500_000) throw new HttpError(413, "File is too large");
+ const absolute = await safeWritablePath(sharedRoot(workspaceId), rel);
+ await fsp.writeFile(absolute, content, { encoding: "utf8", mode: 0o600 });
+ await fsp.chmod(absolute, 0o600);
+ workspace.version += 1;
+ await saveState();
+ return json(res, 200, { ok: true, path: rel, version: workspace.version });
+ }
+ if (req.method === "GET" && parts[3] === "tasks") {
+ return json(res, 200, { tasks: state.tasks.filter((item) => item.workspaceId === workspaceId).sort((a, b) => b.createdAt.localeCompare(a.createdAt)) });
+ }
+ if (req.method === "POST" && parts[3] === "tasks" && parts[5] === "apply") {
+ if (member.role !== "owner" && member.role !== "admin") throw new HttpError(403, "Only owners and admins can apply task changes");
+ const task = state.tasks.find((item) => item.id === parts[4] && item.workspaceId === workspaceId);
+ if (!task || task.status !== "completed") throw new HttpError(404, "Completed task not found");
+ if (task.appliedAt) throw new HttpError(409, "Task changes were already applied");
+ const baseFiles = new Map((task.baseFiles || []).map((file) => [file.path, file]));
+ const currentFiles = new Map((await listFiles(sharedRoot(workspaceId))).map((file) => [file.path, file]));
+ const conflicts = (task.changes || [])
+ .filter((file) => (baseFiles.get(file.path)?.sha256 || null) !== (currentFiles.get(file.path)?.sha256 || null))
+ .map((file) => file.path);
+ if (conflicts.length) {
+ throw new HttpError(409, `Shared files changed after sandbox snapshot: ${conflicts.join(", ")}`);
+ }
+ for (const file of task.changes || []) {
+ const rel = cleanRelative(file.path);
+ const { absolute: from } = await safeReadableFile(sandboxRoot(workspaceId, task.id), rel);
+ const to = await safeWritablePath(sharedRoot(workspaceId), rel);
+ await fsp.copyFile(from, to);
+ await fsp.chmod(to, 0o600);
+ }
+ task.appliedAt = now();
+ task.appliedBy = user.id;
+ workspace.version += 1;
+ await saveState();
+ return json(res, 200, { ok: true, version: workspace.version, changes: task.changes || [] });
+ }
+ throw new HttpError(404, "Route not found");
+}
+
+const server = http.createServer((req, res) => {
+ handle(req, res).catch((error) => {
+ // Keep the HTTP parser aligned for a reused loopback connection when an
+ // auth/origin check rejects a POST before body() has consumed its payload.
+ req.resume();
+ const status = error instanceof HttpError ? error.status : 500;
+ if (status === 500) console.error("[franklin-cloud] request failed", error);
+ json(res, status, { error: error instanceof HttpError ? error.message : "Internal server error" });
+ });
+});
+
+server.requestTimeout = 30_000;
+server.headersTimeout = 15_000;
+server.keepAliveTimeout = 5_000;
+
+server.listen(port, host, () => {
+ const address = server.address();
+ const readyPort = address && typeof address !== "string" ? address.port : port;
+ console.log(`[franklin-cloud] http://${host}:${readyPort} data=${dataRoot} runtime=${fakeAgent ? "test" : runtimeEntry} sandbox=${sandboxProvider}`);
+ if (typeof process.send === "function") process.send({ type: "franklin:cloud-ready", port: readyPort });
+});
+
+for (const signal of ["SIGINT", "SIGTERM"]) {
+ process.on(signal, async () => {
+ server.close();
+ await writeQueue.catch(() => {});
+ process.exit(0);
+ });
+}
diff --git a/apps/desktop/cloud-server/team-proxy.test.mjs b/apps/desktop/cloud-server/team-proxy.test.mjs
new file mode 100644
index 00000000..273c9bf0
--- /dev/null
+++ b/apps/desktop/cloud-server/team-proxy.test.mjs
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import http from "node:http";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const token = "desktop-team-test-token";
+const workspace = {
+ id: "tw_test", name: "Proxy Test", createdAt: new Date().toISOString(), version: 1,
+ runtime: "member-franklin", role: "owner",
+ members: [{ userId: "0x1111111111111111111111111111111111111111", name: "0x1111…1111", role: "owner", joinedAt: new Date().toISOString() }],
+};
+const messages = [];
+const files = [{ path: "README.md", content: "# Proxy Test", bytes: 12, version: 1, updatedAt: new Date().toISOString(), updatedBy: workspace.members[0].userId }];
+let verifiedAuth = null;
+
+const json = (res, status, value, headers = {}) => {
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
+ res.end(JSON.stringify(value));
+};
+const readBody = async (req) => {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
+};
+const mockCloud = http.createServer(async (req, res) => {
+ if (req.url === "/api/try/auth/nonce") return json(res, 200, { nonce: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { "Set-Cookie": "franklin_try_nonce=test; Path=/; HttpOnly" });
+ if (req.url === "/api/try/auth/verify") {
+ const input = await readBody(req);
+ verifiedAuth = input;
+ return json(res, 200, { address: input.address }, { "Set-Cookie": "franklin_try_session=test-session; Path=/; HttpOnly" });
+ }
+ if (req.url !== "/api/try/team" || !String(req.headers.cookie || "").includes("franklin_try_session=test-session")) return json(res, 401, { error: "Not signed in" });
+ const input = await readBody(req);
+ if (input.action === "workspace.list") return json(res, 200, { workspaces: [workspace], wallet: workspace.members[0].userId });
+ if (input.action === "workspace.snapshot") return json(res, 200, { workspace, messages, files });
+ if (input.action === "message.append") {
+ const message = {
+ id: `tm_${messages.length + 1}`, role: input.role, authorId: workspace.members[0].userId,
+ authorName: input.role === "assistant" ? "Franklin · 0x1111…1111" : "0x1111…1111",
+ content: input.content, createdAt: new Date().toISOString(),
+ };
+ messages.push(message);
+ workspace.version += 1;
+ return json(res, 201, { message, version: workspace.version });
+ }
+ return json(res, 400, { error: "Unknown test action" });
+});
+
+const listen = (server) => new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server.address().port)));
+const remotePort = await listen(mockCloud);
+let proxyPort = 0;
+const child = spawn(process.execPath, [path.join(here, "server.mjs")], {
+ cwd: path.join(here, ".."),
+ env: {
+ ...process.env,
+ FRANKLIN_CLOUD_PORT: "0",
+ FRANKLIN_CLOUD_TOKEN: token,
+ FRANKLIN_TEAM_CLOUD_URL: `http://127.0.0.1:${remotePort}`,
+ FRANKLIN_TEAM_FAKE_AGENT: "1",
+ BLOCKRUN_WALLET_KEY: `0x${"11".repeat(32)}`,
+ FRANKLIN_RUNTIME_ENTRY: process.env.FRANKLIN_TEST_RUNTIME_ENTRY
+ || path.join(here, "..", "..", "..", "dist", "index.js"),
+ },
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
+});
+
+const ready = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("Team proxy did not report readiness")), 5_000);
+ child.on("message", (message) => {
+ if (message?.type !== "franklin:cloud-ready" || !Number.isInteger(message.port) || message.port < 1) return;
+ clearTimeout(timer);
+ proxyPort = message.port;
+ resolve();
+ });
+ child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Team proxy exited before readiness (${code})`)); });
+});
+
+try {
+ await ready;
+ for (let attempt = 0; attempt < 50; attempt++) {
+ try { if ((await fetch(`http://127.0.0.1:${proxyPort}/health`)).ok) break; }
+ catch { /* still starting */ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ const unauthorized = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "workspace.list" }) });
+ assert.equal(unauthorized.status, 401);
+
+ const hostileNullOrigin = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers: { "Content-Type": "application/json", Origin: "null", "X-Franklin-Desktop-Token": "wrong" }, body: JSON.stringify({ action: "workspace.list" }) });
+ assert.equal(hostileNullOrigin.status, 403);
+
+ const headers = { "Content-Type": "application/json", "X-Franklin-Desktop-Token": token };
+ const listed = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers, body: JSON.stringify({ action: "workspace.list" }) });
+ assert.equal(listed.status, 200);
+ assert.equal((await listed.json()).workspaces[0].id, workspace.id);
+ assert.match(verifiedAuth?.message || "", new RegExp(`127\\.0\\.0\\.1 wants you to sign in with your Ethereum account:\\n${verifiedAuth?.address}`));
+ assert.match(verifiedAuth?.message || "", new RegExp(`URI: http://127\\.0\\.0\\.1:${remotePort}\\nVersion: 1\\nChain ID: 8453`));
+ assert.match(verifiedAuth?.message || "", /Nonce: a{32}\nIssued At: /);
+ assert.match(verifiedAuth?.signature || "", /^0x[0-9a-f]{130}$/i);
+
+ const unknown = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team`, { method: "POST", headers, body: JSON.stringify({ action: "wallet.export" }) });
+ assert.equal(unknown.status, 400);
+
+ const turn = await fetch(`http://127.0.0.1:${proxyPort}/v1/franklin-team/agent-turn`, { method: "POST", headers, body: JSON.stringify({ workspaceId: workspace.id, content: "Summarize the workspace" }) });
+ assert.equal(turn.status, 201);
+ const result = await turn.json();
+ assert.equal(result.userMessage.content, "Summarize the workspace");
+ assert.match(result.assistant.content, /1 shared file/);
+ assert.equal(messages.length, 2);
+ console.log("team-proxy: token isolation, SIWE bridge, workspace list, and agent turn passed");
+} finally {
+ child.kill("SIGTERM");
+ await new Promise((resolve) => mockCloud.close(resolve));
+}
diff --git a/apps/desktop/cloud-server/test.mjs b/apps/desktop/cloud-server/test.mjs
new file mode 100644
index 00000000..a9f46e67
--- /dev/null
+++ b/apps/desktop/cloud-server/test.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+
+import fsp from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+// Colima only bind-mounts user directories by default. Keep Docker-provider
+// fixtures inside the checkout; the finally block removes them after each run.
+const testRoot = process.env.FRANKLIN_CLOUD_SANDBOX_PROVIDER === "docker" ? process.cwd() : os.tmpdir();
+const dataDir = await fsp.mkdtemp(path.join(testRoot, ".franklin-cloud-test-"));
+let base = "";
+const bootstrapKey = "e2e-private-preview-key";
+const server = spawn(process.execPath, [path.join(here, "server.mjs")], {
+ env: {
+ ...process.env,
+ FRANKLIN_CLOUD_PORT: "0",
+ FRANKLIN_CLOUD_DATA_DIR: dataDir,
+ FRANKLIN_CLOUD_FAKE_AGENT: "1",
+ FRANKLIN_CLOUD_BOOTSTRAP_KEY: bootstrapKey,
+ FRANKLIN_CLOUD_ALLOWED_ORIGINS: "http://localhost:5174",
+ },
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
+});
+
+let stderr = "";
+server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
+
+const ready = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`Cloud test server did not report readiness: ${stderr}`)), 5_000);
+ server.on("message", (message) => {
+ if (message?.type !== "franklin:cloud-ready" || !Number.isInteger(message.port) || message.port < 1) return;
+ clearTimeout(timer);
+ base = `http://127.0.0.1:${message.port}`;
+ resolve();
+ });
+ server.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Cloud test server exited before readiness (${code}): ${stderr}`)); });
+});
+
+async function waitForHealth() {
+ for (let attempt = 0; attempt < 50; attempt++) {
+ try {
+ const response = await fetch(`${base}/health`);
+ if (response.ok) return;
+ } catch { /* still starting */ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error(`Cloud test server did not start: ${stderr}`);
+}
+
+function runE2e() {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [path.join(here, "e2e.mjs")], {
+ env: { ...process.env, FRANKLIN_CLOUD_URL: base, FRANKLIN_CLOUD_BOOTSTRAP_KEY: bootstrapKey, FRANKLIN_CLOUD_DATA_DIR: dataDir },
+ stdio: "inherit",
+ });
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`E2E exited with ${code}`)));
+ });
+}
+
+try {
+ await ready;
+ await waitForHealth();
+ await runE2e();
+} finally {
+ server.kill("SIGTERM");
+ await fsp.rm(dataDir, { recursive: true, force: true });
+}
diff --git a/apps/desktop/dev-server/mock.mjs b/apps/desktop/dev-server/mock.mjs
index fa645404..10d8e4df 100644
--- a/apps/desktop/dev-server/mock.mjs
+++ b/apps/desktop/dev-server/mock.mjs
@@ -14,7 +14,19 @@
import { WebSocketServer } from "ws";
import http from "node:http";
-const PORT = Number(process.env.FRANKLIN_AGENT_PORT) || 3737;
+const requestedPort = Number(process.env.FRANKLIN_AGENT_PORT || 3737);
+if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535) throw new Error("FRANKLIN_AGENT_PORT must be an integer from 0 to 65535");
+const PORT = requestedPort;
+
+function allowedOrigin(origin) {
+ if (!origin) return true;
+ try {
+ const url = new URL(origin);
+ return (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
+ } catch {
+ return false;
+ }
+}
// ── Fake state ─────────────────────────────────────────────────────────────
@@ -31,14 +43,15 @@ const wallet = {
};
const models = [
- { id: "nvidia/deepseek-v4-flash", label: "DeepSeek V4 Flash", free: true, group: "Free" },
- { id: "nvidia/qwen3-coder-480b", label: "Qwen3 Coder 480B", free: true, group: "Free" },
- { id: "nvidia/llama-4-maverick", label: "Llama 4 Maverick", free: true, group: "Free" },
+ { id: "nvidia/nemotron-nano-9b-v2", label: "Nemotron Nano 9B v2", free: true, group: "Free" },
+ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni", free: true, group: "Free" },
+ { id: "nvidia/mistral-nemotron", label: "Mistral Nemotron", free: true, group: "Free" },
{ id: "anthropic/claude-opus-4.8", label: "Claude Opus 4.8", free: false, group: "Premium frontier" },
{ id: "anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", free: false, group: "Premium frontier" },
+ { id: "qwen/qwen3.7-max", label: "Qwen3.7 Max", free: false, group: "Premium frontier", contextWindow: 1000000 },
{ id: "openai/gpt-5.5", label: "GPT-5.5", free: false, group: "Premium frontier" },
{ id: "google/gemini-3.5-flash", label: "Gemini 3.5 Flash", free: false, group: "Reasoning" },
- { id: "anthropic/claude-haiku-4.5-20251001", label: "Claude Haiku 4.5", free: false, group: "Budget" },
+ { id: "anthropic/claude-haiku-4.5", label: "Claude Haiku 4.5", free: false, group: "Budget" },
];
function mkSession(title, lastUser) {
@@ -67,7 +80,12 @@ const server = http.createServer((req, res) => {
// ── WebSocket router ───────────────────────────────────────────────────────
-const wss = new WebSocketServer({ server, path: "/agent" });
+const wss = new WebSocketServer({
+ server,
+ path: "/agent",
+ maxPayload: 8 * 1024 * 1024,
+ verifyClient: (info) => allowedOrigin(info.origin || info.req.headers.origin),
+});
wss.on("connection", (ws) => {
console.log("[mock] client connected");
@@ -226,12 +244,12 @@ function chunkText(s, size) {
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
-server.listen(PORT, () => {
+server.listen(PORT, "127.0.0.1", () => {
const address = server.address();
- const effectivePort = address && typeof address === 'object' ? address.port : PORT;
- if (typeof process.send === 'function') process.send({ type: 'franklin:server-ready', port: effectivePort });
- console.log(`[mock] franklin agent server on http://localhost:${effectivePort}`);
- console.log(`[mock] WebSocket: ws://localhost:${effectivePort}/agent`);
+ const readyPort = address && typeof address !== "string" ? address.port : PORT;
+ console.log(`[mock] franklin agent server on http://localhost:${readyPort}`);
+ console.log(`[mock] WebSocket: ws://localhost:${readyPort}/agent`);
console.log(`[mock] Vite dev server proxies /agent → here. Run \`npm run dev:vite\` in another terminal,`);
console.log(`[mock] then open http://localhost:5173.`);
+ if (typeof process.send === "function") process.send({ type: "franklin:server-ready", port: readyPort });
});
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index a3c7ac3b..62b1686e 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -11,13 +11,20 @@
// real `franklin serve` agent server (or an in-process import of
// @blockrun/franklin) without touching the renderer.
-const { app, BrowserWindow, shell, ipcMain } = require("electron");
+const { app, BrowserWindow, shell, ipcMain, session } = require("electron");
const crypto = require("node:crypto");
const fs = require("node:fs");
+const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
-const { spawn } = require("node:child_process");
+const { spawn, execFile } = require("node:child_process");
const { pathToFileURL } = require("node:url");
+const {
+ externalHttpUrl,
+ loopbackHttpUrl,
+ sameOriginUrl,
+ trustedRendererUrl,
+} = require("./security.cjs");
// Is something already listening on a local port? Used so we don't double-spawn
// Franklin Canvas (a second `npm start` would hit EADDRINUSE on :3100 and, per
@@ -30,25 +37,41 @@ function isPortOpen(port) {
});
}
-const DEV_URL = process.env.FRANKLIN_DESKTOP_DEV_URL; // set by the desktop:dev script
-const CANVAS_URL = process.env.FRANKLIN_CANVAS_URL || "http://localhost:5173";
-const AGENT_TOKEN = DEV_URL ? "" : crypto.randomBytes(32).toString("base64url");
-const FILE_TOKEN = DEV_URL ? "" : crypto.randomBytes(32).toString("base64url");
-
-let agentPort = process.env.FRANKLIN_AGENT_PORT || (DEV_URL ? "3737" : "0");
-if (AGENT_TOKEN) process.env.FRANKLIN_SERVE_TOKEN = AGENT_TOKEN;
+const DEV_URL = process.env.FRANKLIN_DESKTOP_DEV_URL
+ ? loopbackHttpUrl(process.env.FRANKLIN_DESKTOP_DEV_URL, "FRANKLIN_DESKTOP_DEV_URL")
+ : null;
+function configuredPort(value, fallback, label) {
+ const parsed = Number(value === undefined || value === "" ? fallback : value);
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) throw new Error(`${label} must be an integer from 0 to 65535`);
+ return parsed;
+}
+const CONFIGURED_AGENT_PORT = configuredPort(process.env.FRANKLIN_AGENT_PORT, DEV_URL ? 3737 : 0, "FRANKLIN_AGENT_PORT");
+const CONFIGURED_CLOUD_PORT = configuredPort(process.env.FRANKLIN_CLOUD_PORT, 0, "FRANKLIN_CLOUD_PORT");
+const CLOUD_TOKEN = process.env.FRANKLIN_CLOUD_TOKEN || crypto.randomBytes(32).toString("base64url");
+const AGENT_TOKEN = process.env.FRANKLIN_SERVE_TOKEN || crypto.randomBytes(32).toString("base64url");
+const CANVAS_URL = loopbackHttpUrl(process.env.FRANKLIN_CANVAS_URL || "http://127.0.0.1:5173", "FRANKLIN_CANVAS_URL");
+const DIST_ROOT = path.join(__dirname, "..", "dist");
+
+// The preload and the loopback service inherit the same unguessable token.
+// This prevents an arbitrary web page from driving wallet-authenticated Team
+// operations just because it can reach localhost.
+process.env.FRANKLIN_CLOUD_TOKEN = CLOUD_TOKEN;
+process.env.FRANKLIN_SERVE_TOKEN = AGENT_TOKEN;
let win = null;
let startupWin = null;
let backend = null;
+let cloudBackend = null;
let canvas = null;
let canvasWin = null;
+let activeAgentPort = null;
+let activeCloudPort = null;
let quitting = false;
let backendReady = false;
const rendererReadyWaiters = new Map();
let startupState = {
status: "loading",
- message: "Preparing your secure workspace…",
+ message: "Preparing your secure workspace...",
};
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) app.exit(0);
@@ -61,50 +84,48 @@ function backendBaseEnvironment() {
return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
}
-function isSafeExternalUrl(raw) {
- try {
- const protocol = new URL(raw).protocol;
- return protocol === "https:" || protocol === "http:";
- } catch {
- return false;
- }
+function trustedMainRendererUrl(value) {
+ return trustedRendererUrl(value, { devUrl: DEV_URL, distRoot: DIST_ROOT });
}
-function sameOrigin(candidate, trusted) {
- try { return new URL(candidate).origin === new URL(trusted).origin; }
- catch { return false; }
+function trustedCanvasUrl(value) {
+ return sameOriginUrl(value, CANVAS_URL);
}
-function isTrustedMainRendererUrl(raw) {
- if (DEV_URL) return sameOrigin(raw, DEV_URL);
- return raw === pathToFileURL(path.join(__dirname, "..", "dist", "index.html")).toString();
+async function openExternal(value) {
+ const url = externalHttpUrl(value);
+ if (url) await shell.openExternal(url.href);
}
-function isTrustedStartupRendererUrl(raw) {
- return raw === pathToFileURL(path.join(__dirname, "startup.html")).toString();
-}
-
-function guardWindowNavigation(browserWindow, trustedUrl) {
+function guardWindowNavigation(browserWindow, isAllowed) {
browserWindow.webContents.on("will-navigate", (event, url) => {
- const allowed = trustedUrl.startsWith("file:") ? url === trustedUrl : sameOrigin(url, trustedUrl);
- if (!allowed) event.preventDefault();
+ if (isAllowed(url)) return;
+ event.preventDefault();
+ void openExternal(url);
});
+ browserWindow.webContents.on("will-attach-webview", (event) => event.preventDefault());
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
- if (isSafeExternalUrl(url)) void shell.openExternal(url);
+ void openExternal(url);
return { action: "deny" };
});
}
-function assertTrustedIpcSender(event) {
- if (!win || win.isDestroyed() || event.sender !== win.webContents || !isTrustedMainRendererUrl(event.sender.getURL())) {
- throw new Error("Rejected IPC from an untrusted renderer");
+function requireTrustedIpc(event) {
+ if (!win || win.isDestroyed() || event.sender !== win.webContents || event.senderFrame !== win.webContents.mainFrame) {
+ throw new Error("IPC request did not come from the Franklin main frame");
}
+ if (!trustedMainRendererUrl(event.senderFrame.url)) throw new Error("IPC request came from an untrusted renderer URL");
+}
+
+function trustedStartupRendererUrl(value) {
+ return value === pathToFileURL(path.join(__dirname, "startup.html")).href;
}
-function assertTrustedStartupIpcSender(event) {
- if (!startupWin || startupWin.isDestroyed() || event.sender !== startupWin.webContents || !isTrustedStartupRendererUrl(event.sender.getURL())) {
- throw new Error("Rejected startup IPC from an untrusted renderer");
+function requireTrustedStartupIpc(event) {
+ if (!startupWin || startupWin.isDestroyed() || event.sender !== startupWin.webContents || event.senderFrame !== startupWin.webContents.mainFrame) {
+ throw new Error("IPC request did not come from the Franklin startup frame");
}
+ if (!trustedStartupRendererUrl(event.senderFrame.url)) throw new Error("IPC request came from an untrusted startup URL");
}
function updateStartupState(status, message) {
@@ -136,11 +157,13 @@ function createStartupWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
+ webSecurity: true,
+ allowRunningInsecureContent: false,
+ navigateOnDragDrop: false,
},
});
- const startupUrl = pathToFileURL(path.join(__dirname, "startup.html")).toString();
- guardWindowNavigation(startupWin, startupUrl);
+ guardWindowNavigation(startupWin, trustedStartupRendererUrl);
startupWin.once("ready-to-show", () => {
if (startupWin && !startupWin.isDestroyed()) startupWin.show();
});
@@ -172,13 +195,85 @@ if (hasSingleInstanceLock) {
});
}
+const STUDIO_RUNTIME_SPECS = {
+ codex: {
+ command: "codex",
+ candidates: [
+ process.env.CODEX_PATH,
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
+ "/usr/local/bin/codex",
+ "/opt/homebrew/bin/codex",
+ path.join(os.homedir(), ".local", "bin", "codex"),
+ ],
+ },
+ claude: {
+ command: "claude",
+ candidates: [process.env.CLAUDE_PATH, "/usr/local/bin/claude", "/opt/homebrew/bin/claude"],
+ },
+ hermes: { command: "hermes", candidates: [process.env.HERMES_PATH] },
+ deepseek: { command: "dsh", candidates: [process.env.DSH_PATH] },
+};
+
+function execFileText(file, args, timeout = 5000) {
+ return new Promise((resolve) => {
+ execFile(file, args, { timeout, encoding: "utf8" }, (error, stdout, stderr) => {
+ resolve({ ok: !error, output: String(stdout || stderr || "").trim(), error: error ? String(error.message || error) : "" });
+ });
+ });
+}
+
+async function resolveStudioRuntime(id) {
+ const spec = STUDIO_RUNTIME_SPECS[id];
+ if (!spec) return null;
+ const candidates = [...spec.candidates.filter(Boolean)];
+ for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) candidates.push(path.join(dir, spec.command));
+ if (id === "claude") {
+ const nvmRoot = path.join(os.homedir(), ".nvm", "versions", "node");
+ try {
+ for (const version of fs.readdirSync(nvmRoot).sort().reverse()) candidates.push(path.join(nvmRoot, version, "bin", "claude"));
+ } catch { /* nvm is optional */ }
+ }
+ for (const candidate of candidates) {
+ try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* try next */ }
+ }
+ return null;
+}
+
+async function inspectStudioRuntime(id) {
+ const executable = await resolveStudioRuntime(id);
+ if (!executable) return { id, available: false, running: false };
+ const versionResult = await execFileText(executable, ["--version"], 5000);
+ const running = false;
+ return {
+ id,
+ available: true,
+ running,
+ path: executable,
+ version: versionResult.output.split("\n")[0] || "Detected",
+ lifecycleSupported: false,
+ };
+}
+
+async function scanStudioRuntimes() {
+ return Promise.all(Object.keys(STUDIO_RUNTIME_SPECS).map(inspectStudioRuntime));
+}
+
+async function startStudioRuntime(id) {
+ const detected = await inspectStudioRuntime(id);
+ return { ok: false, running: false, ...detected, error: "Runtime detected, but the Desktop protocol adapter is not implemented yet." };
+}
+
+async function stopStudioRuntime(id) {
+ return { ok: false, running: false, error: `The ${id} Desktop protocol adapter is not implemented yet.` };
+}
+
// Auto-start Franklin Canvas (its own backend :3100 + Vite UI :5173) so the
// embedded canvas mode "just works" — the user never juggles a second terminal
// or port. Dev only for now; packaging the canvas is a follow-up.
async function startCanvas() {
if (!DEV_URL) return; // dev only
// Already running (manual instance, or a previous launch)? Reuse it.
- const canvasPort = Number(new URL(CANVAS_URL).port) || 5173;
+ const canvasPort = Number(CANVAS_URL.port) || 5173;
if (await isPortOpen(canvasPort)) {
console.log(`[franklin-desktop] canvas already running on :${canvasPort} — reusing`);
return;
@@ -188,11 +283,12 @@ async function startCanvas() {
console.log("[franklin-desktop] franklin-canvas not found — skipping canvas auto-start");
return;
}
- canvas = spawn("npm", ["start"], {
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
+ canvas = spawn(npmCommand, ["start"], {
cwd: dir,
env: { ...process.env, FORCE_COLOR: "1" },
stdio: "inherit",
- shell: true, // npm is a shell script; resolve via PATH
+ shell: false,
});
canvas.on("exit", (code) => {
console.log(`[franklin-desktop] canvas exited (${code})`);
@@ -206,9 +302,8 @@ async function startCanvas() {
// The real backend is Franklin's `serve` server (drives the actual agent loop,
// wallet and tools). Set FRANKLIN_USE_MOCK=1 to fall back to the dev mock.
function resolveFranklinEntry() {
- // Packaged app: the agent runtime lives inside app.asar next to the
- // production dependencies electron-builder already collects. Keeping the
- // runtime there avoids shipping a second, full copy of node_modules.
+ // Packaged app: the prepared runtime is included in app.asar. Dev resolves
+ // the Franklin workspace dependency.
if (app.isPackaged) {
const bundled = path.join(app.getAppPath(), "franklin-agent", "dist", "index.js");
if (require("node:fs").existsSync(bundled)) return bundled;
@@ -222,68 +317,131 @@ function resolveFranklinEntry() {
}
function startBackend() {
- if (DEV_URL) return Promise.resolve(String(agentPort));
- const useMock = process.env.FRANKLIN_USE_MOCK === "1";
- if (app.isPackaged && useMock) {
- throw new Error("The development mock is disabled in packaged Franklin builds.");
+ if (DEV_URL) {
+ activeAgentPort = CONFIGURED_AGENT_PORT;
+ process.env.FRANKLIN_AGENT_PORT = String(activeAgentPort);
+ return Promise.resolve(activeAgentPort);
}
+ const useMock = process.env.FRANKLIN_USE_MOCK === "1";
const franklinEntry = useMock ? null : resolveFranklinEntry();
- if (!useMock && !franklinEntry) {
- throw new Error("The packaged Franklin agent runtime is missing.");
- }
- // Keep the default tool root out of the user's entire home directory. A user
- // can still select an explicit workspace with FRANKLIN_WORK_DIR.
const workDir = process.env.FRANKLIN_WORK_DIR
? path.resolve(process.env.FRANKLIN_WORK_DIR)
: path.join(app.getPath("documents"), "Franklin");
fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
- const [cmd, args] = franklinEntry
- ? [franklinEntry, ["serve", "--port", agentPort, "--work-dir", workDir]]
- : [path.join(__dirname, "..", "dev-server", "mock.mjs"), []];
- backend = spawn(process.execPath, [cmd, ...args], {
+ if (!franklinEntry && !useMock) {
+ return Promise.reject(new Error("Bundled Franklin runtime is missing; refusing to silently start the mock backend"));
+ }
+ const requestedPort = activeAgentPort ?? CONFIGURED_AGENT_PORT;
+ const [cmd, args] = useMock
+ ? [path.join(__dirname, "..", "dev-server", "mock.mjs"), []]
+ : [franklinEntry, ["serve", "--port", String(requestedPort), "--work-dir", workDir]];
+ const child = spawn(process.execPath, [cmd, ...args], {
cwd: workDir,
env: {
...backendBaseEnvironment(),
- FRANKLIN_AGENT_PORT: agentPort,
+ FRANKLIN_AGENT_PORT: String(requestedPort),
FRANKLIN_SERVE_TOKEN: AGENT_TOKEN,
- FRANKLIN_SERVE_FILE_TOKEN: FILE_TOKEN,
FRANKLIN_SERVE_ALLOW_NULL_ORIGIN: "1",
FRANKLIN_SERVE_DISCOVERY: "off",
FRANKLIN_CLOUD_SYNC: "off",
FRANKLIN_DESKTOP_WORKSPACE_BOUNDARY: "1",
ELECTRON_RUN_AS_NODE: "1", // run the Node entry under Electron's Node
},
- stdio: ["ignore", "inherit", "inherit", "ipc"],
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
+ });
+ backend = child;
+ child.on("exit", (code) => {
+ console.log(`[franklin-desktop] backend exited (${code})`);
+ if (backend === child) backend = null;
});
return new Promise((resolve, reject) => {
let ready = false;
- const timeout = setTimeout(() => {
- if (backend) backend.kill();
- reject(new Error("Franklin agent server did not become ready in time."));
- }, 20_000);
- backend.once("error", (error) => {
- clearTimeout(timeout);
- reject(error);
+ const timer = setTimeout(() => {
+ if (backend === child) backend = null;
+ child.kill();
+ reject(new Error("Franklin agent server did not become ready"));
+ }, 15_000);
+ child.on("message", (message) => {
+ if (message?.type !== "franklin:server-ready") return;
+ const readyPort = configuredPort(message.port, -1, "Franklin ready port");
+ if (readyPort < 1) return;
+ clearTimeout(timer);
+ ready = true;
+ activeAgentPort = readyPort;
+ process.env.FRANKLIN_AGENT_PORT = String(readyPort);
+ resolve(readyPort);
});
- backend.on("message", (message) => {
- if (ready || !message || message.type !== "franklin:server-ready") return;
- const reported = Number(message.port);
- if (!Number.isInteger(reported) || reported <= 0 || reported > 65535) {
- clearTimeout(timeout);
- backend.kill();
- reject(new Error("Franklin agent server reported an invalid port."));
- return;
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
+ child.once("exit", (code) => {
+ if (!ready) {
+ clearTimeout(timer);
+ reject(new Error(`Franklin agent server exited before readiness (${code})`));
}
+ });
+ });
+}
+
+async function switchWalletChain(chain) {
+ if (chain !== "base" && chain !== "solana") throw new Error("Unsupported wallet network");
+ const blockrunDir = path.join(app.getPath("home"), ".blockrun");
+ fs.mkdirSync(blockrunDir, { recursive: true, mode: 0o700 });
+ fs.writeFileSync(path.join(blockrunDir, "payment-chain"), `${chain}\n`, { mode: 0o600 });
+ fs.chmodSync(path.join(blockrunDir, "payment-chain"), 0o600);
+ if (!DEV_URL && backend) {
+ const previous = backend;
+ backend = null;
+ await new Promise((resolve) => {
+ const timer = setTimeout(resolve, 2500);
+ previous.once("exit", () => { clearTimeout(timer); resolve(); });
+ previous.kill();
+ });
+ await startBackend();
+ }
+ return { ok: true, chain };
+}
+
+function startCloudBackend() {
+ const entry = path.join(__dirname, "..", "cloud-server", "server.mjs");
+ const requestedPort = activeCloudPort ?? CONFIGURED_CLOUD_PORT;
+ cloudBackend = spawn(process.execPath, [entry], {
+ cwd: app.getPath("home"),
+ env: {
+ ...process.env,
+ FRANKLIN_CLOUD_PORT: String(requestedPort),
+ FRANKLIN_CLOUD_TOKEN: CLOUD_TOKEN,
+ FRANKLIN_RUNTIME_ENTRY: resolveFranklinEntry() || "",
+ ELECTRON_RUN_AS_NODE: "1",
+ },
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
+ });
+ cloudBackend.on("exit", (code) => {
+ console.log(`[franklin-desktop] cloud workspace backend exited (${code})`);
+ cloudBackend = null;
+ });
+ const child = cloudBackend;
+ return new Promise((resolve, reject) => {
+ let ready = false;
+ const timer = setTimeout(() => {
+ if (cloudBackend === child) cloudBackend = null;
+ child.kill();
+ reject(new Error("Franklin Team sidecar did not become ready"));
+ }, 15_000);
+ child.on("message", (message) => {
+ if (message?.type !== "franklin:cloud-ready") return;
+ const readyPort = configuredPort(message.port, -1, "Franklin Team ready port");
+ if (readyPort < 1) return;
+ clearTimeout(timer);
ready = true;
- clearTimeout(timeout);
- resolve(String(reported));
+ activeCloudPort = readyPort;
+ process.env.FRANKLIN_CLOUD_PORT = String(readyPort);
+ resolve(readyPort);
});
- backend.on("exit", (code) => {
- clearTimeout(timeout);
- console.log(`[franklin-desktop] backend exited (${code})`);
- backend = null;
- if (!ready) reject(new Error(`Franklin agent server exited before readiness (${code}).`));
- else if (!quitting) app.quit();
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
+ child.once("exit", (code) => {
+ if (!ready) {
+ clearTimeout(timer);
+ reject(new Error(`Franklin Team sidecar exited before readiness (${code})`));
+ }
});
});
}
@@ -307,12 +465,19 @@ async function openCanvasWindow() {
// Normal native title bar (NOT hiddenInset) — the canvas app has no custom
// drag region, so it needs the OS title bar to be movable.
titleBarStyle: "default",
- webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webSecurity: true,
+ allowRunningInsecureContent: false,
+ navigateOnDragDrop: false,
+ },
});
- guardWindowNavigation(canvasWin, CANVAS_URL);
+ guardWindowNavigation(canvasWin, trustedCanvasUrl);
canvasWin.on("closed", () => { canvasWin = null; });
for (let i = 0; i < 40; i++) {
- try { await canvasWin.loadURL(CANVAS_URL); return; }
+ try { await canvasWin.loadURL(CANVAS_URL.href); return; }
catch { await new Promise((r) => setTimeout(r, 300)); }
}
}
@@ -322,13 +487,13 @@ async function loadRenderer(targetWindow) {
// Vite may still be coming up — retry until it answers.
for (let i = 0; i < 40; i++) {
try {
- await targetWindow.loadURL(DEV_URL);
+ await targetWindow.loadURL(DEV_URL.href);
return;
} catch {
await new Promise((r) => setTimeout(r, 300));
}
}
- await targetWindow.loadURL(DEV_URL); // final attempt; let the error surface
+ await targetWindow.loadURL(DEV_URL.href); // final attempt; let the error surface
} else {
await targetWindow.loadFile(path.join(__dirname, "..", "dist", "index.html"));
}
@@ -340,7 +505,7 @@ function createWindow() {
height: 800,
minWidth: 720,
minHeight: 480,
- backgroundColor: "#f7f6f1",
+ backgroundColor: "#ffffff",
show: false,
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
webPreferences: {
@@ -348,16 +513,16 @@ function createWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
+ webSecurity: true,
+ allowRunningInsecureContent: false,
+ navigateOnDragDrop: false,
},
});
- const trustedRendererUrl = DEV_URL || pathToFileURL(path.join(__dirname, "..", "dist", "index.html")).toString();
- guardWindowNavigation(win, trustedRendererUrl);
-
+ // Open target=_blank / external links in the system browser, not a new window.
+ guardWindowNavigation(win, trustedMainRendererUrl);
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
- if (isMainFrame) {
- console.error("[franklin-desktop] renderer load failed", { errorCode, errorDescription, validatedURL });
- }
+ if (isMainFrame) console.error("[franklin-desktop] renderer load failed", { errorCode, errorDescription, validatedURL });
});
win.webContents.on("render-process-gone", (_event, details) => {
console.error("[franklin-desktop] renderer process exited", details);
@@ -385,7 +550,7 @@ function waitForRendererContent(targetWindow) {
callback();
};
const timeout = setTimeout(() => {
- finish(() => reject(new Error("Franklin’s interface did not become ready in time.")));
+ finish(() => reject(new Error("Franklin's interface did not become ready in time.")));
}, 15_000);
rendererReadyWaiters.set(webContentsId, () => finish(resolve));
targetWindow.once("closed", () => {
@@ -401,7 +566,6 @@ async function openMainWindow() {
closeStartupWindow();
return;
}
-
const mainWindow = createWindow();
const rendererReady = waitForRendererContent(mainWindow);
await loadRenderer(mainWindow);
@@ -414,27 +578,29 @@ async function openMainWindow() {
async function startApplication() {
createStartupWindow();
- updateStartupState("loading", "Preparing your secure workspace…");
- agentPort = await startBackend();
+ updateStartupState("loading", "Preparing your secure workspace...");
+ await Promise.all([startBackend(), startCloudBackend()]);
backendReady = true;
- process.env.FRANKLIN_AGENT_PORT = agentPort;
- updateStartupState("loading", "Opening Franklin…");
+ updateStartupState("loading", "Opening Franklin...");
void startCanvas();
await openMainWindow();
}
app.whenReady().then(() => {
if (!hasSingleInstanceLock) return;
- ipcMain.handle("franklin:open-canvas", (event) => {
- assertTrustedIpcSender(event);
- return openCanvasWindow();
- });
+ session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
+ session.defaultSession.setPermissionCheckHandler(() => false);
+ ipcMain.handle("franklin:open-canvas", (event) => { requireTrustedIpc(event); return openCanvasWindow(); });
+ ipcMain.handle("franklin:studio-scan", (event) => { requireTrustedIpc(event); return scanStudioRuntimes(); });
+ ipcMain.handle("franklin:studio-start", (event, id) => { requireTrustedIpc(event); return startStudioRuntime(String(id)); });
+ ipcMain.handle("franklin:studio-stop", (event, id) => { requireTrustedIpc(event); return stopStudioRuntime(String(id)); });
+ ipcMain.handle("franklin:wallet-switch", (event, chain) => { requireTrustedIpc(event); return switchWalletChain(String(chain)); });
ipcMain.on("franklin:renderer-ready", (event) => {
- assertTrustedIpcSender(event);
+ requireTrustedIpc(event);
rendererReadyWaiters.get(event.sender.id)?.();
});
ipcMain.handle("franklin:startup-retry", (event) => {
- assertTrustedStartupIpcSender(event);
+ requireTrustedStartupIpc(event);
app.relaunch();
app.exit(0);
});
@@ -443,7 +609,7 @@ app.whenReady().then(() => {
if (win && !win.isDestroyed()) win.destroy();
win = null;
createStartupWindow();
- updateStartupState("error", "Franklin couldn’t start. Please try again.");
+ updateStartupState("error", "Franklin couldn't start. Please try again.");
});
app.on("activate", () => {
if (!backendReady) {
@@ -456,10 +622,10 @@ app.whenReady().then(() => {
return;
}
createStartupWindow();
- updateStartupState("loading", "Opening Franklin…");
+ updateStartupState("loading", "Opening Franklin...");
void openMainWindow().catch((error) => {
console.error("[franklin-desktop] failed to reopen window", error);
- updateStartupState("error", "Franklin couldn’t open its window. Please try again.");
+ updateStartupState("error", "Franklin couldn't open its window. Please try again.");
});
});
});
@@ -472,5 +638,6 @@ app.on("before-quit", () => { quitting = true; });
app.on("quit", () => {
if (backend) backend.kill();
+ if (cloudBackend) cloudBackend.kill();
if (canvas) canvas.kill();
});
diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs
index c47a6f3b..f59656bc 100644
--- a/apps/desktop/electron/preload.cjs
+++ b/apps/desktop/electron/preload.cjs
@@ -5,11 +5,6 @@
const { contextBridge, clipboard, nativeImage, ipcRenderer } = require("electron");
-const port = process.env.FRANKLIN_AGENT_PORT || "3737";
-const token = process.env.FRANKLIN_SERVE_TOKEN || "";
-const agentUrl = new URL(`ws://127.0.0.1:${port}/agent`);
-if (token) agentUrl.searchParams.set("token", token);
-
// `ready-to-show` can fire before React paints meaningful content. Wait for two
// animation frames after DOMContentLoaded, then let the main process replace
// the startup window. This keeps a blank Electron surface off screen.
@@ -19,11 +14,27 @@ window.addEventListener("DOMContentLoaded", () => {
});
}, { once: true });
+function readyPort(value, label) {
+ const parsed = Number(value);
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) throw new Error(`${label} is not ready`);
+ return parsed;
+}
+const port = readyPort(process.env.FRANKLIN_AGENT_PORT, "Franklin agent port");
+const cloudPort = readyPort(process.env.FRANKLIN_CLOUD_PORT, "Franklin Team port");
+const agentToken = process.env.FRANKLIN_SERVE_TOKEN || "";
+const agentQuery = agentToken ? `?token=${encodeURIComponent(agentToken)}` : "";
+
contextBridge.exposeInMainWorld("__FRANKLIN__", {
- agentUrl: agentUrl.toString(),
+ agentUrl: `ws://127.0.0.1:${port}/agent${agentQuery}`,
+ cloudUrl: `http://127.0.0.1:${cloudPort}`,
+ cloudToken: process.env.FRANKLIN_CLOUD_TOKEN || "",
// Franklin Canvas (node-based media studio) opens in its own native window;
// Electron auto-starts the canvas server/UI, so there's nothing to run by hand.
openCanvas: () => ipcRenderer.invoke("franklin:open-canvas"),
+ scanAgentRuntimes: () => ipcRenderer.invoke("franklin:studio-scan"),
+ startAgentRuntime: (id) => ipcRenderer.invoke("franklin:studio-start", id),
+ stopAgentRuntime: (id) => ipcRenderer.invoke("franklin:studio-stop", id),
+ switchWalletChain: (chain) => ipcRenderer.invoke("franklin:wallet-switch", chain),
// Native clipboard — navigator.clipboard is unreliable inside Electron, so the
// renderer prefers this when present.
copy: (text) => {
diff --git a/apps/desktop/electron/security.cjs b/apps/desktop/electron/security.cjs
new file mode 100644
index 00000000..b16e0d00
--- /dev/null
+++ b/apps/desktop/electron/security.cjs
@@ -0,0 +1,54 @@
+const path = require("node:path");
+const { fileURLToPath } = require("node:url");
+
+const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
+
+function parsedUrl(value) {
+ try { return new URL(String(value)); }
+ catch { return null; }
+}
+
+function loopbackHttpUrl(value, label = "URL") {
+ const url = parsedUrl(value);
+ if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) {
+ throw new Error(`${label} must be an HTTP(S) URL`);
+ }
+ if (!LOOPBACK_HOSTS.has(url.hostname) || url.username || url.password) {
+ throw new Error(`${label} must target loopback without credentials`);
+ }
+ return url;
+}
+
+function externalHttpUrl(value) {
+ const url = parsedUrl(value);
+ if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return null;
+ if (url.username || url.password) return null;
+ return url;
+}
+
+function sameOriginUrl(value, allowed) {
+ const url = parsedUrl(value);
+ return Boolean(url && allowed && url.origin === allowed.origin);
+}
+
+function pathInside(root, candidate) {
+ const base = path.resolve(root);
+ const target = path.resolve(candidate);
+ return target === base || target.startsWith(`${base}${path.sep}`);
+}
+
+function trustedRendererUrl(value, { devUrl, distRoot }) {
+ const url = parsedUrl(value);
+ if (!url) return false;
+ if (devUrl) return sameOriginUrl(url.href, devUrl);
+ if (url.protocol !== "file:" || url.username || url.password) return false;
+ try { return pathInside(distRoot, fileURLToPath(url)); }
+ catch { return false; }
+}
+
+module.exports = {
+ externalHttpUrl,
+ loopbackHttpUrl,
+ sameOriginUrl,
+ trustedRendererUrl,
+};
diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js
index 42a1e118..5afc5971 100644
--- a/apps/desktop/eslint.config.js
+++ b/apps/desktop/eslint.config.js
@@ -3,6 +3,7 @@ import typescriptParser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
const browserGlobals = {
+ AbortSignal: "readonly",
Blob: "readonly",
File: "readonly",
FileReader: "readonly",
diff --git a/apps/desktop/index.html b/apps/desktop/index.html
index c83d95ef..8fb73e50 100644
--- a/apps/desktop/index.html
+++ b/apps/desktop/index.html
@@ -2,9 +2,9 @@
-
+
Franklin
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index e246f76c..f3f5875c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,22 +1,32 @@
{
"name": "@blockrun/franklin-desktop",
- "version": "0.1.3-beta.1",
+ "version": "0.2.0-beta.1",
"description": "Franklin Desktop — native desktop app for the Franklin agent (Electron). Same agent, wallet and tools as the CLI, in a polished window.",
"license": "Apache-2.0",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
- "dev": "concurrently -k -n server,vite -c green,cyan \"npm:dev:server\" \"npm:dev:vite\"",
+ "dev": "concurrently -k -n server,cloud,vite -c green,yellow,cyan \"npm:dev:server\" \"npm:dev:cloud\" \"npm:dev:vite\"",
"dev:vite": "vite",
"dev:server": "node dev-server/mock.mjs",
+ "dev:cloud": "node cloud-server/server.mjs",
+ "dev:desktop-web": "concurrently -k -n server,vite -c green,cyan \"npm:dev:server\" \"npm:dev:vite\"",
+ "dev:desktop-real-web": "concurrently -k -n agent,vite -c green,cyan \"npm:dev:agent\" \"npm:dev:vite\"",
+ "test:cloud": "node cloud-server/test.mjs",
+ "test:team-proxy": "node cloud-server/team-proxy.test.mjs",
+ "test:security": "node --test test/*.test.cjs",
+ "test": "npm run test:security && npm run test:cloud && npm run test:team-proxy",
+ "cloud:build-sandbox": "docker build -f cloud-server/Dockerfile.sandbox -t franklin-cloud-sandbox:local cloud-server",
+ "test:cloud:docker": "npm run cloud:build-sandbox && FRANKLIN_CLOUD_SANDBOX_PROVIDER=docker npm run test:cloud",
+ "cloud:compose:up": "docker compose -f cloud-server/compose.yml up --build",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc -b --noEmit",
"dev:agent": "FRANKLIN_DESKTOP_WORKSPACE_BOUNDARY=1 node ../../dist/index.js serve --port 3737",
- "dev:real": "concurrently -k -n agent,vite -c green,cyan \"npm run dev:agent\" \"vite\"",
- "desktop:dev": "concurrently -k -n web,electron -c green,magenta \"npm run dev\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"",
- "desktop:real": "concurrently -k -n web,electron -c green,magenta \"npm run dev:real\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_AGENT_PORT=3737 FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"",
+ "dev:real": "concurrently -k -n agent,cloud,vite -c green,yellow,cyan \"npm run dev:agent\" \"npm run dev:cloud\" \"vite\"",
+ "desktop:dev": "concurrently -k -n web,electron -c green,magenta \"npm run dev:desktop-web\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"",
+ "desktop:real": "concurrently -k -n web,electron -c green,magenta \"npm run dev:desktop-real-web\" \"ELECTRON_RUN_AS_NODE= FRANKLIN_DESKTOP_DEV_URL=http://localhost:5174 electron electron/main.cjs\"",
"desktop": "npm run build && ELECTRON_RUN_AS_NODE= electron electron/main.cjs",
"prepare:runtime": "node scripts/prepare-runtime.mjs",
"dist:mac": "npm run build && npm run prepare:runtime && electron-builder --mac --arm64 --publish never",
@@ -41,9 +51,9 @@
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.68.0",
"@vitejs/plugin-react": "^5.0.0",
- "concurrently": "^9.1.0",
+ "concurrently": "^9.2.4",
"electron": "44.0.0",
- "electron-builder": "^26.8.1",
+ "electron-builder": "^26.15.3",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"shadcn": "^4.8.3",
@@ -51,7 +61,7 @@
"tw-animate-css": "^1.4.0",
"ts-api-utils": "2.4.0",
"typescript": "^5.9.3",
- "vite": "^7.0.0",
+ "vite": "^7.3.5",
"ws": "^8.18.0"
},
"engines": {
@@ -67,6 +77,7 @@
"dist/**",
"electron/**",
"franklin-agent/**",
+ "cloud-server/server.mjs",
"package.json",
"!node_modules/@blockrun/franklin",
"!node_modules/@blockrun/franklin/**"
diff --git a/apps/desktop/src/components/ActivitySummary.tsx b/apps/desktop/src/components/ActivitySummary.tsx
index 4a58977b..0ae839d9 100644
--- a/apps/desktop/src/components/ActivitySummary.tsx
+++ b/apps/desktop/src/components/ActivitySummary.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { ChevronDown, Search, Globe } from "lucide-react";
import type { ChatActivity } from "../hooks/use-franklin-chat";
import { useTryLang } from "../lib/i18n";
+import { safeExternalHttpUrl } from "../lib/external-url";
// Collapsed recap of a finished tool run — "searched N keywords · M sources",
// expandable to show the queries and the pages Franklin referenced.
@@ -34,12 +35,13 @@ export function ActivitySummary({ activity }: { activity: ChatActivity }) {
)}
{activity.sources.length > 0 && (
)}
diff --git a/apps/desktop/src/components/AgentsPanel.tsx b/apps/desktop/src/components/AgentsPanel.tsx
new file mode 100644
index 00000000..45b7d492
--- /dev/null
+++ b/apps/desktop/src/components/AgentsPanel.tsx
@@ -0,0 +1,167 @@
+import {
+ Blocks, Bot, Box, ChevronRight, CircleStop, Download, Network,
+ Play, Power, RotateCcw, Settings2, ShieldCheck, Terminal, Trash2, Users,
+} from "lucide-react";
+import type { AgentId, StudioAgent } from "../hooks/use-studio-registry";
+
+interface Props {
+ agents: StudioAgent[];
+ installedCount: number;
+ connectedCount: number;
+ teamModeEnabled: boolean;
+ scanning: boolean;
+ onScan: () => void | Promise;
+ onImport: (id: AgentId) => void | Promise;
+ onRemove: (id: AgentId) => void | Promise;
+ onRunning: (id: AgentId, running: boolean) => void | Promise;
+ onBlockRun: (id: AgentId, enabled: boolean) => void;
+ onTeamMode: (enabled: boolean) => void;
+}
+
+function Toggle({ checked, onChange, label, disabled = false }: { checked: boolean; onChange: (checked: boolean) => void; label: string; disabled?: boolean }) {
+ return (
+ onChange(!checked)}
+ >
+
+
+ );
+}
+
+function AgentMark({ id }: { id: AgentId }) {
+ const labels: Record = { franklin: "F", codex: "C", claude: "A", hermes: "H", deepseek: "D" };
+ return {labels[id]} ;
+}
+
+export function AgentsPanel({
+ agents, installedCount, connectedCount, teamModeEnabled, onImport, onRemove,
+ onRunning, onBlockRun, onTeamMode, scanning, onScan,
+}: Props) {
+ return (
+
+
+
+
+
Agent studio
+
Franklin
+
Bring your own agent. Keep one workspace, one capability layer and one wallet.
+
+
void onScan()}
+ >
+ {scanning ? "Scanning…" : "Scan local CLIs"}
+
+
+
+
+
{installedCount} Agent runtimes
+
{connectedCount} Using BlockRun
+
1 Shared workspace
+
Local Wallet broker
+
+
+
+
+
+
Agent runtimes
+
Each CLI is an adapter. Install, stop or remove it without changing Franklin.
+
+
{installedCount} installed
+
+
+
+ {agents.map((agent) => (
+
+
+
+
+
+ {agent.name}
+ {agent.builtIn && Built in }
+ {agent.experimental && Experimental }
+
+
{agent.command}
+
+
+ {agent.running ? "Running" : agent.installed ? "Stopped" : scanning ? "Checking" : agent.available ? "Detected" : agent.available === false ? "Not found" : "Available"}
+
+
+
+ {agent.description}
+ {agent.protocol}
+ {(agent.version || agent.endpoint) && {agent.version} {agent.endpoint && {agent.endpoint}}
}
+ {agent.error && {agent.error}
}
+
+ {agent.installed ? (
+ <>
+
+
+ BlockRun Router · Models · MCP · Wallet
+ {agent.id === "franklin"
+ ? onBlockRun(agent.id, enabled)} label={`BlockRun for ${agent.name}`} disabled />
+ : Adapter pending }
+
+ {!agent.builtIn &&
+
onRunning(agent.id, !agent.running)}>
+ {agent.running ? : }
+ {agent.running ? "Stop" : "Start"}
+
+
Configure
+ {!agent.builtIn &&
onRemove(agent.id)}> Remove }
+
}
+ >
+ ) : (
+ void onImport(agent.id)}>
+ {agent.available === false ? "CLI not found" : agent.lifecycleSupported === false ? "Adapter pending" : "Import runtime"}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
Studio modules
+
Product modes are modules too. Turn them on only when this workspace needs them.
+
+
+
+
+
+ Team Mode Shared conversations, files, knowledge and reusable workflows.
+ Workspace module
+
+
+
+
+ BlockRun Capability Broker One local router and wallet policy shared safely by every imported agent.
+ Planned
+ Restart
+
+
+
+
Terminal fallback PTY compatibility for CLIs that do not expose a structured protocol.
+
Planned
+
Enable
+
+
+
+
+
+
+ Live runtime registry. Franklin now detects installed CLIs and manages the Codex app-server lifecycle. Router injection for imported agents remains a separate adapter layer.
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/CloudWorkspaceDrawer.tsx b/apps/desktop/src/components/CloudWorkspaceDrawer.tsx
new file mode 100644
index 00000000..306d4664
--- /dev/null
+++ b/apps/desktop/src/components/CloudWorkspaceDrawer.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react";
+import { Check, Cloud, Copy, Laptop2, Users, X } from "lucide-react";
+import type { CloudMember } from "../hooks/use-cloud-workspace";
+
+const memberAvatar = (name: string) => name.startsWith("0x") ? name.slice(2, 4).toUpperCase() : name.slice(0, 2).toUpperCase();
+
+interface Props {
+ open: boolean;
+ workspaceName: string;
+ workspaceRole: CloudMember["role"];
+ members: CloudMember[];
+ sessionId?: string;
+ onClose: () => void;
+ onUpdateMemberRole: (userId: string, role: "admin" | "member" | "viewer") => Promise;
+ onCreateInvite: (role: "member" | "viewer") => Promise<{ invite: { code: string } }>;
+}
+
+export function CloudWorkspaceDrawer({ open, workspaceName, workspaceRole, members, sessionId, onClose, onUpdateMemberRole, onCreateInvite }: Props) {
+ const [inviteRole, setInviteRole] = useState<"member" | "viewer">("member");
+ const [inviteCode, setInviteCode] = useState(null);
+ const [copied, setCopied] = useState(false);
+ if (!open) return null;
+
+ const copyInvite = async () => {
+ if (!inviteCode) return;
+ await navigator.clipboard.writeText(inviteCode).catch(() => undefined);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1200);
+ };
+
+ return <>
+
+
+
+
+
This Mac · member funded Each turn runs on the initiating member's local Franklin and wallet. Shared context and answers sync to Franklin Cloud.
+
MEMBERS {members.length}
+
{members.map((member) =>
{memberAvatar(member.name)} {member.name}{member.userId === sessionId ? " · You" : ""} {workspaceRole === "owner" && member.role !== "owner" ? void onUpdateMemberRole(member.userId, event.target.value as "admin" | "member" | "viewer")}>Admin Member Viewer : {member.role} }
)}
+ {(workspaceRole === "owner" || workspaceRole === "admin") &&
setInviteRole(event.target.value as "member" | "viewer")}>Can chat & edit View only setInviteCode((await onCreateInvite(inviteRole)).invite.code)}> Invite
}
+ {inviteCode &&
One-time invite {inviteCode} void copyInvite()}>{copied ? : }
}
+
Cloud Franklin Not provisioned · Team turns currently require a member's Desktop.
Coming next
+
+
+ >;
+}
diff --git a/apps/desktop/src/components/CloudWorkspacePanel.tsx b/apps/desktop/src/components/CloudWorkspacePanel.tsx
new file mode 100644
index 00000000..51b590c3
--- /dev/null
+++ b/apps/desktop/src/components/CloudWorkspacePanel.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useRef, useState } from "react";
+import { Check, Cloud, FileText, FolderOpen, Laptop2, Loader2, MessageSquare, Plus, RefreshCw, Send, ShieldCheck, Users, X } from "lucide-react";
+import { useCloudWorkspace } from "../hooks/use-cloud-workspace";
+import { MessageContent } from "./MessageContent";
+import { publishTeamWorkspaceNav, subscribeTeamWorkspaceRequest } from "../lib/team-workspace-events";
+import { CloudWorkspaceDrawer } from "./CloudWorkspaceDrawer";
+
+const friendlyCloudError = (message: string) => message.toLowerCase().includes("fetch failed")
+ ? "Team Cloud is temporarily unavailable. Your shared data is safe—try again in a moment."
+ : message;
+
+function CloudRetryError({ message, className = "", onRetry }: { message: string; className?: string; onRetry: () => void }) {
+ return {friendlyCloudError(message)} Retry
;
+}
+
+export function CloudWorkspacePanel() {
+ const cloud = useCloudWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("BlockRun Cloud Workspace");
+ const [inviteInput, setInviteInput] = useState("");
+ const [message, setMessage] = useState("");
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [fileContent, setFileContent] = useState("");
+ const [fileSavedContent, setFileSavedContent] = useState("");
+ const [fileDraft, setFileDraft] = useState("");
+ const [savingFile, setSavingFile] = useState(false);
+ const [workspaceView, setWorkspaceView] = useState<"chat" | "files">("chat");
+ const [fileStatus, setFileStatus] = useState<"idle" | "loading" | "saved">("idle");
+ const [membersOpen, setMembersOpen] = useState(false);
+ const scrollRef = useRef(null);
+
+ useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [cloud.messages, cloud.sending]);
+ useEffect(() => publishTeamWorkspaceNav({
+ items: cloud.workspaces.map((workspace) => ({ id: workspace.id, name: workspace.name, role: workspace.role, memberCount: workspace.members.length, version: workspace.version })),
+ activeId: cloud.activeId,
+ loading: cloud.loading && !cloud.session,
+ }), [cloud.activeId, cloud.loading, cloud.session, cloud.workspaces]);
+ useEffect(() => subscribeTeamWorkspaceRequest(cloud.setActiveId), [cloud.setActiveId]);
+
+ const openFile = async (path: string) => {
+ setSelectedFile(path);
+ setWorkspaceView("files");
+ setFileStatus("loading");
+ try {
+ const content = (await cloud.readFile(path)).content;
+ setFileContent(content);
+ setFileSavedContent(content);
+ setFileStatus("idle");
+ }
+ catch (error) {
+ setFileContent(error instanceof Error ? error.message : String(error));
+ setFileStatus("idle");
+ }
+ };
+
+ const createFile = async () => {
+ const path = fileDraft.trim();
+ if (!path) return;
+ const content = `# ${path}\n\nCreated by ${cloud.session?.name}.\n`;
+ setSavingFile(true);
+ try {
+ await cloud.saveFile(path, content);
+ setFileDraft("");
+ await openFile(path);
+ setFileStatus("saved");
+ } finally { setSavingFile(false); }
+ };
+
+ if (!cloud.session) {
+ return (
+
+
+
+
FRANKLIN CLOUD · TEAM WORKSPACE
+
{cloud.connected ? "Connect your Franklin wallet" : "Start Franklin to continue"}
+
Your local Franklin signs in to franklin.run with SIWE. The wallet key never leaves this computer; Team data is stored in the existing Franklin Cloud.
+
void cloud.connect()}>
+ {cloud.loading ? : } {cloud.connected ? "Connect Franklin Cloud" : "Franklin is offline"}
+
+ {cloud.error &&
{friendlyCloudError(cloud.error)}
}
+
+
+ );
+ }
+
+ if (!cloud.active) {
+ return (
+
+
+
{cloud.session.name.slice(2, 3).toUpperCase()} {cloud.session.name} Wallet-authenticated Franklin Cloud
+
CREATE OR JOIN
+
Start a shared Cloud Workspace
+
+ {cloud.workspaces.length > 0 &&
Your workspaces {cloud.workspaces.map((workspace) => cloud.setActiveId(workspace.id)}> {workspace.name}{workspace.role} )}}
+ {cloud.error &&
{friendlyCloudError(cloud.error)}
}
+
+
+ );
+ }
+
+ const submit = () => {
+ const content = message.trim();
+ if (!content || cloud.sending) return;
+ setMessage("");
+ void cloud.sendMessage(content);
+ };
+
+ const selectedFileMeta = cloud.files.find((file) => file.path === selectedFile);
+ const fileIsDirty = selectedFile !== null && fileContent !== fileSavedContent;
+ const enterFiles = () => {
+ if (!selectedFile && cloud.files[0]) void openFile(cloud.files[0].path);
+ else setWorkspaceView("files");
+ };
+
+ return (
+
+
+
+
+ {workspaceView === "chat" ? (
+
+
+ {cloud.messages.length === 0 ?
Talk with your Team Franklin Shared files and recent team messages are added to the initiating member's Franklin context.
setMessage("Review the shared workspace and give the team a concise project status.")}>Create a workspace status update Open shared files
: cloud.messages.map((item) =>
{item.authorName} {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
)}
+ {cloud.sending &&
Team Franklin is working Reading shared context from workspace v{cloud.active.version}…
}
+
+
+ {cloud.error && void cloud.refreshActive()} />}
+
+ ) : (
+
+
+
+
+ FILES {cloud.files.length}
+
+ {cloud.files.length === 0 &&
No shared files yet
}
+ {cloud.files.map((file) =>
void openFile(file.path)}>{file.path} v{file.version} · {file.bytes} B )}
+
+ {cloud.active.role !== "viewer" && }
+ Markdown, text, JSON and project notes are shared with the workspace.
+
+
+ {selectedFile ? <>
+
+ Shared files / {selectedFile} {selectedFileMeta ? `Version ${selectedFileMeta.version} · ${selectedFileMeta.bytes} bytes` : "Shared file"}
+
+ {fileIsDirty && Unsaved changes }
+ {fileStatus === "saved" && Saved to team }
+ setSelectedFile(null)}>
+
+
+
+
+ {cloud.error && void cloud.refreshActive()} />}
+
+ )}
+
+
+
setMembersOpen(false)} onUpdateMemberRole={cloud.updateMemberRole} onCreateInvite={cloud.createInvite} />
+
+ );
+}
diff --git a/apps/desktop/src/components/FranklinChat.tsx b/apps/desktop/src/components/FranklinChat.tsx
index 4e9af209..910e9859 100644
--- a/apps/desktop/src/components/FranklinChat.tsx
+++ b/apps/desktop/src/components/FranklinChat.tsx
@@ -4,10 +4,10 @@
// wallet is the local CLI wallet (read-only, no connect flow).
import { useEffect, useRef, useState } from "react";
-import { ArrowUp, PanelLeft, ImageIcon, Clapperboard, Music, X, Plus, Check, ChevronDown, Gauge, BarChart3, TrendingUp, MoreHorizontal } from "lucide-react";
+import { ArrowUp, PanelLeft, ImageIcon, Clapperboard, Music, X, Plus, Check, ChevronDown, Gauge, BarChart3, TrendingUp, MoreHorizontal, Users, Laptop2, ShieldAlert } from "lucide-react";
import { TopBarMenu } from "./TopBarMenu";
import { ModelSelect } from "./ModelSelect";
-import { HistorySidebar, type TryView, type WorkspaceMode } from "./HistorySidebar";
+import { HistorySidebar, type TryView } from "./HistorySidebar";
import { MessageContent } from "./MessageContent";
import { ActivitySummary } from "./ActivitySummary";
import { MessageActions } from "./MessageActions";
@@ -18,15 +18,20 @@ import { ToolsPanel, type TryAction } from "./ToolsPanel";
import { GalleryPanel } from "./GalleryPanel";
import { WalletPanel } from "./WalletPanel";
import { SkillsPanel } from "./SkillsPanel";
+import { McpPanel } from "./McpPanel";
import { CLIPanel } from "./CLIPanel";
-import { TeamPanel } from "./TeamPanel";
+import { AgentsPanel } from "./AgentsPanel";
+import { CloudWorkspacePanel } from "./CloudWorkspacePanel";
import { useFranklinChat } from "../hooks/use-franklin-chat";
-import { useChatHistory } from "../hooks/use-chat-history";
+import { useChatHistory, type ChatSpace } from "../hooks/use-chat-history";
import { useSpend } from "../hooks/use-spend";
import { useAuth } from "../hooks/use-auth";
import { useWallet } from "../hooks/use-wallet";
import { useTryLang } from "../lib/i18n";
import { prepareImageForUpload } from "../lib/image-compress";
+import { useStudioRegistry } from "../hooks/use-studio-registry";
+import { requestTeamWorkspace, subscribeTeamWorkspaceNav, type TeamWorkspaceNavState } from "../lib/team-workspace-events";
+import { safeExternalHttpUrl } from "../lib/external-url";
// Composer "focus" modes — force a specific live-data tool (server tool_choice).
type ToolFocus = "search_prediction_markets" | "web_search" | "get_market_price";
@@ -62,11 +67,29 @@ function EmphTitle({ text }: { text: string }) {
export function FranklinChat() {
const { t } = useTryLang();
const auth = useAuth();
- const { wallet, connectionState: walletConnectionState, isLoading: walletLoading, error: walletError } = useWallet();
- const history = useChatHistory(auth.address);
+ const {
+ wallet,
+ isLoading: walletLoading,
+ error: walletError,
+ connectionState: walletConnectionState,
+ switchingChain: switchingWalletChain,
+ switchChain: switchWalletChain,
+ } = useWallet();
+ const [chatSpace, setChatSpaceState] = useState(() => {
+ if (typeof window === "undefined") return "personal";
+ try {
+ return localStorage.getItem("franklin-desktop-chat-space-v1") === "team" ? "team" : "personal";
+ } catch {
+ return "personal";
+ }
+ });
+ const history = useChatHistory(auth.address, chatSpace);
const usage = useSpend();
+ const studio = useStudioRegistry();
+ const [teamNav, setTeamNav] = useState({ items: [], activeId: null, loading: true });
+ useEffect(() => subscribeTeamWorkspaceNav(setTeamNav), []);
const chat = useFranklinChat(history.messages, history.setMessages, history.ensureConvId);
- const { mode, setMode, model, setModel, models, selectedModel, status, activeTool, needsToolWallet, genConvId, mediaJobs, error, isBusy, isConnected, send, stop, stopMedia, regenerate, imageSize, setImageSize, imageSizes, videoRatio, setVideoRatio, videoRatios, videoResolution, setVideoResolution, videoResolutions } = chat;
+ const { mode, setMode, model, setModel, models, selectedModel, status, activeTool, needsToolWallet, genConvId, mediaJobs, error, pendingPermission, respondToPermission, isBusy, isConnected, send, stop, stopMedia, regenerate, imageSize, setImageSize, imageSizes, videoRatio, setVideoRatio, videoRatios, videoResolution, setVideoResolution, videoResolutions } = chat;
const genHere = genConvId === null || genConvId === history.activeId;
const activeMediaJob = history.activeId ? mediaJobs[history.activeId] : undefined;
const busy = isBusy || !!activeMediaJob;
@@ -76,7 +99,6 @@ export function FranklinChat() {
const [lightbox, setLightbox] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [view, setView] = useState("chat");
- const [workspaceMode, setWorkspaceMode] = useState("personal");
const MOBILE_BP = 880;
const closeSidebarOnMobile = () => {
if (typeof window !== "undefined" && window.innerWidth <= MOBILE_BP) setSidebarOpen(false);
@@ -89,6 +111,14 @@ export function FranklinChat() {
const [resOpen, setResOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
+ const setChatSpace = (next: ChatSpace) => {
+ if (next === "team" && !studio.teamModeEnabled) return;
+ setChatSpaceState(next);
+ try { localStorage.setItem("franklin-desktop-chat-space-v1", next); } catch { /* local cache unavailable */ }
+ setEditingTitle(false);
+ setView("chat");
+ closeSidebarOnMobile();
+ };
// Image mode → aspect-ratio whitelist (value = size like "1536x1024"); video
// mode → ratio list (value = the ratio itself). Picker hidden when ≤1 option.
const ratioOptions: { ratio: string; value: string }[] =
@@ -203,21 +233,22 @@ export function FranklinChat() {
? t.phMusic
: activeFocus
? activeFocus.ph
- : t.phMessage;
+ : chatSpace === "team"
+ ? "Message the BlockRun team agent…"
+ : t.phMessage;
return (
{
- setWorkspaceMode("personal");
- history.newChat();
+ if (chatSpace === "team") requestTeamWorkspace(null);
+ else history.newChat();
setView("chat");
closeSidebarOnMobile();
}}
onSelect={(id) => {
- setWorkspaceMode("personal");
history.selectChat(id);
setView("chat");
closeSidebarOnMobile();
@@ -229,16 +260,23 @@ export function FranklinChat() {
closeSidebarOnMobile();
}}
open={sidebarOpen}
- workspaceMode={workspaceMode}
- onWorkspaceMode={(next) => {
- setWorkspaceMode(next);
- setView(next === "team" ? "team" : "chat");
- closeSidebarOnMobile();
- }}
wallet={wallet}
- walletConnectionState={walletConnectionState}
walletLoading={walletLoading}
walletError={walletError}
+ walletConnectionState={walletConnectionState}
+ switchingWalletChain={switchingWalletChain}
+ onSwitchWalletChain={switchWalletChain}
+ chatSpace={chatSpace}
+ onChatSpace={setChatSpace}
+ teamModeEnabled={studio.teamModeEnabled}
+ teamWorkspaces={teamNav.items}
+ activeTeamWorkspaceId={teamNav.activeId}
+ teamLoading={teamNav.loading}
+ onTeamWorkspace={(id) => {
+ requestTeamWorkspace(id);
+ setView("chat");
+ closeSidebarOnMobile();
+ }}
/>
{sidebarOpen && setSidebarOpen(false)} />}
@@ -253,9 +291,12 @@ export function FranklinChat() {
- {view === "chat" && activeConvo && (
+ {view === "chat" && (activeConvo || chatSpace === "team") && (
)}
@@ -309,8 +356,25 @@ export function FranklinChat() {
)}
- {view === "team" ? (
- { setWorkspaceMode("personal"); setView("chat"); }} />
+ {chatSpace === "team" && view === "chat" ? (
+
+ ) : view === "agents" ? (
+ {
+ studio.setTeamModeEnabled(enabled);
+ if (!enabled && chatSpace === "team") setChatSpace("personal");
+ }}
+ />
) : view === "phone" ? (
) : view === "tools" ? (
@@ -319,8 +383,10 @@ export function FranklinChat() {
) : view === "skills" ? (
+ ) : view === "mcp" ? (
+
) : view === "gallery" ? (
-
+
) : view === "wallet" ? (
) : (
@@ -328,9 +394,25 @@ export function FranklinChat() {
{messages.length === 0 ? (
-
-
-
+ {chatSpace === "team" && mode === "chat" ? (
+
+
+
+ A
+ V
+ F
+
+
Talk with your Team Agent
+
Preview a shared room for BlockRun. Franklin is presented as the team agent while this demo keeps its data on this device.
+
+ Shared conversation Team knowledge Reusable workflows
+
+
+ ) : (
+
+
+
+ )}
{mode === "chat"
? CASES.map((c) => (
@@ -350,7 +432,7 @@ export function FranklinChat() {
m.kind === "tools" ? (
) : (
-
+
{/* Show the role label only on the FIRST bubble of a turn — scan
back past tool groups; if an assistant text/media bubble
already showed it this turn, skip (one "Franklin" per turn). */}
@@ -361,7 +443,7 @@ export function FranklinChat() {
if (messages[j].role === "assistant" && messages[j].kind !== "tools") return false;
}
return true;
- })() &&
{m.role === "user" ? "You" : "Franklin"}
}
+ })() &&
{m.role === "user" ? (chatSpace === "team" ? "Andy · You" : "You") : (chatSpace === "team" ? "Franklin · Team Agent" : "Franklin")}
}
{m.kind === "image" && m.image ? (
) : m.kind === "video" && m.video ? (
) : m.kind === "music" && m.music ? (
) : (
<>
@@ -473,6 +555,9 @@ export function FranklinChat() {
+ {chatSpace === "team" && (
+
Shared with BlockRun Team · demo data stays on this device
+ )}
{needsToolWallet && (
{t.hintToolWallet}
@@ -680,6 +765,25 @@ export function FranklinChat() {
)}
+ {pendingPermission && (
+
+
+
+
+
+
Franklin needs your approval
+
Tool {pendingPermission.toolName}
+
+
+
{pendingPermission.description}
+
+ respondToPermission("n")}>Deny
+ respondToPermission("y")}>Allow once
+
+
+
+ )}
+
{shareOpen &&
setShareOpen(false)} />}
);
diff --git a/apps/desktop/src/components/HistorySidebar.tsx b/apps/desktop/src/components/HistorySidebar.tsx
index aa53d72f..23154715 100644
--- a/apps/desktop/src/components/HistorySidebar.tsx
+++ b/apps/desktop/src/components/HistorySidebar.tsx
@@ -1,18 +1,19 @@
import { useEffect, useRef, useState } from "react";
import {
Plus, MessageSquare, Trash2, Phone, Blocks, Images, Wallet, Sparkles, Search,
- Grid2x2, ChevronRight, Terminal, UsersRound, FolderKanban, BookOpen, Workflow,
+ Grid2x2, ChevronRight, Terminal, Server, UserRound, Users, Bot, Cloud,
} from "lucide-react";
-import type { Conversation } from "../hooks/use-chat-history";
+import type { ChatSpace, Conversation } from "../hooks/use-chat-history";
import type { WalletInfo } from "../lib/wire";
import type { AgentConnectionState } from "../lib/ws";
import { useTryLang } from "../lib/i18n";
import { MoreMenu } from "./MoreMenu";
import { WalletPill } from "./WalletPill";
import franklinAvatar from "../assets/franklin-avatar.png";
+import type { TeamWorkspaceNavItem } from "../lib/team-workspace-events";
+import { useSidebarPreferences } from "../hooks/use-sidebar-preferences";
-export type TryView = "chat" | "phone" | "tools" | "gallery" | "wallet" | "skills" | "cli" | "team";
-export type WorkspaceMode = "personal" | "team";
+export type TryView = "chat" | "agents" | "phone" | "tools" | "gallery" | "wallet" | "skills" | "cli" | "mcp";
// Local bundled logo (no network → no offline blank).
const PORTRAIT_URL = franklinAvatar;
@@ -26,17 +27,25 @@ interface Props {
view: TryView;
onView: (v: TryView) => void;
open: boolean;
- workspaceMode: WorkspaceMode;
- onWorkspaceMode: (mode: WorkspaceMode) => void;
/** Local CLI wallet (read-only) — replaces run's browser connect-wallet UI. */
wallet: WalletInfo | null;
- walletConnectionState: AgentConnectionState;
walletLoading: boolean;
walletError: string | null;
+ walletConnectionState: AgentConnectionState;
+ switchingWalletChain?: "base" | "solana" | null;
+ onSwitchWalletChain?: (chain: "base" | "solana") => void | Promise
;
+ chatSpace: ChatSpace;
+ onChatSpace: (space: ChatSpace) => void;
+ teamModeEnabled?: boolean;
+ teamWorkspaces?: TeamWorkspaceNavItem[];
+ activeTeamWorkspaceId?: string | null;
+ teamLoading?: boolean;
+ onTeamWorkspace?: (id: string) => void;
}
-export function HistorySidebar({ conversations, activeId, onNew, onSelect, onDelete, view, onView, open, workspaceMode, onWorkspaceMode, wallet, walletConnectionState, walletLoading, walletError }: Props) {
- const { t, lang } = useTryLang();
+export function HistorySidebar({ conversations, activeId, onNew, onSelect, onDelete, view, onView, open, wallet, walletLoading, walletError, walletConnectionState, switchingWalletChain, onSwitchWalletChain, chatSpace, onChatSpace, teamModeEnabled = true, teamWorkspaces = [], activeTeamWorkspaceId = null, teamLoading = false, onTeamWorkspace }: Props) {
+ const { t } = useTryLang();
+ const { visibleItems } = useSidebarPreferences();
const [searchOpen, setSearchOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const moreBtnRef = useRef(null);
@@ -50,65 +59,75 @@ export function HistorySidebar({ conversations, activeId, onNew, onSelect, onDel
const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
- const nav: { key: TryView; icon: React.ReactNode; label: string }[] = [
- { key: "tools", icon: , label: t.marketplace },
- { key: "gallery", icon: , label: t.gallery },
- { key: "cli", icon: , label: t.cli },
+ const navItems: { key: TryView; icon: React.ReactNode; label: string }[] = [
+ { key: "agents", icon: , label: "Agents" },
+ { key: "tools", icon: , label: t.marketplace },
+ { key: "gallery", icon: , label: t.gallery },
+ { key: "cli", icon: , label: t.cli },
];
- const moreNav: { key: TryView; icon: React.ReactNode; label: string }[] = [
- { key: "skills", icon: , label: t.skills },
- { key: "phone", icon: , label: t.phone },
- { key: "wallet", icon: , label: t.wallet },
+ const nav = navItems.filter((item) => visibleItems.includes(item.key as "agents" | "tools" | "gallery" | "cli"));
+ const moreNavItems: { key: TryView; icon: React.ReactNode; label: string }[] = [
+ { key: "mcp", icon: , label: "MCP" },
+ { key: "skills", icon: , label: t.skills },
+ { key: "phone", icon: , label: t.phone },
+ { key: "wallet", icon: , label: t.wallet },
];
+ const moreNav = moreNavItems.filter((item) => visibleItems.includes(item.key as "mcp" | "skills" | "phone" | "wallet"));
const moreActive = moreNav.some((n) => n.key === view);
- const teamCopy = lang === "zh"
- ? { personal: "个人", team: "团队", home: "团队首页", chats: "共享对话", files: "共享文件", knowledge: "团队知识", workflows: "工作流", soon: "即将开放" }
- : lang === "es"
- ? { personal: "Personal", team: "Equipo", home: "Inicio del equipo", chats: "Conversaciones", files: "Archivos", knowledge: "Conocimiento", workflows: "Flujos", soon: "Próximamente" }
- : { personal: "Personal", team: "Team", home: "Team home", chats: "Shared chats", files: "Shared files", knowledge: "Knowledge", workflows: "Workflows", soon: "Coming soon" };
- const teamNav = [
- { icon: , label: teamCopy.chats },
- { icon: , label: teamCopy.files },
- { icon: , label: teamCopy.knowledge },
- { icon: , label: teamCopy.workflows },
- ];
return (
<>
- onWorkspaceMode("personal")}>
+ onView("chat")}>
Franklin
-
+
onWorkspaceMode("personal")}
+ className={chatSpace === "personal" ? "is-active" : ""}
+ onClick={() => onChatSpace("personal")}
+ title="Your private conversations"
>
- {teamCopy.personal}
+
+ Personal
onWorkspaceMode("team")}
+ className={chatSpace === "team" ? "is-active" : ""}
+ onClick={() => onChatSpace("team")}
+ disabled={!teamModeEnabled}
+ title="Shared BlockRun team conversations"
>
- {teamCopy.team}Beta
+
+ Team
+ Beta
- {workspaceMode === "personal" ? (
- <>
+ {!teamModeEnabled &&
onView("agents")}>Team Mode is off · Manage modules }
+
+ {chatSpace === "team" &&
+
WORKSPACES {teamWorkspaces.length}
+ {teamLoading ?
Connecting to Franklin Cloud…
: teamWorkspaces.length === 0 ?
No team workspaces yet
: teamWorkspaces.map((workspace) => (
+
onTeamWorkspace?.(workspace.id)}>
+
+ {workspace.name} {workspace.memberCount} members · {workspace.role}
+ v{workspace.version}
+
+ ))}
+
}
+
-
- {t.newChat}
+
+ {chatSpace === "team" ? "New workspace" : t.newChat}
-
setSearchOpen(true)}>
-
+ {chatSpace === "personal" && setSearchOpen(true)}>
+
{t.searchChats}
-
+ }
{nav.map((n) => (
@@ -122,17 +141,17 @@ export function HistorySidebar({ conversations, activeId, onNew, onSelect, onDel
))}
-
0 && (moreOpen ? setMoreOpen(false) : openMore())}
>
-
+
{t.more}
-
-
+
+ }
-
+ {chatSpace === "personal" &&
{sorted.length === 0 ? (
{t.noConversations}
) : (
@@ -157,39 +176,20 @@ export function HistorySidebar({ conversations, activeId, onNew, onSelect, onDel
onDelete(c.id);
}}
>
-
+
))}
)}
+
}
-
- >
- ) : (
-
-
onView("team")}>
- {teamCopy.home}Beta
-
-
{teamCopy.soon}
- {teamNav.map((item) => (
-
- {item.icon}{item.label} {teamCopy.soon}
-
- ))}
-
- )}
diff --git a/apps/desktop/src/components/McpPanel.tsx b/apps/desktop/src/components/McpPanel.tsx
new file mode 100644
index 00000000..47368daf
--- /dev/null
+++ b/apps/desktop/src/components/McpPanel.tsx
@@ -0,0 +1,261 @@
+import { useRef, useState } from "react";
+import { Server, Wrench, AlertTriangle, Sparkles, RefreshCw, Lock, LockOpen, Plus, Upload, Loader2 } from "lucide-react";
+import { useMcp } from "../hooks/use-mcp";
+import { useAgentSkills, type AgentSkill } from "../hooks/use-agent-skills";
+
+const SOURCE_COLOR: Record
= {
+ bundled: "#6b7280",
+ user: "#2563eb",
+ project: "#059669",
+ learned: "#a855f7",
+};
+
+function Badge({ text, color }: { text: string; color: string }) {
+ return (
+
+ {text}
+
+ );
+}
+
+export function McpPanel() {
+ const { servers, failures, loading: mcpLoading, refresh: refreshMcp } = useMcp();
+ const { skills, loading: skillsLoading, refresh: refreshSkills, toggle, create, upload } = useAgentSkills();
+
+ const [desc, setDesc] = useState("");
+ const [creating, setCreating] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const fileRef = useRef(null);
+
+ const refresh = () => {
+ refreshMcp();
+ refreshSkills();
+ };
+
+ const onCreate = async () => {
+ const d = desc.trim().slice(0, 2_000);
+ if (!d || creating) return;
+ setCreating(true);
+ setNotice(null);
+ const r = await create(d);
+ setCreating(false);
+ if (r) { setDesc(""); setNotice(`Created /${r.name} · restart the session to invoke it`); }
+ else setNotice("Couldn't generate the skill — try a clearer description.");
+ };
+
+ const onFiles = async (files: FileList | null) => {
+ if (!files || files.length === 0) return;
+ const selected = Array.from(files).slice(0, 10);
+ if (files.length > selected.length) setNotice("Upload at most 10 skills at a time.");
+ for (const f of selected) {
+ if (f.size > 256 * 1024) {
+ setNotice(`"${f.name}" is larger than the 256 KiB skill limit.`);
+ continue;
+ }
+ const text = await f.text();
+ const r = await upload(text);
+ setNotice(r ? `Uploaded /${r.name}` : `"${f.name}" isn't a valid SKILL.md (needs name + description frontmatter)`);
+ }
+ };
+
+ const totalTools = servers.reduce((s, x) => s + x.toolCount, 0);
+
+ return (
+
+
+
+
MCP & Skills
+
+
+ Refresh
+
+
+
+ What the local agent has connected and loaded — MCP servers (extra tools) and the skill
+ registry the model can invoke.
+
+
+ {/* ── MCP servers ── */}
+
+
+
+ MCP servers · {servers.length} connected · {totalTools} tools
+
+
+ {servers.length === 0 && failures.length === 0 && (
+
+ {mcpLoading ? "Loading…" : "No MCP servers configured. Add them to ~/.blockrun/mcp.json."}
+
+ )}
+
+ {servers.map((s) => (
+
+
+ {s.name}
+
+
+ {s.filtered > 0 && }
+ {s.hasOAuth &&
+ (s.oauthAuthorized ? (
+
+ OAuth
+
+ ) : (
+
+ auth needed
+
+ ))}
+
+ {s.tools.length > 0 && (
+
+
+ {s.tools.map((t) => t.replace(/^mcp__[^_]+__/, "")).join(" · ")}
+
+ )}
+
+ ))}
+
+ {failures.map((f) => (
+
+
+
{f.reason}
+ {f.stderrTail && f.stderrTail.length > 0 && (
+
+ {f.stderrTail.join("\n")}
+
+ )}
+
+ ))}
+
+
+ {/* ── Agent skills ── */}
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => { e.preventDefault(); setDragOver(false); void onFiles(e.dataTransfer.files); }}
+ style={dragOver ? { outline: "2px dashed #2563eb", outlineOffset: 4, borderRadius: 8 } : undefined}
+ >
+
+
+ Agent skills · {skills.length}
+
+
+ {/* Create a skill from a description (model-generated SKILL.md). */}
+
+ {notice &&
{notice}
}
+
+ Drag a SKILL.md here to add it. Toggle a skill off to hide it from the model.
+
+
+ {skills.length === 0 && (
+
+ {skillsLoading ? "Loading…" : "No skills loaded."}
+
+ )}
+ {skills.map((s) => (
+
+
+ /{s.name}
+
+ {!s.modelInvocable && }
+ void toggle(s.name, !s.enabled)}
+ title={s.enabled ? "Disable" : "Enable"}
+ style={{
+ marginLeft: "auto",
+ fontSize: 11,
+ fontWeight: 600,
+ padding: "2px 9px",
+ borderRadius: 999,
+ cursor: "pointer",
+ border: `1px solid ${s.enabled ? "#05966955" : "#9ca3af55"}`,
+ background: s.enabled ? "#0596691a" : "transparent",
+ color: s.enabled ? "#059669" : "#9ca3af",
+ }}
+ >
+ {s.enabled ? "On" : "Off"}
+
+
+
{s.description}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/MessageContent.tsx b/apps/desktop/src/components/MessageContent.tsx
index 04b29e71..40f1448f 100644
--- a/apps/desktop/src/components/MessageContent.tsx
+++ b/apps/desktop/src/components/MessageContent.tsx
@@ -2,6 +2,7 @@
import { Fragment, useState } from "react";
import { Copy, Check, Maximize2, X, Download } from "lucide-react";
+import { safeExternalHttpUrl } from "../lib/external-url";
// Lightweight Markdown renderer for assistant replies (no external deps):
// splits fenced ``` code blocks out, renders the rest with minimal inline
@@ -42,17 +43,15 @@ function renderInline(line: string, key: string) {
} else if (m[3] !== undefined) {
nodes.push({m[3]});
} else if (m[4] !== undefined && m[5] !== undefined) {
- nodes.push(
-
- {m[4]}
- ,
- );
+ const href = safeExternalHttpUrl(m[5]);
+ nodes.push(href
+ ? {m[4]}
+ : m[4]);
} else if (m[6] !== undefined) {
- nodes.push(
-
- {m[6].replace(/^https?:\/\//, "")}
- ,
- );
+ const href = safeExternalHttpUrl(m[6]);
+ nodes.push(href
+ ? {m[6].replace(/^https?:\/\//, "")}
+ : m[6]);
}
last = re.lastIndex;
i++;
diff --git a/apps/desktop/src/components/MoreMenu.tsx b/apps/desktop/src/components/MoreMenu.tsx
index 3a27d262..358b3061 100644
--- a/apps/desktop/src/components/MoreMenu.tsx
+++ b/apps/desktop/src/components/MoreMenu.tsx
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
-import { Settings, BookOpen, FileText, Globe, ArrowUpRight, Check, ChevronRight, Languages, Palette, Terminal, Github, PiggyBank } from "lucide-react";
+import { Settings, BookOpen, FileText, Globe, ArrowUpRight, Check, ChevronRight, Languages, Palette, Terminal, Github, PiggyBank, PanelLeft, RotateCcw } from "lucide-react";
import { useTryLang, TRY_LANGS } from "../lib/i18n";
import { useTheme, type Theme } from "../hooks/use-theme";
import { useCostSaver } from "../hooks/use-cost-saver";
import { copyText } from "../lib/clipboard";
+import { SIDEBAR_ITEMS, useSidebarPreferences } from "../hooks/use-sidebar-preferences";
const SAVER_LABELS: Record = {
en: { label: "Cost saver", desc: "Auto-compress history on long, tool-heavy turns to cut token cost" },
@@ -17,6 +18,7 @@ export function MoreMenu() {
const { t, lang, setLang } = useTryLang();
const { theme, setTheme } = useTheme();
const costSaver = useCostSaver();
+ const sidebar = useSidebarPreferences();
const SL = SAVER_LABELS[lang] ?? SAVER_LABELS.en;
const [copied, setCopied] = useState(false);
const copyInstall = async () => {
@@ -107,6 +109,21 @@ export function MoreMenu() {
+
+
+
+ Sidebar
+
+
+
+
Show in sidebar Reset
+ {SIDEBAR_ITEMS.map((item) => {
+ const visible = sidebar.visibleItems.includes(item.id);
+ return
sidebar.setItemVisible(item.id, !visible)}>{item.label} {visible && } ;
+ })}
+
+
+
{/* Cost-saver (research-bloat compaction) toggle */}
- {!isConnected &&
The local CLI wallet manages phone numbers.
}
+ {!isConnected &&
Phone-number management is not available in Desktop yet. You can still ask Franklin in chat to place a call.
}
Your numbers
diff --git a/apps/desktop/src/components/WalletPanel.tsx b/apps/desktop/src/components/WalletPanel.tsx
index 625dd474..1b835b91 100644
--- a/apps/desktop/src/components/WalletPanel.tsx
+++ b/apps/desktop/src/components/WalletPanel.tsx
@@ -1,9 +1,11 @@
-import { Wallet, ArrowDownToLine, Coins, ArrowLeftRight, ExternalLink } from "lucide-react";
+import { Wallet, ArrowDownToLine, Coins, ArrowLeftRight, ExternalLink, Check, RefreshCw } from "lucide-react";
import type { Usage } from "../hooks/use-usage-stats";
import { useUsdcBalance } from "../hooks/use-usdc-balance";
import { useWalletTokens } from "../hooks/use-wallet-tokens";
import { useWalletSwaps } from "../hooks/use-wallet-swaps";
import { useTryLang } from "../lib/i18n";
+import { useWallet } from "../hooks/use-wallet";
+import { safeExternalHttpUrl } from "../lib/external-url";
const HOLDINGS_LABEL: Record
= { en: "Holdings", zh: "持仓", es: "Tenencias" };
const SWAPS_LABEL: Record = { en: "Swaps", zh: "换币记录", es: "Intercambios" };
@@ -26,11 +28,12 @@ function shortModel(id: string): string {
// Wallet & receipts (Franklin's differentiator): USDC balance + everything
// spent, per model/tool, with a per-request receipt log. Locally the balance
-// comes from the CLI wallet (useUsdcBalance → useWallet), and the receipts are
-// tracked client-side in useUsageStats.
+// comes from the CLI wallet (useUsdcBalance → useWallet), and receipts come
+// from the CLI's authoritative settlement ledger through useSpend.
export function WalletPanel({ usage }: { usage: Usage }) {
const { t, lang } = useTryLang();
const { balance } = useUsdcBalance();
+ const { wallet, switchingChain, switchChain, error: walletError } = useWallet();
const { tokens } = useWalletTokens();
const swaps = useWalletSwaps();
const byModel = Object.entries(usage.byModel).sort((a, b) => b[1].usd - a[1].usd);
@@ -41,6 +44,14 @@ export function WalletPanel({ usage }: { usage: Usage }) {
{t.walletTitle}
+
+ Payment network Franklin keeps a separate local wallet for each network. Switching restarts the local agent, never exports a key.
+
+ {(["base", "solana"] as const).map((chain) => void switchChain(chain)}>{switchingChain === chain ? : wallet?.chain === chain ? : null}{chain === "base" ? "Base" : "Solana"} )}
+
+
+ {walletError &&
{walletError}
}
+
{t.balance}
@@ -89,8 +100,8 @@ export function WalletPanel({ usage }: { usage: Usage }) {
{fmtAmt(s.sellAmount)} {s.sellSym} → {fmtAmt(s.buyAmount)} {s.buySym}
{new Date(s.ts).toLocaleDateString()}
- {s.explorer && (
-
+ {safeExternalHttpUrl(s.explorer) && (
+
)}
diff --git a/apps/desktop/src/components/WalletPill.tsx b/apps/desktop/src/components/WalletPill.tsx
index 0e1e6412..3602ad7a 100644
--- a/apps/desktop/src/components/WalletPill.tsx
+++ b/apps/desktop/src/components/WalletPill.tsx
@@ -5,7 +5,7 @@
// address instead of disconnecting.
import { useState } from "react";
-import { Copy, Check } from "lucide-react";
+import { Copy, Check, RefreshCw } from "lucide-react";
import type { WalletInfo } from "../lib/wire";
import type { AgentConnectionState } from "../lib/ws";
import { copyText } from "../lib/clipboard";
@@ -15,13 +15,15 @@ interface Props {
connectionState: AgentConnectionState;
isLoading: boolean;
error: string | null;
+ switchingChain?: "base" | "solana" | null;
+ onSwitchChain?: (chain: "base" | "solana") => void | Promise
;
}
function fmtBal(n: number): string {
return `$${n < 0.01 ? n.toFixed(4) : n.toFixed(2)}`;
}
-export function WalletPill({ wallet, connectionState, isLoading, error }: Props) {
+export function WalletPill({ wallet, connectionState, isLoading, error, switchingChain, onSwitchChain }: Props) {
const [copied, setCopied] = useState(false);
if (connectionState !== "open" || !wallet) {
@@ -49,13 +51,13 @@ export function WalletPill({ wallet, connectionState, isLoading, error }: Props)
const net = wallet.chain === "base" ? "Base" : "Solana";
// RPC values are runtime data, even when the TypeScript contract says
// `string`. Keep a malformed wallet response from taking down the whole UI.
- const fullAddress = typeof wallet.address === "string" ? wallet.address : "";
- const addr = fullAddress
- ? `${fullAddress.slice(0, 6)}…${fullAddress.slice(-4)}`
+ const safeAddress = typeof wallet.address === "string" ? wallet.address : "";
+ const addr = safeAddress
+ ? `${safeAddress.slice(0, 6)}…${safeAddress.slice(-4)}`
: "Unavailable";
const copy = async () => {
- if (fullAddress && await copyText(fullAddress)) {
+ if (safeAddress && await copyText(safeAddress)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
@@ -65,7 +67,9 @@ export function WalletPill({ wallet, connectionState, isLoading, error }: Props)
- {net}
+ void onSwitchChain?.(wallet.chain === "base" ? "solana" : "base")} title={`Switch to ${wallet.chain === "base" ? "Solana" : "Base"}`}>
+ {switchingChain ? : null}{switchingChain ? (switchingChain === "base" ? "Base" : "Solana") : net}
+
{wallet.balanceUsd !== undefined && {fmtBal(wallet.balanceUsd)} }
{addr}
@@ -73,9 +77,9 @@ export function WalletPill({ wallet, connectionState, isLoading, error }: Props)
{copied ? : }
diff --git a/apps/desktop/src/hooks/use-agent-skills.ts b/apps/desktop/src/hooks/use-agent-skills.ts
new file mode 100644
index 00000000..b9249b4c
--- /dev/null
+++ b/apps/desktop/src/hooks/use-agent-skills.ts
@@ -0,0 +1,89 @@
+// The real agent skill registry (bundled + user + project + learned) the local
+// agent loaded — name, description, source, enabled state. Distinct from the
+// SkillsPanel's hard-coded prompt starters; this reflects what the model can
+// actually invoke via the Skill tool, plus management actions (toggle, create,
+// upload) that mirror WorkBuddy's skill UX.
+
+import { useCallback, useEffect, useState } from "react";
+import { agent } from "../lib/ws";
+
+export interface AgentSkill {
+ name: string;
+ description: string;
+ source: "bundled" | "user" | "project" | "learned";
+ hidden: boolean;
+ modelInvocable: boolean;
+ enabled: boolean;
+}
+
+export function useAgentSkills(): {
+ skills: AgentSkill[];
+ loading: boolean;
+ refresh: () => void;
+ toggle: (name: string, enabled: boolean) => Promise
;
+ create: (description: string) => Promise<{ name: string } | null>;
+ upload: (content: string) => Promise<{ name: string } | null>;
+} {
+ const [skills, setSkills] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const fetchSkills = useCallback(async () => {
+ setLoading(true);
+ try {
+ const r = await agent.request("skills.list");
+ setSkills(r?.skills ?? []);
+ } catch {
+ /* best-effort */
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const toggle = useCallback(
+ async (name: string, enabled: boolean) => {
+ // Optimistic flip, then persist + refetch.
+ setSkills((prev) => prev.map((s) => (s.name === name ? { ...s, enabled } : s)));
+ try {
+ await agent.request<{ name: string; enabled: boolean }, unknown>("skills.toggle", { name, enabled });
+ } catch {
+ void fetchSkills(); // revert to server truth on failure
+ }
+ },
+ [fetchSkills],
+ );
+
+ const create = useCallback(
+ async (description: string) => {
+ try {
+ const r = await agent.request<{ description: string }, { name?: string }>("skills.create", { description });
+ await fetchSkills();
+ return r?.name ? { name: r.name } : null;
+ } catch {
+ return null;
+ }
+ },
+ [fetchSkills],
+ );
+
+ const upload = useCallback(
+ async (content: string) => {
+ try {
+ const r = await agent.request<{ content: string }, { name?: string }>("skills.upload", { content });
+ await fetchSkills();
+ return r?.name ? { name: r.name } : null;
+ } catch {
+ return null;
+ }
+ },
+ [fetchSkills],
+ );
+
+ useEffect(() => {
+ const off = agent.onState((s) => {
+ if (s === "open") void fetchSkills();
+ });
+ return off;
+ }, [fetchSkills]);
+
+ return { skills, loading, refresh: fetchSkills, toggle, create, upload };
+}
diff --git a/apps/desktop/src/hooks/use-chat-history.ts b/apps/desktop/src/hooks/use-chat-history.ts
index 41215086..fbbe7ead 100644
--- a/apps/desktop/src/hooks/use-chat-history.ts
+++ b/apps/desktop/src/hooks/use-chat-history.ts
@@ -14,12 +14,16 @@ import { agent } from "../lib/ws";
const LOCAL_KEY = "franklin-webui-history-v1";
+export type ChatSpace = "personal" | "team";
+
export interface Conversation {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: ChatMessage[];
+ /** Missing on older records, which are migrated as personal conversations. */
+ space?: ChatSpace;
}
type Setter = ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]);
@@ -32,6 +36,10 @@ function titleFrom(messages: ChatMessage[]): string {
return firstUser?.content.slice(0, 48) || "New chat";
}
+function conversationSpace(conversation: Conversation): ChatSpace {
+ return conversation.space === "team" ? "team" : "personal";
+}
+
function loadLocal(): Conversation[] {
if (typeof window === "undefined") return [];
try {
@@ -50,23 +58,32 @@ function saveLocal(convos: Conversation[]) {
}
}
-export function useChatHistory(address: string | null) {
+export function useChatHistory(address: string | null, space: ChatSpace = "personal") {
// Lazy-init FROM localStorage on the very first render. Doing this in an
// effect instead caused the save effect to fire once with the still-empty
// initial state and wipe storage before hydration (history vanished on every
// launch, esp. under StrictMode's double-invoke).
const [conversations, setConversations] = useState(() => loadLocal());
- const [activeId, setActiveIdState] = useState(() => loadLocal()[0]?.id ?? null);
- const activeIdRef = useRef(activeId);
+ const [activeBySpace, setActiveBySpace] = useState>(() => {
+ const loaded = loadLocal();
+ return {
+ personal: loaded.find((c) => conversationSpace(c) === "personal")?.id ?? null,
+ team: loaded.find((c) => conversationSpace(c) === "team")?.id ?? null,
+ };
+ });
+ const activeBySpaceRef = useRef(activeBySpace);
+ activeBySpaceRef.current = activeBySpace;
+ const pendingSpaceRef = useRef>({});
+ const activeId = activeBySpace[space];
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
// Local mode always: the CLI owns the wallet, there is no SIWE backend.
const signedIn = !!address;
const setActiveId = useCallback((id: string | null) => {
- activeIdRef.current = id;
- setActiveIdState(id);
- }, []);
+ activeBySpaceRef.current = { ...activeBySpaceRef.current, [space]: id };
+ setActiveBySpace((prev) => ({ ...prev, [space]: id }));
+ }, [space]);
// Source of truth is a FILE on disk (~/.blockrun via the agent) — reliable in
// both dev and the packaged app, where file:// localStorage doesn't persist.
@@ -80,14 +97,17 @@ export function useChatHistory(address: string | null) {
const server = Array.isArray(r?.conversations) ? r.conversations : [];
if (server.length > 0) {
setConversations(server);
- if (!activeIdRef.current) setActiveId(server[0]?.id ?? null);
+ setActiveBySpace((prev) => ({
+ personal: prev.personal ?? server.find((c) => conversationSpace(c) === "personal")?.id ?? null,
+ team: prev.team ?? server.find((c) => conversationSpace(c) === "team")?.id ?? null,
+ }));
} else if (conversationsRef.current.length > 0) {
void agent.request("history.save", { conversations: conversationsRef.current });
}
} catch { /* keep local cache */ }
});
return off;
- }, [setActiveId]);
+ }, []);
// Persist on change → localStorage cache (instant) + the file (debounced).
useEffect(() => {
@@ -99,7 +119,8 @@ export function useChatHistory(address: string | null) {
return () => clearTimeout(t);
}, [conversations, signedIn]);
- const activeConversation = conversations.find((c) => c.id === activeId) ?? null;
+ const visibleConversations = conversations.filter((c) => conversationSpace(c) === space);
+ const activeConversation = visibleConversations.find((c) => c.id === activeId) ?? null;
const messages = activeConversation?.messages ?? [];
const newChat = useCallback(() => setActiveId(null), [setActiveId]);
@@ -113,9 +134,9 @@ export function useChatHistory(address: string | null) {
const deleteChat = useCallback(
(id: string) => {
setConversations((prev) => prev.filter((c) => c.id !== id));
- if (activeIdRef.current === id) setActiveId(null);
+ if (activeBySpaceRef.current[space] === id) setActiveId(null);
},
- [setActiveId],
+ [setActiveId, space],
);
const deleteMedia = useCallback(
@@ -126,30 +147,32 @@ export function useChatHistory(address: string | null) {
const msgs = cur.messages.filter((m) => m.image !== url && m.video !== url);
if (msgs.length === cur.messages.length) return prev;
if (msgs.length === 0) {
- if (activeIdRef.current === convId) setActiveId(null);
+ if (activeBySpaceRef.current[space] === convId) setActiveId(null);
return prev.filter((c) => c.id !== convId);
}
const updated = { ...cur, messages: msgs, updatedAt: Date.now() };
return prev.map((c) => (c.id === convId ? updated : c));
});
},
- [setActiveId],
+ [setActiveId, space],
);
const ensureConvId = useCallback(() => {
- let id = activeIdRef.current;
+ let id = activeBySpaceRef.current[space];
if (!id) {
id = uid();
+ pendingSpaceRef.current[id] = space;
setActiveId(id);
}
return id;
- }, [setActiveId]);
+ }, [setActiveId, space]);
const setMessages = useCallback(
(next: Setter, targetId?: string) => {
- let resolved = targetId ?? activeIdRef.current;
+ let resolved = targetId ?? activeBySpaceRef.current[space];
if (!resolved) {
resolved = uid();
+ pendingSpaceRef.current[resolved] = space;
setActiveId(resolved);
}
const id = resolved;
@@ -159,14 +182,22 @@ export function useChatHistory(address: string | null) {
const msgs = typeof next === "function" ? next(prevMsgs) : next;
const now = Date.now();
if (cur && msgs.length === 0) {
- if (activeIdRef.current === id) setActiveId(null);
+ if (activeBySpaceRef.current[space] === id) setActiveId(null);
return prev.filter((c) => c.id !== id);
}
let updated: Conversation;
let arr: Conversation[];
if (!cur) {
if (msgs.length === 0) return prev;
- updated = { id, title: titleFrom(msgs), createdAt: now, updatedAt: now, messages: msgs };
+ updated = {
+ id,
+ title: titleFrom(msgs),
+ createdAt: now,
+ updatedAt: now,
+ messages: msgs,
+ space: pendingSpaceRef.current[id] ?? space,
+ };
+ delete pendingSpaceRef.current[id];
arr = [updated, ...prev];
} else {
updated = {
@@ -180,8 +211,8 @@ export function useChatHistory(address: string | null) {
return arr;
});
},
- [setActiveId],
+ [setActiveId, space],
);
- return { conversations, activeId, activeConversation, messages, setMessages, ensureConvId, newChat, selectChat, deleteChat, renameChat, deleteMedia };
+ return { conversations, visibleConversations, activeId, activeConversation, messages, setMessages, ensureConvId, newChat, selectChat, deleteChat, renameChat, deleteMedia };
}
diff --git a/apps/desktop/src/hooks/use-cloud-workspace.ts b/apps/desktop/src/hooks/use-cloud-workspace.ts
new file mode 100644
index 00000000..58468622
--- /dev/null
+++ b/apps/desktop/src/hooks/use-cloud-workspace.ts
@@ -0,0 +1,195 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+export interface CloudUser { id: string; name: string }
+export interface CloudMember { userId: string; name: string; role: "owner" | "admin" | "member" | "viewer"; joinedAt: string }
+export interface CloudWorkspace {
+ id: string; name: string; createdAt: string; updatedAt?: string; version: number; runtime: string;
+ role: CloudMember["role"]; members: CloudMember[];
+}
+export interface CloudMessage {
+ id: string; role: "user" | "assistant"; authorId: string; authorName: string; content: string; createdAt: string;
+}
+export interface CloudFile {
+ path: string; bytes: number; version: number; updatedAt: string; updatedBy: string;
+}
+
+const WORKSPACE_KEY = "franklin-team-workspace-v2";
+const walletLabel = (wallet: string) => `${wallet.slice(0, 6)}…${wallet.slice(-4)}`;
+
+async function teamRequest(action: string, payload: Record = {}): Promise {
+ const base = window.__FRANKLIN__?.cloudUrl || "http://127.0.0.1:3740";
+ const response = await fetch(`${base}/v1/franklin-team`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Franklin-Desktop-Token": window.__FRANKLIN__?.cloudToken || "",
+ },
+ body: JSON.stringify({ action, ...payload }),
+ redirect: "error",
+ signal: AbortSignal.timeout(30_000),
+ });
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(result.error || `Franklin Cloud request failed (${response.status})`);
+ return result as T;
+}
+
+async function agentTurn(workspaceId: string, content: string): Promise {
+ const base = window.__FRANKLIN__?.cloudUrl || "http://127.0.0.1:3740";
+ const response = await fetch(`${base}/v1/franklin-team/agent-turn`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Franklin-Desktop-Token": window.__FRANKLIN__?.cloudToken || "",
+ },
+ body: JSON.stringify({ workspaceId, content }),
+ redirect: "error",
+ signal: AbortSignal.timeout(180_000),
+ });
+ const result = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(result.error || `Team Franklin failed (${response.status})`);
+}
+
+export function useCloudWorkspace() {
+ const [connected, setConnected] = useState(false);
+ const [session, setSession] = useState(null);
+ const [workspaces, setWorkspaces] = useState([]);
+ const [activeId, setActiveIdState] = useState(() => {
+ try { return localStorage.getItem(WORKSPACE_KEY); } catch { return null; }
+ });
+ const [messages, setMessages] = useState([]);
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+ const contentWorkspaceRef = useRef(null);
+
+ const active = useMemo(() => workspaces.find((workspace) => workspace.id === activeId) || null, [workspaces, activeId]);
+
+ const setActiveId = useCallback((id: string | null) => {
+ if (id && contentWorkspaceRef.current && id !== contentWorkspaceRef.current) {
+ setMessages([]);
+ setFiles([]);
+ }
+ setActiveIdState(id);
+ try {
+ if (id) localStorage.setItem(WORKSPACE_KEY, id);
+ else localStorage.removeItem(WORKSPACE_KEY);
+ } catch { /* local storage unavailable */ }
+ }, []);
+
+ const refreshWorkspaces = useCallback(async () => {
+ const result = await teamRequest<{ workspaces: CloudWorkspace[]; wallet: string }>("workspace.list");
+ setSession({ id: result.wallet, name: walletLabel(result.wallet) });
+ setWorkspaces(result.workspaces);
+ setActiveIdState((current) => {
+ const next = current && result.workspaces.some((workspace) => workspace.id === current)
+ ? current
+ : result.workspaces[0]?.id || null;
+ try {
+ if (next) localStorage.setItem(WORKSPACE_KEY, next);
+ else localStorage.removeItem(WORKSPACE_KEY);
+ } catch { /* noop */ }
+ return next;
+ });
+ }, []);
+
+ const refreshActive = useCallback(async () => {
+ if (!activeId) return;
+ setError(null);
+ try {
+ const snapshot = await teamRequest<{ workspace: CloudWorkspace; messages: CloudMessage[]; files: CloudFile[] }>("workspace.snapshot", { workspaceId: activeId });
+ setWorkspaces((current) => current.map((workspace) => workspace.id === activeId ? snapshot.workspace : workspace));
+ setMessages(snapshot.messages);
+ setFiles(snapshot.files);
+ contentWorkspaceRef.current = activeId;
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : String(reason));
+ }
+ }, [activeId]);
+
+ const connect = useCallback(async () => {
+ setLoading(true); setError(null);
+ try {
+ const base = window.__FRANKLIN__?.cloudUrl || "http://127.0.0.1:3740";
+ const health = await fetch(`${base}/health`, {
+ headers: { "X-Franklin-Desktop-Token": window.__FRANKLIN__?.cloudToken || "" },
+ redirect: "error",
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!health.ok) throw new Error("Franklin Desktop service is unavailable");
+ setConnected(true);
+ await refreshWorkspaces();
+ }
+ catch (reason) {
+ setError(reason instanceof Error ? reason.message : String(reason));
+ }
+ finally { setLoading(false); }
+ }, [refreshWorkspaces]);
+
+ useEffect(() => { void connect(); }, [connect]);
+
+ useEffect(() => {
+ if (!connected || !session || !activeId) return;
+ setLoading(true);
+ refreshActive().finally(() => setLoading(false));
+ }, [activeId, connected, refreshActive, session]);
+
+ const createWorkspace = async (name: string) => {
+ setLoading(true); setError(null);
+ try {
+ const result = await teamRequest<{ workspace: CloudWorkspace }>("workspace.create", { name });
+ await refreshWorkspaces();
+ setActiveId(result.workspace.id);
+ } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
+ finally { setLoading(false); }
+ };
+
+ const joinWorkspace = async (code: string) => {
+ setLoading(true); setError(null);
+ try {
+ const result = await teamRequest<{ workspace: CloudWorkspace }>("workspace.join", { code });
+ await refreshWorkspaces();
+ setActiveId(result.workspace.id);
+ } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
+ finally { setLoading(false); }
+ };
+
+ const createInvite = async (role: "member" | "viewer" = "member") => {
+ if (!activeId) throw new Error("Select a workspace first");
+ return teamRequest<{ invite: { code: string; expiresAt: string } }>("workspace.invite", { workspaceId: activeId, role });
+ };
+
+ const updateMemberRole = async (targetWallet: string, role: "admin" | "member" | "viewer") => {
+ if (!activeId) return;
+ await teamRequest("member.role", { workspaceId: activeId, targetWallet, role });
+ await refreshActive();
+ };
+
+ const sendMessage = async (content: string) => {
+ if (!activeId) return;
+ setSending(true); setError(null);
+ try {
+ await agentTurn(activeId, content);
+ await refreshActive();
+ } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
+ finally { setSending(false); }
+ };
+
+ const saveFile = async (filePath: string, content: string) => {
+ if (!activeId) return;
+ const existing = files.find((file) => file.path === filePath);
+ await teamRequest("file.save", { workspaceId: activeId, path: filePath, content, expectedVersion: existing?.version });
+ await refreshActive();
+ };
+
+ const readFile = async (filePath: string) => {
+ if (!activeId) throw new Error("Select a workspace first");
+ return teamRequest<{ path: string; content: string; version: number }>("file.read", { workspaceId: activeId, path: filePath });
+ };
+
+ return {
+ connected, session, workspaces, active, activeId, messages, files, loading, sending, error,
+ setActiveId, connect, createWorkspace, joinWorkspace, createInvite, updateMemberRole, sendMessage, saveFile, readFile,
+ refreshActive,
+ };
+}
diff --git a/apps/desktop/src/hooks/use-cost-saver.ts b/apps/desktop/src/hooks/use-cost-saver.ts
index fb6d0d89..b49de4d1 100644
--- a/apps/desktop/src/hooks/use-cost-saver.ts
+++ b/apps/desktop/src/hooks/use-cost-saver.ts
@@ -11,17 +11,21 @@ export function useCostSaver() {
useEffect(() => {
let alive = true;
- agent
- .request("settings.get")
- .then((r) => {
- if (alive) {
- setEnabled(r?.costSaver !== false);
- setReady(true);
- }
- })
- .catch(() => alive && setReady(true));
+ const off = agent.onState((state) => {
+ if (state !== "open") return;
+ agent
+ .request("settings.get")
+ .then((r) => {
+ if (alive) {
+ setEnabled(r?.costSaver !== false);
+ setReady(true);
+ }
+ })
+ .catch(() => alive && setReady(true));
+ });
return () => {
alive = false;
+ off();
};
}, []);
diff --git a/apps/desktop/src/hooks/use-franklin-chat.ts b/apps/desktop/src/hooks/use-franklin-chat.ts
index 2e504023..0dc66281 100644
--- a/apps/desktop/src/hooks/use-franklin-chat.ts
+++ b/apps/desktop/src/hooks/use-franklin-chat.ts
@@ -12,7 +12,7 @@
import { useCallback, useRef, useState } from "react";
import { agent } from "../lib/ws";
import { useModels } from "./use-models";
-import type { AgentPermissionAsk, AgentSendPayload, AgentStep, ServerMsg } from "../lib/wire";
+import type { AgentSendPayload, AgentStep, ServerMsg } from "../lib/wire";
export type ChatMode = "chat" | "image" | "video" | "music";
@@ -30,18 +30,19 @@ export interface ChatModel {
// Curated fallback lineup (mirrors Franklin's /model picker) used until the CLI
// returns its live catalog over models.list.
export const CHAT_MODELS: ChatModel[] = [
- { id: "nvidia/deepseek-v4-flash", label: "DeepSeek V4 Flash", free: true, group: "Free" },
- { id: "nvidia/qwen3-coder-480b", label: "Qwen3 Coder 480B", free: true, group: "Free" },
- { id: "nvidia/llama-4-maverick", label: "Llama 4 Maverick", free: true, group: "Free" },
+ { id: "nvidia/nemotron-nano-9b-v2", label: "Nemotron Nano 9B v2", free: true, group: "Free" },
+ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni", free: true, group: "Free" },
+ { id: "nvidia/mistral-nemotron", label: "Mistral Nemotron", free: true, group: "Free" },
{ id: "anthropic/claude-opus-4.8", label: "Claude Opus 4.8", group: "Premium frontier" },
{ id: "anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", group: "Premium frontier" },
+ { id: "qwen/qwen3.7-max", label: "Qwen3.7 Max", group: "Premium frontier", contextWindow: 1_000_000 },
{ id: "openai/gpt-5.5", label: "GPT-5.5", group: "Premium frontier" },
{ id: "google/gemini-3.1-pro", label: "Gemini 3.1 Pro", group: "Premium frontier" },
{ id: "openai/o3", label: "OpenAI O3", group: "Reasoning" },
{ id: "deepseek/deepseek-v4-pro", label: "DeepSeek V4 Pro", group: "Reasoning" },
- { id: "anthropic/claude-haiku-4.5-20251001", label: "Claude Haiku 4.5", group: "Budget" },
+ { id: "anthropic/claude-haiku-4.5", label: "Claude Haiku 4.5", group: "Budget" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash", group: "Budget" },
- { id: "moonshot/kimi-k2.6", label: "Kimi K2.6", group: "Budget" },
+ { id: "moonshot/kimi-k3", label: "Kimi K3", group: "Premium frontier", contextWindow: 1_048_576 },
];
// GPT Image 2 leads — the only one here that supports non-square ratios, so it
@@ -140,6 +141,12 @@ export interface MediaJob {
phase: "signing" | "generating";
}
+export interface PendingPermission {
+ askId: string;
+ toolName: string;
+ description: string;
+}
+
type Setter = ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]);
export function useFranklinChat(
@@ -163,6 +170,7 @@ export function useFranklinChat(
const [error, setError] = useState(null);
const [genConvId, setGenConvId] = useState(null);
const [mediaJobs, setMediaJobs] = useState>({});
+ const [pendingPermission, setPendingPermission] = useState(null);
const cancelRef = useRef<(() => void) | null>(null);
const recordRef = useRef(recordSpend);
@@ -231,6 +239,8 @@ export function useFranklinChat(
setGenConvId(convId);
const media = m === "image" || m === "video" || m === "music";
+ let streamedChars = 0;
+ const maxStreamChars = 1_000_000;
if (media) {
setMediaJobs((p) => ({ ...p, [convId]: { kind: m, phase: "generating" } }));
setStatus("generating");
@@ -242,49 +252,64 @@ export function useFranklinChat(
switch (msg.kind) {
case "agent.text": {
const p = msg.payload as { text: string };
+ const incoming = String(p.text || "");
+ const remaining = maxStreamChars - streamedChars;
+ if (remaining <= 0) break;
+ const chunk = incoming.slice(0, remaining);
+ streamedChars += chunk.length;
// Append to the current assistant text bubble, OR start a fresh one
// if the previous segment was a tool group — that keeps the order
// narration → tools → answer (Codex-style ordered parts).
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.role === "assistant" && last.kind === "text") {
- return [...prev.slice(0, -1), { ...last, content: last.content + p.text }];
+ return [...prev.slice(0, -1), { ...last, content: last.content + chunk }];
}
- return [...prev, { role: "assistant", content: p.text, kind: "text" }];
+ return [...prev, { role: "assistant", content: chunk, kind: "text" }];
}, convId);
+ if (incoming.length > remaining) {
+ cancelRef.current?.();
+ cancelRef.current = null;
+ setError("Franklin response exceeded the 1,000,000-character display limit");
+ setStatus("error");
+ setGenConvId(null);
+ break;
+ }
if (!media) setStatus("thinking");
break;
}
case "agent.step": {
const p = msg.payload as AgentStep;
+ const label = String(p.label || "Tool").slice(0, 500);
const st: ToolStep["state"] = p.state === "done" ? "done" : p.state === "sign" ? "sign" : "run";
setStatus(p.state === "sign" ? "signing" : media ? "generating" : "thinking");
- if (p.state === "run") setActiveTool(p.label);
+ if (p.state === "run") setActiveTool(label);
else if (p.state === "done") setActiveTool(null);
// Upsert into a trailing "tools" segment, creating one inline if the
// last item isn't already a tool group.
setMessages((prev) => {
const last = prev[prev.length - 1];
- const detail = p.detail?.trim() || undefined;
+ const detail = p.detail?.trim().slice(0, 2_000) || undefined;
if (last && last.kind === "tools" && last.tools) {
const prior = last.tools.find((s) => s.id === p.stepId);
// Keep the detail from the start event if a later done event
// arrives without one.
- const step: ToolStep = { id: p.stepId, label: p.label, detail: detail ?? prior?.detail, state: st };
+ const step: ToolStep = { id: p.stepId, label, detail: detail ?? prior?.detail, state: st };
const tools = prior
? last.tools.map((s) => (s.id === p.stepId ? step : s))
: [...last.tools, step];
return [...prev.slice(0, -1), { ...last, tools }];
}
- return [...prev, { role: "assistant", content: "", kind: "tools", tools: [{ id: p.stepId, label: p.label, detail, state: st }] }];
+ return [...prev, { role: "assistant", content: "", kind: "tools", tools: [{ id: p.stepId, label, detail, state: st }] }];
}, convId);
break;
}
case "agent.tool_result": {
const p = msg.payload as { artifacts?: Array<{ path: string; mediaType: string }>; isError?: boolean; preview?: string };
- if (p.isError && p.preview) setError(p.preview);
+ if (p.isError && p.preview) setError(String(p.preview).slice(0, 4_000));
if (p.artifacts) {
- for (const a of p.artifacts) {
+ for (const a of p.artifacts.slice(0, 20)) {
+ if (typeof a.path !== "string" || a.path.length > 5_500_000 || typeof a.mediaType !== "string") continue;
if (a.mediaType.startsWith("image/")) {
setMessages((prev) => [...prev, { role: "assistant", content: text, kind: "image", image: a.path }], convId);
} else if (a.mediaType.startsWith("video/")) {
@@ -302,6 +327,15 @@ export function useFranklinChat(
if (p.usd) recordRef.current?.(p.model || modelId || "", p.usd);
break;
}
+ case "agent.permissionAsk": {
+ const p = msg.payload as PendingPermission;
+ if (p.askId && p.toolName) setPendingPermission({
+ askId: String(p.askId),
+ toolName: String(p.toolName),
+ description: String(p.description || "Franklin requested permission to continue."),
+ });
+ break;
+ }
case "agent.done": {
const p = msg.payload as { costUsd?: number };
if (p.costUsd) recordRef.current?.(modelId ?? "", p.costUsd);
@@ -318,6 +352,7 @@ export function useFranklinChat(
}, convId);
clearMediaJob(convId);
cancelRef.current = null;
+ setPendingPermission(null);
break;
}
case "agent.error": {
@@ -327,17 +362,7 @@ export function useFranklinChat(
setGenConvId(null);
clearMediaJob(convId);
cancelRef.current = null;
- break;
- }
- case "agent.permissionAsk": {
- const p = msg.payload as AgentPermissionAsk;
- const approved = window.confirm(
- `Franklin wants to use ${p.toolName}.\n\n${p.description}\n\nAllow this action?`,
- );
- agent.emit("agent.permissionResponse", {
- askId: p.askId,
- decision: approved ? "y" : "n",
- });
+ setPendingPermission(null);
break;
}
default:
@@ -369,6 +394,10 @@ export function useFranklinChat(
const send = useCallback(
(text: string, attachment?: string, modeOverride?: ChatMode, modelOverride?: string, forceTool?: string) => {
const trimmed = text.trim();
+ if (trimmed.length > 100_000) {
+ setError("Message is too large (maximum 100,000 characters)");
+ return;
+ }
if (!trimmed && !attachment) return;
if (isBusy) return;
const m = modeOverride ?? mode;
@@ -402,21 +431,31 @@ export function useFranklinChat(
);
const stop = useCallback(() => {
+ if (pendingPermission) agent.emit("agent.permissionResponse", { askId: pendingPermission.askId, decision: "n" });
cancelRef.current?.();
cancelRef.current = null;
setStatus("idle");
setGenConvId(null);
- }, []);
+ setPendingPermission(null);
+ }, [pendingPermission]);
+
+ const respondToPermission = useCallback((decision: "y" | "n") => {
+ if (!pendingPermission) return;
+ agent.emit("agent.permissionResponse", { askId: pendingPermission.askId, decision });
+ setPendingPermission(null);
+ }, [pendingPermission]);
const stopMedia = useCallback(
(convId: string) => {
+ if (pendingPermission) agent.emit("agent.permissionResponse", { askId: pendingPermission.askId, decision: "n" });
cancelRef.current?.();
cancelRef.current = null;
clearMediaJob(convId);
setStatus("idle");
setGenConvId(null);
+ setPendingPermission(null);
},
- [clearMediaJob],
+ [clearMediaJob, pendingPermission],
);
const regenerate = useCallback(() => {
@@ -446,6 +485,8 @@ export function useFranklinChat(
genConvId,
mediaJobs,
error,
+ pendingPermission,
+ respondToPermission,
isBusy,
send,
stop,
diff --git a/apps/desktop/src/hooks/use-mcp.ts b/apps/desktop/src/hooks/use-mcp.ts
new file mode 100644
index 00000000..fb60872e
--- /dev/null
+++ b/apps/desktop/src/hooks/use-mcp.ts
@@ -0,0 +1,61 @@
+// MCP server status for the visualization panel. Asks the local agent (serve)
+// which MCP servers it connected, with transport / tool-count / OAuth / failure
+// diagnostics. Refetches on (re)connect; exposes a manual refresh.
+
+import { useCallback, useEffect, useState } from "react";
+import { agent } from "../lib/ws";
+
+export interface McpServer {
+ name: string;
+ transport: "stdio" | "http" | "sse";
+ toolCount: number;
+ tools: string[];
+ filtered: number;
+ hasOAuth: boolean;
+ oauthAuthorized: boolean;
+}
+
+export interface McpFailure {
+ name: string;
+ reason: string;
+ transportKind: "stdio" | "http" | "sse";
+ stderrTail?: string[];
+}
+
+interface McpListResponse {
+ servers?: McpServer[];
+ failures?: McpFailure[];
+}
+
+export function useMcp(): {
+ servers: McpServer[];
+ failures: McpFailure[];
+ loading: boolean;
+ refresh: () => void;
+} {
+ const [servers, setServers] = useState([]);
+ const [failures, setFailures] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const fetchMcp = useCallback(async () => {
+ setLoading(true);
+ try {
+ const r = await agent.request("mcp.list");
+ setServers(r?.servers ?? []);
+ setFailures(r?.failures ?? []);
+ } catch {
+ /* best-effort */
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const off = agent.onState((s) => {
+ if (s === "open") void fetchMcp();
+ });
+ return off;
+ }, [fetchMcp]);
+
+ return { servers, failures, loading, refresh: fetchMcp };
+}
diff --git a/apps/desktop/src/hooks/use-models.ts b/apps/desktop/src/hooks/use-models.ts
index c11a6a3e..a72834f6 100644
--- a/apps/desktop/src/hooks/use-models.ts
+++ b/apps/desktop/src/hooks/use-models.ts
@@ -18,7 +18,7 @@ export function useModels(): { models: ModelInfo[]; isLoading: boolean } {
if (state !== "open" || models.length > 0) return;
try {
const resp = await agent.request("models.list");
- setModels(resp?.models ?? []);
+ setModels(Array.isArray(resp?.models) ? resp.models.slice(0, 1_000) : []);
} catch {
// Non-fatal; UI falls back to a single "default" entry and the CLI
// resolves whatever the user picked at runtime.
diff --git a/apps/desktop/src/hooks/use-phone-call.ts b/apps/desktop/src/hooks/use-phone-call.ts
index d4d9cc77..98460011 100644
--- a/apps/desktop/src/hooks/use-phone-call.ts
+++ b/apps/desktop/src/hooks/use-phone-call.ts
@@ -10,7 +10,6 @@
// CLI exposes those RPCs.
import { useCallback, useState } from "react";
-import { useWallet } from "./use-wallet";
export interface PhoneNumber {
phone_number: string;
@@ -18,7 +17,6 @@ export interface PhoneNumber {
}
export function usePhoneCall() {
- const { wallet } = useWallet();
const [numbers] = useState([]);
const [numbersError] = useState(null);
const [loadingNumbers] = useState(false);
@@ -32,7 +30,7 @@ export function usePhoneCall() {
const renewNumber = useCallback(async (_phone: string) => {}, []);
return {
- isConnected: !!wallet,
+ isConnected: false,
numbers,
numbersError,
loadingNumbers,
diff --git a/apps/desktop/src/hooks/use-sidebar-preferences.ts b/apps/desktop/src/hooks/use-sidebar-preferences.ts
new file mode 100644
index 00000000..23d02392
--- /dev/null
+++ b/apps/desktop/src/hooks/use-sidebar-preferences.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+
+export type SidebarItemId = "agents" | "tools" | "gallery" | "cli" | "mcp" | "skills" | "phone" | "wallet";
+
+export const SIDEBAR_ITEMS: { id: SidebarItemId; label: string }[] = [
+ { id: "agents", label: "Agents" },
+ { id: "tools", label: "Marketplace" },
+ { id: "gallery", label: "Gallery" },
+ { id: "cli", label: "Install CLI" },
+ { id: "mcp", label: "MCP" },
+ { id: "skills", label: "Skills" },
+ { id: "phone", label: "Phone" },
+ { id: "wallet", label: "Wallet" },
+];
+
+const STORAGE_KEY = "franklin-sidebar-preferences-v1";
+const CHANGE_EVENT = "franklin:sidebar-preferences";
+const DEFAULT_VISIBLE = SIDEBAR_ITEMS.map((item) => item.id);
+
+function loadVisible(): SidebarItemId[] {
+ if (typeof window === "undefined") return DEFAULT_VISIBLE;
+ try {
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
+ if (!Array.isArray(saved)) return DEFAULT_VISIBLE;
+ return DEFAULT_VISIBLE.filter((id) => saved.includes(id));
+ } catch {
+ return DEFAULT_VISIBLE;
+ }
+}
+
+export function useSidebarPreferences() {
+ const [visibleItems, setVisibleItems] = useState(loadVisible);
+
+ useEffect(() => {
+ const sync = () => setVisibleItems(loadVisible());
+ window.addEventListener(CHANGE_EVENT, sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener(CHANGE_EVENT, sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, []);
+
+ const save = (next: SidebarItemId[]) => {
+ setVisibleItems(next);
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* local cache unavailable */ }
+ window.dispatchEvent(new Event(CHANGE_EVENT));
+ };
+
+ const setItemVisible = (id: SidebarItemId, visible: boolean) => {
+ save(visible
+ ? DEFAULT_VISIBLE.filter((item) => item === id || visibleItems.includes(item))
+ : visibleItems.filter((item) => item !== id));
+ };
+
+ const reset = () => save(DEFAULT_VISIBLE);
+
+ return { visibleItems, setItemVisible, reset };
+}
diff --git a/apps/desktop/src/hooks/use-studio-registry.ts b/apps/desktop/src/hooks/use-studio-registry.ts
new file mode 100644
index 00000000..7857ee3f
--- /dev/null
+++ b/apps/desktop/src/hooks/use-studio-registry.ts
@@ -0,0 +1,195 @@
+import { useEffect, useMemo, useState } from "react";
+
+export type AgentId = "franklin" | "codex" | "claude" | "hermes" | "deepseek";
+
+export interface StudioAgent {
+ id: AgentId;
+ name: string;
+ command: string;
+ protocol: string;
+ description: string;
+ installed: boolean;
+ running: boolean;
+ blockrunEnabled: boolean;
+ builtIn?: boolean;
+ experimental?: boolean;
+ available?: boolean;
+ version?: string;
+ path?: string;
+ endpoint?: string;
+ lifecycleSupported?: boolean;
+ error?: string;
+}
+
+interface StudioState {
+ agents: StudioAgent[];
+ teamModeEnabled: boolean;
+}
+
+const STORAGE_KEY = "franklin-agent-studio-registry-v2";
+
+const DEFAULT_AGENTS: StudioAgent[] = [
+ {
+ id: "franklin",
+ name: "Franklin",
+ command: "franklin",
+ protocol: "Franklin WebSocket",
+ description: "BlockRun's first-party agent, bundled with Franklin.",
+ installed: true,
+ running: true,
+ blockrunEnabled: true,
+ builtIn: true,
+ },
+ {
+ id: "codex",
+ name: "Codex CLI",
+ command: "codex app-server",
+ protocol: "JSON-RPC",
+ description: "Import Codex sessions, approvals and tool events through app-server.",
+ installed: false,
+ running: false,
+ blockrunEnabled: false,
+ },
+ {
+ id: "claude",
+ name: "Claude Code",
+ command: "claude -p",
+ protocol: "Agent SDK / stream-json",
+ description: "Run Claude Code with structured streaming and native permissions.",
+ installed: false,
+ running: false,
+ blockrunEnabled: false,
+ },
+ {
+ id: "hermes",
+ name: "Hermes Agent",
+ command: "hermes serve",
+ protocol: "TUI Gateway JSON-RPC",
+ description: "Connect Hermes sessions, tools and approvals to the same workspace.",
+ installed: false,
+ running: false,
+ blockrunEnabled: false,
+ },
+ {
+ id: "deepseek",
+ name: "DeepSeek Harness",
+ command: "dsh web",
+ protocol: "Harness plugin",
+ description: "Experimental adapter for DeepSeek's plugin-based agent runtime.",
+ installed: false,
+ running: false,
+ blockrunEnabled: false,
+ experimental: true,
+ },
+];
+
+function initialState(): StudioState {
+ if (typeof window === "undefined") return { agents: DEFAULT_AGENTS, teamModeEnabled: true };
+ try {
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as Partial | null;
+ const savedAgents = saved?.agents;
+ if (!savedAgents) return { agents: DEFAULT_AGENTS, teamModeEnabled: true };
+ // Merge stored state into the current catalogue so newly shipped adapters appear.
+ return {
+ agents: DEFAULT_AGENTS.map((agent) => ({
+ ...agent,
+ ...savedAgents.find((savedAgent) => savedAgent.id === agent.id),
+ })),
+ teamModeEnabled: saved.teamModeEnabled ?? true,
+ };
+ } catch {
+ return { agents: DEFAULT_AGENTS, teamModeEnabled: true };
+ }
+}
+
+export function useStudioRegistry() {
+ const [state, setState] = useState(initialState);
+ const [scanning, setScanning] = useState(false);
+
+ useEffect(() => {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { /* local cache unavailable */ }
+ }, [state]);
+
+ const updateAgent = (id: AgentId, patch: Partial) => {
+ setState((current) => ({
+ ...current,
+ agents: current.agents.map((agent) => agent.id === id ? { ...agent, ...patch } : agent),
+ }));
+ };
+
+ const scanRuntimes = async () => {
+ const scan = window.__FRANKLIN__?.scanAgentRuntimes;
+ if (!scan) return;
+ setScanning(true);
+ try {
+ const detected = await scan();
+ setState((current) => ({
+ ...current,
+ agents: current.agents.map((agent) => {
+ const runtime = detected.find((item) => item.id === agent.id);
+ return runtime ? {
+ ...agent,
+ available: runtime.available,
+ running: runtime.running,
+ path: runtime.path,
+ version: runtime.version,
+ endpoint: runtime.endpoint,
+ lifecycleSupported: runtime.lifecycleSupported,
+ error: runtime.error,
+ } : agent;
+ }),
+ }));
+ } finally { setScanning(false); }
+ };
+
+ useEffect(() => { void scanRuntimes(); }, []);
+
+ const importAgent = async (id: AgentId) => {
+ if (id === "franklin") return;
+ const start = window.__FRANKLIN__?.startAgentRuntime;
+ if (!start) { updateAgent(id, { installed: true, running: true }); return; }
+ updateAgent(id, { error: undefined });
+ const result = await start(id);
+ updateAgent(id, {
+ installed: result.ok,
+ running: result.running,
+ available: result.available,
+ version: result.version,
+ path: result.path,
+ endpoint: result.endpoint,
+ lifecycleSupported: result.lifecycleSupported,
+ error: result.error,
+ });
+ };
+ const removeAgent = async (id: AgentId) => {
+ await window.__FRANKLIN__?.stopAgentRuntime?.(id);
+ updateAgent(id, { installed: false, running: false, blockrunEnabled: false, endpoint: undefined, error: undefined });
+ };
+ const setRunning = async (id: AgentId, running: boolean) => {
+ const action = running ? window.__FRANKLIN__?.startAgentRuntime : window.__FRANKLIN__?.stopAgentRuntime;
+ if (!action) { updateAgent(id, { running }); return; }
+ const result = await action(id);
+ const endpoint = (result as { endpoint?: string }).endpoint;
+ updateAgent(id, { running: result.running, endpoint: result.running ? endpoint : undefined, error: result.error });
+ };
+ const setBlockRun = (id: AgentId, blockrunEnabled: boolean) => {
+ if (id === "franklin") updateAgent(id, { blockrunEnabled });
+ };
+ const setTeamModeEnabled = (teamModeEnabled: boolean) => setState((current) => ({ ...current, teamModeEnabled }));
+
+ const installedCount = useMemo(() => state.agents.filter((agent) => agent.installed).length, [state.agents]);
+ const connectedCount = useMemo(() => state.agents.filter((agent) => agent.installed && agent.blockrunEnabled).length, [state.agents]);
+
+ return {
+ ...state,
+ installedCount,
+ connectedCount,
+ scanning,
+ scanRuntimes,
+ importAgent,
+ removeAgent,
+ setRunning,
+ setBlockRun,
+ setTeamModeEnabled,
+ };
+}
diff --git a/apps/desktop/src/hooks/use-wallet.ts b/apps/desktop/src/hooks/use-wallet.ts
index 74786bfe..cf8585ea 100644
--- a/apps/desktop/src/hooks/use-wallet.ts
+++ b/apps/desktop/src/hooks/use-wallet.ts
@@ -16,6 +16,7 @@ let current: WalletInfo | null = null;
let loading = true;
let lastError: string | null = null;
let connectionState: AgentConnectionState = agent.state;
+let switchingChain: "base" | "solana" | null = null;
let started = false;
const subs = new Set<() => void>();
@@ -67,6 +68,8 @@ export function useWallet(): {
isLoading: boolean;
error: string | null;
connectionState: AgentConnectionState;
+ switchingChain: "base" | "solana" | null;
+ switchChain: (chain: "base" | "solana") => Promise;
} {
ensureStarted();
const [, force] = useState(0);
@@ -75,5 +78,22 @@ export function useWallet(): {
subs.add(fn);
return () => { subs.delete(fn); };
}, []);
- return { wallet: current, isLoading: loading, error: lastError, connectionState };
+ const switchChain = async (chain: "base" | "solana") => {
+ if (switchingChain || current?.chain === chain) return;
+ const switchWallet = window.__FRANKLIN__?.switchWalletChain;
+ if (!switchWallet) throw new Error("Wallet switching is available in Franklin Desktop");
+ switchingChain = chain;
+ lastError = null;
+ emit();
+ try {
+ await switchWallet(chain);
+ } catch (err) {
+ lastError = err instanceof Error ? err.message : "Failed to switch wallet network";
+ throw err;
+ } finally {
+ switchingChain = null;
+ emit();
+ }
+ };
+ return { wallet: current, isLoading: loading, error: lastError, connectionState, switchingChain, switchChain };
}
diff --git a/apps/desktop/src/lib/external-url.ts b/apps/desktop/src/lib/external-url.ts
new file mode 100644
index 00000000..9c0d4852
--- /dev/null
+++ b/apps/desktop/src/lib/external-url.ts
@@ -0,0 +1,9 @@
+export function safeExternalHttpUrl(value: unknown): string | null {
+ try {
+ const url = new URL(String(value));
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return null;
+ return url.href;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/desktop/src/lib/team-workspace-events.ts b/apps/desktop/src/lib/team-workspace-events.ts
new file mode 100644
index 00000000..7bf27a13
--- /dev/null
+++ b/apps/desktop/src/lib/team-workspace-events.ts
@@ -0,0 +1,36 @@
+export interface TeamWorkspaceNavItem {
+ id: string;
+ name: string;
+ role: "owner" | "admin" | "member" | "viewer";
+ memberCount: number;
+ version: number;
+}
+
+export interface TeamWorkspaceNavState {
+ items: TeamWorkspaceNavItem[];
+ activeId: string | null;
+ loading: boolean;
+}
+
+const NAV_EVENT = "franklin:team-workspaces";
+const SELECT_EVENT = "franklin:team-workspace-select";
+
+export function publishTeamWorkspaceNav(state: TeamWorkspaceNavState) {
+ window.dispatchEvent(new CustomEvent(NAV_EVENT, { detail: state }));
+}
+
+export function subscribeTeamWorkspaceNav(listener: (state: TeamWorkspaceNavState) => void) {
+ const handler = (event: Event) => listener((event as CustomEvent).detail);
+ window.addEventListener(NAV_EVENT, handler);
+ return () => window.removeEventListener(NAV_EVENT, handler);
+}
+
+export function requestTeamWorkspace(id: string | null) {
+ window.dispatchEvent(new CustomEvent(SELECT_EVENT, { detail: id }));
+}
+
+export function subscribeTeamWorkspaceRequest(listener: (id: string | null) => void) {
+ const handler = (event: Event) => listener((event as CustomEvent).detail);
+ window.addEventListener(SELECT_EVENT, handler);
+ return () => window.removeEventListener(SELECT_EVENT, handler);
+}
diff --git a/apps/desktop/src/lib/wire.ts b/apps/desktop/src/lib/wire.ts
index bcfd01c4..e8c498b0 100644
--- a/apps/desktop/src/lib/wire.ts
+++ b/apps/desktop/src/lib/wire.ts
@@ -29,6 +29,11 @@ export type ClientMsgKind =
| "history.load"
| "history.save"
| "models.list"
+ | "mcp.list"
+ | "skills.list"
+ | "skills.toggle"
+ | "skills.create"
+ | "skills.upload"
| "settings.get"
| "settings.set";
diff --git a/apps/desktop/src/lib/ws.ts b/apps/desktop/src/lib/ws.ts
index 55957663..ffa0adea 100644
--- a/apps/desktop/src/lib/ws.ts
+++ b/apps/desktop/src/lib/ws.ts
@@ -53,9 +53,10 @@ class AgentSocket {
if (injected) {
this.url = injected;
} else if (location.protocol === "file:") {
- // Packaged Electron without an injected URL — fall back to the default
- // local agent port.
- this.url = "ws://127.0.0.1:3737/agent";
+ // A packaged renderer must receive its per-process tokenized URL from
+ // preload. Falling back to an unauthenticated fixed port would silently
+ // discard that boundary if preload failed.
+ throw new Error("Franklin Desktop agent bridge is unavailable");
} else {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
this.url = `${proto}//${location.host}/agent`;
diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css
index bb1cf575..61bfae14 100644
--- a/apps/desktop/src/styles/globals.css
+++ b/apps/desktop/src/styles/globals.css
@@ -1,6 +1,45 @@
@import "tailwindcss";
@import "tw-animate-css";
-@import "shadcn/tailwind.css";
+
+/* Vendored from shadcn's small Tailwind helper. Keeping these static variants
+ avoids shipping the full scaffolding CLI and its server dependency graph. */
+@theme inline {
+ @keyframes accordion-down {
+ from { height: 0; }
+ to { height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)); }
+ }
+ @keyframes accordion-up {
+ from { height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)); }
+ to { height: 0; }
+ }
+}
+
+@custom-variant data-open {
+ &:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) { @slot; }
+}
+@custom-variant data-closed {
+ &:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) { @slot; }
+}
+@custom-variant data-checked {
+ &:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) { @slot; }
+}
+@custom-variant data-unchecked {
+ &:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) { @slot; }
+}
+@custom-variant data-selected { &:where([data-selected="true"]) { @slot; } }
+@custom-variant data-disabled {
+ &:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) { @slot; }
+}
+@custom-variant data-active {
+ &:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) { @slot; }
+}
+@custom-variant data-horizontal { &:where([data-orientation="horizontal"]) { @slot; } }
+@custom-variant data-vertical { &:where([data-orientation="vertical"]) { @slot; } }
+@utility no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar { display: none; }
+}
@custom-variant dark (&:is(.dark *));
@@ -55,27 +94,35 @@
--bill-green: #1a4d3a;
}
-/* Light is the product default. Gold and dark remain user-selectable themes. */
+/* Theme: "gold" is the default (:root above — warm cream + gold). "light" is a
+ cooler neutral white; "dark" is a dark mode. Applied via data-theme on . */
:root[data-theme="light"] {
+ /* Clean, modern light theme (à la ChatGPT / Linear / Doubao): crisp white +
+ subtle cool off-white surfaces, sharp near-black text, and a lively cool
+ BLUE accent for links / active states / primary actions. Cool, not warm —
+ so it stays clearly distinct from the gold theme — but not flat gray. */
--bg: #ffffff;
- --bg-alt: #f5f6f7;
- --fg: #17181a;
- --fg-muted: #62666d;
- --fg-subtle: #92969d;
- --border: #e7e8ea;
- --border-strong: #d8dade;
- --accent: #202124;
- --accent-hover: #34363a;
- --gold: #b88b12;
- --gold-hi: #b88b12;
- --gold-dim: #8a6810;
- --gold-line: rgba(166, 124, 0, 0.28);
- --gold-soft: rgba(184, 139, 18, 0.08);
- --success: #18835a;
+ --bg-alt: #f6f6f7; /* clean neutral surface (not warm cream) */
+ --fg: #0b0c0e;
+ --fg-muted: #585d66;
+ --fg-subtle: #8f939b;
+ --border: #e9eaec;
+ --border-strong: #d7d9dd;
+ /* Dark ink for primary buttons (white text reads on it), GOLD for the accent
+ thread — links, active nav, role labels, highlights. Gives the light theme
+ life via gold (no flat gray, no stark blue), on clean neutral-white. */
+ --accent: #1a1a1a;
+ --accent-hover: #333333;
+ --gold: #c9a227;
+ --gold-hi: #c9a227;
+ --gold-dim: #a87d1a;
+ --gold-line: rgba(201, 162, 39, 0.42);
+ --gold-soft: rgba(201, 162, 39, 0.12);
+ --success: #16a34a;
}
/* …but keep the "Franklin" wordmark gold even in light mode — the one
intentional brand-color touchpoint. */
-:root[data-theme="light"] .try-brand-name { color: #a67c00; }
+:root[data-theme="light"] .try-brand-name { color: #c9a227; }
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #1b1a17;
@@ -3267,6 +3314,24 @@ h2.dark-h,
transform: translate(-50%, -50%);
max-width: 50%;
}
+.try-bar-title-line { display: flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; }
+.try-bar-title-static { font-size: 15px; font-weight: 600; color: var(--fg); white-space: nowrap; }
+.try-team-mode-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex-shrink: 0;
+ padding: 4px 7px;
+ border: 1px solid var(--gold-line);
+ border-radius: 999px;
+ background: var(--gold-soft);
+ color: var(--gold-dim);
+ font-family: var(--font-mono);
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.try-team-mode-pill.is-local { border-color: var(--border); background: var(--bg-alt); color: var(--fg-subtle); }
.try-bar-title-btn {
font-size: 15px;
font-weight: 600;
@@ -3561,6 +3626,45 @@ h2.dark-h,
margin-bottom: 36px;
}
.try-empty-title em { font-style: italic; color: var(--gold); }
+.try-team-empty { display: flex; flex-direction: column; align-items: center; max-width: 680px; }
+.try-team-empty-icon {
+ display: grid;
+ place-items: center;
+ width: 52px;
+ height: 52px;
+ margin-bottom: -9px;
+ border-radius: 16px;
+ color: var(--gold-dim);
+ background: var(--gold-soft);
+ border: 1px solid var(--gold-line);
+}
+.try-team-avatars { display: flex; margin: 0 0 18px 74px; }
+.try-team-avatars span {
+ display: grid;
+ place-items: center;
+ width: 25px;
+ height: 25px;
+ margin-left: -6px;
+ border: 2px solid var(--bg);
+ border-radius: 50%;
+ color: white;
+ font-size: 9px;
+ font-weight: 700;
+}
+.try-team-avatars .is-andy { background: #8763d8; }
+.try-team-avatars .is-vicky { background: #4b9a85; }
+.try-team-avatars .is-franklin { background: var(--gold-dim); }
+.try-team-empty .try-empty-title { margin-bottom: 12px; }
+.try-team-empty-copy { max-width: 520px; color: var(--fg-muted); font-size: 14px; line-height: 1.55; }
+.try-team-capabilities { display: flex; flex-wrap: wrap; justify-content: center; gap: 7px; margin: 18px 0 28px; }
+.try-team-capabilities span {
+ padding: 5px 9px;
+ border-radius: 999px;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ color: var(--fg-subtle);
+ font-size: 11px;
+}
.try-suggestions {
display: flex;
flex-wrap: wrap;
@@ -3612,6 +3716,7 @@ h2.dark-h,
align-items: flex-end;
}
.try-msg-user .try-msg-role { display: none; }
+.try-msg-user.is-team .try-msg-role { display: block; color: var(--fg-subtle); }
.try-msg-user .try-msg-body {
background: var(--bg-alt);
border-radius: 20px;
@@ -3619,6 +3724,11 @@ h2.dark-h,
max-width: 78%;
font-size: 16px;
}
+.try-msg.is-team.try-msg-assistant {
+ padding-left: 14px;
+ border-left: 2px solid var(--gold-line);
+}
+.try-msg.is-team .try-msg-role { letter-spacing: 0.1em; }
/* Collapsible reasoning / chain-of-thought */
.try-reasoning {
@@ -4101,6 +4211,17 @@ h2.dark-h,
position: sticky;
bottom: 0;
}
+.try-team-sharing-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ max-width: 760px;
+ margin: 0 auto 8px;
+ color: var(--fg-subtle);
+ font-size: 10.5px;
+}
+.try-team-sharing-note svg { color: var(--gold-dim); }
.try-input-hint {
font-size: 13px;
color: var(--fg-muted);
@@ -4508,6 +4629,98 @@ h2.dark-h,
color: var(--gold-dim);
}
+/* Personal / Team workspace demo switcher. */
+.try-space-switch {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+ padding: 3px;
+ margin: 2px 2px 8px;
+ background: color-mix(in oklch, var(--fg) 5%, transparent);
+ border: 1px solid var(--border);
+ border-radius: 11px;
+}
+.try-space-switch button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 0;
+ padding: 7px 8px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--fg-subtle);
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.14s, color 0.14s, box-shadow 0.14s;
+}
+.try-space-switch button:hover { color: var(--fg); }
+.try-space-switch button.is-active {
+ background: var(--bg);
+ color: var(--fg);
+ box-shadow: 0 1px 4px rgba(31, 28, 22, 0.09);
+}
+.try-space-switch button.is-active svg { color: var(--gold-dim); }
+.try-team-beta {
+ padding: 1px 4px;
+ border: 1px solid color-mix(in oklch, var(--gold-dim) 35%, transparent);
+ border-radius: 999px;
+ background: color-mix(in oklch, var(--gold-dim) 10%, transparent);
+ color: var(--gold-dim);
+ font-size: 8px;
+ font-weight: 700;
+ line-height: 1.25;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.try-team-card {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: 0 2px 8px;
+ padding: 9px 10px;
+ border: 1px solid var(--gold-line);
+ border-radius: 11px;
+ background: linear-gradient(135deg, var(--gold-soft), color-mix(in oklch, var(--bg) 88%, transparent));
+ color: var(--fg);
+}
+.try-team-mark {
+ display: grid;
+ place-items: center;
+ width: 29px;
+ height: 29px;
+ flex-shrink: 0;
+ border-radius: 9px;
+ color: var(--gold-dim);
+ background: var(--bg);
+ border: 1px solid var(--gold-line);
+}
+.try-team-card > span:nth-child(2) { display: flex; flex: 1; min-width: 0; flex-direction: column; }
+.try-team-card strong { font-size: 12.5px; line-height: 1.25; }
+.try-team-card small { color: var(--fg-subtle); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.try-team-online {
+ width: 7px;
+ height: 7px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background: #34a853;
+ box-shadow: 0 0 0 3px color-mix(in oklch, #34a853 15%, transparent);
+}
+.try-team-workspaces { display: grid; gap: 4px; margin: 1px 2px 7px; }
+.try-team-workspaces-head { display: flex; align-items: center; justify-content: space-between; padding: 2px 7px 4px; color: var(--fg-subtle); font-size: 9px; font-weight: 750; letter-spacing: .09em; }
+.try-team-workspaces-head em { min-width: 18px; height: 18px; display: grid; place-items: center; padding: 0 5px; border: 1px solid var(--border); border-radius: 999px; font-style: normal; letter-spacing: 0; }
+.try-team-workspace { display: grid; grid-template-columns: 30px minmax(0,1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 8px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--fg); text-align: left; cursor: pointer; }
+.try-team-workspace:hover { background: var(--bg-hover); }
+.try-team-workspace.is-active { border-color: var(--gold-line); background: linear-gradient(135deg,var(--gold-soft),color-mix(in oklch,var(--bg) 90%,transparent)); }
+.try-team-workspace > span:nth-child(2) { min-width: 0; }
+.try-team-workspace strong,.try-team-workspace small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.try-team-workspace strong { font-size: 11.5px; line-height: 1.3; }
+.try-team-workspace small { margin-top: 2px; color: var(--fg-subtle); font-size: 9px; text-transform: capitalize; }
+.try-team-workspace > i { color: var(--fg-subtle); font-family: var(--font-mono); font-size: 8px; font-style: normal; }
+.try-team-workspace-loading { padding: 12px 8px; border: 1px dashed var(--border); border-radius: 9px; color: var(--fg-subtle); font-size: 10px; line-height: 1.4; text-align: center; }
+
/* Sidebar nav item (Phone panel) */
.try-nav-item {
display: flex;
@@ -4717,7 +4930,7 @@ h2.dark-h,
padding: 40px 28px;
}
.try-tools-inner {
- max-width: 980px;
+ max-width: 860px;
margin: 0 auto;
}
.try-tools-h {
@@ -4745,7 +4958,7 @@ h2.dark-h,
}
.try-tools-grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.try-tool-card {
@@ -4792,10 +5005,6 @@ h2.dark-h,
justify-content: space-between;
gap: 10px;
}
-.try-mkt-foot .try-tool-card-price {
- flex-shrink: 0;
- white-space: nowrap;
-}
.try-mkt-provider {
font-family: var(--font-mono);
font-size: 10.5px;
@@ -4998,6 +5207,15 @@ h2.dark-h,
padding: 40px 28px;
}
.try-wallet-inner { max-width: 720px; margin: 0 auto; }
+.try-wallet-network-card { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 18px; padding: 15px 16px; border: 1px solid var(--border); border-radius: 13px; background: var(--bg); }
+.try-wallet-network-card > div:first-child { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.try-wallet-network-card strong { font-size: 12px; }
+.try-wallet-network-card small { color: var(--fg-subtle); font-size: 10px; line-height: 1.45; }
+.try-wallet-network-options { display: inline-flex; flex: none; gap: 3px; padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-alt); }
+.try-wallet-network-options button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 76px; height: 31px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--fg-muted); font-size: 10px; font-weight: 600; cursor: pointer; }
+.try-wallet-network-options button.is-active { background: var(--bg); color: var(--gold-dim); box-shadow: 0 1px 3px rgba(31,28,22,.09); }
+.try-wallet-network-options button:disabled { opacity: .65; cursor: wait; }
+.try-wallet-network-options svg { width: 12px; height: 12px; }
.try-wallet-stats {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -5475,7 +5693,12 @@ h2.dark-h,
border-radius: 4px;
background: var(--bg-alt);
color: var(--fg-subtle);
+ cursor: pointer;
}
+.try-footer-wallet .try-wallet-chain { display: inline-flex; align-items: center; gap: 4px; }
+.try-footer-wallet .try-wallet-chain:hover:not(:disabled) { color: var(--gold-dim); background: var(--gold-soft); }
+.try-footer-wallet .try-wallet-chain:disabled { cursor: progress; }
+.try-footer-wallet .try-wallet-chain svg { width: 10px; height: 10px; }
.try-footer-wallet .try-wallet-net-warn {
background: color-mix(in oklch, var(--gold-dim) 10%, var(--bg));
color: var(--gold-dim);
@@ -5663,6 +5886,11 @@ h2.dark-h,
}
.try-sub:hover .try-submenu,
.try-sub:focus-within .try-submenu { display: block; }
+.try-sidebar-settings { min-width: 220px; max-height: 360px; overflow-y: auto; }
+.try-sidebar-settings-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 9px 6px; color: var(--fg-subtle); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }
+.try-sidebar-settings-head button { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent; color: var(--fg-subtle); font-size: 9px; cursor: pointer; }
+.try-sidebar-settings-head button:hover { color: var(--fg); }
+.try-sidebar-settings-head svg { width: 11px; height: 11px; }
@keyframes try-menu-in {
from { opacity: 0; transform: translateY(4px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
@@ -6450,596 +6678,463 @@ html.is-mac .try-brand {
}
/* =====================================================
- Desktop UI refresh — clearer Light hierarchy + motion
+ Agent Studio — detachable runtimes and workspace modules
===================================================== */
-
-html,
-body {
- transition: background-color 0.18s ease, color 0.18s ease;
-}
-
-.try-sidebar {
- width: 264px;
- padding: 14px 12px;
- transition:
- width 0.22s cubic-bezier(0.2, 0.7, 0.2, 1),
- padding 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
-}
-
-.try-brand {
- gap: 10px;
- padding: 2px 7px 12px;
-}
-
-.try-brand-ring {
- width: 28px;
- height: 28px;
- box-shadow: 0 1px 2px rgba(15, 18, 22, 0.08);
-}
-
-.try-brand-name {
- font-size: 21px;
- letter-spacing: -0.02em;
-}
-
-.try-nav-item {
- min-height: 38px;
- gap: 10px;
- border-radius: 10px;
- padding: 8px 10px;
- transition:
- background-color 0.16s ease,
- border-color 0.16s ease,
- color 0.16s ease,
- transform 0.16s ease;
-}
-
-.try-nav-item > svg:first-child,
-.try-more-item > svg:first-child {
- width: 18px;
- height: 18px;
- stroke-width: 1.8;
- transition: color 0.16s ease, transform 0.16s ease;
-}
-
-.try-nav-item:hover {
- transform: translateX(2px);
-}
-
-.try-nav-item:hover > svg:first-child,
-.try-more-item:hover > svg:first-child {
- transform: scale(1.06);
-}
-
-.try-history-item {
- min-height: 34px;
- padding: 7px 10px;
- transition:
- background-color 0.16s ease,
- color 0.16s ease,
- transform 0.16s ease;
-}
-
-.try-history-item:hover { transform: translateX(2px); }
-.try-history-icon { width: 16px; height: 16px; stroke-width: 1.8; }
-
-.try-lang-btn,
-.try-sidebar-toggle,
-.try-bar-more,
-.try-bar-share {
- transition:
- background-color 0.16s ease,
- border-color 0.16s ease,
- color 0.16s ease,
- transform 0.14s ease;
-}
-
-.try-lang-btn:hover,
-.try-sidebar-toggle:hover,
-.try-bar-more:hover,
-.try-bar-share:hover {
- transform: translateY(-1px);
-}
-
-.try-lang-btn:active,
-.try-sidebar-toggle:active,
-.try-bar-more:active,
-.try-bar-share:active,
-.try-send:active:not(:disabled),
-.try-tool-icon:active:not(:disabled),
-.try-tool:active:not(:disabled) {
- transform: scale(0.96);
-}
-
-.try-sidebar-toggle,
-.try-bar-more,
-.try-bar-share {
- width: 36px;
- height: 36px;
- align-items: center;
- justify-content: center;
-}
-
-.try-lang-btn { width: 40px; height: 40px; }
-
-.try-tool svg { width: 17px; height: 17px; stroke-width: 1.8; }
-.try-action svg { width: 16px; height: 16px; }
-
-.try-composer {
- border-radius: 22px;
- transition:
- border-color 0.18s ease,
- box-shadow 0.18s ease,
- transform 0.18s ease;
-}
-
-.try-send {
- width: 38px;
- height: 38px;
- transition:
- background-color 0.16s ease,
- box-shadow 0.16s ease,
- transform 0.14s ease;
-}
-
-.try-send:hover:not(:disabled) {
- box-shadow: 0 6px 16px -8px rgba(0, 0, 0, 0.7);
-}
-
-.try-settings-menu,
-.try-submenu,
-.try-more-flyout,
-.try-topbar-menu,
-.try-ratio-menu {
- box-shadow:
- 0 1px 2px rgba(15, 18, 22, 0.06),
- 0 18px 48px -18px rgba(15, 18, 22, 0.3);
-}
-
-.try-tool-card {
- transition:
- border-color 0.18s ease,
- box-shadow 0.18s ease,
- transform 0.18s ease;
-}
-
-.try-tool-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 26px -20px rgba(15, 18, 22, 0.45);
-}
-
-.try-empty,
-.try-tools-panel,
-.try-gallery,
-.try-wallet-panel,
-.try-phone {
- animation: try-panel-enter 0.2s cubic-bezier(0.2, 0.7, 0.2, 1);
-}
-
-.try-msg {
- animation: try-message-enter 0.18s ease-out;
-}
-
-@keyframes try-panel-enter {
- from { opacity: 0; transform: translateY(5px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes try-message-enter {
- from { opacity: 0; transform: translateY(3px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.try-nav-item:focus-visible,
-.try-history-item:focus-visible,
-.try-lang-btn:focus-visible,
-.try-sidebar-toggle:focus-visible,
-.try-bar-more:focus-visible,
-.try-bar-share:focus-visible,
-.try-tool:focus-visible,
-.try-tool-icon:focus-visible,
-.try-send:focus-visible,
-.try-suggestion:focus-visible,
-.try-select-option:focus-visible {
- outline: 2px solid color-mix(in oklch, var(--gold-dim) 65%, white);
- outline-offset: 2px;
-}
-
-:root[data-theme="light"] .try-shell,
-:root[data-theme="light"] .try-chat {
- background: #fbfbfc;
-}
-
-:root[data-theme="light"] .try-sidebar {
- background: #f4f5f6;
- border-right-color: #e4e5e7;
- box-shadow: inset -1px 0 rgba(255, 255, 255, 0.7);
-}
-
-:root[data-theme="light"] .try-chat-bar {
- background: rgba(251, 251, 252, 0.88);
- border-bottom-color: rgba(25, 28, 32, 0.07);
- backdrop-filter: saturate(130%) blur(14px);
- -webkit-backdrop-filter: saturate(130%) blur(14px);
-}
-
-:root[data-theme="light"] .try-nav-item:hover {
- background: rgba(255, 255, 255, 0.72);
-}
-
-:root[data-theme="light"] .try-nav-item.is-active,
-:root[data-theme="light"] .try-more-item.is-active {
- color: var(--fg);
- background: #ffffff;
- border-color: transparent;
- box-shadow:
- 0 1px 2px rgba(15, 18, 22, 0.06),
- 0 5px 16px -14px rgba(15, 18, 22, 0.35);
-}
-
-:root[data-theme="light"] .try-nav-item.is-active svg,
-:root[data-theme="light"] .try-more-item.is-active svg {
- color: var(--gold-dim);
-}
-
-:root[data-theme="light"] .try-history-item:hover {
- background: rgba(255, 255, 255, 0.7);
-}
-
-:root[data-theme="light"] .try-history-item.is-active {
- background: #ffffff;
- box-shadow: 0 1px 3px rgba(15, 18, 22, 0.06);
-}
-
-:root[data-theme="light"] .try-input-wrap {
- background: linear-gradient(180deg, transparent, #fbfbfc 28%);
-}
-
-:root[data-theme="light"] .try-composer {
- background: #ffffff;
- border-color: #dfe1e4;
- box-shadow:
- 0 1px 2px rgba(15, 18, 22, 0.04),
- 0 10px 32px -22px rgba(15, 18, 22, 0.35);
-}
-
-:root[data-theme="light"] .try-composer:focus-within {
- border-color: #b9bcc2;
- box-shadow:
- 0 0 0 3px rgba(32, 33, 36, 0.045),
- 0 14px 36px -24px rgba(15, 18, 22, 0.4);
-}
-
-:root[data-theme="light"] .try-msg-user .try-msg-body,
-:root[data-theme="light"] .try-toolgroup {
- background: #f3f4f5;
-}
-
-:root[data-theme="light"] .try-settings-menu,
-:root[data-theme="light"] .try-submenu,
-:root[data-theme="light"] .try-more-flyout,
-:root[data-theme="light"] .try-topbar-menu,
-:root[data-theme="light"] .try-ratio-menu {
- background: rgba(255, 255, 255, 0.97);
- border-color: #dfe1e4;
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
-}
-
-:root[data-theme="light"] .try-tool-card,
-:root[data-theme="light"] .try-wallet-stat,
-:root[data-theme="light"] .try-md-table-card {
- border-color: #e3e4e6;
- box-shadow: 0 1px 2px rgba(15, 18, 22, 0.025);
-}
-
-:root[data-theme="light"] .try-tool-card:hover {
- border-color: #d2d4d8;
-}
-
-/* ── Team workspace beta preview ───────────────────────────────────── */
-.try-workspace-switch {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 3px;
- margin: 2px 2px 9px;
- padding: 3px;
- border: 1px solid var(--border);
- border-radius: 11px;
- background: color-mix(in oklch, var(--bg) 52%, var(--bg-alt));
-}
-.try-workspace-switch button {
- min-width: 0;
- height: 30px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: var(--fg-subtle);
- font-size: 11.5px;
- font-weight: 600;
- cursor: pointer;
- transition: background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
-}
-.try-workspace-switch button:hover { color: var(--fg); }
-.try-workspace-switch button.is-active {
- color: var(--fg);
- background: var(--bg);
- box-shadow: 0 1px 3px rgba(15, 18, 22, 0.08);
-}
-.try-workspace-switch button span,
-.try-nav-beta {
- padding: 1px 5px;
- border-radius: 999px;
- background: var(--gold-soft);
- color: var(--gold-dim);
+.try-brand-name small {
+ margin-left: 4px;
font-family: var(--font-mono);
font-size: 8px;
- letter-spacing: 0.06em;
+ font-weight: 600;
+ letter-spacing: .12em;
text-transform: uppercase;
-}
-.try-team-sidebar-content { padding-top: 2px; }
-.try-team-sidebar-label {
- margin: 16px 10px 5px;
color: var(--fg-subtle);
- font-family: var(--font-mono);
- font-size: 9px;
- letter-spacing: 0.11em;
- text-transform: uppercase;
-}
-.try-nav-beta { margin-left: auto; }
-.try-nav-disabled {
- cursor: default;
- opacity: 0.68;
}
-.try-nav-disabled:hover {
- color: var(--fg-muted);
+.try-space-switch button:disabled { opacity: .38; cursor: not-allowed; }
+.try-team-disabled-note {
+ width: 100%;
+ margin: -2px 0 8px;
+ padding: 6px 8px;
+ border: 0;
background: transparent;
-}
-.try-nav-soon {
- margin-left: auto;
color: var(--fg-subtle);
- font-size: 9px;
- font-weight: 500;
+ font-size: 10px;
+ cursor: pointer;
+ text-align: center;
}
+.try-team-disabled-note:hover { color: var(--gold-dim); }
-.try-team-panel {
+.studio-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
- padding: 54px 34px 64px;
- animation: try-panel-enter 0.2s cubic-bezier(0.2, 0.7, 0.2, 1);
-}
-.try-team-inner {
- width: min(920px, 100%);
- margin: 0 auto;
-}
-.try-team-hero {
- max-width: 710px;
- margin-bottom: 28px;
+ background:
+ radial-gradient(circle at 82% 0%, var(--gold-soft), transparent 28%),
+ var(--bg);
}
-.try-team-eyebrow {
- display: flex;
+.studio-inner { width: min(1120px, 100%); margin: 0 auto; padding: 42px 38px 72px; }
+.studio-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 28px; }
+.studio-eyebrow {
+ display: inline-flex;
align-items: center;
- gap: 8px;
- margin-bottom: 15px;
- color: var(--fg-muted);
- font-size: 12px;
- font-weight: 600;
- letter-spacing: 0.025em;
-}
-.try-team-eyebrow > svg { color: var(--gold-dim); }
-.try-team-beta {
- padding: 3px 7px;
- border-radius: 999px;
- background: var(--gold-soft);
- color: var(--gold-dim);
+ gap: 7px;
+ margin-bottom: 10px;
font-family: var(--font-mono);
- font-size: 9px;
- letter-spacing: 0.07em;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: .14em;
text-transform: uppercase;
+ color: var(--gold-dim);
}
-.try-team-hero h1 {
- margin: 0;
- color: var(--fg);
- font-family: var(--font-serif);
- font-size: clamp(38px, 5vw, 58px);
- font-weight: 400;
- line-height: 1.02;
- letter-spacing: -0.035em;
-}
-.try-team-hero > p {
- max-width: 660px;
- margin: 17px 0 0;
- color: var(--fg-muted);
- font-size: 15px;
- line-height: 1.65;
-}
-.try-team-trust {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 7px;
- margin-top: 18px;
- color: var(--fg-subtle);
- font-size: 11px;
-}
-.try-team-trust svg { color: var(--success); }
-.try-team-trust-dot {
- width: 3px;
- height: 3px;
- margin: 0 3px;
- border-radius: 50%;
- background: var(--border-strong);
-}
-.try-team-agent-preview {
- padding: 20px;
- border: 1px solid var(--border);
- border-radius: 18px;
- background: color-mix(in oklch, var(--bg) 92%, var(--gold-soft));
- box-shadow: 0 12px 35px -30px rgba(15, 18, 22, 0.55);
-}
-.try-team-agent-head {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
+.studio-hero h2 { margin: 0; font-family: var(--font-serif); font-size: clamp(34px, 5vw, 52px); font-weight: 400; letter-spacing: -.025em; }
+.studio-hero p { max-width: 660px; margin: 9px 0 0; color: var(--fg-muted); font-size: 14px; line-height: 1.65; }
+.studio-primary-btn {
+ display: inline-flex;
align-items: center;
- gap: 14px;
-}
-.try-team-agent-head h2,
-.try-team-feature h3 {
- margin: 0;
- color: var(--fg);
- font-size: 14px;
+ gap: 8px;
+ flex-shrink: 0;
+ margin-top: 7px;
+ padding: 10px 15px;
+ border: 1px solid var(--accent);
+ border-radius: 9px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 12px;
font-weight: 600;
+ cursor: pointer;
}
-.try-team-agent-head p,
-.try-team-feature p {
- margin: 4px 0 0;
- color: var(--fg-muted);
- font-size: 12.5px;
- line-height: 1.5;
-}
-.try-team-avatar-stack { display: flex; padding-left: 7px; }
-.try-team-avatar-stack span {
- width: 31px;
- height: 31px;
+.studio-primary-btn:hover { background: var(--accent-hover); }
+.studio-stats {
display: grid;
- place-items: center;
- margin-left: -7px;
- border: 2px solid var(--bg);
- border-radius: 50%;
- background: var(--bg-alt);
- color: var(--fg-muted);
- font-family: var(--font-serif);
- font-size: 14px;
+ grid-template-columns: repeat(4, 1fr);
+ margin-top: 32px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
+ overflow: hidden;
}
-.try-team-avatar-stack span:first-child {
- background: var(--accent);
+.studio-stats > div { display: flex; flex-direction: column; gap: 4px; padding: 17px 20px; border-right: 1px solid var(--border); }
+.studio-stats > div:last-child { border-right: 0; }
+.studio-stats strong { font-family: var(--font-serif); font-size: 24px; font-weight: 400; }
+.studio-stats span { color: var(--fg-subtle); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
+.studio-section { margin-top: 42px; }
+.studio-section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 15px; }
+.studio-section-heading h3 { margin: 0; font-size: 16px; font-weight: 650; }
+.studio-section-heading p { margin: 5px 0 0; color: var(--fg-muted); font-size: 12px; }
+.studio-section-count { color: var(--fg-subtle); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
+.studio-agent-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.studio-agent-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 230px;
+ padding: 17px;
+ border: 1px dashed var(--border-strong);
+ border-radius: 13px;
+ background: color-mix(in srgb, var(--bg-alt) 55%, var(--bg));
+ transition: border-color .16s, transform .16s, box-shadow .16s;
+}
+.studio-agent-card:hover { border-color: var(--gold-line); transform: translateY(-1px); box-shadow: 0 12px 30px -26px rgba(0,0,0,.5); }
+.studio-agent-card.is-installed { border-style: solid; background: var(--bg); }
+.studio-agent-head { display: flex; align-items: center; gap: 11px; }
+.studio-agent-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ flex: 0 0 36px;
+ border-radius: 10px;
+ background: #121416;
color: #fff;
+ font-family: var(--font-serif);
+ font-size: 19px;
}
-.try-team-soon {
+.studio-agent-mark.is-franklin { background: #b28a13; }
+.studio-agent-mark.is-claude { background: #c96f4b; }
+.studio-agent-mark.is-hermes { background: #5947a6; }
+.studio-agent-mark.is-deepseek { background: #315fbe; }
+.studio-agent-title { min-width: 0; flex: 1; }
+.studio-agent-title > div { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.studio-agent-title strong { font-size: 14px; }
+.studio-agent-title code { display: block; margin-top: 2px; color: var(--fg-subtle); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.studio-badge {
display: inline-flex;
align-items: center;
- gap: 5px;
- padding: 5px 8px;
+ padding: 2px 6px;
+ border: 1px solid var(--border);
border-radius: 999px;
- background: var(--bg-alt);
color: var(--fg-subtle);
- font-size: 10px;
+ font-family: var(--font-mono);
+ font-size: 8px;
+ letter-spacing: .05em;
+ text-transform: uppercase;
white-space: nowrap;
}
-.try-team-composer {
- height: 48px;
+.studio-badge.is-gold { border-color: var(--gold-line); background: var(--gold-soft); color: var(--gold-dim); }
+.studio-status { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; color: var(--fg-subtle); font-size: 9px; }
+.studio-status i { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
+.studio-status.is-running { color: var(--success); }
+.studio-status.is-running i { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 15%, transparent); }
+.studio-agent-description { min-height: 39px; margin: 15px 0 7px; color: var(--fg-muted); font-size: 11px; line-height: 1.55; }
+.studio-protocol { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-subtle); font-family: var(--font-mono); font-size: 9px; }
+.studio-runtime-detail { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 9px; padding: 7px 8px; border-radius: 7px; background: var(--bg-alt); color: var(--fg-subtle); font-family: var(--font-mono); font-size: 8px; }
+.studio-runtime-detail code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--success); }
+.studio-runtime-error { margin-top: 9px; padding: 7px 8px; border: 1px solid rgba(215,85,85,.25); border-radius: 7px; background: rgba(215,85,85,.07); color: #b94444; font-size: 9px; line-height: 1.45; }
+.studio-capability-row {
display: flex;
align-items: center;
gap: 9px;
- margin-top: 18px;
- padding: 0 8px 0 14px;
+ margin-top: 14px;
+ padding: 10px;
border: 1px solid var(--border);
- border-radius: 15px;
+ border-radius: 9px;
background: var(--bg-alt);
- color: var(--fg-subtle);
- font-size: 13px;
}
-.try-team-composer span { flex: 1; }
-.try-team-composer button {
- width: 33px;
- height: 33px;
- display: grid;
- place-items: center;
- border: 0;
- border-radius: 50%;
- background: var(--border);
- color: var(--fg-subtle);
-}
-.try-team-feature-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
- margin-top: 12px;
-}
-.try-team-feature {
- min-height: 116px;
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 12px;
- padding: 17px;
+.studio-capability-icon { display: inline-flex; color: var(--gold-dim); }
+.studio-capability-row > span:nth-child(2) { display: flex; flex: 1; min-width: 0; flex-direction: column; }
+.studio-capability-row strong { font-size: 11px; }
+.studio-capability-row small { color: var(--fg-subtle); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.studio-toggle { position: relative; width: 34px; height: 19px; flex: 0 0 34px; padding: 0; border: 0; border-radius: 999px; background: var(--border-strong); cursor: pointer; transition: background .16s; }
+.studio-toggle span { position: absolute; left: 3px; top: 3px; width: 13px; height: 13px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); transition: transform .16s; }
+.studio-toggle.is-on { background: var(--success); }
+.studio-toggle.is-on span { transform: translateX(15px); }
+.studio-card-actions { display: flex; gap: 5px; margin-top: 10px; }
+.studio-card-actions button, .studio-row-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ padding: 6px 8px;
border: 1px solid var(--border);
- border-radius: 15px;
- background: var(--bg);
-}
-.try-team-feature-icon {
- width: 34px;
- height: 34px;
- display: grid;
- place-items: center;
- border-radius: 10px;
- background: var(--bg-alt);
- color: var(--gold-dim);
-}
-.try-team-feature-icon svg { width: 17px; height: 17px; }
-.try-team-feature > span {
- grid-column: 2;
- align-self: end;
- width: fit-content;
- color: var(--fg-subtle);
- font-family: var(--font-mono);
+ border-radius: 7px;
+ background: transparent;
+ color: var(--fg-muted);
font-size: 9px;
- letter-spacing: 0.08em;
- text-transform: uppercase;
+ cursor: pointer;
}
-.try-team-personal {
- display: inline-flex;
+.studio-card-actions button:hover, .studio-row-action:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-alt); color: var(--fg); }
+.studio-card-actions button.is-danger { margin-left: auto; }
+.studio-card-actions button.is-danger:hover { border-color: #ef444466; color: #dc2626; }
+.studio-install-btn {
+ display: flex;
align-items: center;
gap: 7px;
- margin-top: 20px;
- padding: 9px 13px;
- border: 1px solid var(--border-strong);
- border-radius: 10px;
+ width: 100%;
+ margin-top: auto;
+ padding: 9px 11px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
background: var(--bg);
color: var(--fg-muted);
- font-size: 12px;
+ font-size: 10px;
+ font-weight: 600;
cursor: pointer;
- transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
-.try-team-personal:hover { border-color: var(--gold-line); color: var(--fg); transform: translateY(-1px); }
-
-:root[data-theme="light"] .try-team-agent-preview,
-:root[data-theme="light"] .try-team-feature {
- border-color: #e3e4e6;
- box-shadow: 0 1px 2px rgba(15, 18, 22, 0.025);
+.studio-install-btn svg:last-child { margin-left: auto; }
+.studio-install-btn:hover { border-color: var(--gold-line); color: var(--gold-dim); }
+.studio-install-btn:disabled { opacity: .52; cursor: not-allowed; }
+.studio-install-btn:disabled:hover { border-color: var(--border); color: var(--fg-muted); }
+.studio-module-list { border: 1px solid var(--border); border-radius: 13px; background: var(--bg); overflow: hidden; }
+.studio-module-row { display: flex; align-items: center; gap: 12px; min-height: 70px; padding: 13px 16px; border-bottom: 1px solid var(--border); }
+.studio-module-row:last-child { border-bottom: 0; }
+.studio-module-icon { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 36px; border-radius: 9px; background: var(--bg-alt); color: var(--gold-dim); }
+.studio-module-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; }
+.studio-module-copy strong { font-size: 12px; }
+.studio-module-copy small { margin-top: 3px; color: var(--fg-subtle); font-size: 10px; }
+.studio-health { display: inline-flex; align-items: center; gap: 4px; color: var(--success); font-size: 9px; }
+.studio-row-action:disabled { opacity: .4; cursor: not-allowed; }
+.studio-demo-note { display: flex; align-items: center; gap: 9px; margin-top: 28px; padding: 11px 13px; border: 1px solid var(--gold-line); border-radius: 10px; background: var(--gold-soft); color: var(--fg-muted); font-size: 10px; }
+.studio-demo-note span { flex: 1; }
+.studio-demo-note strong { color: var(--fg); }
+
+@media (max-width: 800px) {
+ .studio-inner { padding: 28px 18px 52px; }
+ .studio-hero { flex-direction: column; }
+ .studio-primary-btn { margin-top: 0; }
+ .studio-stats { grid-template-columns: repeat(2, 1fr); }
+ .studio-stats > div:nth-child(2) { border-right: 0; }
+ .studio-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
+ .studio-agent-grid { grid-template-columns: 1fr; }
+ .studio-module-row { flex-wrap: wrap; }
+ .studio-module-copy { min-width: calc(100% - 52px); }
+}
+
+/* Cloud Workspace */
+.cloud-workspace { position: relative; height: calc(100vh - 48px); min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); color: var(--fg); }
+.cloud-head { height: 62px; flex: 0 0 62px; border-bottom: 1px solid var(--border); display: grid; grid-template-columns: minmax(190px,1fr) auto minmax(190px,1fr); align-items: center; gap: 16px; padding: 0 18px; }
+.cloud-head-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.cloud-head-identity > div { min-width: 0; }
+.cloud-head-identity strong,.cloud-head-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cloud-head strong { display: block; font-size: 14px; }
+.cloud-head small { display: block; color: var(--fg-subtle); font-size: 11px; margin-top: 2px; }
+.cloud-head select { max-width: 220px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 8px; padding: 7px 9px; }
+.cloud-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
+.cloud-runtime-chip,.cloud-members-button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg-subtle); font-size: 10px; white-space: nowrap; }
+.cloud-runtime-chip svg,.cloud-members-button svg { width: 14px; }
+.cloud-runtime-chip { background: var(--bg-alt); }
+.cloud-members-button { color: var(--fg); cursor: pointer; }
+.cloud-members-button:hover { border-color: var(--border-strong); background: var(--bg-alt); }
+.cloud-members-button em { min-width: 17px; height: 17px; display: grid; place-items: center; padding: 0 4px; border-radius: 999px; background: var(--gold-soft); color: var(--gold-dim); font-size: 8px; font-style: normal; }
+.cloud-view-tabs { display: flex; align-items: center; gap: 3px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-alt); }
+.cloud-view-tabs button { min-width: 94px; height: 32px; padding: 0 10px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; background: transparent; color: var(--fg-subtle); font-size: 11px; font-weight: 650; }
+.cloud-view-tabs button:hover { color: var(--fg); }
+.cloud-view-tabs button.is-active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 4px rgba(0,0,0,.08); }
+.cloud-view-tabs svg { width: 14px; }
+.cloud-view-tabs em { min-width: 17px; height: 17px; display: grid; place-items: center; padding: 0 4px; border-radius: 999px; background: var(--bg-hover); color: var(--fg-subtle); font-size: 8px; font-style: normal; }
+.cloud-view-tabs button.is-active em { background: var(--gold-soft); color: var(--gold-dim); }
+.cloud-logo { width: 48px; height: 48px; border-radius: 15px; display: inline-flex; align-items: center; justify-content: center; color: #f2b84b; background: linear-gradient(145deg,#29210f,#0e0e0e); box-shadow: 0 12px 30px rgba(0,0,0,.18); }
+.cloud-logo svg { width: 23px; }
+.cloud-logo.small { width: 34px; height: 34px; border-radius: 10px; box-shadow: none; }
+.cloud-logo.small svg { width: 17px; }
+.cloud-icon-btn { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg-subtle); }
+.cloud-icon-btn:hover { color: var(--fg); border-color: var(--border-strong); }
+.cloud-icon-btn svg { width: 15px; }
+.cloud-columns { min-width: 0; min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(0,1fr); }
+.cloud-left,.cloud-right { min-height: 0; overflow: auto; padding: 16px 13px; background: var(--bg-alt); }
+.cloud-left { border-right: 1px solid var(--border); }
+.cloud-right { border-left: 1px solid var(--border); }
+.cloud-section-title { display: flex; align-items: center; justify-content: space-between; color: var(--fg-subtle); font-weight: 700; font-size: 10px; letter-spacing: .09em; margin-bottom: 10px; }
+.cloud-section-title span { font-weight: 600; letter-spacing: 0; border: 1px solid var(--border); border-radius: 20px; padding: 1px 6px; }
+.cloud-members { display: grid; gap: 7px; margin-bottom: 10px; }
+.cloud-members > div { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 8px; }
+.cloud-members > div > span,.cloud-account > span { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: #e4ad3b; color: #1d1609; font-size: 11px; font-weight: 800; }
+.cloud-members strong,.cloud-members small { display: block; font-size: 11px; }
+.cloud-members small { color: var(--fg-subtle); text-transform: capitalize; margin-top: 2px; }
+.cloud-members select { margin-top: 2px; max-width: 100px; border: 0; padding: 0; color: var(--fg-subtle); background: transparent; font-size: 10px; text-transform: capitalize; }
+.cloud-primary,.cloud-secondary { border-radius: 9px; min-height: 36px; padding: 8px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; font-weight: 700; font-size: 12px; }
+.cloud-primary { background: #e4ad3b; color: #1a1306; border: 1px solid #e4ad3b; }
+.cloud-secondary { background: var(--bg); color: var(--fg); border: 1px solid var(--border); }
+.cloud-primary:disabled,.cloud-secondary:disabled { opacity: .45; }
+.cloud-primary svg,.cloud-secondary svg { width: 15px; }
+.cloud-secondary.full { width: 100%; }
+.cloud-invite-controls { display: grid; grid-template-columns: 1fr auto; gap: 5px; }
+.cloud-invite-controls select { min-width: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); padding: 6px; font-size: 10px; }
+.cloud-invite { margin-top: 9px; padding: 9px; display: grid; grid-template-columns: 1fr auto; border-radius: 9px; border: 1px dashed #b78a31; background: var(--gold-soft); }
+.cloud-invite small { grid-column: 1 / -1; color: var(--fg-subtle); font-size: 10px; }
+.cloud-invite strong { font-family: monospace; font-size: 13px; margin-top: 4px; }
+.cloud-invite button { background: transparent; color: var(--fg-subtle); }
+.cloud-invite svg { width: 14px; }
+.cloud-runtime-card { display: flex; gap: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg); }
+.cloud-runtime-card > svg { width: 16px; flex: 0 0 16px; color: #c5912d; }
+.cloud-runtime-card strong,.cloud-runtime-card small { display: block; font-size: 10px; line-height: 1.4; }
+.cloud-runtime-card small { color: var(--fg-subtle); margin-top: 3px; }
+.task-title { margin-top: 22px; }
+.cloud-task-list { display: grid; gap: 7px; }
+.cloud-task-list > div { padding: 9px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg); }
+.cloud-task-list > div > div { display: flex; gap: 6px; align-items: flex-start; }
+.cloud-task-list svg { width: 12px; flex: 0 0 12px; }
+.cloud-task-list strong { font-size: 10px; line-height: 1.35; }
+.cloud-task-list small { display: block; color: var(--fg-subtle); font-size: 9px; margin-top: 5px; }
+.cloud-task-list button { width: 100%; margin-top: 7px; padding: 5px; border-radius: 6px; background: #e4ad3b; color: #1a1306; font-size: 10px; font-weight: 800; }
+.cloud-task-list em { display: flex; align-items: center; gap: 4px; color: var(--success); font-size: 10px; margin-top: 6px; font-style: normal; }
+.cloud-chat-column { min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; }
+.cloud-messages { flex: 1; min-height: 0; overflow: auto; padding: 26px max(24px,8%); }
+.cloud-empty { min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: var(--fg-subtle); }
+.cloud-empty > svg { width: 34px; height: 34px; color: #d5a13b; }
+.cloud-empty h2 { color: var(--fg); margin: 15px 0 7px; font-size: 21px; }
+.cloud-empty p { max-width: 440px; font-size: 13px; line-height: 1.55; }
+.cloud-empty button { border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 10px; padding: 9px 13px; font-size: 12px; }
+.cloud-empty-actions { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
+.cloud-empty-actions button { display: inline-flex; align-items: center; gap: 7px; }
+.cloud-empty-actions svg { width: 14px; }
+.cloud-message { margin-bottom: 22px; max-width: 760px; }
+.cloud-message.is-user { margin-left: auto; width: min(80%,650px); padding: 13px 15px; border-radius: 14px; background: var(--gold-soft); border: 1px solid var(--gold-line); }
+.cloud-message.is-assistant { width: 100%; }
+.cloud-message-meta { display: flex; align-items: center; gap: 8px; color: var(--fg-subtle); margin-bottom: 7px; font-size: 10px; }
+.cloud-message-meta strong { color: var(--fg); font-size: 11px; }
+.cloud-message-meta small { border: 1px solid var(--border); border-radius: 20px; padding: 1px 6px; }
+.cloud-running { display: flex; align-items: center; gap: 10px; color: var(--fg-subtle); padding: 12px 0; }
+.cloud-running svg { width: 17px; color: #e4ad3b; }
+.cloud-running strong,.cloud-running small { display: block; font-size: 11px; }
+.cloud-running small { margin-top: 3px; color: var(--fg-subtle); }
+.cloud-composer { margin: 0 max(18px,6%) 18px; padding: 10px; display: grid; grid-template-columns: 1fr 38px; gap: 7px; border: 1px solid var(--border); background: var(--bg); border-radius: 14px; box-shadow: 0 15px 40px rgba(0,0,0,.07); }
+.cloud-composer textarea { resize: none; border: 0; outline: 0; background: transparent; color: var(--fg); min-height: 52px; padding: 5px; font: inherit; font-size: 13px; }
+.cloud-composer > button { width: 36px; height: 36px; align-self: end; border-radius: 10px; background: #e4ad3b; color: #191205; display: grid; place-items: center; }
+.cloud-composer > button:disabled { opacity: .4; }
+.cloud-composer > button svg { width: 16px; }
+.cloud-composer > small { grid-column: 1 / -1; color: var(--fg-subtle); font-size: 9px; }
+.cloud-files { display: grid; gap: 3px; }
+.cloud-files button { display: grid; grid-template-columns: 17px 1fr auto; gap: 6px; align-items: center; width: 100%; padding: 7px; border-radius: 7px; color: var(--fg); text-align: left; background: transparent; }
+.cloud-files button:hover,.cloud-files button.is-active { background: var(--bg); }
+.cloud-files svg { width: 14px; color: #c5912d; }
+.cloud-files span { overflow: hidden; text-overflow: ellipsis; font-size: 11px; }
+.cloud-files small { color: var(--fg-subtle); font-size: 9px; }
+.cloud-new-file { display: grid; grid-template-columns: 1fr 30px; gap: 5px; margin-top: 12px; }
+.cloud-new-file input { min-width: 0; border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 7px; padding: 7px; font-size: 10px; }
+.cloud-new-file button { border-radius: 7px; display: grid; place-items: center; background: var(--bg); border: 1px solid var(--border); color: var(--fg); }
+.cloud-new-file svg { width: 13px; }
+.cloud-preview { margin-top: 15px; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--bg); }
+.cloud-preview > div { padding: 7px 9px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; font-size: 10px; }
+.cloud-preview button { color: var(--fg-subtle); background: transparent; font-size: 16px; }
+.cloud-preview textarea { width: 100%; min-height: 210px; max-height: 360px; resize: vertical; border: 0; outline: 0; padding: 10px; background: var(--bg); color: var(--fg); font: 10px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }
+.cloud-save-file { width: calc(100% - 16px); margin: 0 8px 8px; padding: 7px; display: flex; align-items: center; justify-content: center; gap: 6px; border-radius: 7px; background: #e4ad3b; color: #1a1306; font-size: 10px; font-weight: 800; }
+.cloud-save-file svg { width: 13px; }
+.cloud-files-workspace { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
+.cloud-files-head { min-height: 72px; flex: 0 0 72px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--border); }
+.cloud-files-head > div { display: flex; align-items: center; gap: 11px; min-width: 0; }
+.cloud-files-head strong,.cloud-files-head small { display: block; }
+.cloud-files-head strong { font-size: 13px; }
+.cloud-files-head small { margin-top: 3px; color: var(--fg-subtle); font-size: 10px; }
+.cloud-files-icon { width: 35px; height: 35px; flex: 0 0 35px; display: grid; place-items: center; border: 1px solid var(--gold-line); border-radius: 10px; background: var(--gold-soft); color: var(--gold-dim); }
+.cloud-files-icon svg { width: 17px; }
+.cloud-sync-status { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; color: var(--fg-subtle); font-size: 10px; }
+.cloud-sync-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb,var(--success) 13%,transparent); }
+.cloud-files-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 252px minmax(0,1fr); }
+.cloud-file-browser { min-height: 0; display: flex; flex-direction: column; padding: 16px 12px 13px; border-right: 1px solid var(--border); background: var(--bg-alt); }
+.cloud-file-browser-title { display: flex; align-items: center; justify-content: space-between; padding: 0 5px 10px; color: var(--fg-subtle); font-size: 9px; font-weight: 750; letter-spacing: .1em; }
+.cloud-file-browser-title em { min-width: 19px; height: 18px; padding: 0 5px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; font-style: normal; letter-spacing: 0; }
+.cloud-file-browser .cloud-files { min-height: 0; overflow: auto; }
+.cloud-file-browser .cloud-files button { grid-template-columns: 20px minmax(0,1fr); gap: 8px; min-height: 49px; padding: 8px 9px; }
+.cloud-file-browser .cloud-files button:hover { background: var(--bg-hover); }
+.cloud-file-browser .cloud-files button.is-active { background: var(--bg); box-shadow: inset 2px 0 #d4a03a,0 1px 4px rgba(0,0,0,.05); }
+.cloud-file-browser .cloud-files button > span { min-width: 0; }
+.cloud-file-browser .cloud-files strong,.cloud-file-browser .cloud-files small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cloud-file-browser .cloud-files strong { color: var(--fg); font-size: 11px; font-weight: 600; }
+.cloud-file-browser .cloud-files small { margin-top: 3px; color: var(--fg-subtle); font-size: 9px; }
+.cloud-no-files { display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 30px 8px; color: var(--fg-subtle); font-size: 10px; }
+.cloud-no-files svg { width: 22px; }
+.cloud-file-browser .cloud-new-file { margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border); }
+.cloud-file-browser .cloud-new-file input { height: 34px; }
+.cloud-file-browser .cloud-new-file button { width: 32px; height: 34px; }
+.cloud-file-help { display: block; padding: 9px 3px 0; color: var(--fg-subtle); font-size: 8px; line-height: 1.45; }
+.cloud-file-editor { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--bg); }
+.cloud-file-editor-head { min-height: 72px; flex: 0 0 72px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 20px; border-bottom: 1px solid var(--border); }
+.cloud-file-editor-head > div:first-child { min-width: 0; }
+.cloud-file-editor-head strong,.cloud-file-editor-head small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cloud-file-editor-head strong { margin-top: 2px; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 13px; }
+.cloud-file-editor-head small { margin-top: 4px; color: var(--fg-subtle); font-size: 9px; }
+.cloud-file-breadcrumb { color: var(--fg-subtle); font-size: 9px; }
+.cloud-file-editor-actions { display: flex; align-items: center; gap: 10px; }
+.cloud-file-saved { display: inline-flex; align-items: center; gap: 5px; color: var(--success); font-size: 10px; white-space: nowrap; }
+.cloud-file-saved svg { width: 13px; }
+.cloud-file-dirty { display: inline-flex; align-items: center; gap: 5px; color: var(--gold-dim); font-size: 10px; white-space: nowrap; }
+.cloud-file-dirty::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #d4a03a; }
+.cloud-file-editor > textarea { flex: 1; min-height: 0; width: 100%; resize: none; border: 0; outline: 0; padding: 24px 28px; background: var(--bg); color: var(--fg); font: 12px/1.75 ui-monospace,SFMono-Regular,Menlo,monospace; tab-size: 2; }
+.cloud-file-editor > textarea:read-only { color: var(--fg-muted); }
+.cloud-file-editor-foot { min-height: 58px; flex: 0 0 58px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 18px 0 22px; border-top: 1px solid var(--border); background: var(--bg-alt); }
+.cloud-file-editor-foot > span { color: var(--fg-subtle); font-size: 9px; }
+.cloud-file-editor-foot .cloud-save-file { width: auto; min-width: 146px; margin: 0; padding: 9px 13px; }
+.cloud-file-editor-foot .cloud-save-file:disabled { opacity: .48; }
+.cloud-file-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; color: var(--fg-subtle); }
+.cloud-file-empty > span { width: 50px; height: 50px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-alt); color: var(--gold-dim); }
+.cloud-file-empty > span svg { width: 24px; }
+.cloud-file-empty h2 { margin: 15px 0 7px; color: var(--fg); font-size: 18px; }
+.cloud-file-empty p { max-width: 400px; margin: 0; font-size: 11px; line-height: 1.6; }
+.cloud-file-empty button { margin-top: 16px; padding: 8px 11px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); font-size: 10px; }
+.cloud-file-empty button svg { width: 13px; }
+.cloud-files-error { margin: 8px 14px; }
+.cloud-drawer-scrim { position: absolute; inset: 0; z-index: 20; border: 0; background: rgba(25,22,17,.18); backdrop-filter: blur(1px); animation: try-fade .15s ease; }
+.cloud-member-drawer { position: absolute; z-index: 21; top: 0; right: 0; bottom: 0; width: min(368px,92%); display: flex; flex-direction: column; border-left: 1px solid var(--border); background: var(--bg-alt); box-shadow: -18px 0 45px rgba(31,28,22,.13); animation: cloud-drawer-in .18s ease-out; }
+@keyframes cloud-drawer-in { from { transform: translateX(20px); opacity: 0; } }
+.cloud-member-drawer > header { min-height: 62px; flex: 0 0 62px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px 0 19px; border-bottom: 1px solid var(--border); background: var(--bg); }
+.cloud-member-drawer > header strong,.cloud-member-drawer > header small { display: block; }
+.cloud-member-drawer > header strong { font-size: 13px; }
+.cloud-member-drawer > header small { margin-top: 2px; color: var(--fg-subtle); font-size: 10px; }
+.cloud-drawer-body { min-height: 0; overflow: auto; padding: 17px; }
+.cloud-runtime-summary { display: flex; gap: 11px; margin-bottom: 22px; padding: 13px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg); }
+.cloud-runtime-summary > span { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border: 1px solid var(--gold-line); border-radius: 9px; background: var(--gold-soft); color: var(--gold-dim); }
+.cloud-runtime-summary svg { width: 16px; }
+.cloud-runtime-summary strong,.cloud-runtime-summary small { display: block; }
+.cloud-runtime-summary strong { font-size: 11px; }
+.cloud-runtime-summary small { margin-top: 4px; color: var(--fg-subtle); font-size: 9px; line-height: 1.5; }
+.cloud-member-drawer .cloud-members > div { padding: 8px 5px; }
+.cloud-member-drawer .cloud-members > div > span { width: 32px; height: 32px; }
+.cloud-member-drawer .cloud-members strong { font-size: 11px; }
+.cloud-member-drawer .cloud-invite-controls { margin-top: 14px; }
+.cloud-agent-future { position: relative; display: flex; align-items: center; gap: 10px; margin-top: 22px; padding: 12px; border: 1px dashed var(--border-strong); border-radius: 11px; background: color-mix(in srgb,var(--bg) 70%,transparent); }
+.cloud-agent-future > span { width: 31px; height: 31px; flex: 0 0 31px; display: grid; place-items: center; border-radius: 9px; background: var(--bg-alt); color: var(--fg-subtle); }
+.cloud-agent-future svg { width: 15px; }
+.cloud-agent-future > div { min-width: 0; }
+.cloud-agent-future strong,.cloud-agent-future small { display: block; }
+.cloud-agent-future strong { font-size: 10px; }
+.cloud-agent-future small { margin-top: 3px; color: var(--fg-subtle); font-size: 8.5px; line-height: 1.45; }
+.cloud-agent-future em { align-self: flex-start; flex-shrink: 0; padding: 2px 6px; border-radius: 999px; background: var(--bg-alt); color: var(--fg-subtle); font-size: 7px; font-style: normal; text-transform: uppercase; }
+.cloud-onboard { height: calc(100vh - 48px); overflow: auto; display: grid; place-items: center; padding: 32px; background: radial-gradient(circle at 50% 0%,var(--gold-soft),var(--bg) 45%); }
+.cloud-onboard-card { width: min(460px,100%); padding: 34px; border: 1px solid var(--border); background: var(--bg-alt); border-radius: 18px; box-shadow: 0 24px 70px rgba(0,0,0,.12); }
+.cloud-onboard-card.is-wide { width: min(700px,100%); }
+.cloud-kicker { color: #bd8d2f; font-size: 10px; font-weight: 800; letter-spacing: .12em; margin-top: 20px; }
+.cloud-onboard h2 { font-size: 23px; margin: 9px 0; }
+.cloud-onboard p { color: var(--fg-subtle); font-size: 12px; line-height: 1.6; }
+.cloud-onboard label { display: grid; gap: 6px; color: var(--fg-subtle); font-size: 11px; margin: 22px 0 10px; }
+.cloud-onboard input { border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 9px; padding: 10px; outline: none; }
+.cloud-onboard .cloud-primary { width: 100%; }
+.cloud-account { display: grid; grid-template-columns: 34px 1fr 32px; gap: 9px; align-items: center; margin-bottom: 20px; }
+.cloud-account strong,.cloud-account small { display: block; font-size: 11px; }
+.cloud-account small { color: var(--fg-subtle); margin-top: 2px; }
+.cloud-account button { color: var(--fg-subtle); background: transparent; }
+.cloud-account svg { width: 15px; }
+.cloud-create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 18px; }
+.cloud-create-grid section { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); }
+.cloud-create-grid h3,.cloud-existing h3 { font-size: 12px; margin: 0 0 3px; }
+.cloud-create-grid .cloud-primary { width: auto; }
+.cloud-existing { margin-top: 20px; display: grid; gap: 5px; }
+.cloud-existing button { display: grid; grid-template-columns: 18px 1fr auto; gap: 7px; align-items: center; text-align: left; color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 9px; }
+.cloud-existing svg { width: 14px; color: #d19b34; }
+.cloud-existing small { color: var(--fg-subtle); text-transform: capitalize; }
+.cloud-error { color: #d75555; background: rgba(215,85,85,.08); border: 1px solid rgba(215,85,85,.28); padding: 9px; border-radius: 8px; font-size: 11px; margin-top: 10px; }
+.cloud-error.inline { margin: -10px max(18px,6%) 12px; }
+.cloud-retry-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px 8px 12px; }
+.cloud-retry-error span { line-height: 1.4; }
+.cloud-retry-error button { display: inline-flex; align-items: center; gap: 5px; flex: none; height: 28px; padding: 0 10px; border: 1px solid rgba(215,85,85,.22); border-radius: 7px; background: rgba(255,255,255,.72); color: #b94444; font-size: 10px; font-weight: 600; }
+.cloud-retry-error button:hover { background: #fff; }
+.cloud-retry-error button svg { width: 12px; height: 12px; }
+.spin { animation: cloud-spin 1s linear infinite; }
+@keyframes cloud-spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 1050px) {
+ .cloud-head { grid-template-columns: minmax(140px,1fr) auto minmax(84px,1fr); gap: 8px; padding: 0 10px; }
+ .cloud-runtime-chip span,.cloud-members-button > span { display: none; }
+ .cloud-files-layout { grid-template-columns: 220px minmax(0,1fr); }
}
@media (max-width: 760px) {
- .try-team-panel { padding: 34px 20px 48px; }
- .try-team-feature-grid { grid-template-columns: 1fr; }
- .try-team-agent-head { grid-template-columns: auto minmax(0, 1fr); }
- .try-team-soon { grid-column: 2; width: fit-content; }
-}
-
-@media (prefers-reduced-motion: reduce) {
- html,
- body,
- .try-sidebar,
- .try-nav-item,
- .try-history-item,
- .try-lang-btn,
- .try-sidebar-toggle,
- .try-bar-more,
- .try-bar-share,
- .try-tool-card,
- .try-composer,
- .try-send,
- .try-msg,
- .try-empty,
- .try-tools-panel,
- .try-gallery,
- .try-wallet-panel,
- .try-team-panel,
- .try-phone {
- animation: none !important;
- transition: none !important;
- }
+ .cloud-chat-column,.cloud-files-workspace { min-height: 0; }
+ .cloud-create-grid { grid-template-columns: 1fr; }
+ .cloud-head { grid-template-columns: minmax(105px,1fr) auto auto; }
+ .cloud-head-identity small { display: none; }
+ .cloud-runtime-chip { display: none; }
+ .cloud-view-tabs button { min-width: 76px; }
+ .cloud-files-head { padding: 0 14px; }
+ .cloud-files-head small { display: none; }
+ .cloud-files-layout { grid-template-columns: 185px minmax(0,1fr); }
+ .cloud-file-editor > textarea { padding: 18px; }
}
diff --git a/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts
index 402b1bb3..52527796 100644
--- a/apps/desktop/src/vite-env.d.ts
+++ b/apps/desktop/src/vite-env.d.ts
@@ -6,6 +6,12 @@
interface Window {
__FRANKLIN__?: {
agentUrl: string;
+ cloudUrl?: string;
+ cloudToken?: string;
copy?: (text: string) => boolean;
+ scanAgentRuntimes?: () => Promise>;
+ startAgentRuntime?: (id: string) => Promise<{ ok: boolean; available?: boolean; running: boolean; path?: string; version?: string; endpoint?: string; lifecycleSupported?: boolean; error?: string }>;
+ stopAgentRuntime?: (id: string) => Promise<{ ok: boolean; running: boolean; error?: string }>;
+ switchWalletChain?: (chain: "base" | "solana") => Promise<{ ok: boolean; chain: "base" | "solana" }>;
};
}
diff --git a/apps/desktop/test/electron-security.test.cjs b/apps/desktop/test/electron-security.test.cjs
new file mode 100644
index 00000000..35c38aca
--- /dev/null
+++ b/apps/desktop/test/electron-security.test.cjs
@@ -0,0 +1,36 @@
+const assert = require("node:assert/strict");
+const path = require("node:path");
+const test = require("node:test");
+const { pathToFileURL } = require("node:url");
+const {
+ externalHttpUrl,
+ loopbackHttpUrl,
+ sameOriginUrl,
+ trustedRendererUrl,
+} = require("../electron/security.cjs");
+
+test("desktop service URLs are confined to credential-free loopback", () => {
+ assert.equal(loopbackHttpUrl("http://127.0.0.1:5174").port, "5174");
+ assert.equal(loopbackHttpUrl("https://localhost:5174/app").pathname, "/app");
+ assert.throws(() => loopbackHttpUrl("https://example.com"));
+ assert.throws(() => loopbackHttpUrl("http://user:pass@localhost:5174"));
+ assert.throws(() => loopbackHttpUrl("file:///tmp/index.html"));
+});
+
+test("external navigation accepts only HTTP(S) without embedded credentials", () => {
+ assert.equal(externalHttpUrl("https://blockrun.ai/docs").hostname, "blockrun.ai");
+ assert.equal(externalHttpUrl("javascript:alert(1)"), null);
+ assert.equal(externalHttpUrl("https://user:pass@example.com"), null);
+});
+
+test("renderer trust is exact-origin in development and path-confined when packaged", () => {
+ const dev = new URL("http://127.0.0.1:5174");
+ assert.equal(trustedRendererUrl("http://127.0.0.1:5174/src/main.tsx", { devUrl: dev, distRoot: "/unused" }), true);
+ assert.equal(trustedRendererUrl("http://localhost:5174", { devUrl: dev, distRoot: "/unused" }), false);
+ assert.equal(sameOriginUrl("http://127.0.0.1:5174/other", dev), true);
+
+ const distRoot = path.resolve("/tmp/franklin-dist");
+ assert.equal(trustedRendererUrl(pathToFileURL(path.join(distRoot, "index.html")).href, { distRoot }), true);
+ assert.equal(trustedRendererUrl(pathToFileURL("/tmp/other.html").href, { distRoot }), false);
+ assert.equal(trustedRendererUrl("https://evil.example", { distRoot }), false);
+});
diff --git a/apps/desktop/test/mock-bridge.test.cjs b/apps/desktop/test/mock-bridge.test.cjs
new file mode 100644
index 00000000..42f06997
--- /dev/null
+++ b/apps/desktop/test/mock-bridge.test.cjs
@@ -0,0 +1,52 @@
+const assert = require("node:assert/strict");
+const path = require("node:path");
+const { spawn } = require("node:child_process");
+const { test } = require("node:test");
+const WebSocket = require("ws");
+
+function connect(url, origin) {
+ return new Promise((resolve) => {
+ const ws = new WebSocket(url, { origin });
+ const timer = setTimeout(() => { ws.terminate(); resolve({ opened: false, status: "timeout" }); }, 3_000);
+ ws.once("open", () => { clearTimeout(timer); resolve({ opened: true, ws }); });
+ ws.once("unexpected-response", (_request, response) => { clearTimeout(timer); resolve({ opened: false, status: response.statusCode }); });
+ ws.once("error", () => {});
+ });
+}
+
+test("mock bridge uses an ephemeral loopback port and rejects hostile browser origins", async (t) => {
+ const child = spawn(process.execPath, [path.join(__dirname, "..", "dev-server", "mock.mjs")], {
+ env: { ...process.env, FRANKLIN_AGENT_PORT: "0" },
+ stdio: ["ignore", "ignore", "pipe", "ipc"],
+ });
+ t.after(() => child.kill("SIGTERM"));
+ const port = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("mock readiness timeout")), 5_000);
+ child.on("message", (message) => {
+ if (message?.type !== "franklin:server-ready") return;
+ clearTimeout(timer);
+ resolve(message.port);
+ });
+ child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`mock exited before readiness (${code})`)); });
+ });
+ assert.ok(Number.isInteger(port) && port > 0);
+ const endpoint = `ws://127.0.0.1:${port}/agent`;
+ const hostile = await connect(endpoint, "https://evil.example");
+ assert.equal(hostile.opened, false);
+ assert.equal(hostile.status, 401);
+ const allowed = await connect(endpoint, "http://localhost:5174");
+ assert.equal(allowed.opened, true);
+ const response = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("mock RPC timeout")), 3_000);
+ allowed.ws.on("message", (raw) => {
+ const message = JSON.parse(raw.toString());
+ if (message.id !== "m1") return;
+ clearTimeout(timer);
+ resolve(message);
+ });
+ allowed.ws.send(JSON.stringify({ id: "m1", kind: "session.list" }));
+ });
+ allowed.ws.close();
+ assert.equal(response.kind, "response");
+ assert.ok(Array.isArray(response.payload.sessions));
+});
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index ab05f843..3cf2eabb 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -7,7 +7,11 @@ import { fileURLToPath } from "node:url";
// HTTP RPC to the Franklin CLI (or the mock dev server) running on 3737.
// Prod mode: the build is served directly by the CLI's embedded HTTP server
// — no Vite involved, no proxy needed (same-origin).
-const AGENT_PORT = Number(process.env.FRANKLIN_AGENT_PORT) || 3737;
+const requestedAgentPort = Number(process.env.FRANKLIN_AGENT_PORT || 3737);
+if (!Number.isInteger(requestedAgentPort) || requestedAgentPort < 1 || requestedAgentPort > 65_535) {
+ throw new Error("FRANKLIN_AGENT_PORT must be an integer from 1 to 65535");
+}
+const AGENT_PORT = requestedAgentPort;
export default defineConfig({
// Relative asset paths so the build works when loaded from file:// inside the
@@ -38,7 +42,7 @@ export default defineConfig({
},
build: {
outDir: "dist",
- sourcemap: true,
+ sourcemap: false,
// Browsers Franklin CLI users have are recent — Node 20+ era. Skip the
// legacy fallbacks Vite ships by default to keep the bundle slim.
target: "es2022",
diff --git a/package-lock.json b/package-lock.json
index 015829f9..2d0b24df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,7 +53,7 @@
},
"apps/desktop": {
"name": "@blockrun/franklin-desktop",
- "version": "0.1.3-beta.1",
+ "version": "0.2.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@blockrun/franklin": "*",
@@ -74,9 +74,9 @@
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.68.0",
"@vitejs/plugin-react": "^5.0.0",
- "concurrently": "^9.1.0",
+ "concurrently": "^9.2.4",
"electron": "44.0.0",
- "electron-builder": "^26.8.1",
+ "electron-builder": "^26.15.3",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"shadcn": "^4.8.3",
@@ -84,7 +84,7 @@
"ts-api-utils": "2.4.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
- "vite": "^7.0.0",
+ "vite": "^7.3.5",
"ws": "^8.18.0"
},
"engines": {