--- name: tlon-workflow description: Use when taking a Tlon Messenger task from a fresh worktree to a merged pull request: reproducing or fixing something in the iOS, Android or web app, validating it on a simulator, emulator or browser, opening the PR with evidence, and following its review. --- # Tlon workflow One task, one worktree, one pull request, across three platforms: iOS, Android and web. The iOS and Android devices are EAS Simulator sessions, not simulators or emulators on this machine. Read Stim's guide once per session: ```bash stim guide agent ``` Everything below is written from the **repository root**. `apps/tlon-mobile` is the app directory Stim wants, so every `stim` command runs from there -- including the ones for web and Cosmos ports. Give this skill's scripts an absolute path. ## Before anything Unsandboxed (sandboxed, `gh auth status` cannot reach the keyring and reports a false `not authenticated`): ```bash node .agents/skills/tlon-workflow-doctor/check.mjs ``` If any line says `fix`, use the tlon-workflow-doctor skill and come back. Do not work around a missing tool. ## The loop ### 1. A fresh worktree First, what already exists for this ticket: `gh pr list --search --state all` and the links on the ticket itself. A closed pull request from an earlier run: note its branch name so yours differs, read its diagnosis only after you have reached your own, verify what you take from it, and do not cite it in your description. ```bash git fetch --prune origin git ls-remote --heads origin / # empty, or pick another name git worktree add -b / .worktrees/ origin/develop cd .worktrees//apps/tlon-mobile stim worktree warm --refresh ``` The default branch is `develop`; every branch starts there and every PR targets it. Branches are named `/`; take the handle from the existing branches (`git branch -r | grep -o '^ origin/[^/]*/' | sort | uniq -c`), not from your GitHub login. A name that already exists on origin cannot be pushed without force; pick another. `.worktrees/` is gitignored at any depth. `git worktree add` needs an unsandboxed shell. Sandboxed it half-fails: no worktree, but the branch is created, so the retry stops with `a branch named '<...>' already exists`. Delete the branch before retrying. `warm` carries `node_modules`, `ios/Pods`, both `.env.local` files, and `.claude/`, so the credentials in steps 2 and 3 are set once in the source checkout. Keep the source checkout clean and on `develop`; `--refresh` refuses a dirty or detached one and prints the git line that clears it. Clear it rather than dropping `--refresh`. ### 2. Run the app Stim builds the app on this machine, installs it on an EAS Simulator session, and serves it JavaScript from this worktree's Metro through an Expo tunnel (`metro.tunnel` in `apps/tlon-mobile/.stim.json`). Nothing boots locally. ```bash eas sim:availability # "available" for the tlon account stim start --remote stim ios --remote eas && node /.agents/skills/tlon-workflow/eas-device.mjs keepalive stim logs --errors # exit 0 and "No matching log records" on stderr is the pass ``` Every `stim ios` and `stim android` in this skill takes `--remote eas`; without it Stim boots a local simulator or emulator. Plain `stim start` has no tunnel, and a running one cannot gain it (`STIM_REMOTE_START_REQUIRED`): `stim stop`, then `stim start --remote`. **One platform at a time.** A worktree holds one EAS session, so `stim android --remote eas` beside a live iOS session refuses with `STIM_REMOTE_PLATFORM_MISMATCH`. Finish a platform, `stim stop`, then `stim start --remote` and the other one. Step 4 orders the captures to fit. **A session bills from creation until it stops,** including while the local build runs and while you are thinking. Stim creates it before building, so a fingerprint miss (`fingerprint ... miss -- 1 source changed: ...`) compiles on the clock; `apps/tlon-mobile/.gitignore` is one of those sources. `stim stop` ends it (step 10) as soon as the platform's captures are done; do not leave one open across the review. **Local work over five minutes loses the device.** The EAS device's agent-device daemon exits five minutes after its last request while no agent-device session is open, and Stim sends it nothing between connecting and installing ([appandflow/stim#1212](https://github.com/appandflow/stim/issues/1212)). A fingerprint miss is enough, and so is a `pod install` on a cache hit: the install fails with `Remote daemon is unavailable`, and a rerun fails the same way. `stim stop`, `stim start --remote`, then this step again; the build is cached by then, so the new session installs within a couple of minutes. From the output keep the session ID (`device EAS Simulator ()`, or `udid` under `--json`) and the `Watch this device: ` line. Give the URL to the user: it is the only way to see the device. It carries a token, so it never goes in a pull request, ticket or comment. For Android, the same line with `stim android --remote eas`. Keep the `&& ... keepalive` on every run, backgrounded or not: it has to start the moment Stim returns (see below). A cold `stim ios` outlasts most tool timeouts: run it in the background or with the longest timeout you have, and rerun the same command if a call times out. A rerun reuses the session. So does `stim ios --remote eas` after a native change. Android on EAS Simulator is marked in development by eas-cli, and this workflow has not been run against it: expect gaps and report them. The APK still builds here, so it needs the Android SDK. If Gradle fails with `java.lang.OutOfMemoryError: Java heap space`, the knob is `org.gradle.jvmargs=-Xmx2048m` in `android/gradle.properties`. Android defaults to **`productionDebug`** (`io.tlon.groups`), committed as `android.variant` in `apps/tlon-mobile/.stim.json`, so plain `stim android --remote eas` is right. For the preview flavor (`io.tlon.groups.preview`, which you then pass to `--app` and `open`): ```bash APP_VARIANT=preview stim start --remote APP_VARIANT=preview stim ios --remote eas --scheme Landscape-preview APP_VARIANT=preview stim android --remote eas --variant previewDebug ``` Every line needs `APP_VARIANT=preview`: the Gradle variant alone leaves the app configured as production. The two device lines take the same `&& ... keepalive` as above. Use `stim logs --errors`, not `--since 5m --level error`: it filters the `hiddenapi ... AccessibilityNodeInfo` noise agent-device's snapshots generate on Android. `ready` describes the process, not the screen: allow roughly another minute for the first screen. **Drive the device through `eas-device.mjs`.** It is agent-device with this session's token and agent-device session added, so every agent-device command in this skill and its references goes through it: ```bash node /.agents/skills/tlon-workflow/eas-device.mjs snapshot -i node /.agents/skills/tlon-workflow/eas-device.mjs press 'text="Next"' --settle ``` Bare `agent-device` fails with `requires daemon authentication`: Stim keeps the connection but not the token. **Nothing may leave the device idle for a minute.** An EAS device's lease lapses after about a minute without a command, and the next command then takes a new lease that the session refuses: from then on every call fails with `UNAUTHORIZED: Lease does not match session owner (leaseId)`, and nothing re-attaches, including `stim ios --remote eas`. A pause to think is enough to lose it. `eas-device.mjs` keeps a detached process pinging the device every 15 seconds for as long as Stim records the session: `keepalive` starts it, every other call restarts it if it died, and it stops by itself after `stim stop`. It cannot save a device that was already idle for a minute before it started, which is why it is chained onto `stim ios`. Pings that fail are logged to `agent-device.remote.keepalive.log` in the Stim workspace directory (`~/.stim/workspaces//`); a failure or two during a `stim ios` rerun or a long request is expected. If the session is lost anyway, `stim stop`, then this step and the sign-in again. Stim names each worktree's session after it (`stim--tlon-mobile-`), and agent-device keeps one connection per name, so worktrees can run on EAS side by side. Start them one at a time: Stim boots one EAS device at a time on the machine, whichever agent's worktree it belongs to, and a `stim ios --remote eas` that waits more than four minutes for another boot gives up, then still runs its whole build before reporting it ([appandflow/stim#1213](https://github.com/appandflow/stim/issues/1213)). Wait for one worktree's `Watch this device` line before starting the next. `eas-device.mjs` drives the session of the worktree it sits in, whatever directory it runs from: call it by this worktree's absolute path, never another's. **Web** is a Vite server, no build. Take its port from stim: ```bash cd pnpm --filter tlon-web exec vite \ --port "$(cd /apps/tlon-mobile && stim ports get web)" --strictPort ``` The subshell in `apps/tlon-mobile` matters: a port taken from anywhere else is one `stim ports stop` never visits, so the server outlives the run. Without `--strictPort` Vite moves to the next free port and serves you another worktree. The app is at `http://localhost:/apps/groups/`. It needs `apps/tlon-web/.env.local` with `VITE_SHIP_URL` naming the ship the dev server proxies to (the same self-hosted dev ship as step 3); `VITE_DISABLE_SPLASH_MODAL=true` there skips the wayfinding modal on a fresh profile. **Cosmos** renders a component in a chosen state without driving the app to it: ```bash cd /apps/tlon-web npx cosmos --port "$(cd /apps/tlon-mobile && stim ports get cosmos)" ``` `--port` is not in `cosmos --help` but works. Cosmos needs `packages/editor/dist`: run `pnpm run build:packages` if it is missing. Fixtures live in `packages/app/fixtures`, listed by file and named export, so `ChatMessage.fixture.tsx` appears as `ChatMessage / MessageStates`. `stim ports` lists this worktree's labels and numbers, Metro included: open those and nothing else. Another port is another worktree's, and its page renders and its fixtures load with someone else's code. If you must open a port stim did not hand you, `lsof -a -p "$(lsof -nP -iTCP: -sTCP:LISTEN -t | head -1)" -d cwd -Fn` prints the worktree being served. ### 3. Sign in Most reproductions need a signed-in app. Put a self-hosted dev ship's URL and `+code` in `apps/tlon-mobile/.env.local` **of the source checkout** (gitignored; reaches every worktree through `warm`): ```bash DEFAULT_SHIP_LOGIN_URL=https://your-ship.tlon.network DEFAULT_SHIP_LOGIN_ACCESS_CODE=xxxxxx-xxxxxx-xxxxxx-xxxxxx ``` They are read by `app.config.ts`, which the Metro that `stim start --remote` launched serves to the app, and `warm` copies the file only when the worktree has none. Set them before step 1. If you are setting them now, copy the file into this worktree's `apps/tlon-mobile/` as well, then `stim stop` and step 2 again: a running Metro keeps the environment it started with. Sign in with the script, once per EAS session (a new session is a fresh device). It opens the app under the agent-device session Stim connected, runs the sequence, and leaves it open for the rest of the run: ```bash node /.agents/skills/tlon-workflow/mobile-login.mjs --platform ios --eas node /.agents/skills/tlon-workflow/mobile-login.mjs --platform android --eas ``` It prints `signed in`, or `already signed in` when Home is already up. On failure it says which step failed and leaves the session open to snapshot. Empty login fields mean Metro started without the variables above. The agent-device session is always the one Stim named for this worktree (step 2): a name of your own does not reach the remote device, so there is no session to name or close per run. The script's `--udid` / `--serial` form is for a simulator on this machine; hosted QA uses it on its own Mac worker. What this app does that the sequence above does not show: - Both prompts come back after every full reload, not only the first launch. - A "Stay in the loop" sheet appears later over Home on iOS and Android and covers the bottom of the list: `press 'text="Not now"'`. - On Android the notifications prompt can arrive after `alert dismiss` has already returned; `wait 3000` before it, or `screenshot` and dismiss what is there. - On Android this app's screens collapse into a few group nodes, so `find` matches nothing; a `text="..."` selector still resolves. - iOS shows a keyboard tip ("Speed up your typing...", `Continue`) on the first text entry, which swallows the next tap. Only reached when the fields are not prefilled. **Web** has no prefill, and the login page is the ship's own. Run the script, with the worktree path (from `apps/tlon-mobile` or `apps/tlon-web` the relative path does not resolve, and from the source checkout it writes the session outside your worktree): ```bash node /.agents/skills/tlon-workflow/web-login.mjs --url http://localhost: ``` It takes the `+code` from `apps/tlon-mobile/.env.local`, signs in, and prints the path of a Playwright storageState file (`.evidence/web-auth.json` unless `--state` says otherwise). Hand that to a context -- `browser.newContext({ storageState })` -- and it starts signed in, for the rest of the run. Signing in by hand instead, the selectors are in `apps/tlon-web/e2e/auth.setup.ts`, with one difference: a ship offering eauth renders a second form with its own `Continue`, so scope the click to the form holding the `password` field. The script fails loudly when the ship's cookie does not survive: the ship marks `urbauth-` as `Secure`, so on plain http the app returns to the login page however many times you sign in. Serve over https (`SSL=true`) for those. This yields an `authType: 'self'` session; it does not exercise the hosting-account flows (node status, revival, bot config). `DEFAULT_TLON_LOGIN_EMAIL` and `DEFAULT_TLON_LOGIN_PASSWORD` prefill the hosted path the same way. With neither pair set, the phone and email paths send a 2FA code an unattended run cannot read: ask rather than attempting them. ### 4. Capture the current behavior For a bug or a change to existing behavior, record what the app does now, before touching code. A screen recording is the default; a screenshot only when the state is static and one frame shows it. **Which platforms.** Three exist: iOS, Android and web (which the desktop app wraps). Decide before touching code: once the fix is in, a "before" on a platform you skipped takes the file swap at the end of this step. - **One platform, the one the ticket names**, when the change is logic only, or UI built from components that behave the same everywhere (`View`, `Text`, layout, styling). No named platform: iOS. - **Both iOS and Android**, before and after, when the change touches anything with native quirks: `TextInput`, `Switch`, `ScrollView` and list behavior, keyboard, gestures, the WebView editor, permissions, notifications, a native module, `Platform.select`, or a `.ios.tsx` / `.android.tsx` file; or when the ticket reports a symptom on one platform only. - **Web as well**, whenever the change is under `packages/app`, `packages/ui` or `packages/shared` and is layout or shared-component behavior: those ship to web and desktop too, and the desktop navigation is a different tree from the mobile one. A change confined to `apps/tlon-mobile`, or to a `.ios.tsx` / `.android.tsx` file, does not reach web. - **Cosmos as well as web**, not instead of it, for any UI change to a component that has a fixture in `packages/app/fixtures`. Grep the fixtures for the component before assuming none exists. See step 6. When unsure, more platforms rather than fewer. With one EAS session at a time (step 2), take both native platforms in turn rather than side by side: all of iOS (before, fix, after), `stim stop`, then Android, capturing its "before" with the file swap at the end of this step and its "after" on the branch. Record the behavior, not the journey: navigate to the screen first, start recording, do the one action that triggers it, stop once the result is on screen. Under 30 seconds. Prove the repro first, then record it. ```bash node /.agents/skills/tlon-workflow/eas-device.mjs record start /.evidence/before-ios.mp4 --quality high --hide-touches node /.agents/skills/tlon-workflow/eas-device.mjs press 'text="