Skip to content

app: pair computers, list their chats and continue them here, with the two-computer fixes - #2011

Merged
AbirAbbas merged 50 commits into
merge/dev-into-app-v3from
feat/app-relay-doors
Oct 28, 2026
Merged

AbirAbbas merged 50 commits into
merge/dev-into-app-v3from
feat/app-relay-doors

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2009 (merge of dev into app/v3). Once that merges, this PR's base becomes app/v3.

Summary

Gives the desktop app (codeaf app) what it needs to set up the relay, pair computers, list chats on your other computers, and Continue here. Then fixes what a live two-computer test turned up: a Windows desktop and a Mac codeaf app syncing through one relay, over many rounds.

New app wire (features devices.pair, chats.take, sync.relay)

  • Methods:
    • pair.start/get/answer/cancel, with desktop wording and labelled choices;
    • devices.*;
    • chats.list (a versioned inventory with Location and ChatRow);
    • agents.take (Continue here with a seen consent);
    • sync.get.
  • Events: pair.changed, chat.changed, chat.removed, devices.changed, sync.changed, settings.changed (now also sent for synced settings).
  • Documentation: docs/design/app-v3/PROTOCOL.md and internal/manual/chat/desktop-sync.md describe both.

Fixes from the live test

  • Chats and rows

    • Chats made before pairing are published at start and after pairing.
    • A row says a computer is offline only when presence says so. Unknown presence is omitted, never sent as false.
    • saved_at is the saved turn's time, not the publish time. The takeover line has no absolute timestamp; the desktop formats time locally.
    • chat.changed / devices.changed are sent only for a real change: never from chats.list, never for a reconnect that changed nothing, never as a partial list.
  • Ownership

    • A failed take leaves the chat free, and its source can write again. Fences never roll back.
    • A holder's fence moves only when ownership changes: no extra acquire per take, none at startup for a live hold, none on watch reconnects. An open chat keeps its hold with heartbeats instead of being retired and re-acquired every ten minutes.
    • A take is not refused when the same holder only renewed its fence and the saved head is unchanged.
    • A take either completes or moves nothing: a chat whose model this computer can't use, with no usable default
      either, is taken; its first send then says plainly to set up a model, without calling a provider.
    • A background chat is not reopened (and its lease taken) at start or after the idle sweep because of a job whose
      ending its checkpoint already holds; that check only reads.
  • Relay outages

    • A computer whose own lease lapsed while the relay was unreachable renews it when the relay answers, keeps the chat writable meanwhile, and publishes the work saved during the outage.
    • It never tells itself that it continued the chat elsewhere.
    • The startup quiet release is no longer refused (401) by the native relay.
    • A relay that just (re)started reports computers it has not heard from yet as unknown, not offline, for one
      presence window (native and hosted relays).
  • Settings and models

    • Synced settings reach the desktop (settings.changed).
    • A model choice syncs only to computers that can use its provider; a local Ollama model no longer becomes the other computer's default.
    • A taken chat whose model this computer can't use continues on the local default, with one notice.
    • The chat's model choice (main and fallback) travels with the chat in the sealed session truth, so taking a chat
      back no longer reverts it to the model this computer remembered.
    • A model that refuses reasoning is not sent reasoning, and that 400 is not retried later.
  • Windows: Continue here onto Windows. Three fixes:

    1. the store daemon kept a directory watch on the staging tree, so the rename was refused;
    2. the engine was spawned in a working directory past MAX_PATH;
    3. receipts were restored past MAX_PATH.

    Verified with native Windows test runs and live.

Relay protocol (backward compatible)

  • Codeaf-Saved-Turn-Times: 1. A cell can carry the saved-turn time. Older relays still accept a publish without it.
  • Codeaf-Head-Takes: 1. A head-guarded take accepts a newer fence by the same holder when the head is unchanged. Older relays keep exact-fence checks.
  • Presence. Queries use the same reconnect grace as watch events, and last-seen never moves backwards. After a
    relay starts, unobserved computers are omitted (unknown) for one presence window.
  • Both relays. The native relay (relayserve) and the hosted relay (relay/hosted) both implement these.

Verification

  • Tests: each fix has focused regression tests named by its contract item (E-, R, S*, F*, P*, O*, C1, W*, I*). Loopback relays with two homes and real app processes drive takes, outages (a relay stopped past the lease TTL and restarted), presence across reconnects and model sync. Native Windows runs cover the take round trip with the shared daemon, plus long paths.
  • Live, Windows desktop and a Mac app through a relay:
    • pairing; chats syncing both ways;
    • Continue here in both directions, including Mac-origin chats onto Windows;
    • turns after each take; the held view on the other computer;
    • one fence move per take;
    • a deliberate 4-minute relay outage: turns kept running, the lease was renewed afterwards, the backlog published;
    • recovery of a chat stuck by an earlier outage;
    • a relay restart with both computers running: no offline flash on either side;
    • an open idle chat keeping one fence across sweeps (heartbeats only);
    • a chat whose model isn't set up: the setup sentence without a provider call, then the new default with one line;
    • model sync skipped on a computer without the provider (recorded, not applied).
  • CI: the PR gate fails the same tests, with the same content, as Merge dev (through #2002) into app/v3: relay, two-computer fixes, quit saving #2009's gate (repo law tests and existing
    failures). The full check fails the same tests as app/v3 runs, except TestR25ArchivedPreconditionalClient, which
    builds an archived client offline and fails on a module the runner's cache lacks (golang.org/x/sys@v0.6.0 under
    GOPROXY=off); this branch changes no module and adds no module import to that client's packages. Failures the
    branch had introduced (two tests, two repo laws) are fixed in later commits.
    Runs on the last commit: PR gate 40697998349 (re-run once for a temp-dir cleanup race that passes 30/30 locally),
    Full check 40698002131. This branch's PRs into a merge/ branch trigger no checks, so these were dispatched by hand.

Follow-ups (not in this PR)

  • Taken chats live under copy-<encoded origin>, so a very deep Windows home or project can still pass 260 characters for git/shell working directories. Typical homes stay well under. Options: shorter, hash-based copy folder names; or a long-path-aware manifest with LongPathsEnabled, though that still doesn't let git or a shell start in a long working directory.
  • The desktop's ChatRow.crew is not filled yet.

🤖 Generated with Claude Code

AbirAbbas and others added 30 commits October 27, 2026 23:19
The CLI's chat opening, pairing mailbox/grant and Continuer construction
read the process home. Each now has a variant that takes the home
folder, and the CLI calls it with home.Dir(), so the app backend can
build the same objects for its own home instead of copying them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New app-wire doors behind three hello features:

- devices.pair: pair.start/get/answer/cancel and pair.changed. One
  pairing at a time in all four modes the terminal has (offer a code,
  use a code, make a link, approve a link), with the terminal's own
  sentences. A computer that already has chats is asked before its own
  chats are replaced; a join while an agent works is refused as busy;
  a join that replaces this computer's identity ends with restart.
- chats.take: chats.list plus chat.changed/chat.removed from one shared
  Sync.Follow follower (no polling), each row naming the computer it
  lives on, presence when known, and the local agent when this computer
  has it. Older home-folder chats stay local-only (#2002). agents.take
  continues a chat here through the CLI's Continuer, refuses a stale
  seen with conflict/holder_changed, and returns the local agent; the
  other computer's open agent gets agent.changed with here:false.
  Location gains cell, seen, can_take and takeover_line.
- sync.relay: Sync.relay reports the relay address and where it came
  from (environment, paired, default, none) or off.

Tests cover the desktop contract (E-P1..E-P4, E-C1, E-C2, E-T1, E-T2,
E-Q1, E-R1) with two homes and a loopback relay, including the real app
entry point in two processes taking a chat, refusing the old holder,
and saving on quit so it can be taken straight back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PROTOCOL.md lists the new methods, events, types and hello features;
the chat manual's desktop-sync page says how Relay, pairing, chats on
other computers and Continue here work in the desktop; and a change
entry records the additions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The app reused the terminal's sentences: an unpaired home said "pair
from a terminal with `codeaf pair`" and sync off said "set
CODEAF_SYNC_URL". The desktop now pairs and sets its relay itself, so
app mode says "Only this computer is here so far." and "Sync is off on
this computer." (devices.list, sync.get, devices.remove, agents.take).

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

From the first live two-computer run (Windows desktop and a Mac engine
through one relay):

- Chats saved before pairing now reach the other computer: at app start
  and after a pairing, each local cell chat not yet in the relay's
  directory is published once through the terminal's publish door
  (PublishCopy), without opening it or running a turn. Older home-folder
  chats keep #2002's rule and stay local.
- chat.changed is sent only for rows that changed. Rows carried a
  relative save age, so every directory version looked like a change;
  they now carry the saved date and time.
- Pairing speaks to a desktop person: sentence case, "computer", no
  command lines, flags or y/n buttons (Pair / Don't pair, Replace this
  computer's chats / Not now). Option ids are unchanged; the terminal's
  copy in internal/pair is untouched.
- A chat open on another computer reads "open on <name>", not
  "running": chatlist's running means a live lease, not a turn.
- A failed Continue here says "This chat could not be moved to this
  computer." with the cause in the error detail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Exercises the take's fetch into a staging folder and its swap into
place with the native store, so the Windows rename failure seen in the
live run can be reproduced with a Windows test binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A preference restore from a paired computer now emits one settings.changed
with the full settings, as a local settings.set does. Identical computer
lists no longer repeat devices.changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A model the catalog marks as non-reasoning gets no reasoning parameter. An
unlisted model's specific thinking refusal is repaired once without
reasoning and remembered for the process; that 400 is permanent, so the app
no longer keeps the failed turn to run again later.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After an install fails and the taker releases its lease, a computer
without the chat in place no longer reads it as here; it offers Continue
here and names the last holder. The source's next send re-acquires the
free lease at a higher fence when its saved head still matches, instead of
refusing held_elsewhere. Fences never roll back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Listing, recovery and lazy admission could open a copied chat under
copies/<id>.taking and watch it before the taker renamed it into place,
which on Windows fails the rename with Access is denied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The native relay client's wrapper hid ReleaseQuiet, so the fallback sent an
ordinary release that no longer matched the signed quiet mutation. The relay
refused it with 401 and the startup publish left a stray lease behind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A published cell now records when its sealed head was saved, so a chat
published later still says when its last turn ended. Relays advertise the
optional field with Codeaf-Saved-Turn-Times: 1; an older relay still accepts
the publish without it, and an unknown time is omitted, never replaced by
the publish time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A closed chat no longer reads "<name> offline" while its computer is online;
offline needs presence to say so, and unknown presence is omitted rather
than sent as false. saved_at is the saved turn's time and the takeover line
carries no absolute UTC time. chats.list never emits, and a row is sent
again only when its content changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An incoming model slot is applied only when this computer's resolved
provider connections can call it (a key, a sign-in or a local endpoint).
A skipped choice keeps the receiver's own value, is remembered so it is
not echoed back as a newer edit, and does not advance the slot's clock; a
later choice made after the provider is set up still travels. Effort and
spending limits keep syncing as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Startup heartbeats a hold it still owns instead of acquiring it again, a
take ends at the fence it returns, and a watch reconnect renews an
unchanged idle hold rather than releasing and re-acquiring it. A take
whose seen fence was bumped by the same holder with the same saved head is
no longer a holder_changed conflict; a different holder or a new head
still is. Relays advertise this with Codeaf-Head-Takes: 1, and older relays
keep exact-fence checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Last-seen times only move forward, fresh presence survives a watch
reconnect, and presence queries use the same reconnect grace as watch
events. A computer reads offline only when its presence actually lapsed;
lost observation becomes unknown after the existing 25-second window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The computer list is emitted once complete and then only when its wire
value changes, from every emitter. A watch reconnect that changes no chat
row sends no chat.changed, and a sync round whose only incoming model
choice was skipped sends no settings.changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shared store daemon keeps a directory watch on a tree it materialized.
A take renames its staging tree into place next, which Windows refuses
while the tree is watched (Access is denied). Guarded materialization now
runs a one-shot engine and waits for it to exit; Follow binds the final
root after the swap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Follow and the dirty/local-work plans started the engine inside the chat's
workspace. A taken chat filed under its origin folder can pass Windows'
260-character limit for a working directory, and a recorded workspace can
be the other computer's path. The engine now starts in the local store
and receives the workspace with --repo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Restoring a chat writes receipts under its .cell folder, and those paths can
pass 260 characters. Furrow does not canonicalize this argument itself, so
Windows now receives it in \\?\ form. Other platforms are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ake-back

Real encrypted fetch, staging-watch check, swap, Follow, Dirty/Seal and a
take-back that restores receipts past 260 characters. The app round-trip
test can keep its state on failure (CODEAF_E2_KEEP_FAILED=1) for native runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If this computer's lease lapsed only because the relay was unreachable,
it renews it when the relay answers again (same fence when nobody else
acquired and the relay's head is an ancestor of the local one) and
publishes the work saved meanwhile. An unreachable renewal keeps the chat
writable and its publisher alive; startup publishing retries every 5–60
seconds instead of giving up. A computer never names itself as the one
that continued a chat elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e local default

Before the first turn on this computer, a chat whose model has no usable
provider here switches to this computer's default model and says so once.
Going back to the original computer keeps working. With no usable model at
all, the turn fails with a plain setup reason instead of a provider 400.
The provider check is the one model sync already uses, moved to config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two homes on a loopback relay that stops past the lease TTL and restarts on
the same address; a competing take during the outage; startup recovery; and
disjoint providers for the model fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas and others added 13 commits October 28, 2026 05:35
The ten-minute idle sweep retired an open chat's conversation, and the next
read (skills.list, agents.open) reopened it and acquired again, moving the
fence every ten and a half minutes. A chat the person has open now keeps
its hold with heartbeats until it is closed; background-only conversations
still retire.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A test built Sync without Home, so its refusal wrote a hold lock relative to
the package folder. The fixture now uses a temp home, and an empty home is
an error before any read, mkdir or lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… from offline

Presence lives in relay memory. Right after a relay (re)starts, every paired
computer that has not reconnected yet looked offline, so a peer that never
stopped read "<name> offline" until its first heartbeat reached the new relay.

For one default presence window after presence memory starts, both the native
and hosted relays omit a computer they have not observed (unknown). An
observed connection that actually lapses still reads offline, and a computer
that stays away past the window reads offline as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A take whose chat used a model this computer can't run, with no usable local
default either, acquired the lease and installed the chat, then returned an
error. The chat had moved, but the desktop showed a failure and a stale row.

Now the take completes and returns the local agent, and the row's local id is
reconciled in the same change. The first send fails locally with a plain
setup sentence and no provider call (naming waits too). Once a usable default
is set, the next send uses it and explains the switch once.

Take errors with a plain, actionable reason carry it in the visible message;
the detail keeps the technical cause without repeated "engine:" labels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y restart

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

The guarded materialize always replaced the engine's transport with a fresh
Spawn, so a caller's own transport (a test's fake, an isolated runner) was
dropped and a carried furrow became mandatory. Only the shared daemon is now
swapped for a one-shot process, keeping its socket, so the daemon still never
holds a watch on a staging tree; any other transport is used as given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A model choice now travels only to a computer that can use its provider, so
the receiving home needs the same fake provider key as the sender for the
C30 preference to arrive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The repository laws require each goroutine in the guarded tree to run under
guard.Go and each Lock to be released by a defer directly beneath it, so an
absorbed panic can neither take the surface down nor leave a lock held. The
relay work added two bare goroutines (take and existing-chat cancellation)
and critical sections in the chat inventory, pairing, take, devices, sync
and live-drive code that unlocked by hand.

Each section now sits in a small helper that defers its unlock. Lock order,
what each section covers, and what stays outside it (waits, network calls,
emission) are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A job that ended instantly could leave its process record saying "running"
while the chat's task checkpoint recorded it as finished. Every engine start,
and every idle sweep that closed the chat, found that record, opened the chat
to recover it and so took its relay lease, then released it again 10.5 minutes
later: the fence moved every cycle with nobody using the chat.

The recovery check now treats a running record as finished when the checkpoint
holds the ending of the same run (same start and call), and it only reads, so
a chat held by another computer is never written to. When the chat is next
opened for real work, recovery also writes that ending into the process record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The conversation's model and its main/fallback pair lived only in the local
summary, outside the sealed tree, so taking a chat back onto a computer that
still had its own record of it restored the model from before the other
computer changed it. The choice now rides in the sealed session truth, and a
completed take refreshes the local record from what was installed; the usual
rule then applies (keep it if usable here, else the local default with one line).
Older cells keep reading their summary until their next save.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Switching a continued chat to this computer's model recorded the generic
"Main model changed from X to Y." and then the specific reason, and the first
request could add "Using main model Y." too. The switch now records only the
specific sentence through the same conversation model rules; a separate
fallback change keeps its own line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ith it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas and others added 7 commits October 28, 2026 13:33
The store's SQLite URI was built as file://C:%5C…, which SQLite reads as an
invalid authority, so a native relay on Windows never opened its group's
store and every directory request answered 503. Drive paths now become
file:///C:/…. Directory fsync goes through fsreplace.SyncDir, which skips the
flush Windows cannot do on a read-only directory handle; regular files still
sync before rename.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every 503 path now logs its underlying cause through the relay's log, once
per cause per minute; responses are unchanged. Native provision and transport
persistence use the Windows-safe directory sync.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A computer wrote its own device record only when pairing finished and when a
chat's sync drive started, so one failed write left it invisible to its other
computers until a chat synced. The engine now publishes its record whenever
it starts paired, after pairing, after a rename and when the relay answers
again, retrying with backoff until it lands and never rewriting an identical
record. devices.list keeps a readable roster when the advisory presence read
fails, instead of calling the relay unreachable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The offer tells the other computer to choose Pair a computer, then Enter a
code from the other computer. A successful pairing no longer warns that the
name could not be shared yet (the engine retries it). The approval names the
other computer with its platform, e.g. "Abir (Windows)", and older peers that
send no platform keep working.

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

The device pages keep their vocabulary law (no relay, lease or node), and the
rename line quoted on naming-a-device said "when they can reach the relay".
It now says "once this computer is back online".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h first

Device publication opens the shared directory watch at startup, so the chat
inventory could join a watch that had already heard its version and
presence. A new subscriber only hears later frames, so the first online rows
could stay cached and never reach the desktop. The inventory now reconciles
an already connected watch once when it subscribes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 46496c3 into merge/dev-into-app-v3 Oct 28, 2026
12 of 20 checks passed
@AbirAbbas
AbirAbbas deleted the feat/app-relay-doors branch October 28, 2026 19:05
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