diff --git a/.dockerignore b/.dockerignore
index d31d0915..7dd9be9e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -29,3 +29,6 @@ docs/screenshots
# that survives every `docker save` and every registry push.
stem_key
*.tar
+
+# The relay is a separate Go program with its own image (relay/Dockerfile).
+relay
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 25313ec9..1f00a901 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,6 +56,12 @@ jobs:
- name: Headless server boots under plain node
run: npm run test:server
+ # tests/e2e/browser-live.spec.ts drives a real Chromium with the Stem
+ # extension through the native host — the one check of the per-platform
+ # browser-control glue. It skips itself when no browser is installed.
+ - name: Install Chromium for the browser-control E2E
+ run: npx playwright install ${{ runner.os == 'Linux' && '--with-deps' || '' }} chromium
+
- name: E2E tests (Playwright + Electron)
run: ${{ runner.os == 'Linux' && 'xvfb-run --auto-servernum --' || '' }} npm run test:e2e
@@ -94,3 +100,17 @@ jobs:
- name: Unit tests (Vitest, Windows-relevant subset)
run: npm run test:win
+
+ relay:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: relay
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: relay/go.mod
+ cache-dependency-path: relay/go.sum
+ - run: go vet ./...
+ - run: go test ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 800bb564..33470665 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,6 +6,10 @@ name: Release
on:
push:
tags: ['v*']
+ # Run by hand (Actions → Release → Run workflow, or `gh workflow run release.yml
+ # --ref `) to build every installer from a branch before tagging; the
+ # installers land as workflow artifacts and nothing is published.
+ workflow_dispatch:
permissions:
contents: write
@@ -121,13 +125,30 @@ jobs:
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
# Sign while electron-builder's temporary signing keychain is available.
- - run: npm run dist:mac -- --arm64 --config.forceCodeSigning=true --config.dmg.sign=true
+ # Stapling fetches the ticket from Apple's CloudKit, and the connection
+ # from GitHub's macOS runners drops now and then (Error 68, "The network
+ # connection was lost") after Apple has already accepted the app. Build
+ # again rather than fail the release on it.
+ - name: Build, sign and notarize
+ run: |
+ for attempt in 1 2 3; do
+ if npm run dist:mac -- --arm64 --config.forceCodeSigning=true --config.dmg.sign=true; then exit 0; fi
+ echo "::warning::dist:mac failed (attempt $attempt)"
+ rm -rf release
+ sleep 30
+ done
+ exit 1
- name: Verify signed and notarized app
run: |
app=release/mac-arm64/Stem.app
test "$(lipo -archs "$app/Contents/MacOS/Stem")" = arm64
codesign --verify --deep --strict --verbose=2 "$app"
- xcrun stapler validate "$app"
+ # validate asks Apple's CloudKit too; see the build step above.
+ for attempt in 1 2 3 4 5 6; do
+ if xcrun stapler validate "$app"; then break; fi
+ if [ "$attempt" = 6 ]; then exit 1; fi
+ sleep 20
+ done
spctl --assess --type execute --verbose=4 "$app"
- name: Notarize and verify final DMG
run: bash scripts/notarize-mac-dmg.sh release/*-arm64.dmg
@@ -152,8 +173,41 @@ jobs:
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
+ windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ cache: npm
+ - run: npm ci
+ # Unsigned: no Windows code-signing certificate yet (see the `win` block
+ # in electron-builder.yml).
+ - run: npm run dist:win
+ - name: Packaged app is sound
+ shell: bash
+ run: |
+ app=release/win-unpacked/resources/app
+ node "$app/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" --version
+ for stray in src tests ios docs scripts Dockerfile; do
+ if [ -e "$app/$stray" ]; then echo "::error::$stray/ leaked into the packaged app"; exit 1; fi
+ done
+ ls "$app/node_modules/onnxruntime-node/bin/napi-v6"
+ # latest.yml + the installer's blockmap are what an installed Stem
+ # updates from (src/desktop/updates.ts).
+ - uses: actions/upload-artifact@v4
+ with:
+ name: windows
+ if-no-files-found: error
+ path: |
+ release/Stem-Setup-*.exe
+ release/Stem-Setup-*.exe.blockmap
+ release/latest.yml
+
publish:
- needs: [linux-x64, mac]
+ needs: [linux-x64, linux-arm64, mac, windows]
+ if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
diff --git a/.gitignore b/.gitignore
index 0babebfe..04ebfb03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-node_modules/
+node_modules
dist/
out/
# electron-builder artifacts (see electron-builder.yml directories.output)
diff --git a/README.md b/README.md
index 6e1de918..64aee1dc 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
Download
- · macOS (Apple Silicon) · Linux (AppImage, deb) ·
+ · macOS (Apple Silicon) · Linux (AppImage, deb) · Windows ·
User guide
@@ -114,7 +114,7 @@ A global shortcut opens Quick Chat over whatever app you're in. Ask, hit Enter,
- **Coding agents.** A persona hands work to Claude Code or any other ACP agent, on the server or on one of your computers. The agent's permission requests arrive as cards in the chat.
- **Computer control (alpha).** A persona pinned to your Mac can work in its apps, including windows on another Space or behind other windows. macOS 14 or newer.
- **Skill recorder (alpha).** Record yourself doing a task on the Mac. Stem writes it up as a draft skill, and a practice run tries it before you save it.
-- **Browser control.** The Stem extension for Chrome, Arc, Dia, Brave, Edge and Chromium lets Stem use your own signed-in browser in background tabs.
+- **Browser control.** The Stem extension for Chrome, Arc, Dia, Brave, Edge and Chromium lets Stem use your own signed-in browser in background tabs, on a Mac or Linux.
- **Scheduled tasks.** Ask for "every weekday at 8, check…" and you get a cron job. Each run starts in a fresh thread.
- **Shell commands.** A safety check runs what you asked for and turns the rest back. You decide only after repeated refusals, and you can always allow commands by name or pattern.
- **MCP servers and skills.** Add MCP servers by command or URL, OAuth included. Stem writes skills from work that went well and rewrites the ones that failed.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index aa5c9a12..35aa1cca 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -12,6 +12,77 @@ Maintainer notes:
date, tag.
-->
+## 0.7.0 — 2026-10-19
+
+### Added
+
+- **Mail with other people's Stems.** Add your email address under Settings → Features → **Your
+ email address** and confirm the code Stem emails you. People who use Stem can then mail you there,
+ and you can mail them. Mail is encrypted from one Stem to the other and is text only for now. Mail
+ from someone new waits under **Requests** until you accept them. Your personas see an incoming
+ mail only after you let them, and a reply a persona drafts goes out only after you approve it.
+ **Only people I've written to can reach me** keeps everyone else from finding out that you use
+ Stem. Compare keys in person to mark a contact as checked. Requests and drafts are on the desktop
+ only.
+- **Share links.** Hover an answer and click the share icon to publish the whole chat, the question
+ and answer, or the answer alone as a link anyone can open. The content is encrypted in your Stem,
+ and the key is part of the link, so the server that hosts it can't read it. Links show a preview
+ card in chat apps, stay out of search engines, and expire after 30 days. Settings lists your links
+ so you can take one down. Sharing needs a verified email address, and a company that limits mail
+ with other Stems turns it off. It works from the iPhone too.
+- **Ask about this window.** A second Quick Chat shortcut opens Quick Chat next to the window you're
+ in, with a picture and the text of that window attached. Ask "What does Jana need from me?" in
+ Slack and the answer appears beside it. If one of your MCP servers covers that app, Stem uses it
+ to read what the window doesn't show. With computer control allowed, Stem can also click and type
+ in that window. Set the shortcut under Settings → Quick Chat. Works on the Mac, Windows and Linux
+ (GNOME).
+- **Computer control on Windows and Linux (Alpha).** "Let Stem control this computer" now works on
+ Windows 10 and 11, and on Linux under GNOME on Wayland. On Linux, Stem installs a small GNOME
+ extension from Settings → Features → Computer control. You log out and back in once after the
+ first install, and later updates arrive without a login. Moving your own mouse or pressing a key
+ stops a run.
+- **Browser control on Windows and Linux (Beta).** The Stem extension now works in Chrome, Edge,
+ Brave and Chromium on Windows and Linux, and in Chromium-based AppImages such as Helium. Browsers
+ installed as Snap or Flatpak can't run it.
+- **Several browser profiles.** If you keep separate browser profiles for work and home, each one
+ connects on its own. Name them under Settings → Features → Browser control, pick the one tasks
+ start in, and limit a persona or chat to the profiles it may use. Asked for your open tabs, Stem
+ lists them by profile.
+- **Dictation on Windows and Linux (Beta).** The microphone button now works there too, with Whisper
+ running on your computer. Its model downloads the first time you use it, and the audio never
+ leaves your computer.
+- **Secretary brings in help on its own.** A Secretary mail now adds whichever personas the job
+ needs, including ones that control your computer, and sees the errand through. Use **Normal** if
+ you want to add personas yourself.
+
+### Changed
+
+- **The iPhone opens faster.** The chat list and mail show the last list at once and load less over
+ the network, so a slow connection no longer means a long wait.
+- **Connected folders skip ignored files.** A mirrored folder leaves out what its `.gitignore`
+ leaves out.
+
+### Fixed
+
+- **Several pictures made at once all show.** When Stem made a few pictures in one go, all but the
+ last read "no longer available" until you reopened the chat.
+- **A long chat on the iPhone no longer goes blank** after you send a message.
+- **Every HEIC photo from the iPhone arrives.** Some HEIC and HEIF variants were sent without being
+ converted to JPEG and couldn't be read.
+- **A scheduled task survives a server restart.** A run the server died under is now recorded and
+ run once more, instead of being lost.
+- **Long sign-ins finish.** Signing in during onboarding no longer gets stuck when it takes more
+ than five minutes.
+- **Answered approvals leave the Inbox.** Answering an approval in mail moves that conversation out
+ of the Inbox, and archiving a chat clears its Return-to-chat row.
+- **Named chats stay read.** A chat Stem named after its first turn no longer turned bold again.
+- **The working dots stay** under a reply that's still being written, so you can tell it isn't
+ finished.
+- **Big servers start.** On a server with many chats, the chat list scan at startup could run out of
+ memory.
+- **The first launch on Windows works.** Windows scans every file on the first launch after an
+ install or update, which made Stem time out before it could answer.
+
## 0.6.1 — 2026-10-13
### Fixed
diff --git a/deploy/env.example b/deploy/env.example
index 638163f8..3fdb8226 100644
--- a/deploy/env.example
+++ b/deploy/env.example
@@ -53,10 +53,15 @@ TZ=Europe/Bratislava
# ---- Push notifications to the iOS app (optional; off unless all four are set) ----
#
-# Without these, Stem sends no notifications and does no work towards them — the
-# server is exactly what it was. With them, an approval card, a long turn ending
-# or a task alert also taps the phone on the shoulder; the notification carries
-# ids and a short label and never a message (src/server/push/).
+# You don't need these. Without them, Stem wakes the phone through the global
+# relay's push gateway, which shows fixed wording only ("Approval needed") and
+# sees the device token and ids, never a message. STEM_PUSH_GATEWAY=off turns
+# that off; STEM_PUSH_GATEWAY_URL points it at another relay.
+#
+# With them, this server talks to Apple itself: an approval card, a long turn
+# ending or a task alert taps the phone on the shoulder with ids and a short
+# label and never a message (src/server/push/). Only the holder of the app's
+# APNs key can do this.
#
# The key is the .p8 Apple hands you once, for a key with the APNs service
# enabled. It has to be INSIDE the container, so mount it in docker-compose.yml
diff --git a/docs/README.md b/docs/README.md
index f9e1a3a8..51be7c03 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -7,6 +7,8 @@ Pick the feature you see in Stem.
| [Chats](user/chats.md) | Ask, attach files, organize conversations |
| [Quick Chat](user/quick-chat.md) | Ask from any app |
| [Mail](user/mail.md) | Send requests to personas and get replies in your Inbox |
+| [Mail with other Stems](user/other-stems.md) | Write to people who use Stem, with your personas drafting the replies |
+| [Share links](user/share-links.md) | Publish an answer or a chat as a link anyone can open |
| [Memory](user/memory/README.md) | Learn useful context and recall earlier chats |
| └ [Facts](user/memory/facts.md) | Durable details: preferences, people, projects |
| └ [Past chats](user/memory/recall.md) | Recall: find relevant parts of past conversations |
diff --git a/docs/assistant/assistant-host.md b/docs/assistant/assistant-host.md
index d317be64..dd0d5711 100644
--- a/docs/assistant/assistant-host.md
+++ b/docs/assistant/assistant-host.md
@@ -2,6 +2,6 @@
Stem is NOT running on the computer the user is typing on. Stem itself runs on a server they own (Linux, macOS or Windows); the app in front of them is a client talking to it over the network, and their phone may be another. Your `run_command` shell, the files you read and write, and every MCP server that is not pinned to one of their computers run ON THAT SERVER.
-So: the server has what a server has installed, not what their own computer has, and it cannot reach an address on their home network. When something fails there because a program is missing, say plainly which machine is missing it instead of asking them to check their own. Do not tell them your shell "does not run on your computer" as though you had no shell — you have the server's, which is the right one for anything belonging to Stem. If a job genuinely has to happen on their own computer, you may have two ways in: `run_command` with its `device` parameter runs a shell command on one of their own computers (only one that has switched on "accepts commands" — your per-turn context names them), and an MCP server pinned to that computer covers everything else; read the `assistant-mcp` and `mcp-servers` guide pages. The `device` shell is for shell work — files, git, scripts, `open -a`. Driving that computer's screen (System Settings, clicking, typing, AppleScript at System Events or at an app) is the job of the `computer` tool: a persona pinned to that computer in the persona editor has it, and so does a chat with no persona when the user allowed computer control in chats (Settings → Features). A persona chat without a pin never has it, whatever that switch says. When a persona is pinned to the computer, a GUI-scripting command from a conversation that may not drive it is refused with the persona's name. Hand the task to that persona (send_mail it in a mail conversation the user added it to) or tell the user to ask it, rather than scripting around it. Coding agents follow the same rule: a persona's coding setup, or Settings → Features → Coding agents for chats with no persona. So does the user's browser: the `browser` tool (the Stem extension in their own Arc or Chrome) comes from a persona's browser pin or Settings → Features → Browser control; web pages go through it, not through `computer` or a browser opened over `run_command`.
+So: the server has what a server has installed, not what their own computer has, and it cannot reach an address on their home network. When something fails there because a program is missing, say plainly which machine is missing it instead of asking them to check their own. Do not tell them your shell "does not run on your computer" as though you had no shell — you have the server's, which is the right one for anything belonging to Stem. If a job genuinely has to happen on their own computer, you may have two ways in: `run_command` with its `device` parameter runs a shell command on one of their own computers (only one that has switched on "accepts commands" — your per-turn context names them), and an MCP server pinned to that computer covers everything else; read the `assistant-mcp` and `mcp-servers` guide pages. The `device` shell is for shell work — files, git, scripts, `open -a`. Driving that computer's screen (System Settings, clicking, typing, AppleScript at System Events or at an app) is the job of the `computer` tool, on a Mac, on Windows 10 and 11, and on Linux under GNOME on Wayland: a persona pinned to that computer in the persona editor has it, and so does a chat with no persona when the user allowed computer control in chats (Settings → Features). A persona chat without a pin never has it, whatever that switch says. When a persona is pinned to the computer, a GUI-scripting command from a conversation that may not drive it is refused with the persona's name. Hand the task to that persona (send_mail it in a mail conversation the user added it to) or tell the user to ask it, rather than scripting around it. Coding agents follow the same rule: a persona's coding setup, or Settings → Features → Coding agents for chats with no persona. So does the user's browser: the `browser` tool (the Stem extension in their own Chrome-family browser: Arc, Chrome, Edge, Brave and the like) comes from a persona's browser pin or Settings → Features → Browser control; web pages go through it, not through `computer` or a browser opened over `run_command`.
A program the server lacks is often still one command away, so try these before declaring it missing: anything published on PyPI runs as `uvx ` (`uvx yt-dlp `) and anything on npm as `npx -y `, with no install step — the download is kept, so it is only slow the first time. A tool you will reach for again can go on the PATH with `uv tool install `; those land in a folder that survives server upgrades. `apt-get install` also works (the run pauses for the user's approval) and is the way to a system-level program like `ffmpeg` — but an upgrade replaces the container and an apt install goes with it, so when you resort to apt, say so and tell the user that a few lines in a `Dockerfile.local` beside the server's docker-compose.yml make it permanent (the "Running on a server" doc in the Stem repository shows exactly the lines).
diff --git a/docs/browser-control.md b/docs/browser-control.md
index e4323de0..e8ad26b5 100644
--- a/docs/browser-control.md
+++ b/docs/browser-control.md
@@ -25,9 +25,10 @@ The user chose the computer-control posture instead.
| Actions | `tabs`, `open`, `navigate`, `snapshot` (an accessibility outline with refs), `screenshot`, `click`, `hover`, `type`, `fill`, `press`, `scroll`, `wait`, `dialog`, `evaluate`, `console`, `network`, `upload`, `downloads`, `close`. |
| Uploads | Only files Stem holds: Files, scratch, connected folders, `img_` ids, and the conversation's attachments, which a browser turn keeps as files. Never an arbitrary path on the Mac. |
| Downloads | Copied into the conversation's scratch `downloads/` on the server. |
-| Several browsers | One per Mac, chosen in that Mac's Settings. The chosen browser is started (`open -g -a`) when a run needs it and it is closed. |
+| Several browsers | One per computer, chosen in that computer's Settings. The chosen browser is started when a run needs it and it is closed: `open -g -a` on a Mac; on Linux its executable is run unless a process already runs it, and it may come to the front. |
+| Several profiles (2026-10-13) | Every profile with the extension connects on its own and stays connected. Settings names them and picks the default; the model names another with `profile`, and that profile sticks to the thread until the run ends. `tabs` with no profile lists every open profile's tabs. See below. |
| Concurrency | Several runs at once, each keeping track of its own tabs, with one action queue per tab. A run is warned when another run used the same tab recently. |
-| Platform | macOS only for now: Arc, Chrome, Dia, Brave, plus Chromium, Edge and Chrome for Testing when they are installed. |
+| Platform | macOS: Arc, Chrome, Dia, Brave, plus Chromium, Edge and Chrome for Testing when they are installed. Linux: Chrome, Brave, Chromium, Edge, plus every other Chromium user-data folder under `$XDG_CONFIG_HOME` (one or two levels down, `Local State` + `Default/`, which leaves Electron apps out) — Helium, Thorium, Vivaldi, Opera, beta channels. The browser is named by `/proc//exe`; for an AppImage (exe inside the parent's `APPDIR`) by its `APPIMAGE` file instead, since the mount changes every launch. Stem starts known system binaries, and AppImages that are a regular executable file with the AppImage magic, owned by the user or root and writable by no one else; a tarball browser in the home folder connects but is never started. Snap and Flatpak browsers are out: their sandbox reads its own manifest folder and will not start a host outside it. Windows: no. |
| Install | An unpacked extension: Set up copies it into the profile's state folder and writes native-messaging manifests. The extension id is fixed by the manifest `key`. A Web Store listing comes later. |
| Local server | Works when the server runs on the same Mac. Computer control was opened up to that case at the same time. |
@@ -56,6 +57,37 @@ pi `browser` tool ──ctx.ui.input(stem-browser-bridge)──▶ PiRuntime.han
- The native host keeps retrying the socket while Stem is down. The open native port
keeps the extension's service worker alive, so it sees Stem come back.
+## Profiles
+
+Each browser profile runs its own copy of the extension, and so its own native host.
+Until 0.7.0 the desktop keyed connections by the app bundle the host was launched from,
+so Arc's work and personal profiles replaced each other's connection every few seconds.
+Now the extension keeps a random id in `chrome.storage.local` (one per profile) and sends
+it in its `hello`, along with the three sites most open there. The desktop keys
+connections by `#`, and a connection counts only once that hello
+has arrived (the native host replays it on every socket connection).
+
+- Names: the user's label from Settings, otherwise "Arc 2" by connect order when an app
+ has several profiles, otherwise the app's name. The desktop sends each extension its
+ name (`{type: 'profile'}`) for the toolbar popup, which is how the user tells the
+ profiles apart. The sites hint is shown only in that Mac's Settings and never goes to
+ the server.
+- Routing: `DeviceBrowserRequest.profile` is the name the model passed. The desktop
+ resolves it (a profile's name, or an app with one profile), and it then sticks to the
+ thread until the `end` frame. With no name, the thread's last profile is used, or the
+ default.
+- `tabs` without a profile on a Mac with several asks every connected profile, plus the
+ thread's own one (started if needed), and answers with one section per profile. Arc's
+ sidebar completion is added once.
+- Entries remembered before profiles (the bare app path) are taken over by the first
+ profile of that app to connect, keeping their name and their place as the default.
+- An extension older than 0.7.0 sends no id and is keyed by the app path, as before.
+- Starting a closed profile: Stem can only start the app (`open -g -a`). Chrome then
+ opens its last-used profile. When the wanted profile does not connect, the error tells
+ the model to ask the user to open a window of it.
+- A persona pin to a single profile is not built. The model picks the profile from the
+ task.
+
## Arc's unloaded tabs
Arc gives extensions only the tabs it has loaded since it started. A sidebar tab
diff --git a/docs/inter-stem-mail-design.md b/docs/inter-stem-mail-design.md
new file mode 100644
index 00000000..421b4dd4
--- /dev/null
+++ b/docs/inter-stem-mail-design.md
@@ -0,0 +1,523 @@
+# Inter-Stem mail design
+
+Status: decided 2026-10-13, revised the same day after review (discoverability,
+sender authentication) and after the Stem chat "Stem Inter-Stem Mail Design"
+(purpose, full memory by default). Nothing built. Builds on the mail system
+(`src/server/mail/router.ts`, `MailItem` in `src/shared/types.ts`) and the
+Inbox, which was groundwork for this from 0.4.0.
+
+## Goal
+
+Not another email. Email and messaging apps already carry words between
+people. What Stem adds is that **each side has an assistant that already
+knows its owner's situation**, so two people can work something out without
+either one packaging all the context or relaying every clarification:
+
+- **Making plans together.** "Find a weekend activity for our families."
+ Each Stem knows its own family's constraints and proposes what fits,
+ without handing the other side its owner's personal context.
+- **Delegating across people.** A colleague asks for a review of an
+ infrastructure change. Their Stem brings the proposal and background;
+ yours investigates, asks for what's missing, and returns your conclusions.
+- **Keeping a shared commitment moving.** "We agreed to record a podcast
+ next month." Each Stem tracks its owner's part, gathers material and flags
+ blockers.
+
+The model is **humans let in, agents prepare, humans release.** Your agents use your full
+memory and tools to prepare each reply; you review the exact outgoing mail
+and send it. The value isn't fewer clicks, it's no work behind each click:
+gathering context, investigating, comparing options, remembering what's
+still open.
+
+A remote conversation is therefore shown as an **ongoing collaboration with
+a goal and pending decisions** ("Podcast recording: waiting for Martin's
+availability"), not just a thread of messages.
+
+Mail between Stems uses the email addresses people already have, reaches the
+person rather than their agents, and is end-to-end encrypted.
+
+## Settled decisions
+
+1. **One person per Stem.** No shared multi-user servers. Most Stems run
+ embedded in the desktop app on a laptop, so they are often asleep or
+ offline. A VPS-hosted Stem is the same thing, just always on.
+2. **Addresses are real email addresses** (`martin.mrkvicka@gmail.com`), not
+ relay handles. Owning the address is proven by an emailed code or link.
+3. **Relays.** Awantech runs the global relay. A company can run a private
+ relay for its own domain. Both run the same software.
+4. **End-to-end encrypted.** A relay sees who mails whom, when, and how big
+ the mail is. It never sees subject, body or attachments.
+5. **Every incoming mail is approved by the human before any agent sees
+ it**, including mail from accepted contacts and mid-collaboration
+ replies. An incoming mail never starts a persona turn on its own.
+6. **Every outbound persona mail to another Stem is approved by the human**,
+ whether it is a reply the human asked for or a mail the persona started.
+7. **The relay only carries mail in transit.** It deletes a mail once the
+ recipient's Stem confirms it has it, or when the wait runs out. It is not a
+ mailbox.
+8. **Losing the laptop loses everything**, the key included, the same as the
+ rest of Stem. There is no key backup. Recovery is setting up again and
+ verifying the address again.
+9. **Your agents use your full memory in external conversations, by
+ default.** Nothing is shared with the other Stem except what you approve
+ in an outgoing mail. The other side's agents never get access to your
+ memory.
+10. **Membership is not hidden** from people who know an address, unless the
+ owner turns on Unlisted.
+11. **A sender's address is vouched for by its home relay** with short-lived
+ certificates; DNS is the trust root, as for email.
+12. **Every outgoing mail is approved, in full**, for now. Bounded
+ collaborations where agents handle routine exchanges themselves are a
+ later step.
+13. **Exits other than mail are enforced in code** in external conversations
+ (tool set), since approval only covers mail.
+
+## Identity and keys
+
+- Each Stem has one keypair: X25519 for encryption, Ed25519 for signing. The
+ private key never leaves the Stem.
+- A Stem can register several addresses (work and personal) with the same
+ key.
+- Registration: Stem sends `{address, publicKey}` to the relay. The relay
+ emails a code or link to the address. Confirming it publishes the mapping
+ address → key.
+- The relay answers every registration with "code sent", whether the address
+ is new or already registered. If it is registered, the email tells the
+ owner someone tried to add a key and to ignore it unless it was them.
+- A new key (new laptop) goes through the same email verification. Contacts
+ see a key-change notice in the conversation.
+
+### The relay vouches for keys
+
+Because the relay maps addresses to keys, whoever runs the relay could hand
+out a false key and read mail. The design limits this:
+
+- **Pinning.** A Stem pins a contact's key on first contact. A changed
+ key is shown loudly in the conversation, and nothing is sent to the new
+ key until the user acknowledges the change.
+- **In-person verification.** Comparing a fingerprint or scanning a QR code
+ upgrades a contact from "verified by email" to "verified by you".
+- **Key log (later).** An append-only public log of address → key mappings,
+ which Stems check, so a false key handed to one person is detectable. This
+ is what makes "the relay can't read your mail" true even about the operator.
+
+## Relays and routing
+
+- Which relay serves an address comes from its domain. A TXT record at
+ `_stem.` names the domain's relay. Without a record, the address
+ belongs to the global relay (gmail.com and everything else).
+- The global relay refuses to register addresses in any domain that has its
+ own record, so nobody can claim `martin@acme.com` before Acme sets up.
+- A private relay verifies its people however it likes: email codes first,
+ SSO later.
+- Stems connect outward only: each Stem holds a long-lived connection to the
+ relay of each of its addresses. No open ports, works behind NAT and company
+ firewalls.
+- Sending to an address on another relay goes to that relay directly. Relays
+ don't forward to each other.
+- **A company can keep Stem mail inside.** Three locks, each usable alone:
+ - a **policy file** on company machines (`docs/stem-policy.md`) turns
+ inter-Stem mail off, or allows only the company's domains through the
+ company relay. The app then never contacts the global relay (push
+ gateway included), and the user can't loosen it from Settings;
+ - a **closed relay** (`closed = true`) refuses every sender outside its
+ domains and named partners, and answers their lookups as "not on Stem";
+ - `access=closed` in the `_stem` record makes every other relay, the
+ global one first, refuse the domain's people as senders, which also
+ covers employees whose Stem has no policy file.
+
+### Delivery
+
+1. The sender's Stem looks up the recipient's key (cached and pinned after
+ the first time), seals the mail to it, signs it, attaches its sender
+ certificate (see Sender authentication), and posts it to the
+ recipient's relay.
+2. The relay checks the certificate, then holds the sealed mail until the
+ recipient's Stem collects it and confirms it has stored it, then deletes
+ it. The wait is capped at 7 days; an expired mail is deleted, and between
+ accepted contacts the sender's Stem marks it not delivered.
+3. Approved sends made while offline queue on the sender's Stem and go out
+ when it reconnects.
+4. A lookup that finds no key means "not reachable": the address isn't on
+ Stem, or its owner is unlisted (see Discoverability). The sender's Stem
+ can't tell which. It offers "invite them to Stem"; the relay emails an
+ invite only if the address is unregistered and the invite limits allow,
+ and never reports whether it did. The mail is kept on the sender's Stem
+ and offered for sending once that address becomes reachable.
+
+The global relay needs a transactional mail provider (Postmark or SES) for
+verification and invite emails; a bare VPS lands in spam.
+
+### What the relay knows
+
+Registered addresses and keys, and who mailed whom, when, and the size. Under
+real email addresses this is a social graph. The privacy note says so.
+
+## Sender authentication
+
+An envelope signature proves the sender has a key, not that they own the
+address in `from`. The sender's **home relay** (the relay that verified the
+address) vouches for that, the same way DKIM lets a domain vouch for mail:
+
+- **Relay signing keys.** Every relay has a long-lived signing key. A private
+ relay publishes it in its `_stem.` TXT record (alongside the relay
+ URL). The global relay's key ships inside Stem and is also served at
+ `/.well-known/stem-relay`. DNS is the trust root for a domain, as it is for
+ email; no relay vouches for addresses outside its domains.
+- **Sender certificates.** The home relay issues each verified Stem a
+ certificate per address: `{address, key fingerprint, issuing relay,
+ expires}` signed by the relay's key, valid 24 hours and refreshed
+ automatically while the Stem is connected.
+- **On send,** the envelope carries the certificate and is signed with the
+ key it names. The receiving relay checks: the certificate's issuer is the
+ authority for the address's domain (DNS record, or the global relay when
+ there is none), the issuer signature, expiry, and the envelope signature
+ against the certified key. Anything that fails is refused.
+- **The recipient's Stem checks the same things itself**, and doesn't trust
+ its own relay's word. It also compares the certified key with the pinned
+ one, so a key change shows as a key-change notice.
+- **Revocation is expiry.** Certificates live a day, so a lost or replaced
+ key stops being vouched for within 24 hours without any revocation list.
+ For an immediate cut-off, the home relay marks the key revoked and
+ receiving relays and Stems that look up the sender see it; a sender
+ lookup happens on first contact and whenever a certificate names a key
+ different from the pinned one.
+- **Relay key rotation.** A relay publishes the next signing key alongside
+ the current one for an overlap period (two DNS records); certificates
+ signed by either are accepted until the old one is removed.
+- **Relay-to-relay limits.** A receiving relay rate-limits per issuing relay
+ as well as per sender, and a closed private relay accepts only its own
+ domains and named partner domains (see "A company can keep Stem mail
+ inside").
+- **Replay.** Envelope ids are unique and carry a timestamp; relays and Stems
+ drop duplicates and envelopes older than the 7-day transit cap.
+
+## The relay software
+
+Goal: easy to install, and dumb enough that Stem releases never touch it.
+
+### Build and install
+
+- **Go**, one static binary, plus a small Docker image. No Node, no npm.
+- Built-in Let's Encrypt (autocert): point a domain at the box, run
+ `stem-relay init`, fill in the mail settings, start it.
+- Storage is one embedded SQLite file through a pure-Go driver, so the binary
+ stays a single file and needs no database server.
+- Lives in `relay/` in this repo as its own Go module, released on its own
+ tags (`relay-v1.0.0`). Stem's CI builds it and runs the mail tests against
+ the real binary.
+- The protocol is a written spec plus shared test vectors (envelopes,
+ signatures, sender certificates) that both the Go relay and Stem's
+ TypeScript client test against. No shared code.
+
+### Dumb by design
+
+The relay sees only an envelope:
+
+```
+outer (relay reads): version · to · from · id · timestamp · size ·
+ sender certificate · signature
+inner (sealed): everything else: subject, body, attachments,
+ threading, mail kinds
+```
+
+Rules that keep Stem changes away from the relay:
+
+1. **The relay never looks inside the sealed part**, not even at its
+ version. Stems agree on the inner format between themselves; a Stem that
+ gets an inner version it doesn't know shows "this mail needs a newer
+ Stem" instead of failing.
+2. **Key material is an opaque signed blob per address**, not named fields.
+ New key types or forward-secrecy prekeys are a new blob from Stems, not a
+ relay change.
+3. **The protocol only grows.** Paths under `/v1/`, new fields optional,
+ unknown fields ignored on both sides. `/v1/info` lists what the relay
+ supports; Stem feature-detects and never needs the newest relay for core
+ mail.
+4. **Policy is config, not code.** Every abuse number (invite budget, sender
+ and queue quotas, one knock, circuit-breaker thresholds, lookup limits)
+ lives in the config file. Tuning the global relay is a config edit.
+5. **Optional features are switched in config.** Invites, complaint
+ webhooks and the circuit breaker are on for the global relay; a private
+ relay usually leaves them off.
+
+### What still forces an update
+
+- **Security fixes** in Go or a dependency. The image and binary check for
+ new versions and can update themselves; database migrations only go
+ forward.
+- **Real protocol additions**, expected to be rare. The key log is the only
+ one planned, and it is a new endpoint.
+
+An ordinary Stem release changes nothing on the relay.
+
+## Abuse
+
+The relay can't read mail, so it can't filter content. Every defence works on
+identity, counts and sizes. Numbers below are starting values, tuned on the
+global relay; a private relay sets its own.
+
+### No mail without a key
+
+A sender can only seal to a key the relay returned, so there is nothing to
+send to an unregistered or unlisted address, and the relay never stores mail
+for one. An invite request (below) is the only thing a sender can ask for
+such an address.
+
+### Verification emails
+
+The one email the relay sends, and the one a stranger can trigger by typing
+someone else's address:
+
+- at most 3 codes per address per day, and per requesting Stem and IP;
+- every verification email carries a "this wasn't me, never email me again"
+ link that puts the address on a suppression list (lifted only by the
+ owner verifying from that address later);
+- registration needs a small proof-of-work from the Stem, so mass
+ registration costs CPU.
+
+### Invite emails
+
+Invites are the one way a sender makes the relay email a stranger, so they
+get the tightest limits:
+
+- **An invite request counts as a knock.** It uses the sender's first-contact
+ allowance and the one-knock rule like a mail does.
+- **Per recipient address: one invite email per 30 days**, whoever asks.
+ Further senders in that window are batched into the next one ("Vlado and
+ 2 others want to reach you on Stem"). After 3 invites with no
+ registration, the address gets no more.
+- **Per sender: 5 invites per day, 20 per month.** Addresses verified in the
+ last 7 days can't trigger invites at all.
+- **Fixed template.** The sender controls nothing in it but their verified
+ address; no free text, no display name, nothing to phish with.
+- **Unsubscribe.** Every invite carries "never email me again", which puts
+ the address on the same suppression list as verification emails.
+- **Bounces and complaints.** A hard bounce suppresses the address. A spam
+ complaint against an invite costs that sender their invite rights; repeat
+ complaints count as blocks (see below).
+- **Global budget.** The relay sends at most a fixed number of invites per
+ day across all senders (start at 200, raise slowly as the sending
+ reputation builds). Invites over budget wait in a queue and expire with
+ the 30-day window; verification emails never wait behind invites.
+- **Circuit breaker.** Invites pause automatically when, over a rolling 7
+ days, hard bounces exceed 2% or spam complaints exceed 0.1% (Gmail's
+ bulk-sender line is 0.3%), and the operator is alerted. Verification
+ emails keep going.
+- **Separate sending identity.** Invites go out from their own subdomain and
+ the provider's separate stream for marketing-like mail, never from
+ `awantech.sk` itself or the verification stream, with SPF, DKIM and DMARC
+ set up. If invites ever get blocklisted, company mail and verification
+ codes are unaffected.
+- **Private relays send no invites** unless their operator turns them on;
+ colleagues are expected to be on the relay already.
+
+### One knock before acceptance
+
+A sender can have **one undelivered or unanswered mail** waiting for a
+recipient who hasn't accepted them. Further mail to that recipient is
+refused until the recipient accepts the Request, replies, or the knock
+expires. The relay enforces this from metadata it already has (has this
+recipient ever mailed this sender?). Strangers get one chance, not a flood.
+
+### Sender limits
+
+- New first-contact recipients per sender: 20 per day.
+- New addresses (first 7 days after verification) get a quarter of the
+ normal limits.
+- Total mails per sender per hour, regardless of recipient.
+
+### Queue quotas
+
+For mail held while a recipient is offline:
+
+- per mail: size cap (attachments included), 25 MB;
+- per recipient: total held, 200 MB / 1,000 mails; past that a contact's
+ mail is refused as "recipient's queue is full", a stranger's is silently
+ dropped;
+- per sender toward one recipient: a share of that queue, so one sender
+ can't fill someone's queue and block everyone else.
+
+### Blocks feed back to the relay
+
+Blocking a sender in Requests or a conversation tells the relay. The relay
+then silently drops that sender's mail to that recipient for good. When many
+distinct recipients block the same sender in a short window, the relay
+throttles and then suspends that address, which can appeal by verifying
+again.
+
+### Directory lookups
+
+Lookups need an authenticated, registered Stem and are rate-limited per Stem
+(100 per day, burst 20), so bulk enumeration is slow and visible. They don't
+hide who is listed; see Discoverability.
+
+### Known gap
+
+Free email providers make new identities cheap. Proof-of-work, new-address
+limits, the one-knock rule and block feedback raise the cost; they don't stop
+a determined spammer with many verified mailboxes. If that becomes real, the
+next step is reputation (age, accepted contacts) gating first-contact sends.
+
+## Discoverability
+
+A key directory can't hide who is in it. Fake keys for unregistered
+addresses were considered and rejected: a modified client can re-look up an
+address and see the fake key become a real one when the person registers,
+and with several accounts it can compare answers. Hiding membership
+robustly would need something like private set intersection, which is out
+of scope. So the design is honest instead, the way Signal is about phone
+numbers:
+
+- **Listed (default).** Anyone who knows your address can learn that you use
+ Stem and send you one knock. That's what "people can reach me at my
+ address" means.
+- **Unlisted.** Settings switch "Only people I've mailed can reach me". The
+ relay returns your key only to senders you've mailed first (it knows this
+ from delivery metadata). To everyone else a lookup answers exactly like an
+ unregistered address: same response, same timing, and no invite is sent.
+ Because "no key" covers both cases, an unlisted user is indistinguishable
+ from a non-user.
+- **Registration replies identically** whether the address is new or taken
+ (see Identity and keys), so registering isn't a membership oracle either.
+- **No status toward strangers.** A stranger sees only "sent", never
+ "delivered", "queue full" or "blocked". This hides blocks and the
+ recipient's activity, not membership.
+
+What leaks, accepted: listed users are discoverable one address at a time
+(rate-limited); the relay operator knows who is registered; a
+`_stem.` record shows a company uses Stem.
+
+## Encryption
+
+Each mail is sealed to the recipient's key and signed with the sender's key.
+Attachments travel inside the sealed envelope, with a size cap. No forward
+secrecy in v1: mail sits decrypted in both Stems' stores anyway, and a
+ratchet can come later as a new envelope version.
+
+## Inbound
+
+- External mail lands in the Inbox addressed to `user`, badged as external,
+ with the sender's state: verified by email, verified by you, or key
+ changed.
+- First mail from an unknown sender goes to **Requests**. The user accepts
+ (sender becomes a contact) or blocks (reported to the relay, see Abuse).
+- **Incoming approval.** Every external mail is held from agents until the
+ user releases it, every time: first contact, accepted contacts, and new
+ replies inside a collaboration a persona is already working on. Until
+ then it is shown only to the user; no recall, capture, persona turn or
+ draft touches it. Releasing it is the user saying "my agents may act on
+ this".
+- Releasing doesn't make it safe: a person can miss a prompt injection, so
+ the exit gating (Outbound) applies to every external-context turn
+ regardless.
+- On a released mail the user can:
+ - reply by hand;
+ - ask a persona to draft a reply;
+ - bring it into an internal conversation with personas.
+- External addresses are mail participants like `user` and persona ids. They
+ are told apart by the `@` (agent ids use `~`).
+
+## Outbound
+
+- A persona's `send_mail` to an external address creates a draft, never a
+ send. The draft shows in the conversation and in an Approvals view; the
+ user edits, sends or discards it. This reuses `MailApproval`.
+- The user's own replies send directly.
+- Approval is the main defence against prompt injection in external mail:
+ nothing leaves through mail without the user.
+
+### External conversations: full memory, gated exits
+
+A conversation that contains external mail is **external context**, and so
+is every turn in it: the driver's, other personas', spawned agents', and any
+internal conversation the user brings external mail into (the mark travels
+with the mail).
+
+**What the agents get, by default:** everything a normal turn gets for
+reading: recall, the user summary, standing answers, `read_past_chat`,
+memory tools, and reading connected folders. That is what lets them answer
+and coordinate well. Disclosure is controlled at the one place content
+leaves, the outgoing mail, which the human approves.
+
+**What is gated, in code at `before_agent_start`, because approval doesn't
+see it:** every other way out. External mail can carry a prompt injection,
+and a turn with full memory is exactly the turn an attacker wants to make
+leak through a side channel:
+
+- web reads and fetches always ask (a URL can carry data out); automatic
+ approval tiers are off for the turn;
+- file and folder writes, `run_command`, coding agents, computer and browser
+ control, MCP tools, skill writes and `schedule_task` are removed;
+- a persona can be granted specific ones back ("in external conversations
+ this persona may use: , "), default none.
+
+**What the approver sees:** the exact outgoing mail, body and every
+attachment, not a summary of it. Later the approval may distinguish
+**disclosure** ("may this be shared?") from **commitment** ("may Stem agree
+to this or promise this work?"): "Sunday works for me" is not the same
+approval as "I've booked Sunday".
+
+## Memory
+
+- Your agents read your full memory in external conversations (above). The
+ other Stem only ever receives approved mail; no memory, recall or files
+ are shared between Stems.
+- Facts learned from external mail are tagged with that contact and recalled
+ only in conversations with that contact, so an outsider can't plant facts
+ that surface elsewhere. Capture of the owner's own messages in an external
+ conversation works as normal.
+- Company memory, later, is a **team Stem**: a Stem on the company's private
+ relay with its own memory, skills and personas, which colleagues mail like
+ anyone else. Its owner can switch on auto-answer for members of that relay,
+ the one place where unattended replies make sense. Personal memory stays on
+ personal Stems.
+
+## Build notes (phase 1, 2026-10-14)
+
+Where the first build is stricter or narrower than the design above:
+
+- **Web is removed, not "ask".** A persona's draft runs unattended, so there
+ is nobody to ask; external-context turns get only the read-only allowlist
+ (`EXTERNAL_TOOLS` in `stem-mcp-extension.mjs`: read/grep/find/ls, memory
+ search, `read_past_chat`, `read_notes`, `send_mail`). New tools start closed.
+- **No capture at all yet.** External-context turns are tainted out of Recall
+ entirely; contact-tagged facts are a later step.
+- **Text only.** Attachments to and from other Stems aren't sent yet.
+- **One counterpart per conversation.** An external conversation is with
+ exactly one address; personas join it only to draft.
+- **Global relay keys** ship in the build for `relay.stem.awantech.sk`
+ (`GLOBAL_RELAY_KEYS` in `relay-client.ts`). A relay named by
+ `STEM_GLOBAL_RELAY_URL` brings its keys in `STEM_GLOBAL_RELAY_KEYS` or is
+ learned from its `/.well-known` on first use and pinned.
+
+## Phases
+
+1. **Transport.** Keypair, address registration with email codes, relay
+ (single binary, global and private), DNS routing, sealed delivery with
+ confirm-and-delete, external mail in the Inbox, Requests, hand-written
+ replies, sender certificates and relay signing keys, key pinning and
+ change notices, the unlisted switch, invite emails, the abuse limits
+ (verification and invite caps, one knock, sender and queue quotas, block
+ feedback, lookup limits). External-context exit gating lands here too,
+ since phase 1 already lets the user bring external mail to personas.
+2. **Collaborations.** Persona drafts, the Approvals view (exact content and
+ attachments), the per-persona tool grants, contact-tagged facts, and
+ remote conversations shown as collaborations with a goal and pending
+ decisions.
+3. **Teams and scopes.** Bounded collaborations ("coordinate this recording
+ with Martin; you may discuss availability, topics and past episodes")
+ where agents send routine replies within the scope without asking;
+ disclosure vs commitment approvals; private-relay member directory; the
+ team Stem with opt-in auto-answer for members; iOS support; the key log.
+
+## Open questions
+
+- Hosting of the global relay on the Awantech side.
+
+Resolved during the build: envelopes use Ed25519 signatures and an
+X25519 + HKDF-SHA256 + AES-256-GCM seal (ChaCha20 is missing from Electron's BoringSSL) (no libraries beyond the Go
+and Node standard crypto); the relay connection is SSE plus POST. Both are
+specified in `relay/PROTOCOL.md`.
+- Attachment size cap.
+- Whether contacts and Requests sync to the phone in phase 1.
diff --git a/docs/public-share-design.md b/docs/public-share-design.md
new file mode 100644
index 00000000..1b5a3253
--- /dev/null
+++ b/docs/public-share-design.md
@@ -0,0 +1,163 @@
+# Public share links
+
+Status: built 2026-10-19 (relay, server, desktop, viewer, iOS); relay and
+viewer live on share.heystem.app.
+
+## What it is
+
+From the Share button on an answer (chat message or mail reply, shown on
+hover) the user picks what to share: the whole chat / mail thread, the
+question and that answer, or the answer alone. Anyone with the link can open
+it in a browser, without a Stem:
+
+```
+https://share.heystem.app/s/#
+```
+
+- **Snapshot, not live.** The link shows the chat as it was when shared.
+ Sharing again makes a new link; the old one keeps working until revoked.
+- **Short-lived and capped (abuse).** A link works 30 days by default and
+ at most (7 and 1 day are offered); an address holds at most 100 working
+ links and makes at most 30 a day. The relay enforces all three.
+- **Mail too, but only the user's own.** Mail with another person (inter-Stem)
+ and the memory check's conversation can't be shared; persona↔persona
+ consulting and approval cards are left out.
+- **The relay never sees the content.** Stem encrypts the snapshot before
+ upload. The key rides in the URL fragment, which browsers never send to a
+ server.
+- **Link previews, no indexing.** For the card chat apps show when the link
+ is pasted, the sharer publishes a title and a short description in the
+ clear: suggested from the snapshot (its title, the start of the first
+ answer), editable in the dialog, and switchable off. The relay fills the
+ viewer page's `og:` tags with it. Every page and response says `noindex,
+ nofollow, noarchive`; there is no robots.txt Disallow, since that would also
+ stop the crawlers that draw previews.
+- **Only verified people publish.** Uploading needs a sender certificate for
+ an inter-Stem address (an email verified with a code). Every share is tied
+ to that address, so abuse can be traced and the address suspended. Viewers
+ stay anonymous; no login on the web.
+- **Awantech hosts it** on the global relay box (EU DSA hosting provider):
+ every page has a report link; takedown by id; contact address on the page.
+
+## What is in a snapshot
+
+Built on the server from the same history the app shows (`readThread`), then
+cleaned:
+
+| Kept | Dropped |
+| --- | --- |
+| User text, assistant MDX (charts, diagrams, tables, callouts) | System notices (failed/interrupted turns), failed sends |
+| Generated images (embedded as data URLs) | Tool rows / activity (queries, paths, commands) |
+| Web sources (public URLs) | Recall, file, mail and schedule context (already stripped from history) |
+| The model's name (optional) | `` (personal memory), `` (dead in a static page) |
+| User attachments: only when the user ticks "Include my pictures" | `stem://` and `file:` links (become plain text) |
+
+The user always sees a preview of exactly what will be published before the
+upload. That preview is the main protection against leaking something private.
+
+Private chats can be shared: "private" means "not learned from", and sharing
+is an explicit act.
+
+### Format (plaintext, before encryption)
+
+```json
+{ "v": 1, "kind": "answer" | "thread", "title": "…", "sharedAt": 1760000000000,
+ "messages": [
+ { "role": "user" | "assistant", "text": "…", "createdAt": 1760000000000,
+ "model": "GPT-6.1", "images": [{ "mime": "image/png", "dataUrl": "data:…", "alt": "…" }],
+ "sources": [{ "url": "https://…", "title": "…" }] } ] }
+```
+
+Viewers ignore unknown fields and show "made by a newer Stem" for `v > 1`.
+
+### Encryption
+
+```
+key = 32 random bytes
+plain = gzip(JSON)
+nonce = 12 random bytes
+blob = 0x01 || nonce || AES-256-GCM(key, nonce, plain, aad = "stem-share-v1")
+fragment = base64url(key), no padding
+```
+
+The browser decrypts with WebCrypto and `DecompressionStream('gzip')`.
+
+## Relay (global relay only, feature `share`)
+
+| Method & path | Auth | Body → response |
+| --- | --- | --- |
+| `POST /v1/shares` | SC | raw blob (`application/octet-stream`), `?ttl=` optional → `{id, url, expires}` |
+| `GET /v1/shares/{id}` | — | → blob bytes; `404 not_found`, `410 gone` (revoked or taken down) |
+| `DELETE /v1/shares/{id}` | SC, same address | → `{}` |
+| `GET /v1/shares` | SC | → `{shares: [{id, size, created, expires}]}` for the certified address |
+| `POST /v1/shares/{id}/report` | — | `{reason, detail?, contact?, key?}` → `202 {}` |
+
+- `id` = 16 random bytes, base64url (22 chars).
+- Store: SQLite table `shares(id, owner, size, blob, created, expires,
+ removed, removed_why)`. Revoke/takedown keeps a tombstone (blob dropped) so
+ the link answers 410. Expired rows are swept.
+- Limits in `[share]`: `max_bytes` (12 MiB), `max_ttl_days` (30, also the
+ default expiry), `max_live` (100 working links per address),
+ `per_address_day` (30), `quota_bytes` per address (500 MiB),
+ `reports_per_ip_hour` (20).
+- Reports go to a `share_reports` table and the relay log. The optional `key`
+ is the reporter consenting to show us the content.
+- CLI: `stem-relay share-reports`, `stem-relay share-takedown `.
+- Closed-domain certificates are already refused by `certified()`. Stems
+ whose policy is anything but open never publish (the global relay is never
+ contacted under a restriction).
+
+## Viewer (`share.heystem.app`)
+
+- A separate origin from `heystem.app`, so shared content never runs where a
+ future web login would.
+- Static build `vite.share.config.ts` → `dist/share`, reusing
+ `src/renderer/mdx` and a shared `ShareView` component (also used for the
+ in-app preview). Only web-safe imports: no `window.stem`.
+- stem-relay serves the whole viewer origin from `[share] viewer_dir`
+ (only on the viewer host): `/s/*`, `/assets/*`, icons, `/v1/shares/*`.
+ Caddy proxies just those paths and 404s the rest of the relay API there. Strict CSP (`script-src 'self'`,
+ `connect-src 'self'`, `frame-ancestors 'none'`), `Referrer-Policy:
+ no-referrer`, `X-Robots-Tag: noindex`.
+- Footer: "Made with Stem · heystem.app" and "Report".
+- `/s/` is the built `index.html` with its `` block
+ replaced by the share's card. `og:image` is the
+ Stem icon (`/og.png`).
+
+## Stem server
+
+- `src/server/share/`:
+ - `snapshot.ts`: history → snapshot (the cleaning above).
+ - `seal.ts`: encrypt, build the link.
+ - `store.ts`: `shares.json` (id, link with key, thread, turn, kind, title,
+ dates) so the user can copy a link again or revoke it.
+- `InterStemService.shareIdentity()`: the default verified address with a
+ fresh certificate; throws `no_address` / policy errors in plain words.
+- IPC: `share:preview(source, opts)`, `share:create(source, opts)`,
+ `share:list()`, `share:revoke(id)`; `source = {kind:'chat', threadId} |
+ {kind:'mail', conversationId}`, `opts = {scope: 'thread'|'pair'|'answer',
+ anchorId, includeAttachments?, includeModel?, ttlDays?}`.
+
+## Desktop UI
+
+- The one entry point: a Share icon among an answer's hover actions (chat
+ message action row; a persona's reply in a mail thread).
+- Dialog: what to share (whole chat / mail thread · question and answer ·
+ answer only; question and answer preselected), rendered preview, toggles
+ (include my pictures, show model name), expiry (30 / 7 / 1 days), the link
+ preview card (editable title and description, drawn like an unfurl, with a
+ note that it is not encrypted), Create link. Then: the link with Copy, its end date, and Revoke. Without a verified address, the dialog explains and links to
+ Settings → Your email address.
+- Settings → Your email address → "Shared links": list, copy, revoke.
+
+## Phase 2: iOS
+
+Share in the answer's context menu (chat and mail) with the same three
+choices; calls `share:create` and opens the system share sheet. No preview renderer on the
+phone at first, so the sheet shows what is included (counts, pictures off).
+
+## Not doing
+
+- Live links, comments, view counts.
+- Server-side moderation of content (we can't read it).
+- Per-recipient shares: that's inter-Stem mail.
diff --git a/docs/stem-policy.md b/docs/stem-policy.md
new file mode 100644
index 00000000..03127d12
--- /dev/null
+++ b/docs/stem-policy.md
@@ -0,0 +1,67 @@
+# Stem policy file (for administrators)
+
+A machine-wide file that limits what Stem may do on a company computer. The
+user can't change it from inside Stem. Today it covers mail with other Stems.
+
+| System | Path |
+| ------- | ------------------------------------------------- |
+| macOS | `/Library/Application Support/Stem/policy.json` |
+| Linux | `/etc/stem/policy.json` |
+| Windows | `%ProgramData%\Stem\policy.json` |
+
+Make it writable by administrators only (root-owned, mode 0644). MDM tools
+can deploy it as a plain file. A headless Stem server (Docker) takes the same
+JSON in the `STEM_POLICY` environment variable; a file on the machine wins
+over it. Stem reads the policy at start, so restart it after a change.
+
+## No mail with other Stems
+
+```json
+{ "interStem": { "enabled": false } }
+```
+
+Settings shows "Turned off by your organization". Stem contacts no relay at
+all, and phone push notifications don't go through the global relay either
+(a server with its own APNs key still pushes directly).
+
+## Only inside the company
+
+```json
+{
+ "interStem": {
+ "domains": ["acme.com"],
+ "relay": "https://stem-relay.acme.com",
+ "relayKeys": ["k1:"]
+ }
+}
+```
+
+- People can add only addresses in `domains`, and write only to addresses in
+ `domains`. Anything else fails at once with a message naming the policy.
+- Every address in those domains is taken to live on `relay`, checked with
+ `relayKeys` (`stem-relay dns` prints them as the `k=` values). DNS is not
+ consulted, so a forged `_stem` record can't redirect anyone.
+- The global relay is never contacted. Push notifications go through
+ `relay` (when it has `[push]` enabled) or not at all.
+- Mail arriving from outside the domains is dropped without being shown.
+- An address verified before the policy arrived (on the global relay, or
+ outside the domains) stops being used; adding it again moves it to the
+ company relay.
+
+To include a partner, list its domain too and leave out `relay` and
+`relayKeys`: each domain's `_stem` DNS record then names its relay. A listed
+domain without a record is unreachable; it never falls back to the global
+relay.
+
+## When the file is wrong
+
+A file that exists but can't be read or parsed turns mail with other Stems
+off (the reason is in Stem's log). A broken lockdown never fails open.
+
+## Pair it with the relay
+
+The policy controls company machines. The company relay controls who gets
+in: set `closed = true` there and publish its `access=closed` DNS record
+(`relay/README.md`, "Keep it inside the company"). With both, outsiders
+can't reach your people and your people can't reach outsiders, even from a
+Stem without the policy file.
diff --git a/docs/user/browser-control.md b/docs/user/browser-control.md
index f1c14a4f..76071282 100644
--- a/docs/user/browser-control.md
+++ b/docs/user/browser-control.md
@@ -5,39 +5,63 @@ the Stem extension: open pages, read them, click, type, fill in forms, upload fi
download, and check a page's console and network requests. It works in background
tabs, so the tab you are looking at never changes under you.
-It runs on a Mac, in Arc, Chrome, Dia or Brave. It works whether Stem's server runs on
-that Mac or somewhere else.
+It runs on a Mac, in Arc, Chrome, Dia, Brave, Edge or Chromium; on Windows, in Chrome, Edge, Brave or
+Chromium; and on Linux, in Chrome, Brave, Chromium, Edge, Vivaldi, Opera, or any other Chromium
+browser, including AppImages such as Helium or Thorium. It works whether Stem's server runs on that computer or somewhere else. On Linux,
+Snap and Flatpak builds of a browser can't reach Stem; use the browser's own .deb, .rpm or AppImage.
+Run a new browser once before Set up, so Stem finds its settings folder.
## Setting it up
-On the Mac whose browser Stem should use:
+On the computer whose browser Stem should use:
1. Open Settings → Features → **Browser control** and switch on **Let Stem control this
- Mac's browser**.
+ Mac's browser** (**this computer's browser** on Windows and Linux).
2. Press **Set up…**. Stem puts the extension in a folder, copies that folder's path,
- brings your browser forward, and tells the browsers on this Mac how to reach Stem.
+ brings your browser forward, and tells the browsers on this computer how to reach Stem.
The steps below then appear in Settings, with Copy and Open buttons.
3. In your browser, type the extensions page into the address bar (`chrome://extensions`,
- or `arc://extensions` in Arc) and turn on **Developer mode**.
+ `arc://extensions` in Arc, or `edge://extensions` in Edge) and turn on **Developer mode**.
4. Click **Load unpacked**. In the folder picker press ⌘⇧G, paste the path with ⌘V,
- press Return, then **Select**.
+ press Return, then **Select**. On Linux press Ctrl+L, paste with Ctrl+V, and press Enter.
+ On Windows click the folder picker's address bar, paste with Ctrl+V, press Enter, then
+ **Select Folder**.
Settings shows the browser as connected within a couple of seconds. You do this once per browser; when Stem
-updates, the extension updates itself. If the extension is installed in more than one
-browser, a **Use** row picks the one Stem drives. When that browser is closed and a run
-needs it, Stem starts it in the background.
+updates, the extension updates itself. When that browser is closed and a run needs it,
+Stem starts it in the background. On Windows and Linux it may open in front. On Linux,
+Stem starts only Chrome, Brave, Chromium, Edge and AppImage browsers itself; open any
+other browser, such as Vivaldi or Opera, before a run that needs it.
+
+## Several profiles
+
+If you keep separate browser profiles — an Arc profile for work and one for personal
+things, say — load the extension in each one (press **Set up again** and repeat steps
+3–4 from a window of the other profile). Each profile connects on its own, and Stem can
+work in all of them:
+
+- **Profiles** in Settings lists them. Give each one a name, such as *Work* and
+ *Personal*. To tell which is which, click the Stem button in a browser's toolbar: it
+ says the name that profile goes by.
+- **Use** picks the profile a task starts in.
+- When you ask for something in a particular one ("in my work browser, …"), or the page
+ it needs is open there, the assistant switches to that profile and stays in it for
+ the rest of the task. Asked for the open tabs, it sees every open profile's tabs under
+ that profile's name.
+- A profile you removed the extension from can be forgotten from the list once it is
+ closed.
## Who can use it
The same two places as computer control, and a conversation gets it from exactly one:
- **A persona** gets it from its browser pin: in the Personas tab, open the persona,
- choose **Computer** on its **Runs as** tab, and pick a Mac under **Browser**. The
+ choose **Computer** on its **Runs as** tab, and pick a computer under **Browser**. The
browser pin is separate from **Screen and apps**: a persona can have the browser
without your screen. Pinned personas use it in chats, mail and scheduled
tasks.
- **A chat with no persona** gets it from Settings → Features → Browser control →
- **Allow in chats**, either on a fixed Mac or letting the model pick one.
+ **Allow in chats**, either on a fixed computer or letting the model pick one.
## While it works
@@ -60,17 +84,19 @@ still a page from the internet: keep an eye on runs that touch accounts that mat
- **Uploads** take only files Stem already holds: attachments in the conversation,
files in your Files place, files the assistant made, and files in connected folders.
- It cannot upload an arbitrary file from the Mac.
+ It cannot upload an arbitrary file from your computer.
- **Downloads** land in your browser's Downloads folder as usual, and a copy goes to the
conversation's scratch folder on the server so the assistant can read or send it.
## When it does not work
-- *"does not let Stem drive its browser"*: the switch on that Mac is off.
+- *"does not let Stem drive its browser"*: the switch on that computer is off.
- *"No browser with the Stem extension has connected"*: Set up has not been done there,
or the extension was not loaded with Load unpacked.
- *"the Stem extension did not connect"*: the browser started but the extension is
disabled or removed; check its extensions page.
-- *"not connected right now"*: the Mac is asleep or Stem is not running on it.
+- *"not connected right now"*: the computer is asleep or Stem is not running on it.
+- *"The browser profile … is not open"*: starting the browser opened a different
+ profile. Open a window of the one named, or ask for another profile.
- Pages inside a cross-site frame (some payment and login widgets) are not part of what
the assistant reads; a screenshot still shows them.
diff --git a/docs/user/chats.md b/docs/user/chats.md
index 6a209c78..937c47bb 100644
--- a/docs/user/chats.md
+++ b/docs/user/chats.md
@@ -29,14 +29,16 @@ there:
while it runs. Once the answer is done the rows fold into one line, such as
"Used 3 tools and 1 skill" with the total time. Click it to see every call again.
- The mic button in the composer dictates. Speech is turned into text on the
- device, and the audio never leaves it. It needs macOS 26 or iOS 26; elsewhere
- the button is hidden. Pick the Mac's language in **Settings → App → Dictation**;
- on the phone, hold the mic.
+ device, and the audio never leaves it. On a Mac it needs macOS 26, on the phone
+ iOS 26; on Windows and Linux it runs Whisper on your computer, downloaded the
+ first time you dictate. Pick the language in **Settings → App → Dictation**
+ (see [Dictation](settings.md#dictation)); on the phone, hold the mic.
- **Web** turns web search on or off for the next message. It stays where you leave
it, and is the same switch as **Settings → App → Web search**. Quick Chat keeps
its own.
- Web answers include cited sources: the sites' icons show under the answer;
open them for a card per source.
+- Places in an answer (an address, a shop, a venue) are links that open Maps.
- The context meter shows how full the conversation is.
- Ask for a picture and Stem draws it with your ChatGPT subscription (see
[Settings → Image generation](settings.md#image-generation)). Click it to enlarge.
@@ -50,16 +52,23 @@ there:
- Use **+** on a folder to start a chat inside it.
- Drag chats and folders to move or nest them.
- ⌘-click (Ctrl-click) several chats, or Shift-click a range. A bar over the list
- marks them read or unread, moves them to a folder, or deletes them after asking.
+ marks them read or unread, moves them to a folder, archives them, or deletes them
+ after asking.
Dragging one of them moves them all. **Esc** clears the selection.
-- Right-click a chat to mark it read or unread, **Write a subject**, **Rename**,
- move, or delete it. Deletion is permanent. Tasks scheduled from the chat stay:
- they run on their own and report by mail.
+- Right-click a chat to mark it read or unread, **Archive** it, **Write a subject**,
+ **Rename**, move, or delete it. Deletion is permanent, so the confirmation offers
+ **Archive instead**. Tasks scheduled from the chat stay: they run on their own and
+ report by mail.
+- **Archive** (`⌘⇧A`, `Ctrl+Shift+A`) moves a chat out of the list into the folded
+ **Archived** section at its foot. Nothing is lost: an archived chat still opens and
+ shows up in search, with an archive badge. Right-click it and choose **Move out of
+ archive** to bring it back, or just continue it: a new message brings it back on
+ its own.
- Right-click a folder for **New subfolder**, **Settings…**, **Mark all as read**
(every unread chat in it and its subfolders), **Rename** or **Delete**. Deleting
a folder moves its chats and child folders to the parent.
-- Use **Search**, `⌘F` on Mac, or `Ctrl+F` on Linux to search titles and message
- text across every chat.
+- Use **Search**, `⌘F` on Mac, or `Ctrl+F` on Windows and Linux to search titles
+ and message text across every chat.
### Folders that file chats for you
@@ -75,6 +84,17 @@ Stem never moves a chat you placed yourself or a private chat. To undo a move,
drag the chat back; it then stays wherever you put it. Filing runs on the same
model as chat subjects (**Settings → Models → Quick tasks**).
+### The menu bar
+
+The app's menu bar lists every action that has a shortcut, with its keys beside it,
+so it is a good place to learn them. **File** has new chat, new mail, Quick Chat and
+**Attach Files…**. **View** opens the side panel's tabs (**Chats**, **Memory**,
+**Tools**, **Personas**, **Sources**, **Scheduled Tasks**). **Chat** has Stop,
+effort, Fast, **Archive**, **Snooze…**, read or unread, and **Delete Chat…**.
+**Window** lists the first nine chats under ⌘1–9 (Ctrl+1–9). **Settings** and
+**Check for Updates…** are under **Stem** on a Mac and under **File** on Windows and
+Linux. There is no Close Window item, so ⌘W (Ctrl+W) never closes Stem's window.
+
## Private chats
On a new, empty chat, click **Make this chat private** before sending. A private
@@ -93,6 +113,8 @@ Hover over a message to reveal its actions:
- **Delete from here**: remove that turn and everything after it. Requires a second
click.
- **Pin to this chat**: keep it on the chat's pinboard.
+- **Share this answer as a link**: publish the chat, or part of it, as a link anyone
+ can open (see [Share links](share-links.md)).
The pinboard is the strip under the chat title, **Pinned in this chat**. Besides
whole messages, you can select a few words of a reply and click **Pin**, or type
diff --git a/docs/user/connected-folders.md b/docs/user/connected-folders.md
index ef07c0c1..199aa6eb 100644
--- a/docs/user/connected-folders.md
+++ b/docs/user/connected-folders.md
@@ -117,8 +117,9 @@ Connected folders tab groups it under this computer's name and shows when it las
- After the first sync, changes sync within seconds while this computer is online;
deletes and renames follow. The folder on this computer is always the original —
nothing Stem does can change the mirror's contents from the server side.
-- Mirrors skip `.git`, `node_modules`, OS junk files, symbolic links, and files over
- 25 MB. Anything else skipped is counted on the folder's page.
+- Mirrors skip `.git`, `node_modules`, OS junk files, whatever the folder's own
+ `.gitignore` files leave out, symbolic links, and files over 25 MB. Anything else
+ skipped is counted on the folder's page.
- If the folder disappears (an unplugged drive, a rename), syncing freezes and the
folder's row says so — the mirror keeps its last state rather than treating it as deleted.
Disconnecting the folder in Stem is how you delete the mirror.
diff --git a/docs/user/mail.md b/docs/user/mail.md
index 02483916..7537fcc1 100644
--- a/docs/user/mail.md
+++ b/docs/user/mail.md
@@ -5,6 +5,9 @@ The first recipient coordinates the conversation and can consult the other
personas. Replies arrive in your Inbox. A persona that makes pictures while answering
(see [image generation](settings.md#image-generation)) attaches them to its reply.
+This page is about mail with your own personas. To mail other people who use Stem,
+see [Mail with other Stems](other-stems.md).
+
## Built-in personas
Stem ships with four personas. You can edit each one, but you cannot delete
@@ -13,7 +16,7 @@ it.
| Persona | What it does | Starts agents | Your memory |
| --- | --- | --- | --- |
| Normal | Answers on its own and brings in agents when a job needs them | yes | yes |
-| Secretary | Delegates the work instead of doing it, schedules follow-ups, and mails you only decisions and results | yes | yes |
+| Secretary | Delegates the work and keeps at it until the job is done, brings in any persona it needs without asking you, schedules follow-ups, and mails you only decisions and results | yes | yes |
| Verifier | Checks the factual claims in the work it is sent and lists what is wrong | no | yes |
| Critic | Reads what it is sent as its recipient would, without knowing who wrote it | no | no |
@@ -35,7 +38,9 @@ persona**. Nothing changes until you press **Save**. Three tabs:
- **Access** — **Private memory** (notes it keeps from its work; for a coding agent,
**Standing answers** to the agent's recurring questions), **Sees your memory**,
**Open to chats** (offered in the chat composer on the phone), **Can start
- helpers** (see Agents below), **Uses every MCP server** (switch it off to pick which
+ helpers** (see Agents below), **Adds personas without asking** (it brings any
+ persona into a conversation it leads, including ones that work on your
+ computer; Secretary ships with it on), **Uses every MCP server** (switch it off to pick which
servers it may use; the rest are hidden from it entirely), and **Send budget** (how
many mails it may start between your sends).
@@ -46,12 +51,18 @@ starts agents when a job needs checking or several hands.
Mail **Secretary** for errands you want handled without the details, especially
ones that need a follow-up later. It always hands the work to someone else, so
-it adds a step that a direct question does not need.
+it adds a step that a direct question does not need. It adds whichever personas
+the job needs to the conversation itself, your coding, screen and browser
+personas included, and does not ask you first. When a reply comes back
+incomplete or stuck, it sends the work back or tries another persona instead of
+handing the problem to you. It writes to you with the result, or when only you
+can unblock it (a decision, a password, a payment). Normal is the one that stops
+and suggests which persona to add.
Mail **Verifier** or **Critic** directly when that is the whole job: check
these claims, or read this draft cold. Mail a coding, screen or browser persona
-directly when the request is that work. To let Normal or Secretary use one of
-those, add it to the conversation yourself.
+directly when the request is that work. Secretary brings those in on its own.
+To let Normal use one of them, add it to the conversation yourself.
## Write and read mail
@@ -69,16 +80,21 @@ and says who gets your reply. Each conversation keeps its own reply draft. While
personas work, a live card shows who is on it and the latest steps, with **Stop**.
Below it the newest mail is open and older ones fold to one line. Mail the
personas sent each other folds onto the reply it led to ("Consulted Verifier · 2
-mails"). If you reply while they are still working, your mail is read when the
-current run ends. A reply the server refused stays in the thread as **Not sent**,
+mails"). If you reply while they are still working and only ask how it is going
+("what's the status?"), the lead answers right away from their reports
+and live work, and the work carries on undisturbed. A mail that also asks for
+something is answered that way too and then read when the current run ends; any
+other mail just waits for the run to end. A reply the server refused stays in the thread as **Not sent**,
with Retry, Edit and Delete, so its text is never lost. **Forward** sends a mail on
to other personas with a note of your own.
The Inbox lists **Waiting on you** first, then **Working** (conversations you
-sent that personas are still on), then the folded **Snoozed**, **Archived** and
+sent that personas are still on), then **Requests** (first mail from people on
+other Stems you haven't accepted yet), then the folded **Snoozed**, **Archived** and
**Sent** sections. A run that needs your approval for a command waits in its
mail with **Allow once** and **Deny**. You can also answer in words in the reply
-box instead. On the Mac the reply and compose boxes have the dictation mic too.
+box instead. Once you answer, the conversation moves out of **Waiting on you** to
+**Working**. The reply and compose boxes have the dictation mic too.
## Agents
@@ -110,7 +126,8 @@ agent can fill only five of those places. The last one is kept for the persona
you mailed, so it can always have its lead's work checked. An agent never
gets integrations its starter lacks, and a persona that works on your
computer (a coding agent, screen or browser control) is started as an agent
-only in a conversation you added it to.
+only in a conversation you added it to, or one led by a persona with **Adds
+personas without asking** on.
Orchestrator is no longer a built-in persona, because every persona that can
start agents now does its job. If you had edited Orchestrator's prompt, it
@@ -122,8 +139,10 @@ A scheduled task that finds something worth telling you sends mail. All firings
of one task share one conversation. Each firing keeps its own headline, and the
conversation takes the latest headline as its subject. Beneath the short notice
the mail carries the run's full reply, such as a report or the drafts it wrote,
-once the run finishes. Every run happens in a thread of its own that no chat
-shows; the mail, and the Work beneath it, is the only place a run appears. A
+once the run finishes. A task scheduled from a mail conversation, such as a
+follow-up check a persona set up, reports in that same conversation instead, so a
+chain of checks stays one Inbox row. Every run happens in a thread of its own that
+no chat shows; the mail, and the Work beneath it, is the only place a run appears. A
task that starts failing sends one mail with the reason. Pictures a run makes come
with its mail, and a run that made pictures always sends one.
diff --git a/docs/user/other-stems.md b/docs/user/other-stems.md
new file mode 100644
index 00000000..b1f29acb
--- /dev/null
+++ b/docs/user/other-stems.md
@@ -0,0 +1,127 @@
+# Mail with other Stems
+
+Maya's client Tomas uses Stem too. He mails her from his Stem to ask when the
+next release can ship. His mail waits for Maya alone. She lets her agents read
+it, asks Normal to draft a reply from what it knows about her projects, edits
+the draft and sends it. Tomas's Stem never sees her memory, only the mail she
+sent.
+
+Mail between Stems goes to a person's real email address, on their own Stem.
+It is encrypted from one Stem to the other. Your personas can prepare replies,
+but every mail that leaves is one you sent.
+
+## Add your email address
+
+1. Open Settings → Features → **Your email address**.
+2. Type your address under **Add an address** and press **Email me a code**.
+3. Type the code from that email and press **Confirm the code**. If you clicked
+ the link in the email instead, press **I used the link in the email
+ instead**.
+
+The address then shows **Connected**. You can add more than one; mail you start
+goes out from the first. The trash button (**Stop using this address with other
+Stems**) removes one.
+
+Once an address is verified, two more rows appear:
+
+- **Only people I've written to can reach me.** With it on, everyone else
+ can't tell you use Stem at all: to them your address looks like one that
+ isn't on Stem.
+- **Your key.** A short fingerprint. Compare it with someone in person to
+ verify each other (see Keys below).
+
+## Write to someone
+
+In **New mail**, press **To a person** and type their email address in **To**.
+They need Stem too, with that address verified. **Send** sends it at once: it
+is your own mail, so there is nothing to approve. Your replies in that
+conversation go straight to them as well.
+
+Each mail shows how it went: **Sending…**, **Sent**, **Delivered**, **Not
+collected in 7 days**, **Not sent**, **Can't be reached**, or **Waiting — their
+key changed**. **Can't be reached** means they aren't on Stem, or they only
+accept mail from people they've written to; the button next to the message asks
+Stem to email them an invite, which carries none of your mail. A mail the
+relays can't take is retried for up to a week.
+
+A conversation with another person has one counterpart. You can't forward its
+mail to personas, add personas to it, or share it as a public link.
+
+## Mail you receive
+
+- **Requests.** The first mail from someone new waits under **Requests** in the
+ mail list, out of the Inbox. Open it and press **Accept** to make them a
+ contact, or **Block**. Someone you wrote to first is a contact already.
+- **Held mail.** Every mail from another person arrives held, including mail
+ from contacts: *Only you can see this. Your agents can't read it until you
+ let them.* No persona, memory capture or recall touches it. **Let my agents
+ read it** releases that mail and the earlier ones in the conversation.
+
+## Drafts by your personas
+
+Under the reply box, pick a persona, optionally say what the reply should say,
+and press **Draft a reply**. It works only after you have let your agents read
+their mail. The persona drafts from your memory and connected folders; that is
+what makes the draft useful.
+
+The draft appears as **Send this to …?** with the exact text they will receive.
+Edit it if you like, then press **Send** or **Discard**. Writing your own reply
+instead sets the draft aside.
+
+While a persona drafts for someone outside, it can only read: your memory, past
+chats, notes and files. Web access, commands, writing files, MCP servers, coding
+agents, computer and browser control, skills and scheduling are all switched off
+for that turn, so a mail written to trick your assistant has no way to send
+anything out. A drafting turn is also kept out of your memory.
+
+## What the other side sees
+
+Only the mails you send. Their Stem and their personas never get your memory,
+your chats or your files. Mail they receive from you is held for them the same
+way yours is for you.
+
+## Keys
+
+Each Stem has its own key. Your contacts are listed under **Contacts** in
+Settings → Features → **Your email address**, each marked **Verified by email**
+or **Verified by you**, with their key. To check a contact, compare keys with
+them in person, then press the shield button (**I compared this key with them
+in person**).
+
+If a contact's key changes, the conversation says *their key changed — a new
+device, or someone else* and nothing goes to them until you decide. Check with
+them another way, then press **It's them — use the new key**. Mail that waited
+on the change then goes out to the new key. A contact with a new key counts as
+checked by email only, until you compare again.
+
+**Block** (in Requests, or the block button in **Contacts**) drops their mail
+without telling them. Blocked people are listed under **Blocked**, where you
+can unblock them.
+
+## Relays and encryption
+
+Mail travels through a relay. The global one is `relay.heystem.app`; a company
+can run its own for its domain. Each mail is sealed to the recipient's key
+before it leaves your Stem, so the relay can't read the subject or the body. It
+does see who mails whom, when, and how big the mail is. It holds a mail only
+until the other Stem collects it, for at most 7 days.
+
+## Company restrictions
+
+An administrator can limit this with a policy file on the computer (see
+[the policy file](../stem-policy.md)). You can't change it from Stem.
+
+- **Turned off by your organization**: no mail with other Stems and no public
+ links. Stem contacts no relay at all.
+- **Limited by your organization**: you can add and write to addresses only in
+ the listed domains, through the company's relay. Mail from outside those
+ domains is dropped without being shown, and public links are off.
+
+A company relay can also be closed, so outsiders can't reach its people even
+from a Stem without the policy file.
+
+## Not yet
+
+- Attachments: mail to and from other Stems is text only.
+- The iPhone: Requests, letting your agents read a mail, and persona drafts are
+ on the desktop app.
diff --git a/docs/user/quick-chat.md b/docs/user/quick-chat.md
index 3f9f883a..1ec823e0 100644
--- a/docs/user/quick-chat.md
+++ b/docs/user/quick-chat.md
@@ -37,5 +37,43 @@ again or click the pill.
- **Note**—or starting with `/note ` or `//`—is the optional direct-save path for
Memory. No chat turn. Memory must be on; credential-like text is not saved.
+On Linux under GNOME on Wayland, the pill sits in the bottom-left corner only with
+Stem's GNOME extension (**Settings → Features → Computer control**); without it, GNOME
+places the pill itself. Linux also gets a tray icon with **Summon Quick Chat**,
+**Open Stem** and **Quit Stem**. Stock GNOME shows tray icons only with the
+AppIndicator extension.
+
+## Ask about this window
+
+Maya is in Slack and Jana has asked her to decide something. She presses her
+**Ask about this window** shortcut. Quick Chat opens beside the Slack window,
+not on top of it, with a chip showing which window it captured. She asks
+“What does Jana need from me?” and the answer appears right there.
+
+- The chip shows a picture of the window. Its text goes along too, so the
+ assistant reads names and messages exactly. Click **×** on the chip to ask
+ without the window.
+- If an MCP server covers the app, a second chip names it. The assistant uses it
+ to read what the window doesn't show and to act in the app, and shows you
+ what it will send before sending. See
+ [Works with apps](tools/mcp-servers.md#works-with-apps).
+- With computer control allowed for this computer (**Settings → Features**), the
+ assistant can also click and type in that window.
+- Nothing is captured until you press the shortcut.
+- The panel sits right of the window, else left, else below or above. It covers
+ the window only when the window fills the screen. It follows the window if you
+ move it.
+- Pressing it again in the same window continues that conversation. In another
+ window it starts a new one.
+- On a Mac it needs **Screen Recording** for the picture and **Accessibility**
+ for the text. If one is off, the chip says which and opens the right System
+ Settings pane. On Windows it needs no setup. On Linux it needs the GNOME
+ extension from **Settings → Features → Computer control**.
+
+Set the shortcut under **Settings → Quick Chat → Ask about this window**. It is
+off until you record one. On GNOME (Wayland) apps can't set global shortcuts:
+copy the command shown there and add it as a custom shortcut in GNOME's
+Keyboard settings.
+
Quick Chat has its own thread. Set its defaults in
[**Settings → Quick Chat**](settings.md).
diff --git a/docs/user/scheduled-tasks.md b/docs/user/scheduled-tasks.md
index 0a855b9d..0fc6024b 100644
--- a/docs/user/scheduled-tasks.md
+++ b/docs/user/scheduled-tasks.md
@@ -26,6 +26,10 @@ arrives as mail in the Inbox.
- Stem must be running. If Stem is closed or the computer sleeps, each overdue task
runs once when Stem can run again.
+- If the server stops in the middle of a run, the run is recorded as cut off by a
+ server restart and runs once more when Stem starts again. A run cut off twice in a
+ row is not retried; it waits for its next scheduled time, and the task's row says
+ why.
- Times use the local time of the machine Stem runs on: your computer, or your
server if you moved Stem to one.
- Each run starts with an empty context: the task’s prompt, the persona it runs
@@ -42,7 +46,8 @@ arrives as mail in the Inbox.
- Runs can use enabled tools. Web-search tasks request search automatically. Native
search needs a compatible model; otherwise enable a search-capable tool.
- A run that has something for you sends **mail**: all firings of one task share
- one conversation in the Inbox, and the run’s full reply is attached beneath the
+ one conversation in the Inbox (a task scheduled from a mail conversation reports
+ in that conversation), and the run’s full reply is attached beneath the
notice once it finishes. Each run is also shown what the task's earlier runs
already mailed, so a watch task reports a finding once rather than every morning.
A run that finds nothing leaves no trace, and no run ever makes a chat unread.
diff --git a/docs/user/settings.md b/docs/user/settings.md
index 063143ac..c7ed1239 100644
--- a/docs/user/settings.md
+++ b/docs/user/settings.md
@@ -14,10 +14,10 @@ instructions.
Settings has four tabs: **App** (appearance, the conversation, Quick Chat, dictation,
-the Escape key, notifications, About), **Features** (mail among personas, image
-generation, commands, coding agents, computer control, browser control), **Server**
-(where Stem runs, your devices, moving or backing up) and **Models** (providers, which
-model does which job, memory recall, web search).
+the Escape key, notifications, About), **Features** (mail among personas, your email
+address, shared links, image generation, commands, coding agents, computer control,
+browser control), **Server** (where Stem runs, your devices, moving or backing up) and
+**Models** (providers, which model does which job, memory recall, web search).
## Providers and models
@@ -226,7 +226,8 @@ also left behind when you move Stem to another machine.
## Coding agents and computer control
A coding agent (Claude Code, Codex, OpenCode, …) takes programming work off Stem's
-hands; computer control lets Stem see a Mac's screen and click and type there. Both
+hands; computer control lets Stem see a computer's screen (Mac, Windows, or Linux with
+GNOME) and click and type there. Both
come from two places, and a conversation gets them from exactly one:
- **A persona** gets them from its own setup, on the **Runs as** tab of its editor in
@@ -257,7 +258,8 @@ another computer stops to ask you which folder. A computer that is asleep or has
Stem closed fails right away with a message saying so; nothing is queued.
Each computer still has to agree for itself: **Run coding agents on this computer**
-and **Let Stem control this Mac**, in the same section *on that machine*. Command
+and **Let Stem control this Mac** (or **this computer** on Windows and Linux), in the
+same section *on that machine*. Command
approvals apply to what a coding agent runs, the same as for personas. A persona that
drives Claude Code can instead switch on **Approves its own actions**: Claude Code's own
check then decides each step, so routine work never waits on a card, and Stem shows no
@@ -266,10 +268,36 @@ cards and keeps no approval log for that persona.
If Stem says it cannot use a coding agent or control the computer, it names the
reason: the chat runs as a persona without that setup, or chats are switched off here.
+On Linux, computer control needs GNOME on Wayland. Once you switch it on, a
+**GNOME extension** row appears: **Install** puts Stem's GNOME Shell extension in your
+extensions folder. GNOME loads a new extension only at login, so log out and back in
+once; the row then says **Running**. Later updates to the extension arrive without
+logging out. You can turn the extension off in GNOME's Extensions app. Two Wayland limits apply when Stem works inside one window instead of
+the whole screen: that window gets no key presses, only text typed into a field and
+buttons pressed by name, and some GTK 4 lists can be selected but not opened. Working on
+the whole screen has no such limits.
+
+On Windows, computer control works on Windows 10 and 11 with nothing to install.
+Working inside one window, Stem can type and press plain keys there, but not Ctrl,
+Alt or Shift combinations; it uses the window's menus instead, or the whole screen.
+
**Browser control** follows the same rules with its own pin and its own switch: a
persona's **Browser** (Runs as → Computer), or Settings → Features → Browser control →
-Allow in chats, plus **Let Stem control this Mac's browser** on the Mac itself. Setting
-up the extension is described on the [Browser control](browser-control.md) page.
+Allow in chats, plus **Let Stem control this Mac's browser** (**this computer's
+browser** on Windows and Linux) on that computer itself. Setting up the extension is
+described on the [Browser control](browser-control.md) page.
+
+## Your email address
+
+Settings → Features → **Your email address** is the address other people's Stems
+reach you at. Type it in and Stem emails you a code to confirm it is yours. Once it
+is confirmed, you can [mail people who use Stem](other-stems.md), and your [share
+links](share-links.md) carry it. **Only people I've written to can reach me**,
+**Your key** and your **Contacts** appear here too.
+
+If your organization runs Stem for you, this section may say **Turned off by your
+organization** (no mail with other Stems and no public links) or **Limited by your
+organization** (only addresses at its domains, through its relay).
## Image generation
@@ -306,10 +334,18 @@ Under **Settings → App → Appearance**, set per computer:
## Dictation
-The mic button in the composer turns speech into text with the device's own model;
-the audio never leaves it. **Settings → App → Dictation → Language** picks the Mac's
-language, or **Automatic**. A language's model downloads the first time you use it.
-It needs macOS 26; on the phone (iOS 26), hold the mic to pick the language.
+The mic button in the composer turns speech into text on your own computer; the
+audio never leaves it. **Settings → App → Dictation → Language** picks the language,
+or **Automatic**.
+
+- **On a Mac** it uses the Mac's own speech model and needs macOS 26. A language's
+ model downloads the first time you use it.
+- **On Windows and Linux** it runs Whisper on the computer's processor (Beta). The
+ model downloads the first time you dictate. **Speech model** picks **Fast** (a
+ 240 MB download, quick on any computer, best in English) or **Accurate** (a 730 MB
+ download, slower, much better in other languages). **Language** lists 38
+ languages, Slovak and Czech among them.
+- **On the phone** (iOS 26), hold the mic to pick the language.
## Escape key
@@ -365,8 +401,9 @@ server](../running-on-a-server.md) for setting one up.
**Devices** lists everything signed in to this Stem, with when each was last seen.
**Withdraw** signs one out; Stem warns first if MCP servers run on it. **Add a device**
names the new one and gives a code to enter there (**Get a code**), or **Pair a phone**
-shows a QR code to scan in the Stem iPhone app (beta). The phone app has chats, mail,
-rich answers, dictation and approvals, against the same Stem.
+shows a QR code to scan in the Stem iPhone app (beta). The phone app has chats in their
+folders, mail, rich answers, dictation, approvals and share links, against the same
+Stem.
**Move or back up this Stem** is described in [Moving and backups](moving-and-backups.md).
@@ -374,10 +411,12 @@ rich answers, dictation and approvals, against the same Stem.
Shows the version you're running — worth quoting when you report a problem.
-**Updates** says whether a newer Stem exists and what to do about it. On Linux
-(the AppImage) a new release downloads in the background and installs itself the
-next time you start Stem — or right away with **Restart now**. On a Mac, **Get
-the update** opens the release page and you install it the way you did the first
+**Updates** says whether a newer Stem exists and what to do about it. The Linux
+AppImage, the Windows installer, and a Mac with Stem in its Applications folder
+download a new release in the background and install it the next time you start
+Stem, or right away with **Restart now**. The Linux .deb, and a Mac running Stem
+from anywhere else (straight off the disk image, say), only tell you: **Get the
+update** opens the release page and you install it the way you did the first
time. **Check now** asks immediately; **Check for updates automatically** turns
the periodic check off entirely. The check asks one question of GitHub, where
Stem's releases live, and sends nothing about you or your chats.
diff --git a/docs/user/share-links.md b/docs/user/share-links.md
new file mode 100644
index 00000000..690bdecf
--- /dev/null
+++ b/docs/user/share-links.md
@@ -0,0 +1,102 @@
+# Share links
+
+Maya asked Stem to compare three ways to deploy her app, and a friend wants to
+read the answer. She shares the question and answer as a link and pastes it in
+a chat. Her friend opens it in a browser, without Stem.
+
+A share link is a copy of the conversation as it was when you shared it. Later
+messages are not added. Sharing again makes a new link.
+
+## Before you start
+
+Sharing needs a verified email address, so every public link traces back to a
+real person. Add one under Settings → Features → **Your email address** (see
+[Mail with other Stems](other-stems.md)). Without one, the Share dialog says so
+and offers **Verify an address in Settings**.
+
+## Share an answer
+
+- **In a chat**, hover an answer and click the share button (**Share this
+ answer as a link**).
+- **In mail**, click the share button on a persona's reply (**Share as a
+ link**).
+- **On the iPhone**, touch and hold an answer or a persona's reply and choose
+ **Share as Link…**.
+
+The dialog **Share as a link** opens. Pick what to share:
+
+- **Whole chat** (**Whole mail thread** in mail): every message.
+- **Question and answer**: the answer and the question before it. This is the
+ default.
+- **Answer only**.
+
+Below it is a preview of exactly what will be published. Check it: anyone with
+the link can read what you see there. Then:
+
+- **Show which model answered** is on by default. The model shows by name, such
+ as `gpt-6.1-sol`.
+- **Include my pictures** appears when you attached pictures. They are left out
+ unless you tick it.
+- Expiry: **Expires in 30 days** (the default), 7 days or 1 day.
+
+Press **Create link**. Stem copies the link to the clipboard and shows it with
+**Copy link**, **Open in the browser** and **Revoke**.
+
+## What is left out
+
+Tool steps, memory cards and in-app links are left out. Links to places inside
+Stem keep their words as plain text. Suggested replies, notices about failed
+turns and messages that failed to send stay out too. In mail, only the mails
+between you and your personas are shared: the personas' mail to each other and
+approval cards stay out. Pictures Stem made are included, and so are web
+sources.
+
+A private chat can be shared. Private only means Stem doesn't learn from it.
+
+## The preview card
+
+Chat apps show a card when a link is pasted. **Show a preview when the link is
+pasted in a chat app** is on by default, with a title and description Stem
+suggests from the conversation. Edit them before you create the link, or switch
+the card off. Unlike the page, this preview is not encrypted: the share server
+and anyone the link reaches can read it. Search engines are told not to index
+any of it, page or card.
+
+## Encryption
+
+Your Stem encrypts the page before it uploads it to `share.heystem.app`. The
+key is the part after `#` in the link. Browsers never send that part to a
+server, so the share server can't read the page, and only people you give the
+whole link to can. A link missing that part opens as *This link is incomplete*.
+The link is published under your verified address.
+
+## Limits
+
+- A link works for at most 30 days.
+- You can have at most 100 working links at a time, and make at most 30 a day.
+- A share that is too big fails with a note to leave out the pictures or share
+ one answer instead of the whole chat.
+
+## Take a link down
+
+Settings → Features → **Shared links** → **Public links you made** lists your
+working links with what they share and their end date. The section appears once
+you have made a link. Each row has **Copy link** and a revoke button (**Revoke —
+the link stops opening**). A revoked link opens as *This link was turned off*.
+Anyone who already saved the page keeps their copy.
+
+Each shared page also has a **Report** button for readers. A link taken down after a
+report opens as *This link was removed*.
+
+## What can't be shared
+
+- Mail with another person (see [Mail with other Stems](other-stems.md)). Their
+ mail is theirs to publish.
+- The Memory tab's deep check conversation.
+
+## Company computers
+
+When a company [policy file](../stem-policy.md) turns off or limits mail with
+other Stems, sharing is off too: *Your organization has turned off sharing
+outside the company.* It is also off when your address is on a company relay
+that keeps its people inside.
diff --git a/docs/user/shortcuts.md b/docs/user/shortcuts.md
index cf6d4322..d4e78b84 100644
--- a/docs/user/shortcuts.md
+++ b/docs/user/shortcuts.md
@@ -24,6 +24,7 @@ Everything Stem binds to a key, and a few composer tricks that no keycap adverti
| **Mark read or unread** — flips the unread mark on the selection | ⌘⇧D | Ctrl+Shift+D |
| **Stop** — interrupts the answer in progress | ⌘. | Ctrl+. |
| **Delete chat** — asks to delete the open chat; press again to confirm | ⌃X | Ctrl+Shift+X |
+| **Settings** — opens Settings in the right-hand panel | ⌘, | Ctrl+, |
| **Send** — sends the message; Shift+Enter starts a new line instead | ⏎ | Enter |
## Tricks
diff --git a/docs/user/tools/mcp-servers.md b/docs/user/tools/mcp-servers.md
index 71a80a5e..5ad6c620 100644
--- a/docs/user/tools/mcp-servers.md
+++ b/docs/user/tools/mcp-servers.md
@@ -66,6 +66,18 @@ or headers, and the OAuth client), **Runs on** (when you have computers to choos
from), and **Status**. Stored secrets show as dots; leave them to keep them, or type a
new value. **Save** writes everything at once; **Cancel** throws the edit away.
+## Works with apps
+
+On a Mac, [**Ask about this window**](../quick-chat.md#ask-about-this-window-mac)
+uses the server that covers the window's app. The model is told to prefer it
+for reading and acting in that app, and to show you what it will send before
+sending.
+
+By default a server covers apps named like it: `slack` covers Slack,
+`linear-mcp` covers Linear. To choose the apps yourself, open the server and
+fill in **Works with apps** with app names or bundle ids, separated by
+commas. Clear the field to go back to matching by name.
+
## Where a server runs
**Command** and **URL** say how Stem talks to a server, not where it runs. A server
diff --git a/docs/windows-dev.md b/docs/windows-dev.md
index e7183960..73f1bb5a 100644
--- a/docs/windows-dev.md
+++ b/docs/windows-dev.md
@@ -1,9 +1,10 @@
# Windows development (experimental)
-Stem’s released installers target macOS and Linux. **Windows is a terminal-first
-dev port**: you can clone, install, and run from a user account **without admin
-rights**, using a portable Node.js zip. Packaging (NSIS/portable exe) is not
-included yet.
+Releases from 0.7.0 on include a Windows installer, `Stem-Setup-.exe`
+(per user, no admin prompt, updates itself; unsigned, so SmartScreen asks once).
+This page is about running Stem from a clone: you can clone, install, and run
+from a user account **without admin rights**, using a portable Node.js zip.
+`npm run dist:win` builds the installer on a Windows machine, into `release\`.
Personal data still lives outside the clone, under `%APPDATA%\Stem\` (and
`%APPDATA%\Stem Profiles\` for `--fresh` / `--profile=`). Reinstalling Node or
diff --git a/electron-builder.yml b/electron-builder.yml
index 16c617e2..a1b1c33c 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -100,9 +100,10 @@ files:
# whole repository (src/, tests/, mobile/, docs/, Dockerfile, …) inside the app
# because of two such lists. Target-specific trimming lives in the hook instead.
afterPack: scripts/prune-onnxruntime.mjs
-# Compiles native/mac/stem-computer (the computer-control persona's Swift
-# helper) before the mac app is packed; a no-op on every other platform.
-beforePack: scripts/build-mac-helper.mjs
+# Compiles the computer-control helper before the app is packed: the Swift one
+# (native/mac/stem-computer) for a Mac, the C# one (native/windows/stem-computer)
+# for Windows; nothing for Linux.
+beforePack: scripts/build-native-helpers.mjs
mac:
target: [dmg, zip]
@@ -132,6 +133,11 @@ mac:
linux:
target: [AppImage, deb]
executableName: stem
+ # Computer control on GNOME: the gjs helper and the Shell extension it talks
+ # to (installed from here into ~/.local/share/gnome-shell/extensions).
+ extraResources:
+ - from: native/linux
+ to: linux
# 1024px source; electron-builder derives the icon size set.
icon: build/icon.png
category: Utility
@@ -140,6 +146,26 @@ linux:
entry:
StartupWMClass: Stem
+win:
+ target: [nsis]
+ # Derived from the 1024px png (electron-builder writes the .ico).
+ icon: build/icon.png
+ extraResources:
+ - from: build/native/stem-computer.exe
+ to: bin/stem-computer.exe
+ # No code-signing certificate yet: SmartScreen warns on the first download
+ # ("More info → Run anyway"). electron-updater checks a signature only when
+ # publisherName is set, so updates still apply.
+
+# Per user, into %LOCALAPPDATA%\Programs\Stem: no UAC prompt to install, and
+# the updater can run the next installer over it without one either.
+nsis:
+ oneClick: true
+ perMachine: false
+ artifactName: Stem-Setup-${version}.${ext}
+ shortcutName: Stem
+ deleteAppDataOnUninstall: false
+
deb:
# Expose `stem` on PATH so DE keyboard shortcuts can run `stem --quick-chat`
# (the Wayland summon path — see the single-instance handler in server/index.ts).
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 03e15aef..8c5540fe 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -67,7 +67,10 @@ export default defineConfig({
// The browser extension's native-messaging host: plain Node, run by
// the Stem binary with ELECTRON_RUN_AS_NODE from the wrapper script
// Set up writes (desktop/browser-host/install.ts).
- 'browser-native-host': 'src/desktop/browser-host/native-host-main.ts'
+ 'browser-native-host': 'src/desktop/browser-host/native-host-main.ts',
+ // Dictation off the Mac: Whisper in its own utilityProcess
+ // (utilityProcess.fork(dist/main/dictation-worker.js) in desktop/dictation/whisper.ts).
+ 'dictation-worker': 'src/desktop/dictation/whisper-worker.ts'
},
// transformers.js must stay external: it lazily loads onnxruntime-node's
// native .node binary, which cannot live inside a rollup bundle. Resolved
diff --git a/eslint.config.js b/eslint.config.js
index e1102cae..037c0290 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -67,6 +67,17 @@ export default [
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
rules: { 'no-undef': 'off' }
},
+ {
+ // The Linux computer-control helper and GNOME Shell extension run under
+ // gjs, whose globals (global, print, printerr, TextEncoder…) eslint does
+ // not know.
+ files: ['native/linux/**/*.js'],
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ globals: { global: 'readonly', print: 'readonly', printerr: 'readonly', TextEncoder: 'readonly', TextDecoder: 'readonly', console: 'readonly' }
+ }
+ },
{
// Tests: Playwright's `use()` fixture API trips react-hooks/rules-of-hooks
// (it's not a React Hook), and probing the renderer through `window.stem`
diff --git a/experiments/browser-control/results/2026-10-05-extension.json b/experiments/browser-control/results/2026-10-05-extension.json
index 2b8c8df3..f3791bd0 100644
--- a/experiments/browser-control/results/2026-10-05-extension.json
+++ b/experiments/browser-control/results/2026-10-05-extension.json
@@ -1,7 +1,7 @@
{
"headless=new": {
"recordedOn": "2026-10-05",
- "recordedAt": "2026-10-05T16:19:30.832Z",
+ "recordedAt": "2026-10-19T19:05:23.514Z",
"display": {
"asleep": false,
"locked": false
@@ -11,152 +11,152 @@
"executable": "/Users/join3r/Library/Caches/ms-playwright/chromium-1228/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"extensionId": "bbadcpfklblgjocjcaemcmojkikjnoao",
"status": "passed",
- "passed": 28,
+ "passed": 29,
"failed": 0,
"checks": [
{
"name": "hello: protocol 1, the manifest-derived id, version",
"ok": true,
"ms": 0,
- "detail": "bbadcpfklblgjocjcaemcmojkikjnoao v0.6.0"
+ "detail": "bbadcpfklblgjocjcaemcmojkikjnoao v0.6.0, profile 265d99de…"
},
{
"name": "tabs lists the user’s own tab as active",
"ok": true,
"ms": 2,
- "detail": "1 open tab. \"active\" is the tab showing in its window — the user may be looking at it; the rest are in the background. Pass an id as `tab` to work in it.\nWindow 1 (last focused: new tabs open here):\n- 442773629 · The user’s own page — http://127.0.0.1:58345/user (active)"
+ "detail": "1 open tab. \"active\" is the tab showing in its window — the user may be looking at it; the rest are in the background. Pass an id as `tab` to work in it.\nWindow 1 (last focused: new tabs open here):\n- 1293553521 · The user’s own page — http://127.0.0.1:49512/user (active)"
},
{
"name": "open: new background tab, the user’s tab stays active",
"ok": true,
- "ms": 150,
- "detail": "Opened tab 442773630 in the background: Fixture form — http://127.0.0.1:58345/form?tab=F\n2 open tabs. \"active\" is the tab showing in its window — the user may be looking at it; the rest are in the background. Pass an id as `tab` to work in it.\nWindow 1 (last focused: new tabs open here):\n- 442773629 · The user’s own page — http://127.0.0.1:58345/user (active)\n- 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F (yours, your current tab)"
+ "ms": 149,
+ "detail": "Opened tab 1293553522 in the background: Fixture form — http://127.0.0.1:49512/form?tab=F\n2 open tabs. \"active\" is the tab showing in its window — the user may be looking at it; the rest are in the background. Pass an id as `tab` to work in it.\nWindow 1 (last focused: new tabs open here):\n- 1293553521 · The user’s own page — http://127.0.0.1:49512/user (active)\n- 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F (yours, your current tab)"
},
{
"name": "snapshot: outline with refs, same-origin iframe spliced in",
"ok": true,
- "ms": 13,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\n- banner\n - navigation \"Main\"\n - link \"Page two\" [ref=e1] → /page2\n - link \"Open help\" [ref=e2] → /help\n - link \"Download report\" [ref=e3] → /download\n- main\n - heading \"Order form\" [level=1] [ref=e4]\n - paragraph: Fill in the form below and press Greet.\n - textbox \"Name\" [ref=e5]: Ada\n - textbox \"Email\" [ref=e6]\n - textbox \"Password\" [ref=e7]\n - combobox \"Colour\" [ref=e8]: Green\n - option \"Red\"\n - option \"Green\" [selected]\n - option \"Blue\"\n - checkbox \"Subscribe\" [ref=e9]\n - group \"Size\"\n - radio \"Small\" [checked] [ref=e10]\n - radio \"Large\" [ref=e11]\n - textbox \"Notes\" [ref=e12]: hello\n - button \"Greet\" [ref=e13]\n - button \"Alert me\" [ref=e14]\n - button \"Ask my name\" [ref=e15]\n - search\n - textbox \"Search\" [ref=e16]\n - button \"Go\" [ref=e17]\n - list\n - listitem: One\n - listitem\n - text: Two\n - link \"anchor\" [ref=e18] → #t\n - table\n - row: Item | Price\n - row: Tea | 3\n - region \"Files\"\n - button \"Choose file\" [ref=e19]\n - button \"Send file\" [ref=e20]\n - iframe \"Same-origin frame\":\n - paragraph: Inside the frame\n - button \"Frame button\" [ref=e21]\n - text: Tall section\n - paragraph: The end of the page."
+ "ms": 7,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\n- banner\n - navigation \"Main\"\n - link \"Page two\" [ref=e1] → /page2\n - link \"Open help\" [ref=e2] → /help\n - link \"Download report\" [ref=e3] → /download\n- main\n - heading \"Order form\" [level=1] [ref=e4]\n - paragraph: Fill in the form below and press Greet.\n - textbox \"Name\" [ref=e5]: Ada\n - textbox \"Email\" [ref=e6]\n - textbox \"Password\" [ref=e7]\n - combobox \"Colour\" [ref=e8]: Green\n - option \"Red\"\n - option \"Green\" [selected]\n - option \"Blue\"\n - checkbox \"Subscribe\" [ref=e9]\n - group \"Size\"\n - radio \"Small\" [checked] [ref=e10]\n - radio \"Large\" [ref=e11]\n - textbox \"Notes\" [ref=e12]: hello\n - button \"Greet\" [ref=e13]\n - button \"Alert me\" [ref=e14]\n - button \"Ask my name\" [ref=e15]\n - search\n - textbox \"Search\" [ref=e16]\n - button \"Go\" [ref=e17]\n - list\n - listitem: One\n - listitem\n - text: Two\n - link \"anchor\" [ref=e18] → #t\n - table\n - row: Item | Price\n - row: Tea | 3\n - region \"Files\"\n - button \"Choose file\" [ref=e19]\n - button \"Send file\" [ref=e20]\n - iframe \"Same-origin frame\":\n - paragraph: Inside the frame\n - button \"Frame button\" [ref=e21]\n - text: Tall section\n - paragraph: The end of the page."
},
{
"name": "fill: text field, select by option text, checkbox",
"ok": true,
- "ms": 22,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nSet textbox \"Name\" (e5) to \"Bob\".\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nSelected \"Blue\" in combobox \"Colour\" (e8).\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nChecked checkbox \"Subscribe\" (e9); it is now checked.\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\ncheckbox \"Subscribe\" (e9) was already checked."
+ "ms": 13,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nSet textbox \"Name\" (e5) to \"Bob\".\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nSelected \"Blue\" in combobox \"Colour\" (e8).\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nChecked checkbox \"Subscribe\" (e9); it is now checked.\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\ncheckbox \"Subscribe\" (e9) was already checked."
},
{
"name": "click by ref in a background tab",
"ok": true,
- "ms": 618,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nClicked button \"Greet\" (e13)."
+ "ms": 620,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nClicked button \"Greet\" (e13)."
},
{
"name": "console: load-time and click messages, errorsOnly",
"ok": true,
"ms": 5,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\n3 console messages (oldest first; Stem attached at 18:19:15):\n18:19:15 log form loaded 42 (form:31)\n18:19:15 debug [recommendation] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o (form)\n18:19:15 error greet clicked Bob (form:35)"
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\n3 console messages (oldest first; Stem attached at 21:05:06):\n21:05:06 log form loaded 42 (form:31)\n21:05:06 debug [recommendation] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o (form)\n21:05:07 error greet clicked Bob (form:35)"
},
{
"name": "network: list and one request with its body",
"ok": true,
- "ms": 3,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\n1 requests matching \"/api/data\" since Stem attached at 18:19:15 (oldest first; `network` with `request`: an id for its headers and body):\nr5 GET 200 fetch 239 B http://127.0.0.1:58345/api/data?name=Bob\n---\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nr5 GET http://127.0.0.1:58345/api/data?name=Bob\nStatus: 200 OK · type fetch · application/json · 239 B\nRequest headers:\n Accept-Language: en-US,en;q=0.9\n Referer: http://127.0.0.1:58345/form?tab=F\n User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.0.0 Safari/537.36\n sec-ch-ua: \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"\n sec-ch-ua-mobile: ?0\n sec-ch-ua-platform: \"macOS\"\nResponse headers:\n Connection: keep-alive\n Date: Mon, 05 Oct 2026 16:19:15 GMT\n Keep-Alive: timeout=5\n Transfer-Encoding: chunked\n cache-control: no-store\n content-type: application/json\nResponse body:\n{\"greeting\":\"Hello Bob\",\"items\":[1,2,3]}"
+ "ms": 2,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\n1 requests matching \"/api/data\" since Stem attached at 21:05:06 (oldest first; `network` with `request`: an id for its headers and body):\nr5 GET 200 fetch 239 B http://127.0.0.1:49512/api/data?name=Bob\n---\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nr5 GET http://127.0.0.1:49512/api/data?name=Bob\nStatus: 200 OK · type fetch · application/json · 239 B\nRequest headers:\n Accept-Language: en-US,en;q=0.9\n Referer: http://127.0.0.1:49512/form?tab=F\n User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.0.0 Safari/537.36\n sec-ch-ua: \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"\n sec-ch-ua-mobile: ?0\n sec-ch-ua-platform: \"macOS\"\nResponse headers:\n Connection: keep-alive\n Date: Mon, 19 Oct 2026 19:05:07 GMT\n Keep-Alive: timeout=5\n Transfer-Encoding: chunked\n cache-control: no-store\n content-type: application/json\nResponse body:\n{\"greeting\":\"Hello Bob\",\"items\":[1,2,3]}"
},
{
"name": "type + submit navigates; wait for text",
"ok": true,
- "ms": 311,
- "detail": "Tab 442773630 · Search results — http://127.0.0.1:58345/search?q=stem+rocks\nTyped 10 characters into textbox \"Search\" (e16) and pressed Enter. The tab went to http://127.0.0.1:58345/search?q=stem+rocks.\nTab 442773630 · Search results — http://127.0.0.1:58345/search?q=stem+rocks\nThe text \"Results for stem rocks\" appeared after 0.0 s."
+ "ms": 321,
+ "detail": "Tab 1293553522 · Search results — http://127.0.0.1:49512/search?q=stem+rocks\nTyped 10 characters into textbox \"Search\" (e16) and pressed Enter. The tab went to http://127.0.0.1:49512/search?q=stem+rocks.\nTab 1293553522 · Search results — http://127.0.0.1:49512/search?q=stem+rocks\nThe text \"Results for stem rocks\" appeared after 0.0 s."
},
{
"name": "navigate back, then wait for delayed text and a timeout",
"ok": true,
- "ms": 2784,
- "detail": "Tab 442773630 · Delayed — http://127.0.0.1:58345/delayed\nThe text \"ready NOW\" appeared after 1.5 s.\nTab 442773630 · Delayed — http://127.0.0.1:58345/delayed\nThe text \"never there\" did not appear within 1 s.\nTab 442773630 · Delayed — http://127.0.0.1:58345/delayed\nA URL containing \"/delayed\" appeared after 0.0 s."
+ "ms": 2838,
+ "detail": "Tab 1293553522 · Delayed — http://127.0.0.1:49512/delayed\nThe text \"ready NOW\" appeared after 1.6 s.\nTab 1293553522 · Delayed — http://127.0.0.1:49512/delayed\nThe text \"never there\" did not appear within 1 s.\nTab 1293553522 · Delayed — http://127.0.0.1:49512/delayed\nA URL containing \"/delayed\" appeared after 0.0 s."
},
{
"name": "press: Meta+A then Backspace clears a field (Mac editing commands), Tab moves focus",
"ok": true,
"ms": 781,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nPressed Meta+A."
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nPressed Meta+A."
},
{
"name": "scroll: wheel down in a background tab, then a ref into view",
"ok": true,
- "ms": 462,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nScrolled the page down: it is now at y=330 of 2592 (the view is 413 px high).\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nScrolled button \"Greet\" (e13) into view: its top is 196 px from the top of the 413 px high view."
+ "ms": 477,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nScrolled the page down: it is now at y=330 of 2592 (the view is 413 px high).\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nScrolled button \"Greet\" (e13) into view: its top is 196 px from the top of the 413 px high view."
},
{
"name": "evaluate: value, top-level await, a thrown error",
"ok": true,
- "ms": 157,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\n\"Fixture form\"\nTab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\n{\n \"n\": 42\n}\nThe script threw: ReferenceError: nope is not defined at :1:1"
+ "ms": 243,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\n\"Fixture form\"\nTab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\n{\n \"n\": 42\n}\nThe script threw: ReferenceError: nope is not defined at :1:1"
},
{
"name": "dialog: an alert during a click does not hang; other actions refuse; prompt answered with text",
"ok": true,
- "ms": 670,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nClicked button \"Alert me\" (e14). The page opened a dialog: alert \"Hi there\". Answer it with `dialog` (accept: true or false) before doing anything else in this tab. (18 ms)"
+ "ms": 662,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nClicked button \"Alert me\" (e14). The page opened a dialog: alert \"Hi there\". Answer it with `dialog` (accept: true or false) before doing anything else in this tab. (10 ms)"
},
{
"name": "screenshot of a background tab: CSS-pixel size, not blank, marker hidden",
"ok": true,
- "ms": 189,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nScreenshot of the visible part of the page, 741×413; coordinates are CSS pixels of this picture. | view 756x413 | stdev 34.0 | edge 255,255,255 | Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nFull-page screenshot, 741×1652 (the top 1652 px of a 3039 px page). Its coordinates are page coordinates; `coordinate` clicks use the visible part of the page, so scroll first or use a ref."
+ "ms": 145,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nScreenshot of the visible part of the page, 741×413; coordinates are CSS pixels of this picture. | view 756x413 | stdev 34.0 | edge 255,255,255 | Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nFull-page screenshot, 741×1652 (the top 1652 px of a 3039 px page). Its coordinates are page coordinates; `coordinate` clicks use the visible part of the page, so scroll first or use a ref."
},
{
"name": "upload through a hidden file input; the server receives the bytes",
"ok": true,
- "ms": 343,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nAttached upload-me.txt (33 B) to the file input (from button \"Choose file\" (e19)). Many sites upload as soon as a file is chosen; if this one has a send or upload button, click it."
+ "ms": 338,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nAttached upload-me.txt (33 B) to the file input (from button \"Choose file\" (e19)). Many sites upload as soon as a file is chosen; if this one has a send or upload button, click it."
},
{
"name": "a click on an upload button is answered by a file chooser, not a native dialog",
"ok": true,
- "ms": 310,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F\nClicked button \"Choose file\" (e19). It opened a file chooser: answer it with `upload` (same ref, and the files)."
+ "ms": 313,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F\nClicked button \"Choose file\" (e19). It opened a file chooser: answer it with `upload` (same ref, and the files)."
},
{
"name": "download: reported on the next result exactly once, with its path",
"ok": true,
- "ms": 318,
- "detail": "{\"path\":\"/var/folders/9g/7y5ws8zs42jckrc1h2363fw80000gn/T/stem-extension-live-wlUdpY/downloads/report.txt\",\"name\":\"report.txt\",\"size\":28,\"mime\":\"text/plain\"} | Downloads from this run:\n- report.txt — done, 28 B"
+ "ms": 316,
+ "detail": "{\"path\":\"/var/folders/9g/7y5ws8zs42jckrc1h2363fw80000gn/T/stem-extension-live-40Yo6h/downloads/report.txt\",\"name\":\"report.txt\",\"size\":28,\"mime\":\"text/plain\"} | Downloads from this run:\n- report.txt — done, 28 B"
},
{
"name": "a download the user makes during a run is never the run’s",
"ok": true,
- "ms": 618,
+ "ms": 617,
"detail": "report (1).txt, report.txt on disk; run lists: Downloads from this run: | - report.txt — done, 28 B"
},
{
"name": "a target=_blank click opens a background tab; the user’s view does not switch",
"ok": true,
- "ms": 830,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F#stem-test-user-download\nClicked link \"Open help\" (e2).\nIt opened tab 442773631 in the background (yours to use or close): http://127.0.0.1:58345/help"
+ "ms": 835,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F#stem-test-user-download\nClicked link \"Open help\" (e2).\nIt opened tab 1293553523 in the background (yours to use or close): http://127.0.0.1:49512/help"
},
{
"name": "close: own tab closes, the user’s tab is refused",
"ok": true,
"ms": 8,
- "detail": "Closed tab 442773631 (Help). | You can only close tabs you opened."
+ "detail": "Closed tab 1293553523 (Help). | You can only close tabs you opened."
},
{
"name": "refuses chrome:// and file: (open, and acting in a tab showing one)",
"ok": true,
- "ms": 1,
+ "ms": 6,
"detail": "Stem can't open that: it shows a browser page (chrome://settings/), which extensions are not allowed to control (chrome: pages).\nStem can't open that: it is a file on this Mac, which Stem never opens — uploads go through `upload` with files Stem holds.\nStem can't open that: it is a file on this Mac, which Stem never opens — uploads go through `upload` with files Stem holds."
},
{
"name": "two runs at once in different tabs; a shared tab gets a warning; actions serialize",
"ok": true,
- "ms": 649,
- "detail": "Note: another Stem run also worked in this tab in the last 2 minutes, so the page may have changed under you. | A: Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F#stem-test-user-download B: Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F#stem-test-user-download"
+ "ms": 721,
+ "detail": "Note: another Stem run also worked in this tab in the last 2 minutes, so the page may have changed under you. | A: Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F#stem-test-user-download B: Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F#stem-test-user-download"
},
{
"name": "minimized window: click and scroll still work in a background tab; screenshot works or says why",
"ok": true,
- "ms": 2376,
- "detail": "click 308 ms | Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F#stem-test-minimize | screenshot ok 741x413 (56 ms) | visibilityState visible"
+ "ms": 2371,
+ "detail": "click 314 ms | Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F#stem-test-minimize | screenshot ok 741x413 (50 ms) | visibilityState visible"
},
{
"name": "marker: present, invisible to page script (no binding, closed shadow root)",
@@ -167,14 +167,20 @@
{
"name": "marker Stop button → `stopped` for every run using that tab; later requests refused",
"ok": true,
- "ms": 1518,
+ "ms": 1516,
"detail": "stopped: thread-a, thread-b; marker removed"
},
{
"name": "end: runs forgotten; a new run re-attaches with fresh history",
"ok": true,
- "ms": 2009,
- "detail": "Tab 442773630 · Fixture form — http://127.0.0.1:58345/form?tab=F#stem-test-press-stop\n3 console messages (oldest first; 3 from before Stem attached at 18:19:29):\n18:19:19 log form loaded 42 (form:31)\n18:19:26 error greet clicked (form:35)\n18:19:19 debug [recommendation] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o (form)"
+ "ms": 2019,
+ "detail": "Tab 1293553522 · Fixture form — http://127.0.0.1:49512/form?tab=F#stem-test-press-stop\n3 console messages (oldest first; 3 from before Stem attached at 21:05:20):\n21:05:10 log form loaded 42 (form:31)\n21:05:17 error greet clicked (form:35)\n21:05:10 debug [recommendation] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o (form)"
+ },
+ {
+ "name": "another extension’s frame in the page is taken out so Stem can attach; one added later too",
+ "ok": true,
+ "ms": 993,
+ "detail": "Set textbox \"City\" (e2) to \"Most\"."
},
{
"name": "stale and unknown refs say so",
@@ -185,100 +191,118 @@
],
"markerLog": [
{
- "at": 1791217155147,
- "href": "http://127.0.0.1:58345/user",
+ "at": 1792436706260,
+ "href": "http://127.0.0.1:49512/user",
"present": false,
- "doc": "6asn3v498kb"
+ "doc": "60gacdlevfi"
},
{
- "at": 1791217155599,
- "href": "http://127.0.0.1:58345/form?tab=F",
+ "at": 1792436706941,
+ "href": "http://127.0.0.1:49512/form?tab=F",
"present": false,
- "doc": "nkmxikffj67"
+ "doc": "b4aotsyk856"
},
{
- "at": 1791217156403,
- "href": "http://127.0.0.1:58345/search?q=stem+rocks",
+ "at": 1792436707723,
+ "href": "http://127.0.0.1:49512/search?q=stem+rocks",
"present": false,
- "doc": "4m6t2l9dceh"
+ "doc": "t6gc7zxkeps"
},
{
- "at": 1791217156692,
- "href": "http://127.0.0.1:58345/form?tab=F",
+ "at": 1792436708033,
+ "href": "http://127.0.0.1:49512/form?tab=F",
"present": false,
- "doc": "tpxjqoy9u"
+ "doc": "w7e6rt9if2"
+ },
+ {
+ "at": 1792436708157,
+ "href": "http://127.0.0.1:49512/delayed",
+ "present": false,
+ "doc": "tjw6xcvaej"
+ },
+ {
+ "at": 1792436709296,
+ "href": "http://127.0.0.1:49512/delayed",
+ "present": true,
+ "doc": "tjw6xcvaej"
},
{
- "at": 1791217156813,
- "href": "http://127.0.0.1:58345/delayed",
+ "at": 1792436710876,
+ "href": "http://127.0.0.1:49512/form?tab=F",
"present": false,
- "doc": "3lc4ba4bdaj"
+ "doc": "0bomtwkhthe"
},
{
- "at": 1791217157903,
- "href": "http://127.0.0.1:58345/delayed",
+ "at": 1792436711323,
+ "href": "http://127.0.0.1:49512/form?tab=F",
"present": true,
- "doc": "3lc4ba4bdaj"
+ "doc": "0bomtwkhthe"
+ },
+ {
+ "at": 1792436714787,
+ "href": "http://127.0.0.1:49512/help",
+ "present": false,
+ "doc": "vvvnji2t7rl"
},
{
- "at": 1791217159489,
- "href": "http://127.0.0.1:58345/form?tab=F",
+ "at": 1792436715652,
+ "href": "http://127.0.0.1:49512/page2",
"present": false,
- "doc": "uye1ndq8vgl"
+ "doc": "1n8xuvk9biw"
},
{
- "at": 1791217160222,
- "href": "http://127.0.0.1:58345/form?tab=F",
+ "at": 1792436716321,
+ "href": "http://127.0.0.1:49512/page2",
"present": true,
- "doc": "uye1ndq8vgl"
+ "doc": "1n8xuvk9biw"
},
{
- "at": 1791217163350,
- "href": "http://127.0.0.1:58345/help",
+ "at": 1792436718711,
+ "href": "http://127.0.0.1:49512/form?tab=F#stem-test-press-stop",
"present": false,
- "doc": "yl7jw5t70n8"
+ "doc": "0bomtwkhthe"
},
{
- "at": 1791217164207,
- "href": "http://127.0.0.1:58345/page2",
+ "at": 1792436718713,
+ "href": "http://127.0.0.1:49512/page2",
"present": false,
- "doc": "pcnwgbfstf"
+ "doc": "1n8xuvk9biw"
},
{
- "at": 1791217165216,
- "href": "http://127.0.0.1:58345/page2",
+ "at": 1792436720731,
+ "href": "http://127.0.0.1:49512/form?tab=F#stem-test-press-stop",
"present": true,
- "doc": "pcnwgbfstf"
+ "doc": "0bomtwkhthe"
},
{
- "at": 1791217167203,
- "href": "http://127.0.0.1:58345/form?tab=F#stem-test-press-stop",
+ "at": 1792436720734,
+ "href": "http://127.0.0.1:49512/form?tab=F#stem-test-press-stop",
"present": false,
- "doc": "uye1ndq8vgl"
+ "doc": "0bomtwkhthe"
},
{
- "at": 1791217167205,
- "href": "http://127.0.0.1:58345/page2",
+ "at": 1792436722280,
+ "href": "http://127.0.0.1:49512/extframe",
"present": false,
- "doc": "pcnwgbfstf"
+ "doc": "2eydol4qk21"
},
{
- "at": 1791217169211,
- "href": "http://127.0.0.1:58345/form?tab=F#stem-test-press-stop",
+ "at": 1792436722393,
+ "href": "http://127.0.0.1:49512/extframe",
"present": true,
- "doc": "uye1ndq8vgl"
+ "doc": "2eydol4qk21"
},
{
- "at": 1791217169214,
- "href": "http://127.0.0.1:58345/form?tab=F#stem-test-press-stop",
+ "at": 1792436723230,
+ "href": "http://127.0.0.1:49512/extframe#stem-test-foreign-frame",
"present": false,
- "doc": "uye1ndq8vgl"
+ "doc": "2eydol4qk21"
},
{
- "at": 1791217170721,
- "href": "http://127.0.0.1:58345/form?tab=F#stem-test-press-stop",
+ "at": 1792436723234,
+ "href": "http://127.0.0.1:49512/form?tab=F#stem-test-press-stop",
"present": true,
- "doc": "uye1ndq8vgl"
+ "doc": "0bomtwkhthe"
}
]
},
diff --git a/ios/Stem/Markdown/Components/DiagramView.swift b/ios/Stem/Markdown/Components/DiagramView.swift
new file mode 100644
index 00000000..51b02ef1
--- /dev/null
+++ b/ios/Stem/Markdown/Components/DiagramView.swift
@@ -0,0 +1,215 @@
+import SwiftUI
+import WebKit
+
+/// Draws Mermaid source the way the desktop's does: with pele, in a
+/// web view, links and images off so a diagram can only be a picture, and the
+/// SVG sanitized by DOMPurify before it is inserted, as on the desktop.
+/// pele.global.js and purify.min.js ship in the app bundle (project.yml copies
+/// them from the repo's node_modules, the versions the desktop renders with) and
+/// are injected as user scripts with the draw function; the page itself runs no
+/// script at all (its CSP allows none), has no base URL, may fetch nothing and
+/// may not navigate anywhere. The web view reports the drawing's
+/// height back and the cell sizes to it.
+///
+/// While `live` (the reply is still streaming), each new source redraws in
+/// place from its complete lines, keeping the last drawing that worked, so the
+/// diagram grows as it is written. Once the source is final, one pele can't
+/// parse reports an error and the caller shows the source instead.
+struct DiagramView: View {
+ let source: String
+ var live = false
+ /// Called once if pele could not draw the final source.
+ var onError: () -> Void = {}
+
+ @Environment(\.colorScheme) private var scheme
+ @State private var height: CGFloat = 0
+
+ var body: some View {
+ DiagramWebView(source: source, live: live, dark: scheme == .dark, height: $height, onError: onError)
+ // Keyed on the scheme, so a light/dark flip redraws in the new colors.
+ .id(scheme)
+ .frame(height: max(height, 60))
+ .overlay {
+ if height == 0 { ProgressView().controlSize(.small) }
+ }
+ }
+}
+
+private struct DiagramWebView: UIViewRepresentable {
+ let source: String
+ let live: Bool
+ let dark: Bool
+ @Binding var height: CGFloat
+ let onError: () -> Void
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+ config.websiteDataStore = .nonPersistent()
+ // A weak hop: the controller retains its handlers, and the coordinator owns nothing of the view.
+ config.userContentController.add(WeakHandler(context.coordinator), name: "diagram")
+ // User scripts are not subject to the page's CSP, so these run while the page itself may run nothing.
+ for script in [Self.peleScript, Self.purifyScript].compactMap({ $0 }) {
+ config.userContentController.addUserScript(
+ WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
+ )
+ }
+ config.userContentController.addUserScript(
+ WKUserScript(source: Self.drawScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
+ )
+ let view = WKWebView(frame: .zero, configuration: config)
+ view.isOpaque = false
+ view.backgroundColor = .clear
+ view.scrollView.isScrollEnabled = false
+ view.scrollView.backgroundColor = .clear
+ view.navigationDelegate = context.coordinator
+ context.coordinator.drawn = (source, live)
+ // The first drawing comes from didFinish, once the draw function is in place.
+ view.loadHTMLString(Self.page(dark: dark), baseURL: nil)
+ return view
+ }
+
+ func updateUIView(_ view: WKWebView, context: Context) {
+ let coordinator = context.coordinator
+ coordinator.parent = self
+ // A streaming source grew (or settled): redraw in place, no reload.
+ guard coordinator.drawn != (source, live) else { return }
+ coordinator.drawn = (source, live)
+ // Before the page has loaded, didFinish draws the latest source instead.
+ if coordinator.ready { coordinator.redraw(view) }
+ }
+
+ static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) {
+ view.configuration.userContentController.removeScriptMessageHandler(forName: "diagram")
+ }
+
+ final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
+ var parent: DiagramWebView
+ /// The source and mode the page was last asked to draw.
+ var drawn: (String, Bool) = ("", false)
+ /// True once the page (and its draw function) has loaded.
+ var ready = false
+ private var failed = false
+ private var loaded = false
+ init(_ parent: DiagramWebView) { self.parent = parent }
+
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let body = message.body as? [String: Any] else { return }
+ if let h = body["height"] as? Double, h > 0 {
+ parent.height = CGFloat(h)
+ } else if body["error"] != nil, !failed {
+ failed = true
+ parent.onError()
+ }
+ }
+
+ func redraw(_ view: WKWebView) {
+ view.evaluateJavaScript("draw(\(DiagramWebView.json(drawn.0)), \(drawn.1))")
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ ready = true
+ redraw(webView)
+ }
+
+ // The one page we load, and nothing after it: a diagram never navigates anywhere.
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor action: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+ ) {
+ if !loaded, action.targetFrame?.isMainFrame == true, action.request.url?.absoluteString == "about:blank" {
+ loaded = true
+ decisionHandler(.allow)
+ } else {
+ decisionHandler(.cancel)
+ }
+ }
+ }
+
+ private final class WeakHandler: NSObject, WKScriptMessageHandler {
+ weak var target: WKScriptMessageHandler?
+ init(_ target: WKScriptMessageHandler) { self.target = target }
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ target?.userContentController(controller, didReceive: message)
+ }
+ }
+
+ /// The colors the desktop draws with (Stem's light and dark tokens), as pele's CSS variables.
+ private static func themeVariables(dark: Bool) -> String {
+ let vars: [(String, String)] = [
+ ("bg", dark ? "#242119" : "#faf8f3"),
+ ("fg", dark ? "#f0ece4" : "#23211d"),
+ ("muted", dark ? "#9b948a" : "#6d675d"),
+ ("line", dark ? "#9b948a" : "#6d675d"),
+ ("surface", dark ? "#2e2a23" : "#fffdf9"),
+ ("surface-alt", dark ? "#2f2b25" : "#efeae0"),
+ ("border", dark ? "#9b948a" : "#6d675d"),
+ ("accent", dark ? "#c79257" : "#9a6230"),
+ ("critical", "#c53030"),
+ ("font", "-apple-system, system-ui, sans-serif"),
+ ("series-1", dark ? "#3987e5" : "#2a78d6"), ("series-2", dark ? "#d95926" : "#eb6834"),
+ ("series-3", dark ? "#199e70" : "#1baf7a"), ("series-4", dark ? "#c98500" : "#eda100"),
+ ("series-5", dark ? "#d55181" : "#e87ba4"), ("series-6", "#008300"),
+ ("series-7", dark ? "#9085e9" : "#4a3aa7"), ("series-8", dark ? "#e66767" : "#e34948"),
+ ]
+ return vars.map { "--pele-\($0.0): \($0.1);" }.joined(separator: " ")
+ }
+
+ /// pele.global.js and DOMPurify from the bundle, read once.
+ private static let peleScript = bundledScript("pele.global")
+ private static let purifyScript = bundledScript("purify.min")
+
+ private static func bundledScript(_ name: String) -> String? {
+ Bundle.main.url(forResource: name, withExtension: "js").flatMap { try? String(contentsOf: $0, encoding: .utf8) }
+ }
+
+ /// Draws into #d; the coordinator calls draw(source, live) for every source.
+ /// Streaming: only complete lines, and a line that doesn't parse yet keeps the last drawing.
+ private static let drawScript = """
+ const post = (m) => window.webkit.messageHandlers.diagram.postMessage(m);
+ const box = document.getElementById('d');
+ const report = () => post({ height: Math.ceil(box.getBoundingClientRect().height) });
+ new ResizeObserver(report).observe(box);
+ function draw(source, live) {
+ const cut = source.lastIndexOf('\\n');
+ const code = (live ? (cut === -1 ? '' : source.slice(0, cut)) : source).trim();
+ if (!code) return;
+ try {
+ const { svg } = pele.render(code, {
+ idPrefix: 'stem-diagram', maxWidth: box.clientWidth || undefined,
+ fontFamily: '-apple-system, system-ui, sans-serif', fontSize: 14,
+ links: false, images: false
+ });
+ // Pele escapes labels itself; the source is model text, so DOMPurify too.
+ box.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
+ report();
+ } catch (e) {
+ if (!live) post({ error: String(e && e.message || e) });
+ }
+ }
+ """
+
+ fileprivate static func json(_ value: Any) -> String {
+ guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]),
+ let text = String(data: data, encoding: .utf8) else { return "null" }
+ // Inside a
-