Skip to content

feat: Wear OS companion app for agent triage - #2655

Open
colonelpanic8 wants to merge 17 commits into
getpaseo:mainfrom
colonelpanic8:assembly-wear-os
Open

feat: Wear OS companion app for agent triage#2655
colonelpanic8 wants to merge 17 commits into
getpaseo:mainfrom
colonelpanic8:assembly-wear-os

Conversation

@colonelpanic8

Copy link
Copy Markdown
Contributor

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:

Wear app  ──Wearable Data Layer──▶  Phone app  ──WebSocket──▶  Daemon
   DataClient /paseo/snapshot            ◀── phone publishes flattened state
   DataClient /paseo/transcript/<agent>  ◀── phone answers transcript requests
   DataClient /paseo/icon/<projectKey>   ◀── phone publishes project icons
   MessageClient /paseo/command  ──▶ prompts, approvals, stops, new agents
  • Snapshots ride DataClient (persists, redelivers on reconnect — the list is never blank from a link blip). Commands ride MessageClient (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.
  • Transcripts are on-demand + leased. Opening an agent fetches a projected, wrist-sized conversation (prose kept, tool calls collapsed to one muted line, ≤100 entries, 48KB cap). While the screen is open, the phone holds a lease and pushes updates within ~2s of new activity, coalesced; a 60s watch keepalive renews it, and it lapses ~2.5min after the screen closes.
  • Project icons are the real images the daemon finds in each repo, shipped as per-project DataItem Assets, screened by magic bytes on the watch (SVG/ICO fall back to the ported color-hash initial).
  • One-tap input. Reply launches the system speech recognizer directly; a Type satellite opens Wear's remote-input picker (keyboard/handwriting/emoji). No audio touches our code and there is no RECORD_AUDIO permission.
  • The wire contract is hand-mirrored between packages/app/src/wear/wear-protocol.ts and packages/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/android is regenerated by expo prebuild, so a :wear module 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's applicationId + signing cert or the Data Layer silently routes nothing — documented in packages/watch/README.md, learned the hard way.

Testing

  • 87 phone-side vitest (projection, snapshot building, command execution, transcript paging/epoch handling, lease/coalescing/expiry/disposal, icon sync) and 46 watch JVM unit tests (wire codec against pinned JSON, transcript/icon cache policy, composer) + 1 instrumented test; watch assembleDebug builds.
  • The full diff went through an independent adversarial model review twice; all confirmed findings are fixed and pinned by tests.
  • Verified on hardware (Pixel Watch 3 + Pixel 9 Pro XL, F-Droid-signed pair): snapshot hop, transcript fetch and live tail-follow, permission flow, one-tap recognizer, remote-input picker, round-display insets. packages/watch/HANDOFF.md tracks what remains hardware-unverified (latest wave's icon hop and keepalive cadence).

🤖 Generated with Claude Code

colonelpanic8 and others added 17 commits July 29, 2026 16:50
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>
Comment on lines +417 to +420
// 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security 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)

Comment on lines +89 to +94
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security 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.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a phone-tethered Wear OS companion for monitoring and controlling agents.

  • Introduces the Kotlin/Compose watch application, Data Layer repositories, caches, screens, resources, tests, and standalone Gradle build.
  • Adds an Expo Android bridge and phone-side snapshot, transcript, icon, lease, and command handling.
  • Registers the bridge in the app lifecycle and adds the new native package to workspace builds.

Confidence Score: 4/5

The transcript identity collision and incomplete permission presentation need to be fixed before this PR is safe to merge.

Multi-daemon agent IDs are server-scoped but transcript transport and display collapse them to agentId, while permission approvals can target complete operations whose consequential suffix is omitted from the watch UI.

Files Needing Attention: packages/app/src/wear/wear-bridge.ts, packages/app/src/wear/wear-snapshot.ts, packages/watch/app/src/main/java/sh/paseo/watch/data/DataLayerRepository.kt, packages/watch/app/src/main/java/sh/paseo/watch/ui/PermissionScreen.kt

Security Review

Two authorization-boundary issues were identified: server-scoped transcripts can collide and appear on the wrong agent screen, and watch permission approvals can authorize operation text that the user cannot inspect in full.

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "feat(wear): live lease hardening, watch ..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant