• v0.1.0-rc.9 033f769455

    v0.1.0-rc.9
    All checks were successful
    release / nix flake check (push) Successful in 8s
    darwin / detect relevant changes (push) Successful in 15s
    windows / windows docs-only no-op (push) Has been skipped
    fuzz / cargo fuzz nightly long-run (push) Has been skipped
    darwin / darwin docs-only no-op (push) Has been skipped
    windows / detect relevant changes (push) Successful in 19s
    fuzz / cargo fuzz smoke (per target) (push) Successful in 1m6s
    pr / nix flake check (push) Successful in 8s
    darwin / build felis (aarch64-darwin) (push) Successful in 1m17s
    pr / cargo build / clippy / test / deny (push) Successful in 2m16s
    pr / wire schema is compatible with the base (push) Successful in 7s
    windows / cargo nextest (Windows) (push) Successful in 3m25s
    pr / frontend smoke (x86_64-linux) (push) Successful in 1m42s
    windows / frontend smoke (Windows) (push) Successful in 1m38s
    windows / cargo clippy (Windows cross) (push) Successful in 13s
    release / the tag names this exact revision (push) Successful in 8s
    release / package felis (aarch64-darwin) (push) Successful in 1m3s
    windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m51s
    release / cargo build / clippy / test / deny (push) Successful in 1m41s
    release / wire schema is compatible with the newest release (push) Successful in 12s
    release / frontend smoke (x86_64-linux) (push) Successful in 13s
    release / package felis (x86_64-linux) (push) Successful in 31s
    release / cargo nextest (Windows) (push) Successful in 2m59s
    release / frontend smoke (Windows) (push) Successful in 1m26s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m29s
    release / publish the release page (push) Successful in 18s
    Pre-release

    natsukium released this 2026-09-26 15:55:11 +09:00 | 3 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.9, built from 033f769455c36503639266232abe8b80f0c92116.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    Linux and macOS ship as the attached felis-x86_64-linux.tar.gz and felis-aarch64-darwin.tar.gz: unpack either anywhere and run bin/felis. The macOS tree is ad-hoc signed rather than notarized, so clear its quarantine attribute before the first run. Windows ships as the attached felis-x86_64-pc-windows-msvc.zip.
    The same builds are on https://nix-cache.natsukium.com under the flake reference git+https://git.natsukium.com/natsukium/felis?ref=refs/tags/v0.1.0-rc.9.

    Downloads
  • v0.1.0-rc.8 ba9506787a

    v0.1.0-rc.8
    All checks were successful
    darwin / detect relevant changes (push) Successful in 14s
    darwin / build felis (aarch64-darwin) (push) Has been skipped
    darwin / darwin docs-only no-op (push) Successful in 0s
    pr / cargo build / clippy / test / deny (push) Successful in 2m3s
    pr / wire schema is compatible with the base (push) Successful in 7s
    pr / frontend smoke (x86_64-linux) (push) Successful in 1m39s
    windows / cargo clippy (Windows cross) (push) Successful in 13s
    windows / detect relevant changes (push) Successful in 10s
    windows / cargo nextest (Windows) (push) Has been skipped
    windows / frontend smoke (Windows) (push) Has been skipped
    windows / package felis (x86_64-pc-windows-msvc) (push) Has been skipped
    windows / windows docs-only no-op (push) Successful in -1s
    pr / nix flake check (push) Successful in 8s
    release / the tag names this exact revision (push) Successful in 7s
    release / nix flake check (push) Successful in 8s
    release / cargo build / clippy / test / deny (push) Successful in 1m45s
    release / package felis (aarch64-darwin) (push) Successful in 1m54s
    release / wire schema is compatible with the released baseline (push) Successful in 12s
    release / frontend smoke (x86_64-linux) (push) Successful in 13s
    release / package felis (x86_64-linux) (push) Successful in 33s
    release / cargo nextest (Windows) (push) Successful in 3m3s
    release / frontend smoke (Windows) (push) Successful in 1m27s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m32s
    release / publish the release page (push) Successful in 19s
    release / propose the released schema as the wire baseline (push) Has been skipped
    Pre-release

    natsukium released this 2026-09-26 15:24:34 +09:00 | 5 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.8, built from ba9506787afddd42ef5222f118323db4b287f0cb.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    Linux and macOS ship as the attached felis-x86_64-linux.tar.gz and felis-aarch64-darwin.tar.gz: unpack either anywhere and run bin/felis. The macOS tree is ad-hoc signed rather than notarized, so clear its quarantine attribute before the first run. Windows ships as the attached felis-x86_64-pc-windows-msvc.zip.
    The same builds are on https://nix-cache.natsukium.com under the flake reference git+https://git.natsukium.com/natsukium/felis?ref=refs/tags/v0.1.0-rc.8.

    Downloads
  • v0.1.0-rc.7 ae6fc1d8b9

    v0.1.0-rc.7
    All checks were successful
    darwin / detect relevant changes (push) Successful in 14s
    darwin / build felis (aarch64-darwin) (push) Has been skipped
    darwin / publish felis (aarch64-darwin) (push) Has been skipped
    darwin / darwin docs-only no-op (push) Successful in 1s
    windows / detect relevant changes (push) Successful in 13s
    windows / cargo nextest (Windows) (push) Has been skipped
    windows / frontend smoke (Windows) (push) Has been skipped
    windows / package felis (x86_64-pc-windows-msvc) (push) Has been skipped
    windows / windows docs-only no-op (push) Successful in 0s
    pr / nix flake check (push) Successful in 9s
    pr / cargo build / clippy / test / deny (push) Successful in 1m59s
    pr / wire schema is compatible with the base (push) Successful in 17s
    pr / frontend smoke (x86_64-linux) (push) Successful in 1m38s
    windows / cargo clippy (Windows cross) (push) Successful in 15s
    release / the tag names this exact revision (push) Successful in 6s
    release / cargo nextest (Windows) (push) Successful in 9m48s
    release / frontend smoke (Windows) (push) Successful in 3m39s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 3m13s
    pr / publish felis (x86_64-linux) (push) Successful in 15s
    release / nix flake check (push) Successful in 9s
    release / cargo build / clippy / test / deny (push) Successful in 1m51s
    release / wire schema is compatible with the released baseline (push) Successful in 13s
    release / frontend smoke (x86_64-linux) (push) Successful in 5s
    release / publish felis (aarch64-darwin) (push) Successful in 2m5s
    release / publish felis (x86_64-linux) (push) Successful in 9s
    release / package felis (x86_64-linux) (push) Successful in 38s
    release / publish the release page (push) Successful in 19s
    release / propose the released schema as the wire baseline (push) Has been skipped
    Pre-release

    natsukium released this 2026-09-26 13:00:18 +09:00 | 15 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.7, built from ae6fc1d8b9a3c30d55057cd2bd07d33fde18b069.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    Linux and macOS ship as the attached felis-x86_64-linux.tar.gz and felis-aarch64-darwin.tar.gz: unpack either anywhere and run bin/felis. The macOS tree is ad-hoc signed rather than notarized, so clear its quarantine attribute before the first run. Windows ships as the attached felis-x86_64-pc-windows-msvc.zip.
    The same builds are on https://nix-cache.natsukium.com under the flake reference git+https://git.natsukium.com/natsukium/felis?ref=refs/tags/v0.1.0-rc.7.

    Downloads
  • v0.1.0-rc.6 4b9af18642

    v0.1.0-rc.6
    All checks were successful
    darwin / detect relevant changes (push) Successful in 3s
    darwin / build felis (aarch64-darwin) (push) Has been skipped
    darwin / publish felis (aarch64-darwin) (push) Has been skipped
    pr / nix flake check (push) Successful in 5s
    pr / cargo build / clippy / test / deny (push) Successful in 1m39s
    pr / wire schema is compatible with the base (push) Successful in 16s
    pr / frontend smoke (x86_64-linux) (push) Successful in 1m17s
    windows / detect relevant changes (push) Successful in 3s
    windows / cargo clippy (Windows cross) (push) Has been skipped
    windows / cargo nextest (Windows) (push) Has been skipped
    windows / frontend smoke (Windows) (push) Has been skipped
    windows / package felis (x86_64-pc-windows-msvc) (push) Has been skipped
    release / the tag names this exact revision (push) Successful in 5s
    darwin / darwin docs-only no-op (push) Successful in 0s
    pr / publish felis (x86_64-linux) (push) Successful in 14s
    windows / windows docs-only no-op (push) Successful in 0s
    release / nix flake check (push) Successful in 5s
    release / cargo build / clippy / test / deny (push) Successful in 1m41s
    release / wire schema is compatible with the released baseline (push) Successful in 11s
    release / frontend smoke (x86_64-linux) (push) Successful in 4s
    release / publish felis (x86_64-linux) (push) Successful in 7s
    release / package felis (x86_64-linux) (push) Successful in 48s
    release / cargo nextest (Windows) (push) Successful in 3m53s
    release / publish felis (aarch64-darwin) (push) Successful in 1m56s
    release / frontend smoke (Windows) (push) Successful in 1m26s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m30s
    release / publish the release page (push) Successful in 18s
    release / propose the released schema as the wire baseline (push) Has been skipped
    Pre-release

    natsukium released this 2026-09-23 15:25:41 +09:00 | 93 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.6, built from 4b9af18642b08b4b992af24c57ac92d2ec0dc518.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    Linux and macOS ship as the attached felis-x86_64-linux.tar.gz and felis-aarch64-darwin.tar.gz: unpack either anywhere and run bin/felis. The macOS tree is ad-hoc signed rather than notarized, so clear its quarantine attribute before the first run. Windows ships as the attached felis-x86_64-pc-windows-msvc.zip.
    The same builds are on https://nix-cache.natsukium.com under the flake reference git+https://git.natsukium.com/natsukium/felis?ref=refs/tags/v0.1.0-rc.6.

    Downloads
  • v0.1.0-rc.5 b861eb155d

    v0.1.0-rc.5
    All checks were successful
    fuzz / cargo fuzz nightly long-run (push) Has been skipped
    darwin / detect relevant changes (push) Successful in 4s
    darwin / darwin docs-only no-op (push) Has been skipped
    darwin / build felis (aarch64-darwin) (push) Successful in 53s
    fuzz / cargo fuzz smoke (per target) (push) Successful in 1m15s
    darwin / publish felis (aarch64-darwin) (push) Successful in 37s
    pr / nix flake check (push) Successful in 29s
    pr / cargo build / clippy / test / deny (push) Successful in 2m41s
    pr / wire schema is compatible with the base (push) Successful in 1m30s
    pr / frontend smoke (x86_64-linux) (push) Successful in 3m51s
    windows / detect relevant changes (push) Successful in 4s
    windows / windows docs-only no-op (push) Has been skipped
    release / the tag names this exact revision (push) Successful in 36s
    pr / publish felis (x86_64-linux) (push) Successful in 21s
    windows / cargo clippy (Windows cross) (push) Successful in 2m48s
    windows / cargo nextest (Windows) (push) Successful in 3m48s
    release / nix flake check (push) Successful in 1m53s
    release / cargo nextest (Windows) (push) Successful in 4m8s
    release / cargo build / clippy / test / deny (push) Successful in 3m10s
    release / wire schema is compatible with the released baseline (push) Successful in 38s
    windows / frontend smoke (Windows) (push) Successful in 1m34s
    release / frontend smoke (x86_64-linux) (push) Successful in 5s
    release / publish felis (aarch64-darwin) (push) Successful in 1m4s
    release / frontend smoke (Windows) (push) Successful in 1m33s
    windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m38s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m40s
    release / publish felis (x86_64-linux) (push) Successful in 6m19s
    release / package felis (x86_64-linux) (push) Successful in 2m3s
    release / publish the release page (push) Successful in 2m15s
    release / propose the released schema as the wire baseline (push) Has been skipped
    Pre-release

    natsukium released this 2026-09-23 08:49:43 +09:00 | 98 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.5, built from b861eb155d1ca2cb32aa3f4735f7c32206e117b8.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    Linux and macOS ship as the attached felis-x86_64-linux.tar.gz and felis-aarch64-darwin.tar.gz: unpack either anywhere and run bin/felis. The macOS tree is ad-hoc signed rather than notarized, so clear its quarantine attribute before the first run. Windows ships as the attached felis-x86_64-pc-windows-msvc.zip.
    The same builds are on https://nix-cache.natsukium.com under the flake reference git+https://git.natsukium.com/natsukium/felis?ref=refs/tags/v0.1.0-rc.5.

    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::KeyBytes keeps its meaning for
      literal bytes: sessions send --raw, the send_string action, 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, and Ops::Info answers one session's roster row and its display prefix in one reply.
      felis sessions info, send, capture, search and the bridge's sessions.* 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 as no_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::CycleEnd terminates a pull-paced compose cycle. The daemon emits it after everything it composed
      for one NextGridFrame, 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 ConnHello naming 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 on window retarget.

    Changed

    • Wire: a refusal names which half of the request refused. AttachFailure keeps the five reasons an attach can be
      given (UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous) and the four a spawn can
      (SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining) become CreateFailure;
      SessionAttachFailed.reason is a oneof of the two, and SpawnRefused.reason is a CreateFailure, 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 (exit 2, protocol) instead of as a verdict on the request. felis's error kinds and exit
      codes are unchanged, and so is the felis_json v1 shape of a session attach_failed frame, whose nine reason
      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 u16 format version, and this release writes version 1. The
      FRLY magic 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 relay is 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, and Ops::StatusReply no longer repeats the daemon's
      build and wire version. SessionInfo.sequence is a required uint64 that a 0 (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
      composes version and protocol from the connection's own handshake — Welcome.identity and 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.carrier gains an explicit default_local arm and
      SessionAttach carries its id / id_prefix pair as a target oneof, so a request naming two targets cannot be
      built and one naming none is refused; OpsStop.mode and GridThemeColor (now a Set { rgb } / Reset oneof, as
      PaletteColor already was) are refused at decode when their union is unset, where they used to read as if_empty and
      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, and felis window retarget send the same
      requests spelled explicitly.

    • Wire: ResourceReport carries its scope as a oneof. DaemonScope holds only global_limit; SubjectScope holds
      subject (a SubjectKind of SESSION or SUBSCRIBER), max_subject_used, per_subject_limit and global_limit.
      Every ceiling is a Limit, a oneof of bounded and unlimited, because 0 is 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 status is unchanged in
      both formats: --format json still emits max_subject_used, per_subject_limit and global_limit as keys that an
      unlimited or inapplicable value omits, and the human output still prints unlimited. The one line that can no longer
      appear is max <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_id is gone — BuildIdentity (Welcome.identity) is the daemon's build, typed and in full. The
      five reserved 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 version reports untyped for a daemon whose Welcome carries no
      identity.

    • JSON: the structural JSON a consumer reads when it re-exposes or records a whole session is now a named format,
      felis-json v1, instead of whatever serde produced for felis's own Rust types. Each frame carries
      {"felis_json": 1, "kind": "grid" | "image" | "conn" | "session" | "input", "msg": …}, msg is 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 is crates/felis-grid/schemas/felis-json-v1.schema.json. Inside version 1 a writer may only add optional
      fields, which readers ignore; anything wider bumps felis_json, and a reader refuses a version it does not know
      before it reads the payload. felis bridge output is unchanged: its JSON belongs to the CLI v: 1 contract, 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) and PROTOCOL_MAJOR are 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 in felis.proto rather 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 its protoc-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 GridMsg value the daemon could not have produced ends the client's attachment instead of being
      normalized. A row index past MAX_GRID_ROWS, a row wider than MAX_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 the Size, 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 second Cluster or Hyperlink under an id
      already installed ends the attachment, identical text included.

    • sessions switch and window retarget say what exit 0 means: 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 own for a retarget), and
      already on session <id>; nothing queued for a switch onto the session the window already holds. The machine result
      is unchanged: queued still counts the subscriber outboxes that took the push. Exit 0 claims 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, or Image body 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 pipe binding's target = { file = "<path>" } resolves like every other file-valued config key —
      a relative path against the directory of the config.toml that 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, and felis config check reports the degraded
      field as a value warning rather than a parse error. 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.sock on Linux and macOS, derived from the uid
      alone. XDG_RUNTIME_DIR and TMPDIR no longer take part, and neither does /run/user/<uid>; --socket and
      FELIS_SOCKET remain the only overrides. Windows is unchanged. /tmp lives 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, where FELIS_SOCKET still names it, with
      felis --socket "$FELIS_SOCKET" daemon stop --when-empty (how-to: update felis).

    • Socket directory: a --socket or FELIS_SOCKET path 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 directory 0700 and 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_mismatch is gone from the error.kind vocabulary. No operation could
      produce it — it named a wire-generation mismatch the preface already refuses before any verb runs — so nothing that
      branches on error.kind ever saw it. A token for that class returns additively if an operation can ever raise one.

    Added

    • felis doctor: when this shell's $FELIS_SOCKET names an endpoint other than the default, a daemon-sibling row
      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 the daemon row gains a note naming the endpoint fresh processes resolve. Both appear on the
      local Unix carrier only, never under --socket or --host. A daemon row 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: a sessions.spawn over a local socket now sends the bridge process's environment as the child's
      base, as felis sessions spawn does (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's rows and cols are read at the wire's width, so a geometry past the REQ-605a
      bounds is refused by the daemon with invalid_request on both surfaces. A value above 65535 was answered
      malformed_request by the bridge alone, which reads as "the request was unreadable" rather than "the daemon refused
      this grid". felis-bridge-v1.schema.json publishes the wider bound.

    • Machine output: every failure past a successful argument parse is framed as the verb's own error object. Under
      --format json an unresolvable --config (usage), a daemon target that will not resolve (daemon_unreachable), a
      runtime that will not build (internal), and a sessions send <id> - whose stdin cannot be read (input_failed)
      printed a bare line and exited 1. felis version --format json also 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, and felis-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>.service in app.slice with OOMPolicy=continue, rather
      than forked into the launching window's cgroup. Under a compositor unit with the OOMPolicy=stop default, an OOM kill
      of a single shell child used to stop the compositor and end the graphical session. systemctl --user stop and
      restart on that unit are refused: felis daemon stop remains 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 one WARN line in client.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 LANG set to the host's
      current region, as {language}_{country}.UTF-8 (en_US.UTF-8 when that pair is not installed). launchd gives a
      .app-launched process no LANG or LC_*, so sessions created from the bundle ran under LC_CTYPE=C and pbcopy
      put non-ASCII text on the pasteboard in the region's legacy encoding. The fill never displaces an existing value: any
      of LANG, LC_ALL and LC_CTYPE set to a non-empty value leaves the environment untouched, and no LANG is 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 from felis 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, and pipe/run visits, 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 exits 0 only 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.enable now produces its felis-notify user unit on its own,
      as its description states, instead of requiring programs.felis.enable = true as 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/maximum now carries an x-bound-exceeds-f64-precision
      annotation. i64::MAX and u64::MAX are not representable as f64, and a stock validator compares JSON numbers as
      f64, so it admits values within one f64 step of such a bound; a consumer that needs the exact bound reads the
      number as a 64-bit integer itself. The affected count, exit_code and dropped envelope fields also gained the
      format annotation their generated siblings already carried.

    Removed

    • CLI: felis sessions switch-all and felis sessions retarget-all, and with them the felis bridge parameter
      all on sessions.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
      --session each window dials independently and lands on its own fresh session. A sweep over several sessions is a
      loop over felis window retarget --from <id> (or felis ssh <dest> --from <id>).
    • CLI: --to-host and --to-socket on the retarget verbs. Each verb now carries one destination in its own
      positional slot, so the to- prefix has nothing left to disambiguate against the root --host / --socket.
    • Wire: SwitchScope.all (oneof tag 3) and the SwitchScopeAll message. An OpsSwitch that carries no scope is
      refused as malformed instead of being read as that broadcast, so every switch names its scope. An intended pre-release
      break, acknowledged in crates/felis-protocol/proto/BREAKING.md: a daemon and a client across it must be rebuilt
      together.
    • CLI / wire: operation_id on the switch, ssh and window retarget results, on the matching felis bridge
      results, and the OperationId / OperationEcho wire 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. queued is the whole completion claim a switch makes, and
      not_queued its error kind; a script reading operation_id drops the field. An intended pre-release break,
      acknowledged in crates/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
  • v0.1.0-rc.2 a5e89f8c7f

    v0.1.0-rc.2
    All checks were successful
    fuzz / cargo fuzz nightly long-run (push) Has been skipped
    darwin / detect relevant changes (push) Successful in 4s
    darwin / darwin docs-only no-op (push) Has been skipped
    fuzz / cargo fuzz smoke (per target) (push) Successful in 1m4s
    darwin / build felis (aarch64-darwin) (push) Successful in 1m3s
    pr / nix flake check (push) Successful in 6s
    darwin / publish felis (aarch64-darwin) (push) Successful in 37s
    pr / cargo build / clippy / test / deny (push) Successful in 1m35s
    pr / wire schema is compatible with the base (push) Successful in 17s
    pr / frontend smoke (x86_64-linux) (push) Successful in 56s
    windows / detect relevant changes (push) Successful in 4s
    windows / windows docs-only no-op (push) Has been skipped
    release / the tag names this exact revision (push) Successful in 5s
    pr / publish felis (x86_64-linux) (push) Successful in 15s
    windows / cargo clippy (Windows cross) (push) Successful in 12s
    release / nix flake check (push) Successful in 6s
    release / cargo build / clippy / test / deny (push) Successful in 1m39s
    release / wire schema is compatible with the released baseline (push) Successful in 12s
    release / frontend smoke (x86_64-linux) (push) Successful in 5s
    release / publish felis (x86_64-linux) (push) Successful in 8s
    release / publish felis (aarch64-darwin) (push) Successful in 57s
    windows / cargo nextest (Windows) (push) Successful in 3m58s
    release / cargo nextest (Windows) (push) Successful in 3m34s
    windows / frontend smoke (Windows) (push) Successful in 1m30s
    release / frontend smoke (Windows) (push) Successful in 1m28s
    windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m36s
    release / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m34s
    release / publish the release page (push) Successful in 14s
    release / propose the released schema as the wire baseline (push) Has been skipped
    Pre-release

    natsukium released this 2026-09-23 01:33:20 +09:00 | 102 commits to main since this release

    Signed by natsukium
    SSH key fingerprint: SHA256:ByU5VseUJyH2UYFs65yNYhqJHhqJzP8BNBlvG0a6WOs

    felis v0.1.0-rc.2, built from a5e89f8c7f3d8cf4bc0878e240533779b8f49b07.

    Supported: x86_64-linux, aarch64-darwin, and x86_64-pc-windows-msvc. aarch64-linux is committed and builds from source, but carries no claim.
    The two Nix targets are on https://nix-cache.natsukium.com; the attached felis-x86_64-linux.txt and felis-aarch64-darwin.txt name each store path, its narHash, and the identity the binary reports.
    Windows ships as the attached felis-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::KeyBytes keeps its meaning for
      literal bytes: sessions send --raw, the send_string action, 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, and Ops::Info answers one session's roster row and its display prefix in one reply.
      felis sessions info, send, capture, search and the bridge's sessions.* 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 as no_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::CycleEnd terminates a pull-paced compose cycle. The daemon emits it after everything it composed
      for one NextGridFrame, 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 ConnHello naming 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 on window retarget.

    Changed

    • Wire: a refusal names which half of the request refused. AttachFailure keeps the five reasons an attach can be
      given (UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous) and the four a spawn can
      (SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining) become CreateFailure;
      SessionAttachFailed.reason is a oneof of the two, and SpawnRefused.reason is a CreateFailure, 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 (exit 2, protocol) instead of as a verdict on the request. felis's error kinds and exit
      codes are unchanged, and so is the felis_json v1 shape of a session attach_failed frame, whose nine reason
      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 u16 format version, and this release writes version 1. The
      FRLY magic 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 relay is 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, and Ops::StatusReply no longer repeats the daemon's
      build and wire version. SessionInfo.sequence is a required uint64 that a 0 (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
      composes version and protocol from the connection's own handshake — Welcome.identity and 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.carrier gains an explicit default_local arm and
      SessionAttach carries its id / id_prefix pair as a target oneof, so a request naming two targets cannot be
      built and one naming none is refused; OpsStop.mode and GridThemeColor (now a Set { rgb } / Reset oneof, as
      PaletteColor already was) are refused at decode when their union is unset, where they used to read as if_empty and
      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, and felis window retarget send the same
      requests spelled explicitly.

    • Wire: ResourceReport carries its scope as a oneof. DaemonScope holds only global_limit; SubjectScope holds
      subject (a SubjectKind of SESSION or SUBSCRIBER), max_subject_used, per_subject_limit and global_limit.
      Every ceiling is a Limit, a oneof of bounded and unlimited, because 0 is 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 status is unchanged in
      both formats: --format json still emits max_subject_used, per_subject_limit and global_limit as keys that an
      unlimited or inapplicable value omits, and the human output still prints unlimited. The one line that can no longer
      appear is max <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_id is gone — BuildIdentity (Welcome.identity) is the daemon's build, typed and in full. The
      five reserved 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 version reports untyped for a daemon whose Welcome carries no
      identity.

    • JSON: the structural JSON a consumer reads when it re-exposes or records a whole session is now a named format,
      felis-json v1, instead of whatever serde produced for felis's own Rust types. Each frame carries
      {"felis_json": 1, "kind": "grid" | "image" | "conn" | "session" | "input", "msg": …}, msg is 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 is crates/felis-grid/schemas/felis-json-v1.schema.json. Inside version 1 a writer may only add optional
      fields, which readers ignore; anything wider bumps felis_json, and a reader refuses a version it does not know
      before it reads the payload. felis bridge output is unchanged: its JSON belongs to the CLI v: 1 contract, 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) and PROTOCOL_MAJOR are 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 in felis.proto rather 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 its protoc-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 GridMsg value the daemon could not have produced ends the client's attachment instead of being
      normalized. A row index past MAX_GRID_ROWS, a row wider than MAX_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 the Size, 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 second Cluster or Hyperlink under an id
      already installed ends the attachment, identical text included.

    • sessions switch and window retarget say what exit 0 means: 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 own for a retarget), and
      already on session <id>; nothing queued for a switch onto the session the window already holds. The machine result
      is unchanged: queued still counts the subscriber outboxes that took the push. Exit 0 claims 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, or Image body 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 pipe binding's target = { file = "<path>" } resolves like every other file-valued config key —
      a relative path against the directory of the config.toml that 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, and felis config check reports the degraded
      field as a value warning rather than a parse error. 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.sock on Linux and macOS, derived from the uid
      alone. XDG_RUNTIME_DIR and TMPDIR no longer take part, and neither does /run/user/<uid>; --socket and
      FELIS_SOCKET remain the only overrides. Windows is unchanged. /tmp lives 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, where FELIS_SOCKET still names it, with
      felis --socket "$FELIS_SOCKET" daemon stop --when-empty (how-to: update felis).

    • Socket directory: a --socket or FELIS_SOCKET path 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 directory 0700 and 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_mismatch is gone from the error.kind vocabulary. No operation could
      produce it — it named a wire-generation mismatch the preface already refuses before any verb runs — so nothing that
      branches on error.kind ever saw it. A token for that class returns additively if an operation can ever raise one.

    Added

    • felis doctor: when this shell's $FELIS_SOCKET names an endpoint other than the default, a daemon-sibling row
      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 the daemon row gains a note naming the endpoint fresh processes resolve. Both appear on the
      local Unix carrier only, never under --socket or --host. A daemon row 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: a sessions.spawn over a local socket now sends the bridge process's environment as the child's
      base, as felis sessions spawn does (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's rows and cols are read at the wire's width, so a geometry past the REQ-605a
      bounds is refused by the daemon with invalid_request on both surfaces. A value above 65535 was answered
      malformed_request by the bridge alone, which reads as "the request was unreadable" rather than "the daemon refused
      this grid". felis-bridge-v1.schema.json publishes the wider bound.

    • Machine output: every failure past a successful argument parse is framed as the verb's own error object. Under
      --format json an unresolvable --config (usage), a daemon target that will not resolve (daemon_unreachable), a
      runtime that will not build (internal), and a sessions send <id> - whose stdin cannot be read (input_failed)
      printed a bare line and exited 1. felis version --format json also 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, and felis-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>.service in app.slice with OOMPolicy=continue, rather
      than forked into the launching window's cgroup. Under a compositor unit with the OOMPolicy=stop default, an OOM kill
      of a single shell child used to stop the compositor and end the graphical session. systemctl --user stop and
      restart on that unit are refused: felis daemon stop remains 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 one WARN line in client.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 LANG set to the host's
      current region, as {language}_{country}.UTF-8 (en_US.UTF-8 when that pair is not installed). launchd gives a
      .app-launched process no LANG or LC_*, so sessions created from the bundle ran under LC_CTYPE=C and pbcopy
      put non-ASCII text on the pasteboard in the region's legacy encoding. The fill never displaces an existing value: any
      of LANG, LC_ALL and LC_CTYPE set to a non-empty value leaves the environment untouched, and no LANG is 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 from felis 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, and pipe/run visits, 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 exits 0 only 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.enable now produces its felis-notify user unit on its own,
      as its description states, instead of requiring programs.felis.enable = true as 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/maximum now carries an x-bound-exceeds-f64-precision
      annotation. i64::MAX and u64::MAX are not representable as f64, and a stock validator compares JSON numbers as
      f64, so it admits values within one f64 step of such a bound; a consumer that needs the exact bound reads the
      number as a 64-bit integer itself. The affected count, exit_code and dropped envelope fields also gained the
      format annotation their generated siblings already carried.

    Removed

    • CLI: felis sessions switch-all and felis sessions retarget-all, and with them the felis bridge parameter
      all on sessions.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
      --session each window dials independently and lands on its own fresh session. A sweep over several sessions is a
      loop over felis window retarget --from <id> (or felis ssh <dest> --from <id>).
    • CLI: --to-host and --to-socket on the retarget verbs. Each verb now carries one destination in its own
      positional slot, so the to- prefix has nothing left to disambiguate against the root --host / --socket.
    • Wire: SwitchScope.all (oneof tag 3) and the SwitchScopeAll message. An OpsSwitch that carries no scope is
      refused as malformed instead of being read as that broadcast, so every switch names its scope. An intended pre-release
      break, acknowledged in crates/felis-protocol/proto/BREAKING.md: a daemon and a client across it must be rebuilt
      together.
    • CLI / wire: operation_id on the switch, ssh and window retarget results, on the matching felis bridge
      results, and the OperationId / OperationEcho wire 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. queued is the whole completion claim a switch makes, and
      not_queued its error kind; a script reading operation_id drops the field. An intended pre-release break,
      acknowledged in crates/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