feat: Wear OS companion app for agent triage - #2655
Conversation
Kotlin + Compose for Wear app that surfaces workspaces, unblocks pending permissions, and replies to agents by voice or keyboard. Voice and typing both come from Wear's system input sheet via RecognizerIntent with EXTRA_PREFER_OFFLINE, so Google's on-device recognizer does the work; no audio touches our code and no RECORD_AUDIO permission is needed. Paseo's own daemon-side dictation is deliberately unused: with a phone-tethered transport, streaming PCM off the watch is a bad trade against a free on-device recognizer. Workspaces are the browsing unit, matching Paseo's Project -> Workspace -> Agent model, and every navigation step that isn't a real choice is collapsed: Workspace.destination() sends a single-agent workspace straight to the agent, an empty one straight to a voice prompt, and only shows the picker when a workspace is genuinely ambiguous. That rule lives in exactly one function so it can't drift. Standalone Gradle project rather than a module under packages/app/android, which expo prebuild regenerates. Not an npm workspace either: nothing here is consumed by JavaScript. Verified on a Wear OS 5.1 emulator at 450x450; screenshots and the approved design mock are in design/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone app keeps sole ownership of the daemon connection, pairing, and relay E2EE; the watch never speaks the Paseo WebSocket protocol. Snapshots go out over DataClient, which persists and resyncs when the watch returns to range, so the list is never blank from a link blip. Commands come back over MessageClient, which fails fast — silently queueing "approve this permission" for delivery an hour later would be worse than an honest failure. expo-wear-bridge is a separate workspace package rather than native code inside packages/app/android, which expo prebuild regenerates. It resolves through requireOptionalNativeModule and degrades to no-ops on iOS, web, and F-Droid builds, so WearBridgeListener is safe to mount unconditionally. Known limitation: Play Services starts the listener service even when the app process is dead, but there is no JS runtime to execute against. Rather than boot React Native headlessly, the command persists to a capped on-disk queue and drains on next start — so a command sent while the app is killed takes effect when the app is next opened. WearBridge.start() drains before its first publish so a queued approval isn't overwritten by a stale snapshot. The per-agent `summary` is the daemon's agent title, not the transcript tail: populating the real last message would mean subscribing to every agent's timeline from a background bridge. wear-protocol.ts mirrors packages/watch/.../data/WearBridge.kt by hand. The watch's unit tests pin this exact JSON, so a change on either side should break them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
npm ci rejected the previous lockfile with "Missing: expo-module-scripts@5.0.8 from lock file" and a dozen more. The workspace entry was hand-written and listed devDependencies whose transitive tree — jest-expo, react-test-renderer, eslint-config-universe, glob, commander — was never added, so the lock and the package.json disagreed and the F-Droid build failed at npm ci. None of those dependencies were needed. expo-module-scripts came in with the generated package scaffold, but this module already dropped its tsconfig base (it looks for expo-module-scripts package-locally and npm hoists it to the root), builds with plain tsc, and runs no expo-module scripts. typescript and @types/react are provided by the root, and the module imports no React. expo-modules-core moves to a peerDependency, which is what it actually is: the host app supplies it via expo, and it is already in the lock through that path. With no dependencies of any kind, the lockfile addition is four facts — the root workspaces array, the link node, the workspace entry, and the app's dependency — and nothing transitive can be missing. Verified with `npm ci --dry-run`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project built with whatever `gradle` was on PATH, which is not a build — it is a coincidence. The wrapper pins Gradle 8.14.4, the version AGP 8.13.2 expects, so a local build and CI run the same toolchain. This also unblocks CI: the assembled flake's android dev shell provides the SDK and JDK but no Gradle, and depending on a runner's preinstalled Gradle would reintroduce exactly the drift the wrapper exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures from carrying the wear entry, both because I verified the watch's
Kotlin and the TypeScript but never the phone module's Kotlin or a web bundle.
The module's Android source only compiles as part of the app's Gradle build via
autolinking, so nothing local had ever built it.
Kotlin: the module used `AsyncFunction(name) Coroutine@{ }`, which is not an
Expo API — `Coroutine@` is just a label, so the non-suspend overload was
selected and every Task.await() was a compile error. The real API is
`AsyncFunction(name).SuspendBody<R> { }`. The explicit type argument is
load-bearing: SuspendBody's zero-arg and one-arg overloads are ambiguous when
the lambda declares no parameter list.
Also dropped expo-modules-core from peerDependencies. As a `*` peer, npm
installed a nested 2.5.0 copy beside the hoisted 3.0.29 the app actually uses,
and the nested copy's Gradle plugin was then compiled with mismatched Kotlin
metadata (2.1.0 read by a 1.9.0 compiler). The module gets core from the app's
autolinking config, so declaring it at all was wrong.
Bundling: use-wear-bridge imported the Android-only native module
unconditionally, so the web bundle resolved it and the desktop build died on the
module's uncompiled `main`. Split to use-wear-bridge.android.ts with a no-op
base, per the Metro platform-extension rule in CLAUDE.md. That keeps the module
out of every non-Android bundle rather than teaching the desktop build to build
an Android module it will never load — Wear OS does not pair with iOS, so there
is genuinely nothing to run off Android.
build:app-deps also now builds the module, which is what the F-Droid Android
build needs.
Verified locally this time:
:getpaseo-expo-wear-bridge:compileDebugKotlin -> BUILD SUCCESSFUL
expo export --platform web with the module's build/ DELETED (the exact
condition that failed CI) -> Exported
expo export --platform android -> Exported
app typecheck, lint, 23 wear tests, 10 watch tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The watch showed "Open Paseo on your phone to connect" while the phone was open,
running the matching assembled commit, and connected to four hosts.
buildWearWorkspaces grouped by agent, so a workspace with no agents was never
emitted at all. Two consequences. The design's empty-workspace row ("no agents ·
tap to start") and its 0-agents-goes-straight-to-voice navigation rule could
never trigger. And with nothing running anywhere the snapshot came out
completely empty, which the watch reported as a missing phone — the failure mode
looked identical to a dead bridge.
Now iterates workspaces and attaches agents to them, so a workspace's presence
no longer depends on it being busy. Workspaces mid-archive are excluded:
replying to an agent whose cwd is about to disappear is worse than not showing
it.
Also split the watch's empty state in two. It conflated "no snapshot has ever
arrived" with "the phone reported no workspaces" — a comment in the original
even argued the advice was identical. It isn't: the first is actionable, the
second is not, and collapsing them told the user to open an app that was already
running and made a genuine bridge fault indistinguishable from an empty account.
The repository already tracked LinkState; it just was not surfaced.
Two existing tests asserted the old behaviour — that excluding an agent also
removed its workspace — and now assert the workspace survives with an empty
agent list.
27 wear tests, 10 watch tests, app typecheck and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The watch shipped as sh.paseo.watch with a debug signature. The Wearable Data Layer only routes between a phone app and a watch app that share BOTH the same applicationId and the same signing certificate, so against the phone's sh.paseo.assembly (F-Droid key) nothing could ever cross — puts succeeded, listeners registered, node discovery worked, and Play Services silently delivered nothing to either side. That silence is why every earlier layer verified green while the integration was dead. applicationId, versionCode, and versionName now come from Gradle properties. The default is sh.paseo.debug: a debug watch build and a locally-built dev phone app on the same machine share ~/.android/debug.keystore, so the bridge works in development with no flags. Distribution builds pass -PpaseoApplicationId and must be signed by the same pipeline that signs the phone APK. The Kotlin namespace stays sh.paseo.watch; only the install identity varies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The launcher icon was a microphone glyph I drew — it matched the app's primary action, not the product. It is now the Paseo butterfly, with the path copied verbatim from packages/app/assets/images/butterfly-white.svg (verified byte-identical, not redrawn) and the adaptive-icon background changed from the accent green to #000000 to match adaptiveIcon.backgroundColor on the phone. The path lives in a 0..700 coordinate space and its ink sits off-centre inside that box (x 100.6..632.2, y 81.6..622.6), so the group transform centres the measured bounding box rather than the viewBox: mapping the viewBox naively would render the glyph small and visibly off-centre. Scaled to fit the round mask with margin. At the tighter fit the wingtips touched the 72dp safe-zone circle, which a circular Wear launcher mask would clip; the bounding box now sits ~30dp from centre against a 36dp radius. Verified by rendering the exact group transform over the same path onto a round black canvas with the safe zone overlaid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captures what a fresh agent needs and cannot derive: how to reach the watch and phone over adb (and why the addresses keep moving), the nix toolchain paths, the fork-fold cycle for landing a change in the installed build, and the traps that each cost real time here — Wear WiFi sleeping, black screencaps, the npm-deps hash, and this machine's destructive npm install. Leads with the rule that explains the one genuine dead end: the Data Layer only routes between apps sharing both applicationId and signing certificate, and reports nothing when they differ. Transient by design — delete once the open work lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The watch could only show `summary` — the daemon's agent title — because carrying conversation in the snapshot would mean subscribing to every agent's timeline on every daemon just to fill a wrist. Instead the watch asks. A new `requestTranscript` command makes the phone page that one agent's projected timeline and publish it to its own DataItem at `/paseo/transcript/<agentId>`, outside the snapshot that republishes on every store change. Projection is the whole problem: a Paseo timeline is tool calls, file reads and terminal output, and one Bash result can exceed the Data Layer's ~100 KB item cap alone. So prose is capped per message, tool calls collapse to a single line, reasoning and todos are dropped, and the serialized payload is held under 48 KB by dropping the oldest entries — `truncated` tells the watch to say the rest is on the phone rather than pretend it can fetch more. Paging stops if the timeline's epoch changes mid-walk: a rewind would otherwise splice two histories together and resurrect content the rewind removed. A per-agent generation guard drops a slow fetch whose result a newer one already superseded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The agent screen showed one line — the agent's title — capped at three lines. It now renders the transcript the phone publishes on demand: a chat log that opens at the newest turn with Reply already under your thumb, and scrolls up into the backlog from there. Anchoring at the top would have put the oldest turn under your eyes and the actions off-screen. Kinds are separated by weight rather than labels — a "You:" prefix on every other row spends a line of a 450px screen restating what the styling says. Assistant prose is bright and full-bleed because it is the thing being read, the user's own words sit inset in a bubble as context, tool calls are one muted monospace line, errors carry the destructive tint. An unknown kind still renders, just plainly: the phone may learn to emit one before the watch learns to style it, and a hole in the conversation is the worse failure. Until a transcript arrives the summary card stays. That is a normal state, not an error — a phone too old to answer `requestTranscript` drops it silently and the card is then permanent, so it gets no loading treatment. Stop is also de-emphasized here: 38dp against Reply's 52dp and 20dp clear of it, where two equally weighted circles 10dp apart invited a thumb to split the difference. On a wrist the cost of a mis-tap is what sizes a button. Registering both Data Layer listeners on the same object silently loses one of the two streams — Play Services keys them by callback, not by URI — so the transcript prefix gets its own listener. Transcript cache policy lives in TranscriptCache so it is testable without Play Services. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The keyboard button launched RecognizerIntent with a keyboard hint, so typing was buried a tap inside the voice sheet and depended on a hint the recognizer is free to ignore. RemoteInputIntentHelper opens Wear's actual input picker — keyboard, handwriting, emoji, voice, whichever the watch offers. The mic button keeps the on-device recognizer, which still needs no audio permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three UI asks are built, so the handoff's open work is now hardware verification: nothing about the transcript hop or the remote-input activity has run on a real phone-and-watch pair. Also records the listener-registration trap, which fails the same silent way as the applicationId mismatch already documented — no error on either side, the stream just never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects that only a device could show, both found on a Pixel Watch 3. Prose set 12dp from the edge is clipped by the glass: a 450px circle is only ~164px wide a third of the way up from centre, so an assistant message rendered "builds" as "ouilds" and lost the leading character of a path. The list now insets 22dp — Wear's ~10% margin — and the rows stop adding their own. The screen also opened at the top of the conversation rather than the newest turn. Growing the list makes `canScrollForward` true, so reading it after an update could not tell "the user scrolled up" apart from "new content arrived", and the gate never fired. Sample it only while a scroll is actually in progress: a real gesture is the only thing that means the user left the tail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transcript request now also opens a 150s lease on that agent. While the lease is alive — the watch's ~60s re-request cadence renews it — any session-store change that moves the agent's lastActivityAt schedules a coalesced 2s transcript republish, so an open watch screen follows the conversation within seconds instead of waiting for its next minute tick. The sweep runs ahead of publish()'s unchanged-payload short-circuit on purpose: new activity usually doesn't change the snapshot at all (the only agent field it moves is minute-granular age), so sweeping after the early return would reintroduce the staleness this exists to fix. The activity marker is recorded before each fetch, not after, so a turn that lands while paging still reads as movement to the next sweep. No wire-format change; the watch needs no code — updated DataItems already flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reply on the agent screen now launches the recognizer in place and sends the result — the intermediate composer screen cost every spoken reply a second tap and a screen of canned answers, and both are gone. A 38dp Type satellite opens the system input picker just as directly. The action row becomes Reply alone on top with Type and Stop as satellites below: three abreast doesn't fit inside a 450px round screen's 22dp insets without eating the gaps that keep a thumb aimed at Reply off Stop. Type is deliberately the nearer satellite — mis-tapping into a keyboard is recoverable, killing a run is not. The shared intent plumbing moves to ui/Composer.kt; ReplyScreen becomes NewAgentScreen (mic + keyboard, no canned chips), the only composer screen left since a new agent has no conversation to hang buttons off. Docs record the new flow and the live-update lease; on-hardware verification of the one-tap reply is the new open item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cons Three entangled pieces of the same wave, sharing files on both halves. Lease hardening (phone), from an adversarial review of the live-update work: leases, timers, and the pre-existing generation counter are keyed by server+agent (NUL-joined) instead of agent id alone, so two daemons running the same id no longer share a lease or cancel each other's timers; the coalescing timer rechecks expiry at fire time instead of publishing up to 2s past the cutoff; and publishTranscript checks disposed on entry and before the transport write, so a bridge stopped mid-fetch can't publish over its replacement. Each fix is pinned by a test. Keepalive (watch): the transcript re-request rode snapshot changes, but a continuously busy agent pins state=Running and age="now", so nothing ever changed and the lease died at 150s exactly when there was the most to see. The agent screen now re-requests every 60s unconditionally while open. Project icons: the watch previously only ported the phone's color hash. The phone now fetches each project's real icon from the daemon (once per bridge lifetime, after a snapshot that actually changed, never blocking the snapshot path) and publishes it as an Asset DataItem at /paseo/icon/<encoded projectKey>. The watch receives it on a third listener object, screens by magic bytes (PNG/JPEG/GIF/BMP/WEBP render; SVG/ICO fall back to the colored initial), and draws it everywhere the project glyph appears, including offline from the DataItem cache. Wire protocol stays v1: an old phone publishes nothing and the watch keeps today's fallback. Phone: 87 vitest. Watch: 46 unit tests, APK builds. The Expo SuspendBody 3-arg overload was verified against expo-modules-core source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // The DataItem path stays keyed by agent id alone — that is the wire contract the | ||
| // watch reads, and it only ever talks to one phone. | ||
| await this.deps.transport | ||
| .publishTranscript(agentId, JSON.stringify(transcript)) |
There was a problem hiding this comment.
Transcript keys collapse server identity
When two connected daemons contain the same agent ID, both transcripts are published to the same /paseo/transcript/<agentId> DataItem and cached by agent ID alone, causing one server's conversation to appear on the other server's agent screen.
How this was verified: The phone explicitly treats agent IDs as server-scoped, but the publication path and watch lookup both omit serverId.
Knowledge Base Used: Paseo Mobile App (packages/app)
| const detail = specific ?? request.description ?? request.title ?? request.name; | ||
|
|
||
| return { | ||
| id: request.id, | ||
| title: request.title ?? titleForKind(request.kind, request.name), | ||
| detail: truncate(detail, MAX_DETAIL_LENGTH), |
There was a problem hiding this comment.
Approval hides operation suffix
When a permission command or path exceeds 200 characters, the phone truncates its detail and the watch provides no full-detail view, while approving by requestId still authorizes the complete underlying operation, including any undisclosed destructive or sensitive suffix.
How this was verified: The displayed agent-controlled text is truncated, but the approval forwards the original request ID to the daemon.
|
| Filename | Overview |
|---|---|
| packages/app/src/wear/wear-bridge.ts | Coordinates watch publication, transcript leases, and command execution, but drops serverId from transcript transport identity. |
| packages/app/src/wear/wear-snapshot.ts | Flattens daemon state for the watch, but truncates permission details before authorization. |
| packages/expo-wear-bridge/android/src/main/java/expo/modules/wearbridge/ExpoWearBridgeModule.kt | Implements the native Data Layer transport and keys transcript DataItems using agentId alone. |
| packages/watch/app/src/main/java/sh/paseo/watch/data/DataLayerRepository.kt | Owns watch-side Data Layer state and caches transcripts without preserving their server identity. |
| packages/watch/app/src/main/java/sh/paseo/watch/ui/PermissionScreen.kt | Presents approval controls using the projected permission detail without a route to inspect the complete operation. |
| packages/watch/app/src/main/java/sh/paseo/watch/PaseoWatchApp.kt | Wires navigation and repository state into the watch UI, selecting transcripts by agentId alone. |
Sequence Diagram
sequenceDiagram
participant W as Wear app
participant DL as Wear Data Layer
participant P as Phone bridge
participant D as Paseo daemon
P->>D: Read connected host and agent state
P->>DL: Publish snapshot, transcripts, and icons
DL-->>W: Deliver persistent DataItems
W->>DL: Send prompt, permission, stop, or create command
DL->>P: Deliver command message
P->>D: Execute command against selected server
Reviews (1): Last reviewed commit: "feat(wear): live lease hardening, watch ..." | Re-trigger Greptile
What this is
A native Wear OS companion app (
packages/watch, Kotlin + Compose for Wear) plus the phone-side bridge that feeds it. Triage and unblock your agents from your wrist: see every workspace and agent across connected daemons, read the conversation, approve/deny permissions, reply by voice in one tap, stop runs, and spin up a new agent from a voice prompt.Architecture
Phone-tethered — the watch never speaks the Paseo WebSocket protocol:
DataClient(persists, redelivers on reconnect — the list is never blank from a link blip). Commands rideMessageClient(fails fast; silently queueing "approve this" would be worse than an honest failure). Commands arriving while the phone app is dead are persisted and drained on next launch.packages/app/src/wear/wear-protocol.tsandpackages/watch/.../data/WearBridge.kt(protocol v1). Old phone + new watch degrades gracefully everywhere (summary card instead of transcript, initials instead of icons).Why a standalone Gradle project
packages/app/androidis regenerated byexpo prebuild, so a:wearmodule there would be wiped. The watch app is a separate pinned-wrapper Gradle project and not an npm workspace. The watch must share the phone variant'sapplicationId+ signing cert or the Data Layer silently routes nothing — documented inpackages/watch/README.md, learned the hard way.Testing
assembleDebugbuilds.packages/watch/HANDOFF.mdtracks what remains hardware-unverified (latest wave's icon hop and keepalive cadence).🤖 Generated with Claude Code