diff --git a/docs/agent-control.md b/docs/agent-control.md
index 59af4fd70..0720fc633 100644
--- a/docs/agent-control.md
+++ b/docs/agent-control.md
@@ -8,7 +8,10 @@ agents. The only product entry point is ordinary desktop startup.
## Normal desktop workflow
Run from the feature worktree with `bin/just desktop`, not a management-only
-launcher. This opens **Buzz Foundation** using the ordinary live-development
+launcher. The command prepares the pinned agent runtime before starting Tauri;
+the first build may take several minutes. Later launches verify and reuse matching
+resources, rebuilding missing, stale or corrupt ones. Preparation failure stops
+launch rather than opening a desktop that cannot run agents. This opens **Buzz Foundation** using the ordinary live-development
configuration and persistent native settings. Coordinate the native rebuild/relaunch;
quit other Foundation copies first. Saved enabled agents can restore on startup.
Keep imported agents disabled and old Buzz running until an attended handover.
@@ -27,6 +30,8 @@ During a Create/profile wait, **Close** leaves the native operation running and
exposes the existing cards' recovery Stop. Closing before creation returns skips
automatic profile publication; refresh status and retry on the saved card. Late
completion never closes a subsequently opened dialog.
+Create is blocked with an explanation if this app’s runtime is unavailable;
+existing agents and profile retry remain intact.
The dev broker and native host must both support this flow. Packaged human
signing remains unavailable.
@@ -118,9 +123,11 @@ resources. Production has no disposable storage override or preview launch mode.
unavailable capability; no fetch fallback, local storage, signing or runner.
- `bundled/agents/AgentControlPanel.tsx`: compose with `{ control }` independently
of selected community or relay connectivity. It owns only observation and UI
- drafts. Its five-second refresh runs while visible/ready; reads coalesce. On
- errors it stops polling and exposes explicit Retry. Unmount clears the timer,
- not enabled intent or processes.
+ drafts. Its five-second refresh runs while visible/ready; reads coalesce. A read
+ rejected specifically because native startup is initializing or its lock is busy
+ stays pending for at most twenty 250ms waits. Genuine errors or exhausted retries
+ stop polling and expose explicit Retry; writes are never automatically retried.
+ Unmount clears the timer, not enabled intent or processes.
- Native host owns persistent state, credential custody, process groups, lock and
duplicate ownership checks, source import validation and sanitized diagnostics.
It must bound IPC operations and reject with deliberately user-facing strings;
@@ -224,7 +231,9 @@ resources. Production has no disposable storage override or preview launch mode.
## Runtime resources
-Build without launching any app or accessing old credentials:
+Ordinary `bin/just desktop` and `bin/pnpm tauri build` prepare these resources
+automatically. Direct Cargo builds do not run that JavaScript preparation step.
+To prepare/build without launching any app or accessing old credentials:
```sh
bin/pnpm install --frozen-lockfile
diff --git a/docs/contributing.md b/docs/contributing.md
index 88fcaafba..c9abf4df2 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -48,7 +48,10 @@ need their own validation.
e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port
(default: 1430) or the next available port, allowing parallel browser development.
- `just desktop [args...]`: install locked dependencies and forward arguments to
- Tauri, e.g. `just desktop --port 1431 --no-watch`. The desktop adapter consumes
+ Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter
+ builds the pinned agent runtime when missing/outdated, or verifies and reuses it.
+ A preparation failure stops launch; help does not prepare resources.
+ The desktop adapter consumes
`--port N` or `--port=N` to set both Vite's port and Tauri's development URL;
Tauri's own `--port` is for its static-file server, not Vite. Without this flag,
the existing Tauri configuration is unchanged (port 1430). Desktop requires the
diff --git a/scripts/build-agent-runtime.mjs b/scripts/build-agent-runtime.mjs
index 4e4d1b1f8..eee93e6cd 100644
--- a/scripts/build-agent-runtime.mjs
+++ b/scripts/build-agent-runtime.mjs
@@ -3,6 +3,9 @@ import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
readFile,
+ lstat,
+ access,
+ constants,
mkdir,
mkdtemp,
copyFile,
@@ -48,6 +51,46 @@ const target = (await run(join(root, "bin/rustc"), ["-vV"], true)).match(
/^host: (.+)$/m,
)?.[1];
if (!target) throw new Error("Could not resolve pinned Rust target");
+const destination = join(root, "src-tauri/resources/agent-runtime");
+const filenames = spec.tools.map((name) =>
+ process.platform === "win32" ? `${name}.exe` : name,
+);
+async function currentBundle() {
+ try {
+ if (!(await lstat(destination)).isDirectory()) return false;
+ const manifestPath = join(destination, "manifest.json");
+ const meta = await lstat(manifestPath);
+ if (!meta.isFile() || meta.size > 16384) return false;
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
+ if (
+ Object.keys(manifest).length !== 4 ||
+ manifest.version !== 1 ||
+ manifest.revision !== spec.revision ||
+ manifest.target !== target ||
+ Object.keys(manifest.files).length !== filenames.length
+ )
+ return false;
+ for (const filename of filenames) {
+ const path = join(destination, filename);
+ if (!(await lstat(path)).isFile()) return false;
+ await access(path, constants.X_OK);
+ const hash = createHash("sha256")
+ .update(await readFile(path))
+ .digest("hex");
+ if (manifest.files[filename] !== hash) return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+if (await currentBundle()) {
+ console.log(`Agent runtime ready (${spec.revision}, ${target})`);
+ process.exit(0);
+}
+console.log(
+ "Preparing the agent runtime; the first build can take several minutes.",
+);
await mkdir(join(root, "target"), { recursive: true });
const stage = await mkdtemp(join(root, "target/agent-runtime-stage-"));
try {
@@ -67,7 +110,6 @@ try {
"buzz-cli",
"git-credential-nostr",
]);
- const destination = join(root, "src-tauri/resources/agent-runtime");
await mkdir(destination, { recursive: true });
const files = {};
for (const name of spec.tools) {
diff --git a/scripts/desktop-dev.mjs b/scripts/desktop-dev.mjs
index e541ff66e..f405e8793 100644
--- a/scripts/desktop-dev.mjs
+++ b/scripts/desktop-dev.mjs
@@ -25,6 +25,18 @@ for (; index < args.length && args[index] !== "--"; index++) {
}
}
+// Prepare resources before Tauri can compile or observe an already-running Vite.
+// Its dev-server readiness timeout must not include a cold runtime build.
+if (!forwarded.some((arg) => arg === "--help" || arg === "-h")) {
+ const prepared = spawnSync(
+ process.execPath,
+ [fileURLToPath(new URL("./build-agent-runtime.mjs", import.meta.url))],
+ { stdio: "inherit" },
+ );
+ if (prepared.error) console.error(prepared.error.message);
+ if (prepared.signal) process.kill(process.pid, prepared.signal);
+ if (prepared.status !== 0) process.exit(prepared.status ?? 1);
+}
const config = {};
const icon = worktreeIcon(fileURLToPath(new URL("../", import.meta.url)));
if (icon) config.bundle = { icon: [icon] };
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 510871775..f1ce27a10 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "pnpm dev:desktop",
"devUrl": "http://localhost:1430",
- "beforeBuildCommand": "pnpm build",
+ "beforeBuildCommand": "node scripts/build-agent-runtime.mjs && pnpm build",
"frontendDist": "../dist"
},
"app": {
diff --git a/src/bundled/agents/AgentCreateDialog.tsx b/src/bundled/agents/AgentCreateDialog.tsx
index c1db3a046..a2bf7da7b 100644
--- a/src/bundled/agents/AgentCreateDialog.tsx
+++ b/src/bundled/agents/AgentCreateDialog.tsx
@@ -54,9 +54,10 @@ export function AgentCreateDialog({
state.data?.createAvailable &&
control.create
);
+ const runtimeBlocked = !state.data?.runtimeAvailable && !saved;
const blocked = busy || state.busy || state.status !== "ready";
const create = async () => {
- if (blocked || !available || !control.create) return;
+ if (blocked || runtimeBlocked || !available || !control.create) return;
setError(undefined);
setBusy(true);
try {
@@ -129,6 +130,13 @@ export function AgentCreateDialog({
an agent.
)}
+ {runtimeBlocked && (
+
+ This app’s agent runtime is unavailable. Repair or rebuild the
+ desktop app before creating an agent.
+ {state.data?.runtimeMessage && ` ${state.data.runtimeMessage}`}
+