Files
Greg ShuflinandClaude Opus 5.5 f8fad97f81 AGENTS.md: a background result knows what it was for
The rule F1's sweep applied across Notes, the player, document detail and the
readers: a buffer carries its record's id, a load into a selection is held
rather than detached, and work that can't be canceled checks a generation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00

29 KiB

This file provides guidance to Claude Code (claude.ai/code), Cursor, and any other Agentic coding tools when working with code in this repository.

Project Overview

An early-stage personal server runtime/overlay OS: a decentralized personal server instantiated across many nodes (phone, desktop/laptop, browser extension, headless cloud server — arbitrarily many per user) that sync personal documents and data directly with each other. That includes files (PDFs, music/video) and metadata like podcast/video playback position or which social feeds (Twitter/X/BlueSky/Mastodon) have been read or archived.

Inspired by Urbit's planet/moon model, but with no central planet — every node is effectively a moon. Each node has its own keypair to sign/encrypt messages, plus access to a global keypair that represents ownership of the node-set as a whole. The goal is encrypted, authenticated communication between arbitrary sets of nodes.

Project Status

Experimental — no backwards-compatibility constraints. Breaking changes to serialization formats, database schemas, wire protocols, key derivation salts, and CLI interfaces are all acceptable. That includes not writing migrations: when a schema changes, an existing store is manually deleted and recreated, not converted in place, so no code rewrites old rows on open or keeps a reader for a retired column, table, or artifact kind.

Design Principles

These hold across every agent and every modality (gpui desktop, Android, TUI, CLI). They are not a backlog — a change that violates one of them is not finished, whatever else it does.

  • A sync must never disrupt what the user is doing. Data arriving from another node is the normal case in this system, not an event, and it can land at any moment — mid-sentence, mid-scroll, mid-playback. It must never move what is under the user's cursor, replace text they are editing, reorder a list they are pointing at, or reset a position they chose. When an update arrives for something the user is actively in, alert, don't apply: say so quietly and in place (a marker on the thing, not a modal), keep the incoming version aside, and change what's on screen only when the user asks for it — or when they leave and come back, which is itself an explicit act.
    • The alert has to name what the user gives up, because with whole-buffer writes both answers lose something: taking the incoming version drops edits made since it arrived, and keeping the local one is written as a diff against the merged text and so removes the other node's edit from the document everywhere. Neither may happen by default.
    • While a change is pending, stop writing over it. Autosave, flush-on- close, and any other background write must pause until the user has chosen; a save that silently wins the conflict is the same bug wearing a different hat.
    • Worked examples, one per modality: rust/gui-app/src/agents/notes/mod.rs (check_open_note/render_incoming_body_banner), rust/tui-app/src/notes_screen.rs (on_documents_changed, and the editor's :e), and on Android ui/IncomingBody.kt, whose doc comment is where the rule for telling their write from ours is written down.
    • The two shared pieces to reach for rather than rebuild: the desktop's components::sync_guarded::SyncGuarded<T> (ADR 0031) and Android's ui/IncomingBody plus ui/IncomingBodyStrip, whose words come from copy::incoming_change::Subject — a new subject is a variant there, not a sentence on a screen. agents/clippings is the shortest end-to-end reading of the whole shape on both.
  • Absence is not an error. A document whose content lives on another node is a state to render, not a failure and not a blank. See docs/data-model.md and ADR 0030 §4: offering an empty buffer for text that demonstrably exists elsewhere invites the user to retype it, and saving that forks the document.
  • Editing is a mode, and the mode is the whole screen. A screen that shows a record either reads it or edits it. Entering edit mode makes every field on it editable at once; nothing is written until an explicit Save, and Cancel discards the lot and returns to reading. There is no per-field save, no section that commits on its own, and no control that persists the moment it is touched.
    • This holds for lists and collections on the form as much as for its plain fields, however the store happens to keep them. "Each row is its own artifact, so there's nothing to batch behind Save" is a fact about storage, not a license to write one — it makes Save mean two different things on one form, and it leaves the user unable to back out of a removal. Stage the edit and diff it against what is persisted when Save runs; the Humans screen's diff_staged/diffStaged is the shape.
    • Discarding a staged edit and re-reading the record are the same act, so they should be one code path. Watch for state that lives outside the screen's own struct — the desktop's text fields are TextInput entities, so a Cancel that only dropped the staged collections would leave a canceled rename on screen.
    • Not everything on a screen is part of its form. A thing that is its own document — Humans' interactions, shared with their other attendees — is written when the user says to write it, and lives outside the mode. The test is whether it is a field of this record or a record of its own.
    • A preferences screen is not a record. Settings is a set of independent choices — a theme, a zoom level, a timezone, a sync policy — each meaningful on its own, and some only judgeable once seen applied. So a control there applies the moment it is touched, the way every platform's settings do, and a free-text field (a node name, a credential) commits on its own Save or Enter rather than per keystroke. The test is whether the values describe one thing the user would want to back out of together, or are separate choices that only share a screen.
    • Worked examples: rust/gui-app/src/agents/human.rs (DetailMode, reload_detail, save_current) and Android's HumanDetailScreen.kt.
  • The instant-feedback invariant: anything that might not complete promptly returns control to the UI immediately and becomes a tracked Job. Stated in full in docs/command-palette-and-search.md §4.1.
  • Waiting on the network never occupies a UI worker thread. gpui's background executor is a fixed pool shared by every screen; a thread parked in Handle::block_on waiting on a peer is a thread the rest of the UI can't have, and enough of them is a dead window. On the desktop app, network work goes to the tokio runtime via gui-app/src/network_task.rs and is awaited from the gpui task — local store reads and decoding stay on the background executor. Any lib function that reaches the network should be async rather than taking a tokio::runtime::Handle to block on. This is not per-agent: it holds for every agent that opens something living on another node.
  • A result knows what it was for. Anything a screen loads, saves or plays in the background can finish after the user has moved on — another note selected, the document closed, a different track started — and it must not land on whatever is selected now.
    • A buffer carries the id of the record it belongs to. A save takes its destination and its text from the same value, never the destination from a separate "selected" field. A stale apply can then at worst show the wrong text for a moment; it can never write one record's text into another, which then syncs everywhere. That is the bug Notes' OpenNote closed: a slow load of note A installed A's body under B's title, and the next keystroke autosaved it into B.
    • A load applied into a selection is held, not detached. Keep its Task in a field (_load_task): assigning the next one drops the old, and gpui cancels a dropped Task, so the latest selection wins by construction rather than by a "still selected?" check each async step has to remember. Compose gets the same from LaunchedEffect(key), which cancels the previous coroutine when the key changes.
    • Where the work can't be canceled because it has already handed something off, check a generation at the point it applies — player_controls' play_seq, compared under the audio lock. A result applied by id looks its target up by that id (detail_view's detail_for) rather than writing into whatever is open.

Build Commands

This project runs on machines with different capabilities — GUI session, Android Studio/NDK, Nix — some have all of them, some (CI VMs, sandboxed agent environments) have none. Run just doctor at the start of a session rather than assuming from a previous session or another machine; it reports what's available and which command each finding implies.

The justfile is the canonical source of project commands — run just --list to see everything available, grouped and documented, and prefer just <recipe> over re-deriving the raw cargo/gradlew incantation. A component with recipes of its own carries its own justfile, declared as a mod in the root one and listed as <name> ... rather than expanded; just --list <name> shows what's inside (currently just extension, for the browser extension). Recipes set up environment variables (NDK toolchain paths, JAVA_HOME, RUSTFLAGS) that a bare cargo/./gradlew invocation won't have and will otherwise fail or silently produce a different artifact from what CI/other developers build.

  • Android/Kotlin: see android/CLAUDE.md — the three toolchain cases from just doctor, just android-build / just gradlew <task> / just build-android-nix, running tests (just unit-tests-kotlin), and the two detekt recipes (lint-kotlin vs lint-kotlin-fast).
  • Rust: just build-synchronicity-lib-android builds the synchronicity_lib crate for Android with the correct NDK linker/target env vars set — don't substitute a bare cd rust && cargo build --target aarch64-linux-android --release, it lacks TARGET_CC/TARGET_AR/RUSTFLAGS and will fail to link. For a native (non-Android) build use just build-native / just build-native-release.
  • GUI (rust/gui-app, the gpui desktop app): see rust/gui-app/CLAUDE.md — iterate with just check-gui-nix, use just build-gui-nix for final verification, and why rust-analyzer.json sets checkOnSave: false.
  • Run tests: see the justfile "test" group recipes; for example just unit-tests-rust.
  • Lint: just lint-rust (clippy, whole workspace) and just lint-kotlin (detekt — no baseline, any finding fails the build; see "Two Kotlin lint recipes" in android/CLAUDE.md for the two variants). lint-rust covers the whole workspace including gui-app, so on a machine without the GUI's system libraries it dies in a native build script before linting anything — run just lint-rust-nix there instead. just lint-rust-fix applies clippy's mechanical suggestions (review the diff; it is mechanical, not automatically right). Three separate, compile-free checks cover classes clippy cannot see: just lint-gui-layout (a gpui sizing bug — see the Code Style entry on flex_1 plus max_w), just lint-p2p-admission (a protocol served without the delegation-cert handshake), and just lint-sql-params (a SQL query carrying a value as text instead of as a ? — see the Code Style entry below). Each script's module docstring carries its full reasoning and its escape hatch.

Code Style

  • Use American spelling in user-facing copy and documentation (color, not colour; behavior, not behaviour; canceled, not cancelled; catalog, not catalogue) — consistently, throughout the codebase. Leave alone any spelling that comes from outside the project: a fixed external API/library name (e.g. Kotlin's kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's labelLarge), a URL, or a third-party wordlist (rust/lib/src/ensemble/eff_large_wordlist.txt) — those aren't ours to respell.
  • Errors: Use typed errors with meaningful messages. In rust, prefer thiserror. Avoid using .unwrap() or .expect() in non-test code, and fix uses of these or other panicking functions when you encounter them.
  • the justfile codifies recipes that run standard Rust and Kotlin formatters (just fmt-kotlin, just fmt-rust). Run these tools to keep code formatted on every commit.
  • don't generate code that has fully-qualified type names (e.g. documents::Artifacts::new()); instead prefer to use documents::Artifacts, Artifacts::new()
  • GUI frameworks (GPUI or Jetpack Compose): Prefer to create components built out of functional subcomponents with meaningful names, instead of long components with no internal structure. Prefer to define named constants instead of using the same color, spacing, etc. values in-situ. Don't define default values for properties unless necessary. Prefer to create text elements that have copy-able text;
  • When displaying hex-encoded hashes or UUIDs, use standard UI components (depending on modality) which display a truncated hash but make the full hash available if the user requests it.
  • A truncation of human-readable text cuts from the middle, never the end. What tells two long strings apart is as often at the end as at the start — Quarterly report - draft against - final, an episode number, a file extension — and an end ellipsis drops exactly that.
    • A cut made by character count is copy::elide_middle. The TUI's notes_screen::elide is the same rule in terminal columns, and the browser extension's src/elide.js is it restated in JS, since the extension cannot call the lib.
    • Ids and hashes are the exception: a shortened id keeps its prefix (3fa9c1d2…), the way git shortens a commit hash. An id is random, so its end is no more distinguishing than its start, and the prefix is the conventional handle — the part people read aloud, compare and type. The full value stays one action away through the hash-chip component (see the bullet above). The helpers are copy::short_id/copy::truncate_id.
    • A cut made by layout uses the toolkit's middle ellipsis: components::truncate::TruncateMiddle::truncate_middle() on gpui rather than .truncate(), TextOverflow.MiddleEllipsis in Compose, and the extension's fitMiddle where CSS would have said text-overflow: ellipsis.
    • The one exception is a multi-line clamp (maxLines = 2 and up): Compose only supports a middle ellipsis on a single line and falls back to a hard clip, so those keep TextOverflow.Ellipsis.
  • gpui sizing: no raw px() in render code. Font sizes, icon/control sizes, panel widths, and gaps come from gui-app's sizing module (font::, icon::, control::, layout::), or sizing::scaled(..) for a genuine one-off. These are Rems, resolved against the window's rem size at layout time, so they track the UI zoom control — a hard-coded px() doesn't and silently breaks zoom for whatever it sizes. gpui's rem-based Tailwind helpers (.p_4(), .gap_2(), .text_xl()) are fine for padding/margin/gap; reach for a named token only when an existing one doesn't already fit — the module's job is few sizes, not one per value used.
    • px() is still correct for things genuinely in device pixels: hairline borders (px(1.)), the min_h(px(0.)) flexbox idiom, measured on-screen bounds, scroll offsets, and rasterizer inputs (math/PDF/typst renderers). Convert explicitly with .to_pixels(window.rem_size()) rather than assuming a 1:1 scale — the mismatch is invisible at 100% zoom and wrong everywhere else.
  • User-visible copy lives in synchronicity-lib's copy.rs, not in a screen. Any string a user reads — a heading, an explainer, a button word, a confirmation, an outcome sentence — is written once there and imported by every modality that shows it: gpui desktop and TUI and CLI directly, Android through a #[uniffi::export] record in ffi/free_fns.rs (placeholder_copy and action_labels are the shape). A frontend that retypes a sentence is a frontend that will disagree with the others about it, and the disagreement is invisible until someone opens two of them side by side.
    • This is not only about literal duplication. Copy that is paraphrased across modalities is the same defect and harder to spot: "a node retired with nobody listening shows a record" versus "…prints a record" is two answers to one question. If two screens are explaining the same thing, they get the same words from the same constant.
    • The derivation belongs there too, not just the strings. Where the words depend on state — which of three sentences a fetch shows, whether a count reads "all" or "3 of 40" — the function that picks lives in copy.rs and the screen renders what it returns. copy::content_fetch is the worked example: desktop and Android had each grown their own state machine and disagreed about what "connecting" meant.
    • Keep it grouped by subject (copy::revocation, copy::content_fetch, copy::action) rather than by screen, since one subject reaches several screens on several modalities.
  • Typefaces come from the typeface module, never a family-name string. rust/gui-app/src/typeface.rs and Android's ui/theme/Typeface.kt are the only two places a font family is named; screens ask for a role (UI, MONO, prose). ADR 0024 has the full reasoning — short version: a CSS generic like .font_family("monospace") silently resolves to nothing in gpui, and a variable font loaded directly collapses to one weight, so vendored faces are always static per-weight instances. scripts/vendor- fonts.sh vendors them into assets/fonts/, shared by both modalities; the set exceeds jj's 1 MiB snapshot.max-new-file-size, so a fresh clone needs jj config set --repo snapshot.max-new-file-size 64MiB or the fonts stay untracked. To reconsider a face, use design/typeface-specimen/ rather than eyeballing it — it renders candidates on real content and measures them.
  • Never write an ALPN's version number outside the constant that holds it. Every P2P protocol's ALPN carries a version suffix, and it moves whenever the wire shape does — this project has no backwards-compatibility constraint, so bumps are routine and cheap. A number copied into prose is therefore wrong from the next bump onward, and nothing fails when it goes stale: it is a comment. Write /syn/doc-sync/… and name the constant (DOC_SYNC_ALPN) for anyone who needs the live value. This holds for docs, ADRs, and doc comments alike — including the module doc of the file that defines the constant, which is not close enough to stay correct on its own. p2p/revocation_delivery.rs had drifted so far its header named a protocol, /syn/revocation, that does not exist.
    • Say that an ALPN was bumped where the bump is the point — a change to what a message means, rather than a new field, is exactly what a bump is for and worth recording. Just don't say which number it landed on.
  • A value goes in a ?, never into the SQL string. Column names, table aliases, and a run of ?, ?, ? for a variable-length IN list are this codebase's own text and are fine to interpolate; anything that is data — a timestamp, an id, a search term, an enum's as_str() — is bound. stores::document::SyncScope is the worked example: it hands back a predicate and the values for it together, so there is no way to use it that puts a caller's string into SQL text. placeholders(n) is the one blessed way to build a list of ?s.
    • just lint-sql-params enforces this (scripts/lint-sql-params.py). It flags an interpolation wrapped in SQL quotes ('{x}') or sitting where only a value can go (after a comparison, after LIMIT), in any file that mentions rusqlite. A considered exception takes a // sql-literal-ok: <reason> anywhere in the same statement; the reason is mandatory, and the bar for one is that a parameter genuinely cannot go there (SQLite will not bind an identifier).
    • The reason it wants a linter rather than a rule of thumb is that the bug is invisible at the call site: whether format!(" AND x >= '{s}'") is a security hole depends entirely on where s came from. One shipped — SyncRequest.since is chosen by whichever ensemble member is asking to sync, and reached the WHERE clause verbatim.
  • design/ is for explorations you run to make a visual decision — neither docs/ (material you read) nor scripts/ (build, CI, vendoring). Nothing in it is on a build path and the app must never depend on it; the outcome of an exploration belongs in an ADR. See design/README.md before adding one.
  • Scroll containers need min_h(px(0.)). A flex_1 child of a flex column defaults to min-height: auto (its content height), so it grows past the window instead of shrinking to fit — and .overflow_y_scroll() on it never gets a bounded height to scroll within, leaving content stranded off the bottom edge. Apply it at every level between the bounded ancestor and the scrolling element, not just the scroller itself.
  • A list a user can grow without bound is virtualized — gpui's uniform_list, Compose's LazyColumn/LazyVerticalGrid, and tui-app/src/virtual_list.rs for ratatui. ADR 0037 has the per-modality rules; the ones that bite are that uniform_list gives every row row 0's height (so no gap, no row that grows, and a focused element inside the list has to be anchored so it can't be scrolled out of existence) and that the TUI helper assumes one terminal row per item. A collection with a structural ceiling — an ensemble's nodes, the TUI's agent rails — stays a plain loop.
  • Cap a reading column's width; don't let a detail screen fill a 4K monitor. A gpui screen fills its window, so a label/value list on a 2560px display puts every value at the left edge and every button half a metre away at the right, and a row's two ends stop reading as one row. The cap is sizing::layout::CONTENT_MAX_WIDTH, applied as a plain .max_w(CONTENT_MAX_WIDTH) on the scrolling body itself — not on a wrapper inside it, and not with a w(). On a flex_col body the flex_1 that fills the window vertically is grand-fathered past the rule below, because there it grows height and the cap is on the untouched cross axis. agents/documents/detail_view.rs and agents/human.rs are the call shape; components::detail_form is the label/value grid that goes inside, and deliberately does not offer a second way to do the capping.
    • Lists that are genuinely about scanning many columns — the document table, the presence grid, the media library — stay uncapped, because there the width is the information. The test is whether a row's rightmost element means anything without its leftmost.
    • Sidebars and other already-narrow containers need nothing; neither does a component, which should let the screen that hosts it decide.
  • Don't size a container by flex_1 plus max_w. When the free space exceeds the maximum, the element's final width comes from growing and then being clamped — but its children are laid out against its max-content width, and never replaced. The frame draws at its grown width with its contents placed for the smaller one, so a nominally 630px search field gave its input 2px and clipped every glyph while the caret (2px wide) survived. Worse, it depends on how much free space the row happens to have, so the same component works on one screen and not the next. Give such a container a definite w(..) and flex_shrink_1() instead — a definite width is never re-sized after its children are placed. components/screen_header.rs's header_search carries the worked example.
    • just lint-gui-layout enforces this (scripts/lint-gui-layout.py) — it fails on .flex_1() and .max_w() in the same element chain, matching parens rather than lines so an exploded chain and a .child(div()...) argument are each seen correctly. A chain that also declares .flex_col() is exempt: there flex_1 grows height and the cap is on the untouched cross axis. Run it after touching gpui render code; clippy cannot see this class, since both calls are ordinary on their own.
  • Cross-platform UI iconicity: when a screen needs to switch between sub-views within itself (not top-level app destinations), use the GUI's components::tabs::{tab_bar, tab_pill} on the gpui desktop app and a top-of-screen Jetpack Compose TabRow/Tab on Android — not NavigationBar/NavigationBarItem, which Material 3 reserves for 3-5 persistent, equal-importance, app-level destinations (see https://developer.android.com/develop/ui/compose/components/navigation-bar vs https://developer.android.com/develop/ui/compose/components/tabs). Examples: the document detail screen's Overview/Sync split, and the Settings screen's System/Sync/Ensemble split. Keep the Android tab row at the top of the screen to match the GUI's tab bar placement.
  • A label/value list is two-column on the desktop and stacked on Android, and that is on purpose. The desktop reads a record as a grid — label in a fixed left column, value beside it (components::detail_form) — because a reading_column is 1040px and leaves the value ~870. A phone's content width is ~330-380dp, so the same 160dp label column leaves 156-208dp: long values wrap to three lines and every row ends up taller than the stacked version it replaced. Android therefore stacks label over value.
    • This is the split the desktop and mobile versions of the same apps make for the same reason — macOS and GNOME Contacts are two-column, iOS and Google Contacts stack. It is a width decision, not a platform-taste one, so don't "fix" the inconsistency by making them match; the thing that would change the answer is width, not tidiness.
    • Which means a tablet or foldable is the open question, not a phone. The app has no adaptive-layout machinery at all today (no WindowSizeClass, no BoxWithConstraints), so Android's choice is currently unconditional. Introducing that machinery is what would let the grid appear on a wide Android window, and it is worth doing for its own sake before it is worth doing for this.
    • Worked examples: human.rs's read_only_row/read_only_values against HumanDetailScreen.kt's ReadOnlyField. Android's one stacked row is ui/LabeledValue; reach for it rather than laying out another.

VCS Workflow

  • This project uses Jujutsu (jj) with a git backend. Prefer jj commands over git when the checkout is a Jujutsu repo (check for .jj/ directory). If only git is available (e.g. a plain git clone), git commands are fine.
  • Prefer small, self-contained commits, each representing a single logical change — use as many individual commits as a feature needs; it makes review easier and enables cleaner history/bisection.
  • jj's working copy (@) is always a commit, but nothing gives it a message automatically. When you finish a unit of work, run jj describe -m "<message>" then jj new to seal it and start the next change clean — don't leave @ undescribed, and don't keep piling unrelated work onto one anonymous change. Do this early and often, not just at a session's end: the human developer may step away and not return until the context cache has expired, so a change is only recoverable with a good message if it was written while that context was still warm.
  • Make jj commits freely, but leave changing the commit that jj bookmarks point to, or decisions about when and what commits to push to a remote repo, up to the human developer. location of bookmarks or
  • gstack skills that key off branch identity (/ship, /autoplan, /plan-*-review's file-naming and task scoping) shell out to plain git (git branch --show-current) to find "the current branch." jj's default bookmark-less @ is git-detached-HEAD, which that returns "" or "HEAD" for — not a real branch identity — so gstack's per-branch scoping buckets all bookmark-less work together. Before invoking one of these skills, run jj bookmark set <name> -r @ (a throwaway name is fine) so git branch --show-current resolves to something real.

Testing

  • when developing GUI functionality, always ask the developer to manually run the GUI and visually inspect the output; do not try to automatically test GUI functionality using screenshots or other similar tools
  • when creating tests for new functionality, try to make them as concise as possible. This project uses the rstest crate to define test fixtures and parameterized tests