109 KiB
Agent handoff — cross-machine continuity
Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and
bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is
0442d78. #174 is test-only. #184 is the substantive one — the reserved
protocol-v21
bottom-panel wire family, dark by construction, with the production
handshake deliberately still advertising v20 — following the Journey/GPU
directory-target ratchet (#183), following Journey Stage 1a (#182),
which made directory
startup one coherent local/daemon/GPU path and incorporated the terminal
configuration + copy mode landed-doc work (#180); following terminal
copy mode (#178) — C-c C-t
materializes a terminal's whole retained range into an ordinary buffer,
plus Buffer::set_generated_contents, the first genuinely immutable
generated-buffer write path — and its landed-doc pair (#168); following
Lean 4 Stage 4a (#179) — the typed-edit
consumer chain — and bottom-panel Stage 2A (#177), the classified census
routing that makes every Projection-class consumer ask
primary_document_window; the bottom-panel Stage 2 framing
(#175), terminal configuration Stage 1 (#173) — profiles, scrollback, a
per-terminal configurable escape key, and the C-c t opening binding —
Lean 4 stages 3a and 3b (#167, #170), pmacs' first Lean language server;
the GPU terminal input fix (#166), the double terminal-layout sync that
made a GPU terminal untypable; the CRDT undo repro (#157), the
inline-math landed-doc refresh (#172), the inline-math slice (#158), the
first mathematical typesetting in pmacs; dired Stage 1 (#165), Lean 4
Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) —
the dired arc's Stage 0 — COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
GPU initial-target doc refresh (#152); and before that GPU
initial-target (#148, protocol v20),
following folding Stage 2 (#149) and its landed-doc refresh (#150),
web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair
(#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the
documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering
parity (#137), locals-query processing (#134), modeline detection (#132),
mode system wiring (#129), config registry (#127), Vterm Stages 1–2
(#126/#130), and completed Themes Arc 4 (#120/#124/#125).
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the docs/*-framing.md
files ARE your memory. Read this fully before taking on work, seed
your persistent memory from it, and update this file (and commit
it) whenever project state changes materially — the next machine
reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read docs/active-work.md immediately after this file.
1. Where the project stands (2026-07-28)
-
main@0442d78(the M4 config-sink race fix #174 — test-only — atop bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet #183, Journey Stage 1a #182, incorporating terminal configuration + copy mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic #176, terminal copy mode #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179, bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175, terminal configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro #157, the inline-math landed-doc refresh #172, the bottom-panel landed-doc refresh #156, the inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). Protocol schema support is nowv6..=v21, while the production server-firstHellostill advertises v20. Those are two different facts and #184 landed only the first: the v21 bottom-panel wire family exists, is gated in both directions, and has no producer, no consumer, and no capability behind it. Compatible v21 activation and the production advertisement move belong to Stage 2B-3. The bullets below describe the arcs in their own terms; this line is the head-of-mainanchor. -
COHERENCE.mdis now required reading and a required framing input — #163. It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the standard new work is evaluated against. PerCLAUDE.md, every new framing doc must state its coherence impact — journey steps touched, interaction islands added, config-registry adoption, background-work attribution. Its §2 grades the golden journey; Journey Stage 1a moved that grade off "broken at step 3" — see the arc bullet below. -
Journey arc (P1) — Stage 1a LANDED (
docs/journey-stage1a-framing.md).pmacs .opens a directory instead of exiting 1, on one path:resolve_target_buffergained aResolvedTarget::Directoryarm ahead of the load,EditorState::openbecame a caller of it rather than a parallel implementation, and the daemon/GPU bootstrap shares the same arm. Which surface handles a directory is thepath.open-directorychain with dired as a replaceable fallback slot.tests/journey_acceptance.rsis the new cross-subsystem ratchet (steps 2, 3, 5 seeded; stages add rows, none removes them). No protocol change.- A hook a builtin subscribes to can never be first-claimant-wins
for users.
HookRegistry::addonly appends and builtins load beforeinit.lua, so a dired subscription would always claim before any user listener. That is why dired is a slot (pmacs.path.directory_handler) and not a subscriber — and why clearing the slot has to leave startup succeeding with a status, not exiting 1. - A raise and a
falseare indistinguishable inproceed.run_short_circuitreturnsproceed = falsefor both; onlyHookOutcome.errorsseparates them, and it decides whether to report, not whether to fall back. Getting this backwards produces a fallback that runs after a user's resolver crashed mid-handling. - The listing is async; the bootstrap is synchronous. The whole
post-await commit therefore runs against a destination captured at
request time (
pmacs.window.commit_to), which preflights every precondition before invoking the callback — dired mutates handle state,prev, and paint long before it reaches anything that could refuse, so validating at display time is four mutations too late. Awaiting inside a commit is refused: a yield would restore the scope while the coroutine is still parked. - The scope swaps
core.active_frontend, not just an override —pmacs.window.buffer()'s no-arg arm reads the ambient active buffer directly, so dired'sprevcapture would otherwise follow whatever frontend happened to be dispatching. The override also exists, and is load-bearing in exactly one case: a commit reached from inside an interactive command, where the origin would otherwise outrank the ambient value. Bite-testing found N4 green without it. replace_active_bufferdoes not drop the startup scratch buffer, despite its doc comment having claimed so for as long as it has existed. Its body is oneswitch_active_buffercall. The comment is corrected here; changing the lifetime is separate work.- Stage 1b is the named remainder: compile binding + Cargo defaults, LSP spawn guidance, welcome buffer.
- A hook a builtin subscribes to can never be first-claimant-wins
for users.
-
Terminal configuration + copy mode arc — COMPLETE (
docs/terminal-config-and-copy-mode-framing.mdrev 4; Stage 1 #173, Stage 2 #178; no protocol change in either, still v20). Stage 1 ships profiles, scrollback, a per-terminal configurable escape key and theC-c topener; Stage 2 ships copy mode —M-x terminal.copy-mode/C-c C-t.- The snapshot MATERIALIZES into an ordinary buffer. That is the
arc's organizing decision: isearch, motion, selection and the kill
ring work with no new substrate, and "keys must not reach the child"
dissolves structurally, because the transport arm keys on
is_terminal(buffer_id)and a snapshot is not a terminal. The dispatch-shadow count therefore stays at six. prunereacts to buffer removal rather than causing it — it filters on!registry.contains(buffer_id), so a child exiting does not remove the terminal buffer. That is what makeson_removeda sound teardown hook, and why a finished command's output stays readable.- Ownership means "in our own handle table", never found-by-name
(dired's F7 rule, re-learned here): snapshot writes use
bypass_intercept, so adopting a same-named foreign buffer clobbers user data. Snapshot identity is keyed by comparing buffer handles in an array —BufferIdLuaimplements__eqbut each wrapper is a distinct table key, so comparison works and hashing does not. - Profiles are a raw Lua table, joining
pmacs.lsp.configandpmacs.pair.sets, becauseConfigValueis four scalars with no table kind. The two open-time settings resolve through the global chain (they are read before the identity buffer exists); onlyterminal.escape-keyresolves per buffer, and its cache lives onTerminalSessionso its lifetime is the terminal's —value_epochalone is not a sufficient key, because it does not advance when focus moves between terminals holding different buffer-local values. - Criterion 17 is deliberately unpinned, and its bite is now stated
correctly. A real semantic frontend proving neither copy is mutated
needs the actual GPU binary (the optimistic apply exists only in
pmacs-gpu/src/main.rs; the headlessSemanticClienthas no optimistic path), i.e. thea37footing §5 warns about. Afterset_generated_contentsthe eventual test must look for unauthorized mirror mutation plus daemon refusal — divergence, not the "mutates both sides silently" the criterion originally specified, which can no longer happen and would pass for the wrong reason. A fix can invalidate a test that was never written. - Test instruments worth reusing:
cat -vis the echo probe, because the screen rejects C0 controls before they reach cells so a raw echoedCtrl-Xis invisible; and such probes must count occurrences rather than test presence, because a single-character probe collides with the child's own banner text.
- The snapshot MATERIALIZES into an ordinary buffer. That is the
arc's organizing decision: isearch, motion, selection and the kill
ring work with no new substrate, and "keys must not reach the child"
dissolves structurally, because the transport arm keys on
-
PTY terminate diagnostic LANDED — #176 (merge
bf8878f, 2026-07-26, one review round;docs/process-signal-tolerance-framing.mdrev 4, after three framing rounds). Diagnostic only — no disposition changed. Every call that failed before still fails, with no state transition and no reap-ledger arming;src/process.rsis the only source file touched. It exists because theterminateEPERM flake is real and nothing yet knows why.- Why three tolerance rules were all rejected: each concluded
something about a process from something that was not about that
process. Rev 1 reasoned from an errno alone (EPERM means the caller
lacks permission, not that the id was recycled); rev 2 from
try_wait, which observes the spawned leader while a PTY signal targets-tcgetpgrp(...)— entities that diverge exactly when job control has moved the terminal; rev 3 from group-directed ESRCH, which proves only that the selected foreground group vanished. This is the reusable shape, not a Unix trivium. - Two facts that killed the original argument.
group = trueis rejected for PTY mode at spawn, so the reap ledger never applies to the PTY path at all; and the ledger's own comment saying EPERM "cannot happen for our own children" drops the entry for bounded growth, not as a ruling that EPERM means dead. A comment stating a belief is not the same as code enforcing it. - What ships: a failing
killnow reports five separate facts — target source, target kind/value, spawn-time group, errno, and the leader's realtry_waitstate. The test seam injects the kill result only, never the observation, so the realChildHandle::try_waitruns against the real child. - It is not "strictly additive".
try_waitreaps and caches, so an exited child may be reaped earlier than before. That is safe only becauseportable-pty0.9.0 returns astd::process::Childon Unix and delegatestry_waitto it, sopoll_onestill sees the cached status — pinned by an exactly-one-terminal-event test rather than assumed. - Still open, and still the most likely real fix site:
signal_target's read-then-kill oftcgetpgrp. All tolerance rules remain parked pending the evidence this diagnostic produces, as doesterminateidempotence for an already-reaped process (a different failure, so a different PR).
- Why three tolerance rules were all rejected: each concluded
something about a process from something that was not about that
process. Rev 1 reasoned from an errno alone (EPERM means the caller
lacks permission, not that the id was recycled); rev 2 from
-
Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b, 4a, 4b ALL LANDED (
docs/lean4-mode-framing.md; #160, #161, #167, #170, #179, #181). pmacs edits Lean 4:arborium-leanhighlighting, alean4major mode,⟨⟩ ⦃⦄ ⟮⟯pairs, and alake servelanguage server with a Lake-aware outermost root, a lazy toolchain probe, a one-shotlean --serverfallback, andwaitForDiagnostics. No protocol change in any stage (still v20).- Two of the four stages contained no Lean at all, and that is the
arc's organizing rule: no PR mixes a cross-cutting substrate change
with Lean feature content. Stage 2 made LSP server affinity
per-project-root (
ensure_serverhad been reusing one server across roots — a correctness bug for every language, not just Lean). Stage 3a added notification/response subscription seams tohandle_server_requests, the single shared LSP event drain, pluspmacs.fs.canonicalize. - Two consecutive re-scouts found that rule broken by the stage being scouted — Stage 3 in round 4, Stage 4 in round 5, each time by a risk column that contradicted its own prose. The rule is not self-enforcing. Re-check every remaining stage's risk column at scout time.
- A configured LSP root must be a canonical absolute path. It
reaches
file_uri_forverbatim and that URI is the affinity key, so one package opened by two spellings spawns two servers. Stage 3a'spmacs.fs.canonicalizeis the primitive; it returns nil rather than a lossy path for non-UTF-8 input. LspManager::stopon an already-terminal client strands it inShuttingDownforever —server_is_livethen counts it live so nothing rebuilds against it, andforgetrefuses it for not being terminal. Stopping a dead server is what makes it un-replaceable. Stage 3b works around it by dispatching on state (forgetwhen terminal,stopwhen live); merely skipping the call leavesnext_restart_atarmed. The real fix is unframed substrate work.elanshims lie:lake --versionandlean --versioncan both fail ("no default toolchain configured") on a machine where Lean otherwise works, socommand -v lakeis worthless as a capability check. Lean acceptance is fake-server; live smokes must be PATH- and success-gated.- Stage 3b took six review rounds, and the same defect appeared four times: "the fallback silently doesn't happen," as no re-attach, then re-attach cleared by an unrelated buffer, then satisfied by the very server being replaced, then repairing one buffer while the rest stayed stale. Each fix was locally right; none asked what a global config swap invalidates. The durable lesson is to heal at consumption — the point where a stale record is handed out — not at the moment of the swap.
- Stage 4a (the typed-edit consumer chain) MERGED as #179
(branch
lean4-stage4a-typed-edit-chain, framing rev 8; it is part of the main anchor above). It is substrate only:builtin/runtime/typed_edit.luaowns the singlebuffer.after-editsubscriber and the single one-shot read,pair.luabecomes its first registered consumer, andtests/auto_pair_acceptance.rsis unchanged by zero lines (criterion 46, verified at the diff). No protocol change, no Lean content. The three decisions that turned out load-bearing rather than stylistic: consumers are called even when the record is nil (three existing auto-pair tests assert the non-event through it, and 4b abandons stale pending state on it); each consumer gets its own copy of the record, because pairing readsrec.charand a declining consumer could otherwise forge it; and the fan-out iterates a snapshot, because a consumer that registers a lower-priority one shifts itself forward underipairsand runs twice. - Round 8's durable lesson:
run_all_must_succeeddoes NOT abort the fan-out.src/hook.rs:332collects each callback's error and continues to the remaining subscribers, marking only the run failed — so an uncontained throw inside a hook subscriber does not stoplsp.luafrom flushing didChange. Two framing revisions asserted the opposite to justify apcall. The guard was right and the reason was wrong, and by the time review caught it the wrong reason had been copied into a module comment, an acceptance criterion, a test comment, and the ledger. Correct the source a rationale derives from, not only the sites that quote it. - Stage 4b (the Unicode input method) MERGED as #181
(framing rev 9): a vendored
1,855-entry table generated from
leanprover/vscode-lean4@17d1d08byscripts/regen-lean-abbrev, plus a consumer registered on the Stage 4a chain at priority 50, ahead of pairing. A consumer cannot both edit and let a later consumer act on the same keystroke: the chain hands each consumer a copy of the record made before any consumer ran, so an edit invalidates every copy still to be used. The expansion therefore runs on a SECONDbuffer.after-editsubscriber after the chain — which is how a pair character that terminates an abbreviation still pairs (\alp(→α()). And deferring work past a fan-out means owning which fan-out it belongs to: these fan-outs NEST, so a consumer between the expander and pairing that callspmacs.hook.runre-enters the deferred subscriber while the outer chain is still mid-list, and the count that recognises this has to come from a MINIMUM-PRIORITY consumer — the expander is optional (a claim can stop the chain first) and a subscriber beside the deferred one is too late (the nested fan-out finishes inside the outer chain's subscriber). Its other durable facts: the table must stay an ORDERED SEQUENCE (equal-length ties resolve by source declaration order, which apairs-iterated map cannot express); a generator round-trip check must re-read the BYTES ON DISK, because comparing in-memory strings cannot see an encoding applied by the write itself; and an expansion that SHRINKS the buffer must place the point explicitly, or every later self-insert is silently rejected and the editor looks dead. - Round 9 corrected three approved acceptance criteria by simulating the state machine over all 1,855 entries rather than re-reading the prose. Four review rounds over the text had not found them, because each named an example that reads as obviously right and is wrong only against the data.
- Remaining: stages 5 (goal panel), 6 (
#evaloutput channel), and 7 (module hierarchy) are framed but not scouted against currentmain.
- Two of the four stages contained no Lean at all, and that is the
arc's organizing rule: no PR mixes a cross-cutting substrate change
with Lean feature content. Stage 2 made LSP server affinity
per-project-root (
-
Inline math LANDED — #158 (
docs/inline-math-slice-framing.mdrev 3; merge5aa9044). pmacs renders$…$as typeset mathematics in the GPU frontend. No protocol change (still v20); the whole slice lives inpmacs-gpu, becausepmacs-gpudepends only onpmacs-protocoland never onpmacs— a core-crate parser would have been unreachable from where rendering happens.math_parse.rs→math_layout.rs→ aChunkSource::MathBoxspacer chunk → per-glyph mini-buffers drawn at the shaped line's real baseline, with fraction rules as quads. Font is bundled Latin Modern Math (~717 KiB) under the GUST Font License — not OFL.- The v0 subset is narrow and deliberately so: Greek (34 entries),
sub/superscript, and
\frac. Everything else — including relations like\geq, fences, big operators, and all display math ($$…$$,\[…\]) — is a named deferral, and an unsupported command degrades the whole span back to source rather than rendering partially. In a real paper most inline spans still show source; that is the designed behaviour, not a defect. - Math is suppressed while the caret is inside its span, so editing always sees source. That gate reads the effective caret plus selection endpoints and is fed by three separate refresh triggers; it is the most delicate part of the slice.
- Selection and search washes cover the whole box rectangle, not sub-ranges (sub-range washes are deferred).
- TUI shows the LaTeX source unchanged. That divergence is recorded
against
COHERENCE.md§16, which audits the "no privileged frontend" rule.
-
find-file LANDED — #162 (
docs/dired-framing.md§10, Q#DR11; merge2af1ab3; one review round).C-x C-fis the dired arc's Stage 0: pmacs previously had no discoverable way to open a file by path — no such command existed andpmacs.buffer.find_or_openhad no interactive caller. Pure Lua inbuiltin/commands/default.lua, one keymap line, an 8-test dispatch-driven acceptance suite; no Rust, no protocol change. Two substrate facts it documents, both worth knowing before touching any minibuffer prompt:- Completion over files is flat and cannot be made hierarchical from
Lua. A custom
sourcefunction is called with zero arguments (minibuffer.rs:591) and runs synchronously outside any coroutine, whereHandle:await()raises — so it can neither see the input to re-root on nor list a directory. Only the RustCompletionSource::Files { root }can list, and it is single-directory and 1024-capped. - A selected candidate SHADOWS typed text.
recompute_candidatessetsselected = Some(0)whenever the list is non-empty (minibuffer.rs:372-377) andresolve_accepted_valuereturns the candidate over the typed contents (:564-574). So free-text accept fires only when the input filters every candidate away — for basename candidates under a subsequence filter, when it contains a/. This applies toM-xandswitch-buffertoo. Consequences are pinned as decisions, including the hole where a new bare name that is a subsequence of an existing entry opens the existing file, and the empty-input case (fuzzy_scoregivesSome(0)for an empty needle and ties break lexicographically, so dotfiles lead). - Also:
get_or_load_buffercomputes a normalized path but loads from the raw one (editor_core.rs:842-856), so a~/…path dedups against an open buffer yet fails to load one that is not open — find-file expands the tilde Lua-side. Loading through the normalized path is a named deferral.
- Completion over files is flat and cannot be made hierarchical from
Lua. A custom
-
dired Stage 1 — the directory view — LANDED — #165 (
docs/dired-framing.md§0, S1-1…S1-12; mergec8ec8f3; one review round). pmacs now has a directory surface:C-x d/C-x C-jopen a read-only listing, one buffer per directory named*dired:<canonical path>*, with adiredmajor mode whose mode-scoped keymap carriesRET/f,^,n/p,g,q,s. Protocol unchanged at v20. Stage 2 (marks and operations) and Stage 3 (wdired) each still need their own framing; the frozen fixture shrinks after Stage 3.- The Rust is confined to two things: a per-entry-tolerant
read_dir(ReadDirTolerance {Fatal, PerEntry}→FsDirListing {entries, errors}), becauseread_dir_blockingfails a whole listing on any of five per-entry conditions and the tolerant wrapper its own module doc delegates to package authors cannot be written in Lua (one error value, no partial vec); andeditor_core::normalize_buffer_pathbecomingpub, exposed aspmacs.path.canonicalize. Only non-UTF-8 names stay fatal — byte-preserving paths would be needed. The Lua result shape keys onerrors.is_some(), so the bare array the frozen M8.2 fixture consumes withipairsis untouched. - Exposing a core normalizer beat mirroring it in Lua. A Lua mirror would have been a second canonical form — the same class of bug as the five tab-width constants (#137). Applies to any future Lua-side path reckoning.
- A fixed-width column must be fixed-width for every input. The
exported
pmacs.dired._layout(MARK 0, KIND 2, PERMS 3–12, SIZE 13, MTIME 24, NAME 41) is the contract Stage 3 reads offsets from, and%10doverflows at ≥10 GB, silently shifting every column right of it. Sizes now fall back to a width-clamped magnitude (K/M/G/T/P/E). - An ambient action must be gated on the buffer it assumes. A revert's cursor re-seat settles a tick or more later, by which time the user may have switched buffers; the paint names its buffer and is safe, but seating is ambient. This is the buffer-level instance of the rule below that interactive origin does not survive an await.
- A failure IS an answer — don't probe first. Kinds are lstat-based
in both
read_dirandstat, so nothing in an entry says whether a symlink points at a directory.RETtries to list it and treats the failure as the answer; an explicit probe was a second fullread_dir, so a descent listed twice. - Unbounded per-entry error collection needs a cap when nothing
cancels the work. A dired listing carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly
relied on (
READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS = 1024). - This is the first builtin with mode-scoped keys (#129's first
non-detection consumer), which broke the pre-existing
describe_key_identifies_every_default_binding: it asserted every binding resolves throughdescribe.keycontext-free, which held only while the modes table was empty. It now sets the effective context per binding and explicitly clears the mode for global ones, because a leaked mode legitimately shadows a global chord of the same name (dired'sRETshadowsedit.newline-and-indent), plus a floor assertion that at least one mode-scoped binding exists. - A dedicated panel does not carry its dedication across a descent
— the framing expected it to.
display_buffernever replaces the buffer in a slot dedicated to another one; it discards every side-specific parameter and falls back to the document window (Q#BP3 2.iii), and the exact-window arm errors. Dired does not unpin the user's panel; both arms are pinned. - Smaller facts worth knowing before touching this code: a path-backed
buffer's name is its full path, not its basename, which matters
for any name assertion;
pmacs.buffer.kill(notremove) redirects windows off a doomed buffer first, sodired.kill-when-openingkills after the replacement is displayed; ownership is checked against the handle table only, never the buffer name; andC-x dtakes no completion source on purpose (with one,RETon an empty field opens whatever sorts first, and RET-where-you-are is the gesture the binding exists for — the field is prefilled instead). - Verification at merge: 1,832 default + 2,009 CRDT library tests;
dired acceptance 25 + 25 CRDT; the frozen m8_1 10 / m8_2 15 / m8_3 32
unchanged, which is the additivity gate for the
read_dirchange; M4 121; required GPU 155; isolated-XDG_CONFIG_HOMEworkspace sweep 3,205 across 93 suites. 15 claims bite-verified.
- The Rust is confined to two things: a per-entry-tolerant
-
Canonical
mainis protocol v20 (SUPPORTED=[6..=20]; v16 =ThemeFacts, v17 =FontFacts, v18 =StatuslineSegments, v19 = terminal frames/events, v20 = the GPU initial-target semantic bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21 (SUPPORTED=[6..=21]), but its production daemon deliberately advertises v20: the handshake is server-first, so advertising 21 would make shipped v20 GPU/TUI clients reject beforeAttachRequest. Stage 2B-3 owns compatible production activation. -
Bottom panel Stage 1 (window placement + TUI side windows) LANDED — #155 (
docs/bottom-panel-framing.mdrev 4; mergee745068; two review rounds). No protocol change (still v20). Arc 7's substrate: pmacs now has Emacs'sdisplay-buffer+ window parameters, and a buffer can be displayed in a fixed-height window pinned to the bottom of the frame that feature code targets by policy instead of by stealing the selected window.src/window.rs:WindowParams { side, fixed_rows, dedicated }plus implementation-ownedquit_action/origin_document(Lua reads them,set_paramsrefuses them);MIN_WINDOW_OUTER_ROWS = 2;Layout::compute(area, fixed)subtracts fixed children before dividing the remainder by weight, preserving last-flexible-takes-the-remainder, so a tree with no fixed leaves computes byte-identically to before.Layout::computehas TWO production callers, and both must feed the same sharedpanel_fixed_rowsmap:window_placementsandsrc/overlay_paint.rs's peer-presence pass, which derives its own text-areaRectand never routes through the first. Leaving the second on unfixed geometry paints every peer cursor at the row it would occupy with no panel open.- The minimum is recursive (
subtree_min_rows: horizontal splits sum, vertical splits max). "Two rows at the root" does not give each nested leaf two rows.interactive_min_rowsis the same recursion over the user'swindow.min-heightpreference, and applies to drag/keyboard resize ONLY — the layout pass and frame-resize reconciliation use the structural floor, so changing a preference can never invalidate an existing layout. - Hiding a panel is a durable state transition, not a per-frame effect
(
EditorState::reconcile_panel_layout): it moves focus out and releases the terminal controller, because the terminal resize path merely returns on zero content without releasing. It runs after attach/resize/display/ close and defensively before input dispatch, terminal sync, and paint. FrontendViewgainspanel_capable,frame_geometry(None= unknown, never the GPU attach request's permanent 24×80 placeholder), and derivedpanel_hidden— each spelled explicitly at every construction site, preserving folding's non-Defaultdiscipline.EditorCore::primary_document_windowis the Q#BP14 projection seam;display_bufferis Phase 1 of the display transaction (exact target → side affinity → ordinary reuse, with option-valued height/dedication); the Lua layer owns Phase 2 (activate → hook → reconcile → revalidate → final-focus matrix).- Optimistic input is gated per WINDOW, not per buffer:
dispatch_idle_forreturnsfalsewhenever the acting frontend's active window is a side window. Marking the panel's BUFFER round-trip would be wrong —round_trip_buffersis global byBufferId, so it would disable optimistic apply for another frontend editing that buffer as its document. - Jump entries are per frontend and carry their origin
WindowId; a stale side origin is SKIPPED, because degrading it to an active-window switch is exactly the duplicate-panel corruption the arc removes. pmacs.window.display / display_file / quit / panel / params / set_params / resize / display_target, plusbuiltin/runtime/window.lua(window.panel-height,window.min-height,C-x ^/C-x C-^). Adopters takedisplay = "current" | "panel"; Stage 1 default is"current"and Stage 3 flips it.- The divider is the upper subtree's existing mode-line row — no row added
or consumed,
ui.dividerrestyles every exposed segment of one boundary, and drag state isHashMap<FrontendId, _>so frontends cannot steal each other's gestures. open_initial_targetnow shares oneresolve_target_buffer+ exact-window install seam withdisplay_file, and reasserts into a document window after hooks (a startup hook can now create a panel).- Final gates: 1,817 default + 1,994 CRDT library tests; the new
bottom_panel_stage1_acceptance46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - Stage 2 (the GPU panel band) is FRAMED, and its first two slices
have LANDED —
docs/bottom-panel-stage2-framing.mdrev 6, four framing review rounds, no open framing items; the rev-5 implementation split was explicitly approved 2026-07-27 and rev 6 records PR #184's server-first compatibility and gate correction. It reserves protocol v21 and ships as four serial implementation slices: 2A classified census routing + per-window painter extraction (no wire change, #177), 2B-1 the wire (#184), 2B-2 the daemon projection and epoch machine, then 2B-3 the GPU band, compatible v21 activation, and the negotiatedpanel_capableflip. Production attachment remains v20 through 2B-2. Parent acceptance 37–55 remains authoritative. Stage 3 is the adopter default flip. 2B-2 is the next slice, and it branches from0442d78or newer. - The §1.3 census is CLASSIFIED, not uniformly redirected. Only the
Projection class (#1–#12, #21–#22) routes through
primary_document_window; focus/input (#13–#15, #23), focus chrome and surface-routed (#16–#19), and focus/session (#20) keep their own authorities. Rerouting them breaks remote-op validation and application,DispatchIdle, presence, focused search/menu/completion routing, and terminal bell ownership.
-
Bottom panel Stage 2B-1 (the reserved v21 wire) LANDED — #184 (merge
6bee09d, 2026-07-28, two review rounds plus a gate-found follow-up; all 12 checks green on reviewed head5539b6e). It adds no producer, no consumer, and no capability:panel_capableis stillfalsefor every semantic session, so nothing about it is user-visible. What it establishes is durable:- Schema support and production advertisement are separate facts.
SUPPORTEDis now6..=21; the daemon's unsolicitedHellostill says 20. This is not a hedge — the handshake is server-first, so a shipped v20 frontend rejects aHello { protocol_version: 21 }before it can send anAttachRequest. Bumping the advertised version is therefore an incompatible act on its own, independent of whether any new message is ever sent. A real-daemon acceptance emulates that exact rejection point and then requires the attachment to reach its initial grid. 2B-3 must ship a compatibility-preserving activation mechanism; it may not simply change the unsolicitedHelloto 21. - One shared grid validator, split along a stated boundary
(
pmacs-protocol/src/wire_grid.rs). Shared: checked area, the visible-cell bound (262,144), cell count, cursor bounds, glyph legality, wide-continuation topology, the 8 MiB aggregate glyph-byte budget, and the attachment rejection. Terminal-only: the 512 per-axis PTY caps, title/process metadata, selection spans, and theat_bottom == (scroll_offset == 0)coupling. Per-axis caps are aWireGridLimitsparameter, not a constant, because a panel does not inherit them — a 4K surface at a small font is legitimately wider than 512 columns, and the area bound is what keeps the encoding inside the transport budget. The attachment rejection is deliberately shared: panels render no attachments either, so classifying it terminal-only would let a panel ship a cell no frontend can paint. PanelFramePayload::Absentis authoritative, and silence is not. The receiver retains its last valid frame, so a close or a hide must sendAbsentexplicitly or a stale band stays on screen indefinitely. Validation is likewise atomic — a bad frame is rejected whole and the previous valid frame is retained.- Two epochs, answering two different questions.
panel_epochis opaque and monotonic per frontend: stable across ordinary frames of one continuously present window/buffer, and moved on buffer replacement, new side-window creation, and everyAbsent→Presenttransition — which is what stops a stalePanelPointerfrom addressing a reopened panel as if it were the old one (Q#BP16).geometry_epochanswers a frontend declaration and moves whenever the frontend declares new effective cell geometry, including a font or scale change that leavesCellSizeidentical — exactly the case daemon-side value dedup cannot see (Q#BP2S1). PanelFramecarries an explicitbuffer_id(review round 1) and itsfocusedbit is presentation and focus-chrome routing only (Q#BP14b) — the keys decision remainsDispatchIdle(Q#BP14a).- The frontend half is
FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}, gated in both directions, with each extended enum byte-pinned on its own previous final variant so the v6–v20 encodings are provably unchanged. - A version bump is not done until every ratchet that pins the old
version moves. The full gate — not review — found that the
statusline and Vterm Stage 3 ladders still pinned v20 and rejected
v21, in both structural and real-headless-probe form. Grep for the
outgoing version across
tests/before calling a bump complete.
- Schema support and production advertisement are separate facts.
-
GPU initial target LANDED — #148 (
docs/gpu-initial-target-framing.mdrev 3; merge0dd16a5; two review rounds).pmacs --gpu [--socket NAME|PATH] FILEtransports exact Unix path bytes plus launcher cwd to the managed GPU client. Protocol v20 adds a semantic-sessionSessionBootstrapRequestafterAttachRequestand an appendedInitialTargetResultreadiness barrier; v6–v19 wire encodings stay pinned. The daemon resolves the path lexically, deduplicates or loads/creates it in the authenticated frontend's view, runs the established load/switch hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT upgrade to existing grid replicas before readiness. Semantic replicas receive a publication only when displaying that buffer, so a second target launch cannot switch an existing GPU window; one dead peer cannot fail the new session. Failed bootstrap writes a bounded result, shuts down the socket, removes provisional state, and restores the ambient active frontend. Any stale event from an uninstalled session is dropped before state access. Existing no-target managed launch, direct attach, TUI, and legacy protocol behavior remain intact. Folding Stage 2 integration: fold projection at attach is selected from the same negotiatedsemantic_renderbit the target bootstrap uses (grid collapses, semantic/GPU stays source-line pending Folding Stage 3). Final gates: 1,815 default + 1,992 CRDT library tests; target + invocation gates 14/14 CRDT each; Folding Stage 2 48 CRDT; M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,334 across 88 suites; two concurrent real Wayland/Vulkan GPU windows stayed on distinct target buffers after the second attach. All 12 CI checks passed. -
Folding Stage 1 (headless fold engine) LANDED — #142 (
docs/folding-framing.mdrev 5; mergec49a8c7; three review rounds, round 3 clean). Arc 6's engine — instance-side and headless; no frontend renders a collapse yet (that is Stage 2). No protocol bump.src/fold.rs: a per-bufferFoldStoreof byte ranges attached as a translating/droppingView(theBufferStyleSpanTranslatorpattern — it translates strictly-inside edits and DROPS boundary-crossers, provenance-blind);FoldRegistry/SharedFoldRegistry=Rc<RefCell<HashMap<BufferId, {Arc<Mutex<FoldStore>>, ViewId}>>>(the SyntaxRegistry per-buffer model), held on bothEditorCore(src/editor_core.rs:223) andEditorState(src/editor.rs:110). Containment is start-exclusive, end-inclusive(start, end]; the stored range is[end of head line, end of last hidden line](NOT theByteRangestruct doc's[start,end)).- Structural source: nearest enclosing block-like node ≥2 source lines →
resolve introducer↔body → derived head line (the line immediately
above the first hidden line, so wrapped signatures /
whereclauses stay visible) → closer-aware tail (a closing-delimiter line stays visible, e.g.} else {). Stale/absent parse tree refuses. - The six
EditorCoreedit primitives callunfold_before_point_editfirst (command-path pre-edit unfold, keyed on the active frontend's point). Interactive Lua-command unfold (yank/query-replace/comment) is a Stage 2 obligation; CRDT-origin unfold is Stage 3. src/lua_bindings/fold.rs(install_fold):pmacs.fold.*data API (explicit buffer, no ambient resolution, matching #127) + interactive commands on the Emacs hideshowC-c @prefix set;builtin/runtime/fold.lua.src/semantic_render.rs:fold_state_msgPRODUCESFoldState(semantic/GPU sessions) — authoritative-empty, diff-suppressed, per-session baseline reset onBufferSnapshot(the #120 stale-mirror trap class; the GPU fold-mirror clear-on-snapshot is a named Stage 3 obligation).- Durable lesson (round 2): after wiring a cleanup into a production hook, PIN IT THROUGH THE REAL PATH — a direct-call unit test misses the wiring (falsify by revert).
- Stage 2 (grid/daemon collapse) LANDED — #149 (merge
6ed4fe9; five review rounds;docs/folding-stage2-framing.mdrev 4). Its load-bearing reframe: the TUI had no non-identity source-line↔display-row map (view_top + rowwas baked into ~13 sites), so Stage 2's spine issrc/fold_view.rs'sVisibleLineMap— derived from the fold store plus a window's line offsets, never stored — that the render loop, gutter, diagnostics, caret, selection, peer presence, viewport/scroll/motion, and the mode-line indicator all route through, plus the interactive-Lua unfold widening. Threaded asOption<&'a VisibleLineMap>on a lifetime-bearingViewport<'a>that staysCopy. Design points the review rounds forced, each a trap for Stage 3:- the map's unit is a merged hidden component (overlapping or adjacent intervals unioned, keeping the earliest visible head), not a fold — folds may cross, and an inner/later fold's own head can be hidden;
- instances are per rendered window and per command/event operation, never per frame; a command's map follows the operation's TARGET window (a wheel event names a pane without activating it);
- fold projection is per-frontend (
FrontendView.fold_projection, set at attach from the negotiatedsemantic_renderbit) — sharedEditorCoremotion would otherwise make a simultaneous unfolded GPU session's cursor skip lines it still displays; - a hidden cursor normalizes by position, not row, and
set_view_topclamps in the setter rather than being repaired at render time; - the interactive-Lua unfold keys on the post-intercept edit site — a
managed buffer intercept may legally relocate the op.
No protocol bump. Stage 3 (GPU) is next and has no framing yet; its
named obligations are GPU collapse at TUI parity, caret/hit-test
fold-awareness, the
BufferSnapshotfold-mirror clear (parent R2-4 — the #120 trap class), CRDT-origin / GPU-optimistic interactive unfold (parent R2-3), and flippingFrontendView.fold_projectiontotruefor semantic frontends.
-
One-command GPU invocation LANDED — #141 (
docs/gpu-invocation-framing.mdrev 6; merge63fbc66; two implementation reviews). The additive public path ispmacs --gpu [--socket NAME|PATH]; barepmacs [FILE]remains the TUI. Root owns the CRDT gate, socket resolution, sibling-regular-file GPU discovery/PATH fallback, and GPU outcome. The separatepmacs-gpubinary owns connect-or-start, a five-second / 50-ms retry window, pre-winit event buffering, daemon process-group/stdin/stdout/stderr isolation, and named child reaping with explicit ownership handoff. Directpmacs-gpu --attach RAW_PATHremains strict, is documented as advanced, and never auto-starts. No protocol change. The initial macOS/LuaJIT CI run exceeded an unrelated outline performance threshold (147 ms / 100 ms); the complete failed-job rerun passed all twelve checks before merge. -
Config registry LANDED — #127 (
docs/config-registry-framing.mdrev 3; merge2e37c04; two review rounds).pmacs.configis the typed, introspectable options registry the backlog ranked first, and it closes the "config-registry-blocked" deferrals below. It was built as a PARALLEL LANE alongside vterm in a sibling worktree; the files were assigned per-lane up front and the rebase had zero conflicts.- Third registry beside
CommandRegistry/HookRegistry(src/config_registry.rs), same R42/R50/duplicate-rejection/SourceLocationvocabulary; Lua surface insrc/lua_bindings/config.rs. No protocol change (still v18) and ZERO changes tosrc/editor.rs. - An override is ALWAYS stored, even when equal to the value it
shadows; only
value_epochand listener dispatch key on effective change. The "equal-value set is a no-op" reading silently voids a buffer-local pin: nothing is stored, and a later globalsetflips the very buffer the user pinned. - Two scopes: global and buffer-local.
get(name, buf)resolves local → global → default;get(name)resolves the GLOBAL CHAIN ONLY and never consults an ambient buffer. Per-language and per-project are patterns (a hook callingset_local), not scopes the registry knows about. Mode keymaps now resolve through #129, butpmacs.configdeliberately remains global + buffer-local. - Buffer-locals live in a registry side table purged at
after_buffer_removed, beside the keymap purge. - Listeners: commit → snapshot → drop the borrow → re-enter Lua;
a raising listener is logged without blocking the rest or rolling
back; a depth bound turns a cycle into a pointed error. Explicit
dispose only — there is no
MetaMethod::Gcanywhere in the codebase, and GC timing differs between the two Lua backends. StartupOnlyfreezes off the existingInitCompleteFlagat write time (which is why noeditor.rscall was needed). In--libbuildsset_init_completenever runs, so a post-freeze test must flip the flag explicitly or it passes vacuously.- Adopters own their own
define, soSourceLocationnames the owning module:editing.auto-pair(pair.lua, read against the typed edit's SOURCE buffer),editing.trim-on-save(editops.lua, read against the buffer being saved),autosave.interval-ms(autosave.lua, re-read per tick). The migration wrappers keep their legacy coercion — the registry is strict, the legacy setters stay lenient (trim_on_save("yes"),interval_ms(1500.7)). M-x describe-settingrenders into*help*.
- Third registry beside
-
Mode system wiring LANDED — #129 (
docs/mode-system-wiring-framing.md; mergeb4b925d; one review round). The existing mode-keymap substrate is now live without a protocol change.Buffer.major_mode: Option<String>owns the single major mode. The detected language name initializes it once onbuffer.after-load, before grammar gating, so server-only languages work; switches never rewrite it. Explicit overrides and clears survive switches. A future reload that fires after-load re-detects, and explicit mode state is not session-persisted.- Dispatch borrows the zero-or-one mode through
Option<&str>::as_slice()and&[&str]: no hot-path mode allocation. Resolution remains buffer-local → mode → global, and registry/keymap borrows end before Lua command invocation. - Lua surfaces:
pmacs.buffer.major_mode/set_major_modeandpmacs.editor.active_modes.pmacs.describe.key,pmacs.help.show_key, and followed percent-encoded@mode:links use the same effective context;pmacs.keymap.lookupremains raw-global. - The built-in
modestatusline provider readsctx.buffer, so passive splits render their own mode. Real-daemon acceptance covers all ten framing criteria across both Lua backends and Linux/macOS CI.
-
Modeline language detection LANDED — #132 (
docs/modeline-detection-framing.mdrev 2; merge1dd47fc). Fresh loads scan bounded Emacs-*- mode: ... -*-and Vim/Vift=/filetype=modelines without evaluating file content, normalize common editor aliases, and give explicit modelines precedence over inferred language.builtin/runtime/syntax.luaowns one per-buffer fresh-load decision: modeline → bundled grammar extension → LSP filetype extension → exact filename → shebang. Syntax, initial major mode, pairing, comments, and LSP all reuse that pin; LSP retains its independent backing-path guard.- Editing a modeline or shebang does not switch an attached parser or make language-aware consumers diverge. Close/reopen re-evaluates changed file metadata. Explicit post-load major-mode overrides remain independent.
- Bounded valid unknown names remain passive major modes without starting an unavailable parser or server. All thirteen framing criteria are covered on LuaJIT and Lua 5.4. No Rust or protocol surface changed; protocol stays v18.
-
Syntax-highlight / language-detection side-quest (#114–#118) LANDED — a one-shot arc built in sibling worktrees off main while the user's themes lane (
theme-faces) ran concurrently in the shared checkout. All merged. What shipped:- Grammars (
crate::syntax::BUILTIN_LANGUAGES): every LSP-configured language now has one — cuda, bash, dockerfile (via the ABI-currenttree-sitter-containerfile, NOT the deadtree-sitter-dockerfilewhich pinstree-sitter ^0.20), make, cmake, python, go, javascript (+jsx), typescript (+tsx), toml, zig. - Detection chain and pin (
builtin/runtime/syntax.lua): modeline → grammar extension → LSP filetype map → filename → shebang. User-extensible Lua surfaces includepmacs.parse.modeline_aliases,.shebangs,.filenames,language_from_modeline,language_from_shebang,language_from_filename, and the pinnedbuffer_language.builtin/runtime/lsp.luadelegates to that shared decision after enforcing its backing-path requirement. Grammar name MUST equal thepmacs.lsp.config.<name>key. A buffer keeps its pinned language across edits/switches; close/reopen performs a fresh bounded inference. - LSP configs added: dockerfile (
docker-langserver --stdio), cmake (cmake-language-server, config viainit_options.buildDirectory="build"— it does NOT pullworkspace/configuration). Make has no server. - Substrate:
LanguageEntry.highlights_queryand.locals_queryare&[&'static str]fragments joined base-first (cuda over c/cpp; ts over js/jsx). Since locals-query processing #134, settle compiles the grammar'sLOCALS_QUERY, resolves Tree-sitter's scope/definition/value/ reference conventions into sortedLocalFacts, and stores them beside each layer's tree/query. Work runs once per fresh bundle and only when the highlight query asks aboutlocal; viewport rendering remains bounded. Both TUI and semantic/GPU producers evaluate#is?/#is-not? localthrough the shared capture walk. Non-shadowed JS/TS builtins are restored; shadowed definitions/references keep ordinary variable styling.
- Grammars (
-
Multi-language injections (#122) LANDED — the direct continuation of the #114–#118 highlight arc; four review rounds, framing
docs/multi-language-injections-framing.md(Q#IJ1–IJ11). A buffer can now hold more than one language:ParseTreeBundleholdsVec<Layer>(root + injected children, depth-ascending, installed atomically so the existingStyleGate+ highlight-cache Arc gates still work). The parse worker builds child trees off the staticBUILTIN_LANGUAGEStable (lazy load preserved) viaset_included_ranges— child node offsets are ABSOLUTE, so injected spans are buffer-coordinate-native; settle resolves each layer's highlight query (SyntaxRegistry::resolve_layer_queries). First consumers: markdown fenced code +markdown_inline(retires the M9.7 block-only floor). New substrate:LanguageEntry.injections_query;default_injection_aliases+SyntaxRegistry::injection_alias_snapshot(case-folded fence names, Lua-extensible viapmacs.parse.injection_aliases, snapshotted intoParseRequestso the worker never touches theRcregistry or Lua);ParseTreeBundle.injection_capped(the 4096-layer backstop, surfaced once/buffer viapmacs.errorat settle);compute_highlight_spans_for(query, tree, source, local_facts, range)(per-layer); the wireflatten_layer_spansevent-sweep → DISJOINT effective spans (deeper / later-sibling / narrower wins, keyed by(layer_index, capture_order)); GPUspans_from_segments+source_color_atfold. Two findings to keep: injection ranges exclude only NAMED children (anonymous tokens ARE the injected text — excluding them shreds a blockinlinenode; matches tree-sitter-md's own splitter), and the wire flattener runs over the WHOLE buffer via the file-style summary, so it must be an event sweep, not O(spans²). -
JSON + YAML grammars and language servers (#123) LANDED — bundled ABI-current
tree-sitter-json/tree-sitter-yamlcover.json,.yaml, and.yml; the existing injection engine now highlights YAML frontmatter and JSON/YAML fences. Default external LSP configs are the pinnedvscode-json-language-serverprovider andyaml-language-server; configured settings are pushed afterinitialized, which also supports push-model servers. The fake-server delivery proof and PATH-gated live JSON/YAML provider smokes cover the configuration contract..jsonc/.json5remain a deliberate follow-up because the JSON grammar is strict. -
Compile-mode (Arc 5 stage 1, #113) LANDED (2026-07-14, 7 rounds; framing
docs/compile-mode-framing.mdrev 13).compile.runstreams/bin/sh -c "exec 2>&1; <cmd>"into an intercept-read-only*compilation*buffer via a Lua ANSI parser; once-per-newline error rules; unifiederror.next/error.previous(M-g n/p,C-x `, M-!). Substrate other code can use:ProcessSpec.stdin/group(group lifecycle: reap ledger, in-drain enforcement, cancellable poll readers),buf:revision(), jump_back firesbuffer.after-switch,pmacs.errors.claim,AnsiParser::finish()(observable reset), and the style-overlay stack: buffer-attachedBufferStyleSpanTranslator(once-per-edit, fragment-preserving), render-only window overlays with identity-dedupedWindow::ensure_overlay+clone_for_split, idempotent atomichandle:dispose(). -
Editing-conveniences pack (editops, #111) landed (framing
docs/editing-conveniences-framing.mdrev 6). goto-line, case ops, transpose, zap-to-char, line move/duplicate/join, region sort/reverse/dedupe, delete-trailing-whitespace + opt-inpmacs.editops.trim_on_save. Substrate:pmacs.killring.kill_range/break_chain([fid])/arm_kill_prompt+commit_kill_prompt(marker lifecycle), and the origin-guard pattern for chain-sensitive minibuffer commands. -
Auto-pairing (#110) landed — Arc 2 COMPLETE (framing
docs/auto-pairing-framing.mdrev 6).BUILTIN_PAIR_CHARSin pmacs-protocol leave both frontends' optimistic classifiers;builtin/runtime/pair.lualoads BEFORE lsp.lua (first-didChange ordering contract); one-shot typed-edit provenance viapmacs.editor.take_typed_edit()(buffer-revision postcondition, Q#AP9). Substrate:buf:path(),pmacs.lsp.buffer_language(buf),PMACS_FAKE_LSP_CHANGE_SINK,TestDaemon::spawn_with_config. -
Themes (Arc 4) stages 1–3 LANDED; Arc 4 COMPLETE ON
main.- Stage 1 (#120,
docs/theme-faces-framing.mdrev 9): named UI faces as reservedui/ui.*theme entries; transactional split syntax/face epochs; protocol-v16ThemeFacts; snapshot/baseline symmetry; store-sourced diagnostic-count freeze. - Stage 2 (#124,
docs/gpu-set-font-framing.mdrev 5):pmacs.gpu.set_fontand authoritative protocol-v17FontFacts; frontend-local family resolution, live font reload/reflow, and visual-run caret geometry. - Stage 3 (#125,
statusline-segments,docs/statusline-segments-framing.mdrev 3): composable strictpmacs.statuslineproviders; borrow-released per-window evaluation with failure latches; legacy-preserving TUI composition; a pure built-in LSP provider; dynamic modeline faces; protocol-v18StatuslineSegments; authoritative-empty/snapshot symmetry; and atomic GPU validation, face resolution, shaping, clipping, and cache invalidation. Acceptance 1-27 is implemented. Final gates: Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default + 8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation workspace sweep 2,718 passed across 78 suites (19 ignored,basedpyrightfiltered);git diff --checkclean. Stage 3 landed as #125 and completed Arc 4 onmain.
- Stage 1 (#120,
-
Vterm Stage 1 terminal core LANDED ON
main— #126 (docs/vterm-framing.mdrev 5; merge643d1e1).-
Implementation commits:
bbc1f33(Stage 1),962944b(Darwin signal normalization), first-review fixesf0a235f,28f2e6c,bf972a7, and second-review hardening9797ada; reviewed feature headfc4e0cemerged through PR #126, https://github.com/levineuwirth/pmacs/pull/126. -
AnsiParserProfile::{LineOriented, FullScreen}preserves compile/REPL behavior while terminal PTYs emit the full cursor/mode/device operation set.src/terminal/{screen,input,session}.rsowns the state machine, encoders, and lifecycle registry. -
Public session seam: owned strict
TerminalSpec; ownedTerminalSnapshot;TerminalProcessState; andSharedTerminalManager = Rc<RefCell<TerminalManager>>withopen/is_terminal/process_id/snapshot/tick/send/resize/terminate/prune/ shutdown. Stage 1 snapshots are context-free; Stage 2 adds per-view state without a second screen. -
EditorStatetick order is supervisor → terminal-owned PID drain/prune →process.after-tick. Terminal IDs are not exposed throughpmacs.process; ordinary Lua/LSP/MCP ownership is unchanged. Terminal identity buffers are pathless, clean, empty, round-trip, and guarded read-only at every rope/CRDT/history mutation boundary. -
Acceptance 1–14 is mapped in the framing. The real PTY bite splits ESC/CSI writes, observes alternate-screen cursor addressing, blocks and resumes through raw
send, restores the main screen, and pins final output before exact PID/outcome annotation. One-row annotation visibility, TERM-ignoring shutdown, spawn rollback, buffer-kill prune, and immutable empty CRDT bootstrap are pinned. -
Review round 1 added typed IND/NEL/RI with margin-correct screen behavior, defaults absent
TERMtoxterm-256color, makes shutdown liveness acceptance portable withkill(pid, 0), and preserves custom tab stops on resize. Review round 2 rejects C0/C1 controls before they enter screen cells, preserves the released button code in SGR mouse reports, removes dead screen paths, and clears stale round-trip state during prune. Stage 2 now uniquifies default terminal buffer names transactionally. -
Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit post-arc deferrals in the framing.
-
Final from-start rerun after review round 2: Clippy clean; 1,661 default + 1,837 CRDT library tests (3 ignored each); 9 default + 10 CRDT vterm acceptance; M4 114 passed (3 ignored, 1 filtered); required GPU 109; workspace 2,769 passed across 79 suites (19 ignored, 1 filtered); diff check clean.
scripts/bite HEAD^ src/terminal/screen.rs --test vterm_stage1_acceptance terminal_cells_reject_child_control_charactersis a clean behavioral bite. The parser dispatch has its independent clean behavioral bite; the originalmain/crate-root bite remains explicitly weaker compile-time API evidence. -
Stage 3 GPU/protocol LANDED ON
main— #135 (mergecac4961;docs/vterm-framing.mdRevision 9, criteria 28–37). Protocol v19:InstanceMessage::TerminalFrame(discriminant 26, daemon-gated) plusFrontendEvent::TerminalResize(11) andTerminalPointer(12), both frontend-gated — the first bump gating in BOTH directions.SUPPORTED=[6..=19]. -
pmacs-protocol/src/terminal.rsnow owns the shared terminal bounds,TerminalProcessState,TerminalSelectionSpan, and the single structural policyTerminalFrame::validate;src/terminal/*re-exports them so no duplicate type exists.unicode-widthis a workspace dependency so the screen and the validator measure glyph columns identically.MAX_TERMINAL_FRAME_GLYPH_BYTES = 8 MiBbounds the payload instead of widening the transport cap; the measured maximum legal frame encodes to 13,437,863 bytes under the unchanged 16 MiBMAX_FRAME_BYTES. Over-bound snapshots are rejected, never truncated or silently chunked. -
The
Viewportgate keys on the AUTHENTICATED SOURCE'S ACTIVE BUFFER, not the buffer the message names.Viewportalso aligns the window to the buffer it declares, so a stale document viewport in flight when a command opens a terminal drags the frontend back off it- the terminal then never paints, with no error anywhere. The weaker "is the declared buffer a terminal" reading looks right and fails exactly this way.
-
Suppression compares the COMPLETE ordered payload, never
screen_generation: scroll, selection, viewport, and process state all change without advancing it. -
GPU:
pmacs-gpu/src/terminal.rsis a pure cell-space paint planner (testable without a GPU); the renderer builds one shaped buffer per text run so a wide/cluster advance can never choose the next column's origin.pmacs-gpu --headless-probedrives the real attach client without winit (attach::connect_with_sink), which is how criterion 37 gets one real daemon + real PTY + real wgpu path. -
#135 integrated canonical
mainafter #137 landed. The one code conflict joinedTAB_STOP_COLUMNSwith the terminal imports inpmacs-gpu/src/main.rs; terminal geometry remains fixed-cell and never consumes the document tab-stop projection. -
Final post-integration gates: strict Clippy; 1,768 default + 1,944 CRDT library tests; Vterm Stages 1/2/3 at 9/10, 4/4, and 5/7 default/CRDT; statusline 7/8; tab-width 2/2; M4 121; required GPU 139; workspace 2,946 passed across 84 suites; formatting and diff check clean. The first macOS/LuaJIT CI run timed out waiting for
VTERM_ALT_READYin the Stage 2 real-TUI smoke; the complete failed-job rerun passed all 12 checks. -
Stage 2 TUI LANDED ON
main— #130 (merge86fc1bc;docs/vterm-framing.mdRevision 7, criteria 15–27).TerminalViewKeykeys per-frontend/window projection state over one shared process/screen; logical row anchors retainscroll/selection through reflow. One authenticated frontend controls at most one session, with atomic replacement and release on focus/switch/kill/detach.
-
The strict
pmacs.terminalLua surface owns open/state/view/send/terminate and context-implicit scroll/copy commands; the latter error unless the invoking frontend's active window is a terminal. FixedC-cis the per-frontend terminal escape: only its next key reaches terminal-local editor bindings, while unescaped bound keys pass through to the child.C-c C-csends one literal interrupt. Copy drains through the acting frontend's clipboard path; active BELs drain once locally and per daemon frontend, while historical/passive bells are baseline-suppressed. -
TUI composition paints owned terminal cells/styles only inside each window's content rectangle, suppresses document overlays, and keeps sibling splits independent. Daemon key/mouse/paste/focus/resize/detach routing uses the authenticated connection source rather than client-claimed IDs.
builtin/runtime/terminal.luaprovides the terminal command, view commands, and pureui.modeline.terminalprocess/scroll segment. -
tests/vterm_stage2_acceptance.rsmaps Lua transactionality, shared-view isolation, clipboard/modeline behavior, and a hermetic real/bin/shTUI PTY smoke. Stage 2 changed no wire schema or GPU renderer; protocol remained v18 until Stage 3. -
PR #130 review round 1 (
8702791) aligned dispatch with the approved escape-prefix contract, closed the non-terminal Lua error path, made controller replacement atomic, retained zero-area view anchors, removed duplicate detach work, and replaced per-view deep scrollback clones with borrowed live/published row projections. Focused child-input coverage pins both unescaped bound-key passthrough andC-c C-c. -
PR #130 review round 2 (
b9a7e40) clamps anchors into the first surviving cell when eviction cuts through a wrapped logical line, prevents ambientactive_frontendfrom minting interactive Lua authority, names malformed explicit-context fields, restores dispatcher rationale, and removes owned cell snapshots from terminal mouse routing. The framing now records the transient v18 semantic-controller boundary and bracketed-paste injection deferral. -
Current-main integration (
3f0252f) preserved per-frontend terminal dispatch while applying the landed mode-scoped keymap, and exposed themode,terminal, andlspstatusline providers together. PR #130 merged at86fc1bc. -
Final integrated gate:
cargo fmt --check; strict workspace Clippy; 1,753 default + 1,929 CRDT library tests (3 ignored each); mode-system acceptance 1 default + 1 CRDT; Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109; workspace 2,882 passed across 82 suites (19 ignored, 1 filtered);git diff --checkclean.
-
-
Tab-width rendering parity LANDED — #137 (merge
2625ec7;docs/tab-width-parity-framing.mdrev 2). Source tabs remain one byte while every buffer renderer follows the shared fixedpmacs_protocol::TAB_STOP_COLUMNS = 8.src/display_width.rsowns allocation-free Unicode/tab-aware byte-to-column accounting for plain text, syntax, diagnostics, completion anchors, buffer-style overlays, and search washes.- The GPU rich-chunk projection expands source/adornment tabs before cosmic-text shaping and retains first-class source-tab provenance. Carets, hits, selections, peer washes, and diagnostic geometry share the same source/projected boundary rules, including a soft wrap inside one expanded tab.
- GPU minimap widths use the same tab/Unicode rule and refresh in the accepted text-edit transaction. No config, wire shape, negotiation, or protocol version changed. Local gates: 1,763 default + 1,939 CRDT + 1,763 Lua 5.4 library tests; 2 focused acceptance; M4 121; required GPU 119; workspace 2,911 across 83 suites; strict Clippy and diff check clean.
- This closes the standing "tab width is a rendering-parity bug, NOT a
config gap" deferral in §5: one shared constant now drives every
renderer. Terminal cells are deliberately OUTSIDE it — a terminal's
columns come from the child, so
pmacs-gpu's terminal geometry uses the monospace advance and neverTAB_STOP_COLUMNS.
-
PARKED: kill-ring browser + persistence. Revision 2 framing is preserved on branch
kill-ring-browser, but its0efb5cdscout is stale and must be repeated before implementation. No PR or implementation is active. -
Roadmap:
docs/roadmap-2026-07.md(ranked arcs). Position:- Arc 1 (LSP utility surface) COMPLETE — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus hardening follow-ups (#102, #105, #106).
- Arc 2 (editing table stakes) COMPLETE — query-replace (#97),
kill ring +
M-y(#103/#105/#106), comment-toggle (#107), auto-indent (#109), auto-pairing (#110). - Arc 3 (persistence) COMPLETE — saveplace/recentf (#98), desktop-save (#99), autosave/crash-recovery (#100), save-clobber fix (#101).
- Arc 4 (themes + extensibility) COMPLETE — named UI faces (#120), live GPU font preferences (#124), statusline providers (#125).
- Arc 5 terminal stage COMPLETE — compile mode (#113), Vterm terminal core (#126), TUI frontend (#130), and protocol/GPU Stage 3 (#135) landed.
- Config registry COMPLETE (#127) — not a numbered arc; it was the
cross-cutting substrate ranked first on
docs/side-quest-backlog.md's north star, and it unblocks the editing/indent/comment items that were config-blocked. - Mode system wiring COMPLETE (#129) — major-mode keymaps, introspection, lifecycle initialization, and statusline display shipped.
- Locals-query processing COMPLETE — #134 — grammar locals metadata, lexical resolution, settled per-layer facts, shared TUI/GPU local-predicate filtering, and a registry-wide locals-query invariant shipped without a protocol change.
- Arc 6 (folding) Stages 1 and 2 LANDED — #142 and #149 — the headless
fold engine (store, structural source, Lua
C-c @surface, command-path unfold,FoldStateproduction), then the grid/daemon collapse (theVisibleLineMapspine, fold-aware gutter/diagnostics/caret/selection/ presence/viewport/motion, and the interactive unfold widening). Stage 3 (GPU) is next, unframed. - Web grammars HTML+CSS LANDED — #146, and LaTeX Stage 1 — #144 with its inline-math parent framing #145.
- Arc 7 (bottom panel) Stage 1 LANDED — #155 — window placement,
window parameters, TUI side windows, the divider, and the adopter
displayopt-in. Stage 2 (the GPU band) is next and needs its own re-framing; Stage 3 is the default flip. DAP was parked awaiting exactly this arc's Stage 1 and can now re-baseline its touch census. - Remaining ranked arcs: 6 folding Stage 3, 7 bottom-panel Stages 2–3,
DAP, 8 GPU splits, plus the
.ipynbarc (its JSON-grammar prerequisite shipped in #123).
-
GPU terminal input LANDED — #166 (
main@b889873;docs/gpu-terminal-input-framing.mdrev 2; one review round). The dispatcher applied both terminal-layout syncs to every attached frontend each tick. A semantic session satisfies both conditions — aterm_sizesentry fromAttachRequestand a terminal declaration — so its PTY was resized twice per tick forever: the grid arm installed the TUI placement size, the semantic arm the declared content rectangle, each arm'sold_size == sizeguard seeing only what the other had just written. The child took aSIGWINCHstorm at tick cadence, which made typing into a GPU terminal impossible while output kept flowing. TUI was structurally unaffected.EditorInstance::sync_terminal_layoutis split intosync_terminal_controller_liveness(frontend-kind neutral: panel reconcile + release of a controller whose window moved away — reads only views/windows/controller, never a grid size) andsync_terminal_grid_geometry(grid only: TUI placement + resize).sync_terminal_layoutsurvives as the composition, soeditor::runandLOCALare byte-identical.daemon::sync_terminal_layouts_for_tickis the extracted loop body: liveness for every frontend once per tick, then exactly one geometry arm keyed onsemantic_statesmembership — the same fact session establishment uses, so the arms cannot both fire.- The trap, kept in a comment: the release on a missing
window_placementsentry reads like liveness and is grid geometry. A semantic frontend has no placement entry at all, so moving it into the neutral half would release a GPU controller every tick. - Why not the one-line guard: the grid arm was also the only per-tick
controller-liveness release a semantic frontend got, and
sync_semantic_terminal_layoutcannot take it over — the buffer-follow snapshot clears the viewport declaration, so that arm stops running in exactly the switch-away case that needs the release. - No protocol change (v20). Gates: 1,829 default + 2,006 CRDT library tests; vterm Stage 1/2/3 10/6/9 CRDT; bottom-panel 46; M4 121; required GPU 155; isolated-config workspace sweep 3,177 across 92 suites.
- Known gap, its own lane: CI never enables
crdt, so the Stage 3 real-path acceptance (includinga37) is not compiled there. #166's unit pins are notcrdt-gated and do run. Seedocs/active-work.md.
2. How we work (the part that must not drift)
The user is expert and reviews deeply — they falsify framings and find real bugs in round after round. The cadence that has worked for ~40 PRs:
- Scout ground truth in the code before proposing anything.
- Write a framing doc —
docs/<feature>-framing.md, numbered decisions (Q#XY1…), explicit "Ground truth", "Bets", "Deferred (named)", and an acceptance-test list. Present it and wait for explicit approval ("Go for it" / "Ready to roll"). Expect 1–3 rounds of findings first; revise the doc, don't argue. - Branch off main (one feature = one branch = one PR). Commit the framing as the first commit.
- Implement. Every reviewer finding gets a bite-verified fix — a test that fails without the fix. Watch for vacuously-passing tests.
- Run the full gate suite (§3). Open the PR with
gh. - The user replies with "Findings" lists on the PR rounds too. Same discipline. They say when to merge — never merge unprompted.
- After merge: update this handoff + your memory.
Commit/PR conventions: commit messages via git commit -F <file> (no
inline backticks through the shell). Authorship trailers and PR
attributions must be truthful: do not add a Claude co-author trailer or
Claude Code attribution unless Claude actually contributed. Clippy runs as
its own step, never &&-chained.
3. Gate suite (all green before any PR)
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings # own step
cargo test --lib # ~1500
cargo test --lib --features crdt # ~1672
cargo test --test <the new/touched acceptance suites>
cargo test --test m4_acceptance -- --skip basedpyright
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu # 58
cargo test --workspace -- --skip basedpyright # full sweep
git diff --check
Machine-specific caveats — re-verify on a machine you haven't used before trusting them:
- basedpyright: the DESKTOP's local binary is broken and HANGS the
m4_5_basedpyrighttests — hence the--skipthere. The LAPTOP has a working basedpyright 1.39.9 (verified 2026-07-10: the m4_5 test passes in 0.18s), so the skip is droppable on the laptop. - GPU on the laptop: AMD Radeon 780M (RADV) — native Vulkan,
PMACS_REQUIRE_GPU=1works without lavapipe. - Flaky-under-load tests — rerun isolated before treating a sweep
failure as a regression. The m8 daemon tests and the m6 process/PTY
tests (
m6_1_pty_mode_lifecycle_started_then_exited,m6_8_supervisor_reaps_all_children_across_cycles) are timing-based;editor::composition_overhead_under_ten_percentis a render-ratio microbenchmark that fails ~1/3 even isolated single-threaded (alreadycfg!(macos)-disabled). Vterm Stage 3's merge CI saw one macOS timeout inreal_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell; the complete failed-job rerun passed. The required-GPU gate also failed once inheadless_diag_face_recolors_band_counter_despite_unchanged_text, then passed both an isolated single-thread rerun and the full 139-test rerun. A lone timing failure → rerun the test alone (-- --test-threads=1) before investigating. Run the workspace sweep as ONEcargo testinvocation piped to a full log — a double invocation +grep -c "test result: ok"can mask real failures with a misleading0. - GPU tests need a Vulkan device.
PMACS_REQUIRE_GPU=1makes absence a hard failure instead of a silent skip. Headless option: lavapipe (seedocs/repository-audit-2026-07-03.mdfor the CI harness how-to). On a laptop without discrete GPU, mesa/lavapipe works. - The desktop's shell is fish (no
$(...), use(...); no$UID, use(id -u)). Check$SHELLhere before assuming.
4. Substrate invariants (do not undo; tests enforce most of these)
Generated buffers: Buffer::set_generated_contents is the ONE
authorized write (terminal copy mode #178) — lift read_only, replace
via a single whole-buffer Replace skipping intercepts, discard history,
re-assert read_only, and return the Edit. Three things make it a
unit rather than a convenience:
- An intercept is not read-only.
Buffer::undoreaches the rope throughensure_writableand never consults the intercept chain, so an intercept-only "read-only" buffer is emptied byM-x buffer.undo. Rebinding the undo chords buffer-locally does not close it —compile.lua's own comment says so ("command/menu undo stays dispatchable"). Only rope-levelread_onlydoes. - A bare
set_read_onlywould be worse than nothing, because it also refuses the owner's refresh — the operation such buffers exist for. That is why the pairing, not the setter, is the primitive. There is deliberately no Luaset_read_only. - A rope write is only half of an edit. The returned
Editmust be fanned out (notify_buffer_edit_to_windows, which also queues the daemon-origin CRDT op). Skip it and a displaying window keeps aTextViewline index describing the previous contents — the next paint indexes the new rope with stale ranges and tripsassertion failed: end <= self.len()— while replica mirrors never import the write at all.
History clearing is load-bearing twice (nothing can pop entries
read_only makes unreachable, so they leak), and must clear whichever
history the buffer has: the v0.1 stacks are bypassed in CRDT mode, where
it lives in loro's UndoManager. That has no clear, and needs none — a
manager records only what happens after construction, so
CrdtState::clear_undo_history rebinds a fresh one to the same doc.
Not yet adopted — the inventory is four writer mechanisms covering
five buffers. Every remaining intercept-protected writer uses the
older idiom: an erroring intercept plus set_round_trip_input, written
through bypass_intercept, with the rope left writable. All are
emptiable by M-x buffer.undo:
| writer | buffers | shape |
|---|---|---|
builtin/runtime/listview.lua:60-61 |
every listview panel | delete-all + insert |
builtin/runtime/compile.lua (ensure_slot) |
*compilation*, *shell-command* |
append per output batch |
builtin/commands/default.lua:869 |
*search-results* |
reset per query, then append per match batch |
builtin/runtime/dired.lua:371 |
every dired buffer | whole-buffer replace |
Do not read ensure_slot as covering the search panel — it serves
*compilation* and *shell-command* only (compile.lua:1090,1125).
*search-results* is an independent panel with its own intercept,
round-trip mark and writes, and compile.lua names it only in a
predicate. Nor is the scope "every generated buffer": *workers*,
*help* and *buffer-list* are generated too but do not use this
idiom, and the REPL package's intercept
(builtin/packages/repl/init.lua:187) is an op-filtering editing
policy, not a read-only panel — neither group belongs to this lane.
Adoption is not a one-line swap. It inherits the fan-out obligation, and
the three appending buffers need a streaming variant of the
primitive; listview and dired already write whole-buffer replaces and
are the cheap half. Recorded in COHERENCE.md §14.
And it does not replace set_round_trip_input. The protection is
layered across two copies: rope-level read_only refuses the op at the
daemon; round-trip input stops a semantic frontend applying
optimistically to its own mirror, which a daemon-side refusal cannot
reach — the refusal arrives after the frontend has already painted, so it
buys divergence, not prevention.
Command boundaries (Arc 2 kill-ring substrate) —
EditorCore.command_history: HashMap<FrontendId, CommandBoundary{this, last}>,
per frontend. Rotate on: keybound command, self-insert, menu invoke,
invoke_interactive (the M-x path). Break on: unbound key, GPU
optimistic CRDT edits, pointer gestures (wheel scroll deliberately does
NOT break), unified paste. Plain pmacs.command.invoke stamps
nothing (programmatic API); invoke_interactive rotates-then-invokes
(Emacs M-x semantics). Single-codepoint optimistic CRDT inserts
classify as buffer.self-insert (exact decode; "a(" breaks instead).
Lua: ed.this_command() / ed.last_command().
Effective-edit returns — buf:insert/delete/replace return the
post-intercept (start, end, inserted_len). Callers that care
(killring, comment) compare EXACTLY against the request; length-delta
and text-at-position checks are documented defeated patterns. Always
pcall the mutator: a rejecting intercept must report, not throw
through, and failed ops must leave no state (kill chains, yank
sessions).
Kill ring — entries {id, text} with stable monotonic ids; chains
and yank sessions are per-frontend and id-checked (an index is not
stable under other frontends' pushes). OS clipboard mirrors to the
acting frontend only. Paste is a unified daemon arm keyed by the
dispatcher's AUTHENTICATED source — never a payload frontend_id.
LSP outbound positions — every Position/Range builder in
src/lsp.rs routes through outbound_position (byte → negotiated
encoding). Any new request builder must too; UTF-16 servers reject raw
byte columns on non-ASCII text. Semantic tokens: full, full.delta,
and range are three INDEPENDENT capabilities — gate each.
Persistence (Arc 3) — state-dir wiring lives in
install_state_dirs() on real entry points only, NOT EditorState::new()
(tests stay hermetic); PMACS_STATE_HOME overrides. Autosave: one
buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
Protocol — encoding-breaking bumps are deliberate and versioned. Canonical
main is [6..=20]. The in-review bottom-panel 2B-1 schema extends support
to [6..=21], while ADVERTISED_PROTOCOL_VERSION stays 20 until 2B-3
provides compatibility-preserving activation; the server-first Hello
cannot advertise 21 without stranding existing v20 clients before
AttachRequest. v15 = CompletionPopup + StatusFacts.message; v16 =
ThemeFacts; v17 = FontFacts; v18 = StatuslineSegments; v19 = the vterm
terminal family; v20 = semantic SessionBootstrapRequest plus appended
InitialTargetResult; v21 reserves the panel frame/event family. New wire
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
must be guarded by a byte pin on the PREVIOUS final variant — its own
round-trip cannot detect a discriminant shift.
Fake LSP (src/bin/pmacs_fake_lsp.rs) modes: fullonly,
rangeonly, rangeonly16 (UTF-16 + fail-closed bounds validation),
sighelp. Use these for capability-matrix tests, not real servers.
5. Hard-won ops lessons
-
A test that skips on a missing precondition reports
ok, and a gate log cannot tell that apart from a pass.vterm_stage3_acceptance::a37— the only acceptance driving a real daemon, a real PTY and a real wgpu render together — derivespmacs-gpufromCARGO_BIN_EXE_pmacsand, when that binary is absent from the target directory, prints a skip and returns. A fresh worktree reports the suite 9/9 in 0.17 s having never run it; a real run takes ~4 s.PMACS_REQUIRE_GPU=1is what promotes the skip to a failure, and the standing gate list applies that flag tocargo test -p pmacs-gpu, a different package. Two habits follow: build the workspace before believing any suite that reaches for a sibling binary, and judge such a suite by its elapsed time, not its verdict. -
Before attributing a red test to your branch, run it on the merge base.
a37failed on the #173 branch, which looked like a regression; it failed identically on the PR's own base and on two intermediate commits, and had passed on that same base twenty minutes earlier. The variable was machine load from a second agent compiling continuously. Load-sensitive tests make both verdicts uninformative in isolation, so the base-commit run is the cheapest way to tell a regression from weather — and it is much cheaper than the bisect it replaces. -
A daemon-side fix is not deployed until the daemon is restarted from a tree that contains it. #166's reporter rebuilt and saw no change: the running daemon had been started from a shared checkout still on a pre-fix branch, and
pmacs --gpuattaches to whatever process already owns the socket. Rebuilding a binary does nothing to a running process. When validating a daemon-side fix by hand, check the running process's binary path and start time against the tree you think you fixed —ps -eo pid,lstart,args | grep '[p]macs --daemon'— before concluding the fix failed. -
Two operations that must be alternatives are not made alternatives by being adjacent. The dispatcher applied its grid and semantic terminal-layout syncs to every attached frontend; a semantic session satisfies both conditions, so its PTY was resized twice per tick forever and the child took a
SIGWINCHstorm that made a GPU terminal untypable while output still flowed. Each arm had a correctold_size == sizeidempotence guard — individually sound, jointly useless, because each saw only the size the other had just written. Write mutually exclusive per-frontend-kind work as oneif/elsekeyed on the same fact session establishment uses, and extract the loop body so a test can drive the real thing. -
Bite against every pre-image the fix could plausibly have taken, not just
main. For the same defect, the obvious one-line guard (skip the grid arm for semantic frontends) does fix the storm — and silently introduces a controller leak, because that arm was also the only per-tick controller-liveness release a semantic frontend got. A single revert would have scored the fix complete. The pin that catches it (acc 6) deliberately passes onmainand fails only against the naive guard: today's defect supplies the release by the accident of running an arm it should not. -
A quiet child is an instrument. A frame storm is invisible against a fixture that legitimately emits hundreds of frames, and an assertion like
frames >= 2cannot see one. The same applies to geometry: a "did a frame at the new width arrive" readout is satisfied by a geometry oscillating through that width. Assert upper bounds over a fixed window against a child that produces nothing, and let the child self-report the signal you care about (aSIGWINCHtrap printing a fresh distinct breadcrumb per signal — repeated identical markers paint nothing, becausecell::diffskips already-matching cells). -
TerminalMode::Rawmakessh-based input fixtures useless. There is noICRNL, so Enter delivers CR and aread -rloop waits forever for a LF that never comes — the test then "proves" input never arrived. Useexec cat, which copies stdin to stdout byte by byte. It is also the right echo instrument for the opposite reason people assume: termiosECHOis off in raw mode, so nothing double-echoes and one keystroke yields exactly one cell. -
Draining the event stream to learn a pid also TICKS, and a tick reaps. #176's
observing_the_leader_does_not_consume_the_exit_eventfailed under load with "process ProcessId(26) is not running": its helper drained forStartedto read the pid,drain_untilticks while it drains, and a tick can observe an immediately-exiting child and move the record out ofRunning— after whichsignalnever reaches the code under test at all. It passed standalone only because the drain returned onStartedbeforepoll_onesaw the exit. Read the pid straight from the supervisor record, which does not tick, and fail fast if the record has leftRunning. Verified load-bearing under matched load: 0/15 failures with all 16 cores saturated versus 1/10 for the ticking helper. The general rule: an observation helper that advances the system is not an observation. -
A wait predicate that is WEAKER than the assertion it guards is a race, on every platform that happens to lose it. #174: the m4 config sink test waited for
contains("probe")and then asserted"probe":true— six bytes further on. The sink is JSONL written by a separate process, so the test could read a half-written line; Linux won that race reliably and macOS/lua54 did not, reporting the truncated{"rust":{"probe":. Any "wait until the file mentions X, then assert Y about the file" shape races whenever Y is stricter than X. The fix is to wait for the exact unit the assertion reads — here a trailing newline, true only once a wholewriteln!record has landed, and still correct if the payload's field order or spelling changes. Two things generalize beyond the fix:- A race you cannot reproduce can still be bitten at one remove.
The failure needs a scheduler outcome Linux does not produce, so no
local run falsifies it. But
pump_asyncasserts on its deadline, so replacing the predicate with an unsatisfiable one proves the wait is load-bearing rather than decorative — and restoring the old predicate, which still passes locally, proves a green local run cannot distinguish the two. Bite what you can reach and say plainly which claim rests on structure instead of evidence. - The obvious fix is sometimes worse. The sibling
m4_26has the same weak shape and was deliberately left alone: waiting for the expected value would convert a genuinerootUriregression — what itsassert_ne!s exist to catch — into a five-second timeout with a misleading "server didn't initialize?" message, trading a precise assertion diff for a vague hang. Closing it properly needs a record terminator in the fake server first.
- A race you cannot reproduce can still be bitten at one remove.
The failure needs a scheduler outcome Linux does not produce, so no
local run falsifies it. But
-
Proving a child has exited is harder than it looks on this codebase. A fixed sleep is not proof; nix's
waitidis unavailable on macOS; andlibc::waitidneedsunsafe, which#![forbid(unsafe_code)]rules out. What works is driving the production diagnostic in a bounded loop until it observes the exit. Relatedly, #176's round-1 review replaced every substring assertion with exact message equality built from the kernel-assigned pid — the substring forms would have accepted a hardcoded target or a wrong exit code. -
The checkout may be shared with the user. Check
git statusfor foreign uncommitted work before any stash/checkout/branch surgery; never assume dirty files are yours. (Their uncommitted fix was nearly orphaned once.) A clean status goes stale within minutes when two lanes are active — for parallel work,git worktree adda sibling directory off main instead of switching the shared checkout. -
Never
git stashin this repo. The stash namespace is REPO-GLOBAL — one list shared across every worktree and with the user; a scripted push/pop can pop a human's years-old stash into your tree (happened during #111: a failedstash pushchained intostash pop, which grabbed the user's PR-#17-era entry). For run-tests-against-an-old-version swaps, usescripts/bite— a trap-guarded one-file swap over read-onlygit show, with an inverted verdict (exit 0 iff the tests FAIL against the old version), making bite-verification machine-checkable. -
A fix must be COMMITTED before it is bitten.
scripts/biterestores bygit checkout --, which reverts the file to HEAD, not to the state it found — so any uncommitted work in a bitten file is destroyed. A whole review round's fixes were wiped this way during #165. Corollary for a NEW file: the swap-over-git showmode does not apply at all, so its claims must be bitten by hand-editing, which makes the commit-first rule load-bearing rather than hygienic. -
A CONFLICTING PR silently runs no CI at all. GitHub builds
pull_requestworkflow runs against the PR's merge ref, which it does not create while the branch conflicts with its base. So pushes land, the branch updates, no run is ever queued, and nothing reports the absence — the checks list simply keeps showing the last successful run, which reads as current. Three pushes to #165 produced zero CI before the cause was found, andgh pr checksreturns nothing usable here. On any lane that lives through a movingmain, checkgh pr view <N> --json mergeable,mergeStateStatus,headRefOidand confirm a run exists for the current head sha, not merely that a recent run was green. -
Stacked PRs: retarget the child to main BEFORE merging the parent — GitHub auto-closes a PR whose base branch is deleted and cannot reopen it (#104 → re-opened as #105).
-
Frozen reviewed PRs do not absorb moving overlapping work. For #135 and #137, the approved/frozen #137 landed first; the larger #135 lane then merged canonical
main, retained its review anchors, and reran every gate. Derive that integration surface fromgit diff <base>..main, not the other PR's file list — concurrent landed work added an overlap the original two-PR comparison missed. -
Scripted edits (sed/python) in files with repeated similar blocks (
src/lsp.rsJSON builders): anchor on a unique line or you will silently edit the wrong block. This produced a vacuously-passing test and cost an hour. -
Acceptance fixtures that open
.rs/.pyfiles must emptypmacs.lsp.configfirst — the after-load hook spawns real servers (rust/python/c have default configs). Language detection (grammars +pmacs.lsp.filetypes) is unaffected. -
Test scratch buffers have no path ⇒ no language; use tempdir files when language matters.
-
Dual-purpose session state is a reset-contract trap (#120 rounds 2–5):
last_statusdoubled as the peer emission baseline AND the stale-diag count freeze, so resetting baselines onBufferSnapshotzeroed mid-edit counts. Knowledge about a buffer belongs in shared stores (DiagnosticStoreseverity totals), never in per-session baselines; and any daemon-side reset needs its frontend mirror audited in the same round (the GPU snapshot arm missed search/menu/status the first time). -
Tab width is a rendering semantic, NOT a config gap. The implementation on
tab-width-parityfixes the width at the TUI's established 8 columns, shares that constant throughpmacs-protocol, and expands tabs only in each display projection. Definingeditor.tab-widthcould not have fixed the GPU: source text and semantic spans stay byte-addressed while cosmic-text needs projected spaces plus an inverse hit/caret map. A future configurable width would require a buffer-effective frontend fact and cache invalidation; do not re-plan it as a scalar config-only change. -
A test that never runs passes. Two #127 review-round tests passed vacuously at first:
pmacs.editor.save()is the RAW save, whilebuffer.before-savefires inside thebuffer.saveCOMMAND (builtin/commands/default.lua), andsave()no-ops on an unmodified buffer — so a fixture that opens a file and saves it asserts on bytes nothing rewrote. Dirty the buffer with a real edit and go throughpmacs.command.invoke("buffer.save"). Caught only because the other case failed and the cause was chased instead of the assertion adjusted. -
A message that ALIGNS state cannot be gated on the state it names.
FrontendEvent::Viewportboth declares a byte range and switches the frontend's window to the buffer it names. Gating the vterm v19 dual declaration on "is the DECLARED buffer a terminal" therefore left a stale in-flight document viewport free to drag a frontend straight back off a terminal a command had just opened — the window oscillated, the terminal declaration was refused every time, and no frame ever arrived. Nothing errored. The gate has to key on the authenticated source's ACTIVE buffer. Generalizes: when two messages declare competing views of "what am I showing", the arbiter is the daemon's own state, never the claim inside either message. -
A pass that sets a mode flag must clear it on EVERY exit. The semantic producer's terminal pass returned early via
?when no declaration existed, leavingterminal_activeset — and the daemon uses that flag to suppressCursorByteand the presence sweep, so a frontend that went back to a document silently lost both. Caught by an acceptance assertion, not by any type. -
Sub-crate acceptance needs a real seam, not a fixture.
pmacs-gpudepends only onpmacs-protocol, so "real daemon + real PTY + real wgpu in one path" could not be an in-crate test. Generalizingattach::connect's reader sink (connect_with_sink) and adding--headless-probegave the acceptance the REAL handshake, outbox, writer, andrender_to_view— which is the whole point; a decoded-message fixture would have proved none of the three fit together. The probe found two real defects the in-process tests did not. -
Real-grid acceptance must budget for macOS startup and path width. A 100 ms first-Hello timeout failed under loaded macOS CI; use the normal five-second handshake window, then short polling reads. An 80-column split also clipped a custom statusline segment after macOS's long
/var/folders/...temp path while passing on Linux; size the grid for the longest supported fixture path (the mode-system test uses 160 columns per split). -
A provisional session that fails mid-bootstrap must actually close its socket, not just drop its handle. #148's dispatcher-side target-failure paths wrote
InitialTargetResult::Failedand dropped the write-halfUnixStreamclone, but a clone shares the underlying FD — the per-attach reader thread stayed alive with no installed session state. A client that kept the socket open pastFailedand sent any ordinary event hit an.expectreachable only through the new failure path and panicked the whole daemon. Fix:shutdown(Shutdown::Both)on every dispatcher-side failure path, plus a defense-in-depth session-registry membership check before anyFrontendEventtouches render/size/editor state. Generalizes: when a new failure path can leave a handle installed without its owning session, dropping a handle is not the same as tearing down the connection. -
A guard with no production caller passes every direct-call test. #155 round 1:
EditorCore::try_split_activeimplemented the side-window split refusal, butpmacs.window.split_horizontal/split_vertical— and thereforeC-x 2/C-x 3— still called plainsplit_active. The acceptance test called the core method directly, so reverting the guard entirely would have left every test green. Same shape as folding #142 round 2. Assert through the outermost user-reachable seam (try_exec(&s, "pmacs.window.split_horizontal()")), then falsify by revert. When the test shares a file with the code it pins,scripts/bitecannot swap it — break the production line by hand andgit checkout --. -
A geometric readout is not a state predicate.
TerminalViewStatus::at_bottomis defined asscroll_offset == 0— "the viewport currently reaches the tail", not "this view follows the tail". A still-anchored view satisfies it whenever it happens to be tall enough, so asserting it could not detect that Q#BP7's growth re-arm had never been implemented (#155 round 2): the next rows the child printed pushed the anchored view back into history. Pinning following requires advancing the world — feed more child output through a filesystem gate — and asserting the view came along. Related:scroll_offsetis viewport-relative, so "unchanged across a height change" is vacuous or wrong; the invariant is the frozen ANCHOR. -
A PTY in the default mode does not translate LF to CRLF. An
echo-driven test fixture staircases rightward, and past the viewport width every row clips to blanks — soassert_eq!(top_before, top_after)compares"" == ""and passes for any regression (#155 round 2). Emitprintf '...\r\n', and guard text comparisons withassert!(!observed.is_empty())the same way the panel daemon pin guards on!panel_hidden. -
Widening an ambient resolver into a scoped one can make a total function partial. #155 round 2 resolved both arms of
pmacs.window.buffer()through the acting frontend "for uniformity".acting_frontendfollows the interactive origin, which can name a frontend with no registered view (a baredispatch_keyfrom an unattached peer), so the no-argument arm began raising instead of answering. No runtime callerpcalls it, so killring, syntax, autosave, pair, indent and comment silently dropped operations —kill_ring_acceptancewent 30/30 to 25/5 on every CI platform. The ambient resolver's fallback is what makes it total; keep it, and document that as deliberate. Uniformity is not free when the paths have different totality. -
An upgrade decision must be tracked independently of the outcome that triggered it. #148 published a target's fresh
BufferSnapshotto existing grid replicas only when the buffer wasnewly_loaded || newly_created— but a target can dedup onto an already-existing, not-yet-CRDT-backed buffer (e.g. one a startup hook created viafind_or_openand never activated), silently upgrading it without telling pre-attached replicas. The later F29 lazy-upgrade sweep then saw an already-backed buffer and never broadcast it, permanently stranding those replicas on v0.1 round-trip for that buffer. Fix: have the upgrade helper report whether it performed the upgrade, and OR that into the publish decision rather than inferring it from the caller's own load/create branch.
6. Named deferrals (the standing backlog, consolidated)
Editing: word kills (M-d/M-BS — need bytes-returning deleters +
prepend-on-backward append), C-SPC set-mark, C-u C-y / C-M-w,
kill-ring browser + persistence, clipboard watching, block comments +
mid-line comment spans, comment-dwim append-at-EOL, per-language
comment padding. Pairing (framing "Deferred"): wrap-region on opener,
pair-aware backspace, RET-inside-pair closer-on-own-line,
in-string/in-comment inhibit (needs node-at-byte pmacs.parse),
undo amalgamation (pair = one step), balance-aware quotes
(the per-buffer toggle SHIPPED in #127 as editing.auto-pair).
Editops deferrals (full
list in its framing): recenter (blocked on viewport facts — the GPU
never consumes daemon view_top), Unicode case/word classes,
region-spanning move/duplicate, locale collation for sort-lines,
ensure-final-newline on save.
Substrate: buffer-aware edit epoch (after-edit currently compares the
ACTIVE buffer only), wire provenance for CRDT self-insert
classification, Lua intercept probe, completion.lua still on the old
cursor-delta heuristic (migrate to this_command), the TUI's
nonempty-selection optimistic type-over gate, generated-buffer search
invalidation, cross-peer chronological undo arbitration (mixed
source/daemon history; pinned by auto-pairing acceptance),
origin-pinned buffer.after-edit fan-out (a context-switching
intercept changes what later callbacks — LSP, completion — observe).
LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the
warning half of external-change detection (verify-visited-file-
modtime).
Config registry (SHIPPED #127; these are its own named deferrals):
persistence of settings and the custom-file split-brain question,
M-x list-settings as a listview panel, a settings completion source
for the minibuffer (minibuffer.read's source is a fixed Rust-side
vocabulary), table-valued settings (so pmacs.lsp.config,
pmacs.pair.sets, pmacs.comment.strings and the pmacs.parse.*
write-through proxies stay raw Lua), migrating the remaining scalar
setters (async_config ×2, killring.max, the enable booleans) and
pmacs.gpu.set_font, pending-set staging for names defined after
init.lua runs, and a scope = "global" define flag — set_local is
currently accepted for autosave.interval-ms, where a per-buffer
value is meaningless.
Tab width is NOT a config gap — see §5.
Mode system (SHIPPED #129): minor modes, buffer.after-mode-change,
mode-scoped settings, describe-mode, and persistence of explicit major-mode
overrides/clears across sessions.
Modeline detection (SHIPPED #132): bounded first/last-line Emacs -*-
and Vim ft=/filetype= parsing, explicit-over-inferred precedence,
alias normalization, and shared fresh-load language pinning for
syntax/highlight/LSP startup.
Highlight/detection (from the #114–#118 side-quest + injections #122):
locals-query processing SHIPPED #134; remaining injection follow-ups
now that the engine landed (#122) —
injection.combined (many matches → one shared parse; PHP-in-HTML, some
comment schemes), child-tree incrementality + range-scoped layer rebuild
(child layers cold-reparse on every settle today), injectable
runtime/Lua-registered languages (v1 resolves only against
BUILTIN_LANGUAGES), and the next injection consumers gated on new
grammars — HTML/CSS/GraphQL/SQL (<script>/<style>, JS/TS template
literals, doc-comment code);
modeline detection as a 5th layer ( SHIPPED #132;
byte-accurate multibyte cursor placement in -*- mode: … -*- /
# vim: ft=…)move_active_cursor_to
(still steps one codepoint per LSP byte column). A full Jupyter .ipynb
setup (reader → editable → kernel execution) now has its JSON grammar
prerequisite, but remains a real arc, not a one-shot.
GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter
riders (whitespace guides, folding, git markers).
Themes (full list in theme-faces framing rev 9 "Deferred (named)"):
popup/menu/dropdown bg + selected-row faces, ui.background /
ui.caret, ui.modeline.inactive, minimap chrome, peer-cursor
palette (+ui.selection for peer rects), ui.inlay_hint (needs the
epoch treatment on its producer), wire alpha, Indexed palette
unification, named-theme registry / light theme / persistence
(the registry exists now, #127; theme persistence still waits on
settings persistence), grid-vs-wire default_style asymmetry,
mask widening (gutter bg, wash glyph recolor, statusline bg echo
surface, chrome bold/italic/underline re-shaping).
Housekeeping: F-016 lua_bindings/mod.rs split paused mid-way
(tranches 0–2 landed, ~5–8 PRs left; see
docs/lua-bindings-split-framing.md).
Full cross-cutting index of the non-themes backlog (this list + every
framing doc's Deferred section + a code sweep, themes excluded, with a
prioritization north star): docs/side-quest-backlog.md.
7. Machine-local facts (desktop) that do NOT travel
Three untracked files live only on the desktop working tree and are
deliberately never committed: docs/pmacs-gpu-editing-perf-handoff.md,
docs/session-5-stale-styling-handover.md, python_experiment.md.
Don't expect them in a clone; on the desktop, never delete them.
8. Update protocol for this file
When a PR merges, an arc opens/closes, or a decision lands: edit the
snapshot (§1), append lessons (§5) and deferrals (§6) as they arise,
bump the date line at the top, and commit — usually riding the same PR
as the work. Keep durable architecture here; put branch hashes,
machine-local tools, incomplete verification, and recovery commands in
docs/active-work.md. This is a briefing, not a log: prune sections
that stop being true.