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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,43 @@ jobs:
- run: pnpm install --frozen-lockfile
# The pages are the repository's docs/ folder; a broken page fails here.
- run: pnpm build

mobile:
# Only what was asked for: see "on" above.
if: github.event_name != 'pull_request' || (github.event.action != 'labeled' || github.event.label.name == 'run-ci') && contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ${{ (vars.SELF_HOSTED == 'true' && github.event_name == 'push' && github.actor == github.repository_owner) && fromJSON('["self-hosted","omarchy"]') || 'ubuntu-latest' }}
defaults:
run:
working-directory: mobile
steps:
# omarchy runs only the owner's pushes; its job-started hook
# (ci/runner/job-started.sh) refuses anything else too.
- name: Only the owner's pushes run on a self-hosted runner
if: runner.environment == 'self-hosted'
working-directory: ${{ runner.temp }}
env:
ACTOR: ${{ github.actor }}
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
OWNER: ${{ github.repository_owner }}
EVENT: ${{ github.event_name }}
run: |
if [ "$ACTOR" != "$OWNER" ] || [ "$TRIGGERING_ACTOR" != "$OWNER" ] || [ "$EVENT" != push ]; then
echo "::error::$EVENT by $ACTOR may not run on a self-hosted runner"
exit 1
fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# A self-hosted runner keeps its pnpm store between jobs.
cache: ${{ runner.environment == 'github-hosted' && 'pnpm' || '' }}
cache-dependency-path: mobile/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
# The phone app (mobile/AGENTS.md). No iOS build here: EAS builds
# it. Metro's bundle checks that packages/core resolves.
- run: pnpm typecheck
- run: pnpm test
- run: pnpm export
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Shipyard (formerly Berth) runs coding agents on your own machines. This repo has
- `cmd/berth`, `internal/agent`: the laptop CLI and agent (UI API on 1378, proxy on 1377, phone on 1379).
- `app/`: the Tauri 2 + React desktop app (coss-ui, 23 themes). `app/e2e` holds the Playwright suite on mock fixtures.
- `packages/core`: plain TypeScript shared by `app/` and the phone app (session state, screen and transcript reading, pairing links). Only relative `./x.ts` imports and the plugin SDK's types; no DOM (`check:core`). `app/src/lib/<name>.ts` re-exports it.
- `mobile/`: the phone app (Expo, iOS first), which talks to boxes' phone API over the tailnet. Its rules are in `mobile/AGENTS.md`; check it with `cd mobile && pnpm typecheck && pnpm test && pnpm export`.
- `docs/`: the docs as MDX, built by `docs-site/` (Fumadocs). `site/` is the landing page and `install.sh`.

**Names:** "Shipyard" is the brand in everything people read. `berth`, `berthd`, `~/.berth`, `BERTH_*`, `berth://`, `dev.berth.app`, `berth.prefs` and the `berth-*` skill ids are identifiers. Don't rename them; a later change will, with migrations.
Expand Down
19 changes: 19 additions & 0 deletions app/e2e/phone-apps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, mockOnly, test } from "./fixtures";

// Settings › Phone › Phone apps: the Shipyard phone apps a box notifies
// through the push relay, and removing one.

test("a phone that gets notifications is listed with its box, and Remove stops them", async ({ app }) => {
mockOnly("turning phone access on writes to a box");
await app.open();
const phone = await app.openSettings("phone");
const apps = phone.locator("section").filter({ has: app.page.getByRole("heading", { name: "Phone apps", exact: true }) });
await expect(apps).toContainText("No phone gets notifications yet");

await phone.getByRole("switch", { name: "Phone access on devl" }).click();
await expect(apps).toContainText("Acme iPhone");
await expect(apps).toContainText("devl · needs you");

await apps.getByRole("button", { name: "Remove" }).click();
await expect(apps).toContainText("No phone gets notifications yet");
});
5 changes: 5 additions & 0 deletions app/src/lib/mock-phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export function phoneCall(box: string, method: string, path: string, body: unkno
const c = (body ?? {}) as PhoneChange;
if (c.enabled !== undefined) st.enabled = c.enabled;
if ((st.enabled && !st.token) || c.rotate) st.token = token();
// A new code, or access off, forgets the phones; the demo's one phone
// comes back with access on, as it would after pairing again.
if (c.rotate || !st.enabled) st.push = undefined;
else if (c.enabled) st.push = [{ id: "0123456789abcdef0123456789abcdef", name: "Acme iPhone", platform: "ios", added: new Date(Date.now() - 86_400_000).toISOString(), last_ok: new Date(Date.now() - 600_000).toISOString() }];
if (c.remove_push) st.push = st.push?.filter((d) => d.id !== c.remove_push);
if (c.notify) st.notify = c.notify;
if (c.clear_notify) st.notify = undefined;
st.url = st.enabled ? `http://${addr[box] ?? "100.64.0.1"}:1379/` : undefined;
Expand Down
17 changes: 17 additions & 0 deletions app/src/lib/phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,25 @@ export interface PhoneNotify {
on?: ("waiting" | "finished")[];
}

// A Shipyard phone app the box notifies through the push relay
// (internal/box/phonev2.go): never its key, which stays on the box.
export interface PhonePush {
id: string;
name?: string;
platform: "ios" | "android";
on?: ("waiting" | "finished")[];
added: string;
last_ok?: string;
last_error?: string;
}

export interface PhoneStatus {
enabled: boolean;
token?: string;
notify?: PhoneNotify;
push?: PhonePush[];
// The push relay, when not the default.
relay?: string;
url?: string;
error?: string;
}
Expand All @@ -22,6 +37,8 @@ export interface PhoneChange {
rotate?: boolean;
notify?: PhoneNotify;
clear_notify?: boolean;
// Stops notifying one phone app, by its id.
remove_push?: string;
}

export const phoneApi = {
Expand Down
73 changes: 67 additions & 6 deletions app/src/views/settings/phone-section.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CopyIcon, EllipsisIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react";
import { BellIcon, CopyIcon, EllipsisIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react";
import QRCode from "qrcode";
import { useCallback, useEffect, useMemo, useState } from "react";

Expand All @@ -7,16 +7,17 @@ import { Input } from "@/components/ui/input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
import { Switch } from "@/components/ui/switch";
import { toastManager } from "@/components/ui/toast";
import { errorMessage } from "@/lib/format";
import { ago, errorMessage } from "@/lib/format";
import { BoxError } from "@/components/upgrade-box";
import { newNtfyTopic, type PairedBox, pairingLink, phoneApi, type PhoneStatus } from "@/lib/phone";
import { newNtfyTopic, type PairedBox, pairingLink, phoneApi, type PhonePush, type PhoneStatus } from "@/lib/phone";
import { NONE, useStore } from "@/lib/store";
import { cn } from "@/lib/utils";
import { ConfirmDialog } from "@/views/settings/confirm";
import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows";

// Settings → Phone: turn on each box's phone app, pair a phone with one QR
// code, and send "needs you" notifications through ntfy.
// code, see the Shipyard phone apps each box notifies, and send "needs
// you" notifications through ntfy.
export function PhoneSection() {
const client = useStore((s) => s.client);
const boxes = useStore((s) => s.status?.boxes ?? NONE);
Expand Down Expand Up @@ -112,6 +113,8 @@ export function PhoneSection() {
<PairCode link={link} count={paired.length} />
</SettingsGroup>

<PhoneApps boxes={paired.map((p) => p.name)} phone={phone} onChange={change} />

<Notifications boxes={paired.map((p) => p.name)} phone={phone} onChange={change} />

<ConfirmDialog
Expand Down Expand Up @@ -153,7 +156,7 @@ function PairCode({ link, count }: { link?: string; count: number }) {
</p>
<ol className="list-decimal space-y-1 pl-4 text-muted-foreground">
<li>Your phone needs the Tailscale app, signed in to the same tailnet as the boxes.</li>
<li>Scan the code, then add the page to your home screen (Share → Add to Home Screen).</li>
<li>Scan the code in the Shipyard app on iPhone. Or scan it with the camera, then add the page to your home screen (Share → Add to Home Screen).</li>
<li>Anyone with this code can answer your agents. Make a new code if it leaks.</li>
</ol>
<div>
Expand Down Expand Up @@ -202,7 +205,7 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec
title="Notifications"
description={
<>
Boxes push to an <Code>ntfy</Code> topic when an agent needs you, and the ntfy app shows it on iOS and Android. Tapping it opens that agent here.
For the web phone app and Android: boxes push to an <Code>ntfy</Code> topic when an agent needs you, and the ntfy app shows it. Tapping it opens that agent in the web app. The Shipyard app on iPhone needs none of this.
</>
}
>
Expand Down Expand Up @@ -238,3 +241,61 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec
</SettingsGroup>
);
}

type PhoneState = PhoneStatus | { error: string; unsupported: true };

// PhoneApps lists the Shipyard phone apps the boxes notify, one row per
// phone with the boxes that notify it. A phone turns this on from its
// own Boxes tab; removing it here stops every box at once.
function PhoneApps({ boxes, phone, onChange }: { boxes: string[]; phone: Record<string, PhoneState>; onChange: (box: string, c: Parameters<typeof phoneApi.change>[2]) => Promise<void> }) {
const apps = new Map<string, { push: PhonePush; boxes: string[] }>();
for (const b of boxes) {
const st = phone[b];
for (const d of (st && "push" in st && st.push) || []) {
const app = apps.get(d.id);
if (app) {
app.boxes.push(b);
if ((d.last_ok ?? "") > (app.push.last_ok ?? "")) app.push = d;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This aggregation can hide a box's push failure: a record with last_error is discarded whenever another box has a newer last_ok, so the grouped row appears healthy. Preserve errors when selecting the representative status, or report failures per box.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/views/settings/phone-section.tsx, line 258:

<comment>This aggregation can hide a box's push failure: a record with `last_error` is discarded whenever another box has a newer `last_ok`, so the grouped row appears healthy. Preserve errors when selecting the representative status, or report failures per box.</comment>

<file context>
@@ -238,3 +241,61 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec
+      const app = apps.get(d.id);
+      if (app) {
+        app.boxes.push(b);
+        if ((d.last_ok ?? "") > (app.push.last_ok ?? "")) app.push = d;
+      } else apps.set(d.id, { push: d, boxes: [b] });
+    }
</file context>
Suggested change
if ((d.last_ok ?? "") > (app.push.last_ok ?? "")) app.push = d;
if (d.last_error || (!app.push.last_error && (d.last_ok ?? "") > (app.push.last_ok ?? ""))) app.push = d;

} else apps.set(d.id, { push: d, boxes: [b] });
}
}
const ntfyToo = boxes.some((b) => {
const st = phone[b];
return st && "notify" in st && !!st.notify;
});
return (
<SettingsGroup title="Phone apps" description="The Shipyard app on these phones gets notifications through Shipyard's push relay: which worktree and box need you, never what an agent wrote.">
{apps.size === 0 ? (
<div className="flex items-center gap-3 py-5 text-muted-foreground text-body">
<BellIcon className="size-5 shrink-0" />
No phone gets notifications yet. In the Shipyard app, open Boxes and turn notifications on.
</div>
) : (
[...apps.values()].map(({ push, boxes: from }) => (
<SettingsRow
key={push.id}
label={push.name || (push.platform === "ios" ? "iPhone" : "Android phone")}
description={
push.last_error ? (
<span className="text-destructive">{push.last_error}</span>
) : (
`${from.join(", ")} · ${(push.on ?? ["waiting"]).includes("finished") ? "needs you and done" : "needs you"}${push.last_ok ? ` · last sent ${ago(push.last_ok)}` : ""}`
)
}
>
<Button

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Every row's Remove button has the same accessible name, so screen-reader users cannot tell which phone each one removes. Add an aria-label that includes the phone's name, following the Remove ${...} pattern used in box-routes.tsx.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/views/settings/phone-section.tsx, line 286:

<comment>Every row's Remove button has the same accessible name, so screen-reader users cannot tell which phone each one removes. Add an aria-label that includes the phone's name, following the `Remove ${...}` pattern used in box-routes.tsx.</comment>

<file context>
@@ -238,3 +241,61 @@ function Notifications({ boxes, phone, onChange }: { boxes: string[]; phone: Rec
+              )
+            }
+          >
+            <Button
+              size="xs"
+              variant="ghost"
</file context>

size="xs"
variant="ghost"
onClick={async () => {
for (const b of from) await onChange(b, { remove_push: push.id });
}}
>
Remove
</Button>
</SettingsRow>
))
)}
{apps.size > 0 && ntfyToo && <p className="py-3 text-muted-foreground text-meta">ntfy is on too, so these phones may get each notification twice. Turn ntfy off below if they only use the app.</p>}
</SettingsGroup>
);
}
6 changes: 6 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ upgrades with `berth upgrade BOX`, or by running the
again.

## Unreleased
- **Shipyard for iPhone.** A phone app that pairs with the same code as the
web phone app (Settings → Phone) and shows which agents need you across
your boxes: a session's conversation or its screen, its answers as
buttons, Stop, and a prompt to send; runs and their gates. It gets
notifications without ntfy, and Settings → Phone lists the phones that do,
to remove one. **Try the demo** works without a box.
- **Worktrees fits a narrow window.** With the sidebar open on a small
window, the table and its filters ran off the right edge. Now the Port
column steps aside first and "vs base" after it (both are in a
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/phone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
when an agent waits for you (and, if you switch it on, when one finishes its
turn), and tapping it opens that agent in the phone app.

## The Shipyard app for iPhone

The Shipyard app does what the web app does, and more:

- **Pair** by scanning the same code in the app (Pair), or pasting its link.
- **Needs you** lists every paired box's agents, waiting first, and the runs
waiting at a gate. Boxes with an up-to-date berthd say what changes as it
happens; older ones are read every few seconds.
- **A session** shows its conversation (**Chat**) or its terminal (**Screen**),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Chat is available only when the box advertises transcript support; otherwise the app forces the session to Screen. Qualify this description so users with older boxes know they will not see the conversation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 38:

<comment>Chat is available only when the box advertises transcript support; otherwise the app forces the session to Screen. Qualify this description so users with older boxes know they will not see the conversation.</comment>

<file context>
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
+- **Needs you** lists every paired box's agents, waiting first, and the runs
+  waiting at a gate. Boxes with an up-to-date berthd say what changes as it
+  happens; older ones are read every few seconds.
+- **A session** shows its conversation (**Chat**) or its terminal (**Screen**),
+  the answers it offers as buttons (a permission reads as Allow, Always allow
+  or Deny), Enter, Stop, and a box for a prompt.
</file context>
Suggested change
- **A session** shows its conversation (**Chat**) or its terminal (**Screen**),
- **A session** shows its conversation (**Chat**, when its box supports transcripts) or its terminal (**Screen**),

the answers it offers as buttons (a permission reads as Allow, Always allow
or Deny), Enter, Stop, and a box for a prompt.
- **Runs** shows a run's gate, to approve, reject or pick an attempt, and its
steps.
- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This bullet says notifications already work without ntfy, but the 'What works where' section still calls notifications without ntfy a future step that needs HTTPS Web Push. Reconcile the two so the page does not say both.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 43:

<comment>This bullet says notifications already work without ntfy, but the 'What works where' section still calls notifications without ntfy a future step that needs HTTPS Web Push. Reconcile the two so the page does not say both.</comment>

<file context>
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
+  or Deny), Enter, Stop, and a box for a prompt.
+- **Runs** shows a run's gate, to approve, reject or pick an attempt, and its
+  steps.
+- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs
+  you and done). Each box then notifies the phone through Shipyard's push
+  relay (below), and a notification opens its agent or run. Settings → Phone
</file context>

you and done). Each box then notifies the phone through Shipyard's push

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Boxes without push support cannot send these notifications; the app skips their registration and warns that older berthd versions need updating. Limit this statement to boxes with push support or mention the required update.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 44:

<comment>Boxes without push support cannot send these notifications; the app skips their registration and warns that older berthd versions need updating. Limit this statement to boxes with push support or mention the required update.</comment>

<file context>
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
+- **Runs** shows a run's gate, to approve, reject or pick an attempt, and its
+  steps.
+- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs
+  you and done). Each box then notifies the phone through Shipyard's push
+  relay (below), and a notification opens its agent or run. Settings → Phone
+  on the laptop lists the phones that get them, and removes one.
</file context>
Suggested change
you and done). Each box then notifies the phone through Shipyard's push
you and done). Boxes with push support notify the phone through Shipyard's push

relay (below), and a notification opens its agent or run. Settings → Phone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: (below) points to nothing: this page never describes Shipyard's push relay. Either add a short section on how the relay works, or drop "(below)" and name what the relay sends.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 45:

<comment>`(below)` points to nothing: this page never describes Shipyard's push relay. Either add a short section on how the relay works, or drop "(below)" and name what the relay sends.</comment>

<file context>
@@ -27,6 +27,27 @@ the ntfy app. Boxes then push "Claude Code needs you · shop/checkout on devl"
+  steps.
+- **Notifications** come without ntfy: in Boxes, choose Needs you (or Needs
+  you and done). Each box then notifies the phone through Shipyard's push
+  relay (below), and a notification opens its agent or run. Settings → Phone
+  on the laptop lists the phones that get them, and removes one.
+
</file context>
Suggested change
relay (below), and a notification opens its agent or run. Settings → Phone
relay, and a notification opens its agent or run. Settings → Phone

on the laptop lists the phones that get them, and removes one.

The app keeps each box's token in the iPhone's keychain, on that phone only.
**Try the demo** on its Pair screen shows made-up boxes, without one.

## How it works

Each box serves the phone app itself, on its tailnet address, port 1379.
Expand Down
44 changes: 44 additions & 0 deletions mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

# generated native folders
/ios
/android

# expo export (pnpm export)
.expo-export/
18 changes: 18 additions & 0 deletions mobile/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Working on the phone app

Shipyard for iPhone (Android later, from this code): Expo SDK 58, React Native, expo-router. The repository's own `AGENTS.md` applies too.

- **Expo changes every SDK.** Before writing code against an Expo or React Native API, read the docs for the SDK in `package.json` (`https://docs.expo.dev/versions/v58.0.0/`, and `https://docs.expo.dev/llms.txt`), not memory.
- **Add native modules with `npx expo install <pkg>`**, which picks SDK-matched versions. A module with native code needs a new development build (`eas build --profile development`); Expo Go can't load it, nor receive remote notifications.
- **No `ios/` or `android/`.** They are generated (Continuous Native Generation). Configure native behaviour in `app.config.ts` and config plugins.
- **Routes live in `src/app/`**; everything else in `src/lib` (no React) and `src/components`.
- **Shared code is `packages/core`**, imported as `@berth/core/<file>.ts` (tsconfig paths, Metro's `watchFolders`, Jest's `moduleNameMapper`). Code both apps need goes there, by its rules (`packages/core/README.md`).
- **Boxes are the backend**: each box's phone API on its tailnet address, port 1379 (`docs/reference/phone-api.mdx`, `internal/box/phone.go`, `phonev2.go`). Notifications go through the push relay (`push/`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This bullet points at files that don't exist on this branch: docs/reference/phone-api.mdx, phonev2.go, and push/. Agents following mobile/AGENTS.md will hit dead references. Confirm they land with #207/#208 before merge, or drop the references until they do.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At mobile/AGENTS.md, line 10:

<comment>This bullet points at files that don't exist on this branch: `docs/reference/phone-api.mdx`, `phonev2.go`, and `push/`. Agents following `mobile/AGENTS.md` will hit dead references. Confirm they land with #207/#208 before merge, or drop the references until they do.</comment>

<file context>
@@ -0,0 +1,18 @@
+- **No `ios/` or `android/`.** They are generated (Continuous Native Generation). Configure native behaviour in `app.config.ts` and config plugins.
+- **Routes live in `src/app/`**; everything else in `src/lib` (no React) and `src/components`.
+- **Shared code is `packages/core`**, imported as `@berth/core/<file>.ts` (tsconfig paths, Metro's `watchFolders`, Jest's `moduleNameMapper`). Code both apps need goes there, by its rules (`packages/core/README.md`).
+- **Boxes are the backend**: each box's phone API on its tailnet address, port 1379 (`docs/reference/phone-api.mdx`, `internal/box/phone.go`, `phonev2.go`). Notifications go through the push relay (`push/`).
+- **The demo** (`src/lib/mock.ts`, "Try the demo") is what App Review sees: keep it working, with acme's made-up data.
+
</file context>

- **The demo** (`src/lib/mock.ts`, "Try the demo") is what App Review sees: keep it working, with acme's made-up data.

## Checks

```sh
CI=true pnpm install --frozen-lockfile
pnpm typecheck && pnpm test && pnpm export # export: Metro bundles the iOS app, no Xcode needed
```
51 changes: 51 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Shipyard for iPhone

Which agents need you across your boxes, and answering them, from your phone.
It talks to each box over your tailnet, on the box's phone API, and gets
notifications through Shipyard's push relay. The guide for people is
[docs/guides/phone.mdx](../docs/guides/phone.mdx).

## Running it

You need an iPhone with Tailscale, signed in to your boxes' tailnet, and an
[Expo](https://expo.dev) account. No Mac is needed: EAS builds in the cloud.

```sh
CI=true pnpm install --frozen-lockfile
npx eas-cli@latest build --profile development --platform ios # once, and after adding a native module
pnpm start # then open the build on the phone
```

The phone reaches Metro over the tailnet: start it with
`REACT_NATIVE_PACKAGER_HOSTNAME=<this machine's tailnet address>`.

On a box, turn phone access on in Shipyard → Settings → Phone and scan the
code from the app. **Try the demo** shows made-up boxes without one.

## What's where

| | |
| --- | --- |
| `src/app/` | The screens (expo-router): Needs you, Runs, Boxes, a session, a run, Pair |
| `src/lib/api.ts` | The box's phone API |
| `src/lib/events.ts` | Following a box's events as they happen |
| `src/lib/store.ts` | Boxes, their sessions and runs, conversations |
| `src/lib/push.ts` | Notifications: the relay's key, registering with boxes |
| `src/lib/mock.ts` | The demo |
| `../packages/core` | What the desktop app and this one share |

## Builds

`eas.json` has three profiles: `development` (a development client, APNs
sandbox), `preview` (TestFlight) and `production` (the App Store, APNs
production). The push relay must hold the same team's APNs key
(`push/README.md`), with `APNS_TOPIC=dev.berth.phone`.

## Checks

```sh
pnpm typecheck && pnpm test && pnpm export
```

`pnpm export` bundles the iOS app with Metro (no Xcode), which is how CI
checks that imports from `packages/core` resolve.
Loading