cosmix-editd — the edit citizen
cosmix-editd registers the Bus service edit: shared text buffers that
humans and agents edit together. A buffer has a revision number, a log of
who changed what, a separate undo lane for each origin, named anchors that
follow the text, atomic saves, and a change feed a mirror can follow exactly.
It is the core of ced, the Cosmix editor. The ced app is one
frontend of this daemon. An agent that sends edit.* verbs is another, and
both have the same standing.
The text engine is cosmix-edit-core. That crate is pure Rust with no Bus
code and no async runtime. It builds on a vendored subset of Microsoft's
msedit (MIT): the gap buffer and the SIMD
line scanner. See libraries.
Unsaved text survives a restart (0.2.0). Every dirty buffer is mirrored to recovery files, and a daemon that stops, crashes or is killed comes back with those buffers restored: at most the last second of edits is lost on a crash, none on SIGTERM. Undo history is not recovered. See Recovery.
edit.inforeports"volatile": falsewhile recovery is enabled and healthy; withCOSMIX_EDIT_RECOVERY=0the daemon is E0's volatile one again.
Running it
/opt/cosmix/bin/cosmix-editd serve # registers `edit`, READY=1 once registered
/opt/cosmix/bin/cosmix-editd --version # cosmix-editd <semver> (<sha12>, built …)
The public src/_etc/systemd-user/cosmix-editd.service is a user unit with
Type=notify and WantedBy=default.target. It is not tied to a graphical
session, because agents use the daemon headless. The daemon runs as the user
whose files it edits: ~/… paths expand against the daemon's HOME.
Conventions
- Arguments are a JSON object, which is the body a Mix
sendbuilds fromkey=valueargs. Nested maps and lists are written directly:ops=[{op: "insert", at: "end", text: "x"}]. Never addbody=to aneditsend, because it switches Mix to header routing and the other args stop reaching the daemon as a JSON body. - Success is
rc 0. A refusal isrc 10with a body shaped{"error_code","message","reason"?,"buffer"?,"rev"?,…}. On a plain kv send, Mix puts that body's raw JSON text in$result, so read the fields from$reply, for example$reply.error_codeand$reply.rev. - Codes:
INVALID_ARGUMENT NOT_FOUND CONFLICT RESOURCE_LIMIT IO_ERROR UNKNOWN_VERB FORBIDDEN INTERNAL.CONFLICTreasons:stale_rev overlap history_trimmed undo_conflict dirty disk_modified path_open exists. ACONFLICTalways carries the buffer's currentrev. - Refusal precedence. When several checks would fail, the first in this order is reported: unknown verb → argument shape → unstamped mutation → mesh lock → buffer/epoch lookup → op_id duplicate (returns the original reply) → CAS → position resolution → limits → I/O.
- Buffer ids look like
b3_9f2c41a7. The suffix is the daemon epoch, 8 hex digits chosen fresh at each start. Every reply that names a buffer also carriesepoch. After a restart, an old id getsNOT_FOUNDepoch_mismatch. - Mesh-open. Local and mesh callers reach every verb. With
COSMIX_MESH_OPEN=0in the daemon's environment (read once at start), mutating verbs from a non-local broker are refused withFORBIDDENmesh_locked. A mutating request that did not come through a broker (nobroker_origin) is refused withINVALID_ARGUMENTunstamped.
Wire forms
BUFFER := "b<N>_<epoch>"
POS := <byte offset> | {"line":L,"col":C?} | {"anchor":"name"} | "start" | "end"
RANGE := [s, e] | {"start":POS,"end":POS} | {"lines":[a,b]} | {"anchor":"name"} | "all"
SEL := RANGE | {"anchor":POS,"head":POS} (explicit direction)
POINT := {"offset":N,"line":L,"col":C} (every position in a reply)
OP := {"op":"insert","at":POS,"text":S}
| {"op":"delete","range":RANGE}
| {"op":"replace","range":RANGE,"text":S}
EDIT := {"offset":N,"delete":N,"insert":S} (application order, sequential coordinates)
- Offsets are UTF-8 byte offsets and must fall on a char boundary
(otherwise
not_char_boundary). lineandcolstart at 1.colcounts every Unicode scalar value from the start of the line,\rincluded. A line ends at\nalone, so every char boundary of a CRLF file is addressable.{line}alone means col 1.{"lines":[a,b]}runs from the start of line a to the start of line b+1, so it includes b's\n. When b is the last line it runs to the end of the buffer.bytesalways means text bytes, with any BOM excluded.
Origins, lanes and the kind rule
Every edit records an origin kind:label, where kind is human, agent
or tool and label matches ^[A-Za-z0-9._@/+-]{1,64}$.
- A mutating verb takes an optional
originclaim. Foredit.undoandedit.redothe claim isas, because thereoriginnames the lane to undo. Without a claim the origin is derived from the attested caller:agent:<from>for a registered local service,agent:<service>@<peer>for a mesh caller, andagent:anonfor an anonymous one-shot. - The kind is attested; the label is free.
human:is honoured only for a local registered caller, which is what a frontend is.tool:is never honoured, sincetool:disk(external reload) andtool:editdare the daemon's own. Any other claim keeps its label with kindagent, and the reply says"origin_downgraded": true. So an anonymousorigin="human:x"edits asagent:x. A human frontend on another node edits asagent:until attested remote identities exist. - Lanes. Each origin has its own undo and redo stacks.
edit.undoundoes your own lane by default.origin="agent:foo"undoes that lane, andorigin="*"undoes whichever lane holds the newest group. Mark's Ctrl+Z never eats an agent's edit, and an agent's undo never eats his. - Undo is checked in full before anything changes. If another origin has
since edited the same text, the undo is refused with
CONFLICTundo_conflict, which names the intervening rev. In that case nothing changes: no rev, no event, and the stacks stay as they were. coalesce: trueon a single-edit request merges it into your top undo group when it directly continues your previous edit. This is how keystrokes are grouped. Each keystroke still gets its own rev.- Anonymous callers share
agent:anon, including its lane and its op_id space. Passorigin=agent:<name>to get a lane of your own.
Concurrency: CAS and base_rev
Every text mutation (insert delete replace apply) takes at most one of these.
Passing both is refused with INVALID_ARGUMENT both_cas:
- Neither: the request applies to the current text. The last writer wins, by the caller's choice.
expect_rev: N: the request applies only if the buffer is at rev N. Otherwise it is refused withCONFLICTstale_rev, carrying the currentrev. This is the agent idiom:findorget, then edit with the rev you read.base_rev: N(for optimistic frontends): the positions are byte offsets in the text at rev N. Line/col, anchor and line-range positions are refused withbase_rev_needs_offsets. editd transforms the ranges through every edit since N and applies them. If an intervening edit overlapped them, the request is refused withCONFLICToverlap. If rev N has already been trimmed from the log, it is refused withhistory_trimmed. The reply says"rebased": truewhen a transform happened.- Tie priority is server order. When two inserts land at the same point, the one the server applied first comes first.
Transactions (edit.apply). Every op refers to the same base text. Ranges
that overlap within one request are refused with overlap_in_txn, and
nothing applies. Two non-empty ranges with the same start always count as
overlapping. An insert at a range's start or end is allowed. Inserts at the
same offset read in request order. An insert at a range's start reads
before that range's replacement text.
op_id retries. A mutating request may carry
op_id (^[A-Za-z0-9._:-]{1,64}$). A retry with the same caller, origin,
verb and op_id returns the original reply marked "duplicate": true, and
applies nothing. Only successful replies are cached, 1,024 per buffer, so a
refused request can be retried with its op_id.
Verbs
read_only in the manifest: ping info list get find anchor.get history props.*.
HELP lists the manifest.
| Verb | Args | Reply |
|---|---|---|
edit.ping | — | {pong, service:"edit", schema:"edit.v1", epoch} |
edit.info | — | name, schema, epoch, props_level:"L2", build provenance, buffers, dirty, volatile, mesh_open, event_seq, publisher_loss, limits, recovery (Recovery) |
edit.list | — | {epoch, buffers:[{buffer, path, opened_as, name, language, rev, saved_rev, dirty, disk, lines, bytes, holders, recovery_id, recovered}]} |
edit.open | path? (absolute or ~/…), create?, language?, origin? | {buffer, epoch, path, opened_as, name, language, rev:0, lines, bytes, eol, bom, disk, reopened, created, recovery_id, recovered, recovered_from} |
edit.close | buffer, force? | {buffer, closed, holders} |
edit.save | buffer, path? (save-as), expect_rev?, force? | {buffer, epoch, path, rev, saved_rev, file_bytes, disk:"clean", durable, warning} |
edit.reload | buffer, force?, expect_rev? | a mutation reply, or {buffer, rev, unchanged:true} |
edit.get | buffer, range?="all", numbered?, expect_rev?, snapshot?, max_bytes? (page budget: lower than the 4 MiB default, at least 4 KiB, still cut at a character) | {buffer, epoch, rev, text | lines, start, end, bytes_total, lines_total, truncated, next, snapshot} |
edit.insert | buffer, at, text + common | mutation reply |
edit.delete | buffer, range + common | mutation reply |
edit.replace | buffer, range, text + common | mutation reply |
edit.apply | buffer, ops:[OP…] + common | mutation reply |
edit.find | buffer, pattern, regex?, case?=true, range?, groups?, limit?=1000, from? | {buffer, rev, matches:[{start, end, text, text_truncated, groups, groups_truncated}], truncated, next} |
edit.select | buffer, ranges:[SEL…] | {buffer, rev, origin, origin_downgraded, selections:[{anchor, head}]} |
edit.cursor | buffer, at | same as select |
edit.anchor.set | buffer, name, at? | range?, bias? | {buffer, rev, anchors:[…]} |
edit.anchor.get | buffer, name? | {buffer, rev, anchors:[{name, start, end, bias, collapsed_rev}]} |
edit.anchor.clear | buffer, name | {buffer, cleared} |
edit.undo / edit.redo | buffer, origin? (lane: own / "*" / kind:label), as?, expect_rev?, op_id? | mutation reply + lane, undid / redid = [first_rev, last_rev] |
edit.history | buffer, since_rev?=0, limit?=100 | {buffer, rev, oldest_rev, entries:[{rev, origin, lane, kind, of, op_id, time, via, edits, edits_elided, text_bytes}], truncated, next} |
edit.recovery.flush | — | {synced, records, bytes, repairs} once every queued recovery record and repair is durable; synced:false when recovery is disabled |
edit.props.get|list|describe|watch | SPEC-07 props | see Props |
"Common" means expect_rev | base_rev, coalesce, cursor (post-edit
coordinates), op_id, origin. The mutating verbs are open close save reload insert delete replace apply select cursor anchor.set anchor.clear undo redo, and all of them take origin and op_id. select, cursor and
anchors do not bump the rev.
Mutation reply. The reply is compact and bounded, whatever the size of the
transaction. It never includes the edit list, which lives in the edit event
and in edit.history:
{"buffer":"b3_9f2c41a7","epoch":"9f2c41a7","rev":43,"base_rev":42,"origin":"agent:ctl-90",
"origin_downgraded":false,"op_id":"k-118","duplicate":false,"rebased":false,
"edit_count":1,"inserted_bytes":5,"deleted_bytes":3,
"changed_span":{"start":{"offset":120,"line":12,"col":1},"end":{"offset":125,"line":12,"col":6}},
"changed":[{"start":{"offset":120,"line":12,"col":1},"end":{"offset":125,"line":12,"col":6}}],
"changed_truncated":false,
"cursor":{"offset":125,"line":12,"col":6},"dirty":true,"lines":204,"bytes":8123,
"history_trimmed_to":null}
changedlists at most 64 inserted spans, in the new text's coordinates. Past that,changed_truncatedistrue, andchanged_spanstill gives the envelope of every change.- When an edit trims the log,
history_trimmed_tois set to the oldest rev still kept. Undo reach is the retained log: 100,000 entries or 32 MiB of edit text.
Open, holders and close. Opening a path that is already open returns the
same buffer (reopened: true) and adds your caller key to holders. A buffer
binds to the canonical path resolved at open, so a later retarget of a symlink
is not followed. A missing file binds through its canonical parent directory.
A dangling symlink binds to the link's own path, so the first save
replaces the link with a regular file. That is the one case where a save does
not keep a symlink. edit.open without a path creates a scratch buffer.
Opens are refused in these cases:
- The path, as given or as resolved, is longer than 1,024 bytes JSON-encoded:
INVALID_ARGUMENTbad_path. The path is repeated in replies, props and events, so its size is capped. - The
languageoverride does not match^[A-Za-z0-9._+#-]{1,32}$:INVALID_ARGUMENTbad_args. - A 33rd distinct caller opens a buffer:
RESOURCE_LIMITlimit. A buffer holds at most 32 caller keys, and a caller already holding it can always reopen.create: trueopens a missing file as an empty buffer withdisk:"none".closeremoves your key. The buffer is freed when no holders remain and it is clean. Closing the last holder of a dirty buffer is refused withCONFLICTdirty.force: truefrees the buffer and discards unsaved text, recovery files included. Holders are not yet reaped when an agent crashes.
Paging. get, find and history stop at a 4 MiB encoded reply and
return truncated plus next; resume from there. To read a consistent
multi-page copy, pass snapshot: true on the first get. editd freezes that
rev and returns "snapshot":"s7", and later pages pass snapshot:"s7". The
snapshot is released after the final page, on close, or by LRU eviction (at
most 2 per buffer). A released token gets NOT_FOUND snapshot_expired.
numbered: true returns lines:[{line, text, cont}], where a line split
across pages continues with cont: true.
Replace-all is find plus one apply with expect_rev, a Mix idiom
rather than a verb.
Anchors
A named anchor (^[A-Za-z0-9._-]{1,64}$, at most 1,024 per buffer, shared by
all origins) is a point or a range that follows the text as it changes. An
edit (p, delete dd, insert ii) maps a point a like this:
| Case | Result |
|---|---|
pure insert at p < a | a moves right by ii |
pure insert exactly at a | bias:"before" (default): stays · bias:"after": moves right by ii |
a delete starting at or after a | unchanged |
a delete that contains a (p < a < p+dd) | a collapses to p, and collapsed_rev records when |
a delete or replace wholly before a | a moves by ii − dd |
Range anchors do not expand: text typed exactly at either end stays outside
the range. Selections follow the same rules. The editing origin's carets use
after and every other origin's carets use before, so your caret moves
with your typing and not with someone else's.
Props
The daemon has an SPEC-07 read surface, edit.props.get|list|describe|watch.
props_level is L2.
lifecycle.props_level lifecycle.epoch lifecycle.volatile lifecycle.recovery_ok
lifecycle.event_seq lifecycle.publisher_loss lifecycle.recovery_unsynced (transient)
buffer_count
buffers.<bid>.path | opened_as | name | language | eol | bom | dirty | saved_rev | disk | holders
buffers.<bid>.recovery_id | recovered
buffers.<bid>.rev | lines | bytes | origin_last (transient)
disk is one of clean modified deleted none unwatched. lifecycle.volatile
is !(recovery enabled && recovery_ok) and changes while the daemon runs. Cursors and anchors
are not props; use the verbs. edit.props.watch replies with the
edit.props.changed topic, the domain topic edit.changed, and the bootstrap
recipe below.
The change feed
| Topic | Inner command | Body |
|---|---|---|
edit.props.changed | props.changed | SPEC-07 {path, old, new, ts, cause} |
edit.changed | edit.changed | one event, below |
{"event":"edit","epoch":"9f2c41a7","buffer":"b3_9f2c41a7","rev":43,"base_rev":42,"origin":"agent:ctl-90","lane":"agent:ctl-90","kind":"edit","of":null,"op_id":"k-118","edits":[{"offset":120,"delete":3,"insert":"hello"}],"event_seq":1207}
{"event":"cursor","epoch":"…","buffer":"…","rev":43,"origin":"human:ced","selections":[{"anchor":130,"head":130}],"event_seq":1208}
{"event":"anchor","epoch":"…","buffer":"…","rev":43,"name":"fn_render","start":98,"end":240,"event_seq":1209}
{"event":"disk","epoch":"…","buffer":"…","rev":43,"disk":"modified","event_seq":1210}
{"event":"open","epoch":"…","buffer":"…","path":"/home/u/x.mix","rev":0,"event_seq":1211}
{"event":"close","epoch":"…","buffer":"…","event_seq":1212}
{"event":"resync","epoch":"…","buffers":["b3_9f2c41a7"],"reason":"oversized","rev":44,"event_seq":1213}
kind on an edit event is edit, undo, redo or reload. event_seq
increases by one per event for the life of the daemon. An anchor event with
start: null means the anchor was cleared.
Mirror rule.
- Subscribe to
edit.changed. edit.get snapshot:trueto fetch the text and its rev.- Apply the
editevents whosebase_revequals your rev, applying each event'seditsin list order. - Refetch and resume from the new rev if any of these happens:
- an event's
base_revdoes not match your rev, event_seqskips a number,- a
resyncevent names your buffer (or"all"), epochchanges.
- an event's
Loss is always announced:
- An event larger than 256 KiB (for example a big paste, or undoing a big
delete) is replaced by
resync {reason:"oversized"}. - An event dropped from the publish queue or lost to a send failure marks its
buffer
resync_pending. The resync is retried with backoff (250 ms, doubling, capped at 30 s) until delivered, and goes out ahead of later events. - Every Bus reconnect publishes
resync {buffers:"all", reason:"reconnect"}.
In Mix: subscribe("edit.changed") plus on edit.changed … end.
$event.args is the event.
Files
-
Load. Files are UTF-8. Anything else is refused with
INVALID_ARGUMENTnot_utf8. A BOM and the line-ending style are detected and preserved. The size is checked before the file is read. The language is detected from the name (*.mix→mix,*.conf.mix→mix-data,scene.mix→scene,rs→rust, and so on);edit.open language=overrides it. -
Save writes a temp file beside the target, fsyncs it, then rechecks the destination immediately before replacing it:
- For a plain save, the file on disk must still be the one this buffer last
loaded or saved (same dev/inode/size/mtime), or be absent if it was never
saved. Anything else is refused with
CONFLICTdisk_modified, andforce: trueoverwrites anyway. - For save-as (
path=), a target that exists is refused withCONFLICTexistsunless you passforce. A target open in another buffer is refused withpath_open.
The save then renames the temp file over the target and fsyncs the directory. If that directory fsync fails after the rename, the reply says
durable: falseand carries awarning. - For a plain save, the file on disk must still be the one this buffer last
loaded or saved (same dev/inode/size/mtime), or be absent if it was never
saved. Anything else is refused with
-
The honest guarantee. A save is an atomic replacement guarded by a precondition, not a filesystem compare-and-swap. A writer that lands between the final check and the rename is overwritten, which is the same window every mainstream editor has. Writing the canonical target keeps a symlink intact, except a link that was dangling at open, which the first save replaces with a regular file. Hard links to the old inode are detached.
-
External changes. editd watches each open file's parent directory through inotify; it does not poll. When the file changes on disk:
- A clean buffer reloads with a minimal edit (the common prefix and
suffix are kept). The reload is logged as one
reloadentry in lanetool:diskand can be undone. - A dirty buffer becomes
disk:"modified"and keeps your text. Useedit.reload force=trueto take the disk version, oredit.save force=trueto overwrite it. - A deleted file becomes
disk:"deleted".
A replaced or removed parent directory is re-watched by path. If the watch fails (inotify limit, network filesystem),
diskbecomesunwatchedand the save-time check still protects the file. - A clean buffer reloads with a minimal edit (the common prefix and
suffix are kept). The reload is logged as one
Recovery
Since 0.2.0 editd keeps recovery files for every dirty buffer, path or
scratch, whoever edited it. A daemon that stops, crashes or is killed comes
back with those buffers, before it registers edit and before READY=1.
Where. COSMIX_EDIT_RECOVERY_DIR, else
$XDG_STATE_HOME/cosmix/edit/recovery, else
~/.local/state/cosmix/edit/recovery. The directory is 0700 and the files
0600. COSMIX_EDIT_RECOVERY=0 turns recovery off: the daemon is E0's
volatile one (volatile: true), and SIGTERM logs each dirty buffer as
discarded.
Identity. Each buffer gets a recovery_id (16 lowercase hex) when it is
created, reported by edit.open, edit.list and the props. It survives
restarts, so a frontend can find its scratch buffer again by id.
Generations. A buffer's recovery state is one generation: a snapshot of
the text at some rev plus an append-only log of every edit after it. A
<rid>.meta.json file names the current generation and is the commit point.
Files are never truncated or rewritten in place. A new generation (on the
clean → dirty transition, when the log outgrows the snapshot, after a write
failure, and at every restore) is written in full, fsynced, and only then
named by the meta; the old one — and any a failed switch left behind — is
deleted afterwards. A crash at any moment
leaves the meta naming a complete generation.
The loss window.
- Healthy: at most 1 s of edits on a crash, kill or out-of-memory abort. The log is fdatasynced 1 s after its first unsynced record.
- SIGTERM: none. The daemon drains the queue and syncs before it exits
(within the 10 s shutdown budget), then logs each dirty buffer as
keeping (in recovery files). - Degraded (the 8 MiB write queue overflowed, or a write failed): a crash
loses everything since the last completed sync, and that window keeps
growing while the failure persists — records are dropped, not queued, until
a repair generation is durable. The daemon writes that fresh generation at
once (and again at each later edit while writes fail); meanwhile
recovery.okisfalse,volatileistrue, andlifecycle.recovery_unsyncedcounts what is at risk. - Not recovered: undo and redo history, anchors, selections and holders. A restored buffer starts at rev 0 with an empty log.
edit.recovery.flush replies once everything queued before it, and every
pending repair, is on disk: {synced: true, records, bytes, repairs}. Call it
before a planned restart, or in a test before a SIGKILL. With recovery off it
answers synced: false.
Restore. At start the daemon scans the directory:
- A malformed meta or snapshot moves every file of that buffer into
recovery/quarantine/. Nothing is deleted. - A torn or corrupt log tail is salvaged: replay stops at the first bad record and keeps everything before it, with a warning naming how many records were dropped.
- Files of retired generations, files with no meta and
*.tmpleftovers are removed. - A restored buffer is rev 0 = the recovered text,
recovered: true, and dirty even though it was never saved in this session. An unforced close of its last holder is refused withCONFLICTdirty, a disk change marks itdisk:"modified"and never reloads it, and an immediate restart restores it again. The flag clears at the next durable save or explicit discard. - A path buffer takes its path again and is compared with the disk. Content
equal to the recovered text: the buffer is clean and its files are removed.
Same file identity, different content:
disk:"clean", dirty. A different file identity:disk:"modified", so a plain save refuses withdisk_modified. A missing file:disk:"deleted". edit.openof a restored path returns that buffer (reopened: true) withrecovered_from: {epoch, rev, time_ms}, naming the daemon session it came from.- Buffers over the buffer or byte limits stay on disk (
skipped) and are restored at a later start.
Cleanup. Recovery files are deleted only by a durable save (the reply
says durable: true), a clean reload, or an explicit discard
(edit.close force=true). An ordinary close never deletes them. After a
durable: false save they are kept, and the next restore finds the content
equal to the disk and cleans them up.
Status. edit.info.recovery is
{enabled, ok, degraded, dir, sync_ms, queue_bytes, unsynced, failures, restored, quarantined, skipped},
and volatile is !(enabled && ok). ced shows a red UNPROTECTED badge
whenever volatile is true.
Limits
| Limit | Value |
|---|---|
| Buffer text / lines | 64 MiB / 2,000,000 |
Inserted text per request / ops per apply | 1 MiB / 10,000 |
| Undo log per buffer | 100,000 entries or 32 MiB of edit text |
| Open buffers / holders per buffer | 256 / 32 |
Path (given and resolved, JSON-encoded) / language | 1,024 bytes / 32 chars |
| All buffers + logs + snapshots | 1 GiB |
| Reply / event (encoded) | 4 MiB / 256 KiB |
find matches | 1,000 default, 10,000 max; 4 KiB text per match |
| Anchors / selection origins / selections per origin | 1,024 / 64 / 16 |
| Queued commands per buffer | 256, and a full queue is refused at once with RESOURCE_LIMIT busy |
Exceeding a limit is refused with RESOURCE_LIMIT, and the buffer is left
untouched. Each buffer runs in its own task, so a slow regex on one buffer
never delays another.
A Mix session
send edit edit.open path="~/notes/todo.md"
$b = $result.buffer
send edit edit.find buffer=$b pattern="alpha"
$m = $result.matches[0]
send edit edit.replace buffer=$b range={start: $m.start.offset, end: $m.end.offset} text="beta" expect_rev=$result.rev
if $rc == 10 and $reply.error_code == "CONFLICT" then
print("someone edited first; buffer is now at rev " .. to_string($reply.rev))
end
send edit edit.insert buffer=$b at={line: 2, col: 1} text="-- note\n" origin="agent:tidy" op_id="t1"
send edit edit.apply buffer=$b ops=[{op: "insert", at: "end", text: "tail\n"}, {op: "delete", range: {lines: [1, 1]}}]
send edit edit.undo buffer=$b origin="agent:tidy" -- only tidy's edit is undone
send edit edit.anchor.set buffer=$b name="mark1" at={line: 3}
send edit edit.get buffer=$b numbered=true
send edit edit.save buffer=$b
The same session, with the change feed, an external write and the recovery
cycle checked (SIGTERM keeps a dirty buffer, SIGKILL after a flush loses
nothing across two restarts, a durable save removes the files, and
COSMIX_EDIT_RECOVERY=0 discards), is the end-to-end test
src/crates/cosmix-editd/tests/edit-bus-test.mix. It runs a private broker,
the real daemon and one-shot Mix clients, with the recovery directory inside
its temp dir:
COSMIX=$PWD mix src/crates/cosmix-editd/tests/edit-bus-test.mix --bin src/target/release/cosmix-editd
Install
cp src/_etc/systemd-user/cosmix-editd.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now cosmix-editd # returns once READY=1
mix -c 'send edit edit.info
print($result.version)'
Upgrading from 0.1.x: the old daemon is volatile, so run it down with no dirty
buffers (edit.info dirty: 0) or its unsaved text is lost at the restart.
From 0.2.0 on, edit.recovery.flush before a restart is enough.
Not yet
Syntax highlighting and grapheme-correct navigation live in the frontend
(ced and cosmix-edit-client), not in the daemon. The following
arrive later, each with its first consumer:
- a shared diagnostics store
edit.run- undo history across a restart
- reaping the holders of crashed agents
- lossy loading of non-UTF-8 files
- attested remote human frontends
- merging overlapping edits instead of refusing them
- per-buffer topics