tomo docs
home github

Configuration

Everything lives in <project>/.tomo/config.toml. The file is optional; with no file you get the defaults. There's no global config.

Path rules and classes #

Every path in the tree is classified into exactly one of three classes:

ClassSynced?Versioned?For
synced+versionedyesyessource files — the default for any path no rule matches
synced+unversionedyesnobuild artifacts you want mirrored back without history
ignorednonothings that must never cross the wire (e.g. target/)

You assign classes with [[rules]] entries — git-style glob patterns, matched against the path relative to the project root:

# History capture: adaptive (default) | every-change | off | { interval_ms = 5000 }
[history]
mode = "adaptive"

[[rules]]
pattern = "target/"           # trailing slash expands to target/**
class = "ignored"

[[rules]]
pattern = "dist/**"
class = "synced+unversioned"
direction = "pull"            # both (default) | push | pull

Glob semantics #

Last matching rule wins #

When several rules match a path, the last one in the file wins (git-style precedence). This lets a narrow later rule carve an exception out of a broad earlier one:

[[rules]]
pattern = "build/**"
class = "ignored"

[[rules]]
pattern = "build/keep/**"     # re-include one subtree
class = "synced+versioned"
Direction is load-bearing. A pull-only rule on a server's build directory stops a target/-spraying build from growing your history at build speed. Class and direction are enforced on receive as well as send — an ignored or wrong-direction path is refused at ingress, never applied, never recorded — so a peer on an older binary can't sneak one past you.

Built-in default ignores #

Unless you turn them off, Tomo prepends a small set of built-in ignored rules for editor/tool churn, OS metadata, database sidecars, git metadata, and large regenerable dependency/cache trees. They are applied before your rules, so (last match wins) any [[rules]] entry for the same glob overrides a default. This is the complete current list:

GroupPatterns
vim/editor temp**/*.swp, **/*.swx, **/.*.sw?, **/4913
emacs/gedit**/*~, **/.#*, **/#*#
OS metadata**/.DS_Store, **/Thumbs.db
SQLite sidecars**/*.sqlite-wal, **/*.sqlite-shm, **/*.sqlite-journal
*.db sidecars**/*.db-wal, **/*.db-shm, **/*.db-journal
git metadata**/.git, **/.git/**
dependency / cache trees**/node_modules, **/.venv, **/venv, **/__pycache__, **/.pytest_cache, **/.mypy_cache, **/.ruff_cache, **/.terraform (each with a matching /** for its contents)
IDE / editor project dirs**/.idea, **/.vscode, **/.vs, **/.fleet, **/.zed (each with a matching /**), plus **/*.sublime-workspace
web-framework dev caches**/.next, **/.nuxt, **/.svelte-kit, **/.astro, **/.angular, **/.turbo, **/.parcel-cache, **/.expo, **/.vite, **/.docusaurus, **/.cache (each with a matching /**)

The .git defaults cover the root repo, nested repos, and submodules — .git is a directory in an ordinary clone but a file in a worktree or submodule, so the bare **/.git matches both forms. Syncing .git would cross-contaminate two independent repositories' objects and index; two .git trees must ignore each other entirely. Lookalikes like .gitignore, .gitattributes, and .gitkeep are not matched and sync normally. The .db/.sqlite journal patterns are anchored to those two stems on purpose — a bare *-journal would wrongly swallow an ordinary file like travel-journal.

The dependency and cache trees — node_modules, Python virtualenvs (.venv/venv) and tool caches (__pycache__, .pytest_cache, .mypy_cache, .ruff_cache), and Terraform's .terraform — are each ignored as a directory and its contents, following the same two-pattern shape as .git. They share one rationale: they're large, machine-regenerable, and frequently platform-specific (native node_modules addons, absolute-path virtualenvs, Terraform provider binaries), so dragging them across a Mac↔Linux pair is wasted bytes at best and broken on the peer at worst. Only whole path components match, so a directory merely named like a default — node_modules_backup, my.venv — syncs normally.

IDE and editor project dirs — .idea, .vscode, .vs, .fleet, .zed, and Sublime's per-user *.sublime-workspace — are ignored by default too. They mix shareable settings with machine-local state (indexes, caches, absolute SDK paths) that churns constantly and is plain wrong on the other machine — a synced .idea/ happily points your Linux box at your Mac's JDK. If your team checks .vscode into the repo, git already carries the shared copy; want tomo to sync it anyway? Re-include it with the two-rule pair below. (*.sublime-project, the shareable half of Sublime's pair, stays synced.)

Web-framework dev caches — .next, .nuxt, .svelte-kit, .astro, .angular, .turbo, .parcel-cache, .expo, .vite, .docusaurus, and the generic .cache — are each a dev server's or CLI's regenerable scratch dir: machine-local, disposable, and often platform- or absolute-path-specific, so they never help the peer. They're ignored as a directory and its contents, like the trees above, and every one is overridable. These are dev-server state, not deploy artifacts — the build-output decision below is unchanged. (.cache is dot-prefixed so collisions are unlikely, and its bare glob matches a lone file named .cache as well as the directory, which is intended.)

What we deliberately don't ignore. Build outputstarget/, build/, dist/ — are not default-ignored, because getting a remote build's artifacts to flow back to your laptop (as synced+unversioned, pull-only content) is one of Tomo's flagship use cases; a blanket default would break that out of the box, so you opt out with a single one-line ignored rule if you want to. Nuxt/Nitro's .output is left synced for the same reason (it's the built deploy artifact, unlike the ignored .nuxt dev-state dir), and so is public — it's a build output in Gatsby but the source static-assets dir in most other frameworks, so default-ignoring it would silently drop hand-authored files. And .env is often the very file the remote needs to run the app, so ignoring it by default would silently break deploys.

Disable the whole set with:

[sync]
default_ignores = false       # ON by default

Re-including an ignored tree takes two rules #

Because the scan won't descend into an ignored directory, un-ignoring a whole tree needs two rules — one for the directory itself, one for its contents — exactly like git:

[[rules]]
pattern = ".git"              # un-ignore the directory itself…
class = "synced+versioned"

[[rules]]
pattern = ".git/**"           # …and everything under it
class = "synced+versioned"

What you cannot reconfigure #

.tomo/** is always ignored, enforced below the rule engine as a constant — no rule, however hostile, can make Tomo watch, sync, or version its own state directory. Only the first path component counts, so a nested a/.tomo in your own tree is an ordinary path.

That directory also holds a generated .tomo/README.md — a short brief for any coding agent working in the tree, explaining that files can change under it in real time (a peer's edits land in milliseconds) and that every save is recoverable with tomo log / tomo restore. Because .tomo/** never syncs, each machine writes its own copy; Tomo regenerates it but never touches your CLAUDE.md/AGENTS.md. Who's on the other end lives in .tomo/state/status.json (or tomo status --json).

History modes #

The [history] section chooses how often a save becomes a version:

ValueBehavior
"adaptive"Default. Flushes ~75 ms after a save under light load so every save is versioned, then escalates the flush interval under storms to coalesce bursts into checkpoints, and decays back to immediate when idle.
"every-change"Every canonical change becomes its own version, with a literal 0 ms window. No coalescing.
"off"No history capture. (Sync still works; you just keep no versions.)
{ interval_ms = 5000 }Coalesce a burst into one version per fixed interval, in milliseconds.
Latency is never affected. All of this governs history capture only. The live sync path always ships the latest bytes immediately, and the final state of every burst is always versioned — no matter how aggressively history is coalescing.

The remote peer #

tomo connect and the first tomo sync write the [remote] section for you. You can also write it by hand:

[remote]
host = "you@gpu-box"          # SSH target (resolved through ~/.ssh/config)
path = "/home/you/proj"       # the peer's project-root path
identity = "~/.ssh/gpu_ed25519"  # optional explicit key (else agent / config / defaults)

SSH config support #

The target you give is resolved through ~/.ssh/config first, exactly as your own ssh would — so Tomo authenticates and connects wherever ssh host already works, including hosts reachable only through a jump host or with a non-default key. Point Tomo at a different file with TOMO_SSH_CONFIG. Supported directives:

DirectiveSupport
HostPattern blocks with * / ? / !, plus the global pre-Host section. First-obtained-wins per ssh_config(5).
HostNameAlias → real host. Literal only%h and other token substitution is not performed (rare in practice).
User, PortHonored.
IdentityFileAccumulates (multiple allowed); tried in order.
IdentitiesOnlyyes skips ssh-agent keys and uses only the declared identities.
StrictHostKeyCheckingyes / no / accept-new / ask. ask is treated as yes since Tomo is non-interactive.
UserKnownHostsFileOne or more paths; default ~/.ssh/known_hosts + known_hosts2. /dev/null means nothing is known.
GlobalKnownHostsFileDefault /etc/ssh/ssh_known_hosts + …_known_hosts2. Always consulted for lookup, never recorded into.
ProxyJumpComma-separated [user@]host[:port] chain; each hop is resolved recursively (cycle guard, depth cap 8). none disables.
IncludeGlob-expanded, relative to ~/.ssh, processed in place.

Unknown keywords are ignored (their names collected for a debug line).

Authentication order #

Keys are tried in this order, first accepted wins: ssh-agent (unless IdentitiesOnly yes) → the [remote] identity you recorded → the IdentityFiles from ~/.ssh/config → the built-in ~/.ssh/id_ed25519 / id_rsa. Encrypted (passphrase) keys are out of scope for v0.

Known-hosts semantics #

Environment variables #

VariableEffect
TOMO_COLORalways / never / auto — force or auto-detect colored output. (Auto: colored on a terminal, plain when piped.)
NO_COLORThe standard opt-out; any value disables color, same as TOMO_COLOR=never.
TOMO_ASCIISet to 1 to force ASCII-only glyphs (no Unicode check-marks or badges).
TOMO_SSH_CONFIGPath to an SSH config file to use instead of ~/.ssh/config. For test hermeticity and power-user redirection.
TOMO_INSTALL_DIRRead by the installer (install.sh): where to place the binary. Defaults to ~/.local/bin.

--json output is always byte-identical regardless of these — styling never leaks into machine-readable surfaces.

Troubleshooting #

"It works with ssh but Tomo can't connect." Run tomo dev ssh-route <target> and diff it against ssh -G <target>. ssh-route is Tomo's ssh -G analogue: it prints, per hop, the resolved hostname/port/user, the identity files it will try, the StrictHostKeyChecking policy, the known-hosts files consulted, and the ProxyJump chain — pure resolution, no network. A mismatch (a missing IdentityFile, an unsupported %h token in HostName, a jump host Tomo didn't pick up) shows up immediately.
The local-~ gotcha. tomo sync host:~/proj expands ~ on the server — that is the form to use. The old two-argument form (tomo sync host ~/proj) was removed precisely because an unquoted ~ there lets your local shell expand it first, turning it into an absolute path under your local $HOME before Tomo sees it. A stray second argument now gives a clear error showing the combined host:~/proj target to use instead; and as a backstop, a remote path that is absolute and under the local $HOME is still refused before any SSH with a copy-pasteable fix.