Skip to content

ios: attach photos and files from the composer - #3

Open
mnthr7 wants to merge 41 commits into
cursor/ios-composer-dictation-2f83from
cursor/ios-composer-attach-2f83
Open

ios: attach photos and files from the composer#3
mnthr7 wants to merge 41 commits into
cursor/ios-composer-dictation-2f83from
cursor/ios-composer-attach-2f83

Conversation

@mnthr7

@mnthr7 mnthr7 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

What changed

The iOS chat composer can send a photo or a file. Plus menu: Attach Image, Take Photo, Choose File. Chips sit above the field. Send with or without a caption.

The harness has no upload route. Desktop attachments are already-on-disk paths (<attached-file path="…" /> in the JSON { text }). A photo on the phone is not on that disk, so:

  1. The phone picks a file (Photos picker, camera, or Files).
  2. On send, it POSTs the bytes to the sidecar (/api/inbox) with X-OpenMaus-Filename.
  3. The sidecar writes under ~/.openmausbot-companion/inbox/ (8 MB ceiling, sanitised names, 0700 dir / 0600 file) and returns { path, name, size }.
  4. The phone sends a normal text message carrying the same <attached-file path="…"> tag the desktop composer already uses. The agent opens the file on the Mac.

GET /api/inbox/:name is basename-only, allowlisted, authenticated, and refuses a symlink, a directory, a traversal, or a body over the ceiling — so the bubble can show the photo without turning a stolen token into a reader for the rest of the disk. Bytes the phone just uploaded are also cached locally, so the thumbnail does not depend on that round-trip.

A failed send keeps the chips (host is set after a successful write) so retry does not re-upload. Eight files, 8 MB each. A simulator has no camera and says so rather than crashing. NSCameraUsageDescription is in ios/project.yml.

Also in this branch, because attach made them visible:

  • The wrapping composer stays a rounded rectangle (capsule radius was half the height, so a second line became a fat oval). Actions pin to the last line.
  • Bot bubbles render GFM pipe tables the way the desktop does (remark-gfm). Wide tables scroll sideways.
  • App/ is an Xcode 16 synced folder (projectFormat: xcode16_0, XcodeGen 2.44+) so a pull no longer leaves Xcode looking for Swift files the pbxproj remembered and the disk does not.

This is the fork stacked PR (attach-only vs dictation). Open the upstream PR against milind-soni/OpenMausBot main the same way as #210:

https://github.com/milind-soni/OpenMausBot/compare/main...mnthr7:OpenMausMobile:cursor/ios-composer-attach-2f83?expand=1

Until milind-soni#210 merges, that upstream diff includes dictation; the attach-only work starts at 9e57267.

Follows milind-soni#161 / milind-soni#204 / milind-soni#210. No harness (server/) change — the sidecar is the only new surface.

Why

A phone cannot hand the agent a host path it does not have. Writing the file onto the computer through the sidecar keeps every driver on the tagged-path shape they already understand, and does not widen the harness API.

The bubble has to show the photo, not the Mac path tag. That tag is for the agent. The roster line is the original filename, not 1787…-photo.jpg.

How it was verified

  • Companion inbox / allowlist / proxy tests: pnpm exec vitest run companion/test/inbox.test.ts companion/test/routes.test.ts companion/test/proxy.test.ts — 53 passed (Node 24). Includes GET-after-POST, 401 without a token, 413 over the ceiling, traversal names, symlink / directory / oversized GET refusals.
  • pnpm exec tsc -p tsconfig.companion.build.json — clean.
  • Attachment join, XML-attribute escaping, inbox display-name prefix strip: ios/Tests/CompanionCoreTests/AttachmentTests.swift. GFM tables: MarkdownTests.swift.
  • End-to-end on a real iPhone against a live sidecar (see ios/TESTING.md stage 4 step 7): Attach Image, thumbnail in the user bubble (not a path chip), GFM table in the bot reply, wrapping composer stays a rounded rectangle.
  • After pull: quit Xcode, cd ios && xcodegen generate, then open the project. Electron companion serves dist-companion, so pnpm build:companion and Companion off/on is required for GET /api/inbox — an iOS rebuild alone is not enough.

swift test needs a Mac; this environment has no Swift toolchain.

Screenshots (UI changes)

Composer plus menu on the left of the field (Attach Image / Take Photo / Choose File). A sent photo shows as a thumbnail in the user bubble, not as a host path. Bot replies with GFM tables render as a table.

Please drop in the panel-directory screenshot (electrical-panel photo in the user bubble, transcribed table in the bot bubble).

Checklist

  • Companion inbox / allowlist / proxy tests pass
  • pnpm typecheck and pnpm test (full suite) — server/ / src/ / electron/ are unchanged by this diff; companion tests above were run
  • Server behavior changes come with tests (see CONTRIBUTING.md → Tests) — sidecar inbox is tested; harness server/ is unchanged
  • No dist-server/ edits (it's build output)
  • macOS-only code is platform-gated; no shell: true / cmd.exe string-building — iOS App target + Foundation-only CompanionCore + Node sidecar
  • No secrets in logs, responses, events, or argv
Open in Web Open in Cursor 

aivsomkar and others added 4 commits August 17, 2026 18:46
Cost and token data was on the wire and the UI threw it away — but the
harness also never had a consistent number to keep: thread.token-usage
.updated means a per-call delta on Claude, a running thread total on
Codex, and a per-step figure plus a turn total on Antigravity, so summing
it double-counted. And only Claude reports a price at all.

- turn.completed gains usage {input, output}: THIS turn's total, from
  what each driver already has (Claude result.usage incl. cache reads,
  Codex tokenUsage.last ?? total, Antigravity result.usage). The live
  indicator is unchanged and never summed.
- ProviderSnapshot.billing: Claude and Codex both strip API keys and run
  on the CLI's own login, so both report "subscription" — a Claude cost
  is an equivalent, and the UI captions it that way.
- store.addTaskUsage banks each settled turn onto TaskRecord.usage
  {input, output, costUsd, turns}; flows to clients via wireTask.
- UI: header chip for the open task ("12.4k tok · $0.06", cost only
  when known); a Usage card in bot settings; a Usage tab in app
  settings ranking bots by cost then tokens with a fleet total.

Item 1.5 of docs/plans/agent-harness-upgrades-v2.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main's milind-soni#192 landed its own per-task usage tally (last token-usage event
per turn, no cost). Reconciled: TaskRecord.usage keeps the superset shape
{input, output, costUsd, turns}; one addTaskUsage (with main's NaN/negative
sanitizing); at turn.completed the driver's own per-turn figure
(turn.completed.usage) is authoritative and main's last-reported value is
the fallback for drivers that only stream the running indicator. Records
written before cost existed read costUsd as null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plus menu offers Attach Image, Take Photo, and Choose File. The
sidecar writes the bytes under ~/.openmausbot-companion/inbox and the
phone sends the same <attached-file path="…"> tag the desktop composer
already uses — the harness has no upload route.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Destroying the request in the body reader raced the status line off the
socket, so a client saw a hung connection instead of the ceiling.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
@mnthr7
mnthr7 marked this pull request as ready for review August 18, 2026 03:19
milind-soni and others added 20 commits August 18, 2026 08:57
* Integrate exact search landing with transcript windows

* Address search landing review feedback
PendingAttachment stores a host Attachment.File. The App target does not
see CompanionCore types without the import, which is the two-error
Xcode build.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Capsule uses half the field's height as its radius, so a few lines of
text turn the input into a fat oval. A fixed 20pt corner stays a pill
on one line and matches other chat apps when it grows. Actions pin to
the last line.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Markdown was not lost — the phone already splits headings, lists, fences
and emphasis. Pipe tables were the gap: they fell through as paragraphs
of `|` characters while desktop remark-gfm drew a real table. Same GFM
delimiter-column rule as the desktop, scroll sideways when it does not
fit.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
main gained milind-soni#205 (token spend in the task picker, using the same
TaskRecord.usage), the store change stream, activity states, and typed
approvals. Reconciled: one addTaskUsage with the superset shape (costUsd
kept; emits a bot change like main's did), settle path banks the turn's
authoritative usage then moves the bot to idle via setActivity, the
Usage settings tab sits beside main's new Companion tab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent still receives <attached-file path="…">. The bubble now
splits that into caption plus files, draws the image, and falls back
to a named chip. GET /api/inbox/:name serves only inbox basenames so
a thread can show the photo after a restart.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
The generated project listed every Swift file. After a pull with Xcode
still open, that list pointed at CameraPicker.swift and
SpeechDictation.swift that were not on disk. An Xcode 16 synced folder
compiles whatever Git actually checked out.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
The chip with the inbox filename was the fallback after GET /api/inbox
failed (usually an old sidecar). Bytes the phone already uploaded are
now cached on device, and a failed fetch is a retry rather than a
permanent chip.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
…cost-visibility

Show what each bot spends
GET /api/inbox refuses symlinks, directories, and bodies over the
ceiling. The phone cache will not follow `..`, the GET client rejects
a traversal name, Files folders are skipped, and the roster shows the
original filename rather than the timestamp-hex prefix.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Resolve conflicts with milind-soni#214 conversation parity: keep inbox routes and
the attach/dictation composer alongside search, tasks, share, and
reactions.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
…ion (milind-soni#52)

* ui: give the app a focus state, a brand selection, and honest reduced motion

Four foundations the design system was missing. All of it lives in
src/styles.css with no component changes, so it composes with everything
currently in flight.

FOCUS. Nine components clear the UA outline with `outline-none`, and
nothing anywhere put a ring back — there is not one `focus-visible:`
utility in the app. Measured in the running app, all eight text fields
focused invisibly: the composer, sidebar search, the answer box on an
approval card, both onboarding fields, and all three API key fields. A
keyboard user filling in App Settings could not tell which key they were
typing into. Buttons kept the browser default, which is legible but reads
as an unstyled page inside a hand-tuned dark UI.

The rules are deliberately unlayered. Tailwind ships utilities in
@layer utilities and unlayered styles outrank every layer, so this beats
`outline-none` with no !important and without editing the nine
components — every one of which has an open PR against it. Verified: a
mouse click still paints nothing, Tab paints a 2px accent ring.

The generic ring sets no radius, because an outline already follows the
element's own corners and 26 controls here are `rounded-full`. Text
fields are the exception: most carry a radius already, but the composer
input has none, so its ring came out a hard rectangle inside a pill.

SELECTION. ::selection was never styled, so dragging over a bot's reply
painted Chrome's default blue through a palette that was pixel-sampled
off the real app. It now tints the accent.

REDUCED MOTION. The mascot was the only thing honouring
prefers-reduced-motion. Panels still flew in, cards still popped, and
every hover transition still ran. Now the app stills: panel-in 0.24s and
pop-in 0.2s both collapse to 0.01ms under reduce, and normal playback is
untouched without it. Durations collapse rather than `animation: none`,
because a spinner frozen mid-turn reads as a hung app.

The scrollbar thumb also gets a hover state; it was inert.

No palette values changed, no radii tokens, no type. Nothing here
restyles what was already designed — it fills in what had no styling.

* Address focus and reduced-motion review

---------

Co-authored-by: milind-soni <milindsoni201@gmail.com>
…ule (milind-soni#217)

The bundling fix in milind-soni#198 traded one silent packaging failure for a
subtler one. esbuild inlines drivers/claude.ts and drivers/acp/core.ts
into index.js at the server root, so the `".."` each wrote to reach a
sibling proxy started climbing from the bundle's directory instead of
its own — one level too high, two for the ACP driver:

  PROXY_PATH           <Resources>/computer-proxy.js      missing
  PERM_PROXY_PATH      <Resources>/permission-proxy.js    missing
  DWEB_PROXY_PATH      <Resources>/drivers/dweb-proxy.js  missing
  COMPUTER_PROXY_PATH  <Resources>/../computer-proxy.js   missing

The resolver only stats the .ts branch, so the missing .js was returned
unchecked and nothing failed until a child was spawned. The server still
booted and /api/health still answered — which is exactly why the new
smoke test and the Windows gate both passed the broken build.

Impact had it shipped: permission Allow/Deny cards never appear (the
default permissionMode is acceptEdits, so every Claude turn takes that
branch), cloud-box bots lose mcp__computer, dweb bots lose mcp__dweb,
and every ACP engine — grok, gemini, kimi, droid, qwen, hermes — loses
its computer proxy.

Resolve all five through one anchor in server/proxy-paths.ts, which sits
at the server root and is only ever inlined into root-level entries, so
the anchor is right in the dev tree and in the bundle. The smoke test now
asserts every path in SPAWNED_PROXIES exists inside the staged copy;
mutation-checked by restoring the old "..", which fails it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give rooms a working folder — a shared desk for every member turn

A room's bots each worked in their own private workspace, so a team could
never collaborate on one project's files. Rooms now carry a cwd
(settable from a Working folder card + header chip in the room view,
validated by the same path validator bots use), and the room's thread
pins its own copy on the first turn that dispatches — mirroring task
pinning, because engines key sessions to the folder a thread starts in,
so a later folder change applies to future rooms rather than moving a
working room. Member turns run in the pinned room folder, overriding the
member's own; off-host members (Grok API, cloud box) skip it, and a room
with no folder keeps each member's own workspace exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep room folder pinning host-only and immutable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ni#220)

A message sent mid-turn used to bounce with a 409 and a 'try again
later'. Now it lands in the transcript immediately — persisted, marked
queued — and auto-sends when the bot settles: every queued message for
the thread drains into ONE follow-up turn (texts joined), so a burst of
steering notes costs one turn. Stop-then-steer is deliberate: an
interrupted turn drains too, because these are the user's own words, not
a bot's fan-out. The queue itself is memory-only on purpose — each
queued message is already an ordinary persisted thread message, so a
restart loses only the auto-send intent, never the words, and the client
shows the queued affordance only while the bot is busy so a stranded
flag is invisible rather than a false promise.

Drained turns are plain attended turns: no automationSource, no
unattended marking, no comms depth — exactly what typing the same words
into an idle bot would run. Drain triggers on turn.completed and on the
two settle paths that never emit it (dispatch failure, provider reload).
1:1 chats only; rooms keep their existing client-side hold-one behavior.

branching.test.ts's 'second send while busy is 409' assertion encoded
the old contract; it now asserts 202 + queued + still exactly one live
turn, and stops the drained turn before the edit-fork half of the test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bring in native notifications, App Store materials, and later main
commits. Keep attach, dictation, and inbox alongside those.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Open inbox files by descriptor with O_NOFOLLOW, drain oversized
POSTs instead of destroying the socket, and keep GFM tables from
dropping streamed cells. Downsample previews and refuse files
before they are fully loaded.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
@cursor
cursor Bot changed the base branch from cursor/ios-composer-dictation-2f83 to main August 18, 2026 05:31
@cursor
cursor Bot changed the base branch from main to cursor/ios-composer-dictation-2f83 August 18, 2026 05:31
aivsomkar and others added 3 commits August 18, 2026 11:55
)

* Rebuild context when a bot's engine switches mid-thread

Switching a bot's model mid-conversation silently dropped the conversation:
the new engine had no session cursor for the thread, wasn't grok, and
`rewound` wasn't set — so it received the latest message and nothing else.
Switching back was just as broken in a subtler way: the original engine's
old cursor was trusted even though another engine had taken turns since.

- New server/turn-context.ts: buildTurnContext() extracted from dispatch,
  with a `fresh` marker distinct from `rewound` (a rewind wipes every
  instance's cursor; fresh leaves the others alone).
- engineIsFresh(): "a different instance ran the last turn on this task",
  not "this instance has no cursor". Gated on a prior user turn so a new
  bot's seeded greeting never triggers a replay. Legacy tasks without
  lastInstanceId fall back to the cursor map, replaying when ambiguous.
- TaskRecord.lastInstanceId + store.markTaskDispatched(), set at dispatch
  (not cursor time — transcript-replay engines never produce a cursor).
- e2e in branching.test.ts covers A → B → A via the native protocol tee.

Item 1.1 of docs/plans/agent-harness-upgrades-v2.md; both plan documents
land with this first PR of the series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* plan: skip 1.3 (auto-retry) for now, move it to 3.4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* plan: skip 1.6 (image attachments) for now, move it to 3.5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* plan: record the as-built freshness contract; fix doc cross-references

Review follow-ups (CodeRabbit on milind-soni#180), docs only:
- both plans now describe freshness as dispatch-based (lastInstanceId +
  engineIsFresh, resumeCursor only when resume) rather than the naive
  missing-cursor rule that failed A → B → A; v2 Task 1 carries an
  "as built" note and the interface lists engineIsFresh and
  markTaskDispatched
- v2 Step 8 describes the real per-thread native tee (no per-instance log)
- v1 says seventeen changes; v2's coverage list names items 16 and 17
- v1 gains the Upstream references section both documents pointed at

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: milind-soni <milindsoni201@gmail.com>
* Add a per-thread raw event inspector

When a bot misbehaves the chat view can't say why; the answer is in two
logs the harness already writes per thread — the normalized RuntimeEvent
stream (events/) and the provider's verbatim protocol tee (native/) —
and until now the only way to read them was by hand.

- server/thread-events.ts + GET /api/threads/:id/events?limit=: reads
  both logs, caps each on its own (the native tee is several times
  chattier), merges by time, tags kind. 404 for unknown threads; refuses
  path-shaped ids; a torn line is skipped, not fatal.
- InspectorPanel in the chat's right slot (bug icon in the header, same
  exclusive-panel pattern as Computer/Settings). Events lens: turns,
  tools, requests, token usage, errors, with runs of content.delta
  folded into one row; follows live over its own SSE subscription and
  re-reads the disk when a turn settles. Raw lens: the native tee with
  in/out direction and per-driver labels. Any row expands to full JSON.
- src/lib/inspector.ts keeps the summarizing/folding pure and tested.

Item 1.2 of docs/plans/agent-harness-upgrades-v2.md. Nothing new is
captured; this only reads back what was already on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Validate inspector log records at the wire boundary

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: milind-soni <milindsoni201@gmail.com>
milind-soni and others added 14 commits August 18, 2026 13:08
* feat(ios): streamline companion onboarding

* feat(ios): add secure QR companion pairing
* feat(ios): open live cloud desktops securely

* harden cloud desktop authorization

* test companion permission rollback
…-soni#221)

The harness server reads `OMB_PORT || OGB_PORT || 8799` (server/index.ts:80),
but the dev proxy only read the legacy `OGB_PORT`. Setting `OMB_PORT` — the
documented name — moved the server without moving the proxy, so `pnpm dev`
silently kept talking to 8799 and every /api call 404'd.

This matters for running a second instance beside the first: `OMB_DATA_DIR`
isolates the fleet and Electron already probes 8799/18799/28799
(electron/main.mjs:173), but the dev UI could not follow. The vite dev port
was hardcoded too, so two `pnpm dev` processes collided on 5199.

- proxy target now reads `OMB_PORT` first, keeping `OGB_PORT` as fallback
- dev server port reads `OMB_UI_PORT`, defaulting to 5199

Both stay fully backward compatible: unset variables reproduce the previous
behavior exactly.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Bring in QR pairing, cloud desktop access, connector auth, and later
main commits. Keep inbox attach and dictation alongside those.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
Bump a client generation on pair and sign-out so an in-flight inbox
upload cannot be sent through a later computer. Bound the inbox
descriptor read to the size fstat already allowed.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
* fix(claude): reject colliding ask ids in the permission broker

pending is server-scoped, not per-connection: two asks arriving with the
same id would let the second pending.set silently overwrite the first,
orphaning it once the first resolved and deleted the shared key. Guard
at ingestion, before onAsk fires, and deny the collision on the wire
instead.

* refactor(claude): dedupe socket-connect/answer-read helpers in duplicate-ask-id tests

* fix(claude): address CodeRabbit review on PR milind-soni#230

- Escape the client-controlled askId before logging it in the
  duplicate-ask-id guard, so a crafted id with newlines/control
  characters can't corrupt log output.
- Strengthen the collision tests to assert the full denial payload
  (id + message) and prove no second request.opened fires, not just
  behavior: "deny".
- Add a duplicate-ask-id collision test for question-kind asks, since
  the existing coverage only exercised the default permission kind.

* test(claude): retry transient Windows pipe connects

---------

Co-authored-by: SomSamantray <>
Co-authored-by: milind-soni <milindsoni201@gmail.com>
Two multi-interface bugs made discovery invisible and pairing QRs wrong
on Macs with a VPN, VM bridge, or Thunderbolt link up:

- mdns.ts joined the multicast group per interface but never pinned the
  send side, so announcements, group answers, and goodbyes left on
  whichever single interface the kernel routed 224.0.0.251 to — often
  a utun the phone is not on. Every group send now runs through one
  serialized queue that calls setMulticastInterface per advertised
  address before each send (serialized because the pin redirects every
  subsequent send on the socket), skipping interfaces that vanished
  between enumeration and send. Unicast answers still route normally.

- lanAddresses() returned networkInterfaces() in enumeration order,
  and the pairing QR embeds the first non-tailnet entry — which could
  be bridge100 or vmnet. Addresses are now ranked: en0/en1/... first,
  unrecognized real interfaces next, tunnel/bridge/mesh names
  (utun, tun, tap, bridge, vmnet, awdl, llw, feth) kept but last.

The responder gains a structural ResponderSocket seam so the pinning
contract is asserted against a recording socket in tests; CI cannot
route the real group. Ranking comparator mutation-checked.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…alth (milind-soni#236)

Three lifecycle bugs, all ending the same way: the phone cannot connect and
nothing says why.

- The sidecar never survived a restart. Only the Settings toggle started it,
  so a reboot left port 8810 dead until the user found the switch again. The
  toggle's position now persists in userData (companion-settings.json, the
  cua-connection.json idiom) and app-ready starts the sidecar with the same
  options the IPC handler uses — one attempt, failures surface in the panel.
  Only a start that worked is remembered; stop always clears the flag.

- Dev required `pnpm build:companion` that nobody runs. The entry ladder now
  falls back to companion/src/index.ts with --experimental-strip-types, the
  way the `companion` script already runs it, and the decision is a pure
  function (companion-entry.mjs) with tests. Compiled output still wins when
  it exists; a checkout with neither gets a sentence, not a spawn error.

- Advertising was built once at startup: a laptop opened before wifi
  associates silently never advertised, and DHCP moves left stale A records
  pointing phones at dead addresses. An address watcher polls the interface
  table every 5s, re-advertises on any change to the set, withdraws the
  record when the network goes away, and logs every transition — so
  discovery.advertising stays a true statement.

Also: GET /api/health no longer needs a token. The allowlist's own comment
calls it the unauthenticated smoke test, but the auth check ran first and
401'd exactly the person it was for.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…lind-soni#237)

Pairing stored one host, and one host is one point of failure: a phone
paired over the tailnet keeps a MagicDNS name that stops resolving the
moment either device leaves the tailnet — NSURLError -1003, forever —
while the same computer sits reachable on the LAN right there.

Three pieces, all additive on the wire:

- Pairing now hands the phone an ordered candidate list. The sidecar
  computes [MagicDNS name, LAN addresses, its own mDNS name last] in
  hostCandidates(); the list rides the QR link as a `hosts` param and
  the /api/pair redeem response as a `hosts` field. The single-host
  `address` field stays, older phones ignore the new one, and a saved
  Connection without `hosts` still decodes.

- Late binding on the phone. CandidateRotation (pure, in CompanionCore)
  walks the list when a stream fails with an address-shaped URLError
  (-1003/-1004/-1001/-1200) and promotes — and persists — whichever
  candidate carries a live stream, so the next launch dials the working
  address first. A 401 never rotates: that is a token problem, and
  hiding it behind an address walk would mask the real fix.

- Errors that say what to do. ConnectionAdvice maps the URLError codes
  to advice (-1003 names the tailnet possibility, -1004 points at the
  Companion toggle, -1001 blames the route, -1009 says offline), names
  the candidate being tried next, and always says the app keeps
  retrying. Settings additionally gains an Edit address affordance that
  replaces the host while keeping the pairing and its token.

Validated: swift test 106 green (rotation logic mutation-checked both
ways), simulator build green, vitest 997 green, typecheck and oxlint
clean on every touched file.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Use an explicit localhost image reference for the locally built Cua VM so Podman does not resolve it through Docker Hub. Adds regression coverage for issue milind-soni#231.
Windows CI has been red since the ask-id collision tests landed: their
thread ids (t-perm-dup-1, t-perm-dup-2, …) agree on the first eight
characters, which was the whole socket tag — so on Windows they shared
one named pipe. POSIX hides the collision because a new broker's listen
replaces the socket FILE and the name always points at the fresh server,
but the Windows pipe namespace is global and never unlinked, so a reused
name races the previous broker's async teardown. That race is why the
same tests pass on macOS and Linux and fail on windows-latest.

The tag is now four readable chars + four hex chars of a sha256 of the
FULL thread id, so distinct threads can never share a socket. The tag
stays at eight chars total because the POSIX socket path already brushes
the 104-byte sun_path limit under the deep tmp home dirs the tests use —
a longer tag fails listen(2) with EINVAL on macOS.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Companion keepalive, LAN address ranking, and later main commits.
Keep inbox attach and dictation alongside those.

Co-authored-by: Cursor Grok 4.6 <cursoragent@cursor.com>
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.

6 participants