Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ docker-compose.yml
**/.dev.vars
**/.secrets.local
**/.wrangler
apps/mobile
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ jobs:
working-directory: packages/core
run: bun run scripts/build.ts --smoke

mobile:
name: Phone app (typecheck)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: apps/mobile
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- run: bun install --frozen-lockfile
- run: bun run typecheck

desktop-shell:
name: Desktop shell (${{ matrix.os }})
runs-on: ${{ matrix.os }}
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ needs to be useful:
| 💾 **Backup & restore** | Encrypted `.godmode-backup` archives of your whole setup, including agent repositories. |
| 🩺 **Diagnostic log** | Errors, slow spots and how every run went, with secrets masked. Settings → Logs groups recurring problems and copies an AI-ready report — paste it into Claude to find bugs and speed things up. |
| 🖥️ **Desktop + dashboard** | Native app for macOS, Windows and Linux — or run headless on any device and use the web dashboard. |
| 📱 **Phone app** | Control Godmode from your iPhone or Android phone: hand over tasks, follow answers as they're written, stop runs, run automations and watch an agent's browser, shared screen or VM live — tap to take control. Pair once by scanning a QR code; the phone talks to your computer over [Tailscale](https://tailscale.com), never the open internet. Liquid Glass on iOS 26. |

<img src="docs/screenshots/phone.png" alt="The Godmode phone app: what agents are doing now, a chat with a live browser strip, taking control of the agent's browser, and a finished answer" width="100%" />

<sub><b>Phone app</b> — what your agents are doing right now, a chat that streams as it's written, the agent's browser live (tap to take control), and the answer.</sub>

<table>
<tr>
Expand Down Expand Up @@ -121,6 +126,10 @@ needs to be useful:
<td><img src="docs/screenshots/workspace-sources.png" alt="Folders and git repositories attached to a workspace" /><br /><sub><b>Workspace folders & repos</b> — every agent in the workspace works with them</sub></td>
<td><img src="docs/screenshots/workspace-add-repo.png" alt="Add a git repository to a workspace" /><br /><sub><b>Add a repository</b> — paste a URL, Godmode clones it and keeps it up to date</sub></td>
</tr>
<tr>
<td><img src="docs/screenshots/phone-pairing.png" alt="Connect a phone by scanning a one-time QR code" /><br /><sub><b>Connect a phone</b> — scan a one-time code; the phone gets its own key</sub></td>
<td><img src="docs/screenshots/phone-settings.png" alt="Phone settings: Tailscale status, phone access and paired phones" /><br /><sub><b>Phone settings</b> — Tailscale status, paired phones, remove one anytime</sub></td>
</tr>
<tr>
<td><img src="docs/screenshots/messaging.png" alt="Slack, Telegram and Teams bots connected to agents" /><br /><sub><b>Messaging</b> — talk to agents from Slack, Telegram and Microsoft Teams</sub></td>
<td><img src="docs/screenshots/messaging-bot.png" alt="A bot's access requests, agents and who may use it" /><br /><sub><b>Bot settings</b> — approve people, pick agents, see every chat</sub></td>
Expand Down Expand Up @@ -190,6 +199,17 @@ docker compose up -d # dashboard on http://localhost:7777

> Put the dashboard behind TLS (Caddy, Traefik, Tailscale…) when you expose it beyond your machine.

### Phone app (iOS and Android)

1. Install [Tailscale](https://tailscale.com/download) on the computer that runs Godmode and on your phone, and sign in
with the same account.
2. In Godmode open **Settings → Phone → Connect a phone**.
3. Open the Godmode app and tap **Scan QR code** (or point the camera app at the code).

The code works once and for five minutes; the phone gets its own key, and you can remove it anytime in the same place.
Godmode only listens for phones on the computer's Tailscale address. The app lives in [`apps/mobile`](apps/mobile)
(Expo) — build it with `bun run ios` / `bun run android` or EAS until it's in the stores.

## ⚡ Quick start

1. **Onboarding** — pick a vault passphrase (enable *Remember on this device* so routines run unattended).
Expand Down Expand Up @@ -328,6 +348,11 @@ Every run is committed, so you can see exactly what an agent learned and did —
rate-limited logins, HttpOnly/SameSite cookies for the dashboard.
- **Remember this device** keeps the data key in the OS keychain so routines run unattended — disable it to require
your passphrase after every restart.
- **Phones** pair with a one-time, five-minute QR code and get their own key (stored hashed on the computer, in the
Keychain / Keystore on the phone). Their key only works on the computer's Tailscale address, is only sent to an
address that proves it's your Godmode, and opens a fixed set of routes: no logins, 2FA codes, backups, integrations,
settings or folders, and only screens shared in a chat can be controlled. A paired phone can still ask agents to act
on your computer — remove a lost phone in Settings → Phone, and turn on *Require Face ID* in the app.

> ⚠️ Agents run Claude Code with **bypass permissions** by default. Treat them like a trusted coworker with access to
> your machine; for sensitive work give the agent its own **macOS VM** (see above), or run Godmode in a VM or
Expand All @@ -343,7 +368,8 @@ pnpm typecheck && pnpm test
pnpm build:app # desktop bundles
```

Monorepo: `packages/shared` (types) · `packages/core` (daemon) · `apps/desktop` (UI + Tauri).
Monorepo: `packages/shared` (types) · `packages/core` (daemon) · `apps/desktop` (UI + Tauri) · `apps/mobile` (phone
app, Expo — its own toolchain, see [apps/mobile/README.md](apps/mobile/README.md)).
See [CONTRIBUTING.md](CONTRIBUTING.md).

## 🗺️ Roadmap
Expand All @@ -359,7 +385,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
- [x] API tools: any API with a key (Nano Banana, OpenAI, ElevenLabs…) for agents, global / workspace / agent
- [x] SSH servers: agents run commands, edit files and copy files on remote machines — password or key, sudo, pinned host keys
- [ ] Windows / Linux VMs
- [ ] Mobile companion app & push notifications
- [x] Phone app (iOS / Android): chats, runs, automations, live browser, screen and VM views, paired over Tailscale
- [ ] Push notifications and a hosted gateway for phones without Tailscale
- [ ] Team mode: shared workspaces and approvals

## 🙏 Credits
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0",
"uqr": "^0.1",
"zod": "^4.6.5",
"zustand": "^5"
},
Expand Down
194 changes: 194 additions & 0 deletions apps/desktop/src/components/settings/pair-phone-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { ExternalLink, RefreshCw, Smartphone } from "lucide-react";
import type { MobileDevice, MobilePairingOffer } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { DrawCheck } from "@/components/aicss/Motion";
import { api, errorMessage } from "@/lib/api";
import { openExternal } from "@/lib/desktop";
import { qk } from "@/lib/queryKeys";
import { onServerEvent } from "@/lib/realtime";
import { useNow } from "@/components/vault/use-now";
import { cn } from "@/lib/utils";
import { QrCode } from "./qr-code";

export const TAILSCALE_DOWNLOAD = "https://tailscale.com/download";

export function PairPhoneDialog({ open, onOpenChange, tailnet }: { open: boolean; onOpenChange: (open: boolean) => void; tailnet: string | null }) {
const qc = useQueryClient();
const [offer, setOffer] = useState<MobilePairingOffer | null>(null);
const [paired, setPaired] = useState<MobileDevice | null>(null);
const now = useNow(1000);

const create = useMutation({
mutationFn: api.mobile.pairing,
onSuccess: (o) => {
setOffer(o);
void qc.invalidateQueries({ queryKey: qk.mobile });
},
});

useEffect(() => {
if (!open) return;
setPaired(null);
setOffer(null);
create.mutate();
const off = onServerEvent((e) => {
if (e.type === "mobile.paired") setPaired(e.device);
});
return () => {
off();
void api.mobile.cancelPairing().catch(() => undefined);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);

const remaining = offer ? Math.max(0, Math.round((new Date(offer.expiresAt).getTime() - now) / 1000)) : 0;
const expired = !!offer && remaining === 0;

useEffect(() => {
if (open && expired && !paired && !create.isPending) create.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, expired, paired]);

const address = offer?.urls[0]?.replace(/^https?:\/\//, "");

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="border-b px-6 pt-6 pb-5 text-left">
<DialogTitle className="text-lg font-medium tracking-[-0.02em]">Connect your phone</DialogTitle>
<DialogDescription>Scan the code with the Godmode app. It works once and only for a few minutes.</DialogDescription>
</DialogHeader>

<AnimatePresence mode="wait" initial={false}>
{paired ? (
<motion.div
key="paired"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center px-6 pt-10 pb-8 text-center"
>
<div className="grid size-14 place-items-center rounded-full bg-brand-soft text-brand-strong">
<DrawCheck className="size-7" />
</div>
<h3 className="mt-5 text-lg font-medium tracking-[-0.02em]">{paired.name} is connected</h3>
<p className="mt-1.5 max-w-sm text-sm text-muted-foreground">
Chats, agents and live screens are on your phone now. You can remove it anytime in this list.
</p>
<Button className="mt-7" onClick={() => onOpenChange(false)}>
Done
</Button>
</motion.div>
) : (
<motion.div key="code" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="grid gap-6 px-6 py-6 sm:grid-cols-[256px_1fr]">
<div className="flex flex-col items-center gap-3">
<div className="relative grid size-64 place-items-center rounded-2xl border bg-white p-3 shadow-card">
{offer && !create.isError ? (
<QrCode value={offer.link} label="Pairing code for the Godmode app" className={cn("size-full transition-opacity", create.isPending && "opacity-30")} />
) : create.isError ? (
<div className="px-4 text-center">
<p className="text-sm font-medium text-[#1c1c1c]">No code yet</p>
<p className="mt-1 text-xs leading-relaxed text-[#75716a]">{errorMessage(create.error)}</p>
</div>
) : (
<Spinner className="text-[#75716a]" />
)}
</div>
{offer && !create.isError ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<ExpiryRing seconds={remaining} total={300} />
Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}
<Button variant="ghost" size="xs" className="-mr-2 text-muted-foreground" onClick={() => create.mutate()} disabled={create.isPending}>
<RefreshCw /> New code
</Button>
</div>
) : create.isError ? (
<Button variant="outline" size="sm" onClick={() => create.mutate()}>
<RefreshCw /> Try again
</Button>
) : null}
</div>

<div className="flex min-w-0 flex-col">
<ol className="space-y-4">
<Step n={1} title="Get Tailscale on your phone">
Sign in with the same account as this computer{tailnet ? <> ({tailnet})</> : null}. Your phone reaches Godmode through it, never
over the internet.{" "}
<button type="button" className="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline" onClick={() => void openExternal(TAILSCALE_DOWNLOAD)}>
Download <ExternalLink className="size-3" />
</button>
</Step>
<Step n={2} title="Open the Godmode app">
Tap <span className="font-medium text-foreground">Scan QR code</span>, or point your camera app at the code.
</Step>
<Step n={3} title="Scan this code">
The phone gets its own key. This code can't be used again.
</Step>
</ol>
<div className="mt-auto flex items-center gap-2.5 rounded-lg border bg-paper-2 px-3 py-2.5 text-xs text-muted-foreground">
<Smartphone className="size-4 shrink-0 text-foreground" />
<span className="min-w-0">
Waiting for your phone
{address ? (
<>
{" "}
on <span className="font-mono text-[11px] text-foreground">{address}</span>
</>
) : null}
</span>
<span className="ml-auto flex gap-1" aria-hidden>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="size-1 rounded-full bg-muted-foreground"
animate={{ opacity: [0.25, 1, 0.25] }}
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.2 }}
/>
))}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</DialogContent>
</Dialog>
);
}

function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<li className="flex gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full border bg-card text-xs font-medium tabular-nums shadow-card">{n}</span>
<div className="min-w-0 pt-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{children}</p>
</div>
</li>
);
}

function ExpiryRing({ seconds, total }: { seconds: number; total: number }) {
const r = 6;
const c = 2 * Math.PI * r;
return (
<svg viewBox="0 0 16 16" className="size-3.5 -rotate-90" aria-hidden>
<circle cx="8" cy="8" r={r} fill="none" className="stroke-border" strokeWidth="2" />
<circle
cx="8"
cy="8"
r={r}
fill="none"
className="stroke-brand transition-[stroke-dashoffset] duration-1000 ease-linear"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - Math.min(1, seconds / total))}
/>
</svg>
);
}
Loading
Loading