Sync
How nikcli journals local events, projects them into typed snapshots, ships them through an idempotent outbox, and replays them onto an optional remote hub.
Overview
The sync subsystem is the bridge between a local nikcli process and the optional
remote hub (such as https://s.nikcli-ai.dev). It turns the
ephemeral bus events a session produces into a durable, ordered, replayable log, then ships
that log over HTTP when (and only when) a remote is configured.
Sync is opt-in. With no NIKCLI_REMOTE_URL and no sync
block in the global config file, the local event log and projections still exist, but no
outbound transport runs. Nothing about the local CLI, TUI, or server depends on the hub
being reachable.
Local event log
Append-only per-project event store backed by SQLite (syncEvent, syncSequence).
Projections
Per-aggregate reducers rebuild typed state (session, message, permission, …) from the log.
Outbox
Outgoing events are kept locally until the hub confirms them, so retries are idempotent and crash-safe.
Hub transport
Batched push of confirmed events with token-scope checks, rate limiting, and audit logs.
The event log, projections, outbox, and remote transport are independent. You can disable
only the hub transport by clearing sync.url / NIKCLI_REMOTE_URL
while keeping local journaling for fast cold-starts and snapshot restores.
What gets synced
Anything published on GlobalBus is fair game, but the persisted log filters that
stream down to the aggregates that are useful to replay on another machine. The reducer in
src/sync/reducer.ts maps each event type to its aggregate and project id.
| Aggregate | Examples | Used for |
|---|---|---|
session | Session created, updated, archived, deleted, parent changed. | Project-wide session list, mobile cold-start. |
message | User / assistant / tool parts streamed, edited, or removed. | Transcript replay and snapshot restore. |
permission | Ask, reply, auto-allowed rules. | Hydrating pending approval bars after reconnect. |
question | Ask, reply. | Hydrating open questions across reconnects. |
todo | Todo list updates and notifications. | Cross-device task state. |
workspace | Workspace connection status and lifecycle events. | Remote workspace dashboards. |
Events carry projectID and (when applicable) workspaceID. The
local log and the hub both key on these, so two projects in the same data root never see
each other's events.
Event log
src/sync/sync.sql.ts defines two SQLite tables under the data root:
syncEvent (one row per event) and syncSequence (last-seen seq per
project + aggregate). The storage layer in sync/index.ts exposes load/save
helpers and applies two practical limits:
| Setting | Value | Why |
|---|---|---|
MAX_EVENTS_PER_AGGREGATE | 1000 | Caps per-aggregate growth between compactions. |
COMPACTION_TRIM_TO | 500 | After compaction, keep 500 most recent events per aggregate. |
Compaction rewrites the table transactionally: it deletes all rows for a project and
re-inserts the surviving window in one transaction, so a crash mid-compaction cannot leave a
partial log. saveEvents and saveSequence are the only writers
outside projection tests.
Projections and snapshots
Projections turn the log into typed state. There are two complementary paths:
- Live projection in
src/sync/projector.tsandsrc/sync/projection.ts: a per-aggregate reducer updates in-memory state on every event. TUI routes and the SDKsyncprovider subscribe to it. - Cold-start snapshot in
src/sync/snapshot.ts: when the SDK reconnects (cold start, server restart, app foreground), it asks the server for a single typed snapshot rather than replaying thousands of events. Snapshots are produced from the same projection state, so live and cold-start paths converge.
# Cold-start endpoint used by SDK clients
curl "http://127.0.0.1:4096/sync/snapshot?directory=$PWD"
Projections are reset on SIGUSR2 worker reloads — the next event simply
rebuilds state from the surviving event log, and snapshot consumers refresh on reconnect.
Outbox and idempotency
The outbox (src/sync/outbox.ts) is the only writer that talks to the hub. It
exists so a crash between "event written" and "hub acknowledged" never produces a duplicate
or a missed event.
| Concept | Implementation | Guarantee |
|---|---|---|
| Idempotency | Each event carries a stable id plus origin / originSeq. | The hub can dedup replays on the same key. |
| Per-aggregate seq | syncSequence tracks the last seq the hub confirmed per aggregate. | Outbox only pushes newer events after each ack. |
| Retry | Failed batches stay in the outbox with backoff metadata; retries are bounded by scope and rate-limit windows. | Process crashes, network blips, and hub restarts do not lose events. |
nikcli sync status
# target: https://s.nikcli-ai.dev (config file)
# outbox pending: 12
# outbox failed: 0
# outbox total: 1024 Remote transport
The remote client (src/sync/remote-client.ts) batches confirmed outbox entries
and POSTs them to the hub's /sync/push endpoint. The remote sync loop
(src/sync/remote-sync.ts) wires that client to the outbox and projection:
- Reads the local projection state at startup to catch up after a crash.
- Subscribes to the live event stream for new entries.
- Reconciles after transient failures using stored seq counters.
Without a configured hub, the transport loop never starts. Local journaling, projections, and snapshot endpoints continue to work — they just don't leave the machine.
CLI command
nikcli sync is the user-facing entrypoint. It exposes status, an explicit
connect/disconnect flow, and a token issuer for new CLI machines.
# Set the target hub for this shell
export NIKCLI_REMOTE_URL=https://s.nikcli-ai.dev
export NIKCLI_REMOTE_TOKEN=<cli-sync token>
nikcli sync status # outbox state + source of truth
nikcli sync connect # force a connection (Ctrl-C to disconnect)
nikcli sync disconnect # how to end the connect session
nikcli sync token create --name laptop --expiry-days 90 | Subcommand | Behavior |
|---|---|
status | Prints resolved URL/token source, plus pending / failed / total outbox counts. |
connect | Boots the remote sync loop and keeps it running until interrupted. |
disconnect | There is no separate stop API — ending the connect process is the way to disconnect. |
token create | Issues a cli-sync scoped mobile token for a new CLI machine. |
TUI sync dialog
The TUI exposes sync through dialog-sync.tsx and the
remote-sync context. From the command palette the user can:
- Save URL + token to the global config (written through
POST /sync/config). - Watch live stats polled from
/sync/statsevery two seconds. - Toggle the
autostartflag sonikcliconnects on bootstrap. - Inspect outbox pending / failed counts and trigger a reconnect.
SyncConfig.invalidate() is called after every config write so the next
resolve is fresh, even though the read path caches values for five seconds.
Auth, scopes, rate limits
Tokens used for hub push are minted by the host's mobile auth flow. There are two distinct scopes:
| Scope | Issued by | Permits |
|---|---|---|
mobile | nikcli mobile pair / serve --pair | Full phone workflow: bootstrap, sessions, repos, git, terminals, approvals, routines. |
cli-sync | nikcli sync token create | Hub push only. Cannot open sessions or read messages. |
Hub endpoints enforce scope at the route group level and apply a per-token rate limit
(sliding window) and an audit log entry per push. The audit log records tokenID,
aggregate count, and seq range so the CLI side can correlate its own outbox state with what
the hub saw.
Use cli-sync tokens on headless machines. mobile tokens are
full-power and should stay on phones or paired devices only.
Bootstrapping
The bootstrap flow decides whether to autostart sync. Two flags are honored, in priority order:
NIKCLI_REMOTE_AUTOSTART=1— force autostart regardless of config.config.sync.autostart: true— autostart when both URL and token are present.
With either set and both URL + token resolved, RemoteSync.start runs once per
nikcli bootstrap. The CLI keeps the sync loop running alongside the TUI or
session; --print-logs surfaces retry / failure messages from the hub client.
Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
remote sync not configured | Neither env vars nor sync block are populated. | Set NIKCLI_REMOTE_URL + NIKCLI_REMOTE_TOKEN, or use the TUI /sync dialog. |
outbox failed grows on every push | Hub rejected the token or exceeded the rate limit. | Check token scope, rotate via nikcli sync token create, then reconnect. |
| TUI stats seem stale | Config cache TTL is five seconds; recent writes may not have flushed. | Reopen the dialog or call SyncConfig.invalidate() after a write. |
| Cold start missing sessions | Hub snapshot returned an older revision than the local log. | Verify projectID matches; the local log is still the source of truth. |
| Compaction truncated events I needed | An aggregate exceeded MAX_EVENTS_PER_AGGREGATE. | Take a snapshot first; the next compaction keeps the latest 500 events plus the snapshot. |
| Hot reload drops live state | Worker reload rebuilds projection state from the event log. | Wait for the next event; reconnect SDK clients to refresh from a snapshot. |
Source anchors
| Need | File |
|---|---|
| Event log schema | packages/nikcli/src/sync/sync.sql.ts |
| Local storage helpers | packages/nikcli/src/sync/index.ts |
| Live projection | packages/nikcli/src/sync/projector.ts, projection.ts |
| Cold-start snapshot | packages/nikcli/src/sync/snapshot.ts |
| Event reducer | packages/nikcli/src/sync/reducer.ts |
| Outbox | packages/nikcli/src/sync/outbox.ts |
| Hub transport | packages/nikcli/src/sync/remote-client.ts, remote-sync.ts |
| Hub sync CLI | packages/nikcli/src/cli/cmd/sync.ts |
| TUI sync dialog | packages/tui/src/component/dialog-sync.tsx |
| TUI sync context | packages/tui/src/context/remote-sync.tsx |
| Sync config | packages/nikcli/src/sync/sync-config.ts |