-
v0.1.0-rc.2
Pre-releaseAll checks were successfulfuzz / cargo fuzz nightly long-run (push) Has been skippeddarwin / detect relevant changes (push) Successful in 4sdarwin / darwin docs-only no-op (push) Has been skippedfuzz / cargo fuzz smoke (per target) (push) Successful in 1m4sdarwin / build felis (aarch64-darwin) (push) Successful in 1m3spr / nix flake check (push) Successful in 6sdarwin / publish felis (aarch64-darwin) (push) Successful in 37spr / cargo build / clippy / test / deny (push) Successful in 1m35spr / wire schema is compatible with the base (push) Successful in 17spr / frontend smoke (x86_64-linux) (push) Successful in 56swindows / detect relevant changes (push) Successful in 4swindows / windows docs-only no-op (push) Has been skippedrelease / the tag names this exact revision (push) Successful in 5spr / publish felis (x86_64-linux) (push) Successful in 15swindows / cargo clippy (Windows cross) (push) Successful in 12srelease / nix flake check (push) Successful in 6srelease / cargo build / clippy / test / deny (push) Successful in 1m39srelease / wire schema is compatible with the released baseline (push) Successful in 12srelease / frontend smoke (x86_64-linux) (push) Successful in 5srelease / publish felis (x86_64-linux) (push) Successful in 8srelease / publish felis (aarch64-darwin) (push) Successful in 57swindows / cargo nextest (Windows) (push) Successful in 3m58srelease / cargo nextest (Windows) (push) Successful in 3m34swindows / frontend smoke (Windows) (push) Successful in 1m30srelease / frontend smoke (Windows) (push) Successful in 1m28swindows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m36srelease / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m34srelease / publish the release page (push) Successful in 14srelease / propose the released schema as the wire baseline (push) Has been skippedreleased this
2026-09-23 01:33:20 +09:00 | 102 commits to main since this releasefelis v0.1.0-rc.2, built from
a5e89f8c7f3d8cf4bc0878e240533779b8f49b07.Supported:
x86_64-linux,aarch64-darwin, andx86_64-pc-windows-msvc.aarch64-linuxis committed and builds from source, but carries no claim.
The two Nix targets are onhttps://nix-cache.natsukium.com; the attachedfelis-x86_64-linux.txtandfelis-aarch64-darwin.txtname each store path, its narHash, and the identity the binary reports.
Windows ships as the attachedfelis-x86_64-pc-windows-msvc.zip.Added
-
Wire: a keystroke crosses as
InputMsg::Key— the logical key, the composed text, the modifiers (Super included),
press / repeat / release, and the key location — and the daemon encodes it against the keyboard modes it owns. Keys
pressed while a producer had just changed a keyboard mode could previously be encoded under the mode the client last
saw; they no longer can be. A held key now reports the Kitty keyboard protocol's repeat event (event type 2) to a
program that asked for event types, where it used to report a second press.InputMsg::KeyByteskeeps its meaning for
literal bytes:sessions send --raw, thesend_stringaction, a committed IME string, and a file drop. -
Wire: an attach may name its session by id prefix, resolved inside the daemon in the same step that takes the
session handle, andOps::Infoanswers one session's roster row and its display prefix in one reply.
felis sessions info,send,capture,searchand the bridge'ssessions.*verbs therefore no longer fetch a
roster to turn a prefix into an id: resolution happens where the pool is, so no roster a client holds can go stale
between matching a prefix and acting on it. A session reaped while the request is in flight is reported asno_match,
unless the teardown wins the race with the subscription that follows, which is refused the way an attach by full id
has always been. -
Wire:
GridMsg::CycleEndterminates a pull-paced compose cycle. The daemon emits it after everything it composed
for oneNextGridFrame, and the window holds both its next pull and its grid paint until it arrives, so a scroll
never reaches the screen without the rows and cursor from the same cycle. An empty cycle still ships nothing at all,
and eager-push peers (an SSH-carried attach) are unaffected. -
Wire: a daemon that meets a
ConnHellonaming a connection mode it does not define answers
Refused { UnknownMode }and closes, instead of dropping the connection over an unreadable handshake. Every mode this
build ships belongs to the base schema, so the refusal is what a mode added after the first release lands on. -
CLI:
felis ssh <dest>re-points the window it runs in at the daemon reached over SSH at<dest>, with
repeatable--ssh-arg <token>,--attachment <id>,--from <prefix>, and--session <prefix>or-- <cmd>for the
landing. "ssh somewhere" is the phrase a user reaches for, and its everyday meaning is "point this terminal at that
host";felis --host <dest>remains the other operation, which opens a new window there. A carrier before the verb
(felis --host a ssh b) is a usage error, as it is onwindow retarget.
Changed
-
Wire: a refusal names which half of the request refused.
AttachFailurekeeps the five reasons an attach can be
given (UnknownSession,SessionEnding,SessionExited,NoMatch,Ambiguous) and the four a spawn can
(SpawnFailed,GeometryOutOfRange,SessionLimitReached,DaemonDraining) becomeCreateFailure;
SessionAttachFailed.reasonis a oneof of the two, andSpawnRefused.reasonis aCreateFailure, since a spawn
subscribes to nothing. A client checks a refusal against what it asked for — an attach by prefix cannot be told the id
is unknown, an attach that did not ask for a live session cannot be told the shell exited — and reports anything else
as a protocol failure (exit2,protocol) instead of as a verdict on the request.felis's error kinds and exit
codes are unchanged, and so is thefelis_jsonv1 shape of a sessionattach_failedframe, whose ninereason
tokens still cover both halves. A daemon and a client across this change must be rebuilt together. -
Wire: the relay carrier block's payload begins with a
u16format version, and this release writes version 1. The
FRLYmagic and the payload length word are unchanged, so an unknown format stays skippable by length: a daemon that
does not decode the version warns, serves the connection off its own environment, and reads the client preface from
the next byte. The word's introduction is itself a break, and it fails fast rather than hanging in either skew
direction. A new relay against a warm old daemon: the daemon reads the version word as the high half of an entry
count, breaks the 4096-entry cap and closes — restart the daemon. An old relay against a new daemon: the daemon reads
the old entry count's zero high half as format version 0, which is malformed, and closes — upgrade the relay side.
felis-daemon relayis exec'd fresh per SSH connection from the same package as the daemon, so one package upgrade
plus one daemon restart covers both. -
Wire: a roster row always carries its creation
sequence, andOps::StatusReplyno longer repeats the daemon's
build and wire version.SessionInfo.sequenceis a requireduint64that a0(which is also what an omitted field
writes) is refused for, so no row falls back to id order and the switch ring has one order;felis daemon status
composesversionandprotocolfrom the connection's own handshake —Welcome.identityand the preface's accept —
instead of a second copy in the reply, and reports the same numbers in both formats as before. A daemon and a client
across this change must be rebuilt together. -
Wire: no absence on the wire carries meaning.
RetargetTarget.carriergains an explicitdefault_localarm and
SessionAttachcarries itsid/id_prefixpair as atargetoneof, so a request naming two targets cannot be
built and one naming none is refused;OpsStop.modeandGridThemeColor(now aSet { rgb }/Resetoneof, as
PaletteColoralready was) are refused at decode when their union is unset, where they used to read asif_emptyand
as a theme reset. Every mode-selecting union therefore names its arms, and a truncated write or an arm from a future
schema fails closed instead of arriving as a real operation. A daemon and a client across this change must be rebuilt
together. No CLI surface moves:felis daemon stop,felis attach, andfelis window retargetsend the same
requests spelled explicitly. -
Wire:
ResourceReportcarries its scope as a oneof.DaemonScopeholds onlyglobal_limit;SubjectScopeholds
subject(aSubjectKindofSESSIONorSUBSCRIBER),max_subject_used,per_subject_limitandglobal_limit.
Every ceiling is aLimit, a oneof ofboundedandunlimited, because0is a legitimate value for both a ceiling
and a subject's depth. A daemon-scope row carrying a per-subject ceiling can no longer be built or decoded, and a row
with no scope, or a limit with no arm, is refused instead of read as a default.felis daemon statusis unchanged in
both formats:--format jsonstill emitsmax_subject_used,per_subject_limitandglobal_limitas keys that an
unlimited or inapplicable value omits, and the human output still printsunlimited. The one line that can no longer
appear ismax <scope> unavailable, which named a subject row whose sample was missing — a shape the schema no longer
admits. -
Wire: the 1.0 base schema is renumbered densely. Every field number and closed-enum value that pre-release churn
retired is reclaimed, so a reader of the first public schema never meets a gap it has to explain, and
ConnWelcome.build_idis gone —BuildIdentity(Welcome.identity) is the daemon's build, typed and in full. The
fivereserved 100;declarations stay: field 100 is the correlation envelope's wire-wide slot on the families that
carry no envelope. Every renumbered message and enum reads at a different number than it did, so a daemon and a client
across this change must be rebuilt together;felis versionreportsuntypedfor a daemon whoseWelcomecarries no
identity. -
JSON: the structural JSON a consumer reads when it re-exposes or records a whole session is now a named format,
felis-jsonv1, instead of whatever serde produced for felis's own Rust types. Each frame carries
{"felis_json": 1, "kind": "grid" | "image" | "conn" | "session" | "input", "msg": …},msgis a dedicated shape (a
session id is its 32-digit hex string; a row delta spells its cells out, graphemes, colors and all), and the published
schema iscrates/felis-grid/schemas/felis-json-v1.schema.json. Inside version 1 a writer may only add optional
fields, which readers ignore; anything wider bumpsfelis_json, and a reader refuses a version it does not know
before it reads the payload.felis bridgeoutput is unchanged: its JSON belongs to the CLIv: 1contract, which is
a separate surface. -
Wire: the first public wire contract is protocol 1.0, not 1.14. The pre-release minors 1–14 had no released
peer, so the whole schema this release tags is folded into the minor-0 base: the ledger has one row, and every arm,
field, closed-enum value, connection mode and row-codec version is available from minor 0. The preface, the schema
package (felis.v1) andPROTOCOL_MAJORare unchanged, as is every byte a message encodes to; what changes is the
minor word the preface carries (0x0000) and everything that reports it —felis version,felis doctor,
felis daemon status. The send-authorization machinery stays, so the first addition made after this release is the
first real minor transition. -
Wire (schema): every oneof arm's routing row is a
(felis.v1.arm)field option infelis.protorather than an
// arm:comment beside it. A client generated from the schema reads direction, correlation class, admitting
connection modes, phases, and introducing minor out of the descriptor itsprotoc-gen-*already builds, so routing no
longer has to be transcribed from comments or from the Rust tables. No message shape, field number, or byte on the
wire changes, and the rows themselves are the ones that were declared before. A consumer that parsed the comments must
read the options instead;docs/reference/ipc.md"The arm table" gives the form. -
Wire: a
GridMsgvalue the daemon could not have produced ends the client's attachment instead of being
normalized. A row index pastMAX_GRID_ROWS, a row wider thanMAX_GRID_COLS, a sized cell outside its row, a scroll
region or count outside the announced grid, a cursor off that grid, a viewport past its own maximum, a registry id of
0 or past its table cap, over-cap registry text, and a row naming an entry that never arrived are each refused before
the shadow changes a cell; the client reconnects and rehydrates, and the session and its other attachments are
untouched. Valid resize races still grow, pad, or trim a row inside the two protocol-wide bounds.
docs/reference/ipc.md"Grid admission" states the rule for each value. The daemon retires queued scroll directives
when the grid is resized, so none is shipped against a geometry it does not describe, composes the replay for every
attached mirror behind theSize, and a client keeps the cursor the daemon stated across a resize of its own that the
daemon overrules. A registry id is installed once per connection: a secondClusterorHyperlinkunder an id
already installed ends the attachment, identical text included. -
sessions switchandwindow retargetsay what exit0means: human framing now prints a line of its own,
queued on 1 window; not landed — each window runs its own attach(re-dials on its ownfor a retarget), and
already on session <id>; nothing queuedfor a switch onto the session the window already holds. The machine result
is unchanged:queuedstill counts the subscriber outboxes that took the push. Exit0claims that admission and
nothing more — a window that takes the push and then fails to attach, exits, or cannot reach the target daemon leaves
the exit code and every field of the result as they were, and reports the failure only in its own log. -
Wire protocol: a malformed
Input,Grid, orImagebody ends the connection on every role, including one that
drains that family without consuming it. A reader that discards those frames decodes them first, so an undecodable
body, an oneof arm this build does not know, or a stateless limit breach the decode can see is corruption wherever the
frame lands rather than only on the client that reads it. Stateful and handler-specific limits, row-codec contents,
coordinates against the announced geometry, and image transfer sequencing remain the consumer's to judge
(reference: IPC, "Corruption"). -
Keybindings: a
pipebinding'starget = { file = "<path>" }resolves like every other file-valued config key —
a relative path against the directory of theconfig.tomlthat names it, a leading~/against the home directory —
instead of being taken verbatim against whatever working directory the window happened to be launched with. A binding
that relied on the working directory must name an absolute path. -
Config: a token one of the closed-enum keys does not recognize (
cursor.blink,shader.animate,shader.post,
shader.post.builtin,window.backdrop,clipboard.osc_52) now falls back to that field's default and warns with
the full key path, instead of failing the whole document to built-in defaults. A file written by a newer felis keeps
its font, theme, keymap, and everything else this build does understand, andfelis config checkreports the degraded
field as avaluewarning rather than aparseerror. A value of the wrong shape, such as a table where a token
belongs, is still a structural error on the whole document, as are syntax errors. -
Daemon endpoint: the per-user socket is
/tmp/felis.<uid>/daemon.sockon Linux and macOS, derived from the uid
alone.XDG_RUNTIME_DIRandTMPDIRno longer take part, and neither does/run/user/<uid>;--socketand
FELIS_SOCKETremain the only overrides. Windows is unchanged./tmplives until the host reboots, while a login
session's runtime directory does not, so a daemon no longer becomes unreachable at the user's last logout, and an SSH
login without pam_systemd (Tailscale SSH) and every SSH login on macOS now reach the daemon that already holds the
user's sessions instead of starting a second one.A daemon an older build left at another endpoint keeps running and is not discoverable from outside: stop it from a
shell inside one of its own sessions, whereFELIS_SOCKETstill names it, with
felis --socket "$FELIS_SOCKET" daemon stop --when-empty(how-to: update felis). -
Socket directory: a
--socketorFELIS_SOCKETpath must lie in a dedicated directory owned by you with mode
0700, and a socket directly under a shared directory such as/tmp(--socket /tmp/x.sock) is refused at daemon
start. The daemon creates the default directory0700and no longer tightens a directory it finds: one that is a
symlink, another user's, or any other mode fails the start naming what was found. -
A stopped daemon leaves its socket file for the next start to replace, instead of unlinking it on exit, so an
exiting daemon can never remove a newer one's socket. A starter refuses to unlink a socket whose connect failed for
any reason other than absence or refusal, and refuses to touch a directory or a symlink at the endpoint at all.
Removed
- CLI: the machine error token
protocol_mismatchis gone from theerror.kindvocabulary. No operation could
produce it — it named a wire-generation mismatch the preface already refuses before any verb runs — so nothing that
branches onerror.kindever saw it. A token for that class returns additively if an operation can ever raise one.
Added
felis doctor: when this shell's$FELIS_SOCKETnames an endpoint other than the default, adaemon-siblingrow
reports what the default holds (a daemon answering it warns and carries the path to inspect it on; a cold default is
stated as cold), and thedaemonrow gains a note naming the endpoint fresh processes resolve. Both appear on the
local Unix carrier only, never under--socketor--host. Adaemonrow that reports "not running" after a refused
connect now says whether the endpoint holds a stale socket the next start replaces, or something else it names. The
row also gains a 2 s dial deadline on the platform default and distinguishes a cold socket from an endpoint it could
not dial and from a peer that answered without speaking felis; a silent listener no longer hangs the verb. On every
target, a daemon answering with a preface status this build does not know, or with a refusal of any reason, reports
"running, but …".
Fixed
-
felis bridge: asessions.spawnover a local socket now sends the bridge process's environment as the child's
base, asfelis sessions spawndoes (REQ-912a). A session created through the bridge inherited the daemon's
environment instead, which on a warm daemon is whatever it was started with. Over an SSH relay no environment is
captured, as before. -
felis bridge:sessions.spawn'srowsandcolsare read at the wire's width, so a geometry past the REQ-605a
bounds is refused by the daemon withinvalid_requeston both surfaces. A value above 65535 was answered
malformed_requestby the bridge alone, which reads as "the request was unreadable" rather than "the daemon refused
this grid".felis-bridge-v1.schema.jsonpublishes the wider bound. -
Machine output: every failure past a successful argument parse is framed as the verb's own error object. Under
--format jsonan unresolvable--config(usage), a daemon target that will not resolve (daemon_unreachable), a
runtime that will not build (internal), and asessions send <id> -whose stdin cannot be read (input_failed)
printed a bare line and exited1.felis version --format jsonalso silences the console log, as the other machine
verbs do. -
Auto-spawn hardening: only a connect that failed with "no such file" or "connection refused" starts a daemon now.
Any other connect error (EACCES, an exhausted descriptor table, …) ends the dial naming the path and the error,
instead of starting a second daemon beside one the process merely failed to reach. This covers the local dial, the
systemd hand-off, andfelis-daemon relay. -
Daemon placement (Linux): a daemon auto-spawned by a client running under the systemd user manager is now started
by the manager as a transient service,felis-daemon-<hash>.serviceinapp.slicewithOOMPolicy=continue, rather
than forked into the launching window's cgroup. Under a compositor unit with theOOMPolicy=stopdefault, an OOM kill
of a single shell child used to stop the compositor and end the graphical session.systemctl --user stopand
restarton that unit are refused:felis daemon stopremains the way down, with its drain postures. Every other
launcher (a login-session shell, macOS, Windows, a host without systemd), and any hand-off the manager does not
complete, still forks the daemon into the launcher's own cgroup, where the old risk stands; a window launch records
that fallback as oneWARNline inclient.log. -
--socket: a relative path (on the flag or in$FELIS_SOCKET) is anchored to the caller's working directory
instead of being passed on as typed. -
Environment (macOS): a session whose resolved environment names no locale now starts with
LANGset to the host's
current region, as{language}_{country}.UTF-8(en_US.UTF-8when that pair is not installed). launchd gives a
.app-launched process noLANGorLC_*, so sessions created from the bundle ran underLC_CTYPE=Candpbcopy
put non-ASCII text on the pasteboard in the region's legacy encoding. The fill never displaces an existing value: any
ofLANG,LC_ALLandLC_CTYPEset to a non-empty value leaves the environment untouched, and noLANGis stamped
on other platforms.
Changed
-
CLI:
felis window retarget [<socket-path>]takes its destination as a positional local socket path; naming none
still means the default local daemon, which is the way back fromfelis ssh. -
Cell size: the cell is one pixel taller for any face whose scaled ascent or descent has a fractional part, so the
default window size for a given rows×columns grid changes with it. Ascent and descent now round up separately instead
of the cell height rounding their sum, which puts the baseline on a pixel boundary and keeps the top row of a
full-ascent glyph inside the cell. The resulting size matches foot, and kitty, at the same font size. -
Window: a shell exit no longer takes the window with it while other sessions are running. The window unwinds its
trail of places it has been — chords, retargets, andpipe/runvisits, newest first — attaching each one exactly,
live-only; failing the trail it attaches the nearest live neighbor of the exited session in the session ring, the same
order the switch chords step. A shell exit discovered through a dropped connection takes the same trail instead of
closing outright. It closes and exits0only once that search is spent: the trail is exhausted or its 30-second
budget is up, and the one ring attempt fails too. -
home-manager module:
programs.felis.notifications.enablenow produces itsfelis-notifyuser unit on its own,
as its description states, instead of requiringprograms.felis.enable = trueas well. The unit takes its binary from
programs.felis.package, so a relay-only setup needs no other option. -
Machine-output JSON Schema: a 64-bit
minimum/maximumnow carries anx-bound-exceeds-f64-precision
annotation.i64::MAXandu64::MAXare not representable asf64, and a stock validator compares JSON numbers as
f64, so it admits values within onef64step of such a bound; a consumer that needs the exact bound reads the
number as a 64-bit integer itself. The affectedcount,exit_codeanddroppedenvelope fields also gained the
formatannotation their generated siblings already carried.
Removed
- CLI:
felis sessions switch-allandfelis sessions retarget-all, and with them thefelis bridgeparameter
allonsessions.switch. They were three spellings of one capability, moving every window attached to a session at
once, with no recorded consumer; the cross-carrier form did not even preserve what it claimed to move, since without
--sessioneach window dials independently and lands on its own fresh session. A sweep over several sessions is a
loop overfelis window retarget --from <id>(orfelis ssh <dest> --from <id>). - CLI:
--to-hostand--to-socketon the retarget verbs. Each verb now carries one destination in its own
positional slot, so theto-prefix has nothing left to disambiguate against the root--host/--socket. - Wire:
SwitchScope.all(oneof tag 3) and theSwitchScopeAllmessage. AnOpsSwitchthat carries noscopeis
refused as malformed instead of being read as that broadcast, so every switch names its scope. An intended pre-release
break, acknowledged incrates/felis-protocol/proto/BREAKING.md: a daemon and a client across it must be rebuilt
together. - CLI / wire:
operation_idon theswitch,sshandwindow retargetresults, on the matchingfelis bridge
results, and theOperationId/OperationEchowire types behind it (OpsSwitched.operation_id,
PushReattach.operation,PushRetargetHost.operation,SessionAttach.operation). No message ever read the daemon's
landing records back, so the token named a fan-out nothing could ask about, and keeping it would freeze the shape of
an identifier before the consumer that reads it exists.queuedis the whole completion claim a switch makes, and
not_queuedits error kind; a script readingoperation_iddrops the field. An intended pre-release break,
acknowledged incrates/felis-protocol/proto/BREAKING.md: the fields decode as absent across the change, so the
switch path works between a daemon and a client on either side of it.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-