diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index b5d18f54c0..ed4efe6108 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -252,6 +252,11 @@ jobs: with: go-version-file: go.mod cache: true + - name: Build furrow from engine/ + working-directory: engine + run: | + rustup show active-toolchain || rustup toolchain install + cargo build --release --locked -p furrow-cli - name: Weigh the binary run: | set -euo pipefail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a9e68ece..f87fb6d35e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,11 @@ jobs: with: go-version-file: go.mod cache: true + - name: Build furrow from engine/ + working-directory: engine + run: | + rustup show active-toolchain || rustup toolchain install + cargo build --release --locked -p furrow-cli # Several sessions work this tree at once and a half-finished file breaks # the build for everyone. This is the cheapest way to find that out, and diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0cb29d524..eedf30c4f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,6 +212,11 @@ jobs: with: go-version-file: go.mod cache: true + - name: Build furrow from engine/ + working-directory: engine + run: | + rustup show active-toolchain || rustup toolchain install + cargo build --release --locked -p furrow-cli - name: Build all targets with their furrow env: TAG: ${{ needs.prepare.outputs.tag }} @@ -236,6 +241,10 @@ jobs: if [ "$goos" = "windows" ]; then rm -f internal/furrowbin/cache/furrow-*.gz extension=".exe" + elif [ "$goos/$goarch" = "linux/amd64" ]; then + # The runner's own platform is the one furrow is built for here. + env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ + -goos "$goos" -goarch "$goarch" -built engine/target/release/furrow else env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ -goos "$goos" -goarch "$goarch" diff --git a/.gitignore b/.gitignore index 120deb43b2..58ca9f1128 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ internal/manual/pages.pack.gz .furrow/ bench/idle-surface/runs/ work/ + +# hosted relay dev install (wrangler, miniflare) +relay/hosted/node_modules +relay/hosted/.wrangler/ diff --git a/Makefile b/Makefile index 32e34be07e..a3bae7798f 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # anywhere else — so a stale copy can't shadow a fresh one. BINARY := bin/codeaf -.PHONY: all build build-check build-cross debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ +.PHONY: all build build-check build-cross debug demo-home clean-run embed manual-pack-law furrow furrow-fetch furrow-source test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ changelog changelog-new changelog-check changelog-preview # What the shipped binary is allowed to weigh, in bytes, checked in beside the @@ -72,11 +72,28 @@ embed: manual-pack-law FURROW_GOOS := $(shell go env GOOS) FURROW_GOARCH := $(shell go env GOARCH) -furrow: +# WHERE THE FURROW COMES FROM. engine/ is furrow's source, so a machine with +# cargo builds it and embeds exactly that. The pinned release is the road for +# everyone else: a machine without cargo, a cross-compile (cargo here builds for +# the host only), or an explicit FURROW_ARTIFACT. Force it with FURROW_FROM=fetch +# or FURROW_FROM=source. +FURROW_HOST := $(shell go env GOHOSTOS)-$(shell go env GOHOSTARCH) +FURROW_NATIVE := $(and $(shell command -v cargo),$(filter $(FURROW_HOST),$(FURROW_GOOS)-$(FURROW_GOARCH)),$(if $(FURROW_ARTIFACT),,yes)) +FURROW_FROM ?= $(if $(FURROW_NATIVE),source,fetch) +FURROW_BIN := engine/target/release/furrow + +furrow: furrow-$(FURROW_FROM) + +furrow-fetch: @env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ -goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) \ $(if $(FURROW_ARTIFACT),-from "$(FURROW_ARTIFACT)") +furrow-source: + cd engine && cargo build --release --locked -p furrow-cli + @env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ + -goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) -built $(FURROW_BIN) + # The symbol table and DWARF are a third of the shipped binary and nothing at # runtime reads them. Stripping costs symbolized panic traces, which is exactly # what `debug` keeps — build that when a stack trace is what you need. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 312ba56c9c..4e5e23aa1f 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -809,6 +809,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## github.com/coder/websocket + +- Version: v1.8.15 +- Licence: not determined + +### LICENSE.txt + +```text +Copyright (c) 2025 Coder + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +``` + ## github.com/dlclark/regexp2/v2 - Version: v2.5.2 diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go new file mode 100644 index 0000000000..ed15600eb0 --- /dev/null +++ b/cmd/codeaf/approvals.go @@ -0,0 +1,232 @@ +package main + +// The chat's door onto answering new devices and keeping the device list: what +// the approve screen and `/devices` reach through tui3.Approvals. It is the +// same Approver and signed directory `codeaf pair approve` uses, pointed at +// this computer's home, so the terminal and the app cannot disagree. + +import ( + "context" + "errors" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// approvalsDoor is the surface's tui3.Approvals. +type approvalsDoor struct { + dir string + mailbox func(relayFlag string) (pair.Mailbox, error) + approver func(pair.Mailbox) (pair.Approver, error) + + mu sync.Mutex + asking map[string]pair.Asking // by code: what Pending read, for the answer +} + +var _ tui3.Approvals = (*approvalsDoor)(nil) + +func newApprovalsDoor(dir string) *approvalsDoor { + return &approvalsDoor{dir: dir, mailbox: pairMailbox, approver: linkApprover(dir), asking: map[string]pair.Asking{}} +} + +// toldLine is a refusal said in the sentence every sync surface uses for it, and +// it still is the refusal for errors.Is. +type toldLine struct { + line string + err error +} + +func (e toldLine) Error() string { return e.line } +func (e toldLine) Unwrap() error { return e.err } + +// told puts the relay's refusals (a stopped computer, a clock that is off) in +// the words a person reads, so a screen that lists devices says why it cannot, +// and the sentence is the one the chat list and the sync notice already say. +// Anything that is not a refusal is returned as it came. +func told(err error) error { + if refusal, ok := cellsync.RefusalOf(err); ok { + return toldLine{line: refusal.Say(err), err: err} + } + return err +} + +func (d *approvalsDoor) who() (pair.Approver, error) { + route, err := d.mailbox("") + if err != nil { + return pair.Approver{}, err + } + return d.approver(route) +} + +func (d *approvalsDoor) Pending(ctx context.Context, typed string) (tui3.PendingDevice, error) { + ref, err := pair.ReadLink(typed) + if err != nil { + return tui3.PendingDevice{}, err + } + who, err := d.who() + if err != nil { + return tui3.PendingDevice{}, err + } + as, err := who.Look(ctx, ref) + if err != nil { + return tui3.PendingDevice{}, told(err) + } + d.remember(as) + return pendingOf(as), nil +} + +func pendingOf(as pair.Asking) tui3.PendingDevice { + return tui3.PendingDevice{ + Code: as.Ref.Code, Device: as.DeviceID(), Name: as.Name, Platform: as.Platform, + Check: as.Check, RequestedAt: as.RequestedAt, ExpiresAt: as.ExpiresAt, + } +} + +func (d *approvalsDoor) remember(as pair.Asking) { + d.mu.Lock() + defer d.mu.Unlock() + d.asking[as.Ref.Code] = as +} + +func (d *approvalsDoor) recall(p tui3.PendingDevice) (pair.Asking, error) { + d.mu.Lock() + defer d.mu.Unlock() + as, ok := d.asking[p.Code] + if !ok { + return pair.Asking{}, pair.ErrLinkGone + } + return as, nil +} + +func (d *approvalsDoor) Approve(ctx context.Context, p tui3.PendingDevice) error { + return d.answer(p, func(who pair.Approver, as pair.Asking) error { + return errors.Join(who.Approve(ctx, as), identity.EndSolo(d.dir)) + }) +} + +func (d *approvalsDoor) Deny(ctx context.Context, p tui3.PendingDevice) error { + return d.answer(p, func(who pair.Approver, as pair.Asking) error { return who.Deny(ctx, as) }) +} + +// answer runs one decision on the request Pending read, and forgets it after. +func (d *approvalsDoor) answer(p tui3.PendingDevice, decide func(pair.Approver, pair.Asking) error) error { + as, err := d.recall(p) + if err != nil { + return err + } + who, err := d.who() + if err != nil { + return err + } + if err := decide(who, as); err != nil { + return told(err) + } + d.forget(p.Code) + return nil +} + +func (d *approvalsDoor) forget(code string) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.asking, code) +} + +// home is this computer's signed directory, its identity and its device id. +func (d *approvalsDoor) home() (directory.Client, identity.Identity, string, error) { + route, err := d.mailbox("") + if err != nil { + return nil, identity.Identity{}, "", err + } + client, id, err := homeDirectory(d.dir, route) + if err != nil { + return nil, id, "", err + } + dev, err := identity.Device(d.dir) + return client, id, dev.ID(), err +} + +func (d *approvalsDoor) Devices(ctx context.Context) ([]tui3.DeviceRow, error) { + client, id, self, err := d.home() + if err != nil { + return nil, err + } + listing, err := client.List(ctx) + if err != nil { + return nil, told(err) + } + rows := rowsOf(listing, directory.MetadataKey(id.CellKey()), self) + if own, named := devname.Chosen(d.dir); named { + rows = withOwnName(rows, own) + } + return rows, nil +} + +// withOwnName puts this computer's own name on its own row. The directory holds +// the name this computer last managed to tell it, which is the old one for a +// person who renamed offline; the name kept here is the one they typed. +func withOwnName(rows []tui3.DeviceRow, own string) []tui3.DeviceRow { + for i := range rows { + if rows[i].Self { + rows[i].Name = own + } + } + return rows +} + +func rowsOf(l directory.Listing, key []byte, self string) []tui3.DeviceRow { + rows := make([]tui3.DeviceRow, 0, len(l.Devices)) + for id, dev := range l.Devices { + rows = append(rows, rowOf(id, dev, key, self)) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Name < rows[j].Name || (rows[i].Name == rows[j].Name && rows[i].ID < rows[j].ID) + }) + return rows +} + +func rowOf(id string, dev directory.Device, key []byte, self string) tui3.DeviceRow { + row := tui3.DeviceRow{ID: id, Name: openName(key, dev.Name), Platform: dev.Platform, Self: id == self, Revoked: dev.Revoked} + if dev.LastSeen > 0 { + row.LastSeen = time.UnixMilli(dev.LastSeen) + } + return row +} + +// openName is a sealed name in words, and "a device" when it cannot be opened. +func openName(key []byte, sealed string) string { + if name, err := directory.OpenName(key, sealed); err == nil && strings.TrimSpace(name) != "" { + return devname.Shown(name) + } + return "a device" +} + +func (d *approvalsDoor) Revoke(ctx context.Context, id string) error { + client, _, _, err := d.home() + if err != nil { + return err + } + return told(client.Revoke(ctx, id)) +} + +func (d *approvalsDoor) DeviceName(sealed string) string { + id, err := identity.Load(d.dir) + if err != nil { + return "a new device" + } + return openName(directory.MetadataKey(id.CellKey()), sealed) +} + +// Rename names this computer. It is the door's one write of its own record: the +// same function `codeaf devices rename` runs, so the two cannot say different +// things. +func (d *approvalsDoor) Rename(ctx context.Context, typed string) (string, error) { + return renameThisDevice(ctx, d.dir, typed) +} diff --git a/cmd/codeaf/approvals_test.go b/cmd/codeaf/approvals_test.go new file mode 100644 index 0000000000..624d47c06b --- /dev/null +++ b/cmd/codeaf/approvals_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// doorAt is the production Approvals door for one home of the rig. +func (r *pairRig) approvalsAt(home string) *approvalsDoor { + d := newApprovalsDoor(home) + d.mailbox = func(string) (pair.Mailbox, error) { + return pair.Mailbox{Box: pairbox.NewHTTP(r.url, &http.Client{Timeout: time.Minute}), URL: r.url, Host: "relay.test"}, nil + } + d.approver = linkApprover(home) + return d +} + +// enrol writes the first computer's own record, as its first sync does. +func (r *pairRig) enrol(t *testing.T, door *approvalsDoor, name string) { + t.Helper() + client, id, self, err := door.home() + if err != nil { + t.Fatal(err) + } + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), name) + if err != nil { + t.Fatal(err) + } + dev := directory.Device{V: 1, Name: sealed, AddedBy: id.ID(), Platform: "linux"} + if err := client.PutDevice(context.Background(), self, dev); err != nil { + t.Fatal(err) + } +} + +// A link pasted on device A is read, approved through the door, and B joins; +// then the list names both, A is Self, and Revoke stops B. +func TestApprovalsDoorApprovesListsAndRevokes(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, `https://codeaf\.agentfield\.ai/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + ctx := context.Background() + rig.enrol(t, door, "this computer") + + p, err := door.Pending(ctx, link) + if err != nil { + t.Fatal(err) + } + if p.Name != "laptop" || p.Platform != "linux" || len(p.Check) != 4 || p.Device == "" { + t.Fatalf("pending = %+v", p) + } + if err := door.Approve(ctx, p); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if first, _ := identity.Load(rig.homeA); first.ID() == "" { + t.Fatal("no identity") + } + + rows, err := door.Devices(ctx) + if err != nil || len(rows) != 2 { + t.Fatalf("devices = %+v, %v", rows, err) + } + var self, joined *tui3.DeviceRow + for i := range rows { + if rows[i].Self { + self = &rows[i] + } else { + joined = &rows[i] + } + } + if self == nil || joined == nil || joined.ID != p.Device || joined.Name != "laptop" || self.Name != "this computer" { + t.Fatalf("rows = %+v, want one self and the joined %s", rows, p.Device) + } + if err := door.Revoke(ctx, p.Device); err != nil { + t.Fatal(err) + } + rows, _ = door.Devices(ctx) + for _, row := range rows { + if row.ID == p.Device && !row.Revoked { + t.Fatalf("revoked device still listed in: %+v", row) + } + } +} + +// Deny turns the asker away, and a decided request cannot be answered again. +func TestApprovalsDoorDeny(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, `https://codeaf\.agentfield\.ai/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + p, err := door.Pending(context.Background(), link) + if err != nil { + t.Fatal(err) + } + if err := door.Deny(context.Background(), p); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil { + t.Fatal("the new device was let in after a deny") + } + if err := door.Approve(context.Background(), p); err == nil { + t.Fatal("answered twice") + } +} + +func TestApprovalsDoorRefusesAWrongLink(t *testing.T) { + rig := newPairRig(t) + if _, err := rig.approvalsAt(rig.homeA).Pending(context.Background(), "nope"); err == nil { + t.Fatal("a bad link was read") + } +} + +// A computer that another of the person's computers stopped is told so, in the +// one sentence every sync surface uses, by the screens that list devices. +func TestApprovalsDoorTellsAStoppedComputerItWasStopped(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, `https://codeaf\.agentfield\.ai/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + ctx := context.Background() + rig.enrol(t, door, "this computer") + p, err := door.Pending(ctx, link) + if err != nil { + t.Fatal(err) + } + if err := door.Approve(ctx, p); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if err := door.Revoke(ctx, p.Device); err != nil { + t.Fatal(err) + } + + stopped := rig.approvalsAt(rig.homeB) + if _, err := stopped.Devices(ctx); err == nil || err.Error() != chatlist.Removed { + t.Fatalf("the stopped computer's device list said %v, want %q", err, chatlist.Removed) + } + if err := stopped.Revoke(ctx, p.Device); err == nil || err.Error() != chatlist.Removed { + t.Fatalf("the stopped computer's revoke said %v, want %q", err, chatlist.Removed) + } +} diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go index 2f0c7af047..0d24788b11 100644 --- a/cmd/codeaf/beltdoors_test.go +++ b/cmd/codeaf/beltdoors_test.go @@ -27,20 +27,24 @@ import ( // a command that scrolled off a person's screen. // Part A's terminal connect door adds its two required forms and descriptions. // The cap moves by exactly those seven lines; unrelated help growth still fails. -const helpLineCap = 117 +// The pairing door adds its two forms and their descriptions, four lines more. +const helpLineCap = 121 // doorsOffThePage are the command words main.go dispatches that `codeaf --help` // deliberately does NOT name, so the check below does not demand a line for // them: // -// - `engine` and `tick` are machinery a surface dials, not things a person +// - `engine`, `tick`, `cell` and `identity` are machinery a surface dials, not things a person // runs by hand; main.go says so where it dispatches them and // internal/manual's running-from-the-terminal page says so again. +// - `relay` is the program whoever hosts a relay runs (`codeaf relay --listen +// :8787 --store
` reach through
+// tui3.Pairing. It pairs THIS computer, the one at the keyboard, on every door
+// the chat opens through, because that is the computer a person means by "share
+// my chats" whether the conversation on screen runs here or over --at or --host.
+// The relay, the identity and the joining side's home come from the same three
+// helpers `codeaf pair` uses, so the two doors cannot disagree about where a
+// code goes.
+
+import (
+ "context"
+
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/tui3"
+)
+
+// chatPairDoor is the surface's tui3.Pairing.
+type chatPairDoor struct{}
+
+var _ tui3.Pairing = chatPairDoor{}
+
+// Offer shows a code and shares this computer's chats with the device that types
+// it. Every error it returns is already the sentence a person reads.
+func (chatPairDoor) Offer(ctx context.Context, ui pair.OfferUI) (string, error) {
+ route, err := pairMailbox("")
+ if err != nil {
+ return "", err
+ }
+ grant, err := pairGrant(route)
+ if err != nil {
+ return "", err
+ }
+ return pair.Offer(ctx, route, grant, ui)
+}
+
+// Join types another computer's code and takes its chats, unless this computer
+// already has chats of its own: replacing them is a flag of the terminal command
+// and never a keystroke in a chat.
+func (chatPairDoor) Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) {
+ route, err := pairMailbox("")
+ if err != nil {
+ return pair.Joined{}, err
+ }
+ return pair.Join(ctx, route, pairJoining(false), typed, ui)
+}
diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go
index f8b86d940e..de2cccd562 100644
--- a/cmd/codeaf/chatv3_process.go
+++ b/cmd/codeaf/chatv3_process.go
@@ -637,6 +637,11 @@ func (p *v3Process) closeAll() {
}
waiting.Wait()
+ // THE DRIVE SIDE OF EACH CHAT ENDS AFTER ITS CONVERSATION DID, so the last
+ // seal is already noted when the final publish runs, and every lease is
+ // given back on this one road out.
+ syncDrives.closeAll()
+
// The ledger's background writer is drained AFTER the agents have closed,
// because closing an agent can seal a last turn and a seal records a line.
// It is the same bargain the recall store's Close makes one line below: a
@@ -901,7 +906,8 @@ func (s *v3Seam) bundle(agent v3Live, launch *v3Launch, cfg session.Config,
Owned: cfg.Place.Owned,
AnchorWorkspace: anchor,
Resumed: resumed,
- Notice: notice,
+ Seal: sealSeamOf(agent, cfg.Seals),
+ Notice: v3Interrupted(cfg, notice),
ContextWindow: cfg.ContextWindow,
DraftFile: draft,
History: recall,
diff --git a/cmd/codeaf/chatv3_session_test.go b/cmd/codeaf/chatv3_session_test.go
index 6d33e3f7bc..692265a4fd 100644
--- a/cmd/codeaf/chatv3_session_test.go
+++ b/cmd/codeaf/chatv3_session_test.go
@@ -2,6 +2,7 @@ package main
import (
"errors"
+ "github.com/Agent-Field/codeaf/internal/cell"
"path/filepath"
"strings"
"testing"
@@ -87,9 +88,14 @@ func TestASecondWindowOnALockedSessionIsRefusedAndNamesTheWayOut(t *testing.T) {
}
}
-// sessionBucket is the project directory above one session folder.
+// sessionBucket is the project directory above one session folder. A cell keeps
+// its journal one level deeper, in .cell/, so the folder is found past that.
func sessionBucket(transcript string) string {
- return filepath.Dir(filepath.Dir(transcript))
+ folder := filepath.Dir(transcript)
+ if filepath.Base(folder) == cell.StateDir {
+ folder = filepath.Dir(folder)
+ }
+ return filepath.Dir(folder)
}
// An error that is NOT the lock is still an error: the fallback is for one
diff --git a/cmd/codeaf/chatv3_skillbudget_test.go b/cmd/codeaf/chatv3_skillbudget_test.go
new file mode 100644
index 0000000000..753d4f3d3e
--- /dev/null
+++ b/cmd/codeaf/chatv3_skillbudget_test.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+// A skill folder that is a link into a place the operating system gates behind
+// a permission prompt makes open(2) block with no way to cancel it. The launch
+// must still draw its first frame, so the import pass is given a budget and the
+// conversation opens without that shelf when the budget runs out.
+func TestSkillImportBudgetReleasesTheLaunchFromAStuckPass(t *testing.T) {
+ stuck := make(chan struct{})
+ defer close(stuck)
+
+ started := time.Now()
+ finished := runWithinBudget(50*time.Millisecond, func() { <-stuck })
+
+ if finished {
+ t.Fatal("a pass that never returns was reported as finished")
+ }
+ if took := time.Since(started); took > 2*time.Second {
+ t.Fatalf("the launch waited %v on a stuck pass", took)
+ }
+}
+
+func TestSkillImportBudgetWaitsForAPassThatFinishes(t *testing.T) {
+ ran := false
+ if !runWithinBudget(time.Second, func() { ran = true }) || !ran {
+ t.Fatal("a quick pass was not waited for")
+ }
+}
diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go
index 53d29f440b..6dae0e3cf8 100644
--- a/cmd/codeaf/chatv3_surface.go
+++ b/cmd/codeaf/chatv3_surface.go
@@ -7,6 +7,7 @@ import (
"github.com/Agent-Field/codeaf/internal/buildinfo"
internalenv "github.com/Agent-Field/codeaf/internal/env"
+ "github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
@@ -55,6 +56,9 @@ func runSurface(ctx context.Context, options tui3.Options) error {
restart = &codeupdate.Plan{}
options.Restart = restart
}
+ wireSync(&options)
+ options.Pairing = chatPairDoor{}
+ options.Approvals = newApprovalsDoor(home.Dir())
options.UpdateRunning = revision
options.UpdateCurl = curl
options.UpdateArgs = surfaceArguments()
diff --git a/cmd/codeaf/codeaf b/cmd/codeaf/codeaf
new file mode 100755
index 0000000000..415b6d40b7
Binary files /dev/null and b/cmd/codeaf/codeaf differ
diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go
new file mode 100644
index 0000000000..e3276ff761
--- /dev/null
+++ b/cmd/codeaf/devices.go
@@ -0,0 +1,395 @@
+package main
+
+// ── the door: codeaf devices ────────────────────────────────────────────────
+//
+// TWO KINDS OF DEVICE, ONE DOOR. A computer can hold the person's chats (it was
+// paired with `codeaf pair`) and a device can be let in to use this machine (it
+// was paired with `codeaf serve`). They are different powers with different
+// undo, so they are listed under two headings, but a person who wants one gone
+// types one verb, `codeaf devices revoke `, and never asks which kind it
+// was. Each kind is a [deviceKind]; the door only knows the interface, so a
+// third kind is one more type and not one more branch.
+//
+// THE EMPTINESS LAW: a kind with nothing in it has no heading, and a machine
+// with nothing of either kind says one sentence ([pair.NoDevices]).
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/chatlist"
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+ "github.com/Agent-Field/codeaf/internal/wireauth"
+)
+
+// deviceKind is one kind of device the door lists and stops.
+type deviceKind interface {
+ // table is the kind's heading and rows, or "" when it has none.
+ table(now time.Time) (string, error)
+ // find counts the devices of this kind that answer to name.
+ find(name string) (int, error)
+ // stop stops the one device of this kind that answers to name and returns
+ // the sentence that says so.
+ stop(name string) (string, error)
+}
+
+// allStopper is a kind that can stop every device answering to one name. Only
+// the remote-access kind can: two laptops with one host name are common there,
+// and each is a key the person can tell apart no other way.
+type allStopper interface {
+ stopAll(name string) (string, error)
+}
+
+// runDevices lists the devices of both kinds, or stops one.
+//
+// BOTH THE LIST AND THE STOPPING ANSWER ABOUT THE MACHINE THEY ARE TYPED ON.
+// A remote device cannot list itself out of somebody's machine and cannot stop
+// another device; a computer that holds the chats may stop another of the same
+// person's, because the person owns both.
+func runDevices(args []string) error {
+ kinds := openDeviceKinds()
+ switch {
+ case len(args) > 0 && args[0] == "revoke":
+ return stopDevice(kinds, args[1:])
+ case len(args) > 0 && args[0] == "rename":
+ return runDevicesRename(args[1:])
+ case askedForHelp(args):
+ return commandHelp("devices")
+ case len(args) > 0:
+ return fmt.Errorf("usage: codeaf devices [revoke [--all] | rename ]")
+ }
+ return listDevices(kinds, time.Now())
+}
+
+// revokeDevice stops one remote-access device, or every one answering to one
+// name. It is the door's verb over the book alone, kept for callers that hold
+// only a book.
+func revokeDevice(book *pair.Book, args []string) error {
+ return stopDevice([]deviceKind{bookKind{book}}, args)
+}
+
+// openDeviceKinds is the kinds this machine has: the computers that hold the
+// chats only when sync is on and there is an identity, and always the devices
+// let in to use this machine.
+func openDeviceKinds() []deviceKind {
+ kinds := []deviceKind{}
+ if chats, ok := openChatsKind(); ok {
+ kinds = append(kinds, chats)
+ }
+ return append(kinds, bookKind{pair.DeviceBook()})
+}
+
+// listDevices prints every kind's table, or the one sentence when none has rows.
+func listDevices(kinds []deviceKind, now time.Time) error {
+ var tables []string
+ for _, kind := range kinds {
+ table, err := kind.table(now)
+ if err != nil {
+ return err
+ }
+ if table != "" {
+ tables = append(tables, table)
+ }
+ }
+ if len(tables) == 0 {
+ fmt.Println(pair.NoDevices)
+ return nil
+ }
+ fmt.Print(strings.Join(tables, "\n"))
+ return nil
+}
+
+// stopDevice reads `revoke [--all]` and stops the device that answers to
+// the name.
+//
+// --ALL IS A FLAG LIKE EVERY OTHER FLAG IN THIS BINARY. It used to be read by
+// hand, and only when it was the FIRST word after `revoke`, so `codeaf devices
+// revoke laptop --all` was refused — with a usage line that did not mention
+// `--all` at all. A person taking back access to their own machine was told the
+// wrong grammar for the gesture they had just typed correctly. Through
+// [commandFlags] and [reorder] it is accepted in either position, printed by
+// `codeaf devices revoke --help`, and named in the one usage table.
+func stopDevice(kinds []deviceKind, args []string) error {
+ flags := commandFlags("devices revoke")
+ all := flags.Bool("all", false, "remove every device of that name that can use this machine, not just the one")
+ if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
+ return err
+ }
+ if flags.NArg() != 1 {
+ return errors.New("usage: codeaf devices revoke [--all] — `codeaf devices` lists the names")
+ }
+ name := flags.Arg(0)
+ said, err := stopByName(kinds, name, *all)
+ if err != nil {
+ return err
+ }
+ fmt.Println(said)
+ return nil
+}
+
+// stopByName finds the one kind that has the name and stops it there. With
+// --all the name is taken to mean the kind that can stop several at once.
+func stopByName(kinds []deviceKind, name string, all bool) (string, error) {
+ if all {
+ return stopAllByName(kinds, name)
+ }
+ kind, err := kindOf(kinds, name)
+ if err != nil {
+ return "", err
+ }
+ return kind.stop(name)
+}
+
+func stopAllByName(kinds []deviceKind, name string) (string, error) {
+ for _, kind := range kinds {
+ if many, ok := kind.(allStopper); ok {
+ return many.stopAll(name)
+ }
+ }
+ return "", unknownDevice(name)
+}
+
+// kindOf is the one kind that has a device called name. A name in two kinds is
+// as ambiguous as two in one, and is answered the same way: say so, and point at
+// the list.
+func kindOf(kinds []deviceKind, name string) (deviceKind, error) {
+ var hits []deviceKind
+ var failed error
+ for _, kind := range kinds {
+ n, err := kind.find(name)
+ failed = cmp.Or(failed, err)
+ if n > 0 {
+ hits = append(hits, kind)
+ }
+ }
+ switch {
+ case len(hits) == 1:
+ return hits[0], nil
+ case len(hits) > 1:
+ return nil, fmt.Errorf("more than one device is called %q, so the name cannot pick one — `codeaf devices` lists them", name)
+ case failed != nil:
+ return nil, failed
+ }
+ return nil, unknownDevice(name)
+}
+
+func unknownDevice(name string) error {
+ return fmt.Errorf("no device is called %q — `codeaf devices` lists the ones there are", name)
+}
+
+// ── devices that can use this machine ───────────────────────────────────────
+
+// bookKind is the devices this machine has let in (`codeaf serve`), and the
+// machines this device can reach, which are listed with them because a person
+// asking "what am I paired with" means both directions.
+type bookKind struct{ book *pair.Book }
+
+func (b bookKind) table(now time.Time) (string, error) {
+ paired, err := b.book.Devices()
+ if err != nil {
+ return "", err
+ }
+ known, err := pair.Machines()
+ if err != nil {
+ return "", err
+ }
+ letIn, err := b.letIn(paired, now)
+ return strings.TrimLeft(letIn+pair.MachinesList(known, now), "\n"), err
+}
+
+// letIn is the table of devices let in, or "" when there are none. The machine's
+// own name is only worth saying when there is a device it matters to.
+func (bookKind) letIn(paired []pair.Paired, now time.Time) (string, error) {
+ if len(paired) == 0 {
+ return "", nil
+ }
+ device, err := pair.ThisDevice(pair.OpenKeeper())
+ if err != nil {
+ return "", err
+ }
+ return pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), now), nil
+}
+
+func (b bookKind) find(name string) (int, error) {
+ paired, err := b.book.Devices()
+ return countCalled(paired, name, func(p pair.Paired) string { return p.Label }), err
+}
+
+func (b bookKind) stop(name string) (string, error) {
+ gone, err := b.book.Revoke(name)
+ if err != nil {
+ return "", err
+ }
+ return pair.RevokedLine(gone.Label), nil
+}
+
+// stopAll answers a single device in the plain form's own sentence, because one
+// device stopped is one device stopped whichever flag was typed.
+func (b bookKind) stopAll(name string) (string, error) {
+ count, err := b.book.RevokeAll(name)
+ switch {
+ case err != nil:
+ return "", err
+ case count == 1:
+ return pair.RevokedLine(name), nil
+ }
+ return fmt.Sprintf("%d devices called %s were removed — each needs a new pairing code to come back.", count, name), nil
+}
+
+// countCalled counts the items whose label is name, ignoring case as the book does.
+func countCalled[T any](items []T, name string, label func(T) string) int {
+ n := 0
+ for _, item := range items {
+ if strings.EqualFold(label(item), name) {
+ n++
+ }
+ }
+ return n
+}
+
+// ── devices with your chats ─────────────────────────────────────────────────
+
+// chatsKind is the computers that hold the person's chats: the Device records of
+// their identity in the relay's directory. Nothing is cached, so a listing and a
+// stop each see the directory as it is now.
+type chatsKind struct {
+ dir directory.Client
+ key []byte // opens the sealed names
+ self string // this computer's device id
+ relay string // named when the relay cannot be reached
+}
+
+// openChatsKind is the kind for this machine, and false when it has none: sync
+// off, or no identity yet. Both are the emptiness law, not an error, so the
+// heading is simply absent.
+func openChatsKind() (chatsKind, bool) {
+ s, ok, err := syncsetup.Open(home.Dir())
+ if err != nil || !ok {
+ return chatsKind{}, false
+ }
+ return chatsKindOf(s.Dir, s.Identity.CellKey(), s.Device.ID(), s.Relay), true
+}
+
+func chatsKindOf(dir directory.Client, cellKey []byte, self, relay string) chatsKind {
+ return chatsKind{dir: dir, key: directory.MetadataKey(cellKey), self: self, relay: relay}
+}
+
+// chatsRow is one computer: what the table shows, and the id the directory knows
+// it by.
+type chatsRow struct {
+ pair.ChatsDevice
+ id string
+}
+
+// listRequestWithin bounds one request to the relay so a silent one cannot hang
+// the command.
+const listRequestWithin = 20 * time.Second
+
+func (c chatsKind) rows() ([]chatsRow, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), listRequestWithin)
+ defer cancel()
+ l, err := c.dir.List(ctx)
+ if err != nil {
+ return nil, c.explain(err)
+ }
+ open := func(sealed string) (string, error) { return directory.OpenName(c.key, sealed) }
+ rows := make([]chatsRow, 0, len(l.Devices))
+ for id, d := range l.Devices {
+ rows = append(rows, chatsRow{
+ ChatsDevice: pair.ChatsDevice{Name: chatlist.DeviceName(l.Devices, id, open), This: id == c.self, Stopped: d.Revoked},
+ id: id,
+ })
+ }
+ // This computer first, removed ones last, then by name: the order the chat's list uses.
+ slices.SortFunc(rows, func(a, b chatsRow) int {
+ return cmp.Or(cmp.Compare(rank(a.Stopped), rank(b.Stopped)), -cmp.Compare(rank(a.This), rank(b.This)), cmp.Compare(a.Name, b.Name), cmp.Compare(a.id, b.id))
+ })
+ return rows, nil
+}
+
+// rank is a bool as the number a sort can compare.
+func rank(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func (c chatsKind) table(time.Time) (string, error) {
+ rows, err := c.rows()
+ if err != nil {
+ // The other kind is still worth showing when the relay is not answering.
+ fmt.Fprintln(os.Stderr, "your devices cannot be listed now:", err)
+ return "", nil
+ }
+ // A list of only this computer says nothing a person did not know.
+ if len(rows) == 0 || (len(rows) == 1 && rows[0].This) {
+ return "", nil
+ }
+ shown := make([]pair.ChatsDevice, len(rows))
+ for i, row := range rows {
+ shown[i] = row.ChatsDevice
+ }
+ return pair.ChatsDevicesList(shown), nil
+}
+
+func (c chatsKind) called(name string) ([]chatsRow, error) {
+ rows, err := c.rows()
+ return slices.DeleteFunc(rows, func(r chatsRow) bool { return !strings.EqualFold(r.Name, name) }), err
+}
+
+func (c chatsKind) find(name string) (int, error) {
+ rows, err := c.called(name)
+ return len(rows), err
+}
+
+func (c chatsKind) stop(name string) (string, error) {
+ rows, err := c.called(name)
+ switch {
+ case err != nil:
+ return "", err
+ case len(rows) > 1:
+ return "", fmt.Errorf("more than one of your devices is called %q, so the name cannot pick one — `codeaf devices` lists them", name)
+ case len(rows) == 0:
+ return "", unknownDevice(name)
+ case rows[0].Stopped:
+ return rows[0].Name + " is already removed.", nil
+ }
+ return c.revoke(rows[0])
+}
+
+func (c chatsKind) revoke(row chatsRow) (string, error) {
+ if row.This {
+ return "", errors.New("that is this device — remove it from another of your devices instead")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), listRequestWithin)
+ defer cancel()
+ if err := c.dir.Revoke(ctx, row.id); err != nil {
+ return "", c.explain(err)
+ }
+ return pair.ChatsRevokedLine(row.Name), nil
+}
+
+// explain turns the directory's errors into the sentence a person reads.
+func (c chatsKind) explain(err error) error {
+ switch {
+ case errors.Is(err, directory.ErrRevoked):
+ return errors.New("this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back")
+ case errors.Is(err, wireauth.ErrSkew):
+ return errors.New(chatlist.ClockOff)
+ case errors.Is(err, directory.ErrUnreachable):
+ return fmt.Errorf("the sync service at %s cannot be reached from here — check this computer's network", c.relay)
+ case errors.Is(err, directory.ErrNotFound):
+ return errors.New("that device is not in your list any more — `codeaf devices` shows the ones there are")
+ }
+ return err
+}
diff --git a/cmd/codeaf/devices_revoked_test.go b/cmd/codeaf/devices_revoked_test.go
new file mode 100644
index 0000000000..1c110531ba
--- /dev/null
+++ b/cmd/codeaf/devices_revoked_test.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/directory"
+)
+
+// A device stopped from another computer is a "revoked" row in the device list the next time it is
+// read, on every computer, with no way to take it for an ordinary one.
+func TestRevokedDeviceReadsAsRevokedOnEveryComputer(t *testing.T) {
+ m := directory.NewMemory(time.Now)
+ ctx := context.Background()
+ key := []byte("0123456789abcdef0123456789abcdef")
+ for _, id := range []string{"dev_a", "dev_b", "dev_c"} {
+ sealed, _ := directory.SealName(directory.MetadataKey(key), id)
+ if err := m.For(id).PutDevice(ctx, id, directory.Device{V: 1, Name: sealed, Platform: "darwin"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := m.For("dev_a").Revoke(ctx, "dev_b"); err != nil {
+ t.Fatal(err)
+ }
+ for _, viewer := range []string{"dev_a", "dev_c"} {
+ l, err := m.For(viewer).List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, row := range rowsOf(l, directory.MetadataKey(key), viewer) {
+ if want := row.ID == "dev_b"; row.Revoked != want {
+ t.Errorf("seen from %s, %s Revoked = %v", viewer, row.ID, row.Revoked)
+ }
+ }
+ }
+}
+
+// A name sealed by a build from before devices were named carries the macOS ".local"; the device
+// list reads it like every other label does.
+func TestOpenNameDropsTheLocalSuffixLikeEveryOtherLabel(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ sealed, err := directory.SealName(directory.MetadataKey(key), "studio.local")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := openName(directory.MetadataKey(key), sealed); got != "studio" {
+ t.Fatalf("openName = %q", got)
+ }
+}
diff --git a/cmd/codeaf/devices_test.go b/cmd/codeaf/devices_test.go
new file mode 100644
index 0000000000..ded1f0f2e3
--- /dev/null
+++ b/cmd/codeaf/devices_test.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/pair"
+)
+
+const (
+ thisComputer = "dev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ otherComputer = "dev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+)
+
+var devicesCtx = context.Background()
+
+// chatsWith is a directory holding this computer and one other, both named the
+// way a real device names itself: sealed under the metadata key.
+func chatsWith(t *testing.T, other string) (chatsKind, directory.Client) {
+ t.Helper()
+ cellKey := bytes.Repeat([]byte{7}, 32)
+ dir := directory.NewMemory(time.Now)
+ for id, name := range map[string]string{thisComputer: "desk", otherComputer: other} {
+ sealed, err := directory.SealName(directory.MetadataKey(cellKey), name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := dir.For(id).PutDevice(devicesCtx, id, directory.Device{V: 1, Name: sealed}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return chatsKindOf(dir.For(thisComputer), cellKey, thisComputer, "https://relay.example"), dir.For(thisComputer)
+}
+
+// bookWith is a book of remote devices let in, and a fresh home to keep this
+// machine's own key in.
+func bookWith(t *testing.T, labels ...string) *pair.Book {
+ t.Helper()
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ book := pair.BookAt(filepath.Join(t.TempDir(), "devices.json"))
+ for i, label := range labels {
+ if err := book.Admit(pair.Paired{Label: label, Key: strings.Repeat("k", i+1), Since: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return book
+}
+
+func listed(t *testing.T, kinds ...deviceKind) string {
+ t.Helper()
+ printed, err := captureStdout(t, func() error { return listDevices(kinds, time.Now()) })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return printed
+}
+
+// `codeaf devices` lists both kinds under their two headings, in that order,
+// and one `revoke ` stops either.
+func TestDevicesBothKinds(t *testing.T) {
+ chats, dir := chatsWith(t, "laptop")
+ kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}}
+
+ printed := listed(t, kinds...)
+ first, second := strings.Index(printed, "your devices"), strings.Index(printed, "devices that can use this machine")
+ if first < 0 || second < first {
+ t.Fatalf("the two headings are missing or out of order:\n%s", printed)
+ }
+ for _, want := range []string{"laptop", "this device", "phone"} {
+ if !strings.Contains(printed, want) {
+ t.Errorf("the list lacks %q:\n%s", want, printed)
+ }
+ }
+
+ if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"laptop"}) }); err != nil {
+ t.Fatalf("stopping a computer that holds the chats: %v", err)
+ }
+ l, err := dir.List(devicesCtx)
+ if err != nil || !l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked {
+ t.Fatalf("directory after the stop: %+v, %v", l.Devices, err)
+ }
+ if after := listed(t, kinds...); !strings.Contains(after, "removed") {
+ t.Fatalf("a stopped computer is not marked:\n%s", after)
+ }
+
+ if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone"}) }); err != nil {
+ t.Fatalf("stopping a device that can use this machine: %v", err)
+ }
+ if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 0 {
+ t.Fatalf("the remote device is still let in: %v", left)
+ }
+}
+
+// A kind with nothing in it has no heading, and a machine with nothing of
+// either kind says one sentence.
+func TestDevicesEmptinessLaw(t *testing.T) {
+ chats, _ := chatsWith(t, "laptop")
+ only := listed(t, chats, bookKind{bookWith(t)})
+ if strings.Contains(only, "devices that can use this machine") {
+ t.Fatalf("an empty kind still has its heading:\n%s", only)
+ }
+ if none := listed(t, bookKind{bookWith(t)}); strings.TrimSpace(none) != pair.NoDevices {
+ t.Fatalf("nothing paired said %q, want the one sentence", none)
+ }
+}
+
+// A name that two devices answer to, or nobody does, or that is this computer,
+// is one sentence that points at the list.
+func TestDevicesRevokeNamesThatCannotPickOne(t *testing.T) {
+ chats, dir := chatsWith(t, "phone")
+ kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}}
+ for name, want := range map[string]string{"phone": "more than one", "ghost": "no device is called", "desk": "this device"} {
+ _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{name}) })
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("revoke %s: %v, want a sentence with %q", name, err, want)
+ }
+ }
+ if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked {
+ t.Fatalf("a refused name still stopped a computer: %+v", l.Devices)
+ }
+ // --all is the remote-access kind's word, so the name means that kind.
+ if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone", "--all"}) }); err != nil {
+ t.Fatal(err)
+ }
+ if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked {
+ t.Fatal("--all stopped a computer that holds the chats")
+ }
+}
+
+// What stopping cannot do is said where a person looks for it: in the sentence
+// after the stop and in the help. Neither names a command that does not exist.
+func TestDevicesRevokeText(t *testing.T) {
+ chats, _ := chatsWith(t, "laptop")
+ said, err := captureStdout(t, func() error { return stopDevice([]deviceKind{chats}, []string{"laptop"}) })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The front page has no room for the whole warning, so its line says that
+ // stopping cannot undo what the computer holds, and the sentence after a
+ // stop says what that means for a stolen one.
+ says := map[string][]string{
+ usageForCommand("devices revoke"): {"cannot undo"},
+ said: {"cannot undo", "treat your chats as exposed", "codeaf identity rotate"},
+ }
+ for text, wants := range says {
+ for _, want := range wants {
+ if !strings.Contains(text, want) {
+ t.Errorf("%q does not say %q", text, want)
+ }
+ }
+ }
+}
+
+// This computer is listed first and a removed one last, whatever the names sort
+// to, so the row a person is typing on is always where the eye starts.
+func TestChatsTableListsThisComputerFirst(t *testing.T) {
+ kind, dir := chatsWith(t, "atlas")
+ rows, err := kind.rows()
+ if err != nil || len(rows) != 2 || !rows[0].This || rows[1].Name != "atlas" {
+ t.Fatalf("rows were %+v (%v), want this computer ahead of atlas", rows, err)
+ }
+ if err := dir.Revoke(devicesCtx, otherComputer); err != nil {
+ t.Fatal(err)
+ }
+ table, _ := kind.table(time.Now())
+ if strings.Index(table, "desk") > strings.Index(table, "atlas") || !strings.Contains(table, "removed") {
+ t.Fatalf("the table was:\n%s\nwant desk first and atlas marked removed", table)
+ }
+}
+
+// A list holding only this computer says nothing a person did not know, so it
+// is not drawn and the one sentence takes its place.
+func TestChatsTableOfOnlyThisComputerIsAbsent(t *testing.T) {
+ cellKey := bytes.Repeat([]byte{7}, 32)
+ dir := directory.NewMemory(time.Now)
+ sealed, err := directory.SealName(directory.MetadataKey(cellKey), "desk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := dir.For(thisComputer).PutDevice(devicesCtx, thisComputer, directory.Device{V: 1, Name: sealed}); err != nil {
+ t.Fatal(err)
+ }
+ kind := chatsKindOf(dir.For(thisComputer), cellKey, thisComputer, "https://relay.example")
+ if table, err := kind.table(time.Now()); table != "" || err != nil {
+ t.Fatalf("a lone computer drew %q (%v), want nothing", table, err)
+ }
+}
diff --git a/cmd/codeaf/devrename.go b/cmd/codeaf/devrename.go
new file mode 100644
index 0000000000..3bebfb8537
--- /dev/null
+++ b/cmd/codeaf/devrename.go
@@ -0,0 +1,77 @@
+package main
+
+// ── naming this computer: `codeaf devices rename ` ────────────────────
+//
+// THE NAME IS KEPT HERE FIRST AND TOLD TO THE FLEET SECOND. A rename that
+// needed the network would fail on a plane, and a name that was typed and then
+// lost would be a person typing it twice; so the name is written to this
+// computer's home, and the same upsert every chat start makes ([syncsetup.Sync.Rename])
+// carries it to the other devices when it can. If it cannot, the next chat
+// start does, and the sentence says so.
+//
+// A device only ever writes its own record, so this verb names the computer it
+// is typed on and no other.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/devname"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// renameWithin bounds the one request a rename makes, so a silent relay cannot
+// hang the command.
+const renameWithin = 20 * time.Second
+
+// renameThisDevice keeps typed as the name of the computer whose home is dir,
+// tells the fleet when it may, and returns the sentence a person reads.
+func renameThisDevice(ctx context.Context, dir, typed string) (string, error) {
+ name, err := devname.Set(dir, typed)
+ if err != nil {
+ return "", err
+ }
+ if !syncsetup.MayTalk(dir) {
+ return pair.RenamedAloneLine(name), nil
+ }
+ if tellFleet(ctx, dir, name) != nil {
+ return pair.RenamedHereLine(name), nil
+ }
+ return pair.RenamedLine(name), nil
+}
+
+// tellFleet puts the new name in the directory, and says why not when it cannot.
+func tellFleet(ctx context.Context, dir, name string) error {
+ s, ok, err := syncsetup.Open(dir)
+ if err != nil || !ok {
+ return errors.Join(err, errors.New("sync is off"))
+ }
+ ctx, cancel := context.WithTimeout(ctx, renameWithin)
+ defer cancel()
+ return s.Rename(ctx, name)
+}
+
+// runDevicesRename is `codeaf devices rename `. The name may be several
+// words, typed with or without quotes.
+func runDevicesRename(args []string) error {
+ // Its own usage entry names the command; it takes no flags of its own.
+ flags := commandFlags("devices rename")
+ if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
+ return err
+ }
+ typed := strings.Join(flags.Args(), " ")
+ if strings.TrimSpace(typed) == "" {
+ return errors.New("usage: codeaf devices rename \u2014 " + devname.ErrEmpty.Error())
+ }
+ said, err := renameThisDevice(context.Background(), home.Dir(), typed)
+ if err != nil {
+ return err
+ }
+ fmt.Println(said)
+ return nil
+}
diff --git a/cmd/codeaf/devrename_test.go b/cmd/codeaf/devrename_test.go
new file mode 100644
index 0000000000..b4127516b0
--- /dev/null
+++ b/cmd/codeaf/devrename_test.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/devname"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/pair"
+)
+
+// A computer that is not paired with anything keeps the name and says nobody
+// needs telling yet; it never reaches for the sync service.
+func TestRenameOnAComputerWithNoFleetKeepsTheNameHere(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ t.Setenv("CODEAF_SYNC_URL", "off")
+
+ said, err := renameThisDevice(context.Background(), dir, " atlas ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if said != pair.RenamedAloneLine("atlas") || devname.Name(dir) != "atlas" || deviceName() != "atlas" {
+ t.Fatalf("said %q, name %q, deviceName %q", said, devname.Name(dir), deviceName())
+ }
+}
+
+func TestARefusedRenameKeepsTheOldNameAndSaysWhy(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ if _, err := devname.Set(dir, "atlas"); err != nil {
+ t.Fatal(err)
+ }
+ for typed, want := range map[string]error{"": devname.ErrEmpty, strings.Repeat("x", 33): devname.ErrTooLong, "a\nb": devname.ErrControl} {
+ if _, err := renameThisDevice(context.Background(), dir, typed); !errors.Is(err, want) {
+ t.Errorf("rename to %q: %v, want %v", typed, err, want)
+ }
+ }
+ if devname.Name(dir) != "atlas" {
+ t.Fatalf("a refused rename changed the name to %q", devname.Name(dir))
+ }
+}
+
+func TestDevicesRenameTakesSeveralWordsAndAnEmptyNameIsUsage(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ t.Setenv("CODEAF_SYNC_URL", "off")
+ if _, err := captureStdout(t, func() error { return runDevices([]string{"rename", "kitchen", "laptop"}) }); err != nil {
+ t.Fatal(err)
+ }
+ if devname.Name(dir) != "kitchen laptop" {
+ t.Fatalf("name = %q", devname.Name(dir))
+ }
+ if err := runDevices([]string{"rename"}); err == nil || !strings.Contains(err.Error(), "usage: codeaf devices rename") {
+ t.Fatalf("an empty rename ended with %v", err)
+ }
+}
+
+// `codeaf pair --name` keeps the name before any relay is looked up, so a bad
+// name stops the pairing with its reason and nothing is sent.
+func TestPairNameFlagIsKeptAndSentAsTheJoiningLabel(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ t.Setenv("CODEAF_SYNC_URL", "off")
+
+ err := newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"--name", "atlas", "42-715-302"})
+ if !errors.Is(err, pair.ErrSyncOff) {
+ t.Fatalf("the pairing ended with %v, want it to reach the relay check", err)
+ }
+ if got := pairJoiningAt(dir)(false).Label; got != "atlas" {
+ t.Fatalf("the joining label is %q, want the typed name", got)
+ }
+
+ err = newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"--name", "bad\x00name", "42-715-302"})
+ if !errors.Is(err, devname.ErrControl) {
+ t.Fatalf("a bad name ended with %v", err)
+ }
+ if devname.Name(dir) != "atlas" {
+ t.Fatalf("a refused --name changed the name to %q", devname.Name(dir))
+ }
+}
+
+// A person at a terminal who has never named this computer is asked once, with
+// the host name filled in; Enter keeps it and writes nothing, a name is kept, and
+// a bad one is refused and asked again.
+func TestAJoinAsksForANameAndEnterKeepsTheHostName(t *testing.T) {
+ cases := []struct {
+ typed, want string
+ chosen bool
+ }{
+ {"\n", devname.Default(), false},
+ {"studio\n", "studio", true},
+ {"bad\x00\ngood name\n", "good name", true},
+ }
+ for _, c := range cases {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ out := &pairScreenText{}
+ d := pairDoor{term: newTerminal(strings.NewReader(c.typed), out), asksName: true}
+ if err := d.naming(context.Background(), "", nil, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, chosen := devname.Chosen(dir); chosen != c.chosen || devname.Name(dir) != c.want {
+ t.Errorf("typed %q: name %q chosen %t, want %q %t", c.typed, devname.Name(dir), chosen, c.want, c.chosen)
+ }
+ if !strings.Contains(out.String(), pair.NamePrompt(devname.Default())) {
+ t.Errorf("typed %q: the question was not asked:\n%s", c.typed, out.String())
+ }
+ }
+}
+
+// The question is for a join that has no name yet: not for the device that shows
+// a code or approves, not when the computer already has a name, and not when
+// nobody is at a terminal.
+func TestTheNameQuestionIsAskedOnlyWhereItMeansSomething(t *testing.T) {
+ asked := func(door pairDoor, args []string, showing bool) bool {
+ out := &pairScreenText{}
+ door.term = newTerminal(strings.NewReader("\n"), out)
+ _ = door.naming(context.Background(), "", args, showing)
+ return strings.Contains(out.String(), "Press Enter to keep that")
+ }
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ key, err := pair.NewLinkKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := pair.LinkRef{Code: "k7m2q9xd", Key: key}.Token()
+ person := pairDoor{asksName: true}
+ if !asked(person, nil, false) || !asked(person, []string{"42-715-302"}, false) {
+ t.Fatal("a join with no name was not asked")
+ }
+ if asked(person, nil, true) || asked(person, []string{"approve", token}, false) || asked(person, []string{token}, false) {
+ t.Fatal("a door that shows a code or approves was asked for a name")
+ }
+ if asked(pairDoor{}, nil, false) {
+ t.Fatal("a run with nobody at a terminal was asked")
+ }
+ if _, err := devname.Set(home.Dir(), "atlas"); err != nil {
+ t.Fatal(err)
+ }
+ if asked(person, nil, false) {
+ t.Fatal("a computer that already has a name was asked again")
+ }
+}
diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go
index 6252ec504d..233b029c67 100644
--- a/cmd/codeaf/do.go
+++ b/cmd/codeaf/do.go
@@ -1,13 +1,16 @@
package main
import (
+ "cmp"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
+ "flag"
"fmt"
"github.com/Agent-Field/codeaf/internal/crewroute"
+ "github.com/Agent-Field/codeaf/internal/devname"
"github.com/Agent-Field/codeaf/internal/router"
"io"
"log"
@@ -354,6 +357,8 @@ func runDo(args []string) error {
slotsRaw := flags.String("slots", "",
"how many workers may run at once for this run; 0 is no limit "+
"(default: your task.parallel setting, which is no limit)")
+ continueID := flags.String("continue", "", "carry on a run that did not finish (or was kept with --keep): "+
+ "its id is on the `record kept at` line; any words after it are this round's finding")
debug := flags.Bool("debug", false, debugFlagHelp())
if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
return err
@@ -378,7 +383,7 @@ func runDo(args []string) error {
// the model-call log carries, and a second reading could name a different
// run in a process that had opened two.
run := trace.RunFrom(ctx)
- task, err := readText(flags.Name(), flags.Args())
+ next, err := errandTask(flags, *continueID)
if err != nil {
return err
}
@@ -394,7 +399,7 @@ func runDo(args []string) error {
}
return doErrand(doRequest{
effort: effort, pins: pins.pins,
- task: task, run: run, database: *database, keep: *keep, workspace: *workspace,
+ task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace,
timeout: wall.wall, asJSON: *asJSON,
yesSpend: *yesSpend, model: *model, planModel: *planModel, checkModel: *checkModel,
contextFill: *contextFill, completionReserve: *completionReserve, slots: slots,
@@ -406,6 +411,12 @@ func runDo(args []string) error {
// whole command rather than a piece of it.
type doRequest struct {
task string
+ // assignment is what the person first asked for, when task is a
+ // continuation of it. It names the run; empty is a fresh run, named by task.
+ assignment string
+ // cellRoot is the cell a continuation goes on working in; empty mints one
+ // (cells on, the default) or runs on the host.
+ cellRoot string
// run is the id this invocation minted at the door ([trace.Begin]). It goes
// out on the `--json` envelope, where it is the join to the model-call log
// and to the debug record's folder, both of which are named by it.
@@ -460,6 +471,31 @@ type doRequest struct {
newBeltCompleter func(model string) session.Completer
}
+// headline is what names this run: the assignment it carries on, or its task.
+func (r doRequest) headline() string {
+ return cmp.Or(r.assignment, r.task)
+}
+
+// errandTask is the task text of this invocation and, on a continuation, the
+// assignment it carries on. The words after --continue are the finding.
+func errandTask(flags *flag.FlagSet, continueID string) (continuation, error) {
+ if continueID == "" {
+ task, err := readText(flags.Name(), flags.Args())
+ return continuation{brief: task}, err
+ }
+ if err := continuesOnRunRoad(); err != nil {
+ return continuation{}, err
+ }
+ return continuedBrief(continueID, strings.Join(flags.Args(), " "))
+}
+
+// sayRecordKept names a kept record on stderr, and how to carry it on. The
+// path stays the last line, which is where callers read it.
+func sayRecordKept(w io.Writer, folder string) {
+ fmt.Fprintln(w, continueHint(folder))
+ fmt.Fprintf(w, "record kept at %s\n", folder)
+}
+
func (r doRequest) residentWaitOrDefault() time.Duration {
if r.residentWait > 0 {
return r.residentWait
@@ -581,16 +617,16 @@ func doErrand(request doRequest) error {
fmt.Fprintln(request.stderr, seats.Report())
call := router.CrewCallID(request.run)
if seats.Crew != nil {
- config.LogCrewDecision(profileDir, call, *seats.Crew, repo, crewTitle(request.task))
+ config.LogCrewDecision(profileDir, call, *seats.Crew, repo, crewTitle(request.headline()))
}
outcome, err := errandRun(request, seats, started)
if err != nil {
if !request.asJSON {
if outcome.recordKept != "" {
- fmt.Fprintf(request.stderr, "record kept at %s\n", outcome.recordKept)
+ sayRecordKept(request.stderr, outcome.recordKept)
}
if seats.Crew != nil {
- config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), router.CrewNotKept, 0)
+ config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.headline()), router.CrewNotKept, 0)
}
return err
}
@@ -607,7 +643,7 @@ func doErrand(request doRequest) error {
if outcome.resolvedStop() == stopDone {
settled = router.CrewAccepted
}
- config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), settled, outcome.Spend)
+ config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.headline()), settled, outcome.Spend)
fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line("", outcome.Spend))
}
// THE RUN NAMES ITSELF ON EVERY PATH, including the one where nothing
@@ -856,7 +892,7 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom
// during the unwind kills the process the way it always did. SIGKILL is
// outside all of this and stays correct by accident: no defer runs, so
// nothing deletes the store either.
- signalled, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ signalled, stopSignals := signal.NotifyContext(hostSeated(context.Background()), os.Interrupt, syscall.SIGTERM)
defer stopSignals()
// The person's own ending of the run is the one fact the usage counts
// keep about how it ended: the interrupt word, not a failure word.
@@ -1236,9 +1272,11 @@ func sameStore(left, right string) bool {
return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)
}
+// residentHost names the computer that holds the store, spelled as every other
+// screen spells a computer ([devname.Shown]): the holder recorded its raw host name.
func residentHost(holder *lease.Resident) string {
if host := strings.TrimSpace(holder.Host); host != "" {
- return host
+ return devname.Shown(host)
}
return "unknown host"
}
@@ -3404,7 +3442,7 @@ func withoutSummaryFileList(deliverable string, artifacts []string) string {
func reportErrand(request doRequest, outcome headlessOutcome) error {
sayBlocked(request.stderr, outcome)
if outcome.recordKept != "" && request.stderr != nil {
- fmt.Fprintf(request.stderr, "record kept at %s\n", outcome.recordKept)
+ sayRecordKept(request.stderr, outcome.recordKept)
}
if request.asJSON {
encoded, err := json.MarshalIndent(errandEnvelope(outcome), "", " ")
@@ -3485,7 +3523,7 @@ func errandFooter(outcome headlessOutcome) string {
parts = append(parts, elapsed.String())
}
if outcome.Nodes > 0 {
- parts = append(parts, plural(outcome.Nodes, "node"))
+ parts = append(parts, plural(outcome.Nodes, "part"))
}
if spent := config.SpentFigure(outcome.Spend); spent != "" {
parts = append(parts, spent)
@@ -3639,7 +3677,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
// files were there first, still hold what they held, and are none of the
// run's business ([session.RunTreeSnapshot]).
before := session.SnapshotRunTree(workspace)
- title := topicTitle(request.task)
+ title := topicTitle(request.headline())
_, recordDir, _, err := headlessStore("")
if err != nil {
return headlessOutcome{}, err
@@ -3658,6 +3696,13 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
}
outcome.recordKept = recordDir
}()
+ inCell, err := openErrandCell(workspace, request.cellRoot)
+ if err == nil {
+ err = inCell.pointFrom(recordDir)
+ }
+ if err != nil {
+ return headlessOutcome{}, err
+ }
store, err := session.OpenRunPlanAt(filepath.Join(recordDir, "plandb.db"), title, request.task)
if err != nil {
return headlessOutcome{}, err
@@ -3712,7 +3757,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
Work: seats.Work.Model,
Plan: seats.Plan.Model,
Check: seats.Check.Model,
- }, doStanding(workspace), completerFor),
+ }, doStanding(workspace), completerFor, inCell.workerOptions()...),
})
errand := headlessOutcome{
Artifacts: []string{},
diff --git a/cmd/codeaf/do_cell.go b/cmd/codeaf/do_cell.go
new file mode 100644
index 0000000000..dcfdf53dcc
--- /dev/null
+++ b/cmd/codeaf/do_cell.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/executor"
+ runengine "github.com/Agent-Field/codeaf/internal/run"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// A RUN IN A CELL IS A CHAT'S SESSION WITHOUT THE CHAT. With cells on (the default) the
+// run's workers ride the seat a chat rides — minted and built by the same
+// functions ([v3MintSession], [v3Seated]) — so every tool call seals and `codeaf
+// cell log` shows the run's turns. The run's record folder points at the cell
+// (cellPointer), and `do --continue` follows the pointer back to it.
+
+// cellPointer is the file in a record folder that names the cell the run worked
+// in. A record without one is a pre-cell run and continues from the record alone.
+const cellPointer = "cell"
+
+// runCellClass is the class a run's cell declares. A run's workers drive the
+// run's own record (`plandb done`, the trajectory), which lives outside the
+// directory they edit, so their calls cannot be confined to it: the cell is
+// host-bound and every call it seals is external, never re-run after a crash.
+const runCellClass = cell.HostBound
+
+// errandCell is the cell one run works in. The zero value is a run with no
+// cell: no seat, no pointer, the host as ever.
+type errandCell struct {
+ root string
+ seat executor.Seat
+}
+
+// openErrandCell puts a run in a cell: the one named by resume when a
+// continuation follows a record that has one, a new one otherwise. Flag off, it
+// answers the zero value and touches nothing.
+func openErrandCell(workspace, resume string) (errandCell, error) {
+ if !cell.Enabled() {
+ return errandCell{}, nil
+ }
+ place, err := errandPlace(workspace, resume)
+ if err != nil {
+ return errandCell{}, err
+ }
+ seat, _, _ := v3SeatOf(place, stderrSay)
+ return errandCell{root: place.Dir, seat: seat}, nil
+}
+
+// errandPlace is the session place of the run's cell: the folder, with the
+// directory the run edits as the tree its calls seal (a borrowed workspace).
+func errandPlace(workspace, resume string) (session.Place, error) {
+ if resume != "" {
+ return v3PlaceFor(resume, workspace, false), nil
+ }
+ bucket, err := v3ProjectDir(workspace)
+ if err != nil {
+ return session.Place{}, err
+ }
+ return v3MintSessionAs(runCellClass, bucket, workspace, v3StampLaunchDir(v3LaunchDir(), workspace), false)
+}
+
+// workerOptions seats every worker of the run on the cell's seat.
+func (c errandCell) workerOptions() []runengine.WorkerOption {
+ if c.seat == nil {
+ return nil
+ }
+ return []runengine.WorkerOption{runengine.WithSeat(c.seat)}
+}
+
+// pointFrom writes the pointer into the record folder. A run with no cell
+// writes nothing.
+func (c errandCell) pointFrom(record string) error {
+ if c.root == "" {
+ return nil
+ }
+ return os.WriteFile(filepath.Join(record, cellPointer), []byte(c.root+"\n"), 0o600)
+}
+
+// cellOf is the cell root a record points at, and empty for a pre-cell record
+// or one whose cell is gone: both continue from the record alone.
+func cellOf(record string) string {
+ raw, err := os.ReadFile(filepath.Join(record, cellPointer))
+ if err != nil {
+ return ""
+ }
+ root := strings.TrimSpace(string(raw))
+ if _, err := cell.OpenAt(root, filepath.Base(root)); err != nil {
+ return ""
+ }
+ return root
+}
+
+// sealedState is what a cell's chain holds of an earlier run: how many turns
+// were sealed, the last one, and the calls that began and never finished.
+type sealedState struct {
+ turns int
+ head string
+ unfinished []string
+}
+
+// sealedStateOf reads the chain of the cell at root. A cell that cannot be read
+// has sealed nothing that can be named.
+func sealedStateOf(root string) sealedState {
+ c, err := cell.OpenAt(root, filepath.Base(root))
+ if err != nil {
+ return sealedState{}
+ }
+ turns, _ := cellstore.Turns(c)
+ state := sealedState{turns: len(turns), unfinished: unfinishedCalls(c)}
+ if len(turns) > 0 {
+ state.head = short(turns[len(turns)-1].ID)
+ }
+ return state
+}
+
+// unfinishedCalls names each call the call log holds an intent for and no
+// completion. They are surfaced and never run again.
+func unfinishedCalls(c cell.Cell) []string {
+ _, rec, err := cellstore.OpenWAL(cellEngine(c).WALPath(c))
+ if err != nil {
+ return nil
+ }
+ lines := make([]string, len(rec.Incomplete))
+ for i, in := range rec.Incomplete {
+ lines[i] = fmt.Sprintf("- %s (%s), started %s", in.Tool, in.SideEffect,
+ time.UnixMilli(in.Started).UTC().Format("2006-01-02T15:04:05Z"))
+ }
+ return lines
+}
+
+// empty says the chain holds nothing worth a block in a brief.
+func (s sealedState) empty() bool { return s.turns == 0 && len(s.unfinished) == 0 }
+
+// words is the sealed state as the continuation brief carries it.
+func (s sealedState) words() string {
+ var out strings.Builder
+ fmt.Fprintf(&out, "%s\nThe run's cell sealed %d turns, the last %s; its files stand as that turn left them.", continueSealedHeader, s.turns, orDash(s.head))
+ if len(s.unfinished) > 0 {
+ out.WriteString("\nThese calls began and never finished. They are not run again; look at what they left in the tree before relying on it:\n")
+ out.WriteString(strings.Join(s.unfinished, "\n"))
+ }
+ return out.String()
+}
diff --git a/cmd/codeaf/do_cell_test.go b/cmd/codeaf/do_cell_test.go
new file mode 100644
index 0000000000..c0a84e46a2
--- /dev/null
+++ b/cmd/codeaf/do_cell_test.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// doInCell runs one scripted errand and answers the record it kept.
+func doInCell(t *testing.T, workspace, task, cellRoot string) string {
+ t.Helper()
+ var stdout, stderr strings.Builder
+ err := doErrand(doRequest{task: task, cellRoot: cellRoot, workspace: workspace, asJSON: true, keep: true,
+ timeout: 60 * time.Second, stdout: &stdout, stderr: &stderr,
+ newBeltCompleter: func(string) session.Completer { return finishingSeat(0) }})
+ if err != nil {
+ t.Fatalf("run: %v\n%s\n%s", err, stderr.String(), stdout.String())
+ }
+ lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
+ return strings.TrimPrefix(lines[len(lines)-1], "record kept at ")
+}
+
+func sealedTurns(t *testing.T, root string) int {
+ t.Helper()
+ c, err := cell.OpenAt(root, filepath.Base(root))
+ if err != nil {
+ t.Fatal(err)
+ }
+ turns, err := cellstore.Turns(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(turns)
+}
+
+func TestRunInCellSealsItsCallsAndContinuesFromThem(t *testing.T) {
+ beltRunEnv(t)
+ t.Setenv(cell.EnvVar, "1")
+ t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t))
+ workspace := beltRepoWorkspace(t)
+
+ first := doInCell(t, workspace, "write out.txt", "")
+ root := cellOf(first)
+ if root == "" {
+ t.Fatalf("the record %s points at no cell", first)
+ }
+ sealed := sealedTurns(t, root)
+ if sealed == 0 {
+ t.Fatal("the run's tool calls sealed nothing")
+ }
+
+ next, err := continuedBrief(recordID(first), "and one more")
+ if err != nil || next.cellRoot != root {
+ t.Fatalf("continuedBrief = %+v, %v; want the cell %s", next, err, root)
+ }
+ if !strings.Contains(next.brief, continueSealedHeader) {
+ t.Fatalf("the brief does not carry the sealed state:\n%s", next.brief)
+ }
+ second := doInCell(t, workspace, next.brief, next.cellRoot)
+ if cellOf(second) != root {
+ t.Fatalf("the continuation left the cell: %s", cellOf(second))
+ }
+ if again := sealedTurns(t, root); again <= sealed {
+ t.Fatalf("the continuation sealed no turn: %d then %d", sealed, again)
+ }
+}
+
+func TestRunWithCellsOffMakesNoCell(t *testing.T) {
+ beltRunEnv(t)
+ t.Setenv(cell.EnvVar, "0")
+ t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t))
+
+ record := doInCell(t, beltRepoWorkspace(t), "write out.txt", "")
+ if cellOf(record) != "" {
+ t.Fatal("a run with cells off points at a cell")
+ }
+ if _, err := os.Stat(filepath.Join(record, cellPointer)); !os.IsNotExist(err) {
+ t.Fatalf("a pointer was written: %v", err)
+ }
+ if _, err := os.Stat(home.Join("v3", "projects")); !os.IsNotExist(err) {
+ t.Fatalf("a project bucket was made: %v", err)
+ }
+}
+
+func TestPreCellRecordContinuesFromTheRecordAlone(t *testing.T) {
+ home := beltRunEnv(t)
+ folder := makeRecord(t, home, "77", "rename the logger")
+ prior, err := readPriorRun(folder)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if prior.cellRoot != "" || !prior.sealed.empty() {
+ t.Fatalf("a pre-cell record reads as sealed: %+v", prior)
+ }
+ if brief := continuationBrief(prior, ""); strings.Contains(brief, continueSealedHeader) {
+ t.Fatalf("a pre-cell brief names a sealed state:\n%s", brief)
+ }
+}
+
+func TestSealedStateIsInTheBriefAndTheAssignmentSurvivesIt(t *testing.T) {
+ prior := priorRun{assignment: "add a flag", ending: "it was stopped",
+ sealed: sealedState{turns: 3, head: "abc", unfinished: []string{"- bash (external), started now"}}}
+ brief := continuationBrief(prior, "finish it")
+ for _, want := range []string{continueSealedHeader, "sealed 3 turns", "never finished", "not run again", "bash (external)"} {
+ if !strings.Contains(brief, want) {
+ t.Fatalf("brief lacks %q:\n%s", want, brief)
+ }
+ }
+ if got := assignmentOf(brief); got != "add a flag" {
+ t.Fatalf("assignmentOf = %q", got)
+ }
+}
+
+func TestASealedChainIsNotAnEmptySession(t *testing.T) {
+ t.Setenv(cell.EnvVar, "1")
+ place, err := v3MintSession(t.TempDir(), t.TempDir(), "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !v3EmptySession(place.Dir) {
+ t.Fatal("a fresh cell is not empty")
+ }
+ if err := os.WriteFile(filepath.Join(place.Dir, cellstore.TurnsPath), []byte("{}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if v3EmptySession(place.Dir) {
+ t.Fatal("a cell with sealed turns would be reaped as litter")
+ }
+}
diff --git a/cmd/codeaf/do_continue.go b/cmd/codeaf/do_continue.go
new file mode 100644
index 0000000000..62a9f20507
--- /dev/null
+++ b/cmd/codeaf/do_continue.go
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ homepkg "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// CONTINUE IS THE SAME ASSIGNMENT PLUS ONE FINDING, run in the tree the last run
+// left. A headless run edits the person's own directory in place, so the tree is
+// already where it was; what a continuation needs from the record is the
+// assignment, how the run ended and what its plan reached. It is composed here
+// into the brief of an ordinary new run, so nothing below the door changes.
+//
+// The wrapper is written around the INNERMOST assignment ([assignmentOf]): a
+// continuation of a continuation names the first request once, not a stack of
+// wrappers.
+
+const (
+ // recordPrefix is what every private record folder is named with, and what
+ // an id leaves off: the id is the part a person can type.
+ recordPrefix = "codeaf-do-"
+ // continueAssignmentHeader introduces the assignment inside the wrapper.
+ continueAssignmentHeader = "The original assignment:\n"
+ // continuePreamble is exactly what [continuationBrief] writes before the
+ // assignment. It is matched EXACTLY when peeled, never as a substring.
+ continuePreamble = "Carry on a task a previous run started in this directory. Its edits are already on disk, so parts of the assignment may be done. " +
+ "Plan only what the assignment still needs; do not redo finished work or add verification the assignment does not ask for.\n\n" +
+ continueAssignmentHeader
+ continueEndingHeader = "How the previous run ended: "
+ continueReachedHeader = "What the previous run's plan reached:"
+ continueSealedHeader = "What the previous run sealed:"
+ continueAskedHeader = "What is asked of this round, in the person's words (the assignment above is unchanged):"
+ // resultClip bounds one task's result in the carried account.
+ resultClip = 400
+)
+
+// continueSections is every block the wrapper writes AFTER the assignment, so
+// [assignmentOf] knows where an assignment stops.
+var continueSections = []string{continueEndingHeader, continueReachedHeader, continueSealedHeader, continueAskedHeader}
+
+// endingWords is the root's status in the product's own words.
+var endingWords = map[plandb.Status]string{
+ plandb.StatusDone: "it finished",
+ plandb.StatusFailed: "it stopped before it finished",
+ plandb.StatusCancelled: "it was stopped",
+ plandb.StatusRunning: "it was interrupted",
+}
+
+// priorRun is what a kept record says about a run: the assignment, the ending
+// and one line for every task its plan made.
+type priorRun struct {
+ assignment string
+ ending string
+ reached []string
+ // cellRoot is the cell the run worked in, and sealed what that cell's chain
+ // holds; both are empty for a pre-cell record.
+ cellRoot string
+ sealed sealedState
+}
+
+// assignmentOf recovers the innermost assignment from a brief that may itself
+// be a continuation, and returns any other brief unchanged.
+func assignmentOf(brief string) string {
+ for {
+ rest, wrapped := strings.CutPrefix(brief, continuePreamble)
+ if !wrapped {
+ return brief
+ }
+ end := len(rest)
+ for _, section := range continueSections {
+ if at := strings.Index(rest, "\n\n"+section); at >= 0 && at < end {
+ end = at
+ }
+ }
+ brief = strings.TrimSpace(rest[:end])
+ }
+}
+
+// continuationBrief composes the brief of the next round: the wrapper, the
+// assignment, the ending, what the plan reached, and the person's words when
+// there are any. A block with nothing in it is not written.
+func continuationBrief(prior priorRun, words string) string {
+ var brief strings.Builder
+ brief.WriteString(continuePreamble + prior.assignment)
+ brief.WriteString("\n\n" + continueEndingHeader + prior.ending + ".")
+ if len(prior.reached) > 0 {
+ brief.WriteString("\n\n" + continueReachedHeader + "\n" + strings.Join(prior.reached, "\n"))
+ }
+ if !prior.sealed.empty() {
+ brief.WriteString("\n\n" + prior.sealed.words())
+ }
+ if words = strings.TrimSpace(words); words != "" {
+ brief.WriteString("\n\n" + continueAskedHeader + "\n" + words)
+ }
+ return brief.String()
+}
+
+// readPriorRun reads a kept record's plan. It opens the store the way a run
+// does, so a folder that is not a record is refused rather than created.
+func readPriorRun(folder string) (priorRun, error) {
+ path := filepath.Join(folder, "plandb.db")
+ if _, err := os.Stat(path); err != nil {
+ return priorRun{}, fmt.Errorf("%s holds no record of a run", folder)
+ }
+ store, err := plandb.Open(path, "", "", "", "")
+ if err != nil {
+ return priorRun{}, fmt.Errorf("read the record at %s: %w", folder, err)
+ }
+ defer store.Close()
+ root := store.Task(store.RootID())
+ if root == nil {
+ return priorRun{}, fmt.Errorf("the record at %s has no run in it", folder)
+ }
+ prior := priorRun{assignment: assignmentOf(strings.TrimSpace(root.Description)), ending: endingWords[root.Status]}
+ if prior.ending == "" {
+ prior.ending = "it did not finish"
+ }
+ for _, task := range store.Tasks() {
+ if task.ID != root.ID {
+ prior.reached = append(prior.reached, reachedLine(task))
+ }
+ }
+ if prior.cellRoot = cellOf(folder); prior.cellRoot != "" {
+ prior.sealed = sealedStateOf(prior.cellRoot)
+ }
+ return prior, nil
+}
+
+// reachedLine is one task of the earlier plan: how it stands and what it said.
+func reachedLine(task *plandb.Task) string {
+ said := strings.TrimSpace(task.Result + task.Error)
+ line := fmt.Sprintf("- %s [%s]", task.Title, task.Status)
+ if said == "" {
+ return line
+ }
+ return line + ": " + clip(firstLine(said), resultClip)
+}
+
+// resolveRecord turns what a person typed into a record folder: the id `record
+// kept at` printed, an unambiguous start of one, or the folder's own path.
+func resolveRecord(id string) (string, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return "", errors.New("--continue needs the id of a run · `record kept at` names it")
+ }
+ if info, err := os.Stat(id); err == nil && info.IsDir() {
+ return id, nil
+ }
+ found, err := recordsStartingWith(recordPrefix + strings.TrimPrefix(id, recordPrefix))
+ switch {
+ case err != nil || len(found) == 0:
+ return "", fmt.Errorf("no kept run is named %q · a run keeps its record when it did not finish, or with --keep", id)
+ case len(found) > 1:
+ return "", fmt.Errorf("%q names %d kept runs · say more of it", id, len(found))
+ }
+ return found[0], nil
+}
+
+// recordsStartingWith lists the record folders whose name starts with prefix.
+func recordsStartingWith(prefix string) ([]string, error) {
+ runs := homepkg.Join("runs")
+ entries, err := os.ReadDir(runs)
+ if err != nil {
+ return nil, err
+ }
+ var found []string
+ for _, entry := range entries {
+ if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
+ found = append(found, filepath.Join(runs, entry.Name()))
+ }
+ }
+ sort.Strings(found)
+ return found, nil
+}
+
+// recordID is the part of a record folder's name a person types after
+// `--continue`.
+func recordID(folder string) string {
+ return strings.TrimPrefix(filepath.Base(folder), recordPrefix)
+}
+
+// continueHint is the stderr line that makes a kept record findable as a run
+// to carry on.
+func continueHint(folder string) string {
+ return "continue it with: codeaf do --continue " + recordID(folder)
+}
+
+// continuation is what a continued run is handed: the brief of the next round,
+// the assignment it carries on, and the cell it goes on working in ("" when the
+// record has none).
+type continuation struct {
+ brief, assignment, cellRoot string
+}
+
+// continuedBrief is the whole door: an id and the person's words in, the next
+// round out.
+func continuedBrief(id, words string) (continuation, error) {
+ folder, err := resolveRecord(id)
+ if err != nil {
+ return continuation{}, err
+ }
+ prior, err := readPriorRun(folder)
+ if err != nil {
+ return continuation{}, err
+ }
+ return continuation{continuationBrief(prior, words), prior.assignment, prior.cellRoot}, nil
+}
+
+// continuesOnRunRoad says the run road can carry this request on: the older engine
+// keeps its record in another store, which --continue does not read.
+func continuesOnRunRoad() error {
+ if session.BashBeltAsked() {
+ return nil
+ }
+ return errors.New("--continue reads a run's record, which only the run engine keeps · unset CODEAF_TASK_BELT to use it")
+}
diff --git a/cmd/codeaf/do_continue_test.go b/cmd/codeaf/do_continue_test.go
new file mode 100644
index 0000000000..958b686341
--- /dev/null
+++ b/cmd/codeaf/do_continue_test.go
@@ -0,0 +1,176 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+func TestAssignmentOfPeelsExactlyOneWrapperAtATime(t *testing.T) {
+ first := continuationBrief(priorRun{assignment: "add a flag", ending: "it was stopped"}, "")
+ second := continuationBrief(priorRun{assignment: assignmentOf(first), ending: "it finished"}, "also a test")
+ if got := assignmentOf(second); got != "add a flag" {
+ t.Fatalf("assignment after two rounds = %q", got)
+ }
+ if strings.Count(second, continuePreamble) != 1 {
+ t.Fatalf("a continuation of a continuation nested:\n%s", second)
+ }
+}
+
+func TestAssignmentOfLeavesAPreambleInsideAnAssignmentAlone(t *testing.T) {
+ own := "explain this sentence: " + continuePreamble + "then stop"
+ if got := assignmentOf(own); got != own {
+ t.Fatalf("a preamble inside an assignment was peeled: %q", got)
+ }
+}
+
+func TestContinuationBriefWritesOnlyTheBlocksItHas(t *testing.T) {
+ bare := continuationBrief(priorRun{assignment: "do x", ending: "it finished"}, " ")
+ for _, header := range []string{continueReachedHeader, continueAskedHeader} {
+ if strings.Contains(bare, header) {
+ t.Fatalf("empty block %q was written:\n%s", header, bare)
+ }
+ }
+ full := continuationBrief(priorRun{assignment: "do x", ending: "it finished", reached: []string{"- a [done]"}}, "also y")
+ for _, want := range []string{"do x", continueEndingHeader + "it finished.", "- a [done]", continueAskedHeader + "\nalso y"} {
+ if !strings.Contains(full, want) {
+ t.Fatalf("brief lacks %q:\n%s", want, full)
+ }
+ }
+}
+
+func TestPersonsWordsNeverEnterTheAssignment(t *testing.T) {
+ brief := continuationBrief(priorRun{assignment: "do x", ending: "it finished"}, "also y")
+ if got := assignmentOf(brief); got != "do x" {
+ t.Fatalf("assignment = %q", got)
+ }
+}
+
+func makeRecord(t *testing.T, home, name, brief string) string {
+ t.Helper()
+ folder := filepath.Join(home, "runs", recordPrefix+name)
+ store, err := session.OpenRunPlanAt(filepath.Join(folder, "plandb.db"), "title", brief)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.EndRoot("limit"); err != nil {
+ t.Fatal(err)
+ }
+ return folder
+}
+
+func TestResolveRecordByIdPrefixAndPath(t *testing.T) {
+ home := beltRunEnv(t)
+ one := makeRecord(t, home, "1234567", "a")
+ two := makeRecord(t, home, "1299999", "b")
+ for id, want := range map[string]string{"1234567": one, recordPrefix + "1234567": one, "1234": one, "1299": two, one: one} {
+ if got, err := resolveRecord(id); err != nil || got != want {
+ t.Fatalf("resolveRecord(%q) = %q, %v; want %q", id, got, err, want)
+ }
+ }
+ for _, id := range []string{"12", "77", ""} {
+ if got, err := resolveRecord(id); err == nil {
+ t.Fatalf("resolveRecord(%q) = %q, want a refusal", id, got)
+ }
+ }
+}
+
+func TestReadPriorRunReadsAssignmentAndEnding(t *testing.T) {
+ home := beltRunEnv(t)
+ folder := makeRecord(t, home, "42", "rename the logger")
+ prior, err := readPriorRun(folder)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if prior.assignment != "rename the logger" || prior.ending != endingWords["failed"] {
+ t.Fatalf("prior = %+v", prior)
+ }
+ if _, err := readPriorRun(t.TempDir()); err == nil {
+ t.Fatal("a folder with no record was read")
+ }
+}
+
+// seenBriefs records every prompt a worker was handed, and answers as inner.
+type seenBriefs struct {
+ inner session.Completer
+ mu sync.Mutex
+ docs []string
+}
+
+func (s *seenBriefs) CompleteWithMessages(ctx context.Context, msgs []ai.Message, opts ...ai.Option) (*ai.Response, error) {
+ s.mu.Lock()
+ s.docs = append(s.docs, beltDocument(msgs))
+ s.mu.Unlock()
+ return s.inner.CompleteWithMessages(ctx, msgs, opts...)
+}
+
+func (s *seenBriefs) sawAll(words ...string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, doc := range s.docs {
+ found := true
+ for _, word := range words {
+ found = found && strings.Contains(doc, word)
+ }
+ if found {
+ return true
+ }
+ }
+ return false
+}
+
+func TestContinueCarriesAFailedRunToDone(t *testing.T) {
+ beltRunEnv(t)
+ t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t))
+ workspace := beltRepoWorkspace(t)
+ failing := &beltSeat{ever: func(context.Context, []ai.Message) (*ai.Response, error) {
+ return nil, errors.New("scripted worker failure")
+ }}
+ var stdout, stderr strings.Builder
+ _ = doErrand(doRequest{task: "write out.txt", workspace: workspace, asJSON: true, timeout: 5 * time.Second,
+ stdout: &stdout, stderr: &stderr, newBeltCompleter: func(string) session.Completer { return failing }})
+ folder := keptRunFolder(t, stderr.String())
+ if !strings.Contains(stderr.String(), continueHint(folder)) {
+ t.Fatalf("stderr does not say how to continue:\n%s", stderr.String())
+ }
+
+ got, err := continuedBrief(recordID(folder), "and say so")
+ brief, assignment := got.brief, got.assignment
+ if err != nil || assignment != "write out.txt" {
+ t.Fatalf("continuedBrief = %q, %q, %v", brief, assignment, err)
+ }
+ seat := &seenBriefs{inner: finishingSeat(0)}
+ stdout.Reset()
+ stderr.Reset()
+ err = doErrand(doRequest{task: brief, assignment: assignment, workspace: workspace, asJSON: true,
+ timeout: 60 * time.Second, stdout: &stdout, stderr: &stderr,
+ newBeltCompleter: func(string) session.Completer { return seat }})
+ if err != nil {
+ t.Fatalf("continued run: %v\n%s\n%s", err, stderr.String(), stdout.String())
+ }
+ if stop := doEnvelopeFields(t, stdout.String())["stop"]; stop != "done" {
+ t.Fatalf("continued run stop = %v", stop)
+ }
+ if !seat.sawAll("write out.txt", continueEndingHeader, "and say so") {
+ t.Fatalf("no worker was handed the assignment, the ending and the finding:\n%v", seat.docs)
+ }
+ if _, err := os.Stat(filepath.Join(workspace, "out.txt")); err != nil {
+ t.Fatalf("continued run left no file: %v", err)
+ }
+}
+
+func TestContinueRefusesAnUnknownRun(t *testing.T) {
+ beltRunEnv(t)
+ if _, err := continuedBrief("nope", ""); err == nil || !strings.Contains(err.Error(), `"nope"`) {
+ t.Fatalf("unknown id error = %v", err)
+ }
+}
diff --git a/cmd/codeaf/do_residenthost_test.go b/cmd/codeaf/do_residenthost_test.go
new file mode 100644
index 0000000000..8aee09d932
--- /dev/null
+++ b/cmd/codeaf/do_residenthost_test.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/lease"
+)
+
+func TestResidentHostReadsLikeEveryOtherComputerLabel(t *testing.T) {
+ cases := map[string]string{"studio.local": "studio", " desk ": "desk", "": "unknown host"}
+ for host, want := range cases {
+ if got := residentHost(&lease.Resident{Host: host}); got != want {
+ t.Errorf("residentHost(%q) = %q; want %q", host, got, want)
+ }
+ }
+}
diff --git a/cmd/codeaf/doctor.go b/cmd/codeaf/doctor.go
index 8be599c98a..3c69324df4 100644
--- a/cmd/codeaf/doctor.go
+++ b/cmd/codeaf/doctor.go
@@ -41,7 +41,10 @@ type doctorSnapshot struct {
// worth changing: the command exists to tell somebody why nothing works,
// and a missing key is the most common answer there is.
Key keyReport
- Now time.Time
+ // Cells is what the cells' working files hold against the disk budget, "" when
+ // they hold nothing.
+ Cells string
+ Now time.Time
}
// fallbackKeyEnv is the OpenAI-shaped variable [config.Load] accepts when the
@@ -135,6 +138,7 @@ func runDoctorWith(args []string, output io.Writer, dailyBudget float64, overrid
// The key is read from the same profile, and for the same reason: `--db`
// moves the store and moves nothing about who this machine can talk to.
snapshot.Key = readKeyReport(strings.TrimSpace(env.Get(config.ProfileDirEnv)))
+ snapshot.Cells = cellsUsage()
_, err = io.WriteString(output, formatDoctor(snapshot))
return err
}
@@ -312,6 +316,9 @@ func formatDoctor(snapshot doctorSnapshot) string {
if standing := strings.Join(standingParts, " · "); standing != "" {
block += fmt.Sprintf("%-16s %s\n", "standing", standing)
}
+ if snapshot.Cells != "" {
+ block += fmt.Sprintf("%-16s %s\n", "cells", snapshot.Cells)
+ }
if line := formatCallLog(snapshot.CallLog); line != "" {
block += fmt.Sprintf("%-16s %s\n", "model calls", line)
}
diff --git a/cmd/codeaf/emptiness_test.go b/cmd/codeaf/emptiness_test.go
index 64706b4f43..3c9ed4c426 100644
--- a/cmd/codeaf/emptiness_test.go
+++ b/cmd/codeaf/emptiness_test.go
@@ -24,7 +24,7 @@ func TestTheHeadlessFooterLeavesOutWhatIsZero(t *testing.T) {
Deliverable: "nothing ran", Artifacts: []string{}, stop: stopError,
})
printed := out.String()
- for _, claim := range []string{"0s", "0 nodes", "$0.0000", "$0.00"} {
+ for _, claim := range []string{"0s", "0 parts", "$0.0000", "$0.00"} {
if strings.Contains(printed, claim) {
t.Fatalf("a run that measured nothing still printed %q:\n%s", claim, printed)
}
@@ -39,7 +39,7 @@ func TestTheHeadlessFooterLeavesOutWhatIsZero(t *testing.T) {
Deliverable: "done", Artifacts: []string{}, Seconds: 12, Nodes: 3, Spend: 0.4213,
})
printed = out.String()
- for _, want := range []string{"12s", "3 nodes", "$0.42"} {
+ for _, want := range []string{"12s", "3 parts", "$0.42"} {
if !strings.Contains(printed, want) {
t.Fatalf("the footer dropped %q, which this run really did:\n%s", want, printed)
}
diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go
index 211a7bd060..ffaa4119e0 100644
--- a/cmd/codeaf/engine.go
+++ b/cmd/codeaf/engine.go
@@ -53,6 +53,7 @@ import (
"time"
"github.com/Agent-Field/codeaf/internal/buildinfo"
+ "github.com/Agent-Field/codeaf/internal/cell"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/remote"
@@ -248,6 +249,9 @@ type engineBuild struct {
// Binary is the file this process runs from, "" when the platform will
// not say.
Binary string
+ // Cells is the cells mode this process runs in. A host answers with its
+ // own, and the door settles the two ([settleCellsMode]).
+ Cells bool
}
// thisEngineBuild is the build that is running, measured the way a host
@@ -257,6 +261,7 @@ func thisEngineBuild() engineBuild {
Build: buildinfo.Identity(),
BuiltAt: enginehost.BuildMoment(),
Binary: enginehost.ThisBinary(),
+ Cells: cell.Enabled(),
}
}
@@ -312,12 +317,12 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) {
}
host := held.Self
if held.Answered && sameEngineBuild(host, me) {
- return "", nil
+ return settleCellsMode(workspace, host, me)
}
if held.Answered && !host.BuiltAt.Before(me.BuiltAt) {
// A NEWER BUILD (or a tie) IS NOT REPLACED FROM HERE. Same wire: join it.
if host.Version == remote.Version {
- return "", nil
+ return settleCellsMode(workspace, host, me)
}
return "", &staleHost{reason: newerEngineHostSentence(hostWorkspace(host, workspace))}
}
@@ -335,6 +340,59 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) {
return replacedEngineHostSentence(held), nil
}
+// settleCellsMode is the last question before joining a host: does it run in
+// this process's cells mode? A host reads CODEAF_CELLS once, when it starts, so
+// one started with cells off would serve every later chat with cells off and
+// nothing would ever migrate. There is no silent mismatch. An idle host is asked
+// to retire and the next line of the door starts one in this mode, with one line
+// saying so; a host holding work is left exactly as it is and refused in words.
+func settleCellsMode(workspace string, host remote.HostSelf, me engineBuild) (string, error) {
+ if host.Cells == me.Cells {
+ return "", nil
+ }
+ err := enginehost.Retire(workspace, false)
+ if errors.Is(err, enginehost.ErrHostBusy) {
+ return "", &staleHost{reason: cellsHostBusySentence(host.Cells, hostWorkspace(host, workspace))}
+ }
+ if err != nil {
+ return "", err
+ }
+ return cellsRestartedSentence(me.Cells), nil
+}
+
+// cellsMode is a mode as a person types it.
+func cellsMode(on bool) string {
+ if on {
+ return "on"
+ }
+ return "off"
+}
+
+// cellsRestartedSentence is the line owed for replacing an idle host.
+func cellsRestartedSentence(now bool) string {
+ return fmt.Sprintf("restarted the idle engine because it ran with cells %s; it runs with cells %s now", cellsMode(!now), cellsMode(now))
+}
+
+// cellsHostBusySentence is the refusal for a host in the other mode that is
+// still holding work. It names the workspace in the command, for the reason
+// [staleEngineHostSentence] gives.
+func cellsHostBusySentence(hostCells bool, workspace string) string {
+ stop := "codeaf engine --stop"
+ if workspace = strings.TrimSpace(workspace); workspace != "" {
+ stop += " --workspace " + workspace
+ }
+ return fmt.Sprintf("engine: the engine on %s runs with cells %s and something is still going in it — let that finish, or run %s, then open again",
+ machineOrThis(), cellsMode(hostCells), stop)
+}
+
+// machineOrThis is this machine's name, or "this machine" when it has none.
+func machineOrThis() string {
+ if name := strings.TrimSpace(remote.MachineName()); name != "" {
+ return name
+ }
+ return "this machine"
+}
+
// sameEngineBuild is the host being this build: the same wire, the same source,
// and — when the host names its file — the same file. A host that does not name
// its file is judged on the source alone, which is every host from before the
@@ -564,9 +622,12 @@ func writeEngineStatus(out io.Writer, workspace string, held enginehost.Holder,
fmt.Fprintf(out, " started %s (%s ago)\n", host.Started.Local().Format("2006-01-02 15:04"), roughAge(now.Sub(host.Started)))
}
if held.Answered {
+ fmt.Fprintf(out, " cells %s\n", cellsMode(host.Cells))
fmt.Fprintf(out, " windows %d attached · %s open\n", host.Surfaces, countWord(host.Conversations, "conversation", "conversations"))
if host.Busy {
fmt.Fprintln(out, " working yes — a turn, a task or a question is in flight")
+ } else if host.Conversations > 0 {
+ fmt.Fprintf(out, " holding idle chats keep their lock for %s after the last window closes, then the engine lets go\n", roughAge(enginehost.SessionIdleAfter()))
}
}
stop := "codeaf engine --stop"
@@ -723,6 +784,7 @@ func runEngineHost(workspaceFlag, sessionFlag string) error {
return fmt.Errorf("open %s: %w", workspace, err)
}
err = enginehost.Run(workspace, enginehost.Options{
+ Cells: cell.Enabled(),
Boot: func(hello remote.Hello) (*remote.Engine, error) {
return bootEngine(hello, workspace, sessionFlag)
}, // usage writer owner: see CloseUsage after the host returns
@@ -980,7 +1042,7 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
Workspace: workspace,
SessionFile: transcript,
Resumed: resumed,
- Note: notice,
+ Note: v3Interrupted(cfg, notice),
// The shape this conversation ended up with, for the surface to compare
// against what it asked for. It is the shape that was APPLIED, so a
// hello that joined a conversation somebody else opened reads the other
diff --git a/cmd/codeaf/engine_cells_test.go b/cmd/codeaf/engine_cells_test.go
new file mode 100644
index 0000000000..f659314697
--- /dev/null
+++ b/cmd/codeaf/engine_cells_test.go
@@ -0,0 +1,74 @@
+package main
+
+// The door and the cells mode of the engine it finds. An engine reads
+// CODEAF_CELLS once, so the door must never attach to one in the other mode
+// without saying so.
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/buildinfo"
+ "github.com/Agent-Field/codeaf/internal/enginehost"
+ "github.com/Agent-Field/codeaf/internal/remote"
+)
+
+func sameBuildHost(cells, busy bool) remote.HostSelf {
+ return remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity(), Cells: cells, Busy: busy}
+}
+
+func TestAnIdleEngineInTheOtherCellsModeIsRestarted(t *testing.T) {
+ shortEngineHome(t)
+ workspace := "/home/somebody/api"
+ standIn(t, workspace, sameBuildHost(false, false), false)
+ me := thisEngineBuild()
+ me.Cells = true
+
+ note, err := clearStaleEngineHostAs(workspace, me)
+ if err != nil {
+ t.Fatalf("an idle engine in the other mode was refused: %v", err)
+ }
+ if !strings.Contains(note, "cells off") || !strings.Contains(note, "cells on now") {
+ t.Fatalf("the restart line does not name both modes: %q", note)
+ }
+ if conn, err := enginehost.Dial(workspace); err == nil {
+ _ = conn.Close()
+ t.Fatal("the engine in the other mode was still answering")
+ }
+}
+
+func TestABusyEngineInTheOtherCellsModeIsRefusedAndLeftRunning(t *testing.T) {
+ shortEngineHome(t)
+ workspace := "/home/somebody/api"
+ standIn(t, workspace, sameBuildHost(false, true), false)
+ me := thisEngineBuild()
+ me.Cells = true
+
+ _, err := clearStaleEngineHostAs(workspace, me)
+ if err == nil {
+ t.Fatal("a busy engine in the other mode was attached to in silence")
+ }
+ for _, want := range []string{"cells off", "codeaf engine --stop --workspace " + workspace} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("the refusal %q does not say %q", err, want)
+ }
+ }
+ if conn, err := enginehost.Dial(workspace); err != nil {
+ t.Fatalf("a busy engine was stopped: %v", err)
+ } else {
+ _ = conn.Close()
+ }
+}
+
+func TestAnEngineInTheSameCellsModeIsJoinedWithoutAWord(t *testing.T) {
+ shortEngineHome(t)
+ workspace := "/home/somebody/api"
+ standIn(t, workspace, sameBuildHost(true, true), false)
+ me := thisEngineBuild()
+ me.Cells = true
+
+ note, err := clearStaleEngineHostAs(workspace, me)
+ if err != nil || note != "" {
+ t.Fatalf("a matching engine was not joined quietly: %q, %v", note, err)
+ }
+}
diff --git a/cmd/codeaf/engine_stale_test.go b/cmd/codeaf/engine_stale_test.go
index 83ed4b5a98..eb97074e3f 100644
--- a/cmd/codeaf/engine_stale_test.go
+++ b/cmd/codeaf/engine_stale_test.go
@@ -15,6 +15,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/Agent-Field/codeaf/internal/cell"
"net"
"os"
"os/exec"
@@ -153,7 +154,7 @@ var (
func TestAHostOfThisBuildIsSplicedOntoWithoutAWord(t *testing.T) {
shortEngineHome(t)
workspace := "/home/somebody/api"
- standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity()}, false)
+ standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity(), Cells: cell.Enabled()}, false)
note, err := clearStaleEngineHost(workspace)
if err != nil {
diff --git a/cmd/codeaf/exec.go b/cmd/codeaf/exec.go
index 10f3e4c3af..caa1148f4b 100644
--- a/cmd/codeaf/exec.go
+++ b/cmd/codeaf/exec.go
@@ -128,7 +128,7 @@ func runExec(args []string) error {
}
defer closeRouter(client)
- ctx, stopSignals := signal.NotifyContext(traced, os.Interrupt, syscall.SIGTERM)
+ ctx, stopSignals := signal.NotifyContext(hostSeated(traced), os.Interrupt, syscall.SIGTERM)
defer stopSignals()
deadline := execDeadline(*maxTokens, wall.wall)
if wall.wall > 0 {
diff --git a/cmd/codeaf/helpwidth_test.go b/cmd/codeaf/helpwidth_test.go
index 8fc26378f4..efdd6bc077 100644
--- a/cmd/codeaf/helpwidth_test.go
+++ b/cmd/codeaf/helpwidth_test.go
@@ -127,6 +127,7 @@ func helpPages(t *testing.T) []helpPage {
{"why", runWhy},
{"cache", runCache},
{"devices", runDevices},
+ {"pair", runPair},
{"plan new", func(args []string) error { return runPlanNew("plan new", args) }},
{"plan run", func(args []string) error { return runGraph("plan run", args) }},
{"patch", runPatch},
diff --git a/cmd/codeaf/hostseat_test.go b/cmd/codeaf/hostseat_test.go
new file mode 100644
index 0000000000..afea3ec721
--- /dev/null
+++ b/cmd/codeaf/hostseat_test.go
@@ -0,0 +1,7 @@
+package main
+
+import "github.com/Agent-Field/codeaf/internal/executor/executortest"
+
+// These tests call tools with no session around them, so those calls run on
+// the host seat by name; production still refuses a call that lost its session.
+func init() { executortest.Host() }
diff --git a/cmd/codeaf/identity.go b/cmd/codeaf/identity.go
new file mode 100644
index 0000000000..770391f447
--- /dev/null
+++ b/cmd/codeaf/identity.go
@@ -0,0 +1,165 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/keys"
+)
+
+const identityUsage = "usage: codeaf identity show | codeaf identity export [] | codeaf identity import [--replace] | codeaf identity rotate [--grace 7d] [--yes] [--abandon]"
+
+// identityDoor is what a verb may touch: the codeaf home, where it writes, and
+// how a passphrase is asked for (with a second entry when it is being chosen).
+type identityDoor struct {
+ home string
+ out io.Writer
+ passphrase func(confirm bool) (string, error)
+ // in is where a yes or no is read, and rotation opens the rotation of this
+ // computer's identity; only `rotate` uses them.
+ in io.Reader
+ rotation rotationFactory
+ now func() time.Time
+}
+
+// identityVerbs maps the word after `identity` to what it does.
+var identityVerbs = map[string]func(d identityDoor, args []string) error{
+ "show": identityShow,
+ "export": identityExport,
+ "import": identityImport,
+ "rotate": identityRotate,
+}
+
+// runIdentity is the door onto the person's identity. Like `cell` it is
+// machinery, off the help page, and refused only when CODEAF_CELLS=0 turns cells off.
+func runIdentity(args []string) error {
+ if !cell.Enabled() {
+ return errors.New("codeaf identity needs cells, and CODEAF_CELLS=0 turned them off")
+ }
+ return runIdentityAt(args, identityDoor{home: home.Dir(), out: os.Stdout, passphrase: askPassphrase, in: os.Stdin, rotation: realRotation, now: time.Now})
+}
+
+func runIdentityAt(args []string, d identityDoor) error {
+ if len(args) == 0 {
+ return errors.New(identityUsage)
+ }
+ verb, ok := identityVerbs[args[0]]
+ if !ok {
+ return errors.New(identityUsage)
+ }
+ return verb(d, args[1:])
+}
+
+func identityShow(d identityDoor, args []string) error {
+ if len(args) != 0 {
+ return errors.New(identityUsage)
+ }
+ id, err := identity.Ensure(d.home)
+ if err != nil {
+ return err
+ }
+ dev, err := identity.Device(d.home)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(d.out, "id %s\nfingerprint %s\ncell key id %s\ndevice %s\n",
+ id.ID(), id.Fingerprint(), id.CellKeyID(), dev.ID())
+ return nil
+}
+
+func identityExport(d identityDoor, args []string) error {
+ if len(args) > 1 {
+ return errors.New(identityUsage)
+ }
+ id, err := identity.Ensure(d.home)
+ if err != nil {
+ return err
+ }
+ phrase, err := d.passphrase(true)
+ if err != nil {
+ return err
+ }
+ blob, err := identity.Export(id, phrase)
+ if err != nil {
+ return err
+ }
+ if len(args) == 0 {
+ _, err = fmt.Fprintf(d.out, "%s\n", blob)
+ return err
+ }
+ return os.WriteFile(args[0], append(blob, '\n'), 0o600)
+}
+
+func identityImport(d identityDoor, args []string) error {
+ replace := slices.Contains(args, "--replace")
+ files := slices.DeleteFunc(slices.Clone(args), func(a string) bool { return a == "--replace" })
+ if len(files) != 1 {
+ return errors.New(identityUsage)
+ }
+ blob, err := os.ReadFile(files[0])
+ if err != nil {
+ return err
+ }
+ phrase, err := d.passphrase(false)
+ if err != nil {
+ return err
+ }
+ id, err := identity.Import(blob, phrase)
+ if err != nil {
+ return err
+ }
+ replaced, err := identity.Adopt(d.home, id, replace)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(d.out, "imported %s (%s)\n", id.ID(), id.Fingerprint())
+ if replaced && keys.Exists(d.home) {
+ fmt.Fprintln(d.out, "secrets sealed under the previous identity cannot be read now")
+ }
+ return nil
+}
+
+// askPassphrase reads a passphrase without echo on a terminal, else one line
+// from stdin. Choosing one (confirm) asks twice on a terminal.
+func askPassphrase(confirm bool) (string, error) {
+ if !connectInputIsTTY(connectInput) {
+ return readPassphraseLine(connectInput)
+ }
+ first, err := promptSecret("passphrase: ")
+ if err != nil || !confirm {
+ return first, err
+ }
+ again, err := promptSecret("again: ")
+ if err == nil && again != first {
+ err = errors.New("the two passphrases differ")
+ }
+ return first, err
+}
+
+func promptSecret(prompt string) (string, error) {
+ fmt.Fprint(usageErr, prompt)
+ raw, err := connectReadPassword(connectInput.Fd())
+ fmt.Fprintln(usageErr)
+ return nonEmpty(string(raw), err)
+}
+
+func readPassphraseLine(in io.Reader) (string, error) {
+ raw, err := io.ReadAll(io.LimitReader(in, 1<<16))
+ line, _, _ := strings.Cut(string(raw), "\n")
+ return nonEmpty(strings.TrimRight(line, "\r"), err)
+}
+
+func nonEmpty(phrase string, err error) (string, error) {
+ if err == nil && phrase == "" {
+ err = errors.New("the passphrase is empty")
+ }
+ return phrase, err
+}
diff --git a/cmd/codeaf/identity_rotate.go b/cmd/codeaf/identity_rotate.go
new file mode 100644
index 0000000000..a8f39e0495
--- /dev/null
+++ b/cmd/codeaf/identity_rotate.go
@@ -0,0 +1,193 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/chatlist"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/keys"
+ "github.com/Agent-Field/codeaf/internal/rotate"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// rotation is the part of a rotation the door drives; rotate.Env is one.
+type rotation interface {
+ Plan(ctx context.Context) (rotate.Plan, error)
+ Rotate(ctx context.Context) (rotate.Result, error)
+ Abandon(ctx context.Context) error
+}
+
+// rotationFactory opens the rotation of this computer's identity, with the grace
+// the person asked for and a line-by-line progress sink, and answers what to
+// call when it is over to tidy what it left.
+type rotationFactory func(grace time.Duration, say func(string)) (rotation, func() error, error)
+
+// realRotation is the rotation over this computer's relay and engine.
+func realRotation(grace time.Duration, say func(string)) (rotation, func() error, error) {
+ s, ok, err := syncsetup.OpenForRotation(home.Dir())
+ if err != nil {
+ return nil, nil, err
+ }
+ if !ok {
+ return nil, nil, errors.New("a rotation needs sync: " + chatlist.SyncOff)
+ }
+ env := s.Rotation(cellstore.EngineFor(""), heldChat, deviceName(), say)
+ env.Grace = grace
+ return env, s.Tidy, nil
+}
+
+// heldChat finds the folder a chat has on this computer, if it has one.
+func heldChat(id string) (cell.Cell, bool) {
+ c, err := openCellByID(id)
+ return c, err == nil
+}
+
+type rotateOptions struct {
+ grace time.Duration
+ yes bool
+ abandon bool
+}
+
+func parseRotate(args []string) (rotateOptions, error) {
+ var o rotateOptions
+ flags := flag.NewFlagSet("rotate", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ flags.DurationVar(&o.grace, "grace", 0, "")
+ flags.BoolVar(&o.yes, "yes", false, "")
+ flags.BoolVar(&o.abandon, "abandon", false, "")
+ if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
+ return o, errors.New(identityUsage)
+ }
+ return o, nil
+}
+
+// identityRotate replaces the identity of every computer you keep: a new root,
+// every chat and the vault sealed again under it, and the old identity retired
+// by the relay after a grace period.
+func identityRotate(d identityDoor, args []string) error {
+ opt, err := parseRotate(args)
+ if err != nil {
+ return err
+ }
+ say := func(line string) { fmt.Fprintln(d.out, line) }
+ rot, tidy, err := d.rotation(opt.grace, say)
+ if err != nil {
+ return err
+ }
+ defer tidy()
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+ if opt.abandon {
+ return abandonRotation(d, ctx, rot)
+ }
+ if !rotate.Pending(d.home) {
+ if err := confirmRotation(d, ctx, rot, opt); err != nil {
+ return err
+ }
+ } else {
+ say("finishing the rotation that was started here")
+ }
+ res, err := rot.Rotate(ctx)
+ if err != nil {
+ return err
+ }
+ return rotationCard(d, res)
+}
+
+func abandonRotation(d identityDoor, ctx context.Context, rot rotation) error {
+ if err := rot.Abandon(ctx); err != nil {
+ return err
+ }
+ fmt.Fprintln(d.out, "rotation cancelled; nothing changed")
+ return nil
+}
+
+// confirmRotation says what a rotation costs and asks. Nothing has changed yet,
+// so ctrl+c or n here costs nothing.
+func confirmRotation(d identityDoor, ctx context.Context, rot rotation, opt rotateOptions) error {
+ plan, err := rot.Plan(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(d.out, planText(plan))
+ if opt.yes {
+ return nil
+ }
+ fmt.Fprint(d.out, "continue? y / n ")
+ answer, _ := bufio.NewReader(d.in).ReadString('\n')
+ if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
+ return errors.New("rotation cancelled; nothing changed")
+ }
+ return nil
+}
+
+// planText is the plan in words: what moves, how much, about how long, and what
+// it does to the other computers.
+func planText(p rotate.Plan) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%d chats", p.Chats)
+ if p.Missing > 0 {
+ fmt.Fprintf(&b, ", %d of them not on this computer (%s to fetch first)", p.Missing, megabytes(p.DownBytes))
+ }
+ fmt.Fprintf(&b, ".\nabout %s goes up to sync; allow about %s.\n", megabytes(p.UpBytes), minutes(p.Wait))
+ b.WriteString("your other computers stop syncing until you pair them again.\n")
+ b.WriteString("the old identity stays readable in sync for a while, then sync deletes it.\n")
+ b.WriteString("once it has switched this cannot be undone.\n")
+ return b.String()
+}
+
+func megabytes(n int64) string { return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) }
+
+func minutes(d time.Duration) string {
+ if d < time.Minute {
+ return "a minute"
+ }
+ return fmt.Sprintf("%d minutes", int(d.Round(time.Minute)/time.Minute))
+}
+
+// rotationCard is what a person reads when it is done: what changed, what to do
+// next, and, plainly, what a computer they wanted out still holds.
+func rotationCard(d identityDoor, res rotate.Result) error {
+ names := vaultNames(d.home)
+ fmt.Fprintf(d.out, "rotated. your chats now belong to %s (was %s).\n", res.NewID, res.OldID)
+ fmt.Fprintln(d.out, "other computers: run /pair here, then `codeaf pair ` there, for each one.")
+ fmt.Fprintf(d.out, "the old copy in sync is read-only and is deleted on %s.\n", d.now().Add(res.RetireAfter).Format("2006-01-02"))
+ fmt.Fprintln(d.out, "a computer you lost still holds everything it had: your chats up to now and every secret in your vault.")
+ if len(names) > 0 {
+ fmt.Fprintf(d.out, "change these at their providers: %s\n", strings.Join(names, ", "))
+ }
+ return nil
+}
+
+// vaultNames lists the names of the secrets in the vault, never their values.
+func vaultNames(dir string) []string {
+ v, err := keys.Open(dir)
+ if err != nil {
+ return nil
+ }
+ ids, _ := v.IDs("")
+ seen := map[string]bool{}
+ for _, id := range ids {
+ if e, err := v.Get(id); err == nil && e.Name != "" {
+ seen[e.Name] = true
+ }
+ }
+ names := make([]string, 0, len(seen))
+ for n := range seen {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ return names
+}
diff --git a/cmd/codeaf/identity_rotate_test.go b/cmd/codeaf/identity_rotate_test.go
new file mode 100644
index 0000000000..3788f5c08a
--- /dev/null
+++ b/cmd/codeaf/identity_rotate_test.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/keys"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/rotate"
+)
+
+// fakeRotation records what the door asked of it.
+type fakeRotation struct {
+ plan rotate.Plan
+ rotate int
+ thawed int
+ err error
+}
+
+func (f *fakeRotation) Plan(context.Context) (rotate.Plan, error) { return f.plan, nil }
+func (f *fakeRotation) Rotate(context.Context) (rotate.Result, error) {
+ f.rotate++
+ return rotate.Result{OldID: "id_old", NewID: "id_new", Chats: f.plan.Chats, RetireAfter: 7 * 24 * time.Hour}, f.err
+}
+func (f *fakeRotation) Abandon(context.Context) error { f.thawed++; return f.err }
+
+func rotationDoor(t *testing.T, f *fakeRotation, stdin string) (identityDoor, *bytes.Buffer) {
+ t.Helper()
+ out := &bytes.Buffer{}
+ return identityDoor{
+ home: t.TempDir(), out: out, in: strings.NewReader(stdin),
+ now: func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) },
+ rotation: func(time.Duration, func(string)) (rotation, func() error, error) {
+ return f, func() error { return nil }, nil
+ },
+ }, out
+}
+
+// The door says what the rotation costs before it changes anything, and a no
+// changes nothing.
+func TestRotateAsksFirst(t *testing.T) {
+ f := &fakeRotation{plan: rotate.Plan{Chats: 3, Missing: 1, DownBytes: 5 << 20, UpBytes: 9 << 20, Wait: 3 * time.Minute}}
+ d, out := rotationDoor(t, f, "n\n")
+ err := identityRotate(d, nil)
+ if err == nil || !strings.Contains(err.Error(), "nothing changed") || f.rotate != 0 {
+ t.Fatalf("a no = %v, rotations %d", err, f.rotate)
+ }
+ for _, want := range []string{"3 chats", "1 of them not on this computer (5.0 MB to fetch first)", "9.0 MB goes up", "3 minutes", "cannot be undone"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("the plan does not say %q:\n%s", want, out)
+ }
+ }
+}
+
+// A yes, or --yes, runs it, and the card says what a lost computer keeps.
+func TestRotateCardListsSecretNames(t *testing.T) {
+ f := &fakeRotation{plan: rotate.Plan{Chats: 1}}
+ d, out := rotationDoor(t, f, "yes\n")
+ vault, err := keys.Open(d.home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := vault.Put("p/K", keys.Entry{Name: "OPENROUTER_API_KEY", Value: "sk-never-printed", Scope: "p"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := identityRotate(d, nil); err != nil || f.rotate != 1 {
+ t.Fatalf("rotation = %v, runs %d", err, f.rotate)
+ }
+ text := out.String()
+ for _, want := range []string{"rotated. your chats now belong to id_new (was id_old)", "run /pair here", "deleted on 2026-10-08",
+ "still holds everything it had", "change these at their providers: OPENROUTER_API_KEY"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("the card does not say %q:\n%s", want, text)
+ }
+ }
+ if strings.Contains(text, "sk-never-printed") {
+ t.Fatal("the card printed a secret's value")
+ }
+ d2, _ := rotationDoor(t, &fakeRotation{}, "")
+ if err := identityRotate(d2, []string{"--yes"}); err != nil {
+ t.Fatalf("--yes = %v", err)
+ }
+}
+
+// A rotation that was started is finished without asking again.
+func TestRotateFinishesWhatWasStarted(t *testing.T) {
+ f := &fakeRotation{plan: rotate.Plan{Chats: 2}}
+ d, out := rotationDoor(t, f, "")
+ if err := os.WriteFile(filepath.Join(d.home, rotate.File), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := identityRotate(d, nil); err != nil || f.rotate != 1 {
+ t.Fatalf("rotation = %v, runs %d", err, f.rotate)
+ }
+ if !strings.Contains(out.String(), "finishing the rotation that was started here") || strings.Contains(out.String(), "continue?") {
+ t.Fatalf("output:\n%s", out)
+ }
+}
+
+func TestRotateAbandon(t *testing.T) {
+ f := &fakeRotation{}
+ d, out := rotationDoor(t, f, "")
+ if err := identityRotate(d, []string{"--abandon"}); err != nil || f.thawed != 1 || f.rotate != 0 {
+ t.Fatalf("abandon = %v, thaws %d, runs %d", err, f.thawed, f.rotate)
+ }
+ if !strings.Contains(out.String(), "rotation cancelled; nothing changed") {
+ t.Fatalf("output:\n%s", out)
+ }
+}
+
+func TestRotateRefusesWhatItDoesNotKnow(t *testing.T) {
+ d, _ := rotationDoor(t, &fakeRotation{}, "")
+ for _, args := range [][]string{{"now"}, {"--bogus"}, {"--grace", "soon"}} {
+ if err := identityRotate(d, args); err == nil || !strings.Contains(err.Error(), "usage") {
+ t.Errorf("%v = %v, want the usage line", args, err)
+ }
+ }
+}
+
+// With sync off there is no relay to rotate on, and nothing is done.
+func TestRotateSyncOff(t *testing.T) {
+ t.Setenv(home.EnvVar, t.TempDir())
+ t.Setenv("CODEAF_SYNC_URL", "off") // an empty value now means the built-in default
+ _, _, err := realRotation(0, nil)
+ if err == nil || !strings.Contains(err.Error(), "needs sync") {
+ t.Fatalf("realRotation with sync off = %v", err)
+ }
+}
+
+// Stopping a computer that holds your chats names the way to lock it out for good.
+func TestDevicesRevokeSuggestsRotate(t *testing.T) {
+ chats, _ := chatsWith(t, "laptop")
+ said, err := captureStdout(t, func() error { return stopDevice([]deviceKind{chats}, []string{"laptop"}) })
+ if err != nil || !strings.Contains(said, "codeaf identity rotate") {
+ t.Fatalf("after a stop: %q, %v", said, err)
+ }
+}
+
+// A computer that rotated hands the identities it replaced to whoever it pairs,
+// so a computer still on one of them may follow.
+func TestPairGrantNamesWhatItReplaced(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv(home.EnvVar, dir)
+ gone, err := identity.Mint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := identity.RecordPredecessor(dir, gone.ID()); err != nil {
+ t.Fatal(err)
+ }
+ g, err := pairGrant(pair.Mailbox{URL: "http://relay.test"})
+ if err != nil || len(g.Replaces) != 1 || g.Replaces[0] != gone.ID() {
+ t.Fatalf("grant replaces %v, %v", g.Replaces, err)
+ }
+}
diff --git a/cmd/codeaf/identity_test.go b/cmd/codeaf/identity_test.go
new file mode 100644
index 0000000000..c3415014fc
--- /dev/null
+++ b/cmd/codeaf/identity_test.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "encoding/hex"
+ "os"
+
+ "bytes"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/keys"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func door(t *testing.T, home, phrase string) (identityDoor, *bytes.Buffer) {
+ out := &bytes.Buffer{}
+ return identityDoor{home: home, out: out, passphrase: func(bool) (string, error) { return phrase, nil }}, out
+}
+
+func TestIdentityExportImportAcrossHomes(t *testing.T) {
+ a, b := t.TempDir(), t.TempDir()
+ blob := filepath.Join(t.TempDir(), "id.blob")
+ da, showA := door(t, a, "pw")
+ if err := runIdentityAt([]string{"export", blob}, da); err != nil {
+ t.Fatal(err)
+ }
+ db, showB := door(t, b, "pw")
+ if err := runIdentityAt([]string{"import", blob}, db); err != nil {
+ t.Fatal(err)
+ }
+ showA.Reset()
+ showB.Reset()
+ runIdentityAt([]string{"show"}, da)
+ runIdentityAt([]string{"show"}, db)
+ if showA.String() == "" || showHead(showA.String()) != showHead(showB.String()) || showA.String() == showB.String() {
+ t.Fatalf("shows differ:\n%s\n%s", showA, showB)
+ }
+}
+
+func TestIdentityImportRefusesOtherIdentityWithoutReplace(t *testing.T) {
+ a, b := t.TempDir(), t.TempDir()
+ blob := filepath.Join(t.TempDir(), "id.blob")
+ da, _ := door(t, a, "pw")
+ runIdentityAt([]string{"export", blob}, da)
+ db, _ := door(t, b, "pw")
+ runIdentityAt([]string{"show"}, db) // b now has its own identity
+ if err := runIdentityAt([]string{"import", blob}, db); err == nil || !strings.Contains(err.Error(), "--replace") {
+ t.Fatalf("want a --replace refusal, got %v", err)
+ }
+ if err := runIdentityAt([]string{"import", "--replace", blob}, db); err != nil {
+ t.Fatal(err)
+ }
+ dw, _ := door(t, b, "wrong")
+ if err := runIdentityAt([]string{"import", "--replace", blob}, dw); err == nil {
+ t.Fatal("wrong passphrase accepted")
+ }
+}
+
+func TestIdentityNeedsCells(t *testing.T) {
+ t.Setenv("CODEAF_CELLS", "0")
+ if err := runIdentity([]string{"show"}); err == nil || !strings.Contains(err.Error(), "CODEAF_CELLS") {
+ t.Fatalf("got %v", err)
+ }
+}
+
+func TestIdentityUsage(t *testing.T) {
+ d, _ := door(t, t.TempDir(), "pw")
+ for _, args := range [][]string{{}, {"nope"}, {"show", "x"}, {"import"}, {"export", "a", "b"}} {
+ if err := runIdentityAt(args, d); err == nil || !strings.Contains(err.Error(), "usage") {
+ t.Fatalf("%v: %v", args, err)
+ }
+ }
+}
+
+// showHead is the three identity lines of `show`; the device line differs per machine.
+func showHead(shown string) string { return strings.Join(strings.Split(shown, "\n")[:3], "\n") }
+
+// A home whose vault predates identities (vault.key, no identity.json) keeps it
+// through a plain import: refused, and the vault still opens.
+func TestIdentityImportKeepsAnUnmigratedVault(t *testing.T) {
+ old, other := t.TempDir(), t.TempDir()
+ v, err := keys.Open(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v.Put("a", keys.Entry{Name: "K", Value: "kept", Scope: "p"})
+ id, _ := identity.Load(old)
+ os.Remove(filepath.Join(old, identity.File))
+ os.WriteFile(filepath.Join(old, "vault.key"), []byte(hex.EncodeToString(id.CellKey())), 0o600)
+
+ blob := filepath.Join(t.TempDir(), "id.blob")
+ do, _ := door(t, other, "pw")
+ runIdentityAt([]string{"export", blob}, do)
+ di, _ := door(t, old, "pw")
+ if err := runIdentityAt([]string{"import", blob}, di); err == nil || !strings.Contains(err.Error(), "--replace") {
+ t.Fatalf("want a --replace refusal, got %v", err)
+ }
+ again, err := keys.Open(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if e, err := again.Get("a"); err != nil || e.Value != "kept" {
+ t.Fatalf("vault lost: %v %v", e, err)
+ }
+}
diff --git a/cmd/codeaf/logs.go b/cmd/codeaf/logs.go
index c7ecfc2dbe..e56a9a6b45 100644
--- a/cmd/codeaf/logs.go
+++ b/cmd/codeaf/logs.go
@@ -77,7 +77,7 @@ func runLogsWith(args []string, output io.Writer, path string, now func() time.T
flags.StringVar(&filter.run, "run", "", "only calls belonging to this run")
flags.StringVar(&filter.tag, "tag", "", "only calls with this tag")
flags.StringVar(&filter.model, "model", "", "only calls asking for this model")
- flags.StringVar(&filter.node, "node", "", "only calls belonging to this node")
+ flags.StringVar(&filter.node, "node", "", "only calls belonging to this part")
flags.StringVar(&filter.call, "call", "", "only this call id, both of its rows")
if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
return err
diff --git a/cmd/codeaf/logs_test.go b/cmd/codeaf/logs_test.go
index 0449f6e667..e63ab6d195 100644
--- a/cmd/codeaf/logs_test.go
+++ b/cmd/codeaf/logs_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
"io"
"os"
"path/filepath"
@@ -82,6 +83,7 @@ func TestMain(m *testing.M) {
// "bash" for itself and wins.
os.Setenv("CODEAF_TASK_BELT", "node")
code := m.Run()
+ cellstore.StopSpawned() // no engine daemon outlives the run
restore()
os.Exit(code)
}
diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go
index 9974b41d3d..7e0877365a 100644
--- a/cmd/codeaf/main.go
+++ b/cmd/codeaf/main.go
@@ -35,6 +35,7 @@ import (
lanes "github.com/Agent-Field/codeaf/internal/lane"
"github.com/Agent-Field/codeaf/internal/plan"
"github.com/Agent-Field/codeaf/internal/plandb"
+ "github.com/Agent-Field/codeaf/internal/relayserve"
"github.com/Agent-Field/codeaf/internal/router"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/telemetry"
@@ -301,6 +302,17 @@ func run() error {
// is a command a person runs and watches — which is why it is in the
// usage text and `engine` is not (chatv3_at.go).
return runServe(os.Args[2:])
+ case "pair":
+ // Share this computer's chats with another one: one side shows a code,
+ // the other types it (pair.go). It is a command a person runs and
+ // watches, so it is in the usage text.
+ return runPair(os.Args[2:])
+ case "relay":
+ // The relay program itself, the same relayserve.Main that cmd/relay
+ // builds: `codeaf relay --listen :8787 --store `. It is run by
+ // whoever hosts the relay and not typed in a conversation, so like
+ // `engine` it is absent from the usage text.
+ return relayserve.Main(os.Args[2:])
case "devices":
// Who is allowed to open a conversation here, and the door for taking
// that back. REVOKING IS THIS MACHINE'S DECISION AND ONLY THIS
@@ -354,6 +366,16 @@ func run() error {
return runServices(os.Args[2:])
case "wake":
return runWake(os.Args[2:])
+ case "cell":
+ // The turn chain of a cell (cell.go): its log and its rewind. Machinery
+ // with cells on by default, DELIBERATELY ABSENT from the
+ // usage text below, like `engine` and `tick`.
+ return runCell(os.Args[2:])
+ case "identity":
+ // The person's one root secret and its passphrase-wrapped export
+ // (identity.go). Machinery, so
+ // DELIBERATELY ABSENT from the usage text below, like `cell`.
+ return runIdentity(os.Args[2:])
case "tick":
// One bounded pass over the standing items — the reminders, watches and
// routines a conversation left behind (tick.go). It is what the OS
@@ -550,12 +572,16 @@ Housekeeping — changes state on disk or on the network
delete the build cache; you type "` + cacheCleanWord + `" to confirm, --yes skips it
codeaf rebuild [--db path] [--yes]
discard everything codeaf worked out from the journal and replay it
- codeaf serve [--workspace path] [--relay url]
+ codeaf pair [--via url] [--name name]
+ add this device to yours: shows a link to approve from one you use
+ codeaf pair approve | [--replace] | --code
+ approve a new device; or use or show a six-digit code for your chats
+ codeaf serve [--workspace path] [--via url]
be reachable from your other devices without ssh, with a pairing code
codeaf devices
- list the devices paired with this machine
- codeaf devices revoke [--all]
- stop one device opening a conversation here, --all every device of it
+ list your devices and the ones that can use this machine
+ codeaf devices revoke [--all] | rename
+ remove one (cannot undo what a device holds), or name this one
codeaf notebook [--db path]
what it has learned, and what it has been corrected on
codeaf notebook retract|restore [--db path]
diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go
new file mode 100644
index 0000000000..f4ea40da62
--- /dev/null
+++ b/cmd/codeaf/pair.go
@@ -0,0 +1,193 @@
+package main
+
+// `codeaf pair`: share this computer's chats with another one, from a terminal.
+//
+// one$ codeaf pair shows a code and waits
+// two$ codeaf pair 42-715-302 types it
+//
+// Both doors run internal/pair's Offer and Join and add nothing to them but a
+// terminal to read and print on; the chat's `/pair` runs the same two functions
+// on a screen. Every sentence a person reads is pair's own.
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/Agent-Field/codeaf/internal/devname"
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/pair"
+)
+
+// pairDoor is one run of `codeaf pair`: the terminal it talks on and the three
+// things that say where a pairing goes, which are seams so a test can point two
+// doors at two homes.
+type pairDoor struct {
+ term *terminal
+ mailbox func(relayFlag string) (pair.Mailbox, error)
+ grant func(pair.Mailbox) (pair.Grant, error)
+ joining func(replace bool) pair.Joining
+
+ // asksName is true when a person is at the terminal and so can be asked what
+ // this computer should be called while it joins.
+ asksName bool
+
+ // The three seams of pairing by link.
+ requests func(pair.Mailbox) directory.Requests
+ linking func(route pair.Mailbox, replace bool) pair.LinkJoining
+ approver func(pair.Mailbox) (pair.Approver, error)
+}
+
+func newPairDoor(in io.Reader, out io.Writer) pairDoor {
+ return pairDoor{term: newTerminal(in, out), asksName: in == io.Reader(os.Stdin) && stdinIsTerminal(os.Stdin), mailbox: pairMailbox, grant: pairGrant, joining: pairJoining,
+ requests: linkRequests, linking: linkJoining(home.Dir()), approver: linkApprover(home.Dir())}
+}
+
+const pairUsage = "usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code"
+
+// approveUsage is what `codeaf pair approve` with nothing after it is told.
+const approveUsage = "usage: codeaf pair approve \u2014 the link or code the new device shows"
+
+func runPair(args []string) error {
+ // ctrl+c is how a shown code is taken back, so it cancels the pairing rather
+ // than killing the process: Offer deletes its mailbox on the way out.
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ return newPairDoor(os.Stdin, os.Stdout).run(ctx, args)
+}
+
+func (d pairDoor) run(ctx context.Context, args []string) error {
+ flags := commandFlags("pair")
+ via := flags.String("via", "", "the sync address to pair through; empty is the one this device already uses")
+ renamedFlag(flags, "relay", "via")
+ code := flags.Bool("code", false, "show a six-digit code that shares this device's chats, instead of asking to join")
+ replace := flags.Bool("replace", false, "when typing a code, replace this device's own chats with the shared ones")
+ name := flags.String("name", "", "what your devices call this one; empty keeps its current name, which starts as the host name")
+ if err := parseCommandFlags(flags, reorder(flags, args)); err != nil {
+ return err
+ }
+ if err := d.naming(ctx, *name, flags.Args(), *code); err != nil {
+ return err
+ }
+ return d.dispatch(ctx, flags.Args(), *via, *replace, *code)
+}
+
+// naming settles what this computer is called before a join starts, because the
+// name is what the device that lets it in will show. A typed --name is kept as it
+// is, and a bad one stops the pairing before the relay is looked up. Without one,
+// a person at a terminal who has never named this computer is shown the host name
+// and may keep it with Enter or type another. The device that shows a code or
+// approves already has a name, so only a join asks.
+func (d pairDoor) naming(ctx context.Context, typed string, args []string, showing bool) error {
+ if typed != "" {
+ _, err := devname.Set(home.Dir(), typed)
+ return err
+ }
+ if !d.asksName || showing || !joins(args) {
+ return nil
+ }
+ if _, named := devname.Chosen(home.Dir()); named {
+ return nil
+ }
+ return d.askName(ctx)
+}
+
+// joins says the arguments are a join: a bare `codeaf pair` or a typed code,
+// and not `approve`, a link or a shown code.
+func joins(args []string) bool {
+ return len(args) == 0 || (len(args) == 1 && !pair.IsLinkText(args[0]))
+}
+
+// askName asks until a name is kept or the person leaves it as it is.
+func (d pairDoor) askName(ctx context.Context) error {
+ for {
+ fmt.Fprint(d.term.out, pair.NamePrompt(devname.Name(home.Dir()))+" ")
+ typed, ok := d.term.lines.next(ctx)
+ if !ok || strings.TrimSpace(typed) == "" {
+ fmt.Fprintln(d.term.out)
+ return nil
+ }
+ _, err := devname.Set(home.Dir(), typed)
+ if err == nil {
+ return nil
+ }
+ d.term.say(pair.NameTried(err))
+ }
+}
+
+// dispatch picks the door by what was typed: nothing asks to join, `approve`
+// and anything shaped like a link approve, and a six-digit code joins as before.
+func (d pairDoor) dispatch(ctx context.Context, args []string, relay string, replace, code bool) error {
+ switch {
+ case code && len(args) == 0 && !replace:
+ return d.show(ctx, relay)
+ case len(args) == 0 && !code:
+ return d.askToJoin(ctx, relay, replace)
+ case len(args) == 1 && args[0] == "approve":
+ return wrongCall(approveUsage)
+ case len(args) == 2 && args[0] == "approve" && !code && !replace:
+ return d.approve(ctx, relay, args[1])
+ case len(args) == 1 && !code && pair.IsLinkText(args[0]):
+ return d.approve(ctx, relay, args[0])
+ case len(args) == 1 && !code:
+ return d.join(ctx, relay, args[0], replace)
+ }
+ return wrongCall(pairUsage)
+}
+
+// show is the device that has the chats: a code on screen until one device has
+// typed it and a person has said yes.
+func (d pairDoor) show(ctx context.Context, relay string) error {
+ route, err := d.mailbox(relay)
+ if err != nil {
+ return err
+ }
+ grant, err := d.grant(route)
+ if err != nil {
+ return err
+ }
+ label, err := pair.Offer(ctx, route, grant, offerScreen{d.term})
+ if err != nil {
+ return d.endedByPerson(ctx, err)
+ }
+ d.term.say(pair.PairedChatsLine(label))
+ return nil
+}
+
+// join is the device that gets them. A code that cannot be a code is refused
+// before the relay is even looked up, so a typo costs no request.
+func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) error {
+ if _, err := pair.ReadJoinCode(typed); err != nil {
+ return err
+ }
+ route, err := d.mailbox(relay)
+ if err != nil {
+ return err
+ }
+ joined, err := pair.Join(ctx, route, d.joining(replace), typed, joinScreen{d.term})
+ if err != nil {
+ return d.endedByPerson(ctx, err)
+ }
+ d.term.say(joinedSentence(joined))
+ return nil
+}
+
+// joinedSentence is how a finished join reads.
+func joinedSentence(joined pair.Joined) string { return joined.Sentence() }
+
+// endedByPerson turns the end a person chose with ctrl+c into a clean exit that
+// says nothing was paired, and leaves every other failure as the sentence it already is.
+func (d pairDoor) endedByPerson(ctx context.Context, err error) error {
+ if ctx.Err() != nil {
+ // The terminal echoes ^C on the prompt's own line, so start a fresh one.
+ fmt.Fprintln(d.term.out)
+ d.term.say(pair.CancelledLine)
+ return nil
+ }
+ return err
+}
diff --git a/cmd/codeaf/pair_terminal.go b/cmd/codeaf/pair_terminal.go
new file mode 100644
index 0000000000..e732f7028f
--- /dev/null
+++ b/cmd/codeaf/pair_terminal.go
@@ -0,0 +1,140 @@
+package main
+
+// The terminal's half of a pairing: the questions a person answers with y or n,
+// and the lines both doors of `codeaf pair` print. The sentences themselves are
+// internal/pair's (lines.go), so the terminal and the chat screen cannot drift.
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/guard"
+ "github.com/Agent-Field/codeaf/internal/pair"
+)
+
+// lineSource hands out the lines of one input, one at a time, without ever
+// blocking a caller past its context.
+//
+// A READ CANNOT BE CANCELLED, so a question that runs out of time leaves its read
+// waiting. That read is kept and given to the next question rather than started
+// again: a second reader on the same input would race the first for the line
+// and one of the two answers would be lost.
+type lineSource struct {
+ mu sync.Mutex
+ reader *bufio.Reader
+ waiting chan string
+}
+
+func newLineSource(in io.Reader) *lineSource { return &lineSource{reader: bufio.NewReader(in)} }
+
+// next is the next line typed, or false when ctx ends first. The end of the
+// input is an empty line, which every question here reads as a no.
+func (s *lineSource) next(ctx context.Context) (string, bool) {
+ line := s.pending()
+ select {
+ case text := <-line:
+ s.finish(line)
+ return text, true
+ case <-ctx.Done():
+ return "", false
+ }
+}
+
+// pending is the read in flight, started if there is none.
+func (s *lineSource) pending() chan string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.waiting == nil {
+ s.waiting = make(chan string, 1)
+ out := s.waiting
+ guard.Go("pair/read a line", func() {
+ text, _ := s.reader.ReadString('\n')
+ out <- text
+ })
+ }
+ return s.waiting
+}
+
+// finish forgets a read that has been answered, so the next question starts its own.
+func (s *lineSource) finish(line chan string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.waiting == line {
+ s.waiting = nil
+ }
+}
+
+// terminal is a person at a plain terminal: what they read and what they type.
+type terminal struct {
+ out io.Writer
+ lines *lineSource
+}
+
+func newTerminal(in io.Reader, out io.Writer) *terminal {
+ return &terminal{out: out, lines: newLineSource(in)}
+}
+
+// say prints one line.
+func (t *terminal) say(line string) { fmt.Fprintln(t.out, line) }
+
+// confirm puts a question with the pair package's own choice under it and
+// answers whether the person said yes. THERE IS NO DEFAULT: Enter, the end of
+// the input, silence and every word but y and yes are a no, because a stray key
+// must never let a device in.
+func (t *terminal) confirm(ctx context.Context, question string) bool {
+ fmt.Fprint(t.out, question+" "+pair.AskChoice+" ")
+ typed, ok := t.lines.next(ctx)
+ if !ok {
+ // Nothing was typed, so the line the prompt is on is left open: close it.
+ fmt.Fprintln(t.out)
+ return false
+ }
+ return saidYes(typed)
+}
+
+func saidYes(typed string) bool {
+ switch strings.ToLower(strings.TrimSpace(typed)) {
+ case "y", "yes":
+ return true
+ }
+ return false
+}
+
+// offerScreen is the device that shows the code, as a terminal.
+type offerScreen struct{ *terminal }
+
+func (s offerScreen) Show(_ *pair.Code, lines string) { fmt.Fprint(s.out, lines) }
+
+func (s offerScreen) Burned() { s.say(pair.BurnLine) }
+
+func (s offerScreen) Ask(ctx context.Context, label, words string) bool {
+ return s.confirm(ctx, pair.AskChatsLine(label, words))
+}
+
+// joinScreen is the device that types the code, as a terminal.
+type joinScreen struct{ *terminal }
+
+func (s joinScreen) Waiting(words string) { s.say(pair.WaitingChatsLine(words)) }
+
+// serveApprover is how this machine asks the person at it before a device may
+// use it: the pair package's question on the terminal, answered y or n.
+//
+// NIL WHEN NOBODY IS AT A TERMINAL, because a question nobody can read is a no
+// and [pair.Host] already says so for a nil approver. Each question is given
+// [pair.ConfirmWithin] and no more, the same as the device on the other side.
+func serveApprover(ctx context.Context, in *os.File, out io.Writer) func(label, words string) bool {
+ if !stdinIsTerminal(in) {
+ return nil
+ }
+ person := newTerminal(in, out)
+ return func(label, words string) bool {
+ asked, stop := context.WithTimeout(ctx, pair.ConfirmWithin)
+ defer stop()
+ return person.confirm(asked, pair.AskMachineLine(label, words))
+ }
+}
diff --git a/cmd/codeaf/pair_test.go b/cmd/codeaf/pair_test.go
new file mode 100644
index 0000000000..4ff5b1e09c
--- /dev/null
+++ b/cmd/codeaf/pair_test.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/pairbox"
+ "github.com/Agent-Field/codeaf/internal/relayserve"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// pairScreenText is a terminal's output that a test can read while the door that
+// writes it is still running.
+type pairScreenText struct {
+ mu sync.Mutex
+ text bytes.Buffer
+}
+
+func (b *pairScreenText) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.text.Write(p)
+}
+
+func (b *pairScreenText) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.text.String()
+}
+
+// waitFor is the first match of pattern in what a terminal has printed, or a
+// failure after a while.
+func (b *pairScreenText) waitFor(t *testing.T, pattern string) []string {
+ t.Helper()
+ re := regexp.MustCompile(pattern)
+ deadline := time.Now().Add(15 * time.Second)
+ for time.Now().Before(deadline) {
+ if found := re.FindStringSubmatch(b.String()); found != nil {
+ return found
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("the terminal never printed %q; it printed:\n%s", pattern, b.String())
+ return nil
+}
+
+// A typo is refused before any relay is looked up, so it needs no network and
+// no relay setting at all.
+func TestPairBadCode(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ t.Setenv("CODEAF_SYNC_URL", "off")
+ for _, typed := range []string{"12", "abc-def-ghi", "4-715-30x"} {
+ out := &pairScreenText{}
+ err := newPairDoor(strings.NewReader(""), out).run(context.Background(), []string{typed})
+ if !errors.Is(err, pair.ErrCodeShape) {
+ t.Fatalf("typing %q ended with %v, want the code-shape sentence", typed, err)
+ }
+ if out.String() != "" {
+ t.Fatalf("typing %q printed %q before it was refused", typed, out.String())
+ }
+ }
+}
+
+// A relay that is switched off is said at once, by both doors, and nothing is
+// sent to any relay: not the saved one and not the one named on the command line.
+func TestPairSyncOff(t *testing.T) {
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Errorf("a request reached the relay: %s %s", r.Method, r.URL.Path)
+ }))
+ defer stub.Close()
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", dir)
+ if err := syncsetup.SaveRelayURL(dir, stub.URL); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CODEAF_SYNC_URL", "OFF")
+ for _, args := range [][]string{nil, {"42-715-302"}} {
+ err := newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), args)
+ if !errors.Is(err, pair.ErrSyncOff) {
+ t.Fatalf("`codeaf pair %v` with sync off ended with %v", args, err)
+ }
+ }
+}
+
+// pairRig is two computers and a relay between them, in this process.
+type pairRig struct {
+ url string
+ homeA string
+ homeB string
+}
+
+func newPairRig(t *testing.T) *pairRig {
+ t.Helper()
+ svc := relayserve.New(relayserve.Config{Store: t.TempDir()})
+ srv := httptest.NewServer(svc.Handler)
+ t.Cleanup(func() { srv.Close(); svc.Close() })
+ rig := &pairRig{url: srv.URL, homeA: t.TempDir(), homeB: t.TempDir()}
+ if _, err := identity.Ensure(rig.homeA); err != nil {
+ t.Fatal(err)
+ }
+ return rig
+}
+
+// door is the real command with its three seams pointed at one of the homes.
+func (r *pairRig) door(home string, in io.Reader, out io.Writer) pairDoor {
+ d := newPairDoor(in, out)
+ d.mailbox = func(string) (pair.Mailbox, error) {
+ return pair.Mailbox{Box: pairbox.NewHTTP(r.url, &http.Client{Timeout: time.Minute}), URL: r.url, Host: "relay.test"}, nil
+ }
+ d.grant = func(route pair.Mailbox) (pair.Grant, error) {
+ id, err := identity.Ensure(home)
+ return pair.Grant{Identity: id, SyncURL: route.URL}, err
+ }
+ d.joining = func(replace bool) pair.Joining {
+ return pair.Joining{Home: home, Label: "laptop", Replace: replace,
+ SaveSyncURL: func(url string) error { return syncsetup.SaveRelayURL(home, url) }}
+ }
+ d.linking = func(route pair.Mailbox, replace bool) pair.LinkJoining {
+ j := linkJoining(home)(route, replace)
+ j.Label = "laptop"
+ return j
+ }
+ d.approver = linkApprover(home)
+ return d
+}
+
+// showing is the device that has the chats, running in the background with a
+// pipe a test types its answers into.
+type showing struct {
+ out *pairScreenText
+ typed *io.PipeWriter
+ done chan error
+ stop context.CancelFunc
+}
+
+func (r *pairRig) show(t *testing.T) *showing {
+ t.Helper()
+ in, typed := io.Pipe()
+ s := &showing{out: &pairScreenText{}, typed: typed, done: make(chan error, 1)}
+ ctx, stop := context.WithCancel(context.Background())
+ s.stop = stop
+ t.Cleanup(func() { stop(); _ = typed.Close() })
+ go func() { s.done <- r.door(r.homeA, in, s.out).run(ctx, []string{"--code"}) }()
+ return s
+}
+
+func (s *showing) code(t *testing.T) string {
+ t.Helper()
+ return s.out.waitFor(t, `codeaf pair (\S+)`)[1]
+}
+
+func (s *showing) end(t *testing.T) error {
+ t.Helper()
+ select {
+ case err := <-s.done:
+ return err
+ case <-time.After(20 * time.Second):
+ t.Fatal("`codeaf pair` did not finish")
+ return nil
+ }
+}
+
+// joinWith types a code on the other computer and answers what it ends with.
+func (r *pairRig) joinWith(t *testing.T, code string, replace bool) (*pairScreenText, error) {
+ t.Helper()
+ out := &pairScreenText{}
+ args := []string{code}
+ if replace {
+ args = append(args, "--replace")
+ }
+ return out, r.door(r.homeB, strings.NewReader(""), out).run(context.Background(), args)
+}
+
+// The whole of both doors through the real command: one shows a code, the other
+// types it, both screens show the same three words, a person types y, and the
+// second computer holds the first one's chats and the relay it came through.
+func TestPairBothDoorsOnATerminal(t *testing.T) {
+ rig := newPairRig(t)
+ shown := rig.show(t)
+ code := shown.code(t)
+
+ joined := make(chan error, 1)
+ var joiner *pairScreenText
+ go func() {
+ var err error
+ joiner, err = rig.joinWith(t, code, false)
+ joined <- err
+ }()
+
+ asked := shown.out.waitFor(t, `"laptop" wants your chats\. Same three words on that screen: ([a-z ]+)\? y / n `)
+ if _, err := io.WriteString(shown.typed, "y\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := <-joined; err != nil {
+ t.Fatal(err)
+ }
+ if err := shown.end(t); err != nil {
+ t.Fatal(err)
+ }
+
+ wanted := pair.WaitingChatsLine(asked[1])
+ if got := joiner.String(); got != wanted+"\n"+pair.JoinedLine+"\n" {
+ t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, pair.JoinedLine)
+ }
+ if !strings.HasSuffix(shown.out.String(), pair.PairedChatsLine("laptop")+"\n") {
+ t.Fatalf("the sharing terminal did not end on the paired line:\n%s", shown.out.String())
+ }
+ first, _ := identity.Load(rig.homeA)
+ second, err := identity.Load(rig.homeB)
+ if err != nil || second.ID() != first.ID() {
+ t.Fatalf("the second computer holds %v (%v), want the first one's chats", second.ID(), err)
+ }
+ if got := syncsetup.Resolve(rig.homeB).URL; got != rig.url {
+ t.Fatalf("the second computer saved the relay %q, want %q", got, rig.url)
+ }
+}
+
+// A person who does not say y lets nobody in: Enter alone is a no, and the
+// joining computer is told so in the pair package's own sentence.
+func TestPairEnterIsNo(t *testing.T) {
+ rig := newPairRig(t)
+ shown := rig.show(t)
+ code := shown.code(t)
+
+ joined := make(chan error, 1)
+ go func() { _, err := rig.joinWith(t, code, false); joined <- err }()
+ shown.out.waitFor(t, `y / n `)
+ if _, err := io.WriteString(shown.typed, "\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := <-joined; !errors.Is(err, pair.ErrRefused) {
+ t.Fatalf("the joining computer ended with %v, want a refusal", err)
+ }
+ if err := shown.end(t); !errors.Is(err, pair.ErrRefused) {
+ t.Fatalf("the sharing computer ended with %v, want a refusal", err)
+ }
+ if _, err := identity.Load(rig.homeB); err == nil {
+ t.Fatal("a refused computer was given the chats")
+ }
+}
+
+// A wrong code costs the person nothing but a new code, which is on the screen
+// before anyone presses a key; ctrl+c then ends the door cleanly.
+func TestPairWrongCodeBurnsAndShowsANewOne(t *testing.T) {
+ rig := newPairRig(t)
+ shown := rig.show(t)
+ code := shown.code(t)
+
+ wrong := code[:len(code)-1] + string(rune('0'+(code[len(code)-1]-'0'+1)%10))
+ if _, err := rig.joinWith(t, wrong, false); !errors.Is(err, pair.ErrCodeDidNotWork) {
+ t.Fatalf("a wrong code ended with %v, want the code-did-not-work sentence", err)
+ }
+ shown.out.waitFor(t, regexp.QuoteMeta(pair.BurnLine)+`\n(?s:.*)codeaf pair \S+`)
+ shown.stop()
+ if err := shown.end(t); err != nil {
+ t.Fatalf("ctrl+c ended the sharing door with %v, want a clean end", err)
+ }
+}
+
+// Every answer but y and yes is a no, whatever its case or spacing, and the end
+// of the input is one too.
+func TestTerminalConfirmReadsOnlyYesAsYes(t *testing.T) {
+ for typed, want := range map[string]bool{
+ "y\n": true, " YES \n": true, "Yes\n": true,
+ "\n": false, "n\n": false, "no\n": false, "yep\n": false, "ok\n": false, "": false,
+ } {
+ out := &pairScreenText{}
+ got := newTerminal(strings.NewReader(typed), out).confirm(context.Background(), "ok?")
+ if got != want {
+ t.Errorf("answering %q gave %v, want %v", typed, got, want)
+ }
+ if !strings.HasPrefix(out.String(), "ok? "+pair.AskChoice+" ") {
+ t.Errorf("the question was printed as %q", out.String())
+ }
+ }
+}
+
+// Silence is a no: a question that runs out of time answers false, and the next
+// question still gets the line a person types afterwards.
+func TestTerminalConfirmSilenceIsNo(t *testing.T) {
+ in, typed := io.Pipe()
+ defer typed.Close()
+ person := newTerminal(in, &pairScreenText{})
+
+ quiet, stop := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer stop()
+ if person.confirm(quiet, "first?") {
+ t.Fatal("silence was read as a yes")
+ }
+ go func() { _, _ = io.WriteString(typed, "y\n") }()
+ if !person.confirm(context.Background(), "second?") {
+ t.Fatal("the answer typed after the silence was lost")
+ }
+}
+
+// `codeaf serve` asks nobody when nobody is at a terminal, which the host reads
+// as a no.
+func TestServeApproverIsAbsentWithoutATerminal(t *testing.T) {
+ if serveApprover(context.Background(), nil, io.Discard) != nil {
+ t.Fatal("a machine with no terminal was given someone to ask")
+ }
+}
diff --git a/cmd/codeaf/pairlink.go b/cmd/codeaf/pairlink.go
new file mode 100644
index 0000000000..14548d78d7
--- /dev/null
+++ b/cmd/codeaf/pairlink.go
@@ -0,0 +1,180 @@
+package main
+
+// `codeaf pair` by link: a new device asks to join and shows a link; a device
+// that is already in approves it with `codeaf pair approve `.
+//
+// new$ codeaf pair shows a link and waits
+// old$ codeaf pair approve k7m2q9xd.Qm9v shows who is asking, asks y or n
+//
+// Both doors run internal/pair's JoinByLink and Approver. The app's approve
+// screen calls the same Approver, so the two cannot disagree.
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "net/http"
+ "runtime"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/reqsign"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// linkTimeout outlasts one held poll so the answer, not the client, ends a wait.
+const linkTimeout = directory.MaxWait + 10*time.Second
+
+// linkRequests is the open side of the directory at a route.
+func linkRequests(route pair.Mailbox) directory.Requests {
+ return directory.NewLinkHTTP(route.URL, &http.Client{Timeout: linkTimeout})
+}
+
+// homeDirectory is this computer's signed directory at a route, and the identity
+// it signs for.
+func homeDirectory(dir string, route pair.Mailbox) (directory.Client, identity.Identity, error) {
+ id, err := identity.Load(dir)
+ if errors.Is(err, identity.ErrNone) {
+ return nil, id, pair.ErrNotJoined
+ }
+ if err != nil {
+ return nil, id, err
+ }
+ dev, err := identity.Device(dir)
+ if err != nil {
+ return nil, id, err
+ }
+ sign := reqsign.SignFor(homeSigner{id, dev}, time.Now)
+ return directory.NewHTTP(route.URL, sign, &http.Client{Timeout: linkTimeout}), id, nil
+}
+
+type homeSigner struct {
+ id identity.Identity
+ dev identity.Dev
+}
+
+func (s homeSigner) IdentityKey() ed25519.PublicKey { return s.id.PublicKey() }
+func (s homeSigner) Cert() identity.Cert { return s.dev.Cert }
+func (s homeSigner) Sign(msg []byte) []byte { return s.dev.Sign(msg) }
+
+// pullFleet counts what the identity holds, as this computer now sees it.
+func pullFleet(dir string, route pair.Mailbox) func(context.Context) (pair.Fleet, error) {
+ return func(ctx context.Context) (pair.Fleet, error) {
+ client, _, err := homeDirectory(dir, route)
+ if err != nil {
+ return pair.Fleet{}, err
+ }
+ listing, err := client.List(ctx)
+ return fleetOf(listing), err
+ }
+}
+
+// fleetOf counts the devices that are still in and the workspaces that are still open.
+func fleetOf(l directory.Listing) pair.Fleet {
+ var f pair.Fleet
+ for _, d := range l.Devices {
+ if !d.Revoked {
+ f.Devices++
+ }
+ }
+ for _, c := range l.Cells {
+ if !c.Archived {
+ f.Workspaces++
+ }
+ }
+ return f
+}
+
+// linkJoining is this computer as the device that asks to join.
+func linkJoining(dir string) func(pair.Mailbox, bool) pair.LinkJoining {
+ return func(route pair.Mailbox, replace bool) pair.LinkJoining {
+ return pair.LinkJoining{
+ Joining: pairJoiningAt(dir)(replace), Platform: runtime.GOOS, Host: route.Host, Via: route.Shown,
+ Pull: pullFleet(dir, route),
+ }
+ }
+}
+
+// inviteScreen is the new device's terminal.
+type inviteScreen struct{ *terminal }
+
+func (s inviteScreen) Invited(in pair.Invite) { s.say(pair.InviteLines(in)) }
+
+// askToJoin is the new device: a link on screen until a device that is in says yes.
+func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) error {
+ route, err := d.mailbox(relay)
+ if err != nil {
+ return err
+ }
+ joined, err := pair.JoinByLink(ctx, d.requests(route), d.linking(route, replace), inviteScreen{d.term})
+ if err != nil && !errors.Is(err, pair.ErrPairedNotRead) {
+ return d.endedByPerson(ctx, err)
+ }
+ if err != nil {
+ d.term.say(err.Error())
+ return nil
+ }
+ d.term.say(pair.JoinedFleetLine(joined.Fleet.Workspaces))
+ return nil
+}
+
+// approve is a device that is in: it reads the request, shows who is asking and
+// the check number, and lets them in on a yes. Anything but a yes turns them down.
+func (d pairDoor) approve(ctx context.Context, relay, typed string) error {
+ ref, err := pair.ReadLink(typed)
+ if err != nil {
+ return err
+ }
+ route, err := d.mailbox(relay)
+ if err != nil {
+ return err
+ }
+ who, err := d.approver(route)
+ if err != nil {
+ return err
+ }
+ asking, err := who.Look(ctx, ref)
+ if err != nil {
+ return err
+ }
+ d.term.say(pair.WantsToJoinLine(asking.Name, asking.Platform))
+ if !d.term.confirm(ctx, pair.CheckQuestion(asking.Check)) {
+ return d.decline(ctx, who, asking)
+ }
+ if err := who.Approve(ctx, asking); err != nil {
+ return err
+ }
+ if err := identity.EndSolo(home.Dir()); err != nil {
+ return err
+ }
+ d.term.say(pair.ApprovedLine(asking.Name))
+ return nil
+}
+
+func (d pairDoor) decline(ctx context.Context, who pair.Approver, asking pair.Asking) error {
+ if ctx.Err() != nil {
+ return d.endedByPerson(ctx, nil)
+ }
+ if err := who.Deny(ctx, asking); err != nil {
+ return err
+ }
+ d.term.say(pair.DeclinedLine)
+ return nil
+}
+
+// linkApprover is this computer as the device that approves.
+func linkApprover(dir string) func(pair.Mailbox) (pair.Approver, error) {
+ return func(route pair.Mailbox) (pair.Approver, error) {
+ client, id, err := homeDirectory(dir, route)
+ if err != nil {
+ return pair.Approver{}, err
+ }
+ return pair.Approver{
+ Requests: linkRequests(route), Dir: client, Identity: id,
+ SyncURL: syncsetup.Named(route.URL), Replaces: identity.Predecessors(dir),
+ }, nil
+ }
+}
diff --git a/cmd/codeaf/pairlink_test.go b/cmd/codeaf/pairlink_test.go
new file mode 100644
index 0000000000..0900620e72
--- /dev/null
+++ b/cmd/codeaf/pairlink_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/pair"
+)
+
+// asking runs `codeaf pair` on the new computer in the background.
+func (r *pairRig) asking(t *testing.T) (*pairScreenText, chan error) {
+ t.Helper()
+ out := &pairScreenText{}
+ done := make(chan error, 1)
+ go func() { done <- r.door(r.homeB, strings.NewReader(""), out).run(context.Background(), nil) }()
+ return out, done
+}
+
+// approveWith runs `codeaf pair approve ` on the first computer, with the
+// person typing answer.
+func (r *pairRig) approveWith(t *testing.T, args []string, answer string) (*pairScreenText, error) {
+ t.Helper()
+ out := &pairScreenText{}
+ return out, r.door(r.homeA, strings.NewReader(answer), out).run(context.Background(), args)
+}
+
+// The headless path end to end: the new computer prints a link and a typed form,
+// the first one is shown who asks and the check number and types y, and the new
+// one says it is paired.
+func TestPairByLinkEndToEnd(t *testing.T) {
+ rig := newPairRig(t)
+ screen, done := rig.asking(t)
+ typed := screen.waitFor(t, `codeaf pair approve (\S+)`)[1]
+ link := screen.waitFor(t, `https://codeaf\.agentfield\.ai/p/\S+#\S+`)[0]
+ check := screen.waitFor(t, `Check number: (\d{4})`)[1]
+ if !strings.Contains(link, typed[:8]) {
+ t.Fatalf("the link %s and the typed form %s name different requests", link, typed)
+ }
+
+ out, err := rig.approveWith(t, []string{"approve", typed}, "y\n")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := pair.WantsToJoinLine("laptop", "linux") + "\n" + pair.CheckQuestion(check) + " y / n "
+ if !strings.HasPrefix(out.String(), want) || !strings.Contains(out.String(), pair.ApprovedLine("laptop")) {
+ t.Fatalf("the approving terminal printed:\n%s\nwant it to start:\n%s", out.String(), want)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ screen.waitFor(t, `Paired\.`)
+ first, _ := identity.Load(rig.homeA)
+ second, err := identity.Load(rig.homeB)
+ if err != nil || second.ID() != first.ID() {
+ t.Fatalf("the new computer holds %v (%v), want the first one's identity", second.ID(), err)
+ }
+}
+
+// A link pasted without the word approve means the same, because its shape says so.
+func TestPairLinkShapeApproves(t *testing.T) {
+ rig := newPairRig(t)
+ screen, done := rig.asking(t)
+ link := screen.waitFor(t, `https://codeaf\.agentfield\.ai/p/\S+#\S+`)[0]
+ if _, err := rig.approveWith(t, []string{link}, "y\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Anything but y turns the new computer down, and it is told so.
+func TestPairApproveNoDeclines(t *testing.T) {
+ rig := newPairRig(t)
+ screen, done := rig.asking(t)
+ typed := screen.waitFor(t, `codeaf pair approve (\S+)`)[1]
+ out, err := rig.approveWith(t, []string{"approve", typed}, "\n")
+ if err != nil || !strings.Contains(out.String(), pair.DeclinedLine) {
+ t.Fatalf("a no ended with %v and printed %q", err, out.String())
+ }
+ if err := <-done; !errors.Is(err, pair.ErrLinkDeclined) {
+ t.Fatalf("the new computer ended with %v, want the declined sentence", err)
+ }
+}
+
+// A computer with no identity of its own has no say, and a typo is refused
+// before any request is made.
+func TestPairApproveRefusals(t *testing.T) {
+ rig := newPairRig(t)
+ out := &pairScreenText{}
+ err := rig.door(rig.homeB, strings.NewReader("y\n"), out).run(context.Background(), []string{"approve", "k7m2q9xd.AAAAAAAAAAAAAAAAAAAAAA"})
+ if !errors.Is(err, pair.ErrNotJoined) {
+ t.Fatalf("an unpaired computer approving ended with %v", err)
+ }
+ err = rig.door(rig.homeA, strings.NewReader(""), out).run(context.Background(), []string{"approve", "nope"})
+ if !errors.Is(err, pair.ErrLinkShape) {
+ t.Fatalf("a typo ended with %v", err)
+ }
+}
+
+// `codeaf pair approve` with nothing after it is a usage error that names the
+// link, not a complaint about the shape of a six-digit code.
+func TestPairApproveWithoutALinkSaysUsage(t *testing.T) {
+ rig := newPairRig(t)
+ errs := &bytes.Buffer{}
+ previous := usageErr
+ usageErr = errs
+ defer func() { usageErr = previous }()
+ err := rig.door(rig.homeA, strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"approve"})
+ if err != exitStatus(2) || !strings.Contains(errs.String(), "usage: codeaf pair approve ") || strings.Contains(errs.String(), "42-715-302") {
+ t.Fatalf("a bare approve ended with %v and said %q, want its own usage line and exit 2", err, errs.String())
+ }
+}
+
+// ctrl+c while the link waits ends the pairing and says that nothing was paired,
+// so a person is not left looking at a silent prompt.
+func TestPairCancelSaysNothingWasPaired(t *testing.T) {
+ rig := newPairRig(t)
+ out := &pairScreenText{}
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- rig.door(rig.homeB, strings.NewReader(""), out).run(ctx, nil) }()
+ out.waitFor(t, `ctrl\+c to cancel`)
+ cancel()
+ if err := <-done; err != nil {
+ t.Fatalf("cancelling ended with %v, want a clean exit", err)
+ }
+ if !strings.Contains(out.String(), pair.CancelledLine) {
+ t.Fatalf("cancelling printed %q, want %q", out.String(), pair.CancelledLine)
+ }
+}
diff --git a/cmd/codeaf/pairroute.go b/cmd/codeaf/pairroute.go
new file mode 100644
index 0000000000..fc2bc9f09a
--- /dev/null
+++ b/cmd/codeaf/pairroute.go
@@ -0,0 +1,85 @@
+package main
+
+// The three things every pairing door needs before it can say a word: which
+// relay to go through, the identity to hand over, and where to keep a relay the
+// other device named. The chat's `/pair` and the terminal's `codeaf pair` both
+// take them from here, so the two doors cannot disagree about where a code goes.
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/devname"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/pair"
+ "github.com/Agent-Field/codeaf/internal/pairbox"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// pairPollTimeout outlasts one held poll (pairbox.MaxWait) so the relay ends the
+// wait and this client never cuts a healthy one short.
+const pairPollTimeout = pairbox.MaxWait + 10*time.Second
+
+// pairMailbox is the relay a pairing runs through: the --relay flag, else the
+// relay this computer syncs through. A relay named by flag is the one the other
+// device is told to pass, so it is `Shown`; the default needs no telling.
+func pairMailbox(relayFlag string) (pair.Mailbox, error) {
+ flag := strings.TrimSpace(relayFlag)
+ base := flag
+ if flag == "" {
+ relay := syncsetup.Resolve(home.Dir())
+ if relay.Off {
+ return pair.Mailbox{}, pair.ErrSyncOff
+ }
+ base = relay.URL
+ }
+ if base == "" {
+ return pair.Mailbox{}, pair.ErrNoSync
+ }
+ u, err := url.Parse(base)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return pair.Mailbox{}, pair.ErrNoSync
+ }
+ return pair.Mailbox{
+ Box: pairbox.NewHTTP(base, &http.Client{Timeout: pairPollTimeout}),
+ URL: base,
+ Host: u.Host,
+ Shown: flag,
+ }, nil
+}
+
+// pairGrant is what this computer hands to the device it pairs: the identity it
+// has, made on first use, and the relay the pairing went through, so the other
+// device syncs through the same one. The hosted default is left out: the other
+// device reaches it by default, and a saved copy would pin it to an address the
+// hosted relay may one day leave.
+// It also names the identities this one replaced by rotation, so a computer
+// still on one of them may follow instead of being refused.
+func pairGrant(route pair.Mailbox) (pair.Grant, error) {
+ id, err := identity.Ensure(home.Dir())
+ if err != nil {
+ return pair.Grant{}, err
+ }
+ if err := identity.EndSolo(home.Dir()); err != nil { // offering the identity is starting a fleet
+ return pair.Grant{}, err
+ }
+ return pair.Grant{Identity: id, SyncURL: syncsetup.Named(route.URL), Replaces: identity.Predecessors(home.Dir())}, nil
+}
+
+// pairJoining is this computer as the device that types the code.
+func pairJoining(replace bool) pair.Joining { return pairJoiningAt(home.Dir())(replace) }
+
+// pairJoiningAt is the same for the codeaf home at dir.
+func pairJoiningAt(dir string) func(replace bool) pair.Joining {
+ return func(replace bool) pair.Joining {
+ return pair.Joining{
+ Home: dir,
+ Label: devname.Name(dir),
+ Replace: replace,
+ SaveSyncURL: func(url string) error { return syncsetup.SaveRelayURL(dir, url) },
+ }
+ }
+}
diff --git a/cmd/codeaf/polishrows_test.go b/cmd/codeaf/polishrows_test.go
index 1b7fea94d2..549e0f5279 100644
--- a/cmd/codeaf/polishrows_test.go
+++ b/cmd/codeaf/polishrows_test.go
@@ -207,9 +207,9 @@ func TestDevicesRevokeReadsAllInEitherPositionAndSaysSoInTheUsage(t *testing.T)
t.Fatal(err)
}
if len(left) != 0 {
- t.Fatalf("%d devices are still admitted after --all stopped them: %v\nsaid: %s", len(left), left, printed)
+ t.Fatalf("%d devices are still admitted after --all removed them: %v\nsaid: %s", len(left), left, printed)
}
- if !strings.Contains(printed, "stopped") {
+ if !strings.Contains(printed, "removed") {
t.Fatalf("nothing said what happened:\n%s", printed)
}
})
diff --git a/cmd/codeaf/pool.go b/cmd/codeaf/pool.go
index c1b8e486c4..4953b286b4 100644
--- a/cmd/codeaf/pool.go
+++ b/cmd/codeaf/pool.go
@@ -81,9 +81,9 @@ var errPoolKeyDoesNotDecode = errors.New("the stored public key does not decode"
// from there — the remedy belongs to whichever word is in force.
func poolKeyReason(cfg poolcfg.Config) string {
if cfg.Source.PublicKey == "env" {
- return "CODEAF_MODEL_POOL_PUBLIC_KEY does not decode (set it to the base64 Ed25519 public key of your relay, or clear it)"
+ return "CODEAF_MODEL_POOL_PUBLIC_KEY does not decode (set it to the base64 Ed25519 public key of your model-pool server, or clear it)"
}
- return "models.pool.public_key does not decode (set it to the base64 Ed25519 public key of your relay, or clear it)"
+ return "models.pool.public_key does not decode (set it to the base64 Ed25519 public key of your model-pool server, or clear it)"
}
// poolKeys is --key's value: a base64 ed25519 public key, repeatable, each
@@ -195,7 +195,7 @@ func printPool(output io.Writer, poolDir string, cfg poolcfg.Config, now time.Ti
}
for _, line := range []string{
fmt.Sprintf("mode %s · %s", cfg.Mode, cfg.Source.Mode),
- fmt.Sprintf("relay %s · %s", cfg.RelayURL, cfg.Source.RelayURL),
+ fmt.Sprintf("pool server %s · %s", cfg.RelayURL, cfg.Source.RelayURL),
fmt.Sprintf("index %s · %s", cfg.IndexURL, cfg.Source.IndexURL),
fmt.Sprintf("mirror %s · %s", orNowhere(cfg.MirrorURL), cfg.Source.MirrorURL),
fmt.Sprintf("submit %s · %s", orNowhere(cfg.SubmitURL), cfg.Source.SubmitURL),
@@ -474,22 +474,22 @@ func probeAddress(url, poolDir string, keys []ed25519.PublicKey, now func() time
// asks nothing and says that instead.
func relayStatusLine(cfg poolcfg.Config, relay, mirror probeSummary, cached *index.Index) string {
if !cfg.CanRead() {
- return "relay: not read (model_pool off)"
+ return "pool server: not read (model_pool off)"
}
if relay.Reachable {
if relay.fromCache {
- return fmt.Sprintf("relay: reachable · index unchanged, version %d", relay.Version)
+ return fmt.Sprintf("pool server: reachable · index unchanged, version %d", relay.Version)
}
- return fmt.Sprintf("relay: reachable · index version %d", relay.Version)
+ return fmt.Sprintf("pool server: reachable · index version %d", relay.Version)
}
if mirror.Reachable {
- return fmt.Sprintf("relay: unreachable (%s) · mirror: reachable · index version %d", relay.Reason, mirror.Version)
+ return fmt.Sprintf("pool server: unreachable (%s) · mirror: reachable · index version %d", relay.Reason, mirror.Version)
}
where := "built-in seed"
if cached != nil {
where = "cache"
}
- return fmt.Sprintf("relay: unreachable (%s) · mirror: unreachable (%s) · reading %s", relay.Reason, mirror.Reason, where)
+ return fmt.Sprintf("pool server: unreachable (%s) · mirror: unreachable (%s) · reading %s", relay.Reason, mirror.Reason, where)
}
// oneLine is a reason said on one line: any newline becomes a space, because
diff --git a/cmd/codeaf/pool_test.go b/cmd/codeaf/pool_test.go
index b9469e1538..244e2aa3b2 100644
--- a/cmd/codeaf/pool_test.go
+++ b/cmd/codeaf/pool_test.go
@@ -184,7 +184,7 @@ func TestPoolShowPrintsTheConfigAndSaysWhenNoIndexIsCached(t *testing.T) {
body := out.String()
for _, want := range []string{
"mode on · default",
- "relay https://codeaf.agentfield.ai/pool · default",
+ "pool server https://codeaf.agentfield.ai/pool · default",
"index https://codeaf.agentfield.ai/pool/index.json · default",
"mirror https://raw.githubusercontent.com/Agent-Field/CodeAF/model-pool/pool/index.json · default",
"submit https://codeaf.agentfield.ai/pool/v1/rows · default",
@@ -1799,7 +1799,7 @@ func TestPoolStatusSaysTheRelayAnswered(t *testing.T) {
if err := runPoolWith([]string{"status", "--key", base64.StdEncoding.EncodeToString(pub)}, &out, t.TempDir(), poolClock(t), lookup); err != nil {
t.Fatalf("a reachable relay failed status: %v", err)
}
- if !strings.Contains(out.String(), "relay: reachable · index version 7") {
+ if !strings.Contains(out.String(), "pool server: reachable · index version 7") {
t.Fatalf("status did not say the relay answered:\n%s", out.String())
}
}
@@ -1950,7 +1950,7 @@ func TestPoolStatusSaysUnreachableAndStillExitsZero(t *testing.T) {
t.Fatalf("an unreachable relay failed status: %v", err)
}
body := out.String()
- if !strings.Contains(body, "relay: unreachable (") {
+ if !strings.Contains(body, "pool server: unreachable (") {
t.Fatalf("status did not say the relay was unreachable:\n%s", body)
}
if !strings.Contains(body, "mirror: unreachable (") {
@@ -1977,7 +1977,7 @@ func TestPoolStatusFallsToTheMirrorWhenTheRelayIsDown(t *testing.T) {
t.Fatalf("a mirror that answered still failed status: %v", err)
}
body := out.String()
- if !strings.Contains(body, "relay: unreachable (") || !strings.Contains(body, "mirror: reachable · index version 7") {
+ if !strings.Contains(body, "pool server: unreachable (") || !strings.Contains(body, "mirror: reachable · index version 7") {
t.Fatalf("status did not fall to the mirror:\n%s", body)
}
}
@@ -2046,7 +2046,7 @@ func TestPoolStatusSaysWhyAStoredKeyDoesNotDecode(t *testing.T) {
if err := runPoolWith([]string{"status"}, &out, dir, poolClock(t), deadEnv()); err != nil {
t.Fatalf("a broken stored key failed status: %v", err)
}
- if !strings.Contains(out.String(), "relay: unreachable (models.pool.public_key does not decode") {
+ if !strings.Contains(out.String(), "pool server: unreachable (models.pool.public_key does not decode") {
t.Fatalf("status did not name the broken row:\n%s", out.String())
}
@@ -2073,7 +2073,7 @@ func TestPoolStatusDoesNotReadWhenOff(t *testing.T) {
if err := runPoolWith([]string{"status"}, &out, t.TempDir(), poolClock(t), lookup); err != nil {
t.Fatal(err)
}
- if !strings.Contains(out.String(), "relay: not read (model_pool off)") {
+ if !strings.Contains(out.String(), "pool server: not read (model_pool off)") {
t.Fatalf("an off pool did not say it asked nothing:\n%s", out.String())
}
}
diff --git a/cmd/codeaf/render.go b/cmd/codeaf/render.go
index b3a7a0deca..0781f819a3 100644
--- a/cmd/codeaf/render.go
+++ b/cmd/codeaf/render.go
@@ -55,8 +55,8 @@ func renderSpine(graph *plan.Graph) {
}
func renderNodes(graph *plan.Graph) {
- fmt.Printf("\n── nodes ───────────────────────────────────────────────────────────\n")
- fmt.Printf(" # %-24s %-11s %-12s %s\n", "node", "size", "waits for", "context received")
+ fmt.Printf("\n── parts ───────────────────────────────────────────────────────────\n")
+ fmt.Printf(" # %-24s %-11s %-12s %s\n", "part", "size", "waits for", "context received")
for _, node := range graph.Nodes {
waits, context := "—", "goal only"
if len(node.Needs) > 0 {
@@ -136,12 +136,12 @@ func renderSchedule(graph *plan.Graph) {
if len(stageWaves) > 0 {
freed = len(waves[0]) - len(stageWaves[0])
}
- fmt.Printf("\n %d of %d nodes start immediately", graph.Roots(), len(graph.Nodes))
+ fmt.Printf("\n %d of %d parts start immediately", graph.Roots(), len(graph.Nodes))
if freed > 0 {
fmt.Printf(" — %d freed from the stage barrier", freed)
}
fmt.Printf("\n %s across %s, critical path %d (%d doing real work)\n",
- plural(graph.Edges(), "edge"), plural(len(graph.Nodes), "node"), len(waves), graph.WorkDepth())
+ plural(graph.Edges(), "edge"), plural(len(graph.Nodes), "part"), len(waves), graph.WorkDepth())
fmt.Printf(" %s to execute, decomposed %s deep",
plural(len(graph.Leaves()), "leaf"), plural(graph.Depth()+1, "level"))
if unresolved := graph.Unresolved(); unresolved > 0 {
@@ -163,7 +163,7 @@ func renderOperations(operations []plan.Operation) {
}
label := operation.Title
if label == "" {
- label = fmt.Sprintf("node %d", operation.Node)
+ label = fmt.Sprintf("part %d", operation.Node)
}
fmt.Printf(" %-8s %-22s %s\n", operation.Op, clip(label, 22), status)
if operation.Reason != "" {
diff --git a/cmd/codeaf/run.go b/cmd/codeaf/run.go
index d3ca63dfba..7fc892771a 100644
--- a/cmd/codeaf/run.go
+++ b/cmd/codeaf/run.go
@@ -198,7 +198,7 @@ func runGraph(name string, args []string) error {
// The ruler stays keyed to the work model even when a different model
// plans: the anchors measure the executor.
installMeasuredRulers(settings, settings.Model)
- ctx := settings.Context(context.Background(), graph.Goal)
+ ctx := settings.Context(hostSeated(context.Background()), graph.Goal)
// Discovery started before the clients are built, because an adapter reads
// the catalog to decide which knobs a model will accept. It is started, not
// waited for: every question it answers here is asked later than the first
diff --git a/cmd/codeaf/sealseam_test.go b/cmd/codeaf/sealseam_test.go
new file mode 100644
index 0000000000..34d79153b5
--- /dev/null
+++ b/cmd/codeaf/sealseam_test.go
@@ -0,0 +1,26 @@
+package main
+
+import "testing"
+
+type sealingStub struct{ failing bool }
+
+func (s sealingStub) SealFailing() bool { return s.failing }
+
+// ON THE ORDINARY LAUNCH there is no local seal watch: the agent is a handle on
+// an engine in another process, and the segment must read what that agent says.
+func TestTheSealSegmentReadsTheAgentWhenThereIsNoLocalWatch(t *testing.T) {
+ seam := sealSeamOf(sealingStub{failing: true}, nil)
+ if seam.Failing == nil || !seam.Failing() {
+ t.Fatal("a failing far seal did not reach the surface")
+ }
+ if seam.Notice != nil {
+ t.Fatal("a seam with no local watch has nothing to drain")
+ }
+}
+
+// Unknown is not a failure: an agent that cannot say, and no watch, draw nothing.
+func TestNoAgentStateAndNoWatchDrawsNothing(t *testing.T) {
+ if seam := sealSeamOf(struct{}{}, nil); seam.Failing != nil || seam.Notice != nil {
+ t.Fatal("a seam appeared from nothing")
+ }
+}
diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go
new file mode 100644
index 0000000000..d46df2a7f0
--- /dev/null
+++ b/cmd/codeaf/syncdrive.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/cellsync"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// driveCloseWithin bounds the quit path's last publish and lease release, so a
+// silent relay cannot hold the terminal.
+const driveCloseWithin = 10 * time.Second
+
+// syncDrives is the drive side of every conversation the chat door opened in
+// this process. It exists at all only for a chat: a headless run never syncs.
+var syncDrives = &driveBook{}
+
+// driveBook keeps one drive side per cell, so a conversation opened again in the
+// same process (a resume, a switch back) goes on with the drive it had instead
+// of taking a second lease.
+type driveBook struct {
+ mu sync.Mutex
+ drive map[string]*liveDrive
+}
+
+// driveOf is the drive side of c, made on first use. It answers nil, and the
+// chat runs exactly as it did before sync existed, when sync is off, or when it
+// cannot start: a relay setting must never stop a conversation from opening.
+// A computer with no identity yet gets a drive side that starts by itself the
+// moment one is made (see liveDrive). A nil book answers nil, which is how a
+// door that never syncs asks.
+func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error)) *liveDrive {
+ if b == nil {
+ return nil
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if d := b.drive[c.ID]; d != nil && !d.superseded() {
+ return d
+ }
+ // A drive side that no longer holds the chat's lease (another machine took it, or it was moved
+ // back here) never drives again: the chat opened here needs a drive side of its own, or every
+ // tool call in it would be refused as superseded.
+ delete(b.drive, c.ID)
+ d := newLiveDrive(func() (*syncsetup.Drive, error) { return startDrive(c, engine, report) })
+ if d != nil {
+ if b.drive == nil {
+ b.drive = map[string]*liveDrive{}
+ }
+ b.drive[c.ID] = d
+ }
+ return d
+}
+
+// startDrive opens the drive side of c. The error it answers is already
+// reported; it is returned so a caller can tell a computer that has no identity
+// yet (which /pair will change) from one that cannot sync at all.
+func startDrive(c cell.Cell, engine cellstore.Engine, report func(error)) (*syncsetup.Drive, error) {
+ s, ok, err := syncsetup.Open(home.Dir())
+ if errors.Is(err, syncsetup.ErrNoIdentity) {
+ // Waiting for an identity is not a seal failing: the live drive starts by itself once one
+ // is made, and until then nothing is owed to the relay. With the hosted default every
+ // fresh computer is in this state, so reporting it would mark every first launch as
+ // failing to seal.
+ return nil, err
+ }
+ if err != nil || !ok {
+ report(err)
+ return nil, err
+ }
+ if syncsetup.Quiet(home.Dir()) {
+ return nil, syncsetup.ErrQuiet // a fleet of one has nothing to sync with yet
+ }
+ if line, first := s.FirstRun(); first {
+ surfaceNotices.Say(line)
+ }
+ d, err := s.Drive(context.Background(), engine, c, syncsetup.DriveOptions{
+ DeviceName: deviceName(),
+ // The title the chat has now, sealed on its way out: the other machine's
+ // list names the chat by it.
+ Title: func() string { return chatTitle(c) },
+ // A line is said by the surface the moment it happens (tui3/notices.go).
+ OnNotice: surfaceNotices.Say,
+ })
+ if sayRefusal(err) {
+ return nil, err
+ }
+ report(err)
+ return d, err
+}
+
+// sayRefusal tells the person what the relay refused when it refused this
+// device outright (removed from the fleet, replaced, over its limit) and
+// answers true, so the chat runs unsynced with the reason on screen instead
+// of opening as if nothing were wrong. Any other failure is not its business.
+func sayRefusal(err error) bool { return sayRefusalTo(surfaceNotices.SayOnce, err) }
+
+func sayRefusalTo(say func(string), err error) bool {
+ r, ok := cellsync.RefusalOf(err)
+ if ok {
+ say(r.Say(err))
+ }
+ return ok
+}
+
+// chatTitle is the chat's title as its session record has it now: the name it
+// earned, or the person's opening words until it has one.
+func chatTitle(c cell.Cell) string {
+ m, _ := session.LoadMeta(c.Root)
+ return m.Title
+}
+
+// closeAll ends every drive side: what is sealed is published and each lease is
+// given back, so another machine can take the chat at once. It runs on the
+// door's way out, after the conversations closed, so their last seal is in.
+func (b *driveBook) closeAll() {
+ drives := b.take()
+ ctx, cancel := context.WithTimeout(context.Background(), driveCloseWithin)
+ defer cancel()
+ for _, d := range drives {
+ if err := d.Close(ctx); err != nil {
+ log.Print("sync: closing a chat: ", err)
+ }
+ }
+}
+
+// take hands over every drive side and forgets them.
+func (b *driveBook) take() map[string]*liveDrive {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ drives := b.drive
+ b.drive = nil
+ return drives
+}
+
+// driveGate is what a tool call meets before it runs: the superseded line once
+// another machine has taken the chat, and nothing while this one drives it.
+func driveGate(d *liveDrive) func() error {
+ if d == nil {
+ return nil
+ }
+ return d.Gate
+}
+
+// driveStore is how the seat's seals reach the drive side; nil seals on the
+// engine itself.
+func driveStore(d *liveDrive) func(cellstore.Engine) cellstore.Store {
+ if d == nil {
+ return nil
+ }
+ return d.Store
+}
diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go
new file mode 100644
index 0000000000..0e53d025f0
--- /dev/null
+++ b/cmd/codeaf/syncdrive_live.go
@@ -0,0 +1,130 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// liveDrive is the drive side of one chat, started when it can be. A chat opens
+// before its computer is paired, and /pair inside the chat then makes the
+// identity that sync needs; a drive side fixed at open would leave that chat
+// unsynced until the next restart. So the chat's seat holds a liveDrive, which
+// asks again at each seal, gate and idle for as long as the one thing missing
+// is the identity, and goes on with the real drive from the call that finds it.
+type liveDrive struct {
+ start func() (*syncsetup.Drive, error)
+
+ mu sync.Mutex
+ drive *syncsetup.Drive
+ waiting bool // sync is on and only an identity, or the end of solo, is missing
+}
+
+// newLiveDrive starts the drive side now when it can. It answers nil when sync
+// is off or cannot start for any reason a pairing would not change, so the chat
+// runs unsynced exactly as it did before sync existed.
+func newLiveDrive(start func() (*syncsetup.Drive, error)) *liveDrive {
+ l := &liveDrive{start: start}
+ l.try()
+ if l.drive == nil && !l.waiting {
+ return nil
+ }
+ return l
+}
+
+// try starts the drive side if it is not running and an identity is there now.
+// The identity is one file read and the relay is asked only once it exists, so a
+// chat that never pairs costs nothing per call.
+func (l *liveDrive) try() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.drive != nil || (l.waiting && !syncsetup.MayTalk(home.Dir())) {
+ return
+ }
+ d, err := l.start()
+ l.drive, l.waiting = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet)
+}
+
+// current is the running drive side, or nil while there is none.
+func (l *liveDrive) current() *syncsetup.Drive {
+ l.try()
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.drive
+}
+
+// Gate is the drive side's gate: nothing is refused until a drive side exists.
+func (l *liveDrive) Gate() error {
+ if d := l.current(); d != nil {
+ return d.Gate()
+ }
+ return nil
+}
+
+// Idle uploads what is sealed, once a drive side exists.
+func (l *liveDrive) Idle() {
+ if d := l.current(); d != nil {
+ d.Idle()
+ }
+}
+
+// Store is the store a seal goes through: the engine itself until a drive side
+// exists, then the drive side's. A turn sealed before it started is noted by the
+// drive side's own catch-up, so nothing sealed in between is left behind.
+func (l *liveDrive) Store(e cellstore.Engine) cellstore.Store {
+ return lateStore{live: l, engine: e}
+}
+
+// Close ends the drive side if one ever started.
+func (l *liveDrive) Close(ctx context.Context) error {
+ if d := l.started(); d != nil {
+ return d.Close(ctx)
+ }
+ return nil
+}
+
+// holdsLeaseWithin bounds the look at the directory that a chat opened again makes.
+const holdsLeaseWithin = 5 * time.Second
+
+// superseded says a drive side was running and the chat has since been taken from it, by
+// another machine or by a move back to this one, so that it no longer holds the lease it
+// publishes under. A drive side that still waits for an identity, or that drives, is not.
+func (l *liveDrive) superseded() bool {
+ d := l.started()
+ if d == nil {
+ return false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), holdsLeaseWithin)
+ defer cancel()
+ return !d.HoldsLease(ctx)
+}
+
+// started is the drive side if one ever started, without trying to start it.
+func (l *liveDrive) started() *syncsetup.Drive {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.drive
+}
+
+// lateStore seals on whichever store the chat has now.
+type lateStore struct {
+ live *liveDrive
+ engine cellstore.Engine
+}
+
+var _ cellstore.Store = lateStore{}
+
+// Seal implements cellstore.Store.
+func (s lateStore) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) {
+ var store cellstore.Store = s.engine
+ if d := s.live.current(); d != nil {
+ store = d.Store(s.engine)
+ }
+ return store.Seal(ctx, c, info)
+}
diff --git a/cmd/codeaf/syncdrive_test.go b/cmd/codeaf/syncdrive_test.go
new file mode 100644
index 0000000000..7a8587b5ed
--- /dev/null
+++ b/cmd/codeaf/syncdrive_test.go
@@ -0,0 +1,376 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/executor"
+ "github.com/Agent-Field/codeaf/internal/furrow"
+ "github.com/Agent-Field/codeaf/internal/handoff"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/relayserve"
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+ "github.com/Agent-Field/codeaf/internal/wireauth"
+)
+
+// doorChat is a chat cell opened through the door's own seat construction, in a
+// home of its own.
+func doorChat(t *testing.T) (session.Config, cell.Cell) {
+ t.Helper()
+ if _, err := furrow.ResolveOwned(); err != nil {
+ t.Skipf("no engine binary: %v", err)
+ }
+ // The engine daemon has no sync verbs until its own lane lands, so the
+ // door's sync runs over the spawn transport, which has them.
+ t.Setenv("CODEAF_ENGINE_DAEMON", "0")
+ t.Setenv(home.EnvVar, t.TempDir())
+ t.Setenv("HOME", t.TempDir())
+ c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound})
+ if err != nil {
+ t.Fatal(err)
+ }
+ prior := syncDrives
+ syncDrives = &driveBook{}
+ t.Cleanup(func() { syncDrives.closeAll(); syncDrives = prior })
+ return session.Config{Place: session.Place{Dir: c.Root, Workspace: t.TempDir()}}, c
+}
+
+func toolCallOn(t *testing.T, seat executor.Seat, workspace string) error {
+ t.Helper()
+ call := executor.Call{Tool: "bash", Args: []byte(`{"command":"echo"}`)}
+ return seat.Around(context.Background(), call, func() ([]byte, bool) {
+ return nil, os.WriteFile(filepath.Join(workspace, "f.txt"), []byte("x"), 0o600) != nil
+ })
+}
+
+// With no relay set the door is Stage 0 exactly: no drive side is made, the seat
+// is not gated, and a turn is sealed by the zero device under fence 0.
+func TestDriveSideOffIsStageZero(t *testing.T) {
+ cfg, c := doorChat(t)
+ t.Setenv(syncsetup.URLVar, "off")
+ seated := v3Seated(cfg)
+ if len(syncDrives.drive) != 0 {
+ t.Fatal("a drive side was made with sync off")
+ }
+ if seated.Seat == nil {
+ t.Fatal("no seat")
+ }
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ turns, err := cellstore.Turns(c)
+ if err != nil || len(turns) == 0 {
+ t.Fatalf("turns = %v, %v", turns, err)
+ }
+ if got := turns[len(turns)-1]; got.Fence != 0 || got.Device != "0000000000000000000000000000000000000000000000000000000000000000" {
+ t.Fatalf("a Stage 0 turn carries %q fence %d", got.Device, got.Fence)
+ }
+}
+
+// With a relay set the door notes every sealed turn for it, and closing the
+// door gives the lease back after publishing what was sealed.
+func TestDriveSideDoorPublishesAndReleases(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ if _, err := identity.Ensure(home.Dir()); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ t.Setenv(syncsetup.IntervalVar, "50")
+
+ seated := v3Seated(cfg)
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ syncDrives.closeAll()
+
+ s, ok, err := syncsetup.Open(home.Dir())
+ if err != nil || !ok {
+ t.Fatalf("Open = %v, %v", ok, err)
+ }
+ head, _ := cellstore.Head(c)
+ v, err := s.Dir.Cell(context.Background(), c.ID)
+ if err != nil || v.Cell.Head != head.Turn.ID {
+ t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID)
+ }
+ if v.Cell.Lease.Expires != 0 || v.Cell.Lease.Device != s.Device.ID() {
+ t.Fatalf("lease after close = %+v, want released by this device", v.Cell.Lease)
+ }
+}
+
+// A device the relay has stopped is a refusal the person is told of, even when
+// the error arrives wrapped; a relay that is merely unreachable is not.
+func TestSayRefusalOnlyForRefusals(t *testing.T) {
+ if !sayRefusal(fmt.Errorf("put device: %w", wireauth.ErrRevoked)) {
+ t.Error("a stopped device was not told")
+ }
+ if sayRefusal(errors.New("connection refused")) || sayRefusal(nil) {
+ t.Error("a failure that is not a refusal was said")
+ }
+}
+
+// A chat that opened before this computer had an identity starts to sync the
+// moment /pair makes one, with no restart: the next call's seal is published and
+// closing the chat gives the lease back, exactly as for a chat that was paired
+// from the start.
+func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ t.Setenv(syncsetup.IntervalVar, "50")
+
+ seated := v3Seated(cfg)
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := identity.Ensure(home.Dir()); err != nil { // what /pair does in the running chat
+ t.Fatal(err)
+ }
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ syncDrives.closeAll()
+
+ s, ok, err := syncsetup.Open(home.Dir())
+ if err != nil || !ok {
+ t.Fatalf("Open = %v, %v", ok, err)
+ }
+ head, _ := cellstore.Head(c)
+ v, err := s.Dir.Cell(context.Background(), c.ID)
+ if err != nil || v.Cell.Head != head.Turn.ID {
+ t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID)
+ }
+}
+
+// A chat open while its computer is solo uploads its next turns the moment the
+// computer is approved, with no reopen.
+func TestDriveSideStartsWhenTheSoloMarkerEnds(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ t.Setenv(syncsetup.IntervalVar, "50")
+ if _, err := identity.EnsureSolo(home.Dir()); err != nil {
+ t.Fatal(err)
+ }
+
+ seated := v3Seated(cfg)
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ if d := syncDrives.drive[c.ID]; d == nil || d.started() != nil {
+ t.Fatal("a solo computer must hold a waiting drive side, not a running one")
+ }
+ if err := identity.EndSolo(home.Dir()); err != nil { // what an approval does
+ t.Fatal(err)
+ }
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ syncDrives.closeAll()
+
+ s, ok, err := syncsetup.Open(home.Dir())
+ if err != nil || !ok {
+ t.Fatalf("Open = %v, %v", ok, err)
+ }
+ head, _ := cellstore.Head(c)
+ v, err := s.Dir.Cell(context.Background(), c.ID)
+ if err != nil || v.Cell.Head != head.Turn.ID {
+ t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID)
+ }
+}
+
+// One predicate, four states, the drive paths: startDrive and liveDrive agree
+// with syncsetup.MayTalk.
+func TestDrivePathsFollowMayTalk(t *testing.T) {
+ cases := []struct {
+ name string
+ setup func(dir string)
+ talk bool
+ }{
+ {"no identity", func(string) {}, false},
+ {"solo", func(d string) { _, _ = identity.EnsureSolo(d) }, false},
+ {"paired", func(d string) { _, _ = identity.Ensure(d) }, true},
+ {"engaged", func(d string) { _, _ = identity.EnsureSolo(d); _ = identity.EndSolo(d) }, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ tc.setup(home.Dir())
+ if got := syncsetup.MayTalk(home.Dir()); got != tc.talk {
+ t.Fatalf("MayTalk = %v, want %v", got, tc.talk)
+ }
+ d, err := startDrive(c, cellstore.EngineFor(""), func(error) {})
+ if (d != nil) != tc.talk {
+ t.Fatalf("startDrive drive=%v err=%v, want drive %v", d != nil, err, tc.talk)
+ }
+ if d != nil {
+ _ = d.Close(context.Background())
+ }
+ _ = cfg
+ })
+ }
+}
+
+// A computer with no identity is waiting, not failing to seal: with the hosted default every fresh
+// computer is in that state, and it must not show a failing seal on its first launch.
+func TestStartDriveWithNoIdentityReportsNothing(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ t.Setenv(syncsetup.URLVar, "")
+ var reported []error
+ d, err := startDrive(cell.Cell{}, cellstore.Engine{}, func(e error) { reported = append(reported, e) })
+ if d != nil || !errors.Is(err, syncsetup.ErrNoIdentity) {
+ t.Fatalf("startDrive = %v, %v; want no drive and ErrNoIdentity", d, err)
+ }
+ if len(reported) != 0 {
+ t.Fatalf("waiting for an identity was reported as a seal outcome: %v", reported)
+ }
+}
+
+// A chat that was moved to another computer and then moved back opens here with a drive side of
+// its own. The drive side kept from before the move only shows the chat, so reusing it would
+// refuse every tool call in the chat that was moved back.
+func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ t.Setenv(syncsetup.IntervalVar, "50")
+ first := home.Dir()
+ id, err := identity.Ensure(first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seated := v3Seated(cfg)
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ firstBook := syncDrives
+
+ // The second computer, one identity with the first, opens the chat while the first drives it.
+ second := t.TempDir()
+ blob, err := identity.Export(id, "p")
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved, err := identity.Import(blob, "p")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := identity.Adopt(second, moved, false); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv(home.EnvVar, second)
+ syncDrives = &driveBook{}
+ t.Cleanup(func() { syncDrives = firstBook })
+ engine := cellstore.EngineFor(cfg.Place.Workspace)
+ viewing := syncDrives.driveOf(c, engine, func(error) {})
+ if viewing == nil || viewing.started() == nil {
+ t.Fatal("the second computer made no drive side")
+ }
+ if _, viewer := viewing.started().Viewer(); !viewer {
+ t.Skip("the first computer did not hold the lease yet")
+ }
+
+ // The first computer lets the chat go; opening it again here must drive it.
+ t.Setenv(home.EnvVar, first)
+ firstBook.closeAll()
+ t.Setenv(home.EnvVar, second)
+ again := syncDrives.driveOf(c, engine, func(error) {})
+ if again == viewing {
+ t.Fatal("the drive side that only showed the chat was reused")
+ }
+ if line := again.Gate(); line != nil {
+ t.Fatalf("a chat opened after it was moved back is refused: %v", line)
+ }
+}
+
+// A chat that another computer took and that was then taken back here, with no word reaching the
+// drive side this window kept, opens with a drive side of its own: the old one publishes under a
+// lease that is gone, so reusing it would refuse every tool call in the chat that came back.
+func TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn(t *testing.T) {
+ cfg, c := doorChat(t)
+ srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler)
+ t.Cleanup(srv.Close)
+ t.Setenv(syncsetup.URLVar, srv.URL)
+ t.Setenv(syncsetup.IntervalVar, "50")
+ first := home.Dir()
+ id, err := identity.Ensure(first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seated := v3Seated(cfg)
+ if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil {
+ t.Fatal(err)
+ }
+ kept := syncDrives.drive[c.ID]
+
+ second := t.TempDir()
+ blob, err := identity.Export(id, "p")
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved, err := identity.Import(blob, "p")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := identity.Adopt(second, moved, false); err != nil {
+ t.Fatal(err)
+ }
+ force := func(dir string) {
+ t.Helper()
+ s, ok, err := syncsetup.Open(dir)
+ if err != nil || !ok {
+ t.Fatalf("Open(%s) = %v, %v", dir, ok, err)
+ }
+ if _, err := s.Dir.Acquire(context.Background(), c.ID, directory.AcquireOpts{Force: true}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ force(second) // the other computer takes the chat
+ force(first) // and it is taken back here, as a take does
+
+ again := syncDrives.driveOf(c, cellstore.EngineFor(cfg.Place.Workspace), func(error) {})
+ if again == kept {
+ t.Fatal("the drive side whose lease was taken was reused")
+ }
+ if err := again.Gate(); err != nil {
+ t.Fatalf("a chat taken back is refused: %v", err)
+ }
+}
+
+// A chat in the middle of its takeover here is not opened: the session would
+// read the journal as it was before the swap. The door says so, builds nothing,
+// and opens the chat once the takeover is over.
+func TestAChatBeingTakenIsNotOpened(t *testing.T) {
+ cfg, _ := doorChat(t)
+ opened := 0
+ open := func(session.Config) (*session.Agent, error) { opened++; return nil, errors.New("stop here") }
+ release := handoff.Arrive(cfg.Place.Dir)
+ if _, _, _, err := openV3Agent(cfg, cfg.Place.Workspace, open); err == nil || err.Error() != chatArrivingSentence {
+ t.Fatalf("a chat being taken was opened or refused in other words: %v", err)
+ }
+ if opened != 0 {
+ t.Fatal("a session was built for a chat that is being taken")
+ }
+ release()
+ _, _, _, _ = openV3Agent(cfg, cfg.Place.Workspace, open)
+ if opened != 1 {
+ t.Fatal("the chat did not open after its takeover ended")
+ }
+}
diff --git a/cmd/codeaf/syncstanding_test.go b/cmd/codeaf/syncstanding_test.go
new file mode 100644
index 0000000000..de093e964b
--- /dev/null
+++ b/cmd/codeaf/syncstanding_test.go
@@ -0,0 +1,130 @@
+package main
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/chatlist"
+ "github.com/Agent-Field/codeaf/internal/dirwatch"
+ "github.com/Agent-Field/codeaf/internal/tui3"
+
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/wireauth"
+)
+
+// countingDir answers every listing as a relay that has stopped this device,
+// and counts how often it was asked.
+type countingDir struct {
+ directory.Client
+ asked int
+}
+
+func (c *countingDir) List(context.Context) (directory.Listing, error) {
+ c.asked++
+ return directory.Listing{}, wireauth.ErrRevoked
+}
+
+func TestCheckStandingAsksOnlyASharedHome(t *testing.T) {
+ cases := []struct {
+ name string
+ shared bool
+ asked int
+ said int
+ }{{"solo home sends nothing", false, 0, 0}, {"shared home asks once and says so", true, 1, 1}}
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ if _, err := identity.EnsureSolo(home); err != nil {
+ t.Fatal(err)
+ }
+ if tc.shared {
+ if err := identity.EndSolo(home); err != nil {
+ t.Fatal(err)
+ }
+ }
+ dir, lines := &countingDir{}, []string{}
+ checkStanding(home, dir, func(l string) { lines = append(lines, l) })
+ if dir.asked != tc.asked || len(lines) != tc.said {
+ t.Errorf("asked %d said %v, want %d and %d lines", dir.asked, lines, tc.asked, tc.said)
+ }
+ })
+ }
+}
+
+// fakeFeed is a follower the test drives: set the state, then signal.
+type fakeFeed struct {
+ dirwatch.Follower
+ mu sync.Mutex
+ state dirwatch.State
+ changes chan struct{}
+ closed bool
+}
+
+func newFakeFeed() *fakeFeed { return &fakeFeed{changes: make(chan struct{}, 4)} }
+
+func (f *fakeFeed) State() dirwatch.State {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.state
+}
+func (f *fakeFeed) Changes() <-chan struct{} { return f.changes }
+func (f *fakeFeed) Close() { f.closed = true }
+
+func (f *fakeFeed) refuse(err error) {
+ f.mu.Lock()
+ f.state.Refused = err
+ f.mu.Unlock()
+ f.changes <- struct{}{}
+}
+
+func sharedHome(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ if _, err := identity.EnsureSolo(home); err != nil {
+ t.Fatal(err)
+ }
+ if err := identity.EndSolo(home); err != nil {
+ t.Fatal(err)
+ }
+ return home
+}
+
+func TestARevokedSocketIsSaidOnceWhileTheAppRuns(t *testing.T) {
+ feed := newFakeFeed()
+ desk := tui3.NewNotices()
+ var lines []string
+ say := func(l string) { lines = append(lines, l); desk.SayOnce(l) }
+ done := make(chan struct{})
+ go func() { watchStanding(sharedHome(t), func() dirwatch.Follower { return feed }, say); close(done) }()
+ feed.changes <- struct{}{} // a presence change is not a refusal
+ feed.refuse(dirwatch.ErrRevoked)
+ <-done
+ // the first refused request finds out the same thing a moment later
+ sayRefusalTo(desk.SayOnce, wireauth.ErrRevoked)
+ if len(lines) != 1 || lines[0] != chatlist.Removed {
+ t.Fatalf("said %q, want the removed sentence once", lines)
+ }
+ if !feed.closed {
+ t.Error("the listener kept the feed after the refusal")
+ }
+}
+
+func TestAFeedAlreadyRefusedIsSaidAtOnce(t *testing.T) {
+ feed := newFakeFeed()
+ feed.state.Refused = dirwatch.ErrRotated
+ var lines []string
+ watchStanding(sharedHome(t), func() dirwatch.Follower { return feed }, func(l string) { lines = append(lines, l) })
+ if len(lines) != 1 || lines[0] != chatlist.Replaced {
+ t.Fatalf("said %q, want the replaced sentence", lines)
+ }
+}
+
+func TestASoloHomeOpensNoSocketToListen(t *testing.T) {
+ home := t.TempDir()
+ if _, err := identity.EnsureSolo(home); err != nil {
+ t.Fatal(err)
+ }
+ watchStanding(home, func() dirwatch.Follower { t.Fatal("a solo home followed the feed"); return nil }, nil)
+}
diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go
new file mode 100644
index 0000000000..009a7065f2
--- /dev/null
+++ b/cmd/codeaf/synctake.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/cellstore"
+ "github.com/Agent-Field/codeaf/internal/devname"
+ "github.com/Agent-Field/codeaf/internal/directory"
+ "github.com/Agent-Field/codeaf/internal/dirwatch"
+ "github.com/Agent-Field/codeaf/internal/guard"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+ "github.com/Agent-Field/codeaf/internal/tui3"
+ "github.com/Agent-Field/codeaf/internal/wireauth"
+)
+
+// surfaceNotices is the one desk the sync side puts its sentences on for the
+// surface to say at once: another machine took the chat over, the clock is off.
+// The drive side and the take side both speak through it (tui3/notices.go).
+var surfaceNotices = tui3.NewNotices()
+
+// wireSync gives the surface everything that needs a relay: the chats on other
+// machines, `continue here`, and what a branch row can do. With sync off, or
+// when it cannot start, it adds nothing and the home screen must not fail to
+// start over a relay setting: every seam stays nil, and a nil seam is absent.
+func wireSync(o *tui3.Options) {
+ if o.Notices == nil {
+ o.Notices = surfaceNotices
+ }
+ s, err := syncOfFirst()
+ if err != nil {
+ return
+ }
+ if o.Machines == nil {
+ o.Machines = s
+ }
+ if o.Fleet == nil {
+ o.Fleet = s
+ }
+ if o.Takeover == nil {
+ o.Takeover = takeover{s.Continuer(cellstore.EngineFor(""), syncsetup.TakeOptions{
+ DeviceName: deviceName(),
+ RootFor: takeRootFor,
+ Notify: surfaceNotices.Say,
+ })}
+ }
+ // THERE IS NO MERGE: a branch row says `discard` alone (cell_branch.go).
+ if o.Branches.Discard == nil {
+ o.Branches = tui3.BranchActions{Discard: s.Discard}
+ }
+ guard.Go("sync/standing", func() { checkStanding(s.Home, s.Dir, surfaceNotices.SayOnce) })
+ guard.Go("sync/standing-live", func() { watchStanding(s.Home, s.Follow, surfaceNotices.SayOnce) })
+}
+
+// standingWithin bounds the launch-time check so a silent relay costs nothing.
+const standingWithin = 15 * time.Second
+
+// checkStanding asks the directory once at launch whether this device is still
+// let in, and says the refusal (removed from the fleet, replaced) as soon as the
+// surface is up. A conversation that is only resumed, and so has no drive side
+// yet, would otherwise open as if nothing were wrong until its first message.
+// A solo identity (made here, never shared) cannot have been stopped, so it
+// asks nothing: a fresh install sends the sync service no request on its own.
+func checkStanding(dir string, d directory.Client, say func(string)) {
+ if !syncsetup.MayTalk(dir) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), standingWithin)
+ defer cancel()
+ _, err := d.List(ctx)
+ sayRefusalTo(say, err)
+}
+
+// watchStanding keeps listening, for as long as the app runs, for the relay
+// ending this device's socket as removed or replaced, and says it the moment it
+// happens, in the open chat and on home alike, without waiting for a restart or
+// the next refused request. The same quiet law as checkStanding: a home that
+// cannot talk to anyone holds no socket. The feed stops reconnecting after a
+// refusal, so the listener ends there too.
+func watchStanding(dir string, follow func() dirwatch.Follower, say func(string)) {
+ if !syncsetup.MayTalk(dir) {
+ return
+ }
+ f := follow()
+ if f == nil {
+ return
+ }
+ defer f.Close()
+ for refused(f) == nil {
+ if _, open := <-f.Changes(); !open {
+ return
+ }
+ }
+ sayRefusalTo(say, standingRefusal(refused(f)))
+}
+
+// refused is the final refusal the feed ended with, nil while it is not ended.
+func refused(f dirwatch.Follower) error { return f.State().Refused }
+
+// standingRefusals maps the feed's final refusals to the wire's, so the one
+// table of sentences (cellsync.refusals) is the only place they are written.
+var standingRefusals = []struct{ feed, wire error }{
+ {dirwatch.ErrRevoked, wireauth.ErrRevoked},
+ {dirwatch.ErrRotated, wireauth.ErrRotated},
+}
+
+func standingRefusal(err error) error {
+ for _, r := range standingRefusals {
+ if errors.Is(err, r.feed) {
+ return r.wire
+ }
+ }
+ return err
+}
+
+// takeover is the surface's Taker over the take side: what a takeover reports,
+// in the shape the surface knows.
+type takeover struct{ c *syncsetup.Continuer }
+
+var _ tui3.Taker = takeover{}
+
+func (t takeover) Take(ctx context.Context, id string) (tui3.Taken, error) {
+ began := time.Now()
+ got, err := t.c.Take(ctx, id)
+ elapsed := time.Since(began)
+ if err != nil {
+ return tui3.Taken{}, err
+ }
+ return tui3.Taken{Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed}, nil
+}
+
+// takeRootFor is where this machine keeps the chat with an id: the folder it
+// already has, found where a chat keeps it, and otherwise a folder in the
+// bucket of chats that belong to no project, where a chat that arrives from
+// another machine is at home.
+func takeRootFor(id string) string {
+ if c, err := openCellByID(id); err == nil {
+ return c.Root
+ }
+ here, _ := os.UserHomeDir()
+ return filepath.Join(home.Join("v3", "projects", encodeWorkspace(here)), id)
+}
+
+// deviceName is what this machine is called in a sentence about it: the name a
+// person gave it, else its host name.
+func deviceName() string { return devname.Name(home.Dir()) }
diff --git a/cmd/codeaf/synctake_test.go b/cmd/codeaf/synctake_test.go
new file mode 100644
index 0000000000..4ea1bde0e9
--- /dev/null
+++ b/cmd/codeaf/synctake_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/cell"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/identity"
+ "github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+ "github.com/Agent-Field/codeaf/internal/tui3"
+)
+
+// With no relay set the surface gets the notice desk and nothing else: every
+// other seam stays nil, and a nil seam is an absent verb, never a broken one.
+func TestWireSyncOffAddsOnlyTheDesk(t *testing.T) {
+ t.Setenv(home.EnvVar, t.TempDir())
+ t.Setenv(syncsetup.URLVar, "off")
+ var o tui3.Options
+ wireSync(&o)
+ if o.Machines != nil || o.Takeover != nil || o.Branches.Discard != nil || o.Branches.Merge != nil {
+ t.Fatalf("sync off wired %+v", o)
+ }
+ if o.Notices == nil {
+ t.Fatal("the notice desk is missing")
+ }
+}
+
+// With a relay and an identity the surface can list, continue and discard, and
+// it offers no merge.
+func TestWireSyncOnWiresTheTakeSide(t *testing.T) {
+ h := t.TempDir()
+ t.Setenv(home.EnvVar, h)
+ t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1")
+ if _, err := identity.Ensure(h); err != nil {
+ t.Fatal(err)
+ }
+ var o tui3.Options
+ wireSync(&o)
+ if o.Machines == nil || o.Takeover == nil || o.Branches.Discard == nil {
+ t.Fatalf("sync on wired %+v", o)
+ }
+ if o.Branches.Merge != nil {
+ t.Fatal("a merge is offered and there is none")
+ }
+}
+
+// A chat this machine already has is taken back into the folder it is in; one
+// it has never seen gets a folder in the bucket of chats with no project.
+func TestTakeRootForFindsTheFolderAChatIsIn(t *testing.T) {
+ h := t.TempDir()
+ t.Setenv(home.EnvVar, h)
+ t.Setenv("HOME", t.TempDir())
+ bucket := filepath.Join(h, "v3", "projects", "-work-project")
+ c, err := cell.CreateIn(bucket, cell.Options{Class: cell.HostBound})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := takeRootFor(c.ID); got != c.Root {
+ t.Fatalf("takeRootFor(%s) = %s, want the folder it is in, %s", c.ID, got, c.Root)
+ }
+ const stranger = "01J0000000000000000000000Z"
+ if got := takeRootFor(stranger); filepath.Base(got) != stranger || filepath.Dir(filepath.Dir(got)) != filepath.Join(h, "v3", "projects") {
+ t.Fatalf("a new chat is kept at %s, want a bucket under v3/projects", got)
+ }
+}
+
+// The title a chat publishes is the one its session record has now.
+func TestChatTitleIsTheSessionsTitle(t *testing.T) {
+ c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := chatTitle(c); got != "" {
+ t.Fatalf("a chat nothing named has title %q", got)
+ }
+ if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Title: "Port the picker"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := chatTitle(c); got != "Port the picker" {
+ t.Fatalf("chatTitle = %q", got)
+ }
+}
diff --git a/cmd/codeaf/syncwire.go b/cmd/codeaf/syncwire.go
new file mode 100644
index 0000000000..7bd164b459
--- /dev/null
+++ b/cmd/codeaf/syncwire.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "errors"
+
+ "github.com/Agent-Field/codeaf/internal/chatlist"
+ "github.com/Agent-Field/codeaf/internal/home"
+ "github.com/Agent-Field/codeaf/internal/syncsetup"
+)
+
+// syncListSource is the chat list's source for the headless verb. With no
+// relay set it says sync is off, and with a relay but no identity it says so:
+// two causes, two sentences, so the person is told what to do.
+func syncListSource() (chatlist.Source, error) { return syncOf() }
+
+// syncOf is the sync of this machine, or the sentence that says why there is
+// none.
+func syncOf() (*syncsetup.Sync, error) { return syncFrom(syncsetup.Open) }
+
+// syncOfFirst is syncOf for the app's own launch, where a machine with a relay
+// and no identity is the first machine and makes one.
+func syncOfFirst() (*syncsetup.Sync, error) { return syncFrom(syncsetup.OpenFirst) }
+
+func syncFrom(open func(string) (*syncsetup.Sync, bool, error)) (*syncsetup.Sync, error) {
+ s, ok, err := open(home.Dir())
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, errors.New(chatlist.SyncOff)
+ }
+ return s, nil
+}
diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go
index 3178d73033..8c6f5bcece 100644
--- a/cmd/codeaf/usage.go
+++ b/cmd/codeaf/usage.go
@@ -339,11 +339,7 @@ func breakLong(word string, width int) []string {
// cache clean` are one noun with two verbs on it, not one word meaning two
// things, and without this entry `codeaf cache --help` would print the
// destructive command's line under the harmless one's name.
-// `devices revoke` is the same shape as `cache clean` and is here for the same
-// reason: one noun, a harmless reading verb and a destructive one, and without
-// the entry `codeaf devices --help` would print the revoking line under the
-// listing's name.
-var longerCommands = []string{"cache clean", "devices revoke"}
+var longerCommands = []string{"cache clean"}
// usageForCommand lifts one command's lines out of [usageText].
//
@@ -507,7 +503,7 @@ func commandWords() []string {
// reason they are absent from the usage text: nothing types them. The programs
// this build carries are joined to it where it is read ([commandWords]).
var knownCommands = []string{
- "chat", "resume", "serve", "devices", "do", "plan", "revise", "run", "exec",
+ "chat", "resume", "pair", "serve", "devices", "do", "plan", "revise", "run", "exec",
"show", "models", "pool", "notebook", "collections", "competence", "services", "wake", "patch",
"doc", "web", "image",
"doctor",
diff --git a/cmd/codeaf/why.go b/cmd/codeaf/why.go
index 2ec0eb17ab..8454d39924 100644
--- a/cmd/codeaf/why.go
+++ b/cmd/codeaf/why.go
@@ -23,7 +23,7 @@ func runWhyTo(args []string, output io.Writer, now time.Time) error {
return err
}
if flags.NArg() != 1 {
- return fmt.Errorf("usage: codeaf why self| [--db path]")
+ return fmt.Errorf("usage: codeaf why self| [--db path]")
}
path, err := expandHome(strings.TrimSpace(*database))
if err != nil {
diff --git a/cmd/relay/main.go b/cmd/relay/main.go
index 02480a7a2a..3bc9e7a780 100644
--- a/cmd/relay/main.go
+++ b/cmd/relay/main.go
@@ -1,5 +1,6 @@
// Command relay is the codeaf relay: a blind pipe between a machine that runs
-// codeaf and a machine somebody is sitting at.
+// codeaf and a machine somebody is sitting at, and, given --store, the
+// directory and blob store a person's machines share.
//
// IT IS A SEPARATE BINARY BECAUSE IT IS A SEPARATE THING. Nothing in it imports
// the session, the surface, or the wire they speak; it cannot open a frame and
@@ -8,66 +9,21 @@
// import away from being able to read what it forwards.
//
// It is also what internal/pair's own tests drive, which is the other reason it
-// exists: the end-to-end story — a machine registering, a device pairing, a
-// conversation crossing — is tested against this exact service and not against
-// a stub of it.
+// exists: the end-to-end story is tested against this exact service and not
+// against a stub of it. `codeaf relay` runs the same relayserve.Main.
//
-// relay --listen :8787
-//
-// In front of it in production goes whatever already terminates TLS. The relay
-// reads X-Forwarded-For when it is there, so its rate limits count the caller
-// and not the load balancer.
+// relay --listen :8787 [--store /var/lib/codeaf]
package main
import (
- "encoding/json"
- "flag"
"fmt"
- "log"
- "net/http"
"os"
- "time"
- "github.com/Agent-Field/codeaf/internal/relay"
+ "github.com/Agent-Field/codeaf/internal/relayserve"
)
func main() {
- listen := flag.String("listen", ":8787", "address to listen on")
- quiet := flag.Bool("quiet", false, "do not log arrivals and departures")
- status := flag.Bool("status", true, "answer GET /status with what is connected right now")
- flag.Parse()
-
- service := &relay.Server{}
- if !*quiet {
- // WHAT A RELAY OPERATOR MAY LOG IS WHAT A RELAY OPERATOR CAN SEE, and
- // that is a machine name and a moment. There is no payload to log here
- // and no argument in this file that could carry one.
- service.Note = func(name, what string) { log.Printf("%s %s", name, what) }
- }
-
- mux := http.NewServeMux()
- mux.Handle(relay.EnginePath, service)
- mux.Handle(relay.DialPrefix, service)
- if *status {
- mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(service.Ledgers())
- })
- }
-
- server := &http.Server{
- Addr: *listen,
- Handler: mux,
- // READ AND WRITE TIMEOUTS ARE DELIBERATELY ABSENT. Every connection
- // this service accepts is meant to be held open for hours, and a write
- // timeout on a hijacked connection is a conversation cut in half.
- // The bounds that matter are in the package: a registration must finish
- // its handshake within relay.HandshakeWithin, and a carrier that stops
- // answering pings is dropped at relay.IdleAfter.
- ReadHeaderTimeout: 20 * time.Second,
- }
- log.Printf("relay on %s, speaking %s", *listen, relay.Protocol)
- if err := server.ListenAndServe(); err != nil {
+ if err := relayserve.Main(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "relay:", err)
os.Exit(1)
}
diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md
index 982f485dc0..7410023288 100644
--- a/docs/HEADLESS.md
+++ b/docs/HEADLESS.md
@@ -16,7 +16,7 @@ schema, and a person needs to know which command actually thinks.
```
codeaf do "" [--dir dir] [--db path] [--keep] [--timeout D]
- [--json] [--yes-spend] [--model slug] [--plan-model slug]
+ [--continue id] [--json] [--yes-spend] [--model slug] [--plan-model slug]
[--check-model slug] [--best|--cheap] [--pin seat=model[@provider]]
[--context-fill N] [--completion-reserve N]
```
@@ -39,6 +39,7 @@ work done.
| `--dir dir` | the current directory | The directory it works in, **edited in place**. Not an output folder — it opens what is there and leaves nothing behind that you did not ask for. `-w` is the shorthand and keeps working forever. |
| `--db path` | a private temp store, deleted on exit | Work in this durable store instead. This is how state survives across runs. |
| `--keep` | off | Keep the private store instead of deleting it; the path is printed to stderr. |
+| `--continue id` | none | Carry on a kept run (one that did not finish, or was kept with `--keep`): `id` is the part of the `record kept at` folder name after `codeaf-do-`, an unambiguous start of it, or the folder path. Words after it are this round's finding; the assignment is unchanged. Works in `--dir`. Run engine only. |
| `--timeout D` | `15m` | Hard wall, as a duration with a unit: `5m`, `2h`, `90s`. A bare number is still read as seconds for one release, so `--timeout 900` keeps working. A wall, not a schedule — the length of rope at which a wedged run is more useful dead. |
| `--json` | off | Print one machine-readable object instead of the prose deliverable. |
| `--yes-spend` | off | Spend past today's limit and past the plan-price question, without stopping to ask. The same flag with the same one sentence on `codeaf plan run`. Equivalent to `CODEAF_PREAUTHORIZE_SPEND=1`. It does not lift the per-task limit (`/crew cap task`, $5 unless set). |
diff --git a/docs/changes/unreleased/1738-two-computers-pair-and-continue.md b/docs/changes/unreleased/1738-two-computers-pair-and-continue.md
new file mode 100644
index 0000000000..ed74536ec0
--- /dev/null
+++ b/docs/changes/unreleased/1738-two-computers-pair-and-continue.md
@@ -0,0 +1,22 @@
+---
+kind: added
+title: two computers pair with a link and a chat continues on the other one with its files and history
+pr: 1738
+surface: [chat, engine, remote, docs]
+invalidates:
+ - "A chat belonged to the computer that started it. Another computer could not list it, open it or carry it on. Now a paired computer lists the chat on its home screen with the name of the computer it runs on, and choosing 'continue here' moves it with its files, history, tool output and task records, so the agent can answer from what it did. Moving chats is on with no setting; a person who never pairs sends nothing, and `CODEAF_CELLS=0` turns cells off."
+ - "The way to add a second computer was the six-digit code typed on both. Now the new computer runs `codeaf pair` and prints a link and a four-digit check number; a computer already in use approves it from the home screen card or with `codeaf pair approve`. The six-digit code still works."
+ - "A computer was known to other computers only by its host name. It can now be named: `codeaf pair --name`, `codeaf devices rename` or the n key in /devices, and every screen that names a device uses that name."
+ - "Sync did nothing until a relay was set. With no setting it now goes to the hosted service; `codeaf relay` runs your own and one setting points codeaf at it."
+ - "A computer that froze or lost its network could show as online for minutes. A paired computer that has not answered for 25 seconds now shows as offline, within 30 seconds of its last answer, and shows online again as soon as it answers."
+ - "A moved chat's task journals were capped like job logs. Each task's transcript, trajectory and saved output now travels whole with the chat, with no size limit; only job logs and saved tool output keep the 1 MiB per file and 16 MiB together caps."
+ - "A `models.fallbacks` list naming only the model that was failing was read as no list, so codeaf sent the turn to other models it picked. It is now a pin: no other model is used, codeaf asks that model again a bounded number of times, then ends the turn and says what failed."
+ - "Opening a chat on a computer while it was still arriving there could open a half-arrived copy. It now shows `this chat is arriving on this computer; it opens by itself in a moment`, and the chat opens by itself when it has arrived."
+ - "A wrong or expired pairing code could leave the new computer waiting with no word. It now always ends with a sentence: `that code did not work, or it timed out; ask for a new one`, or `the other device stopped pairing; run /pair there again`."
+ - "When a model you named for a task, or pinned to the checking role, failed, codeaf could move the work to a model it guessed from that computer's own catalog, so two computers could pick differently. Now that work stays on your model, or moves only to a model you wrote in `models.fallbacks`; the guess is used only when you picked nothing and wrote no fallback list."
+ - "The job logs codeaf writes in a task's working copy moved with the chat and came back on the other computer as if you had edited them. They now stay behind; a file you made in that copy still moves."
+ - "A test run left its engine daemons (`furrow serve`) running after it finished, and a daemon could hold open files its starter was handed, such as a lock taken by a wrapper script. Now the test binaries of cmd/codeaf, internal/session and internal/tui3 stop every daemon they started before they exit, and a daemon started under a test exits after one idle minute if its test run crashed. Any daemon codeaf starts now inherits only stdin, stdout and stderr. The daemon a person's own codeaf starts still outlives it, as before."
+ - "The hosted relay drew a pairing number again the moment its mailbox was released, so a device still waiting on the old code could find a new, live mailbox under the same number and wait there until its own timeout. A released or expired number is now held back for ten minutes, and every mailbox carries a random generation that the device sends back, so a device from an earlier pairing is told the code is gone. Old builds send no generation and keep working. The self-hosted relay does not name generations yet."
+ - "The hosted relay let one network make 20 new identities a day, counted by whole IPv6 address, and a refused computer was told only that the network had started too many, with no time. Now the limit is 200 a day, an IPv6 network counts by its /64, for the pairing limits too (an IPv4-mapped address counts as its IPv4 address), the refusal carries the seconds until that network's day ends in `Retry-After` and in a `retry_after` field, and the chat says `too many new identities from this network today — try again in about 3 hours`."
+ - "Restoring or deleting a withheld file could follow a symlinked parent outside the chat workspace; capturing that file could also read outside secrets. Vault file operations now resolve beneath opened directory handles, including when a parent name is replaced during the operation, and keep outside files untouched. In-root symlinks that stay within the opened root can still resolve; this is confinement, not a blanket ban on symlinks."
+---
diff --git a/docs/relay.md b/docs/relay.md
new file mode 100644
index 0000000000..eb30455dbc
--- /dev/null
+++ b/docs/relay.md
@@ -0,0 +1,64 @@
+# Running your own relay
+
+The relay is the service your computers sync and pair through. It stores ciphertext and
+sees the short list of metadata in the manual page "What the relay stores and what it can
+see". This page is for the person who runs one.
+
+## Run
+
+```
+codeaf relay --listen :8787 --store /var/lib/codeaf
+```
+
+`relay --listen :8787 --store /var/lib/codeaf` (the separate `relay` binary) is the same program.
+
+| Flag | Meaning |
+|---|---|
+| `--listen` | address to listen on, default `:8787` |
+| `--store ` | where the directory and the chunks live. Empty runs the blind pipe and pairing only: no chats are kept |
+| `--quiet` | no log lines |
+| `--status=false` | do not answer `GET /status` |
+| `--trust-proxy` | count the first `X-Forwarded-For` address as the caller's network in the pairing limits. Only behind a proxy that sets it |
+
+Point each computer at it with `CODEAF_SYNC_URL=https://relay.example.com`, or pass
+`--relay` to `codeaf pair`.
+
+The relay has no TLS and no allow-list. Put a TLS-terminating proxy in front of it, and keep
+it on a private network or behind a proxy that checks who calls: any valid signature may make
+an identity and store on it. It has no quota of its own, so the disk is the limit.
+
+It logs the first 8 characters of an identity id, the kind of request, the status and two
+byte counts. Never a body.
+
+## Back up
+
+Everything is under `--store`:
+
+- `directory/.db`: one SQLite file for each identity (chat records, device records, leases);
+- `blobs//`: that identity's chunks and the pointers that find an object inside one.
+
+Stop the relay (SIGTERM; requests under way finish) and copy the whole folder, or copy a
+filesystem snapshot. Copying the `.db` files while the relay runs can give a torn copy.
+Restore by putting the folder back
+and starting the relay. A restore to an older copy loses turns sent after it; a computer that
+still holds them sends them again at its next sync (not tested here).
+
+The relay cannot decrypt what it keeps, so its backup is ciphertext too. It holds no key that
+could recover a lost identity.
+
+## Upgrade
+
+Stop it, replace the `codeaf` binary, start it with the same `--store`. There is no migration command, and this page does not promise that every version reads an older store: back up first. A client newer than the
+relay by a pairing change sees `that relay is too old for pairing`.
+
+## The hosted relay
+
+`relay/hosted/` is the same relay as a Cloudflare Worker with Durable Objects and R2, with
+per-identity limits (5 GiB, rate limits, a daily cap on new identities per network, set in `relay/hosted/src/limits.js`). Its own
+README says how to deploy it to an account. Nothing in this repository deploys it.
+
+codeaf's own deployment is called the fabric and is served at `https://codeaf.agentfield.ai/fabric`,
+the address every computer syncs through when nobody names another. It sees only ciphertext, the
+time and size of each request, and the network address of each computer; it keeps no request log.
+The address has a path, and every client keeps it. `codeaf relay` (this page) always serves from
+the root of its host; behind your own proxy, pass the path through unchanged.
diff --git a/docs/remote-access-testing.md b/docs/remote-access-testing.md
index c3d3fc1176..dd1e5064e5 100644
--- a/docs/remote-access-testing.md
+++ b/docs/remote-access-testing.md
@@ -406,7 +406,7 @@ codeaf serve # on the engine machine
```
this machine is reachable as otter-lamp-42
- pair a new device with code 715 302 (valid 10 minutes)
+ let a device use this machine with code 42-715-302 (valid 10 minutes)
```
Then from the other machine, `codeaf chat --at otter-lamp-42` walks the pairing:
diff --git a/docs/testdata/ux-pairing-vectors.json b/docs/testdata/ux-pairing-vectors.json
new file mode 100644
index 0000000000..6f32740d0c
--- /dev/null
+++ b/docs/testdata/ux-pairing-vectors.json
@@ -0,0 +1,15 @@
+{
+ "request_pending": {"code":"k7m2q9xd","device":"dev_3Fq9","pubkey":"AAAA","x25519":"BBBB","name_sealed":"CCCC","platform":"linux","check":"4821","requested_at":1790000000000,"expires_at":1790000600000,"state":"pending","grant":null},
+ "create_answer": {"code":"k7m2q9xd","device":"dev_3Fq9","check":"4821","requested_at":1790000000000,"expires_at":1790000600000},
+ "device_legacy": {"V":1,"name":"bg==","added_by":"id_x","revoked":false,"caps":{"os":"linux","arch":"arm64","sandbox":null,"container":null,"gpu":null,"cow":"none"}},
+ "device_new": {"V":1,"name":"bg==","added_by":"id_x","revoked":false,"caps":{"os":"linux","arch":"arm64","sandbox":null,"container":null,"gpu":null,"cow":"none"},"platform":"linux","created":1790000123456,"last_seen":1790000999000},
+ "presence_list": {"now":1790000001000,"devices":{"dev_A":{"online":true,"last_seen":1790000001000},"dev_B":{"online":false,"last_seen":1789999000000}}},
+ "frames": [
+ {"v":42},
+ {"t":"joined","device":"dev_B","name":"bg==","platform":"linux","at":1790000123456},
+ {"t":"presence","device":"dev_B","online":true,"at":1790000123999},
+ {"t":"presence","device":"dev_B","online":false,"at":1790000555000},
+ {"t":"revoked","device":"dev_B","at":1790000700000}
+ ],
+ "error": {"err":"already_decided"}
+}
diff --git a/docs/testing-anywhere.md b/docs/testing-anywhere.md
new file mode 100644
index 0000000000..b4eb58a130
--- /dev/null
+++ b/docs/testing-anywhere.md
@@ -0,0 +1,266 @@
+# Testing a chat that moves between computers
+
+This page says how to test every path a chat takes between two computers, from the
+fastest check to a walk by hand. Run the sections in order. Each one costs more than the
+one before it and proves more.
+
+Moving chats is on by default, so no test or rig sets a cell variable. Only a test of
+the off path sets `CODEAF_CELLS=0`.
+
+Settings. Every script that names a machine, a folder or a relay reads it from an
+environment variable (through `scripts/measure/rigenv.py`) and stops with a message that
+names the variable when one is missing. Nothing is edited in a script. Export these once:
+
+| Variable | Meaning | Default |
+|---|---|---|
+| `CODEAF_RELAY` | the relay under test: a hosted one (`https://...`) or your own (`codeaf relay`, see [relay.md](relay.md)) | none |
+| `CODEAF_SECOND_HOST` | the second machine, reachable with `ssh $CODEAF_SECOND_HOST` and no password prompt; a Mac or a Linux box | none |
+| `CODEAF_SECOND_ROOT` | an absolute folder on the second machine that a rig may fill and wipe | none |
+| `CODEAF_FIRST_ROOT` | the same on this machine | `~/caf-rig` |
+| `CODEAF_CORPUS` | a folder holding the corpus repos `r18-pareto-c365`, `r02-mj-base`, `r06-agentfield` | none |
+| `CODEAF_RIG_BIN` | a folder holding the built `codeaf`, `codeaf-vd` and `s1probe` | none |
+| `CODEAF_EVIDENCE` | where `hosted-validate.py` writes its result folders | `~/caf-evidence` |
+
+Other names read by one rig: `CODEAF_SRC` and `BENCH_BUILD` (`bench-move.sh`: the checkout to
+build, where it builds), `CONT_PORT` and `CONT_SUFFIX` (`continuity.py`: a dev-server port and a
+tmux name, so two rigs can run side by side), `FID_REUSE` and `FID_PLAIN` (`fidelity.py`: keep the
+pairing between runs; build the older fixture), `BILL_DOC` (`relay-bill.sh`: the decision
+document it rewrites).
+
+Anything that calls a model uses `deepseek/deepseek-v4.1-flash` for every role. The key must
+already be in your login environment; none of these scripts print it.
+
+## What each test proves
+
+| Test | Proves | Runtime |
+|---|---|---|
+| `make test-quick` | the tree builds, is formatted, passes the laws and the manual gates | a few minutes |
+| `go test` on the sync packages | seal, upload, fetch, take, pairing and the setup flow work in process | under 5 minutes |
+| `relay/hosted` `npm test` | the hosted relay's units: store, directory, leases, pairing, watch | under 2 minutes |
+| `TestPairJourney` | a person can pair two homes and move a chat, by keystrokes in a real terminal | about 2 minutes |
+| `scripts/relay-conformance.sh` | both relays (Go and hosted) obey the same contract | about 10 minutes (about 3 with `SKIP_E2E=1`) |
+| `scripts/durability-hosted.sh` | nothing a chat completed is lost when its machine dies | about 20 minutes |
+| `scripts/hosted-validate.py` | the whole loop on two real machines: pair, work, move, take back, resume card, integrity | about 30 to 60 minutes a pass |
+| `scripts/continuity.py` | the chat on the other machine carries all the work, so the agent continues and does not redo it | about 20 minutes a run |
+| `scripts/fidelity.py` | a folder arrives byte for byte, awkward cases included | about 10 minutes |
+| `scripts/bench-move.sh` | what a cold take, warm take, take back and publish cost in time, requests and dollars | tens of minutes to hours by size |
+
+## 1. Fast local gates
+
+From the repository root, after `make build`:
+
+```sh
+make test-quick
+go test -count=1 ./internal/cellsync/ ./internal/handoff/ ./internal/pair/ \
+ ./internal/syncsetup/ ./internal/cellstore/ ./cmd/codeaf/
+(cd relay/hosted && npm ci && npm test)
+```
+
+Some tests need the real engine (`furrow`), which `make build` makes. Without it they skip
+with the words `no engine binary`; a skip is not a pass. Run them with the tag:
+
+```sh
+go test -count=1 -tags engine ./internal/cellsync/ ./internal/cellstore/ ./internal/syncsetup/
+```
+
+Pass: every package says `ok`, no `engine` test skips.
+
+## 2. Two homes on one machine, with the Go relay
+
+Two state roots on one machine are two computers as far as the product can tell.
+
+**Pairing and moving, driven by keystrokes.** `internal/e2e/pairjourney_e2e_test.go` starts
+the Go relay, two homes and the real binary in tmux. It needs `tmux` and `go`:
+
+```sh
+make build
+go test -tags e2e -count=1 -v -timeout 10m -run '^TestPairJourney$' ./internal/e2e/
+```
+
+Set `UX_SEGMENT=A` to stop after the approval and run only the fresh-install check. Per-step
+times and verdicts go to `$UX_EVIDENCE` (default `~/ux-evidence/e2e-run.json`).
+Pass: every step prints its screen and the test ends `ok`. The journey budget is two minutes.
+
+**Durability, against the Go relay or any other.** The same tests as section 3 run against
+`$CODEAF_RELAY`, the same variable every script reads, with no default; a run with it unset stops
+(the Go suite skips) and names it. Point it at your own relay to keep the load off a hosted one:
+
+```sh
+CODEAF_RELAY=https://relay.example.com scripts/durability-hosted.sh KillAfterLoneCall
+```
+
+The name after the script picks one test (`KillAfterLoneCall`, `KillAfterBurst`,
+`KillAfterTurnEnds`, `KillAfterLaterLoneCall`, `KillDuringCall`, `NetworkCutThenKill`,
+`LidClose`, `FirstCallKillRate`). Without a name it runs them all. Each prints one `DURABILITY`
+line, `PASS`, `FAIL` (work missing on the other machine) or `FAIL-RECORD` (the work arrived,
+its transcript line did not), or `UNREACHABLE` (work is missing, but the kill came sooner after the
+call than the relay takes to store a frame, which the run measures with three probe puts and prints
+as `relayAckFloor`; nothing could have been durable yet, so it does not fail the run). It uses a
+scripted model, so it makes no model calls.
+
+**Real engine round trips, in process.**
+`go test -tags engine ./internal/cellsync/ ./internal/cellstore/ ./internal/syncsetup/`
+(section 1) covers dedup, held moves and the setup round trip on the real engine.
+
+**The old single-box remote check.** `make test-remote` (tag `docker_e2e`) runs
+`TestRemoteTwoMachines`; it needs docker.
+
+## 3. Two real machines against the hosted relay
+
+You need a Linux and a Mac, the second reachable as `$CODEAF_SECOND_HOST`. Build `bin/codeaf` and the
+engine on both machines from the same commit (`make build`; on the Mac, `export
+PATH=/opt/homebrew/bin:$PATH` first). A copied binary is killed by macOS until it is signed:
+`codesign -s - -f `.
+
+Export the settings above first; each script's docstring says which of them it reads and the
+layout it expects under the roots. Run `--help` on any of them to see the options.
+
+The relay under test is whatever `CODEAF_RELAY` says; the rigs put it into each home as
+`CODEAF_SYNC_URL`, which is the variable the product itself reads.
+
+**The whole loop.**
+
+```sh
+python3 scripts/hosted-validate.py 1 # pass 1: pair, work, move, take back, integrity
+python3 scripts/hosted-validate.py 2 --card-test 6 # six cold takes of small chats, counting resume cards
+python3 scripts/hosted-validate.py 1 --resume # redo push and integrity on a pass's saved state
+```
+
+It writes raw files and `results.json` under `/pass/`; `--out` picks the folder.
+It makes real model calls and takes a lock on the Mac (`~/caf-bench.lock`) because it times
+things. Helpers are in `scripts/measure/hosted/`: `setup.sh` (a clean rig home on one
+machine), `tuidrive.py` (types into and reads the terminal), `cellread.py`, `treehash.py`,
+`durable.py`, `clockoff.py` (the clock difference between the machines) and `report.py` (a
+table of pass results).
+
+**Continuity of a move.** Does the other machine carry all of the work?
+
+```sh
+python3 scripts/continuity.py pair # once: pair the two homes
+python3 scripts/continuity.py cold # a fresh machine takes a finished session
+python3 scripts/continuity.py warm # a machine with an older copy takes again
+python3 scripts/measure/continuity/report.py
+```
+
+`pair` uses one pairing from the relay's budget (see the limits below). The checks live in
+`scripts/measure/continuity/check.py`; `rig.sh ` makes a clean home pinned to the one
+model.
+
+**Folder fidelity, with the awkward cases.**
+
+```sh
+python3 scripts/fidelity.py run # build the fixture, move it there, change it, move it back
+python3 scripts/fidelity.py probes # the single awkward cases, one at a time
+```
+
+The fixture (`scripts/measure/continuity/fidelity-fixture.sh`, about 350 MiB) holds a full
+`.git` with a stash and a submodule, empty directories, a path over 200 characters, names in
+different Unicode forms, names that differ only in case, big binaries, every kind of symlink,
+a hard-linked pair, odd file modes and secret files. `fidelity-manifest.py` reads the tree on
+each machine and `fidelity-compare.py` compares them. The move uses a private take program
+(`scripts/measure/hosted/vdemotake.go.txt`, built as `vdemo-take`), because the home screen
+has no door for a chat the machine already lists. Pass: every row PASS except the known case
+below.
+
+**What it costs.**
+
+```sh
+scripts/bench-move.sh --envs same,second --sizes S --reps 3
+```
+
+`--envs` picks `same` (two homes on this box, a Go relay in docker), `second` (the second
+machine, which reaches the relay through `ssh -R`) and `hosted` (both machines talk to
+`$CODEAF_RELAY`). It needs `CODEAF_SECOND_HOST` and `CODEAF_SECOND_ROOT` (for `second`
+and `hosted`) and `CODEAF_CORPUS`; `CODEAF_SRC` is the checkout to build (default this one).
+It builds, runs scripted edits (no model), and writes `rows.jsonl`,
+`summary.json` and `summary.md` under `.lane/bench-move/`. Size S is a few seconds; M and L
+take minutes to an hour a cell on a slow link. Run it on a quiet machine: it measures time.
+
+**Durability on the real machines.**
+
+```sh
+scripts/durability-hosted.sh # all scenarios, about 20 minutes
+```
+
+## 4. Conformance for both relays
+
+```sh
+scripts/relay-conformance.sh # the whole matrix and one table
+SKIP_E2E=1 scripts/relay-conformance.sh # without the hosted Worker's end-to-end script
+```
+
+It starts the Go relay and the hosted Worker (under `wrangler dev --local`) on free ports,
+never against a hosted deployment, and exits 0 only when no suite failed or ran zero cases.
+To hold one live relay to the suites by hand:
+
+```sh
+go test -count=1 -tags relayurl ./internal/relayconf/ -relay-url=$CODEAF_RELAY
+```
+
+The suites are store, directory, isolation, pairing, rotation, big take, watch and lease. Run
+the whole suite only against a relay with a short pairing time and a lifted per-address
+identity cap (a local one, or staging); production lacks both on purpose. The Worker's own
+end-to-end scripts: `(cd relay/hosted && npm run e2e)`.
+
+## 5. The manual walk
+
+Use two terminals, one per machine, both with `CODEAF_SYNC_URL=$CODEAF_RELAY`. A is the machine that
+already has your work, B is the new one. This is what each step should look like.
+
+1. **Home on A.** Run `codeaf`. If this machine never synced, the home screen shows a card,
+ "Add another device", with the pitch "pick up your work anywhere".
+2. **Pair B.** On B run `codeaf pair`. B prints a link ending `codeaf.agentfield.ai/p/`
+ (staging prints its own host), a "Check number", and "Waiting for approval".
+3. **Approve on A.** Open the card, choose to add a machine, and paste the link. A shows
+ "wants to pair" with the device's name, its platform, how long ago it asked and
+ the same Check number B shows. If the numbers differ, deny. Press `a` to approve.
+4. **Both sides confirm.** B prints "Paired - N workspaces available". A shows a news line,
+ "is now paired".
+5. **Work on B.** On B open a chat in a folder, ask for a small change, and let it finish.
+6. **See B from A.** A's home shows a devices row with both machines online (filled dots).
+ `/devices` lists both.
+7. **The continue card.** Close the chat on B (or leave B idle), reopen A. A offers "Continue
+ where you left off on ?". Enter chooses "continue here".
+8. **Move.** A asks "Continue this chat here?". Confirm. A prints a banner "Moved from
+ in N s" and opens the chat with a resume card: the files not yet committed, the last test
+ run and what B saw. Your folder is the same, file for file.
+9. **Take it back.** Do step 5 to 8 the other way: work on A, then on B continue the same chat
+ from B's home. It arrives in B's folder with A's work in it. (The devices screen says
+ "undo", not "take back".)
+10. **Offline row.** Close the app and sleep or disconnect B. Within a minute A shows B as
+ `○ (offline)`, its chats no longer say "running", and the continue card says the
+ device is off. Reconnect B: the dot fills and the card goes away.
+11. **Revoke.** On A open `/devices`, move down to B's row (not "this device") and press `r`.
+ A says B "was revoked". On B, opening a chat says it was revoked or turned away, and B's
+ own device list says so. B no longer reads A's chats.
+
+## 6. What a pass looks like, and the limits
+
+A pass: every command above ends with exit 0 and its own PASS line; no `FAIL`,
+`FAIL-RECORD`, skip or zero-case suite; the two folders compare equal (the fidelity rows, and
+`diff -r` apart from `.git`, `.furrow` and `node_modules`); and in the manual walk every step
+shows what is written above.
+
+Known limits, from the measured runs (the benchmark notes of the program have the tables):
+
+- **Time follows the size of the repo, not of the change.** At the measured build, a warm take
+ after a one-file change fetched about 15 requests and 0.1 MB but took over a minute on a
+ 200 MB repo, because the receiving machine rewrote the whole tree. A later build changed the
+ sealing and upload path; the numbers were not repeated on it. Treat a slow warm take on a
+ big repo as known, not as a regression, until you compare with a fresh bench run.
+- **Cold takes fetch one object per request.** Over a hosted relay a 17,000-object repo took
+ about 20 minutes cold at 70 to 90 ms a request. Small repos take seconds.
+- **The benchmarks are not a real WAN for all rows.** The same-box row has no network, and the
+ tunnelled row adds the latency of ssh. Runs were repeated three times at most; on a shared
+ machine the spread is large (a warm take of 54 to 88 s on one box).
+- **Dollar figures are a price table, not a bill.** They count the client's requests against
+ list prices with every free tier used.
+- **A moved chat's folder differs from the original in a few known ways.** Names that differ
+ only in case cannot both exist on a case-insensitive Mac, so that take is refused. `.git/index` and `.git/logs` are compared by kind and size only, because
+ git rewrites them. A `node_modules` folder is withheld and rebuilt with `npm ci`.
+- **The pairing rate limit.** The hosted relay allows about 12 pairings per 40 minutes from one
+ network (staging says `too many pairings from this network; wait N min`). Pair once and reuse
+ the homes; do not run `continuity.py pair` in a loop.
+- **The conformance suite and load runs against staging** cost real requests; run them with
+ the owner's agreement, not by habit.
+- **Only the Mac and Linux pairs were tested.** The fidelity runs were Linux to a Mac on APFS.
diff --git a/docs/ux-pairing-contract.md b/docs/ux-pairing-contract.md
new file mode 100644
index 0000000000..790b4a443e
--- /dev/null
+++ b/docs/ux-pairing-contract.md
@@ -0,0 +1,300 @@
+# UX pairing contract (link pairing, device records, presence)
+
+Status: fixed. Every UX lane builds against this file. All changes are additive. A client that knows
+nothing of this file keeps working: the six-digit code path (`/v1/pair`, internal/pairbox,
+internal/pair), `{"v":N}` watch frames, `POST /v1/dir/devices/{id}/revoke` and the existing
+`directory.Device` JSON all stay as they are.
+
+Words in UI strings follow the vocabulary law: "device", "Move here", "Continue here". The words in
+this file (relay, node, lease, manifest) never reach a screen.
+
+Conventions: JSON bodies; errors are `{"err":""}` with the status given (the relay's existing
+shape, `wire.js`). Times are Unix milliseconds. `b64u` is unpadded base64url. Every answer carries
+`Codeaf-Now` as today. Signed routes use the existing identity/device cert headers and are called
+"signed"; routes marked "open" take no identity (the new device has none yet).
+
+## 1. Pending request record
+
+A new device asks to join by creating a request. The relay keeps it for 10 minutes in one new Durable
+Object, `LinkGate` (global decisions and per-IP counters, like `PairGate`), plus one `LinkRequest`
+object per code (like `Mailbox`).
+
+```json
+{
+ "code": "k7m2q9xd",
+ "device": "dev_3Fq9...",
+ "pubkey": "b64u(32 bytes ed25519 device public key)",
+ "x25519": "b64u(32 bytes, key the grant is sealed to)",
+ "name_sealed": "b64u(secretbox of the UTF-8 device name under the link key)",
+ "platform": "linux",
+ "check": "4821",
+ "requested_at": 1790000000000,
+ "expires_at": 1790000600000,
+ "state": "pending",
+ "grant": null
+}
+```
+
+- `code`: 8 characters of Crockford base32 (no I, L, O, U; lowercase accepted, upper-cased on read),
+ 40 random bits from `crypto.getRandomValues`. It is a lookup key, not a secret that protects keys.
+- `device`: `dev_` id derived from `pubkey` exactly as today; the relay recomputes it and refuses a
+ mismatch (`bad_request`).
+- `platform`: one of `darwin`, `linux`, `windows`, `ios`, `android`, `other`. Unknown values are stored
+ as `other`. Plain text; it is the platform icon on the approve screen.
+- `name_sealed`: the name is hidden from the relay. It is sealed (NaCl secretbox, 24-byte nonce
+ prepended) under the 16-byte link key `k`, which lives only in the URL fragment (section 2) and is
+ never sent to any server. At most 96 bytes of name before sealing.
+- `check`: four decimal digits, `uint16_be(sha256(pubkey)[0:2]) mod 10000`, zero padded. The new
+ device shows it; the approver compares it. It makes a substituted public key visible.
+- `expires_at = requested_at + 600000` (10 min, `limits.pairTtlMs`). After expiry every read is `404 gone`.
+- `state`: `pending` | `approved` | `denied`. Moves once, only from `pending` (single use). A request is
+ never revived; a new attempt is a new code.
+- `grant`: `null` until approved. After `approved` it is `b64u(sealed box)` sealed to `x25519`; it holds
+ what the new device needs (cert, identity material, vault key). The relay stores it as opaque bytes
+ (4 KiB cap, `too_big`) and never reads it. A `denied` request has `grant: null`.
+- After a decision the record lives 2 more minutes (so the new device can read it), then it is deleted.
+
+Wire form for the `device` object written into the directory on approval is section 4.
+
+## 2. Short link and mapping to the existing code
+
+```
+https://codeaf.agentfield.ai/p/#
+```
+
+`` is `b64u(16 bytes)`, the link key of section 1. `codeaf.agentfield.ai/p/*` is a redirect/handoff page: it
+sends the visitor to the installed app (`codeaf://pair?code=#`), or shows install and CLI steps.
+The fragment never leaves the browser.
+
+Printed or typed form, for headless use: `.` (for example `k7m2q9xd.Qm9v...`). The CLI prints
+both the link and this token. Paste into any approve box is accepted in either shape.
+
+Existing path, unchanged: a six-digit code with a nameplate (`42-715-302`) goes through `/v1/pair` and
+the CPace exchange in internal/pair. The two paths are siblings, chosen by the shape of what is
+entered:
+
+| Entered text | Path |
+|---|---|
+| `^\d{1,4}-\d{3}-\d{3}$` or `\d{3} \d{3}` | existing pairbox + CPace, no change |
+| `^[0-9a-hjkmnp-tv-z]{8}([.#].+)?$` (case-insensitive) or a `codeaf.agentfield.ai/p/` URL (the old `codeaf.link` host is not a codeaf link and is answered as such) | link pairing, this file |
+
+There is no secret in the short code, so link pairing has two guards instead of the PAKE: a human
+approves on an already-paired device, and the approver sees `check` and the name. The grant is sealed
+to the new device key, so the relay and anyone who reads the request learn nothing usable.
+
+## 3. Routes
+
+All paths are under the hosted relay origin. No route removes or changes an existing one.
+
+| # | Method and path | Auth | Purpose |
+|---|---|---|---|
+| 1 | `POST /v1/link/requests` | open | create a request |
+| 2 | `GET /v1/link/requests/{code}` | open | read one (approver and new device) |
+| 3 | `POST /v1/dir/requests/{code}/approve` | signed | approve |
+| 4 | `POST /v1/dir/requests/{code}/deny` | signed | deny |
+| 5 | `GET /v1/dir/presence` | signed | who is online now |
+| 6 | `GET /v1/link/limits` | open | numbers below, for clients |
+
+Revoke is unchanged: `POST /v1/dir/devices/{id}/revoke` (204; revoked keys are refused as today).
+
+### 3.1 Create: `POST /v1/link/requests` (open)
+
+Request:
+
+```json
+{ "pubkey": "b64u", "x25519": "b64u", "name_sealed": "b64u", "platform": "linux" }
+```
+
+Answer `201`:
+
+```json
+{ "code": "k7m2q9xd", "device": "dev_3Fq9...", "check": "4821",
+ "requested_at": 1790000000000, "expires_at": 1790000600000 }
+```
+
+The client builds the link from `code` and its own `k`. Errors:
+
+| Status | err | When |
+|---|---|---|
+| 400 | `bad_request` | missing field, wrong key length, `device` mismatch, name over 96 bytes |
+| 413 | `too_big` | body over 1 KiB |
+| 429 | `rate_limited` | over 10 creates per hour per IP, or 3 pending requests at once per IP (`Retry-After` set) |
+| 503 | `full` | 2000 live requests on the relay |
+
+### 3.2 Read: `GET /v1/link/requests/{code}` (open)
+
+Answer `200` is the record of section 1 with `state`, `grant`, `check`, `name_sealed`, `platform`,
+`requested_at`, `expires_at`, `device`, `pubkey`, `x25519`. Query `?wait=` (max 25000) long-polls
+until `state` leaves `pending`, answering `204` if the wait ends first (same behaviour as the mailbox
+read). The new device uses it to wait for approval; the approver uses it once, without `wait`.
+
+| Status | err | When |
+|---|---|---|
+| 404 | `gone` | unknown, expired or deleted code (these are one answer on purpose) |
+| 429 | `rate_limited` | over 60 reads per minute per IP, or over 20 misses per minute per IP, or 4 open polls per IP |
+
+### 3.3 Approve: `POST /v1/dir/requests/{code}/approve` (signed)
+
+Called by an already-paired device of the identity that is approving.
+
+```json
+{
+ "device": {
+ "V": 1, "name": "b64 sealed under the metadata key", "added_by": "id_...",
+ "revoked": false, "caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock",
+ "container": null, "gpu": null, "cow": "btrfs" },
+ "platform": "linux", "created": 1790000123456, "last_seen": 0
+ },
+ "cert": "b64u device cert for `dev_...` signed by the identity key (existing cert format)",
+ "grant": "b64u(sealed box to x25519)"
+}
+```
+
+Effect, in this order and in one turn of the identity object: verify `cert` is for the request's
+`device`; write the Device record (as `PUT /v1/dir/devices/{id}` would, `created` set by the relay to
+directory time); bump the directory version (watchers hear `{"v":N}`); emit `joined` (section 5); set
+the request `approved` with `grant`. A retry of the same approve with the same `device` is `204`
+(idempotent). Answer `204`.
+
+| Status | err | When |
+|---|---|---|
+| 400 | `bad_request` | `device`/`cert` do not match the request |
+| 401 | `unauthorized` | not signed, or signer revoked (existing causes) |
+| 404 | `gone` | no such live request |
+| 409 | `already_decided` | request is `approved` for another body, or `denied` |
+| 410 | `rotated` | identity replaced (existing) |
+| 413 | `too_big` | `grant` over 4 KiB |
+| 429 | `rate_limited` | existing per-device limits |
+
+### 3.4 Deny: `POST /v1/dir/requests/{code}/deny` (signed)
+
+Empty body. Sets `denied`; the waiting device reads `state:"denied"` and shows "Request declined".
+Answer `204`. Errors: `401 unauthorized`, `404 gone`, `409 already_decided` (a repeat deny is `204`).
+Any paired device may approve or deny; the first decision wins.
+
+### 3.5 Presence list: `GET /v1/dir/presence` (signed)
+
+```json
+{ "now": 1790000001000,
+ "devices": {
+ "dev_A": { "online": true, "last_seen": 1790000001000 },
+ "dev_B": { "online": false, "last_seen": 1789999000000 } } }
+```
+
+One entry per non-revoked device in the directory. Cost: reads socket state only, no storage write.
+Answers with `Codeaf-Dir-Version` like `/v1/dir/list`. Used at app open and as the fallback for a client
+whose socket is down.
+
+### 3.6 Limits: `GET /v1/link/limits` (open)
+
+```json
+{ "ttl_ms": 600000, "create_per_hour": 10, "pending_per_ip": 3, "read_per_minute": 60,
+ "miss_per_minute": 20, "concurrent_polls": 4, "max_grant": 4096, "max_live": 2000 }
+```
+
+New keys in `limits.js` `DEFAULTS` (override via `CAF_LIMITS` as today): `linkCreatePerHour` 10,
+`linkPendingPerIp` 3, `linkReadPerMinute` 60, `linkMissPerMinute` 20, `linkConcurrentPolls` 4,
+`linkMaxGrant` 4096, `linkMaxLive` 2000. `pairTtlMs` is reused for the 10 minutes.
+
+## 4. Device record (additive fields)
+
+`directory.Device` gains three fields. Go (`internal/directory/records.go`):
+
+```go
+Platform string `json:"platform,omitempty"` // darwin|linux|windows|ios|android|other
+Created int64 `json:"created,omitempty"` // directory ms when the device joined; set by the relay
+LastSeen int64 `json:"last_seen,omitempty"` // directory ms of the last watch socket close or hello
+```
+
+Full JSON:
+
+```json
+{ "V": 1, "name": "b64", "added_by": "id_...", "revoked": false,
+ "caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock", "container": null, "gpu": null, "cow": "btrfs" },
+ "platform": "linux", "created": 1790000123456, "last_seen": 1790000999000 }
+```
+
+Rules:
+- `V` stays 1. Old readers ignore unknown fields; old records decode with zero values, shown as
+ "unknown" platform and no time. A device written without the fields is valid.
+- `created` is set by the relay on first write of a device id and never changes afterward; a client
+ value is ignored.
+- `last_seen` is set only by the relay: at watch connect (hello) and at watch close. It is not written
+ per ping, so an open quiet socket costs no storage write. A client value is ignored.
+- `platform` is client-supplied, same value set as section 1, written by self-registration or approve.
+- Rename and `caps` changes keep using `PUT /v1/dir/devices/{id}` (self only). `Revoked` is still set
+ only by revoke.
+- Writing `last_seen` bumps no directory version and sends no `{"v":N}` frame.
+
+## 5. Watch frames
+
+Today a watch socket gets only `{"v":N}` (version). That does not change for a socket opened as today.
+
+A client opts in to events with a query flag: `GET /v1/dir/watch?events=1` (plus existing `hold`
+parameters). A socket without the flag never receives an event frame, so old clients cannot be
+confused. With the flag the socket receives `{"v":N}` frames exactly as before, and in addition event
+frames. An event frame has a string key `t` and no key `v`:
+
+```json
+{"t":"joined","device":"dev_B","name":"b64 sealed","platform":"linux","at":1790000123456}
+{"t":"presence","device":"dev_B","online":true,"at":1790000123999}
+{"t":"presence","device":"dev_B","online":false,"at":1790000555000}
+{"t":"revoked","device":"dev_B","at":1790000700000}
+```
+
+- `joined`: sent to every open event socket of the identity (the new device itself excluded) in the same
+ turn as the approve. The directory version also moves, so a client that only lists still sees it.
+ The UI string is " is now paired", decoded from `name` with the metadata key.
+- `presence`: sent to the other event sockets when the first socket of a device opens (`online:true`)
+ and when its last socket closes (`online:false`). A device is online iff it holds at least one open
+ watch socket. A flapping device is debounced: an `online:false` is sent 15 s after the last socket
+ closes unless a socket opens first, and no `online:true` is sent for a reconnect inside that gap.
+ The close 15 s timer is a Durable Object alarm, set only when the last socket closes.
+- On connect an event socket first gets `{"v":N}` then one `presence` frame per other online device, so
+ it needs no `/v1/dir/presence` call at start. `presence` frames for offline devices are not sent on
+ connect; use the list for `last_seen`.
+- `revoked`: informational; the close `4401` for the revoked device itself is unchanged.
+- Event frames are at most 512 bytes and are never queued for a socket that is gone. Clients must
+ ignore frames with an unknown `t`.
+
+## 6. Security properties (must hold in every implementation)
+
+1. The relay never holds an unsealed device name, a grant's contents, or the link key.
+2. Approval needs a signed call by a non-revoked device of the identity. Open routes can only create,
+ read and wait.
+3. A request decides once. Two approvers racing: one gets `204`, the other `409 already_decided`.
+4. Code guessing is bounded: 40 bits, 10 min, 20 misses per minute per IP, and a guessed code reveals a
+ sealed name, a platform and a public key only.
+5. Revoked device ids are refused at the relay as today; a revoked device re-joins only as a new
+ request and a new device id.
+
+## 7. Errors added
+
+`already_decided` (409). Reused with the existing meaning: `bad_request` 400, `unauthorized` 401,
+`gone` 404, `too_big` 413 (here, `too_large` for directory bodies stays), `rate_limited` 429, `full` 503,
+`rotated` 410.
+
+## 7a. Nameplates and generations (`/v1/pair`)
+
+A nameplate is a short number, so it is drawn again some day. Two rules keep a device that still holds an
+old code from being answered by a newer mailbox that landed on the same nameplate.
+
+1. **Quarantine.** A relay does not draw a nameplate again until `plateQuarantineMs` after its mailbox ended
+ (released or expired). The hosted relay's default is one pairing life (`pairTtlMs`, 10 minutes): a device
+ may come back to a code until the code's life ends, and a poll that starts during the quarantine finds the
+ old mailbox gone. Quarantined plates count as occupied when the nameplate length (2 to 4 digits) is chosen.
+2. **Generation.** Each opening of a mailbox has a random generation, 32 hex characters, sent in the header
+ `Codeaf-Pair-Gen` on every answer about that mailbox (create, post, poll). A device sends back the one it
+ was told, on post, poll and delete. A mailbox that is a different opening answers `404 gone`, the same
+ answer as for a mailbox that is not there.
+
+The header is optional both ways. A request with none is not fenced, and a mailbox opened before generations
+existed, or a relay that names none (the self-hosted one), answers without it; a device then sends none. No
+body, status or word changes, so old and new builds keep working together. A joining device learns the
+generation from its first answer, so it is fenced from its second call on.
+
+## 8. Test vectors
+
+`docs/testdata/ux-pairing-vectors.json` holds the record shapes above as fixtures for the lanes
+(request, approve body, presence answer, the four event frames, an old-style `{"v":N}` frame, and a
+legacy Device record without the new fields).
diff --git a/engine/.github/workflows/release.yml b/engine/.github/workflows/release.yml
new file mode 100644
index 0000000000..38b269d6f2
--- /dev/null
+++ b/engine/.github/workflows/release.yml
@@ -0,0 +1,111 @@
+name: release
+
+# Publishes per-platform `furrow` binaries so callers can install it without a
+# Rust toolchain. Windows is absent on purpose: 13 source files use
+# `std::os::unix` with no `cfg(windows)` guards, so a native Windows build needs
+# a source port, not a build target. Windows users reach furrow through WSL,
+# which uses the linux asset.
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Existing tag to build and publish (e.g. v0.1.0)"
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ build:
+ name: ${{ matrix.asset }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - asset: furrow-linux-amd64
+ target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ native: true
+ # arm64 Linux is not a niche here: DGX Spark and Jetson, Graviton,
+ # Ampere, and Raspberry Pi all land on this target, and every one of
+ # them has to install a Rust toolchain and build from source today.
+ # Native on a hosted arm64 runner, so it gets the same smoke test as
+ # the other native targets instead of shipping unverified.
+ - asset: furrow-linux-arm64
+ target: aarch64-unknown-linux-gnu
+ os: ubuntu-24.04-arm
+ native: true
+ - asset: furrow-darwin-arm64
+ target: aarch64-apple-darwin
+ os: macos-14
+ native: true
+ # Cross-compiled on the arm64 runner; Apple's toolchain ships both
+ # slices, so this needs no extra SDK. It cannot be smoke-tested here.
+ - asset: furrow-darwin-amd64
+ target: x86_64-apple-darwin
+ os: macos-14
+ native: false
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.tag || github.ref }}
+
+ - name: Install toolchain
+ run: |
+ rustup toolchain install stable --profile minimal --no-self-update
+ rustup target add ${{ matrix.target }}
+
+ - name: Build
+ run: cargo +stable build --release --locked --target ${{ matrix.target }}
+
+ - name: Smoke test
+ if: matrix.native
+ run: ./target/${{ matrix.target }}/release/furrow --version
+
+ - name: Stage asset
+ run: |
+ mkdir -p dist
+ install -m 755 "target/${{ matrix.target }}/release/furrow" "dist/${{ matrix.asset }}"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.asset }}
+ path: dist/${{ matrix.asset }}
+ if-no-files-found: error
+
+ publish:
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+
+ - name: Checksums
+ working-directory: dist
+ run: sha256sum furrow-* > SHA256SUMS
+
+ - name: Publish release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ inputs.tag || github.ref_name }}
+ run: |
+ if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
+ else
+ gh release create "$TAG" dist/* \
+ --repo "$GITHUB_REPOSITORY" \
+ --title "furrow $TAG" \
+ --generate-notes \
+ --verify-tag
+ fi
diff --git a/engine/.gitignore b/engine/.gitignore
new file mode 100644
index 0000000000..28be0d521d
--- /dev/null
+++ b/engine/.gitignore
@@ -0,0 +1,4 @@
+/target/
+/.DS_Store
+*.tmp
+
diff --git a/engine/Cargo.lock b/engine/Cargo.lock
new file mode 100644
index 0000000000..f339c8c472
--- /dev/null
+++ b/engine/Cargo.lock
@@ -0,0 +1,1601 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
+[[package]]
+name = "assert_cmd"
+version = "2.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "doc-comment",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "blake3"
+version = "1.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.2.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "typenum",
+]
+
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "doc-comment"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "filetime"
+version = "0.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "furrow-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "assert_cmd",
+ "blake3",
+ "clap",
+ "filetime",
+ "furrow-core",
+ "furrow-daemon",
+ "hex",
+ "libc",
+ "predicates",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "xattr",
+]
+
+[[package]]
+name = "furrow-core"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "blake3",
+ "chacha20poly1305",
+ "clap",
+ "directories",
+ "filetime",
+ "fs2",
+ "getrandom",
+ "hex",
+ "libc",
+ "notify",
+ "rusqlite",
+ "rusty-s3",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "tiny_http",
+ "unicode-normalization",
+ "ureq",
+ "url",
+ "xattr",
+ "zeroize",
+]
+
+[[package]]
+name = "furrow-daemon"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "blake3",
+ "fs2",
+ "furrow-core",
+ "hex",
+ "libc",
+ "serde",
+ "serde_json",
+ "tempfile",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "inotify"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.169"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
+
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "plain",
+ "redox_syscall",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
+[[package]]
+name = "notify"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
+dependencies = [
+ "bitflags 2.13.0",
+ "filetime",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "notify-types"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
+dependencies = [
+ "instant",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "predicates"
+version = "3.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.13.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rusty-s3"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac2edd2f0b56bd79a7343f49afc01c2d41010df480538a510e0abc56044f66c"
+dependencies = [
+ "hmac",
+ "jiff",
+ "percent-encoding",
+ "sha2",
+ "url",
+ "zeroize",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.217"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.217"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.138"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
+dependencies = [
+ "base64",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf-8",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "xattr"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
+dependencies = [
+ "libc",
+ "linux-raw-sys",
+ "rustix",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
diff --git a/engine/Cargo.toml b/engine/Cargo.toml
new file mode 100644
index 0000000000..129358bc22
--- /dev/null
+++ b/engine/Cargo.toml
@@ -0,0 +1,45 @@
+[workspace]
+resolver = "2"
+members = ["furrow-core", "furrow-cli", "furrow-daemon"]
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.83"
+license = "Apache-2.0"
+repository = "https://github.com/Agent-Field/furrow"
+
+[workspace.dependencies]
+anyhow = "=1.0.95"
+blake3 = "=1.5.5"
+chacha20poly1305 = "=0.10.1"
+clap = { version = "=4.5.31", features = ["derive", "env"] }
+directories = "=5.0.1"
+fs2 = "=0.4.3"
+filetime = "=0.2.25"
+hex = "=0.4.3"
+getrandom = "=0.2.17"
+libc = "=0.2.169"
+notify = "=7.0.0"
+rusqlite = { version = "=0.32.1", features = ["backup", "bundled"] }
+rusty-s3 = { version = "=0.8.1", default-features = false }
+serde = { version = "=1.0.217", features = ["derive"] }
+serde_bytes = "=0.11.15"
+serde_json = "=1.0.138"
+sha2 = "=0.10.9"
+tempfile = "=3.15.0"
+tiny_http = "=0.12.0"
+ureq = { version = "=3.1.4", default-features = false, features = ["rustls"] }
+unicode-normalization = "=0.1.25"
+url = "=2.5.2"
+xattr = "=1.3.1"
+zeroize = "=1.8.1"
+assert_cmd = "=2.0.16"
+predicates = "=3.1.3"
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+opt-level = 3
+panic = "abort"
+strip = true
diff --git a/engine/LICENSE b/engine/LICENSE
new file mode 100644
index 0000000000..b38789657b
--- /dev/null
+++ b/engine/LICENSE
@@ -0,0 +1,18 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ Copyright 2026 furrow contributors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/engine/README.md b/engine/README.md
new file mode 100644
index 0000000000..c808ad7f0e
--- /dev/null
+++ b/engine/README.md
@@ -0,0 +1,169 @@
+
+
+# furrow
+
+### Every agent, its own fork, any machine
+
+[](LICENSE)
+[](https://github.com/Agent-Field)
+
+
+ Parallel Agents •
+ Distributed Agents •
+ Rewind •
+ Daily Kit •
+ Architecture
+
+
+
+
+
+
+**furrow copy-on-write forks your entire workspace, every file, dependency, and piece of local state, into a byte-exact copy in about a second. Fork it right here on your machine for another terminal, or send it to a cloud sandbox or your other laptop with the same guarantee.**
+
+One fork, one primitive, wherever an agent runs: instant and disk-cheap right next to you, or sealed and encrypted so it can reach a completely different machine. You stop being the one who copies config and reseeds state by hand before an agent can even start.
+
+```bash
+cd my-project && furrow watch
+```
+
+That's the whole setup. From this moment your workspace is continuously sealed into an immutable, content-addressed timeline: fork it, diff it, merge it, materialize it on another machine, rewind it when anything — human or agent — breaks it. Git versions your commits; furrow versions everything between them.
+
+Local-first and Apache-2.0. No account, no telemetry, works offline. Remotes, when you add them, receive only ciphertext.
+
+Every scenario below is a runnable script in [`./demo/`](demo/).
+
+---
+
+## Parallel agents, one machine
+
+Start your agents the way you always start them — just more of them. One optional alias gives every session its own universe automatically:
+
+```bash
+alias claude='furrow exec -- claude' # once, in your shell profile
+```
+
+Now open three terminals and run `claude` in each. Each session gets a warm, isolated copy of the *full* working state — dependencies, `.env`, database, everything — via filesystem copy-on-write: ready in about a second, and ten universes cost roughly the disk of one. No cold `npm install`, no port collisions, no shared files to fight over.
+
+While they work, **conflict radar** watches all universes and flags two agents touching the same file *before* merge time. Agents can partition work and leave notes for each other through plain files — no orchestrator. When a universe is done:
+
+```bash
+furrow merge --check "cargo test --all" # lands only if your checks pass
+```
+
+Skip the alias and sessions simply share the directory like today — still sealed continuously, still collision-warned. `furrow forks` shows every universe, its real disk cost, and live conflicts whenever you're curious.
+
+## Parallel agents, another machine
+
+Same fork, different machine. Cloud sandboxes and second machines normally see your last push — never your dirty edits, `.env`, dev database, or Git index. furrow materializes your *current state*, exactly, on any machine that can reach yours (SSH over LAN or Tailscale), or through any S3-compatible bucket as an always-available encrypted mailbox. No hosted service required.
+
+```bash
+furrow remote add ssh://dev@machine-a.tailnet --name my-project && furrow sync --follow
+
+# on the other machine — the clone is your complete working state, not a checkout
+FURROW_RECOVERY_KEY= furrow clone ssh://dev@machine-a.tailnet/my-project
+```
+
+`sync --follow` keeps a warm session both ways: seal on one machine, and it's on the other before you've switched chairs. Transfers are deduplicated deltas — a day of work syncs as kilobytes. Remotes hold only ciphertext; the recovery key (entered once per machine) is the only thing that can read a workspace.
+
+> A laptop died mid-session. The exact working directory — dirty edits, `.env`, dev database — came back on a different machine from an S3 bucket that never saw plaintext.
+
+**Measured, end-to-end:** Agent A edits source and tests on one machine, furrow
+encrypts and publishes the sealed state, and Agent B receives a usable workspace
+on a separate machine, reviews it, and sends a follow-up change back.
+
+| Two-agent workflow | End-to-end rate |
+|---|---:|
+| Optimized warm handoff gate between two running workspaces | **4.1 handoffs/s** |
+| Actual furrow repo, cold publish over the internet | **0.27 MB/s** |
+| Actual furrow repo, cold materialization on machine B | **0.40 MB/s** |
+| Raw `scp` of the same uncompressed repo payload | **0.71 MB/s** |
+
+The real-repo run covered 67 files / 1.14 MB plus two source-and-test agent
+turns. Rates include encryption and transport; handoff and materialization also
+include making the destination usable. Idle RSS was 16.6 MB on machine A and
+15.7 MB on machine B for its follower plus A's remote helper. The encrypted
+remote occupied 12.9 MB after the cold state and retained agent turns. These are
+observed integration results, not guarantees.
+
+## Rewind what Git can't see
+
+An agent just ran `git clean -fdx`, trashed `.env`, and corrupted the dev database. Git protects none of that; furrow's timeline holds all of it — and every rewind seals the current state first, so rewinding is itself rewindable.
+
+```bash
+furrow timeline # scrub your history — turns, labels, quiet points
+furrow rewind --paths .env # one secret back, newer work untouched
+furrow rewind # or the whole workspace, byte-exact
+```
+
+Restoration covers symlinks, permissions, extended attributes, SQLite (with a logically consistent image available), and Git's own mutable state. `--dry-run` previews any rewind's impact first.
+
+> `.env` and the dev SQLite database, both back, both intact, after an agent ran `git clean -fdx`.
+
+## The rest of the daily kit
+
+- **`furrow try -- npm install framework@latest`** — run anything risky with automatic before/after restore points. Keep the result or be back in seconds.
+- **`furrow bisect -- cargo test`** — "it worked twenty minutes ago" usually has no commit to bisect. This bisects *states*, in disposable CoW forks, and finds the breaking moment even when the culprit was an `.env` edit or a dependency change Git never saw.
+- **`furrow shrink`** — reclaims recognized dependency/build caches with honest numbers; everything removed stays exactly restorable.
+- **`furrow ui`** — Mission Control: timeline scrubbing, universes, live radar, diffs, and merge preview in a local page embedded in the binary. No Node, no CDN, no account.
+
+## Built for agents as first-class users
+
+The CLI is the agent API: `--json` everywhere, stable IDs from every mutation, structured errors, and destructive operations gated on explicit IDs plus `--yes` — a prompt in machine mode is an error, not a hang. `furrow hook install` turns agent turns into attributed restore points; `furrow events --follow` streams seals, conflicts, and merges as resumable NDJSON; `furrow mcp` serves harnesses that prefer MCP. Nothing in furrow knows which agent is calling — Claude Code, Codex, Cursor, or a shell script get the same contract.
+
+
+Full command reference — every command, one line each
+
+| Command | What it does |
+|---|---|
+| `furrow watch` | Attach a repo and start continuous sealing |
+| `furrow exec -- ` | Run a command inside its own isolated universe |
+| `furrow merge --check ""` | Land a universe's changes, only if checks pass |
+| `furrow forks` | List universes, disk cost, live conflicts |
+| `furrow timeline` | Scrub sealed history |
+| `furrow rewind [--paths] [--dry-run]` | Restore workspace state, whole or partial |
+| `furrow try -- ` | Run something risky with automatic before/after restore points |
+| `furrow bisect -- ` | Bisect workspace state, not just commits |
+| `furrow shrink` | Reclaim recognized dependency/build caches |
+| `furrow ui` | Mission Control — local web UI, no account |
+| `furrow remote add ` | Add a remote (SSH or S3-compatible) |
+| `furrow sync --follow` | Keep a warm two-way sync session with a remote |
+| `furrow clone ` | Materialize a workspace's exact state on another machine |
+| `furrow hook install` | Turn agent turns into attributed restore points |
+| `furrow events --follow` | Stream seals/conflicts/merges as NDJSON |
+| `furrow mcp` | Serve furrow over MCP for harnesses that prefer it |
+
+
+
+## How it's built
+
+The load-bearing decisions, for readers who want them:
+
+- **Immutable Merkle DAG over content-defined chunks.** A snapshot ID exists only after every referenced byte is durable. Sealing cost scales with changed entries, never repository size; memory stays bounded regardless of file size.
+- **Crash-safe by construction.** Hash-verified append-only packs, an fsynced hash-chained publication log, recovery from truncated pack tails and deleted indexes — SQLite is an advisory index, never the truth.
+- **Warm forks via native CoW** — APFS `clonefile`, Linux `FICLONE` — with a streaming-copy fallback whose cost is disclosed before the fork runs.
+- **Retention with honest grades.** Timelines thin under hard disk budgets; every snapshot knows whether it's byte-exact or partial, and every missing path knows its recovery route. Pins override everything. `furrow estimate` projects store cost before you ever attach.
+- **Encryption below the transport.** XChaCha20-Poly1305, opaque remote names, every chunk verified against its BLAKE3 identity on restore; path traversal and symlink-escape tricks rejected at the boundary.
+- **The store never lives inside the repository.** `.furrow/` holds a pointer and policy; deleting it is recoverable. Exclusions (`.furrowpolicy`: `exclude node_modules`) are honored by capture, watcher, and rewind alike.
+
+Reproducible performance claims — `cargo bench --bench engine` (regression ceilings checked on macOS and Linux; a 1M-file reference profile available). Methodology and baselines: [docs/performance.md](docs/performance.md).
+
+## Honest edges
+
+- Cross-machine divergence is preserved and reported, not yet auto-merged — smoothest today: one writer at a time, hand off after convergence.
+- Same-path universes are Linux; macOS uses disclosed sibling directories.
+- Cold internet sync has been rerun; the final receiver optimization still needs a warm internet rerun.
+- Fidelity is declared, not implied: `furrow status --fidelity` is the contract.
+
+## Development
+
+```bash
+cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings
+cargo test --all
+```
+
+The black-box suite runs against independent temporary Git repositories and isolated stores: secret recovery, reversible rewind, metadata fidelity, SQLite logical recovery, index reconstruction, truncated-pack recovery, path-escape rejection, interrupted-rewind recovery, and fork isolation.
+
+## License
+
+Apache-2.0
diff --git a/engine/assets/banner.png b/engine/assets/banner.png
new file mode 100644
index 0000000000..a191188145
Binary files /dev/null and b/engine/assets/banner.png differ
diff --git a/engine/demo/Dockerfile.two-machine b/engine/demo/Dockerfile.two-machine
new file mode 100644
index 0000000000..8ce916ddea
--- /dev/null
+++ b/engine/demo/Dockerfile.two-machine
@@ -0,0 +1,15 @@
+FROM rust:1.83-bookworm AS build
+WORKDIR /src
+COPY Cargo.toml Cargo.lock ./
+COPY src ./src
+COPY benches ./benches
+COPY ui ./ui
+RUN cargo build --release --bin furrow
+
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates coreutils findutils git \
+ && rm -rf /var/lib/apt/lists/*
+COPY --from=build /src/target/release/furrow /usr/local/bin/furrow
+WORKDIR /machine
+CMD ["sleep", "infinity"]
diff --git a/engine/demo/agent-disaster.sh b/engine/demo/agent-disaster.sh
new file mode 100755
index 0000000000..4d647efb1c
--- /dev/null
+++ b/engine/demo/agent-disaster.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+BIN="$PROJECT_ROOT/target/release/furrow"
+DEMO_ROOT=${FURROW_DEMO_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/furrow-demo.XXXXXX")}
+REPO="$DEMO_ROOT/project"
+export FURROW_DATA_DIR="$DEMO_ROOT/furrow-data"
+export FURROW_NO_DAEMON=1
+
+green='\033[0;32m'
+red='\033[0;31m'
+bold='\033[1m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf '%bFAIL%b %s\n' "$red" "$reset" "$1"; exit 1; }
+
+step "Build furrow"
+cargo build --manifest-path "$PROJECT_ROOT/Cargo.toml" --release --quiet
+
+step "Create a realistic agent workspace"
+mkdir -p "$REPO/node_modules/demo-package" "$REPO/dist"
+git -C "$REPO" init -b main --quiet
+git -C "$REPO" config user.email demo@furrow.dev
+git -C "$REPO" config user.name "furrow demo"
+cat > "$REPO/.gitignore" <<'EOF'
+.env
+dev.sqlite*
+node_modules/
+dist/
+EOF
+cat > "$REPO/app.js" <<'EOF'
+export function greeting(name) {
+ return `hello ${name}`;
+}
+EOF
+git -C "$REPO" add app.js .gitignore
+git -C "$REPO" commit -m "initial app" --quiet
+
+# Real state that Git does not protect.
+printf 'API_TOKEN=local-development-secret\n' > "$REPO/.env"
+printf 'developer reasoning that is not committed\n' > "$REPO/notes.txt"
+dd if=/dev/zero of="$REPO/node_modules/demo-package/index.bin" bs=1024 count=512 2>/dev/null
+printf 'warm-build-cache\n' > "$REPO/dist/cache.txt"
+sqlite3 "$REPO/dev.sqlite" \
+ "PRAGMA journal_mode=WAL; CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users(name) VALUES('Ada');" \
+ >/dev/null
+printf '// dirty human work\n' >> "$REPO/app.js"
+
+printf 'Workspace: %s\n' "$REPO"
+printf 'Logical size: '
+du -sh "$REPO" | awk '{print $1}'
+
+step "Protect the complete working state"
+SNAPSHOT=$(
+ "$BIN" --repo "$REPO" --json watch \
+ | sed -n 's/.*"snapshot":"\([0-9a-f]*\)".*/\1/p'
+)
+[[ ${#SNAPSHOT} -eq 64 ]] || fail "snapshot was not created"
+ok "complete snapshot ${SNAPSHOT:0:12} is durable"
+
+step "Simulate a destructive autonomous agent"
+git -C "$REPO" clean -fdx >/dev/null
+cat > "$REPO/app.js" <<'EOF'
+throw new Error("agent destroyed the application");
+EOF
+printf 'Git can restore app.js, but .env, notes, dependencies, build state, and SQLite are gone.\n'
+[[ ! -e "$REPO/.env" ]] || fail ".env should have been deleted by the simulation"
+[[ ! -e "$REPO/dev.sqlite" ]] || fail "database should have been deleted by the simulation"
+
+step "Preview the rewind"
+"$BIN" --repo "$REPO" rewind "$SNAPSHOT" --dry-run
+
+step "Restore everything, using the consistent SQLite image"
+OUTPUT=$("$BIN" --repo "$REPO" rewind "$SNAPSHOT" --sqlite-consistent --yes)
+printf '%s\n' "$OUTPUT"
+
+step "Verify independently"
+grep -q 'local-development-secret' "$REPO/.env" && ok "ignored .env restored" || fail ".env mismatch"
+grep -q 'developer reasoning' "$REPO/notes.txt" && ok "untracked notes restored" || fail "notes mismatch"
+grep -q 'dirty human work' "$REPO/app.js" && ok "dirty tracked edit restored" || fail "tracked edit mismatch"
+[[ -s "$REPO/node_modules/demo-package/index.bin" ]] && ok "warm dependency state restored" || fail "dependency missing"
+grep -q 'warm-build-cache' "$REPO/dist/cache.txt" && ok "build cache restored" || fail "build cache mismatch"
+[[ $(sqlite3 "$REPO/dev.sqlite" 'SELECT name FROM users WHERE id=1;') == Ada ]] \
+ && ok "SQLite row restored" || fail "SQLite data mismatch"
+[[ $(sqlite3 "$REPO/dev.sqlite" 'PRAGMA integrity_check;') == ok ]] \
+ && ok "SQLite integrity check passes" || fail "SQLite integrity failed"
+
+step "Timeline proves the rewind is reversible"
+"$BIN" --repo "$REPO" timeline
+
+printf '\n%bDemo complete.%b Workspace retained at %s\n' "$green" "$reset" "$DEMO_ROOT"
diff --git a/engine/demo/conflict-radar.sh b/engine/demo/conflict-radar.sh
new file mode 100755
index 0000000000..86808c0e5d
--- /dev/null
+++ b/engine/demo/conflict-radar.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-radar.XXXXXX")
+REPO="$WORK/project"
+ALPHA="$WORK/alpha"
+BETA="$WORK/beta"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+bold='\033[1m'
+green='\033[0;32m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+step "Build furrow"
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+
+step "Create one complete dirty workspace"
+mkdir -p "$REPO"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@example.com
+git -C "$REPO" config user.name Demo
+printf 'export const auth = "base";\n' >"$REPO/auth.ts"
+printf '.env\n' >"$REPO/.gitignore"
+git -C "$REPO" add auth.ts .gitignore
+git -C "$REPO" commit -q -m initial
+printf 'TOKEN=local-only\n' >"$REPO/.env"
+"$BIN" --repo "$REPO" watch --no-daemon >/dev/null
+
+step "Fork two warm universes and publish intent"
+"$BIN" --repo "$REPO" fork alpha --destination "$ALPHA" >/dev/null
+"$BIN" --repo "$REPO" fork beta --destination "$BETA" >/dev/null
+"$BIN" --repo "$ALPHA" claim auth.ts --owner alpha-agent >/dev/null
+
+step "Two agents independently edit the same path"
+printf 'export const auth = "alpha refresh tokens";\n' >"$ALPHA/auth.ts"
+printf 'export const auth = "beta passkeys";\n' >"$BETA/auth.ts"
+"$BIN" --repo "$ALPHA" snap -m "alpha tool boundary" >/dev/null
+"$BIN" --repo "$BETA" snap -m "beta tool boundary" >/dev/null
+
+step "Conflict radar groups the collision without blocking either agent"
+forks=$("$BIN" --repo "$REPO" forks)
+printf '%s\n' "$forks"
+test "$(printf '%s\n' "$forks" | grep -c '1 conflict')" = 2 \
+ || fail "both universes should report one conflict"
+events=$("$BIN" --repo "$REPO" events)
+printf '%s\n' "$events"
+test "$(printf '%s\n' "$events" | grep -c '"state":"opened"')" = 1 \
+ || fail "the path should produce exactly one grouped open event"
+printf '%s\n' "$events" | grep -q '"claim_state":"covered"' \
+ || fail "the event should carry alpha's advisory claim state"
+ok "agents can route around one durable, cursor-addressable conflict event"
+
+step "Beta reverts; the same conflict closes"
+printf 'export const auth = "base";\n' >"$BETA/auth.ts"
+"$BIN" --repo "$BETA" snap -m "beta rerouted" >/dev/null
+forks=$("$BIN" --repo "$REPO" forks)
+printf '%s\n' "$forks"
+test "$(printf '%s\n' "$forks" | grep -c 'clear')" = 2 \
+ || fail "both universes should be clear after rerouting"
+events=$("$BIN" --repo "$REPO" events)
+test "$(printf '%s\n' "$events" | grep -c '"state":"resolved"')" = 1 \
+ || fail "the radar should emit one durable resolve event"
+ok "the collision resolved before merge work began"
+
+printf '\n%bDemo complete.%b Workspaces retained at %s\n' "$green" "$reset" "$WORK"
diff --git a/engine/demo/find-regression.sh b/engine/demo/find-regression.sh
new file mode 100755
index 0000000000..17af44fbaf
--- /dev/null
+++ b/engine/demo/find-regression.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-bisect-demo.XXXXXX")
+REPO="$WORK/project"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+bold='\033[1m'
+green='\033[0;32m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+step "Build furrow"
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+
+step "Create a warm project timeline"
+mkdir -p "$REPO/node_modules/runtime"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@example.com
+git -C "$REPO" config user.name Demo
+printf 'node_modules/\n' >"$REPO/.gitignore"
+printf 'status=good\n' >"$REPO/app.conf"
+dd if=/dev/zero of="$REPO/node_modules/runtime/cache.bin" bs=1048576 count=16 2>/dev/null
+git -C "$REPO" add .gitignore app.conf
+git -C "$REPO" commit -q -m initial
+"$BIN" --repo "$REPO" watch --no-daemon >/dev/null
+
+printf 'one\n' >"$REPO/feature.txt"
+"$BIN" --repo "$REPO" snap -m "feature started" >/dev/null
+printf 'two\n' >>"$REPO/feature.txt"
+"$BIN" --repo "$REPO" snap -m "feature still passes" >/dev/null
+printf 'status=broken\n' >"$REPO/app.conf"
+FIRST_BAD=$("$BIN" --repo "$REPO" --json snap -m "regression introduced" \
+ | sed -n 's/.*"snapshot":"\([0-9a-f]*\)".*/\1/p')
+printf 'later work\n' >"$REPO/later.txt"
+"$BIN" --repo "$REPO" snap -m "later failing state" >/dev/null
+printf 'more work\n' >>"$REPO/later.txt"
+"$BIN" --repo "$REPO" snap -m "latest failing state" >/dev/null
+test "${#FIRST_BAD}" -eq 64 || fail "could not record the expected bad snapshot"
+ok "six complete states recorded around one regression"
+
+step "Find the first failure with logarithmic checks"
+REPORT="$WORK/bisect-report"
+"$BIN" --repo "$REPO" bisect -- /bin/sh -c \
+ 'grep -q "status=good" app.conf; code=$?; printf "probe-only\n" > app.conf; exit $code' \
+ | tee "$REPORT"
+FOUND=$(sed -n 's/^First bad: //p' "$REPORT")
+CHECKS=$(grep -Ec '^[0-9a-f]{12} ' "$REPORT" || true)
+test "$FOUND" = "$FIRST_BAD" || fail "bisect selected the wrong snapshot"
+test "$CHECKS" -le 5 || fail "bisect used more than logarithmic checks"
+grep -q 'status=broken' "$REPO/app.conf" || fail "probe side effects reached the source"
+ok "first bad state found in $CHECKS isolated checks; source stayed untouched"
+
+printf '\n%bDemo complete.%b Workspace retained at %s\n' "$green" "$reset" "$WORK"
diff --git a/engine/demo/five-agent-scale.sh b/engine/demo/five-agent-scale.sh
new file mode 100755
index 0000000000..b1891dab9a
--- /dev/null
+++ b/engine/demo/five-agent-scale.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-five-agents.XXXXXX")
+REPO="$WORK/project"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+bold='\033[1m'
+green='\033[0;32m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+step "Build furrow"
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+
+step "Create one dirty workspace with a 32 MiB warm dependency"
+mkdir -p "$REPO/node_modules/runtime"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@example.com
+git -C "$REPO" config user.name Demo
+printf 'node_modules/\n.env\n' >"$REPO/.gitignore"
+printf 'base source\n' >"$REPO/app.txt"
+git -C "$REPO" add .gitignore app.txt
+git -C "$REPO" commit -q -m initial
+printf 'dirty source must survive\n' >"$REPO/app.txt"
+printf 'LOCAL_TOKEN=five-agent-demo\n' >"$REPO/.env"
+dd if=/dev/zero of="$REPO/node_modules/runtime/cache.bin" bs=1048576 count=32 2>/dev/null
+"$BIN" --repo "$REPO" watch --no-daemon >/dev/null
+ok "complete warm state protected"
+
+step "Start five complete universes in one command"
+"$BIN" --repo "$REPO" exec -n 5 -- sh -c '
+ printf "result from agent %s\n" "$FURROW_UNIVERSE_INDEX" >"result-$FURROW_UNIVERSE_INDEX.txt"
+ printf "agent %s implementation\n" "$FURROW_UNIVERSE_INDEX" >app.txt
+'
+
+step "Verify pairwise isolation and complete warm state"
+test "$(cat "$REPO/app.txt")" = "dirty source must survive" \
+ || fail "an agent modified the source"
+test ! -e "$REPO/result-1.txt" || fail "an agent result leaked into the source"
+for index in 1 2 3 4 5; do
+ fork=$(find "$WORK/project.furrow-forks" -maxdepth 1 -type d -name "exec-*-$index" -print -quit)
+ test -n "$fork" || fail "agent $index universe was not retained"
+ test -f "$fork/result-$index.txt" || fail "agent $index lost its result"
+ test -f "$fork/node_modules/runtime/cache.bin" || fail "agent $index lost warm dependencies"
+ grep -q LOCAL_TOKEN "$fork/.env" || fail "agent $index lost ignored local configuration"
+ for other in 1 2 3 4 5; do
+ if test "$other" != "$index" && test -e "$fork/result-$other.txt"; then
+ fail "agent $other leaked into agent $index"
+ fi
+ done
+done
+ok "five agents completed with zero cross-workspace or source interference"
+
+step "Inspect disclosed fork costs"
+"$BIN" --repo "$REPO" forks
+
+printf '\n%bDemo complete.%b Six workspaces retained at %s\n' "$green" "$reset" "$WORK"
diff --git a/engine/demo/mission-control.sh b/engine/demo/mission-control.sh
new file mode 100755
index 0000000000..bbb7285496
--- /dev/null
+++ b/engine/demo/mission-control.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-mission-control.XXXXXX")
+REPO="$WORK/project"
+AUTH="$WORK/auth-hardening"
+PASSKEY="$WORK/passkey-rollout"
+DOCS="$WORK/docs-refresh"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+mkdir -p "$REPO/src"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@furrow.local
+git -C "$REPO" config user.name "Furrow Demo"
+printf 'export function authenticate(user: string) {\n return user.length > 0;\n}\n' >"$REPO/src/auth.ts"
+printf 'export const port = 4173;\n' >"$REPO/src/config.ts"
+printf '# Parallel agent demo\n' >"$REPO/README.md"
+printf '.env\n' >"$REPO/.gitignore"
+git -C "$REPO" add .
+git -C "$REPO" commit -q -m "initial workspace"
+printf 'SESSION_KEY=local-demo-only\n' >"$REPO/.env"
+
+"$BIN" --repo "$REPO" watch --no-daemon >/dev/null
+"$BIN" --repo "$REPO" snap -m "baseline before parallel work" >/dev/null
+printf 'Mission Control demo: %s\n' "$WORK"
+printf 'Watch real furrow commands create three universes and open a collision.\n'
+printf 'Press Ctrl-C to stop the local UI server. The demo workspaces remain on disk.\n'
+
+scenario() {
+ sleep 2
+ printf '\n[furrow] creating a clear documentation universe\n'
+ "$BIN" --repo "$REPO" fork docs-refresh --destination "$DOCS" >/dev/null
+ printf '\nOperational notes for the next release.\n' >>"$DOCS/README.md"
+ "$BIN" --repo "$DOCS" snap -m "documentation agent updated release notes" >/dev/null
+
+ sleep 3
+ printf '[furrow] security agent claims and edits src/auth.ts\n'
+ "$BIN" --repo "$REPO" fork auth-hardening --destination "$AUTH" >/dev/null
+ "$BIN" --repo "$AUTH" claim src/auth.ts --owner security-agent >/dev/null
+ printf 'export function authenticate(user: string) {\n return user.length >= 8; // hardened policy\n}\n' >"$AUTH/src/auth.ts"
+ "$BIN" --repo "$AUTH" snap -m "security agent hardened authentication" >/dev/null
+
+ sleep 3
+ printf '[furrow] identity agent edits the same path; conflict radar opens\n'
+ "$BIN" --repo "$REPO" fork passkey-rollout --destination "$PASSKEY" >/dev/null
+ printf 'export function authenticate(user: string) {\n return user.startsWith("passkey:");\n}\n' >"$PASSKEY/src/auth.ts"
+ "$BIN" --repo "$PASSKEY" snap -m "identity agent added passkeys" >/dev/null
+ printf '[furrow] live scenario ready: inspect the diffs and merge previews in Mission Control\n'
+}
+
+scenario &
+if [[ "${FURROW_DEMO_NO_OPEN:-0}" == "1" ]]; then
+ exec "$BIN" --repo "$REPO" ui --no-open --merge-check "git diff --check"
+fi
+exec "$BIN" --repo "$REPO" ui --merge-check "git diff --check"
diff --git a/engine/demo/parallel-agent-forks.sh b/engine/demo/parallel-agent-forks.sh
new file mode 100755
index 0000000000..42e724e884
--- /dev/null
+++ b/engine/demo/parallel-agent-forks.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+BIN="$PROJECT_ROOT/target/release/furrow"
+DEMO_ROOT=${FURROW_DEMO_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/furrow-forks.XXXXXX")}
+REPO="$DEMO_ROOT/project"
+ALPHA="$DEMO_ROOT/alpha"
+BETA="$DEMO_ROOT/beta"
+export FURROW_DATA_DIR="$DEMO_ROOT/furrow-data"
+export FURROW_NO_DAEMON=1
+
+green='\033[0;32m'
+red='\033[0;31m'
+bold='\033[1m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf '%bFAIL%b %s\n' "$red" "$reset" "$1"; exit 1; }
+
+step "Build furrow"
+cargo build --manifest-path "$PROJECT_ROOT/Cargo.toml" --release --quiet
+
+step "Create one dirty, warm developer workspace"
+mkdir -p "$REPO/node_modules/example" "$REPO/.cache/build"
+git -C "$REPO" init -b main --quiet
+git -C "$REPO" config user.email demo@furrow.dev
+git -C "$REPO" config user.name "furrow demo"
+printf '.env\nnode_modules/\n.cache/\n' > "$REPO/.gitignore"
+printf 'export const result = "original";\n' > "$REPO/app.js"
+git -C "$REPO" add app.js .gitignore
+git -C "$REPO" commit -m "initial app" --quiet
+printf 'API_TOKEN=local-only\n' > "$REPO/.env"
+printf '// uncommitted developer edit\n' >> "$REPO/app.js"
+dd if=/dev/zero of="$REPO/node_modules/example/warm-cache.bin" bs=1048576 count=32 2>/dev/null
+printf 'compiled-before-agents\n' > "$REPO/.cache/build/state"
+
+"$BIN" --repo "$REPO" watch >/dev/null
+ok "complete dirty state protected"
+
+step "Fork two full workspaces with warm dependencies"
+"$BIN" --repo "$REPO" fork alpha --destination "$ALPHA"
+"$BIN" --repo "$REPO" fork beta --destination "$BETA"
+
+step "Make overlapping agent intent visible before writes"
+"$BIN" --repo "$ALPHA" claim app.js --owner alpha-agent >/dev/null
+if "$BIN" --repo "$BETA" claim app.js --owner beta-agent; then
+ fail "beta should not receive alpha's active path claim"
+else
+ ok "beta saw alpha's advisory app.js claim before editing"
+fi
+"$BIN" --repo "$ALPHA" coord write tasks/alpha.txt \
+ --value "alpha owns app.js" --owner alpha-agent >/dev/null
+grep -q 'alpha owns app.js' "$BETA/.furrow/coord/tasks/alpha.txt" \
+ && ok "alpha's versioned coordination note propagated eagerly to beta" \
+ || fail "coordination note did not propagate"
+
+step "Run two simulated agents concurrently"
+(
+ printf 'export const result = "alpha implementation";\n' > "$ALPHA/app.js"
+ printf 'alpha-only artifact\n' > "$ALPHA/.cache/build/alpha"
+) &
+alpha_pid=$!
+(
+ printf 'export const result = "beta implementation";\n' > "$BETA/app.js"
+ printf 'beta-only migration\n' > "$BETA/migration.sql"
+) &
+beta_pid=$!
+wait "$alpha_pid" "$beta_pid"
+
+step "Verify full-state isolation"
+grep -q 'uncommitted developer edit' "$REPO/app.js" \
+ && ok "source dirty edit was never touched" || fail "source was modified"
+grep -q 'alpha implementation' "$ALPHA/app.js" \
+ && ok "alpha has its own implementation" || fail "alpha result missing"
+grep -q 'beta implementation' "$BETA/app.js" \
+ && ok "beta has its own implementation" || fail "beta result missing"
+[[ -s "$ALPHA/node_modules/example/warm-cache.bin" && -s "$BETA/node_modules/example/warm-cache.bin" ]] \
+ && ok "both agents started with the 32 MiB warm dependency tree" || fail "warm state missing"
+grep -q 'local-only' "$ALPHA/.env" && grep -q 'local-only' "$BETA/.env" \
+ && ok "ignored local configuration exists in both forks" || fail "ignored state missing"
+[[ $(cat "$REPO/.furrow/workspace-id") != $(cat "$ALPHA/.furrow/workspace-id") ]] \
+ && [[ $(cat "$ALPHA/.furrow/workspace-id") != $(cat "$BETA/.furrow/workspace-id") ]] \
+ && ok "every fork has an independent timeline" || fail "workspace identities collided"
+
+"$BIN" --repo "$ALPHA" snap -m "alpha agent boundary" >/dev/null
+"$BIN" --repo "$BETA" snap -m "beta agent boundary" >/dev/null
+
+step "List the parallel workspaces with live conflict radar"
+"$BIN" --repo "$REPO" forks
+events=$("$BIN" --repo "$REPO" events)
+printf '%s\n' "$events"
+printf '%s\n' "$events" | grep -q '"state":"opened"' \
+ && ok "one durable conflict event names both forks and app.js" \
+ || fail "conflict radar event missing"
+
+step "Verification-gated merge of alpha"
+"$BIN" --repo "$REPO" merge alpha \
+ --check "grep -q 'alpha implementation' app.js && test -f .cache/build/alpha"
+grep -q 'alpha implementation' "$REPO/app.js" \
+ && ok "alpha converged only after its check passed" || fail "alpha merge missing"
+
+step "Overlapping beta edit stops as an explicit conflict"
+if "$BIN" --repo "$REPO" merge beta --dry-run; then
+ fail "beta should conflict with alpha on app.js"
+else
+ ok "beta conflict was reported without mutating the source"
+fi
+[[ ! -e "$REPO/migration.sql" ]] \
+ && ok "no partial beta changes landed" || fail "conflicted merge partially applied"
+
+printf '\n%bDemo complete.%b Workspaces retained at %s\n' "$green" "$reset" "$DEMO_ROOT"
diff --git a/engine/demo/risky-command.sh b/engine/demo/risky-command.sh
new file mode 100755
index 0000000000..dd220a05e3
--- /dev/null
+++ b/engine/demo/risky-command.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-try.XXXXXX")
+REPO="$WORK/project"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+bold='\033[1m'
+green='\033[0;32m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+step "Build furrow"
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+
+step "Create an ordinary repository with state Git cannot restore"
+mkdir -p "$REPO/cache"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@example.com
+git -C "$REPO" config user.name Demo
+printf 'API_TOKEN=irreplaceable\n' >"$REPO/.env"
+printf '.env\ncache/\n' >"$REPO/.gitignore"
+printf 'console.log("working");\n' >"$REPO/app.js"
+printf 'warm dependency bytes\n' >"$REPO/cache/dependency.bin"
+git -C "$REPO" add .gitignore app.js
+git -C "$REPO" commit -q -m initial
+
+step "Run a destructive command with no prior furrow setup"
+REPORT="$WORK/try-report"
+"$BIN" --repo "$REPO" try -m "destructive cleanup" -- \
+ /bin/sh -c 'rm -rf .env cache; printf "%s\n" '\''console.log("broken");'\'' > app.js' \
+ 2>"$REPORT"
+cat "$REPORT"
+BEFORE=$(sed -n 's/^Protected //p' "$REPORT")
+test "${#BEFORE}" -eq 64 || fail "furrow did not report an exact restore point"
+test ! -e "$REPO/.env" || fail "the demo command did not delete .env"
+grep -q broken "$REPO/app.js" || fail "the demo command did not damage app.js"
+ok "the risky command ran normally and its damage is visible"
+
+step "Undo the complete command"
+"$BIN" --repo "$REPO" rewind "$BEFORE" --yes >/dev/null
+grep -q 'API_TOKEN=irreplaceable' "$REPO/.env" \
+ || fail "ignored secret was not restored"
+grep -q 'console.log("working")' "$REPO/app.js" \
+ || fail "tracked source was not restored"
+grep -q 'warm dependency bytes' "$REPO/cache/dependency.bin" \
+ || fail "ignored dependency cache was not restored"
+ok "tracked, ignored, and dependency state recovered from one restore point"
+
+printf '\n%bDemo complete.%b Workspace retained at %s\n' "$green" "$reset" "$WORK"
diff --git a/engine/demo/shrink-cache.sh b/engine/demo/shrink-cache.sh
new file mode 100755
index 0000000000..48f5ad717f
--- /dev/null
+++ b/engine/demo/shrink-cache.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+BIN="$ROOT/target/release/furrow"
+WORK=$(mktemp -d "${TMPDIR:-/tmp}/furrow-shrink.XXXXXX")
+REPO="$WORK/project"
+export FURROW_DATA_DIR="$WORK/data"
+export FURROW_NO_DAEMON=1
+
+bold='\033[1m'
+green='\033[0;32m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
+
+step "Build furrow"
+cargo build --release --quiet --manifest-path "$ROOT/Cargo.toml"
+
+step "Create a warm project with a 24 MiB ignored dependency tree"
+mkdir -p "$REPO/node_modules/runtime" "$REPO/node_modules/compiler"
+git -C "$REPO" init -q -b main
+git -C "$REPO" config user.email demo@example.com
+git -C "$REPO" config user.name Demo
+printf 'node_modules/\n' >"$REPO/.gitignore"
+printf 'console.log("ready");\n' >"$REPO/app.js"
+dd if=/dev/zero of="$REPO/node_modules/runtime/archive.bin" bs=1048576 count=16 2>/dev/null
+dd if=/dev/zero of="$REPO/node_modules/compiler/cache.bin" bs=1048576 count=8 2>/dev/null
+printf 'runtime sentinel\n' >"$REPO/node_modules/runtime/version.txt"
+git -C "$REPO" add .gitignore app.js
+git -C "$REPO" commit -q -m initial
+"$BIN" --repo "$REPO" watch --no-daemon >/dev/null
+ok "the complete warm workspace is already deduplicated in the local store"
+
+step "Preview reclaimable workspace bytes without changing anything"
+"$BIN" --repo "$REPO" shrink
+test -e "$REPO/node_modules/runtime/archive.bin" \
+ || fail "preview changed the workspace"
+ok "preview was read-only"
+
+step "Remove the redundant workspace copy with an exact undo point"
+REPORT="$WORK/shrink-report"
+"$BIN" --repo "$REPO" shrink --yes | tee "$REPORT"
+BEFORE=$(sed -n 's/^Undo with: furrow rewind //p' "$REPORT")
+test "${#BEFORE}" -eq 64 || fail "shrink did not report an exact restore point"
+test ! -e "$REPO/node_modules" || fail "dependency tree was not removed"
+grep -q ready "$REPO/app.js" || fail "source was changed"
+grep -q 'Estimated net disk reclaimed:' "$REPORT" || fail "net savings were not reported"
+ok "dependency bytes were reclaimed without touching source"
+
+step "Restore the exact warm dependency tree"
+"$BIN" --repo "$REPO" rewind "$BEFORE" --yes >/dev/null
+test "$(wc -c <"$REPO/node_modules/runtime/archive.bin" | tr -d ' ')" = 16777216 \
+ || fail "runtime archive was not restored"
+grep -q 'runtime sentinel' "$REPO/node_modules/runtime/version.txt" \
+ || fail "dependency metadata was not restored"
+ok "the complete cache returned from the local snapshot"
+
+printf '\n%bDemo complete.%b Workspace retained at %s\n' "$green" "$reset" "$WORK"
diff --git a/engine/demo/two-machine-network.sh b/engine/demo/two-machine-network.sh
new file mode 100755
index 0000000000..a1826396fb
--- /dev/null
+++ b/engine/demo/two-machine-network.sh
@@ -0,0 +1,215 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+tag=${FURROW_DEMO_IMAGE:-furrow-two-machine:local}
+network=furrow-two-machine-net
+minio=furrow-two-machine-minio
+laptop_a=furrow-laptop-a
+laptop_b=furrow-laptop-b
+volume_a=furrow-laptop-a-data
+volume_b=furrow-laptop-b-data
+follow_a=
+follow_b=
+
+now_ms() {
+ perl -MTime::HiRes=time -e 'printf "%.0f\n", time * 1000'
+}
+
+cleanup() {
+ test -z "$follow_a" || kill "$follow_a" >/dev/null 2>&1 || true
+ test -z "$follow_b" || kill "$follow_b" >/dev/null 2>&1 || true
+ docker rm -f "$laptop_a" "$laptop_b" "$minio" >/dev/null 2>&1 || true
+ docker network rm "$network" >/dev/null 2>&1 || true
+ docker volume rm "$volume_a" "$volume_b" >/dev/null 2>&1 || true
+}
+
+remote_usage() {
+ docker run --rm --network "$network" --entrypoint /bin/sh minio/mc:latest -c '
+ mc alias set local http://furrow-two-machine-minio:9000 furrow-test furrow-test-secret >/dev/null
+ mc du --json --recursive local/sync | tail -1
+ '
+}
+
+json_number() {
+ printf '%s\n' "$1" | sed -n "s/.*\"$2\":\([0-9][0-9]*\).*/\1/p"
+}
+
+tree_digest() {
+ docker exec "$1" sh -c '
+ cd /machine/project
+ find . -type f ! -path "./.furrow/*" -print0 |
+ sort -z | xargs -0 sha256sum | sha256sum | cut -d " " -f 1
+ '
+}
+
+wait_for_file() {
+ machine=$1
+ file=$2
+ expected=$3
+ for _ in $(seq 1 300); do
+ if docker exec "$machine" sh -c "test -f '$file' && grep -qx '$expected' '$file'"; then
+ return 0
+ fi
+ sleep 0.1
+ done
+ printf 'timed out waiting for %s on %s\n' "$file" "$machine" >&2
+ return 1
+}
+
+trap cleanup EXIT
+cleanup
+
+docker build -q -f "$root/demo/Dockerfile.two-machine" -t "$tag" "$root" >/dev/null
+docker network create "$network" >/dev/null
+docker volume create "$volume_a" >/dev/null
+docker volume create "$volume_b" >/dev/null
+docker run -d --name "$minio" --network "$network" \
+ -e MINIO_ROOT_USER=furrow-test -e MINIO_ROOT_PASSWORD=furrow-test-secret \
+ minio/minio:latest server /data >/dev/null
+docker run --rm --network "$network" --entrypoint /bin/sh minio/mc:latest -c '
+ until mc alias set local http://furrow-two-machine-minio:9000 furrow-test furrow-test-secret >/dev/null 2>&1; do sleep 1; done
+ mc mb local/sync >/dev/null
+'
+
+for machine in "$laptop_a:$volume_a" "$laptop_b:$volume_b"; do
+ name=${machine%%:*}
+ volume=${machine#*:}
+ docker run -d --name "$name" --network "$network" -v "$volume:/machine" \
+ -e FURROW_DATA_DIR=/machine/furrow-data \
+ -e AWS_ACCESS_KEY_ID=furrow-test \
+ -e AWS_SECRET_ACCESS_KEY=furrow-test-secret \
+ -e FURROW_S3_ENDPOINT=http://furrow-two-machine-minio:9000 \
+ -e FURROW_S3_ALLOW_HTTP=1 -e FURROW_S3_PATH_STYLE=1 \
+ "$tag" >/dev/null
+done
+
+# Laptop A begins with a realistic mixed working tree: committed source, a large
+# fixture, ignored credentials, and uncommitted agent notes.
+docker exec "$laptop_a" sh -c '
+ mkdir -p /machine/project/src /machine/project/fixtures /machine/project/reports
+ cd /machine/project
+ git init -q
+ git config user.email agent-a@example.test
+ git config user.name "Agent A"
+ for number in $(seq -w 1 80); do
+ printf "export function module%s(value: number) { return value + %s; }\n" "$number" "${number#0}" > "src/module${number}.ts"
+ done
+ dd if=/dev/urandom of=fixtures/research-corpus.bin bs=1M count=4 status=none
+ printf "node_modules/\n.env\n" > .gitignore
+ printf "MODEL_TOKEN=encrypted-working-state\n" > .env
+ printf "Investigate corpus outliers and write the handoff report.\n" > agent-notes.txt
+ git add src .gitignore fixtures/research-corpus.bin
+ git commit -qm "seed analysis project"
+ printf "Uncommitted hypothesis: cohorts 7 and 12 need review.\n" >> agent-notes.txt
+ furrow watch --no-daemon >/dev/null
+'
+
+source_bytes=$(docker exec "$laptop_a" sh -c 'du -sb /machine/project | cut -f 1')
+source_files=$(docker exec "$laptop_a" sh -c 'find /machine/project -type f ! -path "*/.furrow/*" | wc -l | tr -d " "')
+add_output=$(docker exec "$laptop_a" sh -c 'cd /machine/project && furrow remote add s3://sync/transport --name project')
+key=$(printf '%s\n' "$add_output" | sed -n 's/^Recovery key //p')
+test ${#key} -eq 64
+
+push_started=$(now_ms)
+initial_push=$(docker exec "$laptop_a" sh -c 'cd /machine/project && furrow sync --push')
+push_latency_ms=$(( $(now_ms) - push_started ))
+initial_usage=$(remote_usage)
+initial_remote_bytes=$(json_number "$initial_usage" size)
+initial_remote_objects=$(json_number "$initial_usage" objects)
+test -n "$initial_remote_bytes"
+
+clone_started=$(now_ms)
+clone_output=$(docker exec -e FURROW_RECOVERY_KEY="$key" "$laptop_b" sh -c '
+ cd /machine
+ furrow clone s3://sync/transport/project --no-watch
+')
+clone_latency_ms=$(( $(now_ms) - clone_started ))
+
+test "$(tree_digest "$laptop_a")" = "$(tree_digest "$laptop_b")"
+docker exec "$laptop_b" grep -qx 'MODEL_TOKEN=encrypted-working-state' /machine/project/.env
+docker exec "$laptop_b" grep -qx 'Uncommitted hypothesis: cohorts 7 and 12 need review.' /machine/project/agent-notes.txt
+docker exec "$laptop_b" sh -c 'cd /machine/project && test "$(git log -1 --format=%s)" = "seed analysis project"'
+
+docker exec "$laptop_a" sh -c 'cd /machine/project && furrow sync --follow --poll-seconds 1' >/tmp/furrow-follow-a.log 2>&1 &
+follow_a=$!
+docker exec "$laptop_b" sh -c 'cd /machine/project && furrow sync --follow --poll-seconds 1' >/tmp/furrow-follow-b.log 2>&1 &
+follow_b=$!
+
+# Agent A produces several related artifacts in one turn. The completion marker
+# is written last so its arrival means B can validate the complete turn.
+a_started=$(now_ms)
+docker exec "$laptop_a" sh -c '
+ cd /machine/project
+ mkdir -p reports artifacts
+ {
+ printf "# Cohort analysis\n\n"
+ printf "The corpus scan found two review candidates.\n\n"
+ printf "| cohort | samples | confidence |\n| --- | ---: | ---: |\n"
+ for cohort in $(seq 1 160); do
+ printf "| cohort-%03d | %d | 0.%03d |\n" "$cohort" "$((800 + cohort))" "$((700 + cohort % 250))"
+ done
+ } > reports/analysis.md
+ printf "{\"candidate_cohorts\":[7,12],\"rows\":160}\n" > artifacts/summary.json
+ printf "Agent A complete\n" > artifacts/agent-a.done
+ furrow snap -m "agent A: cohort analysis" >/dev/null
+'
+wait_for_file "$laptop_b" /machine/project/artifacts/agent-a.done 'Agent A complete'
+a_latency_ms=$(( $(now_ms) - a_started ))
+docker exec "$laptop_b" grep -q '^| cohort-160 |' /machine/project/reports/analysis.md
+docker exec "$laptop_b" grep -Fqx '{"candidate_cohorts":[7,12],"rows":160}' /machine/project/artifacts/summary.json
+test "$(tree_digest "$laptop_a")" = "$(tree_digest "$laptop_b")"
+a_usage=$(remote_usage)
+a_remote_bytes=$(json_number "$a_usage" size)
+a_delta_bytes=$(( a_remote_bytes - initial_remote_bytes ))
+a_payload_bytes=$(docker exec "$laptop_a" sh -c 'cd /machine/project; wc -c < reports/analysis.md; wc -c < artifacts/summary.json' | awk '{ total += $1 } END { print total }')
+
+# Agent B consumes A's report, adds a review and final report, then hands the
+# complete state back. This also exercises the explicit writer handoff.
+b_started=$(now_ms)
+docker exec "$laptop_b" sh -c '
+ cd /machine/project
+ test "$(grep -c "^| cohort-" reports/analysis.md)" -eq 160
+ mkdir -p reviews
+ cat > reviews/methodology.md <<"EOF"
+# Methodology review
+
+The analysis is internally consistent. Cohorts 7 and 12 should remain flagged,
+and the generated table contains all 160 expected cohorts.
+EOF
+ {
+ printf "# Final research report\n\n"
+ printf "Reviewed by Agent B after reading the complete Agent A artifact set.\n\n"
+ printf "Decision: investigate cohorts 7 and 12.\n"
+ } > reports/final.md
+ printf "Agent B complete\n" > artifacts/agent-b.done
+ furrow snap -m "agent B: methodology review" >/dev/null
+'
+wait_for_file "$laptop_a" /machine/project/artifacts/agent-b.done 'Agent B complete'
+b_latency_ms=$(( $(now_ms) - b_started ))
+docker exec "$laptop_a" grep -qx 'Decision: investigate cohorts 7 and 12.' /machine/project/reports/final.md
+docker exec "$laptop_a" grep -q 'all 160 expected cohorts' /machine/project/reviews/methodology.md
+final_digest_a=$(tree_digest "$laptop_a")
+final_digest_b=$(tree_digest "$laptop_b")
+test "$final_digest_a" = "$final_digest_b"
+b_usage=$(remote_usage)
+b_remote_bytes=$(json_number "$b_usage" size)
+b_delta_bytes=$(( b_remote_bytes - a_remote_bytes ))
+b_payload_bytes=$(docker exec "$laptop_b" sh -c 'cd /machine/project; wc -c < reviews/methodology.md; wc -c < reports/final.md' | awk '{ total += $1 } END { print total }')
+
+container_stats=$(docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} network={{.NetIO}}' "$laptop_a" "$laptop_b")
+
+kill "$follow_a" "$follow_b" >/dev/null 2>&1 || true
+wait "$follow_a" "$follow_b" >/dev/null 2>&1 || true
+follow_a=
+follow_b=
+
+printf 'PASS: two isolated machines exchanged and consumed one complete working tree\n'
+printf ' source: %s files, %s bytes (committed, dirty, ignored, and Git state)\n' "$source_files" "$source_bytes"
+printf ' initial publish: %s ms, %s encrypted bytes, %s remote objects\n' "$push_latency_ms" "$initial_remote_bytes" "${initial_remote_objects:-unknown}"
+printf ' fresh-machine clone: %s ms; %s\n' "$clone_latency_ms" "$(printf '%s' "$clone_output" | tail -1)"
+printf ' Agent A -> B: %s ms, %s payload bytes, %s new encrypted remote bytes\n' "$a_latency_ms" "$a_payload_bytes" "$a_delta_bytes"
+printf ' Agent B -> A: %s ms, %s payload bytes, %s new encrypted remote bytes\n' "$b_latency_ms" "$b_payload_bytes" "$b_delta_bytes"
+printf ' final working-tree digest: %s\n' "$final_digest_a"
+printf ' initial push: %s\n' "$(printf '%s' "$initial_push" | tr '\n' ';')"
+printf ' %s\n' "$container_stats"
diff --git a/engine/demo/two-machine-sync.sh b/engine/demo/two-machine-sync.sh
new file mode 100755
index 0000000000..465714ddbd
--- /dev/null
+++ b/engine/demo/two-machine-sync.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+BIN="$PROJECT_ROOT/target/release/furrow"
+DEMO_ROOT=${FURROW_DEMO_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/furrow-sync.XXXXXX")}
+LAPTOP="$DEMO_ROOT/laptop/project"
+DESKTOP="$DEMO_ROOT/desktop/project"
+REMOTE="$DEMO_ROOT/encrypted-remote"
+LAPTOP_DATA="$DEMO_ROOT/laptop/furrow-data"
+DESKTOP_DATA="$DEMO_ROOT/desktop/furrow-data"
+export FURROW_NO_DAEMON=1
+
+green='\033[0;32m'
+red='\033[0;31m'
+bold='\033[1m'
+reset='\033[0m'
+
+step() { printf '\n%b%s%b\n' "$bold" "$1" "$reset"; }
+ok() { printf '%bPASS%b %s\n' "$green" "$reset" "$1"; }
+fail() { printf '%bFAIL%b %s\n' "$red" "$reset" "$1"; exit 1; }
+laptop() { FURROW_DATA_DIR="$LAPTOP_DATA" "$BIN" --repo "$LAPTOP" "$@"; }
+desktop() { FURROW_DATA_DIR="$DESKTOP_DATA" "$BIN" --repo "$DESKTOP" "$@"; }
+
+step "Build furrow"
+cargo build --manifest-path "$PROJECT_ROOT/Cargo.toml" --release --quiet
+
+step "Create independent laptop and desktop repositories"
+mkdir -p "$LAPTOP"
+git -C "$LAPTOP" init -b main --quiet
+git -C "$LAPTOP" config user.email demo@furrow.dev
+git -C "$LAPTOP" config user.name "furrow demo"
+printf '.env\n.cache/\n' > "$LAPTOP/.gitignore"
+printf 'export const machine = "base";\n' > "$LAPTOP/app.js"
+git -C "$LAPTOP" add app.js .gitignore
+git -C "$LAPTOP" commit -m "initial app" --quiet
+mkdir -p "$(dirname "$DESKTOP")"
+git clone --quiet "$LAPTOP" "$DESKTOP"
+
+printf 'API_TOKEN=never-visible-to-remote\n' > "$LAPTOP/.env"
+printf 'unfinished laptop reasoning\n' > "$LAPTOP/notes.txt"
+mkdir -p "$LAPTOP/.cache"
+dd if=/dev/zero of="$LAPTOP/.cache/warm.bin" bs=1048576 count=8 2>/dev/null
+printf 'export const machine = "dirty laptop";\n' > "$LAPTOP/app.js"
+laptop watch --no-daemon >/dev/null
+desktop watch --no-daemon >/dev/null
+ok "both machines have independent stores and timelines"
+
+step "Pair through a dumb developer-owned directory"
+PAIR=$(laptop --json pair "$REMOTE" --name viral-demo)
+KEY=$(printf '%s' "$PAIR" | sed -n 's/.*"key_hex": "\([^"]*\)".*/\1/p')
+[[ ${#KEY} -eq 64 ]] || fail "pairing key was not generated"
+desktop pair "$REMOTE" --name viral-demo --key "$KEY" >/dev/null
+ok "remote namespace paired with a private 256-bit key"
+
+step "Move the exact dirty workspace to the desktop"
+laptop sync --push
+desktop sync --pull --bootstrap
+cmp "$LAPTOP/app.js" "$DESKTOP/app.js" || fail "dirty tracked file mismatch"
+cmp "$LAPTOP/.env" "$DESKTOP/.env" || fail "ignored secret mismatch"
+cmp "$LAPTOP/notes.txt" "$DESKTOP/notes.txt" || fail "untracked notes mismatch"
+cmp "$LAPTOP/.cache/warm.bin" "$DESKTOP/.cache/warm.bin" || fail "warm cache mismatch"
+ok "tracked, ignored, untracked, and warm dependency state arrived exactly"
+
+if grep -R -a -q 'never-visible-to-remote' "$REMOTE"; then
+ fail "remote leaked plaintext workspace bytes"
+fi
+ok "remote contains opaque names and authenticated ciphertext only"
+
+step "Send only the next delta"
+printf 'export const machine = "laptop delta";\n' > "$LAPTOP/app.js"
+laptop sync --push
+desktop sync --pull
+cmp "$LAPTOP/app.js" "$DESKTOP/app.js" || fail "delta did not fast-forward"
+ok "desktop fast-forwarded without retransmitting the full workspace"
+
+step "Preserve simultaneous offline work instead of overwriting it"
+printf 'desktop offline idea\n' > "$DESKTOP/notes.txt"
+printf 'export const machine = "new laptop branch";\n' > "$LAPTOP/app.js"
+laptop sync --push
+if desktop sync --pull; then
+ fail "divergent desktop work should not be overwritten"
+fi
+grep -q 'desktop offline idea' "$DESKTOP/notes.txt" \
+ && ok "desktop sibling was preserved byte-for-byte" \
+ || fail "desktop work was lost"
+grep -q 'new laptop branch' "$LAPTOP/app.js" \
+ && ok "laptop sibling remains independently available" \
+ || fail "laptop work was lost"
+
+printf '\n%bDemo complete.%b Machines and encrypted remote retained at %s\n' \
+ "$green" "$reset" "$DEMO_ROOT"
diff --git a/engine/docs/README.md b/engine/docs/README.md
new file mode 100644
index 0000000000..c0aa661f9e
--- /dev/null
+++ b/engine/docs/README.md
@@ -0,0 +1,17 @@
+# furrow Documentation
+
+Start with the repository [README](../README.md) for installation, daily use,
+and two-machine sync.
+
+## Performance
+
+[Performance](performance.md) — reproducible benchmark methodology, current
+measurements, and regression gates.
+
+## Repository Notes
+
+- UI dependency notices remain at
+ [ui/THIRD_PARTY_NOTICES.md](../ui/THIRD_PARTY_NOTICES.md) beside the embedded
+ assets they describe.
+- Runnable workflows live in [`demo/`](../demo/); benchmark code lives in
+ [`benches/`](../benches/).
diff --git a/engine/docs/performance.md b/engine/docs/performance.md
new file mode 100644
index 0000000000..bb557fa129
--- /dev/null
+++ b/engine/docs/performance.md
@@ -0,0 +1,79 @@
+# furrow Performance Baselines
+
+This document records reproducible measurements, not extrapolated product claims. The harness is `benches/engine.rs`; every sample runs in a fresh subprocess so CPU and peak RSS are isolated from other scenarios.
+
+## Run It
+
+```bash
+cargo bench --bench engine
+FURROW_BENCH_ENFORCE=1 cargo bench --bench engine
+FURROW_BENCH_PROFILE=reference cargo bench --bench engine
+```
+
+The default developer profile uses three samples, 5,000 files, 100 changed files, a 128 MiB generated stream, a 32 MiB warm dependency, 721 snapshots spread over six months, and 1,000 tree/ref lookups. CI uses one smaller enforced smoke sample on macOS and Linux. The `reference` profile uses five samples, 1,000,000 files, a 1 GiB stream, and 17,281 historical snapshots.
+
+Peak RSS comes from `getrusage(RUSAGE_SELF)`. CPU is user plus system time consumed inside the measured operation. Fixture construction, Git initialization, and baseline capture are outside wall/CPU timing but remain included in the subprocess peak-RSS measurement.
+
+## Baseline: 2026-07-09
+
+Hardware: Apple arm64 `Mac15,10`, APFS, macOS 26.5.1. Rust release profile uses thin LTO, one codegen unit, and `opt-level=3`.
+
+| Scenario | Dataset | Median | p95 | CPU p95 | Peak RSS | Minimum rate |
+|---|---:|---:|---:|---:|---:|---:|
+| Chunk + BLAKE3 | 128 MiB | 240.9 ms | 244.8 ms | 239.3 ms | 7.8 MiB | 522.9 MiB/s |
+| Paged tree diff | 5k entries, one change, 1k runs | 1.371 s | 1.378 s | 1.347 s | 9.7 MiB | 1.38 ms/run |
+| Reverse ref lookup | 721 refs, 20 returned, 1k runs | 176.8 ms | 188.8 ms | 181.7 ms | 6.4 MiB | 0.189 ms/read |
+| Cold seal | 5k files | 967.1 ms | 1.065 s | 778.1 ms | 12.8 MiB | 4,693 files/s |
+| Delta seal | 100 of 5k files | 15.3 ms | 16.1 ms | 2.1 ms | 13.1 MiB | 6,202 files/s |
+| Full-state fork | 5k files + 32 MiB warm cache | 727.7 ms | 751.4 ms | 621.7 ms | 18.3 MiB | 6,693 entries/s |
+| Five warm universes | 5 × (5k files + shared 32 MiB cache) | 1.242 s | 1.270 s | 837.9 ms | 18.6 MiB | 3.94 universes/s |
+| Conflict radar | 5 forks × 100 overlapping paths | 543.6 ms | 548.1 ms | 524.1 ms | 18.0 MiB | 912 dirty memberships/s |
+| Retention + exact GC | 721 snapshots / six months | 78.0 ms | 87.8 ms | 60.2 ms | 9.1 MiB | 8,216 snapshots/s |
+
+The universe row covers five verified CoW materializations plus five concurrent process launches through the macOS sibling-directory driver. It measures startup cost, not page-cache sharing.
+
+The radar row is a cold family-index reconciliation from five independently sealed heads. Its p95 no-change refresh was 52.5 ms. Dirty scopes and conflict membership stay in SQLite; the process never constructs a repository-sized cross-fork map.
+
+The fork's p95 inner atomic hierarchy clone was 133 ms. The remaining time is bounded metadata/cache proof, path-index backup, and durable publication.
+
+## Optimization Evidence
+
+The same 20,000-file + 32 MiB fork scenario was measured while optimizing the macOS path:
+
+| Implementation | Wall | Inner clone | Peak RSS |
+|---|---:|---:|---:|
+| Per-file clone + full destination recapture | 8.822 s | 3.572 s | 33.8 MiB |
+| Atomic recursive clone + serial proof | 2.323 s | 425 ms | 18.5 MiB |
+
+This is a 3.8x wall-time improvement and a 45% peak-RSS reduction. The fast path uses one atomic recursive `clonefile`, repairs directory metadata, rejects hard-link/special-file cases to the fidelity-preserving fallback, proves every indexed path against capture metadata and cached blob identity, rejects extra/missing paths, and then reuses the already-durable base DAG. Policy-excluded trees use the conservative verifier.
+
+## Gates And Gaps
+
+`FURROW_BENCH_ENFORCE=1` currently requires at least 50 MiB/s chunk throughput, a 2 s delta seal ceiling, at most 100 ms per paged-tree diff, at most 5 ms per indexed ref read, tier-aware fork and multi-universe ceilings, a 10 s conflict-radar ceiling, a 10 s GC ceiling, and at most 512 MiB peak RSS per isolated scenario.
+
+Still unproven and therefore not claimed as achieved:
+
+- The full 1M-file reference profile on both declared reference machines.
+- Idle watcher `<1% CPU / <=150 MiB RSS` across 10 repos and 2M entries.
+- The complete Btrfs/ZFS/overlayfs/APFS/XFS platform matrix.
+- Ten-universe physical-memory/page-cache sharing. The harness measures startup wall time, CPU, and process RSS, but the `~1.0x` shared-page-cache goal remains unclaimed until resident pages are measured on Linux CoW filesystems.
+- Generic flat-directory Merkle diff is proportional to root page count; agent delta sealing avoids that path through the disk-backed changed-path index.
+- Fork verification remains O(entries) without a true immutable filesystem snapshot, even when hierarchy creation is atomic.
+
+## Warm SSH Session Smoke Baseline: 2026-07-10
+
+The CLI integration fixture runs two independent repositories and object stores
+through the real framed SSH-helper protocol, using a local wrapper in place of
+network transport. A peer publishes a small changed snapshot while the other
+side is blocked on the persistent HEAD subscription. The measured publish start
+through notification, pull, and workspace materialization was **255 ms** on the
+development Mac. The same test asserts one SSH helper process across multiple
+reconciliation cycles, a measured notification phase, and
+`reused_connection=true` on the second operation.
+
+This is a local protocol regression baseline, not a WAN result. Run the same
+test under latency/loss shaping before claiming the 100 ms RTT contract:
+
+```bash
+cargo test --test cli persistent_ssh_helper_syncs_independent_stores_over_framed_stdio -- --nocapture
+```
diff --git a/engine/furrow-cli/Cargo.toml b/engine/furrow-cli/Cargo.toml
new file mode 100644
index 0000000000..189cb4106b
--- /dev/null
+++ b/engine/furrow-cli/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "furrow-cli"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+description = "The furrow command line"
+license.workspace = true
+repository.workspace = true
+
+[[bin]]
+name = "furrow"
+path = "src/main.rs"
+
+[dependencies]
+furrow-core = { path = "../furrow-core" }
+furrow-daemon = { path = "../furrow-daemon" }
+anyhow.workspace = true
+clap.workspace = true
+libc.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tempfile.workspace = true
+
+[dev-dependencies]
+furrow-core = { path = "../furrow-core", features = ["fault-injection"] }
+assert_cmd.workspace = true
+predicates.workspace = true
+blake3.workspace = true
+filetime.workspace = true
+hex.workspace = true
+xattr.workspace = true
+rusqlite.workspace = true
diff --git a/engine/furrow-cli/src/exchange.rs b/engine/furrow-cli/src/exchange.rs
new file mode 100644
index 0000000000..0cfe4c7350
--- /dev/null
+++ b/engine/furrow-cli/src/exchange.rs
@@ -0,0 +1,154 @@
+//! The verbs that move a snapshot between stores as sealed frames. Secrets
+//! come from the environment only; no flag carries one.
+
+use clap::Subcommand;
+use furrow::exchange::export::DEFAULT_MAX_FRAME;
+use furrow::exchange::keys::keys_from_env;
+use furrow::exchange::ops::{self, ImportMode};
+use furrow::repository::data_root;
+use serde_json::Value;
+use std::path::{Path, PathBuf};
+
+#[derive(Subcommand)]
+pub enum Exchange {
+ /// Seal what the named store lacks into frames in an outbox.
+ /// Reads FURROW_CELL_KEY and FURROW_DEDUP_SECRET.
+ Export {
+ /// Snapshot ID to send.
+ #[arg(long)]
+ head: String,
+ #[arg(long)]
+ outbox: PathBuf,
+ /// Ledger name: 16 lowercase hex characters naming the destination store.
+ #[arg(long)]
+ ledger: String,
+ #[arg(long, default_value_t = DEFAULT_MAX_FRAME)]
+ max_frame: usize,
+ },
+ /// Record uploaded frames in the ledger and delete them.
+ Published {
+ #[arg(long)]
+ ledger: String,
+ #[arg(required = true)]
+ frames: Vec,
+ },
+ /// List the remote ids still needed to complete a snapshot locally.
+ /// Reads FURROW_CELL_KEY and FURROW_DEDUP_SECRET.
+ Want {
+ #[arg(long)]
+ head: String,
+ },
+ /// Verify and store the objects in an inbox, one file per remote id.
+ /// Reads FURROW_CELL_KEY and FURROW_DEDUP_SECRET.
+ Import {
+ #[arg(long)]
+ head: String,
+ #[arg(long)]
+ inbox: PathBuf,
+ /// Ledger name of the store the objects came from: they are recorded
+ /// there as published, so an export never sends them back.
+ #[arg(long)]
+ ledger: String,
+ /// Priming mode: an inbox file no pass wanted is deleted rather than
+ /// an error, because the caller knows the inbox holds more than this
+ /// head wants.
+ #[arg(long)]
+ primed: bool,
+ /// Streaming mode: store what the walk wants from what has arrived and
+ /// leave every other file in place, for an inbox still filling.
+ #[arg(long, conflicts_with = "primed")]
+ partial: bool,
+ },
+ /// Tell the engine that the folder it restored into `--from` was moved to
+ /// the --repo path, so the tree keeps its history there.
+ Rebind {
+ #[arg(long)]
+ from: PathBuf,
+ },
+ /// Restore a snapshot held in the store into the --repo folder.
+ Materialize {
+ #[arg(long)]
+ head: String,
+ /// Put the sealed `.cell/` entry in this directory instead of the tree.
+ #[arg(long, value_name = "DIR")]
+ cell_dir: Option,
+ },
+}
+
+pub fn run(verb: Exchange, repo: &Path, json: bool) -> anyhow::Result<()> {
+ let ok = execute(verb, repo)?;
+ if json {
+ println!("{ok}");
+ } else {
+ println!("{}", describe(&ok));
+ }
+ Ok(())
+}
+
+fn execute(verb: Exchange, repo: &Path) -> anyhow::Result {
+ let data_dir = data_root()?;
+ match verb {
+ Exchange::Export {
+ head,
+ outbox,
+ ledger,
+ max_frame,
+ } => ops::export(
+ &data_dir,
+ &keys_from_env()?,
+ &head,
+ &ledger,
+ &outbox,
+ max_frame,
+ ),
+ Exchange::Published { ledger, frames } => {
+ ops::record_published(&data_dir, &ledger, &frames)
+ }
+ Exchange::Want { head } => ops::wanted(&data_dir, &keys_from_env()?, &head),
+ Exchange::Import {
+ head,
+ inbox,
+ ledger,
+ primed,
+ partial,
+ } => ops::import(
+ &data_dir,
+ &keys_from_env()?,
+ &head,
+ &ledger,
+ &inbox,
+ ImportMode::from_flags(primed, partial)?,
+ ),
+ Exchange::Rebind { from } => ops::rebind(&data_dir, &from, repo),
+ Exchange::Materialize { head, cell_dir } => {
+ ops::restore_head(&data_dir, repo, cell_dir, &head)
+ }
+ }
+}
+
+/// One line for a person; scripts use `--json`.
+fn describe(ok: &Value) -> String {
+ if let Some(frames) = ok["frames"].as_array() {
+ return format!(
+ "Exported {} objects in {} frames",
+ ok["objects"],
+ frames.len()
+ );
+ }
+ if let Some(want) = ok["want"].as_array() {
+ return format!("{} objects wanted", want.len());
+ }
+ if let Some(recorded) = ok.get("recorded") {
+ return format!("Recorded {recorded} objects");
+ }
+ if let Some(imported) = ok.get("imported") {
+ return format!("Imported {imported} objects");
+ }
+ if let Some(workspace) = ok["workspace"].as_str() {
+ return format!("Workspace {workspace} follows its folder");
+ }
+ format!(
+ "Restored snapshot {}",
+ ok["snapshot"].as_str().unwrap_or_default()
+ )
+}
diff --git a/engine/furrow-cli/src/main.rs b/engine/furrow-cli/src/main.rs
new file mode 100644
index 0000000000..a146ed307d
--- /dev/null
+++ b/engine/furrow-cli/src/main.rs
@@ -0,0 +1,2412 @@
+use anyhow::Context;
+use clap::{Parser, Subcommand};
+use furrow::model::{id_hex, SnapshotTrigger};
+use furrow::{FurrowRepository, SealOptions, SyncDisposition, SyncFollowOutcome};
+use std::ffi::OsString;
+use std::fs;
+use std::io::{self, IsTerminal, Read, Write};
+use std::os::unix::fs::PermissionsExt;
+use std::path::PathBuf;
+
+mod exchange;
+
+#[derive(Parser)]
+#[command(
+ name = "furrow",
+ version,
+ about = "Undo everything between Git commits"
+)]
+struct Cli {
+ #[arg(long, global = true, default_value = ".")]
+ repo: PathBuf,
+ #[arg(long, global = true)]
+ json: bool,
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Project first-capture cost after policy and existing-store deduplication.
+ Estimate,
+ /// Attach a repository and create its first complete snapshot.
+ Watch {
+ /// Keep running and seal after filesystem write quiescence.
+ #[arg(long)]
+ foreground: bool,
+ /// Attach and snapshot without leaving a background watcher running.
+ #[arg(long, conflicts_with = "foreground")]
+ no_daemon: bool,
+ #[arg(long, default_value_t = 500)]
+ debounce_ms: u64,
+ },
+ #[command(name = "__daemon", hide = true)]
+ Daemon {
+ #[arg(long, default_value_t = 500)]
+ debounce_ms: u64,
+ },
+ /// Serve seal, restore and log requests on a unix socket, keeping each
+ /// store's repository open between requests.
+ Serve {
+ #[arg(long)]
+ socket: PathBuf,
+ /// Exit after this many minutes with no client.
+ #[arg(long, default_value_t = 10)]
+ idle_minutes: u64,
+ },
+ #[command(flatten)]
+ Exchange(exchange::Exchange),
+ #[command(name = "__remote", hide = true)]
+ RemoteHelper { namespace: String },
+ #[command(name = "__namespace-probe", hide = true)]
+ NamespaceProbe,
+ #[command(name = "__exec-namespace", hide = true)]
+ ExecNamespace {
+ #[arg(long)]
+ source: PathBuf,
+ #[arg(long)]
+ target: PathBuf,
+ #[arg(last = true, required = true)]
+ command: Vec,
+ },
+ /// Create a complete labeled snapshot now.
+ Snap {
+ #[arg(short, long)]
+ message: Option,
+ /// Seal this directory, which lives outside the workspace, as the
+ /// tree's `.cell/` entry.
+ #[arg(long, value_name = "DIR")]
+ cell_dir: Option,
+ },
+ /// List recent workspace snapshots.
+ Timeline {
+ #[arg(short, long, default_value_t = 20)]
+ limit: usize,
+ },
+ /// Keep a snapshot exact across timeline thinning and garbage collection.
+ Pin { snapshot: String },
+ /// Return a pinned snapshot to the normal retention policy.
+ Unpin { snapshot: String },
+ /// Inspect path-level changes in a fork or since a snapshot.
+ Diff {
+ /// Fork name, full snapshot ID, or snapshot prefix.
+ target: String,
+ },
+ /// Preview or restore a previous workspace snapshot.
+ Rewind {
+ snapshot: String,
+ #[arg(long = "paths")]
+ paths: Vec,
+ #[arg(long)]
+ dry_run: bool,
+ #[arg(long)]
+ yes: bool,
+ /// Restore SQLite databases from their logically consistent backup image.
+ #[arg(long)]
+ sqlite_consistent: bool,
+ /// Put the sealed `.cell/` entry back in this directory instead of the workspace.
+ #[arg(long, value_name = "DIR")]
+ cell_dir: Option,
+ },
+ /// Show workspace protection, store health, and optional fidelity guarantees.
+ Status {
+ /// Enumerate what rewind preserves exactly, approximately, or not at all.
+ #[arg(long)]
+ fidelity: bool,
+ },
+ /// Open the local Mission Control workspace dashboard.
+ Ui {
+ /// Loopback port; 0 selects an available ephemeral port.
+ #[arg(long, default_value_t = 0)]
+ port: u16,
+ /// Print the URL without launching a browser.
+ #[arg(long)]
+ no_open: bool,
+ /// Fixed verification command allowed for UI merge actions.
+ #[arg(long)]
+ merge_check: Option,
+ },
+ /// Create an isolated full-state workspace, optionally running a command inside it.
+ Fork {
+ /// Stable name used by `furrow forks` and as the default directory name.
+ name: Option,
+ /// Destination directory. Defaults to .furrow-forks/ beside the repository.
+ #[arg(long)]
+ destination: Option,
+ /// Materialize from this stored snapshot instead of the live workspace;
+ /// it also becomes the fork's merge base.
+ #[arg(long)]
+ at: Option,
+ /// Command to run inside the completed fork, supplied after `--`.
+ #[arg(last = true)]
+ command: Vec,
+ },
+ /// List full-state workspace forks created from this repository.
+ Forks,
+ /// Stream durable machine-readable workspace events as NDJSON.
+ #[command(hide = true)]
+ Events {
+ /// Exclusive event cursor returned by an earlier line.
+ #[arg(long)]
+ after: Option,
+ #[arg(long, default_value_t = 100)]
+ limit: usize,
+ #[arg(long)]
+ follow: bool,
+ #[arg(long, default_value_t = 250)]
+ interval_ms: u64,
+ },
+ /// Stream newly sealed snapshots from a sibling fork.
+ WatchFork {
+ name: String,
+ #[arg(long)]
+ after: Option,
+ #[arg(long)]
+ once: bool,
+ #[arg(long, default_value_t = 250)]
+ interval_ms: u64,
+ },
+ /// Remove a completed fork and detach its timeline.
+ #[command(name = "fork-rm")]
+ ForkRemove {
+ name: String,
+ /// Forget the fork record but leave its directory and timeline intact.
+ #[arg(long)]
+ keep_files: bool,
+ },
+ /// Claim a path glob advisory lease for an agent or workspace.
+ Claim {
+ pattern: String,
+ #[arg(long)]
+ owner: Option,
+ #[arg(long, default_value_t = 3600)]
+ ttl_seconds: u64,
+ },
+ /// List active advisory path claims in this workspace family.
+ Claims,
+ /// Release a claim by ID or exact pattern.
+ Release {
+ claim: String,
+ #[arg(long)]
+ owner: Option,
+ },
+ /// Read and write the eagerly replicated coordination directory.
+ Coord {
+ #[command(subcommand)]
+ command: CoordCommand,
+ },
+ /// Install or execute vendor-neutral agent turn-boundary hooks.
+ Hook {
+ #[command(subcommand)]
+ command: HookCommand,
+ },
+ /// Run any agent or command inside a new isolated full-state fork.
+ Run {
+ name: String,
+ #[arg(long)]
+ destination: Option,
+ /// Materialize from this stored snapshot instead of the live workspace;
+ /// it also becomes the fork's merge base.
+ #[arg(long)]
+ at: Option,
+ #[arg(last = true, required = true)]
+ command: Vec,
+ },
+ /// Run one or more commands in isolated, whole-workspace universes.
+ Exec {
+ /// Stable fork name. May only be used for a single universe.
+ #[arg(long)]
+ fork: Option,
+ /// Number of universes to create and run concurrently.
+ #[arg(short = 'n', default_value_t = 1)]
+ count: usize,
+ /// Materialize every universe from this stored snapshot instead of
+ /// the live workspace; it also becomes each fork's merge base.
+ #[arg(long)]
+ at: Option,
+ /// Print the platform driver, paths, ports, and fork costs without executing.
+ #[arg(long)]
+ plan: bool,
+ #[arg(last = true)]
+ command: Vec,
+ },
+ /// Run a command with exact before/after restore points in the current workspace.
+ #[command(name = "try")]
+ Attempt {
+ /// Human label recorded on both restore points.
+ #[arg(short, long)]
+ message: Option,
+ #[arg(last = true, required = true)]
+ command: Vec,
+ },
+ /// Find and reversibly remove dependency and build caches.
+ Shrink {
+ /// Remove the reported candidates after sealing a complete restore point.
+ #[arg(long)]
+ yes: bool,
+ /// Include an additional repository-relative path.
+ #[arg(long = "path")]
+ paths: Vec,
+ },
+ /// Find the first retained snapshot where a check starts failing.
+ Bisect {
+ /// Known passing snapshot; must be paired with --bad.
+ #[arg(long)]
+ good: Option,
+ /// Known failing snapshot; must be paired with --good.
+ #[arg(long)]
+ bad: Option,
+ /// Maximum number of recent snapshots in the search window.
+ #[arg(long, default_value_t = 100)]
+ limit: usize,
+ #[arg(last = true, required = true)]
+ command: Vec,
+ },
+ /// Three-way merge a fork, or a stored snapshot, after verifying the
+ /// result in a scratch workspace.
+ Merge {
+ /// Fork name to merge from. Mutually exclusive with --snapshot.
+ #[arg(conflicts_with = "snapshot", required_unless_present = "snapshot")]
+ fork: Option,
+ /// Merge from a stored snapshot ID (for example one fetched with
+ /// `furrow sync --pull`) instead of a fork.
+ #[arg(long, conflicts_with = "fork")]
+ snapshot: Option,
+ /// Explicit merge base snapshot ID. Only valid with --snapshot;
+ /// required when its ancestry does not unambiguously resolve one
+ /// against this workspace's own lineage.
+ #[arg(long, requires = "snapshot")]
+ base: Option,
+ /// Project verification command executed through /bin/sh in the scratch workspace.
+ #[arg(long)]
+ check: Option,
+ /// Plan and report changes/conflicts without materializing or checking them.
+ #[arg(long)]
+ dry_run: bool,
+ },
+ /// Stop watching this repository.
+ Forget {
+ #[arg(long)]
+ purge: bool,
+ },
+ /// Reclaim objects unreachable from every retained workspace timeline.
+ Gc {
+ /// Report what would be reclaimed without changing the store.
+ #[arg(long)]
+ dry_run: bool,
+ },
+ /// Inspect or configure the global content-store disk budget.
+ Budget {
+ /// Maximum physical pack size, for example 20GiB or 500MiB.
+ #[arg(long)]
+ max: Option,
+ /// Minimum filesystem free space to preserve.
+ #[arg(long = "reserve-free")]
+ reserve_free: Option,
+ },
+ /// Pair with an encrypted directory or direct ssh://user@host remote.
+ Pair {
+ remote: PathBuf,
+ /// Shared remote workspace name. Use the same value on every machine.
+ #[arg(long)]
+ name: Option,
+ /// Existing 64-character pairing key from another machine.
+ #[arg(long)]
+ key: Option,
+ },
+ /// Configure the encrypted transport for this workspace.
+ Remote {
+ #[command(subcommand)]
+ command: RemoteCommand,
+ },
+ /// Materialize a complete workspace from an encrypted network remote.
+ Clone {
+ /// Workspace URL printed by `furrow remote add`.
+ remote: String,
+ /// New destination directory. Defaults to the remote workspace name.
+ destination: Option,
+ /// Existing 64-character recovery key.
+ #[arg(long, env = "FURROW_RECOVERY_KEY")]
+ key: String,
+ /// Attach without starting the background watcher.
+ #[arg(long)]
+ no_watch: bool,
+ },
+ /// Transfer complete encrypted working-state snapshots through the paired remote.
+ Sync {
+ #[arg(long, conflicts_with_all = ["pull", "follow"])]
+ push: bool,
+ #[arg(long, conflicts_with_all = ["push", "follow"])]
+ pull: bool,
+ /// Continuously reconcile sealed local state and the encrypted remote.
+ #[arg(long, conflicts_with_all = ["push", "pull", "takeover", "bootstrap"])]
+ follow: bool,
+ /// Bucket polling interval and live-session subscription fallback.
+ #[arg(long, default_value_t = 5, requires = "follow")]
+ poll_seconds: u64,
+ /// Explicitly take the single-writer lease from another machine.
+ #[arg(long, requires = "push")]
+ takeover: bool,
+ /// Replace this machine's initial state with the remote state, reversibly.
+ #[arg(long, requires = "pull")]
+ bootstrap: bool,
+ /// Publish or follow an independent named ref instead of the
+ /// default HEAD, so concurrent publishers to the same remote never
+ /// contend. Omit to use today's single, well-known HEAD.
+ #[arg(long = "ref")]
+ ref_name: Option,
+ /// Print transport phase timings to stderr.
+ #[arg(long)]
+ timings: bool,
+ },
+ /// Run a headless job at a remote input snapshot and publish the result.
+ ///
+ /// Pulls `--ref` from the remote into the local store, materializes an
+ /// isolated fork at that snapshot, runs the command after `--` inside it,
+ /// then seals and pushes the resulting state to `--result-ref`, recording
+ /// the input snapshot as its merge base. The process exit code mirrors the
+ /// command; the fork is removed unless `--keep`.
+ Work {
+ /// Remote directory, ssh://, or s3:// spec, as accepted by `pair`.
+ #[arg(long)]
+ remote: PathBuf,
+ /// Shared remote workspace name. Must match the publisher's `--name`.
+ #[arg(long)]
+ name: Option,
+ /// 64-character recovery key for the shared remote workspace.
+ #[arg(long, env = "FURROW_RECOVERY_KEY")]
+ key: String,
+ /// Named input ref to pull the job's starting snapshot from.
+ #[arg(long = "ref")]
+ input_ref: String,
+ /// Named output ref to publish the sealed result snapshot to.
+ #[arg(long = "result-ref")]
+ result_ref: String,
+ /// Keep the fork workspace and its checkout instead of removing it.
+ #[arg(long)]
+ keep: bool,
+ #[arg(last = true, required = true)]
+ command: Vec,
+ },
+ /// Serve furrow tools to coding agents over MCP stdio.
+ Mcp,
+}
+
+#[derive(Subcommand)]
+enum RemoteCommand {
+ /// Add an encrypted directory, SSH, or S3-compatible remote.
+ Add {
+ remote: PathBuf,
+ /// Shared workspace name. Defaults to the source folder name.
+ #[arg(long)]
+ name: Option,
+ /// Existing 64-character recovery key.
+ #[arg(long)]
+ key: Option,
+ },
+}
+
+#[derive(Subcommand)]
+enum CoordCommand {
+ /// Write a coordination value and propagate it to live sibling forks.
+ Write {
+ path: PathBuf,
+ #[arg(long, conflicts_with = "file")]
+ value: Option,
+ #[arg(long, conflicts_with = "value")]
+ file: Option,
+ #[arg(long)]
+ owner: Option,
+ },
+ /// Read a coordination value from this workspace.
+ Read { path: PathBuf },
+ /// List coordination values without reading their contents.
+ List,
+ /// Remove a coordination value and propagate a durable tombstone.
+ Remove {
+ path: PathBuf,
+ #[arg(long)]
+ owner: Option,
+ },
+}
+
+#[derive(Subcommand)]
+enum HookCommand {
+ /// Install executable pre-turn, post-tool, and turn-end adapters.
+ Install,
+ /// Seal immediately before an agent turn begins.
+ PreTurn {
+ #[arg(long)]
+ agent: Option,
+ #[arg(long)]
+ turn: Option,
+ #[command(flatten)]
+ seal: SealArgs,
+ },
+ /// Seal after an agent tool invocation completes.
+ PostTool {
+ #[arg(long)]
+ agent: Option,
+ #[arg(long)]
+ turn: Option,
+ #[arg(long)]
+ tool: Option,
+ #[command(flatten)]
+ seal: SealArgs,
+ },
+ /// Seal when an agent turn ends.
+ TurnEnd {
+ #[arg(long)]
+ agent: Option,
+ #[arg(long)]
+ turn: Option,
+ #[command(flatten)]
+ seal: SealArgs,
+ },
+}
+
+/// How a hook seal finds what to capture.
+#[derive(clap::Args, Default)]
+struct SealArgs {
+ /// A path that changed since the previous seal; repeatable. With any
+ /// changed-path flag only those paths are visited; without one the whole
+ /// tree is walked and the stat cache spares unchanged files.
+ #[arg(long = "changed", value_name = "PATH")]
+ changed: Vec,
+ /// Read NUL-separated changed paths from standard input. An empty input
+ /// means nothing changed.
+ #[arg(long)]
+ changed_stdin: bool,
+ /// Seal this directory, which lives outside the workspace, as the tree's
+ /// `.cell/` entry.
+ #[arg(long, value_name = "DIR")]
+ cell_dir: Option,
+}
+
+impl SealArgs {
+ fn options(self) -> anyhow::Result {
+ let listed = !self.changed.is_empty() || self.changed_stdin;
+ let mut changed = self.changed;
+ if self.changed_stdin {
+ changed.extend(read_changed_stdin()?);
+ }
+ Ok(SealOptions {
+ changed: listed.then_some(changed),
+ overlay: self.cell_dir,
+ })
+ }
+}
+
+fn read_changed_stdin() -> anyhow::Result> {
+ use std::os::unix::ffi::OsStrExt;
+ let mut input = Vec::new();
+ io::stdin().read_to_end(&mut input)?;
+ Ok(input
+ .split(|byte| *byte == 0)
+ .filter(|path| !path.is_empty())
+ .map(|path| PathBuf::from(std::ffi::OsStr::from_bytes(path)))
+ .collect())
+}
+
+fn main() -> anyhow::Result<()> {
+ let cli = Cli::parse();
+ match cli.command {
+ Command::Estimate => {
+ let estimate = FurrowRepository::estimate(&cli.repo)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&estimate)?);
+ } else {
+ println!(
+ "{} files, {} directories, {} logical, {} physical",
+ estimate.files,
+ estimate.directories,
+ human_bytes(estimate.logical_bytes),
+ human_bytes(estimate.physical_bytes)
+ );
+ println!(
+ "{} unique chunks; {} already in the store; {} projected new chunk payload",
+ estimate.unique_chunks,
+ human_bytes(estimate.deduplicated_chunk_bytes),
+ human_bytes(estimate.projected_new_chunk_bytes)
+ );
+ println!(
+ "{} policy rule(s), {} excluded subtree root(s)",
+ estimate.policy_rules, estimate.excluded_subtrees
+ );
+ }
+ }
+ Command::Watch {
+ foreground,
+ no_daemon,
+ debounce_ms,
+ } => {
+ let (repository, id) = FurrowRepository::watch(&cli.repo)?;
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "workspace": repository.root(),
+ "snapshot": id_hex(&id),
+ "store": repository.store_root(),
+ })
+ );
+ } else {
+ println!("Protected {}", repository.root().display());
+ println!("Snapshot {}", id_hex(&id));
+ println!("Store {}", repository.store_root().display());
+ }
+ if foreground {
+ furrow::watcher::run(
+ repository,
+ std::time::Duration::from_millis(debounce_ms.max(10)),
+ )?;
+ } else if !no_daemon && std::env::var_os("FURROW_NO_DAEMON").is_none() {
+ spawn_background_watcher(&repository, debounce_ms.max(10))?;
+ }
+ }
+ Command::Daemon { debounce_ms } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ furrow::watcher::run(
+ repository,
+ std::time::Duration::from_millis(debounce_ms.max(10)),
+ )?;
+ }
+ Command::Serve {
+ socket,
+ idle_minutes,
+ } => serve(socket, idle_minutes)?,
+ Command::Exchange(verb) => exchange::run(verb, &cli.repo, cli.json)?,
+ Command::RemoteHelper { namespace } => {
+ furrow::remote::serve(&namespace)?;
+ }
+ Command::NamespaceProbe => {
+ furrow::universe::probe_namespace_helper()?;
+ }
+ Command::ExecNamespace {
+ source,
+ target,
+ command,
+ } => {
+ furrow::universe::exec_linux_namespace(&source, &target, &command)?;
+ }
+ Command::Snap { message, cell_dir } => {
+ let mut repository = FurrowRepository::open_composed(&cli.repo, cell_dir)?;
+ let id = repository.snapshot(message, SnapshotTrigger::Manual)?;
+ if cli.json {
+ println!("{}", serde_json::json!({"snapshot": id_hex(&id)}));
+ } else {
+ println!("Snapshot {}", id_hex(&id));
+ }
+ }
+ Command::Timeline { limit } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let timeline = repository.timeline(limit)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&timeline)?);
+ } else {
+ for snapshot in timeline {
+ println!(
+ "{} {} {:<10} {:<7} {}",
+ &snapshot.id[..12],
+ snapshot.sealed_at,
+ snapshot.trigger,
+ snapshot.materialization.grade,
+ snapshot.label.unwrap_or_default()
+ );
+ }
+ }
+ }
+ Command::Pin { snapshot } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let id = repository.resolve_snapshot(&snapshot)?;
+ let changed = repository.pin(&id)?;
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({"snapshot": id_hex(&id), "pinned": true, "changed": changed})
+ );
+ } else if changed {
+ println!("Pinned {}", id_hex(&id));
+ } else {
+ println!("Already pinned {}", id_hex(&id));
+ }
+ }
+ Command::Unpin { snapshot } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let id = repository.resolve_snapshot(&snapshot)?;
+ let changed = repository.unpin(&id)?;
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({"snapshot": id_hex(&id), "pinned": false, "changed": changed})
+ );
+ } else if changed {
+ println!("Unpinned {}", id_hex(&id));
+ } else {
+ println!("Snapshot was not pinned {}", id_hex(&id));
+ }
+ }
+ Command::Diff { target } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let diff = repository.diff(&target)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&diff)?);
+ } else {
+ println!(
+ "Diff {} ({} -> {})",
+ diff.target,
+ &diff.base_snapshot[..12],
+ &diff.target_snapshot[..12]
+ );
+ if diff.changes.is_empty() {
+ println!("No changes");
+ } else {
+ for change in diff.changes {
+ println!(" {:<7} {}", change.action, change.path);
+ }
+ }
+ }
+ }
+ Command::Rewind {
+ snapshot,
+ paths,
+ dry_run,
+ yes,
+ sqlite_consistent,
+ cell_dir,
+ } => {
+ let mut repository = FurrowRepository::open_composed(&cli.repo, cell_dir)?;
+ let target = repository.resolve_snapshot(&snapshot)?;
+ let plan = repository.plan_rewind(&target, &paths)?;
+ if cli.json || dry_run {
+ println!("{}", serde_json::to_string_pretty(&plan)?);
+ } else {
+ print_plan(&plan);
+ }
+ if dry_run || plan.changes.is_empty() {
+ return Ok(());
+ }
+ if !yes {
+ anyhow::ensure!(
+ io::stdin().is_terminal(),
+ "non-interactive rewind requires --yes"
+ );
+ print!("Apply this rewind? [y/N] ");
+ io::stdout().flush()?;
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ anyhow::ensure!(answer.trim().eq_ignore_ascii_case("y"), "rewind cancelled");
+ }
+ let (pre, applied) = repository
+ .rewind(&target, &paths, sqlite_consistent)
+ .context("rewind failed")?;
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "restored": applied.target,
+ "pre_rewind_snapshot": id_hex(&pre),
+ "changes": applied.changes.len(),
+ })
+ );
+ } else {
+ println!("Restored {} paths", applied.changes.len());
+ println!("Undo snapshot: {}", id_hex(&pre));
+ }
+ }
+ Command::Status { fidelity } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let status = repository.status()?;
+ if cli.json {
+ if fidelity {
+ println!(
+ "{}",
+ serde_json::json!({"status": status, "fidelity": repository.fidelity()?})
+ );
+ } else {
+ println!("{}", serde_json::to_string_pretty(&status)?);
+ }
+ } else {
+ println!("Workspace: {}", status.workspace.display());
+ println!("Store: {}", status.store.display());
+ if let Some(head) = status.head {
+ println!("Head: {head}");
+ }
+ println!("Snapshots: {}", status.snapshots);
+ println!("Objects: {}", status.objects);
+ println!("Pack data: {} bytes", status.physical_bytes);
+ println!(
+ "Budget: {} / {} ({} free; {})",
+ human_bytes(status.budget.physical_bytes),
+ human_bytes(status.budget.max_store_bytes),
+ human_bytes(status.budget.available_bytes),
+ if status.budget.satisfied {
+ "satisfied"
+ } else {
+ "under pressure"
+ }
+ );
+ println!(
+ "Watcher: {}",
+ if status.watcher_running {
+ "running"
+ } else {
+ "stopped"
+ }
+ );
+ if fidelity {
+ let report = repository.fidelity()?;
+ println!("Fidelity: {} ({})", report.grade, report.platform);
+ for aspect in report.aspects {
+ println!(
+ " {:<28} {:<24} {}",
+ aspect.aspect, aspect.fidelity, aspect.detail
+ );
+ }
+ }
+ }
+ }
+ Command::Ui {
+ port,
+ no_open,
+ merge_check,
+ } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ furrow::ui::run(repository.root(), port, no_open, merge_check, cli.json)?;
+ }
+ Command::Fork {
+ name,
+ destination,
+ at,
+ command,
+ } => {
+ anyhow::ensure!(
+ !cli.json || command.is_empty(),
+ "--json cannot be combined with a fork command"
+ );
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let name = name.unwrap_or_else(default_fork_name);
+ let destination =
+ destination.unwrap_or_else(|| default_fork_destination(&repository, &name));
+ let plan = match at {
+ Some(at) => {
+ let at = repository.resolve_snapshot(&at)?;
+ repository.prepare_fork_at(&name, &destination, &at)?
+ }
+ None => repository.prepare_fork(&name, &destination)?,
+ };
+ if !cli.json {
+ print_fork_plan(&plan);
+ io::stdout().flush()?;
+ }
+ let summary = repository.materialize_fork(plan.clone())?;
+ if cli.json {
+ println!("{}", serde_json::json!({"plan": plan, "result": summary}));
+ } else {
+ println!("Fork {}", summary.name);
+ println!("Path {}", summary.destination.display());
+ println!(
+ "Tier {} | {} files | {} logical bytes | {} cloned | {} copied | {} ms",
+ summary.tier,
+ summary.files,
+ summary.logical_bytes,
+ summary.cloned_bytes,
+ summary.copied_bytes,
+ summary.elapsed_ms
+ );
+ println!("Base {}", &summary.base_snapshot[..12]);
+ }
+ if let Some((program, arguments)) = command.split_first() {
+ let status = std::process::Command::new(program)
+ .args(arguments)
+ .current_dir(&summary.destination)
+ .env("FURROW_FORK_NAME", &summary.name)
+ .env("FURROW_FORK_BASE", &summary.base_snapshot)
+ .status()
+ .with_context(|| format!("run {:?} in fork", program))?;
+ anyhow::ensure!(status.success(), "fork command exited with {status}");
+ }
+ }
+ Command::Forks => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let forks = repository.forks()?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&forks)?);
+ } else if forks.is_empty() {
+ println!("No forks");
+ } else {
+ for fork in forks {
+ println!(
+ "{:<20} {:<14} {:>8} ms {:>9} {}",
+ fork.name,
+ fork.tier,
+ fork.elapsed_ms,
+ if fork.conflicts == 0 && !fork.radar_stale {
+ "clear".to_owned()
+ } else if fork.conflicts == 0 {
+ "clear/stale".to_owned()
+ } else if fork.radar_stale {
+ format!("{} conflict/stale", fork.conflicts)
+ } else {
+ format!("{} conflict", fork.conflicts)
+ },
+ fork.destination.display()
+ );
+ }
+ }
+ }
+ Command::Events {
+ after,
+ limit,
+ follow,
+ interval_ms,
+ } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let mut cursor = after;
+ loop {
+ let page = repository.events(cursor.as_deref(), limit)?;
+ anyhow::ensure!(
+ page.cursor_found,
+ "event cursor expired; resume from {}",
+ page.earliest_cursor
+ .as_deref()
+ .unwrap_or("the current stream")
+ );
+ for event in &page.events {
+ write_ndjson(event)?;
+ }
+ if let Some(event) = page.events.last() {
+ cursor = Some(event.cursor.clone());
+ }
+ if !follow {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(interval_ms.max(50)));
+ }
+ }
+ Command::WatchFork {
+ name,
+ after,
+ once,
+ interval_ms,
+ } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let mut cursor = after;
+ loop {
+ let updates = repository.fork_updates(&name, cursor.as_deref(), 1000)?;
+ if !updates.cursor_found {
+ eprintln!(
+ "warning: cursor was not in the latest 1000 seals; replaying the retained window"
+ );
+ }
+ if !updates.snapshots.is_empty() {
+ if cli.json {
+ println!("{}", serde_json::to_string(&updates)?);
+ } else {
+ for snapshot in &updates.snapshots {
+ println!(
+ "{} {} {:<12} {}",
+ &snapshot.id[..12],
+ snapshot.sealed_at,
+ snapshot.trigger,
+ snapshot.label.clone().unwrap_or_default()
+ );
+ }
+ }
+ }
+ cursor = updates.head;
+ if once {
+ return Ok(());
+ }
+ std::thread::sleep(std::time::Duration::from_millis(interval_ms.max(50)));
+ }
+ }
+ Command::ForkRemove { name, keep_files } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let removal = repository.remove_fork(&name, keep_files)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&removal)?);
+ } else if removal.files_removed {
+ println!(
+ "Removed fork {} and {}",
+ removal.name,
+ removal.destination.display()
+ );
+ } else {
+ println!(
+ "Forgot fork {}; files remain at {}",
+ removal.name,
+ removal.destination.display()
+ );
+ }
+ }
+ Command::Claim {
+ pattern,
+ owner,
+ ttl_seconds,
+ } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let owner = owner.unwrap_or_else(|| repository.default_claim_owner());
+ let outcome = repository.claim(&pattern, &owner, ttl_seconds)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!(
+ "Claimed {} as {} until {}",
+ outcome.claim.pattern, outcome.claim.owner, outcome.claim.expires_at
+ );
+ println!("Claim {}", outcome.claim.id);
+ println!("Snapshot {}", &outcome.snapshot[..12]);
+ }
+ }
+ Command::Claims => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let claims = repository.claims()?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&claims)?);
+ } else if claims.is_empty() {
+ println!("No active claims");
+ } else {
+ for claim in claims {
+ println!(
+ "{} {:<20} {:<32} expires {}",
+ &claim.id[..12],
+ claim.owner,
+ claim.pattern,
+ claim.expires_at
+ );
+ }
+ }
+ }
+ Command::Release { claim, owner } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let owner = owner.unwrap_or_else(|| repository.default_claim_owner());
+ let outcome = repository.release_claim(&claim, &owner)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!("Released {} claim(s)", outcome.released.len());
+ println!("Snapshot {}", &outcome.snapshot[..12]);
+ }
+ }
+ Command::Coord { command } => match command {
+ CoordCommand::Write {
+ path,
+ value,
+ file,
+ owner,
+ } => {
+ let bytes = match (value, file) {
+ (Some(value), None) => value.into_bytes(),
+ (None, Some(file)) => fs::read(file)?,
+ (None, None) => {
+ let mut bytes = Vec::new();
+ io::stdin().take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
+ bytes
+ }
+ (Some(_), Some(_)) => unreachable!("clap enforces conflicts"),
+ };
+ anyhow::ensure!(bytes.len() <= 1024 * 1024, "coord value exceeds 1 MiB");
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let owner = owner.unwrap_or_else(|| repository.default_claim_owner());
+ let outcome = repository.coord_write(&path, &bytes, &owner)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!(
+ "Wrote coord/{} ({} bytes) to {} workspace(s)",
+ outcome.propagation.path,
+ outcome.propagation.bytes,
+ outcome.propagation.propagated_workspaces
+ );
+ for failure in outcome.propagation.failures {
+ eprintln!(
+ "warning: {}: {}",
+ failure.workspace.display(),
+ failure.error
+ );
+ }
+ println!("Snapshot {}", &outcome.snapshot[..12]);
+ }
+ }
+ CoordCommand::Read { path } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let bytes = repository.coord_read(&path)?;
+ if cli.json {
+ let value = String::from_utf8(bytes)
+ .context("--json coord read requires UTF-8 content")?;
+ println!("{}", serde_json::json!({"path": path, "value": value}));
+ } else {
+ io::stdout().write_all(&bytes)?;
+ }
+ }
+ CoordCommand::List => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let entries = repository.coord_list()?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&entries)?);
+ } else if entries.is_empty() {
+ println!("No coordination values");
+ } else {
+ for entry in entries {
+ println!("{:>8} {}", entry.bytes, entry.path);
+ }
+ }
+ }
+ CoordCommand::Remove { path, owner } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let owner = owner.unwrap_or_else(|| repository.default_claim_owner());
+ let outcome = repository.coord_remove(&path, &owner)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!(
+ "Removed coord/{} from {} workspace(s)",
+ outcome.propagation.path, outcome.propagation.propagated_workspaces
+ );
+ println!("Snapshot {}", &outcome.snapshot[..12]);
+ }
+ }
+ },
+ Command::Hook { command } => match command {
+ HookCommand::Install => {
+ let hooks = install_hook_adapters(&cli.repo)?;
+ let (_, snapshot) = FurrowRepository::attach_and_snapshot(
+ &cli.repo,
+ Some("installed agent hooks".to_owned()),
+ SnapshotTrigger::AgentRun,
+ )?;
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({"hooks": hooks, "snapshot": id_hex(&snapshot)})
+ );
+ } else {
+ for hook in hooks {
+ println!("Installed {}", hook.display());
+ }
+ println!("Snapshot {}", id_hex(&snapshot));
+ }
+ }
+ HookCommand::PreTurn { agent, turn, seal } => {
+ run_hook(&cli.repo, cli.json, "pre-turn", agent, turn, None, seal)?;
+ }
+ HookCommand::PostTool {
+ agent,
+ turn,
+ tool,
+ seal,
+ } => {
+ run_hook(&cli.repo, cli.json, "post-tool", agent, turn, tool, seal)?;
+ }
+ HookCommand::TurnEnd { agent, turn, seal } => {
+ run_hook(&cli.repo, cli.json, "turn-end", agent, turn, None, seal)?;
+ }
+ },
+ Command::Run {
+ name,
+ destination,
+ at,
+ command,
+ } => {
+ anyhow::ensure!(!cli.json, "--json cannot be combined with `furrow run`");
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let destination =
+ destination.unwrap_or_else(|| default_fork_destination(&repository, &name));
+ let plan = match at {
+ Some(at) => {
+ let at = repository.resolve_snapshot(&at)?;
+ repository.prepare_fork_at(&name, &destination, &at)?
+ }
+ None => repository.prepare_fork(&name, &destination)?,
+ };
+ print_fork_plan(&plan);
+ io::stdout().flush()?;
+ let summary = repository.materialize_fork(plan)?;
+ println!(
+ "Running in {} ({}; {} cloned, {} copied)",
+ summary.destination.display(),
+ summary.tier,
+ summary.cloned_bytes,
+ summary.copied_bytes
+ );
+ let (program, arguments) = command
+ .split_first()
+ .context("`furrow run` requires a command after --")?;
+ let status = std::process::Command::new(program)
+ .args(arguments)
+ .current_dir(&summary.destination)
+ .env("FURROW_FORK_NAME", &summary.name)
+ .env("FURROW_FORK_BASE", &summary.base_snapshot)
+ .status()
+ .with_context(|| format!("run {:?} in fork", program))?;
+ let mut fork_repository = FurrowRepository::open(&summary.destination)?;
+ let head = fork_repository.snapshot(
+ Some(format!("command completed in {}", summary.name)),
+ SnapshotTrigger::AgentRun,
+ )?;
+ println!("Fork head {}", &id_hex(&head)[..12]);
+ println!("Source workspace was not modified");
+ println!(
+ "Merge with: furrow merge {} --check ''",
+ summary.name
+ );
+ anyhow::ensure!(status.success(), "fork command exited with {status}");
+ }
+ Command::Exec {
+ fork,
+ count,
+ at,
+ plan,
+ command,
+ } => {
+ execute_universes(&cli.repo, cli.json, fork, count, at, plan, &command)?;
+ }
+ Command::Attempt { message, command } => {
+ anyhow::ensure!(!cli.json, "--json cannot be combined with `furrow try`");
+ let (program, arguments) = command
+ .split_first()
+ .context("`furrow try` requires a command after --")?;
+ let command_name = std::path::Path::new(program)
+ .file_name()
+ .unwrap_or(program.as_os_str())
+ .to_string_lossy();
+ let label = message.unwrap_or_else(|| command_name.into_owned());
+ anyhow::ensure!(!label.trim().is_empty(), "try label cannot be empty");
+ anyhow::ensure!(label.len() <= 256, "try label is limited to 256 bytes");
+ let (mut repository, before) = FurrowRepository::attach_and_snapshot(
+ &cli.repo,
+ Some(format!("before try: {label}")),
+ SnapshotTrigger::AgentRun,
+ )?;
+ eprintln!("Protected {}", id_hex(&before));
+
+ let status = std::process::Command::new(program)
+ .args(arguments)
+ .current_dir(repository.root())
+ .env("FURROW_TRY_SNAPSHOT", id_hex(&before))
+ .status()
+ .with_context(|| format!("run {:?}", program))?;
+ let outcome = status
+ .code()
+ .map_or_else(|| status.to_string(), |code| format!("exit {code}"));
+ let after = repository.snapshot(
+ Some(format!("after try ({outcome}): {label}")),
+ SnapshotTrigger::AgentRun,
+ )?;
+ eprintln!("Result {}", id_hex(&after));
+ eprintln!("Undo with: furrow rewind {}", id_hex(&before));
+ if !status.success() {
+ exit_with_status(status);
+ }
+ }
+ Command::Shrink { yes, paths } => {
+ let plan = furrow::shrink::discover(&cli.repo, &paths)?;
+ if !yes {
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&plan)?);
+ } else {
+ print_shrink_plan(&plan);
+ if !plan.candidates.is_empty() {
+ println!("Preview only. Re-run this command with `--yes` to reclaim them.");
+ }
+ }
+ return Ok(());
+ }
+ if plan.candidates.is_empty() {
+ if cli.json {
+ println!("{}", serde_json::json!({"plan": plan, "changed": false}));
+ } else {
+ println!("No recognized dependency or build caches found");
+ }
+ return Ok(());
+ }
+
+ let store_before = FurrowRepository::global_store_physical_bytes()?;
+ let (mut repository, before) = FurrowRepository::attach_and_snapshot(
+ &cli.repo,
+ Some("before shrink".to_owned()),
+ SnapshotTrigger::Manual,
+ )?;
+ eprintln!("Protected {}", id_hex(&before));
+ let removal = furrow::shrink::apply(repository.root(), &plan);
+ let after =
+ repository.snapshot(Some("after shrink".to_owned()), SnapshotTrigger::Manual)?;
+ let store_after = repository.store_physical_bytes()?;
+ let store_added = store_after.saturating_sub(store_before);
+ let net_reclaimed = plan.total_physical_bytes.saturating_sub(store_added);
+ let net_added = store_added.saturating_sub(plan.total_physical_bytes);
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "plan": plan,
+ "changed": true,
+ "before_snapshot": id_hex(&before),
+ "after_snapshot": id_hex(&after),
+ "estimated_workspace_bytes_removed": plan.total_physical_bytes,
+ "protected_store_bytes_added": store_added,
+ "estimated_net_bytes_reclaimed": net_reclaimed,
+ "estimated_net_bytes_added": net_added,
+ })
+ );
+ } else {
+ print_shrink_plan(&plan);
+ println!(
+ "Removed about {} from the workspace; protected store added {}",
+ human_bytes(plan.total_physical_bytes),
+ human_bytes(store_added)
+ );
+ if net_added == 0 {
+ println!(
+ "Estimated net disk reclaimed: {}",
+ human_bytes(net_reclaimed)
+ );
+ } else {
+ println!(
+ "Estimated net disk increase: {} to keep this cleanup reversible",
+ human_bytes(net_added)
+ );
+ }
+ println!("Undo with: furrow rewind {}", id_hex(&before));
+ }
+ removal?;
+ }
+ Command::Bisect {
+ good,
+ bad,
+ limit,
+ command,
+ } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let outcome = repository.bisect(&command, good.as_deref(), bad.as_deref(), limit)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ for check in &outcome.checks {
+ println!(
+ "{} {:<4} check {:>6} ms fork {:>4} ms {}",
+ &check.snapshot[..12],
+ if check.passed { "pass" } else { "fail" },
+ check.elapsed_ms,
+ check.probe_fork_ms,
+ check.label.clone().unwrap_or_default()
+ );
+ }
+ println!("Last good: {}", outcome.good_snapshot);
+ println!("First bad: {}", outcome.first_bad_snapshot);
+ }
+ }
+ Command::Merge {
+ fork,
+ snapshot,
+ base,
+ check,
+ dry_run,
+ } => {
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ let outcome = if let Some(snapshot) = snapshot {
+ repository.merge_snapshot(&snapshot, base.as_deref(), check.as_deref(), dry_run)?
+ } else {
+ let fork =
+ fork.context("merge requires a fork name or --snapshot ")?;
+ repository.merge(&fork, check.as_deref(), dry_run)?
+ };
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!(
+ "Merge {}: {} changes, {} conflicts",
+ outcome.fork,
+ outcome.changes,
+ outcome.conflicts.len()
+ );
+ for conflict in &outcome.conflicts {
+ println!(
+ " conflict {:<16} {}",
+ conflict.kind,
+ String::from_utf8_lossy(&conflict.path)
+ );
+ }
+ if let Some(snapshot) = &outcome.result_snapshot {
+ println!("Merged snapshot {}", &snapshot[..12]);
+ if let Some(output) = &outcome.check_output {
+ if !output.is_empty() {
+ println!("Check output:\n{output}");
+ }
+ }
+ } else if dry_run && outcome.conflicts.is_empty() {
+ println!("Dry run: source workspace was not changed");
+ }
+ }
+ anyhow::ensure!(
+ outcome.conflicts.is_empty(),
+ "merge stopped with {} conflict(s)",
+ outcome.conflicts.len()
+ );
+ }
+ Command::Forget { purge } => {
+ FurrowRepository::open(&cli.repo)?.forget(purge)?;
+ println!("Repository detached from furrow");
+ }
+ Command::Gc { dry_run } => {
+ let report = FurrowRepository::gc_global(dry_run)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&report)?);
+ } else {
+ println!(
+ "{} objects reachable; {} unreachable",
+ report.reachable_objects, report.unreachable_objects
+ );
+ println!(
+ "{} retained snapshots; {} historical snapshots thinned",
+ report.retained_snapshots, report.thinned_snapshots
+ );
+ println!(
+ "{} bytes {} ({} -> {})",
+ report.reclaimed_bytes,
+ if dry_run { "reclaimable" } else { "reclaimed" },
+ report.physical_bytes_before,
+ report.physical_bytes_after
+ );
+ }
+ }
+ Command::Budget { max, reserve_free } => {
+ let max = max.as_deref().map(parse_byte_size).transpose()?;
+ let reserve_free = reserve_free.as_deref().map(parse_byte_size).transpose()?;
+ let status = FurrowRepository::budget_global(max, reserve_free)?;
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&status)?);
+ } else {
+ println!(
+ "Store {} / {}",
+ human_bytes(status.physical_bytes),
+ human_bytes(status.max_store_bytes)
+ );
+ println!(
+ "Free {} / {} reserved",
+ human_bytes(status.available_bytes),
+ human_bytes(status.reserved_free_bytes)
+ );
+ println!(
+ "Budget {}",
+ if status.satisfied {
+ "satisfied"
+ } else {
+ "cannot be met without deleting protected bytes"
+ }
+ );
+ }
+ }
+ Command::Pair { remote, name, key } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let namespace = name.unwrap_or_else(|| default_sync_name(&repository));
+ let summary = repository.pair(&remote, &namespace, key.as_deref())?;
+ print_pair_summary(&summary, cli.json)?;
+ }
+ Command::Remote {
+ command: RemoteCommand::Add { remote, name, key },
+ } => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ let namespace = name.unwrap_or_else(|| default_sync_name(&repository));
+ let summary = repository.pair(&remote, &namespace, key.as_deref())?;
+ print_pair_summary(&summary, cli.json)?;
+ if !cli.json {
+ println!(
+ "Clone URL {}/{}",
+ summary.remote.trim_end_matches('/'),
+ summary.namespace
+ );
+ }
+ }
+ Command::Clone {
+ remote,
+ destination,
+ key,
+ no_watch,
+ } => {
+ let (transport, namespace) = split_clone_url(&remote)?;
+ let destination = destination.unwrap_or_else(|| PathBuf::from(&namespace));
+ anyhow::ensure!(
+ !destination.exists(),
+ "clone destination already exists: {}",
+ destination.display()
+ );
+ fs::create_dir_all(&destination)?;
+ let result = (|| -> anyhow::Result<(FurrowRepository, furrow::SyncPullOutcome)> {
+ let status = std::process::Command::new("git")
+ .arg("init")
+ .arg("--quiet")
+ .arg("--")
+ .arg(&destination)
+ .status()
+ .context("start git for clone destination")?;
+ anyhow::ensure!(status.success(), "git init failed for clone destination");
+ let (mut repository, _) = FurrowRepository::watch(&destination)?;
+ repository.pair(PathBuf::from(&transport).as_path(), &namespace, Some(&key))?;
+ let outcome = repository
+ .sync_pull(true, None)
+ .context("workspace was not found or the recovery key does not match")?;
+ Ok((repository, outcome))
+ })();
+ let (repository, outcome) = match result {
+ Ok(value) => value,
+ Err(error) => {
+ let _ = fs::remove_dir_all(&destination);
+ return Err(error.context("clone failed; incomplete destination was removed"));
+ }
+ };
+ if !no_watch && std::env::var_os("FURROW_NO_DAEMON").is_none() {
+ spawn_background_watcher(&repository, 500)?;
+ }
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "workspace": repository.root(),
+ "remote": remote,
+ "sync": outcome,
+ })
+ );
+ } else {
+ println!("Cloned {}", repository.root().display());
+ println!("Remote snapshot {}", &outcome.remote_snapshot[..12]);
+ println!(
+ "{} objects fetched ({} bytes); {} reused; {} files materialized",
+ outcome.fetched_objects,
+ outcome.fetched_bytes,
+ outcome.reused_objects,
+ outcome.materialized_files
+ );
+ }
+ }
+ Command::Sync {
+ push,
+ pull,
+ follow,
+ poll_seconds,
+ takeover,
+ bootstrap,
+ ref_name,
+ timings,
+ } => {
+ anyhow::ensure!(
+ push || pull || follow,
+ "choose exactly one of --push, --pull, or --follow"
+ );
+ let ref_name = ref_name.as_deref();
+ let mut repository = FurrowRepository::open(&cli.repo)?;
+ if follow {
+ anyhow::ensure!(poll_seconds > 0, "--poll-seconds must be greater than zero");
+ let mut session = repository.sync_follow_session(ref_name)?;
+ loop {
+ let result = repository.sync_follow_once(&mut session);
+ let session_failed = result.is_err();
+ match result {
+ Ok(SyncFollowOutcome::Idle) => {}
+ Ok(SyncFollowOutcome::Published { report }) => {
+ println!(
+ "Published {} ({} objects, {} bytes)",
+ &report.snapshot[..12],
+ report.uploaded_objects,
+ report.uploaded_bytes
+ );
+ if timings {
+ print_transport_timings(&report.timings);
+ }
+ }
+ Ok(SyncFollowOutcome::Pulled { outcome }) => {
+ println!(
+ "Remote {} {:?}",
+ &outcome.remote_snapshot[..12],
+ outcome.disposition
+ );
+ if outcome.disposition == SyncDisposition::Diverged {
+ eprintln!(
+ "Divergence preserved; follow is paused until it is resolved"
+ );
+ }
+ if timings {
+ print_transport_timings(&outcome.timings);
+ print_apply_timings(&outcome.apply_timings);
+ }
+ }
+ Err(error) => eprintln!("sync follow: {error:#}"),
+ }
+ if session_failed {
+ std::thread::sleep(std::time::Duration::from_secs(poll_seconds));
+ session = repository.sync_follow_session(ref_name)?;
+ continue;
+ }
+ if let Err(error) =
+ session.wait_for_remote_change(std::time::Duration::from_secs(poll_seconds))
+ {
+ eprintln!("sync follow notification: {error:#}");
+ std::thread::sleep(std::time::Duration::from_secs(poll_seconds));
+ session = repository.sync_follow_session(ref_name)?;
+ }
+ }
+ } else if push {
+ let report = repository.sync_push(takeover, ref_name)?;
+ if timings {
+ print_transport_timings(&report.timings);
+ }
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&report)?);
+ } else {
+ println!("Published snapshot {}", &report.snapshot[..12]);
+ println!(
+ "{} objects uploaded ({} bytes); {} reused",
+ report.uploaded_objects, report.uploaded_bytes, report.reused_objects
+ );
+ }
+ } else {
+ let outcome = repository.sync_pull(bootstrap, ref_name)?;
+ if timings {
+ print_transport_timings(&outcome.timings);
+ print_apply_timings(&outcome.apply_timings);
+ }
+ if cli.json {
+ println!("{}", serde_json::to_string_pretty(&outcome)?);
+ } else {
+ println!("Remote snapshot {}", &outcome.remote_snapshot[..12]);
+ println!(
+ "{} objects fetched ({} bytes); {} reused; {} files materialized",
+ outcome.fetched_objects,
+ outcome.fetched_bytes,
+ outcome.reused_objects,
+ outcome.materialized_files
+ );
+ match outcome.disposition {
+ SyncDisposition::FastForwarded => println!("Workspace fast-forwarded"),
+ SyncDisposition::Bootstrapped => {
+ println!("Workspace initialized from the remote")
+ }
+ SyncDisposition::UpToDate => println!("Workspace already up to date"),
+ SyncDisposition::Diverged => println!(
+ "Divergence preserved; inspect or materialize the full remote snapshot {}",
+ outcome.remote_snapshot
+ ),
+ }
+ }
+ anyhow::ensure!(
+ outcome.disposition != SyncDisposition::Diverged,
+ "local and remote working states diverged; neither side was overwritten"
+ );
+ }
+ }
+ Command::Work {
+ remote,
+ name,
+ key,
+ input_ref,
+ result_ref,
+ keep,
+ command,
+ } => {
+ run_work(WorkArgs {
+ json: cli.json,
+ remote: &remote,
+ name: name.as_deref(),
+ key: &key,
+ input_ref: &input_ref,
+ result_ref: &result_ref,
+ keep,
+ command: &command,
+ })?;
+ }
+ Command::Mcp => {
+ let repository = FurrowRepository::open(&cli.repo)?;
+ furrow::mcp::run(repository)?;
+ }
+ }
+ Ok(())
+}
+
+fn print_transport_timings(timings: &furrow::sync::TransportTimings) {
+ eprintln!(
+ "sync timings: connect/auth={}ms negotiate={}ms stream={}ms fsync-wait={}ms notify={} total={}ms reused_connection={}",
+ timings.connect_auth_ms,
+ timings.negotiate_ms,
+ timings.stream_ms,
+ timings.durability_ms,
+ timings
+ .notify_ms
+ .map_or_else(|| "n/a".to_owned(), |value| format!("{value}ms")),
+ timings.total_ms,
+ timings.connection_reused
+ );
+}
+
+fn print_apply_timings(timings: &furrow::repository::ApplyTimings) {
+ eprintln!(
+ "apply timings: diff-compute={}ms divergence-check={}ms write={}ms fsync={}ms baseline-install={}ms watcher-requiesce={}ms",
+ timings.diff_compute_ms,
+ timings.divergence_check_ms,
+ timings.write_ms,
+ timings.fsync_ms,
+ timings.baseline_install_ms,
+ timings.watcher_requiesce_ms,
+ );
+}
+
+fn default_fork_name() -> String {
+ let seconds = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+ format!("fork-{seconds}")
+}
+
+fn print_pair_summary(summary: &furrow::sync::PairSummary, json: bool) -> anyhow::Result<()> {
+ if json {
+ println!("{}", serde_json::to_string_pretty(summary)?);
+ } else {
+ println!("Paired {}", summary.namespace);
+ println!("Remote {}", summary.remote);
+ println!("Machine {}", summary.machine_id);
+ println!("Recovery key {}", summary.key_hex);
+ println!("Keep the recovery key private; enter it once on each additional machine.");
+ }
+ Ok(())
+}
+
+fn split_clone_url(value: &str) -> anyhow::Result<(String, String)> {
+ let trimmed = value.trim_end_matches('/');
+ let (transport, namespace) = trimmed
+ .rsplit_once('/')
+ .context("clone URL must end with a workspace name")?;
+ furrow::remote::validate_namespace(namespace)?;
+ if transport.starts_with("s3://") {
+ anyhow::ensure!(
+ transport.len() > "s3://".len(),
+ "clone URL must include a bucket and workspace name"
+ );
+ furrow::s3_remote::S3Spec::from_uri(transport)?;
+ } else if let Some(host) = transport.strip_prefix("ssh://") {
+ anyhow::ensure!(
+ !host.is_empty() && !host.contains('/'),
+ "SSH clone URL must be ssh://HOST/WORKSPACE"
+ );
+ } else {
+ anyhow::bail!("clone currently requires an ssh:// or s3:// URL");
+ }
+ Ok((transport.to_owned(), namespace.to_owned()))
+}
+
+fn default_exec_name() -> String {
+ let seconds = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+ format!("exec-{seconds}-{}", std::process::id())
+}
+
+fn execute_universes(
+ repo: &std::path::Path,
+ json: bool,
+ explicit_name: Option,
+ count: usize,
+ at: Option,
+ plan_only: bool,
+ command: &[OsString],
+) -> anyhow::Result<()> {
+ use furrow::universe::{ExecPlan, ExecutionDriver, UniverseCommand, UniversePlan};
+
+ anyhow::ensure!((1..=32).contains(&count), "-n must be between 1 and 32");
+ anyhow::ensure!(
+ explicit_name.is_none() || count == 1,
+ "--fork names one universe and cannot be combined with -n greater than 1"
+ );
+ anyhow::ensure!(
+ plan_only || !command.is_empty(),
+ "`furrow exec` requires a command after -- (or use --plan)"
+ );
+
+ let mut repository = FurrowRepository::open(repo)?;
+ let canonical_workdir = repository.root().to_path_buf();
+ let driver = furrow::universe::select_driver();
+ let base_name = explicit_name.unwrap_or_else(default_exec_name);
+ let first_name = if count == 1 {
+ base_name.clone()
+ } else {
+ format!("{base_name}-1")
+ };
+ let first_destination = default_fork_destination(&repository, &first_name);
+ let first_fork_plan = match at {
+ Some(at) => {
+ let at = repository.resolve_snapshot(&at)?;
+ repository.prepare_fork_at(&first_name, &first_destination, &at)?
+ }
+ None => repository.prepare_fork(&first_name, &first_destination)?,
+ };
+ let base_port = std::env::var("PORT")
+ .ok()
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(3000);
+ anyhow::ensure!(
+ usize::from(base_port).saturating_add(count - 1) <= usize::from(u16::MAX),
+ "PORT range exceeds 65535"
+ );
+
+ let mut fork_plans = Vec::with_capacity(count);
+ let mut universes = Vec::with_capacity(count);
+ for offset in 0..count {
+ let index = offset + 1;
+ let name = if count == 1 {
+ base_name.clone()
+ } else {
+ format!("{base_name}-{index}")
+ };
+ let destination = if offset == 0 {
+ first_fork_plan.destination.clone()
+ } else {
+ default_fork_destination(&repository, &name)
+ };
+ let mut fork_plan = first_fork_plan.clone();
+ if offset > 0 {
+ fork_plan.fork_id = furrow::new_fork_id()?;
+ }
+ fork_plan.name = name.clone();
+ fork_plan.destination = destination.clone();
+ let process_workdir = match driver.driver {
+ ExecutionDriver::LinuxMountNamespace => canonical_workdir.clone(),
+ ExecutionDriver::SiblingDirectory => destination.clone(),
+ };
+ universes.push(UniversePlan {
+ index,
+ fork_id: fork_plan.fork_id.clone(),
+ name,
+ destination,
+ process_workdir,
+ port: base_port + u16::try_from(offset)?,
+ base_snapshot: fork_plan.base_snapshot.clone(),
+ logical_bytes: fork_plan.logical_bytes,
+ projected_fork_ms: fork_plan.projected_native_cow_ms,
+ });
+ fork_plans.push(fork_plan);
+ }
+ let exec_plan = ExecPlan {
+ driver,
+ canonical_workdir,
+ universes,
+ };
+
+ if plan_only {
+ if json {
+ println!("{}", serde_json::to_string_pretty(&exec_plan)?);
+ } else {
+ print_exec_plan(&exec_plan);
+ }
+ return Ok(());
+ }
+
+ if !json {
+ print_exec_plan(&exec_plan);
+ io::stdout().flush()?;
+ }
+ let started = std::time::Instant::now();
+ let mut summaries = Vec::with_capacity(count);
+ for fork_plan in fork_plans {
+ summaries.push(repository.materialize_fork(fork_plan)?);
+ }
+ let materialized_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
+ if !json {
+ println!("Materialized {count} universe(s) in {materialized_ms} ms");
+ io::stdout().flush()?;
+ }
+
+ let executable = std::env::current_exe().context("resolve furrow executable")?;
+ let mut children = Vec::with_capacity(count);
+ for (universe, summary) in exec_plan.universes.iter().zip(&summaries) {
+ let mut child = UniverseCommand {
+ driver: exec_plan.driver.driver,
+ executable: &executable,
+ source: &summary.destination,
+ canonical_target: &exec_plan.canonical_workdir,
+ command,
+ }
+ .command()?;
+ child
+ .env("FURROW_WORKDIR", &universe.process_workdir)
+ .env("FURROW_CANONICAL_WORKDIR", &exec_plan.canonical_workdir)
+ .env("FURROW_FORK_NAME", &summary.name)
+ .env("FURROW_FORK_BASE", &summary.base_snapshot)
+ .env("FURROW_UNIVERSE_INDEX", universe.index.to_string())
+ .env("FURROW_UNIVERSE_COUNT", count.to_string())
+ .env("PORT", universe.port.to_string());
+ if json {
+ redirect_stdout_to_stderr(&mut child)?;
+ }
+ match child.spawn() {
+ Ok(child) => children.push(child),
+ Err(error) => {
+ for child in &mut children {
+ let _ = child.kill();
+ }
+ for mut child in children {
+ let _ = child.wait();
+ }
+ return Err(error).with_context(|| format!("start universe {}", universe.name));
+ }
+ }
+ }
+
+ let mut statuses = Vec::with_capacity(count);
+ for mut child in children {
+ statuses.push(child.wait().context("wait for universe command")?);
+ }
+
+ let mut results = Vec::with_capacity(count);
+ for ((universe, summary), status) in exec_plan.universes.iter().zip(&summaries).zip(&statuses) {
+ let mut fork_repository = FurrowRepository::open(&summary.destination)?;
+ let outcome = status
+ .code()
+ .map_or_else(|| status.to_string(), |code| format!("exit {code}"));
+ let head = fork_repository.snapshot(
+ Some(format!("exec {} completed ({outcome})", universe.name)),
+ SnapshotTrigger::AgentRun,
+ )?;
+ results.push(serde_json::json!({
+ "index": universe.index,
+ "fork": universe.name,
+ "fork_id": universe.fork_id,
+ "base_snapshot": summary.base_snapshot,
+ "head_snapshot": id_hex(&head),
+ "path": summary.destination,
+ "process_workdir": universe.process_workdir,
+ "port": universe.port,
+ "exit_code": furrow::universe::exit_code(*status),
+ }));
+ }
+
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "driver": exec_plan.driver,
+ "materialized_ms": materialized_ms,
+ "universes": results,
+ }))?
+ );
+ } else {
+ for result in &results {
+ println!(
+ "{} exit {} port {} {}",
+ result["fork"].as_str().unwrap_or("unknown"),
+ result["exit_code"],
+ result["port"],
+ result["path"].as_str().unwrap_or("unknown")
+ );
+ }
+ println!("Source workspace was not modified");
+ }
+ if let Some(status) = statuses.iter().find(|status| !status.success()) {
+ exit_with_status(*status);
+ }
+ Ok(())
+}
+
+struct WorkArgs<'a> {
+ json: bool,
+ remote: &'a std::path::Path,
+ name: Option<&'a str>,
+ key: &'a str,
+ input_ref: &'a str,
+ result_ref: &'a str,
+ keep: bool,
+ command: &'a [OsString],
+}
+
+/// Execute a headless job: pull the input ref, fork at its snapshot, run the
+/// command in an isolated universe, then seal and publish the result under the
+/// result ref. The working directory holding both the throwaway bootstrap
+/// workspace and the job fork is removed unless `--keep`, and the process exit
+/// code mirrors the command.
+fn run_work(args: WorkArgs<'_>) -> anyhow::Result<()> {
+ anyhow::ensure!(
+ !args.command.is_empty(),
+ "`furrow work` requires a command after --"
+ );
+
+ let unique = format!(
+ "{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ );
+ let work_dir = std::env::temp_dir().join(format!("furrow-work-{unique}"));
+ anyhow::ensure!(
+ !work_dir.exists(),
+ "work directory already exists: {}",
+ work_dir.display()
+ );
+ fs::create_dir_all(&work_dir)?;
+
+ match work_core(&args, &work_dir, &unique) {
+ Ok((status, fork_destination)) => {
+ if args.keep {
+ // The fork is the durable artifact; the bootstrap workspace was
+ // only a pull target and can go regardless.
+ let _ = fs::remove_dir_all(work_dir.join("base"));
+ if !args.json {
+ println!("Kept fork {}", fork_destination.display());
+ }
+ } else {
+ let _ = fs::remove_dir_all(&work_dir);
+ }
+ if !status.success() {
+ exit_with_status(status);
+ }
+ Ok(())
+ }
+ Err(error) => {
+ let _ = fs::remove_dir_all(&work_dir);
+ Err(error)
+ }
+ }
+}
+
+fn work_core(
+ args: &WorkArgs<'_>,
+ work_dir: &std::path::Path,
+ unique: &str,
+) -> anyhow::Result<(std::process::ExitStatus, PathBuf)> {
+ use furrow::universe::{ExecutionDriver, UniverseCommand};
+
+ let namespace = match args.name {
+ Some(name) => name.to_owned(),
+ None => {
+ let derived = args
+ .remote
+ .file_name()
+ .and_then(|name| name.to_str())
+ .context("could not derive a workspace name from --remote; pass --name")?;
+ furrow::remote::validate_namespace(derived)?;
+ derived.to_owned()
+ }
+ };
+
+ // Bootstrap a throwaway workspace at the input snapshot, mirroring `clone`:
+ // git init, pair to the shared remote, then a bootstrap pull of the named
+ // input ref lands its objects and head into the local store.
+ let base_root = work_dir.join("base");
+ fs::create_dir_all(&base_root)?;
+ let status = std::process::Command::new("git")
+ .arg("init")
+ .arg("--quiet")
+ .arg("--")
+ .arg(&base_root)
+ .status()
+ .context("start git for the work bootstrap workspace")?;
+ anyhow::ensure!(status.success(), "git init failed for the work workspace");
+
+ let (mut base_repository, _) = FurrowRepository::watch(&base_root)?;
+ base_repository
+ .pair(args.remote, &namespace, Some(args.key))
+ .context("pair the work machine with the shared remote")?;
+ let pull = base_repository
+ .sync_pull(true, Some(args.input_ref))
+ .with_context(|| format!("pull input ref '{}' from the remote", args.input_ref))?;
+ let input_snapshot = base_repository.resolve_snapshot(&pull.remote_snapshot)?;
+
+ // Materialize an isolated fork at the input snapshot, reusing the
+ // `fork --at` machinery so the job never observes the bootstrap workspace.
+ let fork_name = format!("work-{unique}");
+ let fork_destination = work_dir.join("fork");
+ let plan = base_repository.prepare_fork_at(&fork_name, &fork_destination, &input_snapshot)?;
+ let summary = base_repository.materialize_fork(plan)?;
+
+ // Run the command through the existing universe execution driver, streaming
+ // stdout/stderr straight through by inheriting them.
+ let driver = furrow::universe::select_driver();
+ let canonical_workdir = base_repository.root().to_path_buf();
+ let process_workdir = match driver.driver {
+ ExecutionDriver::LinuxMountNamespace => canonical_workdir.clone(),
+ ExecutionDriver::SiblingDirectory => summary.destination.clone(),
+ };
+ let executable = std::env::current_exe().context("resolve furrow executable")?;
+ let mut child = UniverseCommand {
+ driver: driver.driver,
+ executable: &executable,
+ source: &summary.destination,
+ canonical_target: &canonical_workdir,
+ command: args.command,
+ }
+ .command()?;
+ child
+ .env("FURROW_WORKDIR", &process_workdir)
+ .env("FURROW_CANONICAL_WORKDIR", &canonical_workdir)
+ .env("FURROW_FORK_NAME", &summary.name)
+ .env("FURROW_FORK_BASE", &summary.base_snapshot);
+ let status = child
+ .status()
+ .with_context(|| format!("run {:?} in the job fork", args.command[0]))?;
+
+ // Seal the resulting state, recording the input snapshot as the merge base
+ // so a downstream `merge --snapshot` derives it, then publish to the result
+ // ref regardless of the command's exit status.
+ let mut fork_repository = FurrowRepository::open(&summary.destination)?;
+ let outcome = status
+ .code()
+ .map_or_else(|| status.to_string(), |code| format!("exit {code}"));
+ let result_snapshot = fork_repository.snapshot_recording_base(
+ Some(format!("furrow work result ({outcome})")),
+ SnapshotTrigger::AgentRun,
+ &input_snapshot,
+ )?;
+ let report = base_repository
+ .push_snapshot_to_ref(&result_snapshot, args.result_ref)
+ .with_context(|| format!("publish result to ref '{}'", args.result_ref))?;
+
+ if args.json {
+ println!(
+ "{}",
+ serde_json::json!({
+ "namespace": namespace,
+ "input_ref": args.input_ref,
+ "result_ref": args.result_ref,
+ "input_snapshot": id_hex(&input_snapshot),
+ "result_snapshot": id_hex(&result_snapshot),
+ "base_snapshot": summary.base_snapshot,
+ "uploaded_objects": report.uploaded_objects,
+ "uploaded_bytes": report.uploaded_bytes,
+ "exit_code": furrow::universe::exit_code(status),
+ "fork": summary.destination,
+ "kept": args.keep,
+ })
+ );
+ } else {
+ println!(
+ "Input {} @ {}",
+ args.input_ref,
+ &id_hex(&input_snapshot)[..12]
+ );
+ println!(
+ "Result {} @ {}",
+ args.result_ref,
+ &id_hex(&result_snapshot)[..12]
+ );
+ println!(
+ "{} objects uploaded ({} bytes); {} reused",
+ report.uploaded_objects, report.uploaded_bytes, report.reused_objects
+ );
+ println!("Command exit {}", furrow::universe::exit_code(status));
+ }
+
+ Ok((status, summary.destination))
+}
+
+fn print_exec_plan(plan: &furrow::universe::ExecPlan) {
+ println!(
+ "Driver {} | same canonical path: {}",
+ plan.driver.driver,
+ if plan.driver.same_canonical_path {
+ "yes"
+ } else {
+ "no"
+ }
+ );
+ println!("Reason: {}", plan.driver.reason);
+ for universe in &plan.universes {
+ println!(
+ " {:>2} {:<28} port {:>5} {}",
+ universe.index,
+ universe.name,
+ universe.port,
+ universe.process_workdir.display()
+ );
+ }
+}
+
+fn write_ndjson(value: &impl serde::Serialize) -> anyhow::Result<()> {
+ let stdout = io::stdout();
+ let mut output = stdout.lock();
+ if let Err(error) = serde_json::to_writer(&mut output, value)
+ .and_then(|()| writeln!(output).map_err(serde_json::Error::io))
+ {
+ if error.io_error_kind() == Some(io::ErrorKind::BrokenPipe) {
+ return Ok(());
+ }
+ return Err(error.into());
+ }
+ output.flush()?;
+ Ok(())
+}
+
+fn redirect_stdout_to_stderr(command: &mut std::process::Command) -> anyhow::Result<()> {
+ use std::os::fd::FromRawFd;
+
+ let descriptor = unsafe { libc::dup(libc::STDERR_FILENO) };
+ if descriptor < 0 {
+ return Err(std::io::Error::last_os_error()).context("duplicate stderr");
+ }
+ let stderr = unsafe { std::fs::File::from_raw_fd(descriptor) };
+ command.stdout(std::process::Stdio::from(stderr));
+ Ok(())
+}
+
+fn default_fork_destination(repository: &FurrowRepository, name: &str) -> PathBuf {
+ let root = repository.root();
+ let parent = root.parent().unwrap_or_else(|| std::path::Path::new("."));
+ let repository_name = root
+ .file_name()
+ .unwrap_or_else(|| std::ffi::OsStr::new("workspace"));
+ let mut forks_name = repository_name.to_os_string();
+ forks_name.push(".furrow-forks");
+ parent.join(forks_name).join(name)
+}
+
+fn default_sync_name(repository: &FurrowRepository) -> String {
+ repository
+ .root()
+ .file_name()
+ .and_then(|name| name.to_str())
+ .filter(|name| !name.is_empty())
+ .unwrap_or("workspace")
+ .to_owned()
+}
+
+fn print_plan(plan: &furrow::RewindPlan) {
+ println!("Rewind to {}", &plan.target[..12]);
+ for change in &plan.changes {
+ println!(" {:<8} {}", change.action, change.path);
+ }
+}
+
+fn print_fork_plan(plan: &furrow::ForkPlan) {
+ println!(
+ "Fork plan: {} files, {} directories, {} logical",
+ plan.files,
+ plan.directories,
+ human_bytes(plan.logical_bytes)
+ );
+ println!(
+ "Native CoW target: about {} ms; streaming fallback: about {} ms and up to {} copied",
+ plan.projected_native_cow_ms,
+ plan.projected_streaming_copy_ms,
+ human_bytes(plan.worst_case_copied_bytes)
+ );
+ if plan.at_snapshot {
+ println!(
+ "Materializing from stored snapshot {}",
+ &plan.base_snapshot[..12]
+ );
+ }
+}
+
+fn print_shrink_plan(plan: &furrow::shrink::ShrinkPlan) {
+ if plan.candidates.is_empty() {
+ println!("No recognized dependency or build caches found");
+ return;
+ }
+ for candidate in &plan.candidates {
+ println!(
+ " {:>9} {:<24} {}",
+ human_bytes(candidate.physical_bytes),
+ candidate.class,
+ candidate.path
+ );
+ }
+ println!(
+ "{} paths, {} entries, {} physical ({} logical)",
+ plan.candidates.len(),
+ plan.total_entries,
+ human_bytes(plan.total_physical_bytes),
+ human_bytes(plan.total_logical_bytes)
+ );
+}
+
+fn human_bytes(bytes: u64) -> String {
+ const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
+ let mut value = bytes as f64;
+ let mut unit = 0;
+ while value >= 1024.0 && unit + 1 < UNITS.len() {
+ value /= 1024.0;
+ unit += 1;
+ }
+ if unit == 0 {
+ format!("{bytes} {}", UNITS[unit])
+ } else {
+ format!("{value:.1} {}", UNITS[unit])
+ }
+}
+
+fn parse_byte_size(value: &str) -> anyhow::Result {
+ let value = value.trim();
+ let split = value
+ .find(|character: char| !character.is_ascii_digit())
+ .unwrap_or(value.len());
+ anyhow::ensure!(split > 0, "byte size must start with an integer");
+ let number: u64 = value[..split].parse()?;
+ let suffix = value[split..].trim().to_ascii_lowercase();
+ let multiplier = match suffix.as_str() {
+ "" | "b" => 1,
+ "k" | "kb" | "kib" => 1024_u64,
+ "m" | "mb" | "mib" => 1024_u64.pow(2),
+ "g" | "gb" | "gib" => 1024_u64.pow(3),
+ "t" | "tb" | "tib" => 1024_u64.pow(4),
+ _ => anyhow::bail!("unsupported byte-size suffix `{suffix}`"),
+ };
+ number
+ .checked_mul(multiplier)
+ .context("byte size exceeds the supported range")
+}
+
+fn install_hook_adapters(root: &std::path::Path) -> anyhow::Result> {
+ let root = root.canonicalize()?;
+ let directory = root.join(".furrow/hooks");
+ fs::create_dir_all(&directory)?;
+ let mut installed = Vec::new();
+ for event in ["pre-turn", "post-tool", "turn-end"] {
+ let destination = directory.join(event);
+ let script = format!(
+ "#!/bin/sh\nset -eu\nrepo=$(CDPATH= cd \"$(dirname \"$0\")/../..\" && pwd)\nexec \"${{FURROW_BIN:-furrow}}\" --repo \"$repo\" hook {event} \"$@\"\n"
+ );
+ let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
+ temporary.write_all(script.as_bytes())?;
+ temporary.as_file().sync_all()?;
+ fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o755))?;
+ temporary
+ .persist(&destination)
+ .map_err(|error| error.error)?;
+ installed.push(destination);
+ }
+ std::fs::File::open(&directory)?.sync_all()?;
+ Ok(installed)
+}
+
+fn serve(socket: PathBuf, idle_minutes: u64) -> anyhow::Result<()> {
+ let config = furrow_daemon::Config {
+ socket,
+ idle: std::time::Duration::from_secs(idle_minutes.saturating_mul(60)),
+ };
+ // Another daemon already serves this socket: that is the goal met.
+ let Some(server) = furrow_daemon::Server::bind(config)? else {
+ return Ok(());
+ };
+ furrow_daemon::signals::install();
+ server.run()
+}
+
+fn run_hook(
+ root: &std::path::Path,
+ json: bool,
+ event: &str,
+ agent: Option,
+ turn: Option,
+ tool: Option,
+ seal: SealArgs,
+) -> anyhow::Result<()> {
+ let agent = hook_value(agent, "FURROW_AGENT_ID")?.unwrap_or_else(|| "agent".to_owned());
+ let turn = hook_value(turn, "FURROW_TURN_ID")?;
+ let tool = hook_value(tool, "FURROW_TOOL_NAME")?;
+ let label = furrow_daemon::hook_label(event, &agent, turn.as_deref(), tool.as_deref());
+ let (_, snapshot) = FurrowRepository::attach_and_seal(
+ root,
+ Some(label.clone()),
+ SnapshotTrigger::AgentRun,
+ seal.options()?,
+ )?;
+ if json {
+ println!(
+ "{}",
+ serde_json::json!({"event": event, "label": label, "snapshot": id_hex(&snapshot)})
+ );
+ } else {
+ println!("Sealed {} {}", &id_hex(&snapshot)[..12], label);
+ }
+ Ok(())
+}
+
+fn hook_value(explicit: Option, environment: &str) -> anyhow::Result