diff --git a/.github/workflows/mac-release.yml b/.github/workflows/mac-release.yml
index f435eba..74651a3 100644
--- a/.github/workflows/mac-release.yml
+++ b/.github/workflows/mac-release.yml
@@ -2,9 +2,19 @@ name: Mac release
on:
workflow_dispatch:
-
+ inputs:
+ version:
+ description: "Marketing version to publish, e.g. 1.0.0. Leave empty to build without publishing."
+ required: false
+ default: ""
+ prerelease:
+ description: "Mark the GitHub release as a pre-release"
+ type: boolean
+ default: false
+
+# Write, for the one step that creates the GitHub release. Nothing else here pushes.
permissions:
- contents: read
+ contents: write
# Hoisted to the job so the optional steps can be gated on them.
#
@@ -16,6 +26,9 @@ env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
+ XBOT_VERSION: ${{ inputs.version }}
+ # Sparkle compares CFBundleVersion, so it must rise with every release. The run number does.
+ XBOT_BUILD_NUMBER: ${{ github.run_number }}
jobs:
build:
@@ -48,7 +61,7 @@ jobs:
- name: Bundle .app
env:
- XBOT_APPCAST_URL: ${{ secrets.XBOT_APPCAST_URL }}
+ XBOT_APPCAST_URL: ${{ inputs.version != '' && format('https://github.com/{0}/releases/latest/download/appcast.xml', github.repository) || secrets.XBOT_APPCAST_URL }}
XBOT_SPARKLE_PUBLIC_KEY: ${{ secrets.XBOT_SPARKLE_PUBLIC_KEY }}
run: scripts/bundle-mac-app.sh
@@ -110,10 +123,13 @@ jobs:
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: scripts/sign-mac-app.sh
+ # The enclosure has to point at the asset this run is about to upload. The feed itself is
+ # served from releases/latest/download/appcast.xml, which GitHub redirects to the newest
+ # release — so each release's appcast only needs to describe itself.
- name: Generate Sparkle appcast
if: env.SPARKLE_EDDSA_PRIVATE_KEY != ''
env:
- XBOT_RELEASE_DOWNLOAD_PREFIX: ${{ secrets.XBOT_RELEASE_DOWNLOAD_PREFIX }}
+ XBOT_RELEASE_DOWNLOAD_PREFIX: ${{ inputs.version != '' && format('https://github.com/{0}/releases/download/v{1}/', github.repository, inputs.version) || secrets.XBOT_RELEASE_DOWNLOAD_PREFIX }}
run: scripts/generate-appcast.sh
- uses: actions/upload-artifact@v4
@@ -122,3 +138,34 @@ jobs:
path: |
dist/xBot.dmg
dist/releases/
+
+ # Only a signed, notarized build is published: an unsigned DMG on the download page is the
+ # "cannot be opened" dialog that stops a non-technical person for good.
+ - name: Verify what a stranger's Mac will check
+ if: inputs.version != ''
+ run: |
+ set -euo pipefail
+ mnt="$(mktemp -d)"
+ hdiutil attach -nobrowse -readonly -mountpoint "${mnt}" dist/xBot.dmg
+ trap 'hdiutil detach "${mnt}" >/dev/null' EXIT
+ spctl -a -vvv -t execute "${mnt}/XBot.app" 2>&1 | tee /dev/stderr | grep -q "Notarized Developer ID"
+ xcrun stapler validate "${mnt}/XBot.app"
+ xcrun stapler validate dist/xBot.dmg
+ test -f dist/releases/appcast.xml
+ grep -q 'sparkle:edSignature=' dist/releases/appcast.xml
+ test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${mnt}/XBot.app/Contents/Info.plist")" = "${XBOT_VERSION}"
+
+ - name: Publish GitHub release
+ if: inputs.version != ''
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ tag="v${XBOT_VERSION}"
+ flags=()
+ [[ "${{ inputs.prerelease }}" == "true" ]] && flags+=(--prerelease) || flags+=(--latest)
+ gh release create "${tag}" --repo "${GITHUB_REPOSITORY}" --target "${GITHUB_SHA}" \
+ --title "xBot ${XBOT_VERSION}" --generate-notes "${flags[@]}" \
+ "dist/xBot.dmg#xBot.dmg" \
+ "dist/releases/xBot-${XBOT_VERSION}.dmg" \
+ "dist/releases/appcast.xml"
diff --git a/CLAUDE.md b/CLAUDE.md
index 8370786..74ad8b1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,8 +29,7 @@ finished. Route it through the app.
### Non-goals
- No hosted/SaaS version of xBot. The app, the engine, the agents and their browsers run on the
- user's machine. **In v1 the conversation transcript is the exception** — it lives in CopilotKit
- Intelligence, and onboarding says so before the user types a key. See ADR-0007.
+ user's machine, and so are conversations: the engine keeps them itself. See ADR-0008.
- No Mac App Store build. See `docs/decisions/0005-distribution-outside-app-store.md`.
- No Windows or Linux client in v1. The engine is portable; the client is not.
- No model of our own. xBot supplies no intelligence — the user brings keys or runs Ollama.
@@ -79,7 +78,7 @@ it — it usually does, and better than a first attempt would.
- New xBot code goes in **new files**. A moved or heavily edited upstream file is a permanent merge
conflict.
-### 1. v1 runs on CopilotKit Intelligence, and the seam to leave it is already built.
+### 1. v1 runs in local mode, on the vendor's own SSE runner made durable.
OpenBot's `runtimeCapabilities()` in `server/src/config.ts` used to **throw on startup** unless all
four of `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` and
@@ -87,13 +86,12 @@ four of `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API
means local history, and a partial set still throws — for upstream's original reason, that somebody
who set two of four intended Intelligence and got it wrong.
-**v1 sets all four.** The local mode exists, boots, and serves, but `LocalIntelligence` is a spike
-that records which methods are reached and throws; it is not a provider yet.
+**v1 sets none.** Local mode runs `LocalThreadRunner` (`server/src/history/local-thread-runner.ts`),
+which extends CopilotKit's `InMemoryAgentRunner` and persists each thread to `local_threads`. The
+app has no field for a CopilotKit key. Recall/memory (pgvector) is still ADR-0001's, for v1.1.
-- ADRs: `docs/decisions/0007-...` (the decision), `docs/decisions/0001-...` (the eventual design)
-- Measured scope: `grep` says 156 references across 18 files; the **compiler** says 5 across 4.
- Trust the compiler. Widen the `RuntimeCapabilities` union and let it tell you.
-- `COPILOTKIT_LICENSE_TOKEN` is telemetry only. The runtime validates nothing.
+- ADRs: `docs/decisions/0008-...` (what shipped), `0007-...` (the seam), `0001-...` (the fuller design)
+- `LocalThreadRunner` is single-user: SSE mode has no `identifyUser`.
- **Never add a new direct call to the Intelligence client.** Go through the seam.
### 2. The model provider is a process-wide environment variable. We are making it per-agent.
diff --git a/README.md b/README.md
index d154fcd..e85cd3f 100644
--- a/README.md
+++ b/README.md
@@ -67,9 +67,9 @@ process. On 27 September a throwaway engine with no CopilotKit key held a three-
with a local Ollama model, drove its browser through the client-tool loop, remembered across turns,
and kept every conversation intact through a restart.
-Still open: a second live vendor, one intermittent fault seen only under the full live suite, a
-clean-VM first run, and Developer ID signing, notarization, Sparkle keys and the first published
-release. Those last items need an account holder.
+Still open: a second live vendor, a clean-VM first run, notarization credentials that Apple
+accepts, and the first published release. Signing, Sparkle keys and the release pipeline are in
+place.
Start at [`docs/README.md`](docs/README.md). The milestone table is in
[`docs/12-roadmap.md`](docs/12-roadmap.md), and what is left, in order, is in
diff --git a/docs/01-vision.md b/docs/01-vision.md
index aba3e04..d610d23 100644
--- a/docs/01-vision.md
+++ b/docs/01-vision.md
@@ -121,12 +121,12 @@ is genuinely local and genuinely model-agnostic.
Recorded here rather than discovered in month four.
-**⚠️ The engine has a cloud dependency we are living with in v1.** OpenBot's server refuses to
-start without CopilotKit Intelligence unless the seam described in
-[ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) is used. It is built and the engine
-has been run without an account — but v1 ships on Intelligence, so a third party can change its
-free tier and affect the product. The seam is what keeps that from being fatal rather than
-inconvenient. [ADR-0001](decisions/0001-local-history-provider.md) remains the end state.
+**The engine's cloud dependency is gone for conversations.** OpenBot's server refused to start
+without CopilotKit Intelligence; the seam from
+[ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) lets it run without, and
+[ADR-0008](decisions/0008-local-thread-runner.md) made local mode the one v1 ships: conversations
+are kept by the engine's own `LocalThreadRunner`. Cross-conversation memory (pgvector) is still
+[ADR-0001](decisions/0001-local-history-provider.md)'s, for v1.1.
**⚠️ Container runtimes on macOS are a licensing and UX minefield.** Docker Desktop requires a paid
licence above a company-size threshold, is a large install, and is not something we can bundle.
diff --git a/docs/13-launch-checklist.md b/docs/13-launch-checklist.md
index 94e0041..94575eb 100644
--- a/docs/13-launch-checklist.md
+++ b/docs/13-launch-checklist.md
@@ -154,13 +154,16 @@ That first run found three engine faults, each of which failed every conversatio
guidance, and Anthropic accepts a system message only first. All system texts are now merged, in
order, into one leading message (`agent-langgraph/src/history.ts`).
-**One open finding.** On a freshly started engine, roughly one full live run in six had a turn come
-back empty, or a request go unanswered, while all nine live tests ran at once. The engine logged
-"Thread already running" or, once, "No agents are registered", both of which should be impossible
-there. It was never seen with the conversation suite alone (5 of 5 fresh engines), nor with a
-logging proxy in the path (12 of 12). A leftover process, a database lock, identity, idle
-keep-alive and split request writes were each checked and ruled out. Worth one more look before
-launch, starting from the app under ordinary use rather than nine tests at once.
+**The intermittent fault, found and fixed** (29 September). On a freshly started engine, roughly one
+full live run in six had a turn come back empty, followed by "Thread already running". The cause was
+Bun's own `idleTimeout`, 10 seconds by default: a streaming run writes RUN_STARTED and then nothing
+until the model's first token, and a cold local model takes longer than that. The connection was
+closed under a live run, the reply was empty, and the orphaned run held the thread until it
+finished. A fake model that stays silent for 15 seconds reproduced it every time. Streaming agent
+runs are now exempt from the idle timeout (`server/src/xbot/stream-idle-timeout.ts`); with that, 6
+of 6 full live rounds on fresh engines with a cold model passed with no busy refusals. A message
+sent while a run genuinely is still going now gets RUN_ERROR with a sentence rather than an empty
+200.
**Two more, found starting the app on this Mac's own engine** (27 September), both leaving the
container unhealthy with nothing on its port and no way back short of deleting the data:
@@ -233,8 +236,8 @@ From a shell holding no credentials:
now needs re-running against `LocalThreadRunner` rather than a CopilotKit key — see item 5.
- On 27 September, it was: a throwaway engine in local mode with no CopilotKit key held a real
conversation with a local Ollama model, used its computer through the client-tool loop, remembered
- across turns, and kept every conversation intact through a restart. Item 5 has the details and
- the one open finding.
+ across turns, and kept every conversation intact through a restart. Item 5 has the details,
+ including the intermittent empty reply found and fixed on 29 September.
- The pinned engine is one multi-arch image (linux/amd64 and linux/arm64). On 14 September an Apple
Silicon Mac pulled it anonymously, got arm64, was healthy in about ten seconds on a port other than
3001, and passed the live suite: browser, files, shell, and a help request handed back. Before that
diff --git a/docs/README.md b/docs/README.md
index 2dac201..d7b6505 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -58,6 +58,7 @@ Architecture Decision Records. Each one exists because the decision looks wrong
| [0005](decisions/0005-distribution-outside-app-store.md) | Developer ID and a DMG, not the Mac App Store |
| [0006](decisions/0006-naming-and-trademark.md) | Open questions about the name and the visual reference |
| [0007](decisions/0007-wrap-openbot-keep-intelligence.md) | **Wrap OpenBot rather than re-engineer it, and keep Intelligence for v1.** Defers 0001 and re-orders the roadmap — read it before 01 or 03 |
+| [0008](decisions/0008-local-thread-runner.md) | **Conversations kept locally** by a durable wrapper around the vendor's SSE runner. Supersedes 0007's "keep Intelligence" for v1 |
## Conventions in these documents
diff --git a/engine/server/src/history/local-thread-runner.ts b/engine/server/src/history/local-thread-runner.ts
index b1bc265..691c4b1 100644
--- a/engine/server/src/history/local-thread-runner.ts
+++ b/engine/server/src/history/local-thread-runner.ts
@@ -1,8 +1,10 @@
-import type { Message } from "@ag-ui/client";
+import { type BaseEvent, EventType, type Message } from "@ag-ui/client";
import {
type AgentRunnerRunRequest,
InMemoryAgentRunner,
+ ɵGLOBAL_STORE,
} from "@copilotkit/runtime/v2";
+import { of } from "rxjs";
import type { Database } from "../db/client";
import { localThreads } from "../db/schema";
import type { IntelligenceLike } from "../routines/run-turn";
@@ -50,6 +52,23 @@ export class LocalThreadRunner extends InMemoryAgentRunner {
}
override run(request: AgentRunnerRunRequest) {
+ /*
+ * A conversation that is still answering refuses the next run, and says so as an event.
+ *
+ * The vendor's runner throws "Thread already running" here. Thrown inside the SSE handler's
+ * factory, that is logged and the response — already a 200 — is closed with no events in it, so
+ * the Mac app saw a reply that was simply empty and could not tell anybody why. RUN_ERROR is the
+ * event every caller already reads as a failed turn: the app shows its sentence, and a routine or
+ * a hop, which take a lock before they get here, treat it as the refusal it is.
+ */
+ if (this.isBusy(request.threadId)) {
+ return of
Create agents, give them a computer, watch them work, and take the wheel when you want to.
Free and open source. macOS 14 Sonoma or later, Apple Silicon or Intel.Your own AI coworkers, on your own Mac
Where your things live
- One exception, and we would rather you read it here than find it later. In - version 1, your conversation history is stored by - CopilotKit, the service xBot's engine is built on. Your - agents' files and browsers are not — only the transcript. Onboarding says so before you type - a key, and - ADR-0007 - records why and what replaces it. + Your conversations stay here too. The engine keeps every conversation in + its own database on this Mac, with no CopilotKit account and nothing to sign up for. + What leaves the Mac is what you send to the model provider you chose — and nothing at all + if that is a model running locally through Ollama. + ADR-0008 + records how.