RET now runs edit.newline-and-indent (builtin/runtime/indent.lua):
one insert/replace of "\n" plus the current line's leading whitespace,
copied verbatim and clipped at the split point (Q#AI3). Region RET
stays a single Replace (CUA type-over, one undo step, one CRDT op);
the selection clears after every successful edit (Q#AI4). Fix-up is
snapshot-guarded against context-switching intercepts and repairs the
cursor by right-gravity translation through the effective edit
(Q#AI5). buffer.newline remains the plain-newline escape hatch.
GPU (Q#AI1/Q#AI6): plain Enter is no longer optimistic-eligible --
its classifier arm's premise (byte-identical to a self-insert) died
with the new binding. Enter round-trips like the TUI, which also
makes global and buffer-local RET rebindings (buffer-list visit)
reachable from the GPU frontend.
Substrate fixes that RET would otherwise ship on top of:
- Q#AI8 search staleness: notify_buffer_edit now marks matches stale
and right-gravity-translates the live session origin, matching
apply_active_edit; SearchStore::step and search_match_summary fail
closed while stale (a live search un-sticks on the next pattern
keystroke, since set() clears staleness).
- Q#AI9 empty selections: insert_char reports success and the
no-region arm of insert_char_over_region clears a lingering anchor
only on Ok -- ordinary typing no longer type-overs its own previous
keystroke after S-Left at BOF, and a rejected insert mutates no
state.
Acceptance: tests/auto_indent_acceptance.rs (20 dispatch-driven
cases), tests/auto_indent_crdt_acceptance.rs (pending optimistic
input then round-tripped Enter converges on the source replica),
flipped GPU classifier test, and lib tests for the store, core, and
dispatch seams.
Framing: docs/auto-indent-framing.md (five review rounds).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
docs/agent-handoff.md is the continuity bridge between development
machines: current state snapshot, the framing->review->gates->PR
working method, substrate invariants (command boundaries,
effective-edit returns, outbound_position, state-dir isolation),
machine-specific caveats to re-verify per box, ops lessons, and the
consolidated named-deferral backlog. Carries its own update protocol
so each machine hands it forward.
CLAUDE.md (auto-loaded by Claude Code on any clone) points there and
pins the always-true constraints: workflow, gate suite, shared-checkout
discipline, commit/PR conventions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtRqijWecEzTjPt1B4Nrt5
Q#CT1-6: pure Lua on the Arc 2 substrate (effective-edit mutators,
command boundaries, after-edit delivery). M-; = comment-line-style
toggle (named deviation from comment-dwim); pmacs.comment.strings
public table; min-indent insertion, skip-blank, mixed-span comments;
single buf:replace per toggle = one undo step / one CRDT op / one
effective-edit verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q#KR1-11: Lua ring on a per-frontend command-boundary substrate.
Eight-row boundary table (keybound/self-insert/unbound/GPU-optimistic/
pointer/paste/menu/M-x-interactive); stable ring-entry ids so shared-
ring interleaving cannot corrupt per-frontend chains or yank sessions;
sessions carry {buffer,start,end,entry_id,text} snapshots with slice
verification. Three shipped bugs in scope (Q#KR10): semantic-path Paste
drop (GPU Ctrl-V is a no-op today), missing after-edit for M-x/menu/
paste edits, and paste trusting the client-supplied payload id instead
of the dispatcher's authenticated source. Q#KR11: frontend.detached
hook + command_history detach cleanup. Incorporates three review
rounds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the PR #100 review round 4.
- MEDIUM stale skip-cache entry after a slot transfer. adopt() set
owner[hash] = new buffer but left the previous owner's `written` entry
pointing at the same hash, breaking the invariant
`written[id] => owner[hash] == id`. Repro: A and B are duplicate buffers
on one path; A owns the slot; B adopts (recover-file); B is killed
without saving, which frees the slot and deletes the file. A is still
dirty, but its stale written[A] = (hash, revA) makes the next sweep call
it "unchanged since its last copy" --- silently unprotected until its
next edit. adopt() now drops any other buffer's written entry for that
hash. Verified the new test fails without the fix (sweep writes 0).
- MEDIUM autosave write failures were swallowed. write_private can fail
(ENOSPC, a permission change, a clobbered state dir), but the tick and
before-quit paths did `pcall(sweep)` and dropped the error. For a
data-protection feature that is the worst failure mode: the user keeps
working, believing edits are captured, while nothing is written. Both
paths now go through a reporting wrapper --- status line "autosave
FAILED: ... --- your work is NOT being protected" on every failing sweep,
each distinct fault logged once via pmacs.error. The quit path reports
too (a failure there means the quit is about to discard work that was
never written anywhere) and still never vetoes.
Tests (autosave_acceptance now 29):
adopting_clears_the_previous_owners_stale_skip_cache,
a_failing_sweep_is_reported_not_swallowed (plants a regular file where
autosave/ must be a directory, standing in for ENOSPC).
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 29 + 8
units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review round 3.
pmacs.buffer.from_file does not dedup, so two buffers can visit one path.
Ownership was tracked as a path-wide `owned: HashSet<path_hash>`, which
made the duplicate case silently corrupting: both dirty buffers queued a
write to autosave/<same hash>, the later write won on disk, and BOTH were
recorded in `written` --- so the loser skipped future sweeps while its
contents were unrecoverable. The path-wide set also let either buffer's
save/kill retire the other's recovery.
A recovery file must stay keyed by path (a later session knows only
paths, never old BufferIds), so two divergent buffers cannot both be
protected under one key. Ownership is now `owner: path_hash -> BufferId`:
- the first modified buffer to reach a free slot claims it, including
within a single pass (the write loop updates `owner`, so the gather
loop tracks slots queued this pass --- otherwise two duplicates both
queue a write);
- any other buffer on that path is counted `conflicted` and reported
("autosave paused for N buffer(s): another buffer is visiting the same
file"), never silently mis-protected. It records no `written` entry, so
it re-attempts each sweep instead of believing itself saved;
- `discard_buffer` (save/kill) retires ONLY slots this buffer owns, which
now enforces both invariants at once: an unowned slot is unclaimed
crash data (Q#AS12), and a slot owned by another buffer is that
buffer's recovery;
- saving or killing the owner releases the slot; the duplicate claims it
on the next sweep;
- `recover-file` adopting into a buffer makes that buffer the owner --- the
file's contents are now its contents, and the previous owner truthfully
becomes conflicted.
sweep() now returns (written, blocked, conflicted). Its gather phase is
extracted into `gather()` (clippy too-many-lines).
This is honest rather than clever: pmacs cannot protect two divergent
buffers over one file, and now says so instead of pretending.
Tests (autosave_acceptance now 27):
duplicate_buffers_on_one_path_conflict_instead_of_corrupting (owner's
copy on disk; the dup never wins the slot by editing),
a_duplicate_buffers_save_does_not_retire_the_owners_recovery,
killing_the_owner_frees_the_slot_for_the_duplicate.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 27 + 8
units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review round 2. Q#AS12's ownership rule guarded the
sweep but not the RELEASE paths, so three doors were still open.
The rule is now total: exactly two things may release an unclaimed
recovery file --- recover-file (which adopts it) and discard-recovery
(explicit user intent). Not a sweep, not a save, not a kill.
- HIGH: buffer.after-save called _discard_buffer unconditionally, which
removed the live buffer's current-path key without checking ownership.
Repro: session 1 autosaves and crashes; session 2 opens the file, does
not recover, then saves --- the crash artifact was deleted. Same door
was open on kill. discard_buffer now removes ONLY keys this session
owns. The unclaimed copy survives (reported Stale, so never
auto-offered, but still recoverable/discardable). The on-disk file holds
the new work; the crash copy holds work never written anywhere, so
deleting it was the same data loss by a different door.
- MEDIUM/LOW: _adopt only recorded the path in `owned`, not an
association with the buffer. A removal callback fires after the buffer
has left the registry, so discard_buffer had no path to read and no
`written` entry to fall back on --- recover-then-kill leaked the copy
and it was offered again. adopt now takes the BUFFER and records a
`written` entry at the revision whose contents the file holds. That is
correct twice over: the skip cache declines to rewrite an identical
copy, and a kill can find and retire it.
- LOW: _discard(path) removed the file and unowned the hash but left
matching `written` entries, so a still-dirty buffer hit the unchanged
(path_hash, revision) fast path and went unprotected until its next
edit. discard_path now clears those entries; the next sweep re-protects
immediately.
Tests (autosave_acceptance now 24):
saving_without_recovering_preserves_unclaimed_crash_data,
killing_without_recovering_preserves_unclaimed_crash_data,
recover_then_kill_retires_the_adopted_recovery,
discard_recovery_lets_the_next_sweep_reprotect_immediately.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 24;
desktop 11; persistence 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review.
- HIGH data loss: sweep could overwrite an existing crash recovery before
the user ran recover-file. Reopen a file after a crash, edit it, and the
next autosave wrote the current buffer over the recovery key --- losing
exactly what autosave exists to protect. New ownership rule (Q#AS12): a
per-session `owned` set records which path hashes THIS session wrote or
adopted. A recovery file at a key we do not own is unclaimed crash data;
the sweep refuses to write that buffer, counts it `blocked`, and says so
("autosave paused for N file(s) with unclaimed recovery"). recover-file
ADOPTS the copy once its contents are in the buffer; discard-recovery
removes it. Either resumes normal autosave. sweep() now returns
(written, blocked).
- MEDIUM cleanup missed paths autosave can write. Kill/save cleanup now
goes through `discard_buffer(BufferId)`, which removes BOTH the buffer's
current-path key and the key its last sweep actually wrote (they differ
after a rename --- an LSP WorkspaceEdit changes the path while the
BufferId stays; a path-captured callback deleted the wrong key). And a
sweep-time GC deletes the recovery of any buffer that left the registry,
which is the backstop for argv `[new file]` buffers: they fire no
after-load, so no removal callback is ever registered for them.
- LOW/MEDIUM recover-file pinned only on the active path. Two buffers can
visit one path (pmacs.buffer.from_file does not dedup), so focus drift
could recover into the wrong buffer. It now captures and compares the
origin buffer handle as well as the path.
- LOW write_private left a pre-existing lax autosave/ directory alone. The
birth-mode only applies to dirs that call creates, so a 0755 autosave/
from an older run still leaked recovery-file names, sizes, and mtimes
despite 0600 contents. It is now tightened to 0700 --- but never `base`
itself, which is shared with history/recentf/desktop and may predate us.
New `state::exists` (an existence check, no read) backs the ownership
gate.
Tests (autosave_acceptance now 20): sweep_never_overwrites_unclaimed_
crash_recovery (blocked, crash copy byte-identical, adopt resumes),
discarding_an_unclaimed_recovery_unblocks_the_sweep,
killing_a_new_file_buffer_gcs_its_recovery,
saving_after_a_rename_removes_the_recovery_written_under_the_old_path,
a_pre_existing_lax_autosave_dir_is_tightened.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 20;
desktop 11; persistence 5; m4 90; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Q#AS1-11: hybrid (Rust sweep+guard, Lua cadence/config/UX);
process.after-tick + monotonic_ms cadence (no parked worker thread,
live-reconfigurable interval); validated-setter config
(pmacs.autosave.interval_ms, default 30s, 1s floor); one atomic
header-line+bytes envelope with NULLABLE origin meta (new files);
Fresh/Stale/Corrupt/None status table; pull-based aggregated notify on
the tick (covers argv [new file] buffers that fire no hook); explicit
hook.run(buffer.after-edit) after recover-file's replace; path-aware
(path_hash, revision) skip cache; shared sha256_hex extraction;
private 0700/0600 storage as a precondition for default-on.
Incorporates the 2026-07-09 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #99 review:
- HIGH daemon local-only was not reliable: run_daemon sets DaemonMode
only after EditorState::new() has run init.lua, so desktop_mode(true)
in init saw is_daemon()==false and the raw bindings were ungated. Now
save_session/restore_session early-return in Rust when the DaemonMode
marker is present — set right after the daemon's new(), so it holds for
every save/restore that can run after startup (before-quit hook, manual
commands, direct binding calls).
- MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore
is now a boolean (arm_restore(on)) that sets/removes the marker, and
desktop_mode(on) calls arm_restore(on). enable-then-disable no longer
restores.
- MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now
fires buffer.after-load once PER LEAF (per window), not once per buffer.
Syntax attaches its overlay to the active window, so each pane gets its
own; LSP attach_buffer is idempotent, so the same file in two panes
attaches LSP once but syntax to both.
- MEDIUM hidden restored buffers: documented as registry-only in v1 (they
are live/openable/in recentf, but do not fire after-load, so they
attach syntax on first visit via after-switch and LSP when next shown).
Full initial attach for hidden buffers is deferred. Noted in the
framing + a code comment.
- LOW trailing whitespace in docs/desktop-save-framing.md.
Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts
after-load fires twice for two panes of one file; daemon_mode_disables_
save_and_restore; disabling_desktop_mode_unarms_restore.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11;
persistence 5; m4 90; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review High (merge blocker): query-replace searched the origin buffer's
bytes but applied edits and moved the cursor through apply_active_edit /
search_place_cursor, which target whatever is ACTIVE. Focus can drift
mid-session — a click into another split, a key from another frontend,
both changing the active buffer outside the shadow — so a match found in
the origin buffer could be applied to an unrelated one. Buffer
corruption.
Fix: query_replace_on_origin() verifies the active buffer still equals
the session's origin buffer before every edit; on mismatch it ABORTS
without editing (clears the highlight, drops the session, status
'query-replace aborted: active buffer changed'), so an origin match can
never land in a foreign buffer. Guards replace/skip/all/replace-and-quit.
The dispatcher's after-edit revision compare now targets the ORIGIN
buffer (query_replace_origin_buffer + buffer_revision) not the active
one, so a drift-abort — which edits nothing — never spuriously fires
the hook. The forward-search clamp uses the origin bytes' length, not
active_buffer_len.
Also (review Low/med): query_replace_active() added to the
completion-popup modal-close guard, so a popup opened via the direct
Lua start (ed.query_replace_start) can't linger rendered-but-unreachable
while QR swallows keys.
Tests: core drift-abort (both buffers untouched) + end-to-end
focus-drift regression; ! fires after-edit exactly once for the batch;
RET/Esc/C-g quit paths (keep replacements); DEL skips. Acceptance
header corrected to match actual coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three PR #96 review findings (documentation accuracy):
- Q#P7 coordinates section claimed panels inherit a byte==UTF-16 wire
assumption with position-encoding hardening deferred. False as-built:
the transport layer negotiates general.positionEncoding and converts
every Position at the request/response boundary (PositionEncoding +
char_to_byte/byte_to_char, src/lsp.rs), so location rows reach Lua as
byte offsets. Reworded to record what landed; the true residual is
the codepoint-vs-byte cursor walk in move_active_cursor_to (shared
with go_to_definition, not introduced by panels).
- Intro described pre-arc behavior in present tense (references throw
rows away, code actions apply acts[1] blind, ...). Marked as the
pre-arc baseline with a status banner + inline as-built pointers.
- Drifted hard-coded line refs (editor_core.rs:2052-2071,
lsp.lua:658-662, lsp.lua:1187-1213) replaced with symbol names.
Also fixed the move_active_cursor_to comment in lsp.lua itself — it
was the same 'v0.2 hardening' false trail the doc's stale ref pointed
at, now naming the real residual (codepoint-walk, not wire encoding).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Q#P1-P7: panels are buffers (a shared listview runtime module
generalizing the *buffer-list* idiom — zero protocol change, both
frontends render them for free); switch-in-place presentation with q
restore (the GPU cannot show splits); read-only via intercept with its
limits recorded; the Q#P6 round-trip buffer seam so semantic frontends
never optimistic-apply into panels (RET visits instead of inserting a
newline); references list, outline, minibuffer code-action picker,
hover-doc panel; byte==UTF-16 caveat inherited, not multiplied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five findings from the manual validation pass, all in-branch:
1. LSP-only words never queried the server: the auto-open path fired
request_completion only when the sync providers already produced
rows. An empty sweep now leaves a pending session and the request
always fires; isIncomplete responses re-request on further typing.
Corollary: attachment_for_request now flushes-if-attached but NEVER
attaches -- the first cut wrapped attached_for_active, which spawns
servers on demand, i.e. per-keystroke spawn attempts in unattached
buffers (wedged the parallel m4 suite; serial ran 3x slower).
Attachment stays buffer-open policy.
2. Cross-buffer LSP leak: the built-in provider's no-uri fallback was
the legacy global store drain, so scratch/unattached buffers could
show another file's cached completions. Strict now: no uri, no rows.
3. Pending prefixes own the keyboard: Action::Pending (C-x ...)
dismisses the popup and the popup shadow is guarded on an empty
dispatcher prefix, so the sequence's continuation and its C-g abort
reach the dispatcher instead of the popup.
4. Window-scoped sessions: CompletionPopupState.window_id (stamped by
completion_popup_open; Lua never sees it). Only the owning window's
overlay paints -- same-buffer splits each carry a persistent
overlay -- and a focus change invalidates the session.
5. Flaky worker test: the /proc thread-count probe and the idempotence
check both build EditorStates and could run concurrently, polluting
the baseline; merged into one test (non-Linux keeps a portable
idempotence variant).
Regression tests for 1-4; framing doc gains the as-built notes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
State assessment from a five-way sweep (core editing/persistence, LSP,
GPU parity, extensibility/terminal, deferred-work inventory). Records
the decision to push Arc 1 (completion popup, LSP panels, semantic-token
auto-pull, signature trigger) with Arc 2 editing table-stakes
interleaved, plus the ranked remaining arcs and housekeeping list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record sub-arc 3: the shared LineNumberMode enum + number_for in
pmacs-protocol, the v14 mode bump, the cursor-line repaint-on-move
dependency, stable gutter width, and the binary-toggle + completion-picker
selection UX. Plus the arc-close note: Q#UX1 scored false (two protocol
bumps), and the deferred gutter-riding features.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Record the sub-arc 2 as-built: signs-ride-the-gutter coupling, the
Viewport.gutter_w + paint-reorder mechanism (TUI), the layout_runs bar
mechanism (GPU), the TUI-glyph/GPU-bar rendering difference, the
en-route multi-frontend window.close crash fix (PR #87), and the known
completion-navigation follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Fix the control plane for the line-number gutter: M-x
window.toggle-line-numbers now works from EITHER frontend, each affecting
its own window.
Root cause (scores framing bet Q#UX1 false): rendering a gutter is
frontend-local, but the TOGGLE is a daemon command, so the mode has to
reach the GUI over the wire. My earlier GPU control (a --line-numbers flag)
left M-x-in-the-GUI a no-op and the two frontends' settings disconnected.
- Protocol: new additive `InstanceMessage::LineNumbers { buffer_id,
enabled }`; PROTOCOL_VERSION 12 → 13, SUPPORTED grows to [6..13].
Daemon-gated < 13 (a v12 peer keeps its gutter off), like every prior
additive bump — no encoding break.
- Producer: SemanticRenderState::line_numbers_msg reads the frontend's
active window mode (via active_window_for(frontend_id)) and emits on
change; cached-compare suppression seeded to the frontend's `off`
default, so a plain window adds zero traffic and existing frames are
unchanged.
- Daemon: gate LineNumbers >= 13 in the write loop.
- TUI: drops LineNumbers silently (reads its window directly).
- GPU: consumes LineNumbers → drives local `line_numbers`; the
--line-numbers flag retired.
Now the daemon Window.line_numbers is the single source of truth; both
frontends render locally from it.
Tests: line_numbers_msg emit-on-toggle/suppress-when-unchanged; protocol
version pins updated to 13. Validated: fmt + clippy --all-targets clean
both flavors; 1440 lib + 12 protocol + 53 pmacs-gpu tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Review follow-up on the F-016 split. install_diag / install_project_index
/ install_mcp were `pub fn` reachable at crate::lua_bindings::install_* be-
fore the split, but moving them into private child modules dropped those
paths without a re-export — shrinking the public API, which the split is
supposed to preserve. (They take crate-internal handle types so no external
caller can invoke them, and none does, so nothing actually broke — but the
paths should still resolve.)
Re-export all three alongside the factories/handles already re-exported,
restoring the paths for the two already-merged tranches (diag, index) too.
Deliberately narrowing these to pub(crate) is left as a separate change.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Third tranche of the F-016 split. Extract the pmacs.mcp surface (MCP client
bindings) from src/lua_bindings/mod.rs into src/lua_bindings/mcp.rs,
verbatim.
Corrected model (see framing): a helper-hoist is NOT a prerequisite for
most domains. The contamination that stopped parse/theme bites only when a
shared helper is *defined inside* the range being extracted. A domain that
merely *uses* a cross-section helper reaches it via `super::`
(parent-private access). So mcp extracts cleanly: all its items are
self-contained, and it reaches the JSON converters (still in the lsp
section) via super::json_to_lua / lua_to_json, and SharedProcessSupervisor
via super::. The JSON-helper hoist is deferred to the tranche that
extracts lsp itself (where they're defined).
mod.rs declares `mod mcp;` and re-exports make_mcp_manager (external caller
editor.rs) and McpServerIdLua — the latter to preserve its public-API path
crate::lua_bindings::McpServerIdLua (moving it into a private module had
dropped it from the crate surface; the split must not shrink the public
API).
Pure code motion, no behavior change. mod.rs: 14603 → 14020 lines.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Second tranche of the F-016 split. Extract the pmacs.index surface (the
project symbol-index bindings) from src/lua_bindings/mod.rs into
src/lua_bindings/index.rs, moved verbatim.
index is the one genuinely clean remaining leaf: its private helpers
(symbol_kind_from_lua, lua_symbol_from_table, search_hit_to_lua) are used
only within its own range, and it has zero shared-core coupling — it
depends only on crate::project_index, mlua, and std, reaching one stranded
helper (lua_to_json, still in the lsp section) via `super::`.
mod.rs declares `mod index;` and re-exports `SharedProjectIndexer` +
`make_project_indexer` via `pub use`, so the crate::lua_bindings::… paths
in editor.rs and completion_framework.rs (and an in-file completion-
framework use) stay valid — no external file changes.
Pure code motion, no behavior change. mod.rs: 14986 → 14603 lines.
While vetting the next leaves I found the recon under-counted the
misplaced shared helpers: parse/theme, window, and minibuffer trail off
into shared style/color, caller_source, and command/menu helpers, so a
dedicated helper-hoist tranche must precede them (framing tranche plan
updated). This tranche stops at index rather than force a contaminated
extraction.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
First tranche of the F-016 split of the 15k-line src/lua_bindings.rs.
Deliberately minimal — it validates the mechanics before bulk moves.
- Convert src/lua_bindings.rs → src/lua_bindings/mod.rs (the
`pub mod lua_bindings;` in lib.rs resolves to mod.rs unchanged).
- Extract the pmacs.diag surface (diagnostic_to_lua + install_diag) into
src/lua_bindings/diag.rs, moved verbatim. mod.rs declares `mod diag;`
and its one internal call site is now `diag::install_diag(...)`.
Pure code motion: no logic, signature, or behavior change. diag.rs reaches
shared-core items (BufferIdLua, SharedCore) via `super::` — a child module
can see its ancestors' private items, so no visibility widening was
needed; install_diag's only caller is mod.rs itself, so no re-export
either. The Lua-visible pmacs.diag.* API is byte-for-byte unchanged.
mod.rs: 15202 → 14986 lines. Framing + tranche plan:
docs/lua-bindings-split-framing.md.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit AND lua54 (the tests drive
pmacs.diag.* through the Lua VM — same outcomes, code relocated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
`--all-features` can't build pmacs — luajit and lua54 select mlua's
mutually-exclusive Lua backends. Document the model so generic tooling
(CI, cargo hack, distro packaging) doesn't trip over it:
- README §Build: a feature-matrix table (luajit default / lua54 fallback /
orthogonal crdt), the supported build lines, and an explicit "don't use
--all-features".
- src/lib.rs crate docs: a "Lua flavor features" section stating the
exactly-one-flavor rule.
- Cargo.toml [features]: expanded comment on the mutual exclusivity.
CI already iterates the flavors explicitly (never --all-features), so no
CI change was needed.
The audit's suggested crate-local compile_error! for the wrong-flavor case
was investigated and rejected as unreachable: the flavor check lives in
the mlua-sys *build script*, which cargo compiles before the pmacs crate,
so a mis-set flavor (both or neither) fails there first and pmacs's own
compile_error! never evaluates — confirmed empirically for both cases. A
dependent crate can't preempt a dependency's build failure, so the docs
are the honest mitigation and they name mlua-sys as the actual error
surface.
Validated: fmt clean; clippy clean under both Lua flavors; both flavors
build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Review follow-up on F-005. The basename-collision check only ran in
ResolverState::into_plan, which covers fresh resolves and UpdateOne — but
UpdatePolicy::Frozen returns Lockfile::to_resolve_plan(...) directly,
building a ResolvePlan without the check. A pre-existing or hand-edited
lockfile containing two distinct packages that share an install basename
(e.g. owner/magit and other/magit) would produce one plan and install both
to <root>/<basename>, silently colliding.
Make find_basename_collision (and its message helper) pub(crate) and apply
it in Lockfile::to_resolve_plan too — up front, before any fetch, so a
colliding lockfile fails fast via a new LockfileError::BasenameCollision
(surfaced through the Frozen path as ResolveError::Lockfile). Both
plan-construction sites now reject; to_resolve_plan is pub and has direct
callers, so guarding the method (not just the resolve_with_policy branch)
covers them all.
New unit test builds a two-entry colliding lockfile and asserts
to_resolve_plan rejects it before touching the fetcher.
Validated: fmt clean; clippy --all-targets clean under both Lua flavors;
1437 lib tests pass (incl. the new frozen-path test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Package-manager hardening sweep from the repo audit — one Medium + four
Lows, all in src/packages/ (F-011 also renames across lua_bindings + tests).
F-005 (Medium) — install dirs are named by package basename and require
routes by basename, so two distinct packages `owner/magit` and
`other/magit` collapse to one dir with most-recent-install silently
winning. Reject a resolve plan that contains distinct names sharing a
basename: new ResolveError::BasenameCollision + find_basename_collision()
in into_plan (the one place holding every name at once). The loader's
*intended* cross-scope override (project- vs user-scope, most-recent-first)
is untouched — its test still passes. Namespace-preserving layout and
cross-resolve install-time detection are named-deferred.
F-009 (Low) — the fetch bare-mirror cache dir was keyed by 64-bit FNV-1a
of the (attacker-adjacent) repo URL — trivially collidable. Swap to
SHA-256 (sha2, already a dep for lockfile hashing). normalize_url still
folds equivalent URLs to one entry; only the digest changes (re-clones
once, it's a cache).
F-010 (Low) — on a git subprocess timeout, run_with_timeout returned
before joining the stdout/stderr drain threads (joined only on the normal
path), leaving detached readers. Restructure to break the wait loop with a
Result, reap the child on every path, and join both threads at one point
before propagating.
F-011 (Low) — ResolvedPackage.commit was documented "Full 40-character
commit hash" but commit_for_tag() puts a tag string there (the resolver
works against commit-ishes by design, deferring SHA resolution to the
installer/lockfile). Rename the field to `revision` + honest doc.
Compiler-driven rename hit exactly the ResolvedPackage sites; the
Lua-visible "commit" record key is unchanged.
F-012 (Low) — the topo sort built an indegree map, argued in comments it
was backwards, and rebuilt it. Delete the dead first block + the
meandering narration.
Framing/as-built: docs/package-manager-hardening-framing.md.
Validated: fmt clean; clippy --all-targets clean under both Lua flavors;
1436 lib unit tests pass (incl. new F-005/F-009 tests, the F-010 timeout
test, and the loader override test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Review follow-up on the F-008 bounded outbox. Closing the outbox on a
lossless overflow set a `closed` flag but did not disconnect: the reader
stayed blocked on its still-open socket clone, so no `Disconnected` fired,
the daemon was never signaled, and the optimistic CRDT edit whose
`send_crdt_op` failed was applied locally, logged, and forgotten. That is
silent divergence — the GPU keeps showing text the daemon never received,
the exact stalled-daemon case F-008 exists to handle.
Keep a `shutdown_handle` socket clone and `shutdown(Both)` whenever the
outbox closes — the overflow path in `send_event`, and the writer's own
write-failure path. Clones share the socket's file description, so the
shutdown wakes the reader (blocked in `read_message`) with EOF: it fires
the existing `Disconnected` flow, which renders `(daemon disconnected)`,
and the daemon sees the half-close. The fail-fast is now a real teardown
→ the user gets a visible disconnect (and a fresh snapshot on re-attach)
instead of a silently diverged buffer.
New socketpair test asserts a send against a closed outbox drives the peer
to EOF. Auto-reconnect/resync remains deferred (named in the framing).
Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; 52
pmacs-gpu tests pass (incl. the new shutdown test + both headless renders
on the local adapter).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
GPU attach-path robustness batch from the repo audit. All three live in
pmacs-gpu; no protocol or daemon change.
F-003 — a daemon built without `--features crdt` advertises
`crdt_replica`/`semantic_render` as false in its `Hello`; negotiation then
"succeeds" but no BufferSnapshot ever arrives and the window hangs on
`(connecting...)`. The daemon already tells us its capabilities in Hello,
so check them client-side right after the handshake and fail with an
actionable in-window line ("daemon lacks CRDT support — restart it built
with --features crdt") instead of hanging. New CapabilityMismatch error +
missing_capabilities() + window_status(). No AttachResponse/daemon change.
F-008 — the outbound FrontendEvent queue was an unbounded mpsc, so a
stalled daemon grew memory without bound and replayed stale
viewport/pointer traffic on recovery. Replace it with a bounded,
coalescing Outbox (Mutex + Condvar): a Viewport or Pointer{Drag} whose
kind matches the queue tail replaces it (collapsing scroll/drag floods to
O(1) without reordering across a click or key), everything else is
appended lossless, and a lossless append past OUTBOX_MAX fails fast
(closes the outbox → clean disconnect/resync) rather than silently drop a
CrdtOp and desync the optimistic replica.
F-007 — the completion dropdown grew upward by n*row_height with no clamp,
so a short window rendered rows above y=0 with the selection off-screen.
Add mb_dropdown_window(n, selected, band_top) → (first, count): clamp the
count to rows that fit (hide when not even one fits, so top_y is never
negative) and scroll to keep the selection visible. glyphon's existing
TextBounds clip the scrolled-out rows; the buffer is still shaped once, so
no per-resize re-shape. The whole-fits path is (0, n) — byte-identical to
before.
Framing/as-built: docs/gpu-attach-robustness-framing.md.
Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; 51
pmacs-gpu unit tests pass (9 new across the three findings), incl. the two
headless render tests on the local Vulkan adapter. Still needs a human
eyeball (non-CRDT banner; tiny-window dropdown; normal attach renders).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Two follow-ups on this session's audit work, both on freshly-merged code.
F-004 hardening (regression fix). The AltGr text-input strip
(`is_layout_text`) gated on *any* command modifier, so it fired on
Alt-alone. On macOS the Option key is reported as Alt and emits printable
text for most letters (Option+x → "≈"), so every GUI Meta binding (M-x,
M-f, M-b, …) was stripped to a self-insert. Tighten the gate to the true
AltGr signature — both Ctrl and Alt (the LCtrl+RAlt the OS synthesizes on
Windows) — so Alt-alone forwards as a Meta chord again while Windows AltGr
still inserts. Strict narrowing of when we strip: no-op on Linux/Windows,
unblocks macOS Option-as-Meta. Test flips the Alt-alone € assertion and
adds the macOS Option+x case.
CI coverage (F-001 residue). `workspace_default_members` is only the root
`pmacs` package, so `cargo test` skipped pmacs-protocol — the shared wire
format the daemon, TUI, and GPU all depend on. Add
`cargo test -p pmacs-protocol --all-targets` to the test job (its ~12
encode/decode + transport-framing tests). All three first-party crates now
run in CI (root pmacs, pmacs-gpu via the render job, now pmacs-protocol).
Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; pmacs-gpu
tests 42 pass (render tests on the local adapter, PMACS_REQUIRE_GPU=1);
pmacs-protocol 12 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Records the gap (no test exercised the wgpu path), the enabling refactor
(window/surface Optional + render_to_view split + offscreen readback),
the narrow smoke scope, and the lavapipe CI decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Acts on the 2026-07-03 repository audit (tracked as
docs/repository-audit-2026-07-03.md).
F-001 — the pmacs-gpu clippy CI blind spot, closed:
- Fix the 3 workspace-clippy lints in pmacs-gpu/src/main.rs (two
cast_possible_wrap in minimap_jump_to via i64::try_from; a
format_push_string in compose_status_spans via write!).
- README documents `cargo clippy --workspace --all-targets` and
`cargo test --workspace`.
- CI's Lint job now also runs `cargo clippy -p pmacs-gpu --all-targets
-- -D warnings` — the root-package clippy never lints the flavor-
independent pmacs-gpu crate, which is how these (and recent) warnings
slipped through.
F-013 — refresh stale metadata: pmacs-gpu/Cargo.toml description and the
docs/pmacs-gpu-design.md status header, from "session 2: hello-world /
pre-implementation" to the real feature set + the per-feature docs.
F-015 — .gitignore editor backups (escaped `#*#`, `.local-bak`) and the
root `/pmacs` dev symlink, so they stop showing as untracked. Untracked
docs are left for triage per the audit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Records the rule (forward Char/Enter/Tab + Ctrl/Alt), what stays local
(Ctrl-V paste, Escape, Meta/Super → OS), what didn't change
(should_forward_key, motion, the optimistic path), and the bets that held.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Framing + as-built record: the render-only architecture, the v12 wire, the
band prompt line + caret, the candidate dropdown, opening-chord forwarding,
the delivered phasing, and an "As-built divergences" section (monospace
caret, a third TextRenderer, best-at-top dropdown ordering) plus the
categorical bets that held.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Consolidates the framing into an as-built record: the architecture,
context model, clipboard, input/routing, wire (v11), the delivered
phasing, and an "As-built divergences" section noting where the build
departed from the framing (no `SetClipboard` message, resolve runs in
Lua, `MenuPrompt` carries rows not "MenuItemWire", Emacs clipboard keys,
etc.) plus the categorical bets that held.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Folds incremental-search-framing.md (#70) and regex-search-framing.md
into a single in-buffer-search-framing.md that records the design as
shipped, and removes the two superseded docs. The incremental doc in
particular described the C-f + minibuffer-hosted plan that never
shipped (C-s/C-r + a dedicated core SearchSession did), which was
actively misleading.
The consolidated doc adds an "As-built divergences" section capturing
the four places implementation departed from the framing passes:
C-f → C-s/C-r (veto resolved), minibuffer-hosted → frontend-agnostic
core mode (the GUI has no minibuffer), regex deferred → shipped, and
single-line substring → multi-line regex wash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the merged isearch (#70) with regex matching: both dedicated
entry keys (C-M-s / C-M-r) and a mid-search toggle (M-r), with
multi-line matches. Q#RX1–RX6 stances + bets.
Key survey finding recorded: the GPU's push_glyph_extent_rects already
fans a byte range across visual lines (multi-line selections use it),
so multi-line needs no GPU rendering change — only the TUI SearchView
(which assumed single-line) mirrors paint_local_selection's per-row
wash. SearchPrompt gains regex/invalid → protocol v10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing over a selection composed two edits in Lua —
delete_region() + insert_char() — so it recorded two undo steps:
one undo left the half-replaced text, two restored the original.
Undo granularity is per apply_edit in both modes (v0.1 pushes one
UndoEntry per edit; CRDT commits per edit via export and groups by
commit, with record_checkpoint unused), so the fix is to make
type-over one edit.
New core EditorCore::insert_char_over_region emits a single
EditOp::Replace when a region is active (cursor past the inserted
bytes, selection cleared) and delegates to insert_char otherwise.
The three type-over commands (buffer.newline / tab / self-insert)
call it via a new Lua binding instead of the delete+insert pair.
delete_region and insert_char are unchanged for their other
callers; region-aware backspace/delete already emit one op.
Verified one undo unit in BOTH modes (dual_mode
replace_is_a_single_undo_step covers v01 + crdt — a CRDT Replace is
delete-then-insert internally but one commit) plus an end-to-end
acceptance test through the key-dispatch path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the straight 2px diagnostic underline bar (PR #65) with an
actual sine squiggle. A dedicated SquiggleRenderer pipeline whose
fragment shader computes A·sin(x·2π/λ) and alphas pixels by distance
to the curve (fwidth/smoothstep — core WGSL, no MSAA or feature
flag). Geometry reuses push_glyph_extent_rects unchanged; only the
vertex format differs, adding a uv: absolute screen-x (so phase is
continuous across separately emitted glyph-run rects) and signed px
from the band centerline.
The diagnostic underline is split out of the solid wash batch into
its own buffer + draw, slotted under the glyphs where the bar was;
washes (selection / current-line) stay solid quads. The severity
palette (decoration_kind_to_underline_color) and minimap marks are
untouched. DIAG_UNDERLINE_PX (2px) → DIAG_SQUIGGLE_PX (6px) so the
wave fits inside the band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Q#S1 splits facts by authority: L:C and scroll derive locally (the
optimistic caret must not lag a round trip); name, modified, and
exact diag counts arrive via a new additive StatusFacts variant
(protocol v8). The reserved ModeLine(Vec<Cell>) stays grid-shaped
and unused. Q#S2 quad + second TextArea; Q#S3 one text_area_bottom
helper for the five bottom-assuming geometry sites. Three bets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q#D1 quad-pipeline 2px bars (glyphon draws no underlines), Q#D2
retire the diagnostic fg recolor, Q#D3 minimap marks via v6
underline_color in FileStyleSummary, Q#D4 defer counts (no GPU
status band). Three categorical bets recorded, incl. the summary
recompute-gate (generation-only today; diagnostic publishes must
refresh marks without naive traffic growth).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/pmacs-gpu-perline-reshape-framing.md. Every keystroke ran a
full visible-slice reshape: rebuild all rich chunks, set_rich_text
(resets every BufferLine's shape cache), shape_until_scroll re-shapes
every visible line with Shaping::Advanced. Now a single-line edit —
the keystroke case — rebuilds exactly ONE BufferLine; the other
lines' shape caches survive and shape_until_scroll touches only the
fresh line.
- Q#R1: clipped_chunks_for_range is the single chunk source both the
full reshape and the surgery derive from (full = slice range,
surgery = the line's content range), so the two paths cannot
disagree about a line's content. Parity with cosmic-text's own
line splitting verified against the vendored 0.18.2 source:
BidiParagraphs strips the separator per line in both its ASCII and
BidiInfo paths, creates no trailing empty line, and set_rich_text
assigns LineEnding::Lf uniformly + adds attr spans only when they
differ from the defaults — the surgery mirrors all three.
- Fallbacks to full reshape: slice origin moved, line count changed
(Enter / multi-line deletes), '\n' in the inserted text, edited
line outside the shaped slice (an edit entirely PAST the slice updates
view_range + redraws without any shaping), exotic paragraph
separators, multi-edit batches.
- Q#R2: the pointer hit map goes lazy — surgery marks it dirty and
hit_test_source_byte rebuilds on demand from the same chunk fn
(clicks are rare next to keystrokes; the rebuild is an O(slice)
byte walk, no shaping).
- Pure parity test pins full-walk == concatenated per-line walks
(text + colors), including the newline-anchored inlay-hint
boundary case (predicted finding #1's most likely site).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>