# Mahana Devices — Distribution
*How the bundle ships, spawns, and plugs into hardware. 2026-04-22.*

---

## The four shippable binaries

From DEPENDENCIES.md, Mahana Devices = **four binaries + CF Worker edges + Vercel web apps**.

| Shippable | Origin | Builds with | Target |
|---|---|---|---|
| `mahana-daemon` | `src/main/` (this repo) in `--daemon` mode | `pnpm build` + Node bundling (esbuild or Rollup single-file) | macOS .app + Linux ELF |
| `mahana-capture` | `~/mahana-ecosystem/mahana-capture/` Rust | `cargo build --release` | macOS aarch64 + Linux x86_64 |
| `mahana-terminal` | `~/mahana-ecosystem/mahana-terminal/` Swift | `swift build -c release` | macOS 14+ only |
| `mahana-mobile` | `~/mahana-ecosystem/mahana-unified/` Next+Capacitor | `npm run cap:sync && fastlane beta` | iOS IPA (TestFlight, App Store) |

Plus edge:
- `packages/translator-proxy` → `wrangler deploy` (CF Worker)
- `mahana-mini-mcp` → `wrangler deploy` (CF Worker)
- `web/apps/*` → `vercel deploy` (per-app)

---

## macOS DMG pipeline

### Build
```bash
# 1. Build all four binaries
cd /Users/Mahana/mahana-ecosystem/mahana
pnpm build                              # src/main → out/main/headless.js
pnpm build:web-apps                     # web/apps/* → out/web/

cd ../mahana-capture
cargo build --release --target aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
lipo -create -output mahana-capture target/*/release/mahana-capture

cd ../mahana-terminal
swift build -c release                  # → .build/release/MahanaTerminal

# 2. Assemble into DMG via electron-builder (custom config)
cd ../mahana
npx electron-builder --mac --config electron-builder-devices.yml
```

### `electron-builder-devices.yml` (proposed — does not yet exist)
```yaml
appId: cc.mahana.devices
productName: Mahana Devices
directories:
  buildResources: build-devices
mac:
  target:
    - dmg
    - zip
  category: public.app-category.productivity
  hardenedRuntime: true
  gatekeeperAssess: false
  entitlements: build-devices/entitlements.mac.plist
  entitlementsInherit: build-devices/entitlements.mac.plist
  extraResources:
    - from: ../mahana-capture/mahana-capture
      to: capture/mahana-capture
    - from: ../mahana-terminal/.build/release/MahanaTerminal
      to: terminal/MahanaTerminal
    - from: build-devices/launchd/
      to: launchd/
afterSign: scripts/notarize-devices.js
```

### `entitlements.mac.plist` (macOS TCC declarations)
```xml
<plist version="1.0">
<dict>
  <key>com.apple.security.device.audio-input</key>       <!-- mic -->
  <true/>
  <key>com.apple.security.device.camera</key>
  <true/>
  <key>com.apple.security.device.screen-recording</key>  <!-- ScreenCaptureKit -->
  <true/>
  <key>com.apple.security.automation.apple-events</key>  <!-- for pointer-brain -->
  <true/>
  <key>com.apple.security.cs.allow-jit</key>
  <true/>
</dict>
</plist>
```

### Notarize + staple
Reuses the existing pipeline from `837f7a2ad merge: feat/electron-dist-pipeline — DMG notarize/auto-update` commit. `scripts/notarize-devices.js` is a clone of the Desktop notarize script with:

- Different appId (`cc.mahana.devices` vs `cc.mahana.desktop`)
- Different update feed (`https://releases.mahana.ai/devices.json`)
- Extra `codesign --deep` pass for the bundled Rust + Swift binaries

**Verification before DONE** (per `build-vs-product.md`):

```bash
# Ship receipt
codesign --verify --deep --strict "Mahana Devices.app"
stapler validate "Mahana Devices.app"
spctl --assess --type execute "Mahana Devices.app"

# Product-rendered assertion (required per build-vs-product rule)
# — DMG mounted, app dragged to Applications, opened on a fresh account:
open "/Applications/Mahana Devices.app"
sleep 5
curl -f http://127.0.0.1:9878/health                   # must 200
curl -f 'http://127.0.0.1:9878/council?first-run=true' # must return council room HTML
# Human confirms: "I see a grey chat that says 'I'm here. What do you want to do?'"
```

Status stays `SHIPPED-TO-NOTARIZE-AWAITING-PRODUCT-VERIFICATION` until a human confirms the council renders on a device.

---

## Linux `curl | sh` pipeline

### The installer

```bash
# https://get.mahana.ai  (served by CF Worker, signed with cosign)
set -eu
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m | sed 's/aarch64/arm64/; s/x86_64/amd64/')
URL="https://releases.mahana.ai/devices/mahana-devices-${OS}-${ARCH}.tar.gz"
PUBKEY_URL="https://releases.mahana.ai/mahana-fleet.pub"

DEST="$HOME/.local/share/mahana-devices"
mkdir -p "$DEST" && cd "$DEST"

curl -fsSL -o devices.tar.gz "$URL"
curl -fsSL -o devices.tar.gz.sig "${URL}.sig"
curl -fsSL -o mahana-fleet.pub "$PUBKEY_URL"

# Verify signature (cosign required — fall back to explicit instructions if absent)
if command -v cosign >/dev/null 2>&1; then
  cosign verify-blob --key mahana-fleet.pub --signature devices.tar.gz.sig devices.tar.gz
else
  echo "Install 'cosign' or verify manually: https://docs.mahana.ai/devices/verify"
  exit 1
fi

tar -xzf devices.tar.gz

# Systemd --user units
install -d "$HOME/.config/systemd/user"
install -m 0644 systemd/*.service "$HOME/.config/systemd/user/"

# Shell alias
echo 'export PATH="$HOME/.local/share/mahana-devices/bin:$PATH"' >> "$HOME/.profile"
export PATH="$HOME/.local/share/mahana-devices/bin:$PATH"

# Start the fleet
systemctl --user daemon-reload
systemctl --user enable --now mahana-daemon.service
systemctl --user enable --now mahana-capture.service
systemctl --user enable --now mahana-substrate.service

# Wait for daemon
for i in 1 2 3 4 5; do
  curl -fsS http://127.0.0.1:9878/health >/dev/null && break
  sleep 1
done

echo ""
echo "Mahana is ready."
echo "Open http://localhost:9878/council in your browser."
```

### systemd --user units (example: `mahana-daemon.service`)
```ini
[Unit]
Description=Mahana Daemon (Service)
After=network.target

[Service]
ExecStart=%h/.local/share/mahana-devices/bin/mahana-daemon --daemon
Restart=always
RestartSec=5
StandardOutput=append:%h/.mahana/logs/daemon.log
StandardError=append:%h/.mahana/logs/daemon-stderr.log
Environment=MAHANA_BROWSER_BACKEND=puppeteer

[Install]
WantedBy=default.target
```

### Self-update
```bash
$ mahana update    # just re-runs https://get.mahana.ai | sh
```

Idempotent: the installer stops units, replaces binaries, re-starts. Version check (`mahana --version` vs release feed) means it's a no-op when already current.

---

## Council spawn on first run

The council room is a route, not a wizard. First-run simply opens a URL that triggers the council spawn.

### Route: `GET /council?first-run=true` (proposed, does not yet exist)

**Handler flow** (sketch — belongs in a new `src/main/automation/routes/council-routes.ts`):

```typescript
// Pseudocode — not to be implemented in this audit, spec only
async function handleCouncilFirstRun(req, res, ctx) {
  // 1. Check for existing council room for this user (anon JWT from Supabase SSR)
  const user = await authFromCookie(req)  // anon ok

  // 2. If no room, spawn default council:
  //    - room_id = uuid
  //    - agent = Grok 4.1 Fast (cheap, non-PRC per sovereignty-no-prc-hosts.md)
  //    - system prompt = COUNCIL_FIRST_RUN_PROMPT (hardcoded)
  //    - capabilities = [] (none granted yet — council asks)
  const room = await getOrCreateCouncilRoom(user)

  // 3. Render the grey chat UI from web/apps/mahana-mastermind-chat-core
  //    embedded in an iframe with ?room=<room_id>&first-run=true
  res.writeHead(200, { 'Content-Type': 'text/html' })
  res.end(renderCouncilShell({ roomId: room.id, firstRun: true }))
}
```

### The COUNCIL_FIRST_RUN_PROMPT (locked, product copy — do not alter without council vote)

```
You are the Mahana council opener. A new user just installed Mahana Devices.

Your first message (verbatim): "I'm here. What do you want to do?"

When they answer:
- If they mention voice / listening / speaking → check /devices/available for `microphone`.
  If not granted, POST /devices/request {capability:"microphone"} and wait for the TCC dialog result.
- If they mention screen / seeing → check for `screen-recording`. Same flow.
- If they mention camera / video → check for `camera`. Same flow.
- If they mention typing / writing / running code → check for `keyboard` and `terminal`.

Grant prompts happen inline in this chat. You explain what each permission does in one sentence before the system dialog pops.

Do not mention "daemon", "substrate", "session", "neuropacket", or any Mahana internals. Use the vocab from VOCABULARY.md.

If they deny a permission, offer a fallback (text-only, copy/paste, etc.) and move on. Do not re-ask.

Your role: make the first 60 seconds feel like meeting a helpful person, not configuring software.
```

---

## Hardware access as room capability, not system permission

The council flow above **re-frames macOS TCC / Linux hardware access as a property of the room**, not of the app.

### The model

- **Room** has `capabilities: string[]` (mic, camera, screen, keyboard, etc.)
- **Capability** = "this room's agents may call the tools associated with this capability"
- **Capability granting** = flows through `POST /devices/request {capability:"X", roomId:"..."}` which:
  1. Queries the OS-level TCC state (`systemPreferences.getMediaAccessStatus('microphone')`)
  2. If not granted, triggers `askForMediaAccess` → pops the system dialog
  3. On result, writes the grant to the room's `capabilities` array in Supabase
  4. Returns `{granted: true|false, fallback: "..."}` to the council agent

### Why this shape

- **User intuition**: "this room has microphone. that room doesn't." — natural. "my app has microphone access" — less natural.
- **Agent intuition**: the council agent knows which tools it can use by reading `room.capabilities`; if `mic` not in there, it can't call `/voice/call`.
- **Multi-device**: a room's capability might be granted on the iPhone but not the Mac. The room sees "we have microphone via iPhone" — agent knows to speak through the phone.
- **Revocation**: user revokes mic in Settings → TCC reports denied → `/devices/health` sweeps rooms → capability is removed → agents adapt.

### Table: macOS TCC grants + fallback paths

| Capability | macOS TCC key | Linux path | Fallback if denied |
|---|---|---|---|
| Microphone | `NSMicrophoneUsageDescription` | `pactl / PipeWire` | Text-only council (type-input) |
| Camera | `NSCameraUsageDescription` | `/dev/video0` + v4l2 | Screen-stream or phone-camera |
| Screen recording | `NSScreenCaptureUsageDescription` | `xwd` / Wayland `grim` | User-pasted screenshots |
| Keyboard simulation | `NSAppleEventsUsageDescription` + accessibility | `xdotool` / `ydotool` | HTTP-only (no physical type) |
| Speaker output | (no TCC) | ALSA | Text-only output |

**Info.plist** for macOS Devices.app must declare all five usage descriptions with human-readable strings:

```xml
<key>NSMicrophoneUsageDescription</key>
<string>Mahana listens when you ask it to, to understand what you need.</string>

<key>NSCameraUsageDescription</key>
<string>Mahana uses your camera when a room needs it — only when you invite it.</string>

<key>NSScreenCaptureUsageDescription</key>
<string>Mahana sees your screen when a room needs it — only when you invite it.</string>

<key>NSAppleEventsUsageDescription</key>
<string>Mahana types for you in other apps when you ask it to.</string>
```

**Denial is non-fatal.** The council agent says "no mic — that's fine, we can type" and moves on. No progress bar, no error page.

---

## Cross-device rooms

From CONSOLIDATION.md: rooms live as rows in Supabase with realtime subscribe. A user on iPhone opens the Capacitor app, authenticates (Supabase session flows through webview), and sees the same rooms the Mac is running.

### Wire format (proposed schema — does not yet exist, put it in NEXT-PHASE)

```sql
-- Supabase migration (proposed, NOT yet applied)
create table rooms (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id),
  name text not null default 'New room',
  capabilities text[] not null default '{}',
  created_at timestamptz not null default now(),
  last_active_at timestamptz not null default now()
);

create table room_messages (
  id uuid primary key default gen_random_uuid(),
  room_id uuid not null references rooms(id) on delete cascade,
  author_type text not null check (author_type in ('user','agent','system')),
  author_id text,
  body text not null,
  created_at timestamptz not null default now()
);

create table room_participants (
  id uuid primary key default gen_random_uuid(),
  room_id uuid not null references rooms(id) on delete cascade,
  agent_id text,              -- null for the user
  device_id text,             -- which device they're connecting from
  joined_at timestamptz not null default now()
);

alter table rooms enable row level security;
alter table room_messages enable row level security;
alter table room_participants enable row level security;

create policy "users see own rooms"
  on rooms for all
  using (user_id = auth.uid());

create policy "users see their rooms' messages"
  on room_messages for all
  using (exists (select 1 from rooms r where r.id = room_id and r.user_id = auth.uid()));
```

### Device roaming

- iPhone pulls out → Capacitor app opens → Supabase session loads → `SELECT * FROM rooms` → user sees "Council" + "Email draft" rooms
- iPhone joins "Email draft" room → inserts into `room_participants` (device_id = phone)
- Mac's council agent sees the phone joined → says "I see you're on your phone now. Camera's there if we need it."

**The protocol is just Supabase Realtime + the rooms tables.** No custom sync server. Already proven to work per `project_cloud_state_2026-04-14.md` memory (cloud relay online, gyz canonical).

---

## Versioning + release cadence

- **Version line**: `v6.x.y` for Mahana Devices. The ecosystem is on v6 per `docs/distribution-sprint/GROUND-TRUTH.md` (referenced in CLAUDE.md).
- **Channels**: `stable` (default), `beta` (curl-sh flag `--channel=beta`), `canary` (internal only).
- **Auto-update**: sparkle-rs polls `releases.mahana.ai/devices/<channel>.json` every 6h. Silent download, prompt on next council-room open.
- **Release discipline**: per `build-vs-product.md`, every release holds at `SHIPPED-TO-STAGING-AWAITING-PRODUCT-VERIFICATION` until Fredrik opens the DMG on a clean machine, opens the council room, speaks to it, grants mic, and confirms. Then stamp `RELEASE-GA`.

---

## What's out of scope (honest)

- **Windows**: macOS and Linux only for v6 Devices. Windows takes a separate sprint (mahana-capture Rust has no Windows target, mahana-terminal Swift is macOS-only).
- **Offline-first**: Devices assumes network for Supabase + CF. A true offline mode (local Whisper + local Kokoro + local sqlite) is NEXT-PHASE.
- **Council rooms for non-Fredrik users**: the auth flow today is Supabase email-linked to `fredrikgdanby@gmail.com`. A multi-tenant public release requires Clerk/Auth0 or a Supabase-magic-link signup flow. Out of this scope.
- **Mahana Desktop** (Electron full monolith): ships as a separate DMG (`cc.mahana.desktop`) from the same monorepo. Not this doc's problem.
