Skip to content
v0.6 - Knowledge Base & Wiki

v0.6 - Knowledge Base & Wiki

The knowledge base: the wiki pane with a vault model, a Markdown editor and preview, wikilinks and backlinks, the link graph canvas, declared link weights and agent access to a vault. Everything in this chain shipped; the follow-ups that remain are in v0.11 and Future.

v0.6.0 - Knowledge Base & Wiki

Done Area Item
✅ WIKI Wiki pane type - wiki in PaneTypes.ALL; opens a vault at an absolute directory path validated by sanitize_tile (same trust rule as code_viewer/file_tree). Closed: wiki is registered on both sides of the vocabulary seam - PaneType (variant, as_str, FromStr, ALL: [PaneType; 7]) and PaneTypes.ALL (appended last in both, the order the key-for-key seam test enforces), plus the _PaneScripts body map - so every derived surface carries it with no second list: the sidebar button, the palette’s new wiki, gpty new-pane -t wiki, the swap menu, W<n> labels, and the plugin-manifest tile validator. The new WikiPane body opens a vault at an absolute directory path validated at use (is_absolute_path + DirAccess.dir_exists_absolute, file_tree’s rule; sanitize_tile is unchanged - the item’s ‘validated by sanitize_tile’ is the same tile gate every type passes, and a file-supplied path is never trusted), lists the vault’s .md notes recursively (hidden entries skipped, bounded at 2000 entries / depth 16), and renders a note read-only through the sanitized MarkdownView; the read guard is shared with code_viewer as TextRead.read_prefix (regular-file gate, 1 MiB cap, torn-UTF8-safe decode) rather than copied. Verified: test_wiki_pane.gd 9/9 (recursive scan, hidden skip, depth cap, entry cap + truncated, invalid-vault refusal, note render + back, layout-state round trip), the file-sink suite passing through the extracted guard, both Rust seam tests, and the full ci-check (49 GUT scripts / 513 tests; the smoke’s new wiki step shows new-pane -t wiki reporting type wiki live).
✅ WIKI Vault model - local-first directory of plain Markdown files (potentially Obsidian-compatible: .md on disk, no proprietary format), indexed with full-text search in SQLite FTS5 (reuses the v0.5.1 history engine). Closed: a new gpty_core::vault store (SQLite + FTS5 at state_dir()/vaults.db - never inside the vault, which stays plain .md files the app only reads) is the one definition of what a note is: the walk (scan_vault: hidden entries skipped at every level, .md only, MAX_NOTES 2000 / MAX_DEPTH 16, sorted) and the 1 MiB body cut with the torn UTF-8 tail dropped, the same cut TextRead makes on the display path. The wiki pane’s list and its debounced search box both come from it, through the static GptyTerminal.vault_index/vault_search (JSON envelopes; error reserved for “the query could not run”, so “no match” stays distinguishable from “not indexed”); activating a hit opens the note through the same TextRead + sanitized MarkdownView path. The store reuses the v0.5.1 engine’s skeleton deliberately - WAL + busy_timeout, a user_version gate, external-content FTS5 with the three triggers, restrict_to_owner on the db and its -wal/-shm siblings, the process-wide write_guard held innermost across one transaction - with a (vault_id, rel_path) key and porter unicode61, because notes are prose. A pass re-reads only notes whose mtime (nanoseconds) or size changed, drops rows for vanished notes, prunes rows whose vault root is gone, and never prunes from a truncated walk (the notes it did not reach would be deleted). Verified: 14 vault unit tests (schema idempotence, owner-only mode, vault-scoped and porter-stemmed search, the punctuation-only query, the hidden/depth/entry walk rules, added/updated/removed staleness, the truncated-walk no-prune rule, the torn-tail read, title extraction), test_wiki_pane.gd 9/9, test_gdext_ffi.gd 6/6 (the JSON contract, including the error envelope), test_layout_restore.gd 13/13, and ci-check green.
✅ WIKI Markdown editor & preview - edit notes with live preview through the existing sanitized Markdown→BBCode pipeline (v0.4.0); source/rendered toggle like code_viewer. Closed: the note view is an editor - a CodeEdit beside the existing MarkdownView in a split container, with three modes (edit / split / preview, split by default, persisted as the tile’s view_mode and exposed as a “Note view” pane-settings row) - and the preview is fed from the buffer on every edit (coalesced by MarkdownView’s own 75 ms timer), so Preview shows unsaved work, not the file. Saving is explicit: a Save button and Ctrl+S, a dirty marker, Save disabled while clean, a first-ask before leaving a dirty note (Back) or changing the vault, and Back re-walks the vault so the list reflects the edit. The write path is one new pair of static FFI functions over gpty_core::vault, the module that already owns the note vocabulary: vault_note_read (the index’s 1 MiB cut, the walk’s path rules, a regular-file gate - a FIFO in a vault must not reach File::open from the GUI thread - plus the file’s CRLF flag and a (mtime, size) stamp in microseconds, because nanoseconds lose their low bits in JSON’s double) and vault_note_write (the same rules; the canonicalized path must stay under the vault root, so a symlink out of the vault is refused and one to a sibling note is written through; content ≤ MAX_NOTE_BYTES; a stamp that differs from the loaded one answers code: "changed_on_disk", which the pane turns into an explicit overwrite confirmation; the replacement is a hidden random-named sibling temp renamed over the target, keeping the note’s own mode; a successful write re-indexes the vault before answering, so the edit is searchable at once - indexed: false when the save landed but the index could not). A note read truncated stays read-only (“Read-only - larger than 1 MiB”): saving a prefix would drop the rest. CRLF endings survive an edit. Refusals the caller asked about are not logged; only Outside/Io are diagnostics. Verified by 9 Rust tests (path vocabulary, stamp conflict, both symlink directions, mode preservation, cap, CRLF/truncation flags), 11 new GUT cases in test_wiki_pane.gd (open/save, live preview, modes, both guards, re-index-then-search, read-only past the cap, CRLF round trip, vault change, layout state) and the wire shapes in test_gdext_ffi.gd. One seam lesson, now in AGENTS.md: a programmatic CodeEdit.text assignment emits text_set only - text_changed is for edits and arrives deferred - so the pane recomputes dirty state from the buffer and tests type through insert_text_at_caret.
✅ WIKI Closing a pane or quitting the app loses unsaved wiki edits - the editor’s guards cover in-pane navigation (Back, a vault change), but the kill paths do not: Ctrl+Shift+W / the sidebar kill / IPC killPane and quitting the app destroy a dirty buffer with no prompt, and the pane writes nothing on _exit_tree (deliberately - nothing writes without a user action). Found while building the editor (2026-10). Closed: the close is a pane contract now - PaneBody.request_close(answer) (the answer is async and boolean: true proceeds, false cancels) with PaneBody.has_unsaved_state() as its state half - and every destruction path consults it: the single-pane ones through Workspace._kill (the titlebar’s X, the sidebar’s close button, Ctrl+Shift+W/the palette via _kill_last, IPC killPane), the type swap (_swap_pane, whose old body dies), the mass ones through one walk (_ask_close_then: _reset/_do_reset, profile activation _do_activate, _close_workspace, the palette’s _restore), and quitting - request_quit() is the app’s only quit path now, reached from the window-manager close request (auto_accept_quit is off), the titlebar’s close button and the Rust shutdown flag (daemon stop, a refused socket bind), walking every pane of every workspace. The ask is the editor convention - Save (write, then continue), Discard, Cancel - and one answer drives the whole action: Cancel cancels the reset/swap/close/quit, a failed save or a declined overwrite cancels the close instead of proceeding over it, a pane already asking declines a second ask (dialogs cannot stack), and an exclusive dialog is freed a frame before the next one is raised (Godot refuses two at once, which would have stranded the answer). The Back button gained the same Save answer, and _save(force, answer) now reports its outcome to its caller - without that, a failed save would have closed the pane anyway. An IPC caller, which cannot answer a dialog, is refused instead: killPane and layoutLoad answer an error naming the pane, the rule layoutLoad already applied to untrusted profiles. Verified: 6 new wiki-pane cases (Save-then-continue, Discard-without-writing, Cancel reported as false, conflict-again mid-close, has_unsaved_state, a clean pane answering at once) plus the reworked Back guard, 11 cases in the new test_pane_close_guard.gd (the walk’s continue/refuse/skip-freed semantics, the quit seam - including a cancelled quit being askable again - and the kill/kill-last/reset/swap/workspace-close funnels against a real workspace), 2 IPC refusals in test_ipc_dispatch_contract.gd, and the full ci-check - gut-check: OK - 50 scripts, 545 tests (27 unit + 23 integration scripts, the new test_pane_close_guard.gd among them) and the live pane-API smoke, which drives kill-pane, layout load and daemon stop through the guard. The scratch-buffer fallback for SIGKILL-class exits is not part of this item and is queued below.
✅ WIKI A crash no longer takes the wiki buffer - the close guards cover every deliberate path, but SIGKILL/crash (or an outside kill) loses unsaved edits: nothing is written until the user saves, by design, and the pane writes nothing on teardown. Found while closing the item above, which named a scratch buffer as the fallback (2026-10). a recovery copy of the dirty buffer, owned by gpty_core::vault (write_scratch/read_scratch/clear_scratch, behind the static vault_scratch_read/vault_scratch_write/vault_scratch_clear) and living outside the vault at state_dir()/scratch/<fnv1a64(canonical root, NUL, rel path)>.json - FNV-1a spelled out locally because DefaultHasher’s algorithm is not promised across Rust releases and a toolchain bump would orphan every copy - written atomically (hidden random temp, renamed over the previous copy), dir 0700 / file 0600, and carrying the identity it was written for (vault, path, mtime, size, text): a record that names another vault or note, or cannot be parsed, answers absent, so a stale file or a hash collision can never restore the wrong text. The pane snapshots from a throttle armed by the first dirty edit (SCRATCH_DEBOUNCE, deliberately not restarted per keystroke - a restarted debounce never fires during a long typing run, which is exactly when the most work is at risk) and drops the copy on every deliberate end: a save, the Discard answer of the close ask, and the settings panel’s vault change (keyed to the vault the buffer belonged to). Reopening the note asks - Restore / Discard / Escape = not now - and never applies anything on its own; the restored buffer carries the stamp it was typed against, so a file that moved on while gpty was away still gets the existing changed_on_disk overwrite question instead of a silent overwrite. A note read-only for being past the 1 MiB cap is never offered a restore (a restored buffer could not be saved and the close guard would not see it as unsaved work - the copy is kept instead), and _save remains the only write that reaches a vault. A buffer past MAX_NOTE_BYTES gets no copy on purpose: the same module refuses its save by that limit, so the two say one thing instead of promising a recovery nothing could write back. Verified: 5 Rust unit tests (round trip + idempotent clear, key isolation across vaults and notes, identity/stamp/malformed-record guards, the walk’s path vocabulary + size cap, dir 0700 / file 0600) and 10 GUT tests in test_wiki_pane.gd (a clean note writes nothing, a dirty one snapshots the exact buffer with its stamp, the throttle is not restarted by a later keystroke, save / discard / vault change drop it, the crash → reopen → Restore → save chain, a decided copy never returns, a moved-on file still asks, a read-only note is not offered and keeps its copy, CRLF) plus the scratch wire shapes (absent vs found vs error envelopes, the caller’s stamp coming back, idempotent clear) in test_gdext_ffi.gd. One product defect fell out of the blast radius and is fixed with it: a CRLF note opened already dirty (_saved_text held the file’s CRLF text while the editor works in LF, so Save was lit for an edit nobody made and - with the copy in place - a snapshot of the file itself would have been offered back as a recovered edit); test_a_crlf_note_opens_clean pins the fix. The tests hit the known exclusive-child rule the hard way (a queue_freed dialog is still registered for the rest of the frame, so the next exclusive popup is refused): they await a frame before counting dialogs, the same lesson _save_after_dialog already documents.
✅ WIKI Recovery copies are never pruned - a copy is only ever read, offered or cleared through the key of the note being opened, so one whose vault directory (or note) went away is unreachable forever: state_dir()/scratch keeps every orphan a crash produced. Cheap and content-free today (a copy exists only while a buffer is dirty, and it is a few hundred bytes), but nothing reclaims the files, and a vault that is moved or renamed orphans one per unsaved note. Found 2026-10 while closing the item above. Fix direction: a prune alongside the write - the record already names its vault root and note, so a copy whose vault is not a directory can be dropped - held to the index’s discipline: prune only from a complete walk of the small scratch directory, never delete a copy whose vault still exists (it may be temporarily unreadable), and never touch a copy whose note merely was not listed, because a truncated walk would delete exactly the work this feature exists to keep. prune_scratch is the first step of write_scratch, so the prune rides the pane’s 1.5 s throttle - one short directory listing, each record read to be judged, a few times a minute at most over a directory that holds one file per dirty note, and it runs before the copy is located so even a write refused for a vault that is no longer a directory tidies the directory. The deletion rule is the item’s discipline made concrete: a copy goes only when its own record names a vault the filesystem answers definitively gone for - metadata says NotFound, or says the path is not a directory - while a permission error, an unmounted network path, or any other I/O error answers “cannot judge” and the copy is kept, and a copy whose note is missing is kept too, because nothing on this path walks a vault at all (a truncated walk cannot reach the prune’s verdict, which is stronger than checking the note’s absence would have been). A record that cannot be read or parsed, one that never names a vault, one whose vault is not absolute (ours always is - scratch_location canonicalizes), and any name the module did not build (<16 hex>.json / .gpty-scratch-<16 hex>.tmp are the only shapes it claims) are all left where they are: a deletion needs a record that says “unreachable”, never one the code could not interpret. The same pass reclaims the .gpty-scratch-*.tmp remains of a write that died between its temp write and its rename, once older than an hour - a live temp file exists for microseconds, so no writer can lose one to the age check - because those are the same leak from the same directory. Every prune failure is a logged diagnostic; housekeeping never fails the write it rides on. Verified: 5 new gpty-core::vault tests - the two orphan shapes reclaimed (vault directory removed; vault path replaced by a file) while a live vault’s copy and its text survive, a note deleted out of a live vault keeping its copy, foreign / unparseable / vault-less / relative-vault records kept, a stale temp reclaimed with a fresh one kept, and a #[cfg(unix)] case where the vault’s parent is unreadable (kept on root and non-root alike - the metadata call either fails with EACCES or succeeds and sees a directory) - plus cargo test --workspace (445 tests, 0 failed) and the full ci-check: gut-check: OK - 50 scripts, 558 tests, all passed, the live pane-API smoke, and cargo audit. The GUT and smoke steps ran with a sandboxed XDG_RUNTIME_DIR because a GUI was live - the collision is the queued item below, and this run is one more data point for it.
✅ WIKI The Markdown preview no longer stops at a cap of its own - crates/gpty-gdext/src/markdown.rs cut its input at MAX_MARKDOWN_BYTES (512 KiB) and rendered the rest not at all, with nothing said: a 900 KiB note rendered half of itself with no notice, and the wiki pane’s own truncation notice (appended after the body) could never be seen for a note past the cap it announces - which is exactly the case it exists for. code_viewer showed the same thing for a large .md. Found while testing the editor (2026-10). the cap is the read cap - MAX_MARKDOWN_BYTES = TextRead.DEFAULT_MAX_BYTES = gpty_core::vault::MAX_NOTE_BYTES (1 MiB), one number, so a document at a pane’s read cap renders whole - and it cannot drift again: the_input_cap_is_the_read_cap_the_panes_apply (a Rust test in markdown.rs) include_str!s godot/scenes/panes/text_read.gd, reads the DEFAULT_MAX_BYTES declaration and fails if it stops agreeing with either the render cap or the note cap, the same cross-language seam check pane_types_match_the_gui_registry_key_for_key uses. The cut now travels with the text: the render answer is JSON, {text, truncated, shown, size} - the shape a read answers (TextRead.read_prefix), so a caller gets the numbers rather than having to compute UTF-8 byte lengths in GDScript - and MarkdownView is the single place a reader is told, as one banner above the body: the renderer’s own numbers when its cap bit (a pasted buffer, a routed capture, a long model turn - sources no read capped), and set_source_notice for a cut the caller made. The panes stopped appending a notice to the source: a note at the read cap is at the render cap too, so the appended notice was the first thing the cut dropped, and the wiki pane’s preview now hands the notice to the view while the text views keep their own at the end of the text they hold (TextRead.truncation_notice is a bare sentence the caller places). Costs measured before raising the cap (a probe_render_cost Rust test stayed behind, and the GUI-thread numbers are in the cap’s doc comment): the Rust render is ~30 / 55 / 107 ms debug (~5 / 9 / 14 ms release) for 256 KiB / 512 KiB / 1 MiB, linear, and the render is not the whole cost of showing a document - the label’s own BBCode parse and shape is, which is why the follow-up below exists rather than a smaller cap. Verified: 3 new Rust tests (at-cap whole / past-cap reported / UTF-8-safe cut) + the seam test + the probe, test_markdown_renderer_formats_and_sanitizes rewritten for the envelope and the cap, 2 new GUT cases (test_a_capture_over_the_render_cap_says_what_it_is_not_showing for the renderer’s cut on a routed capture, test_a_markdown_file_past_the_cap_announces_the_cut_in_the_rendered_view for the read cut in a rendered .md), the wiki read-only case extended to assert the banner sits before the body, and the full ci-check - gut-check: OK - 50 scripts, 558 tests.
✅ WIKI A live preview of a large note pays its whole render per keystroke burst - measured 2026-10 while aligning the render cap: MarkdownView.render_now plus the label’s own BBCode parse is ~74 / 157 / 336 ms for 256 KiB / 512 KiB / 1 MiB of Markdown in the debug build the harness runs (the Rust render is ~30 / 55 / 107 ms of that, the rest is RichTextLabel), and a pane in split/preview mode re-renders through the 75 ms coalescing timer after every edit burst - so a note near the cap stutters while it is being typed. Nothing is hidden any more (that was the item above) and edit mode renders nothing, so this is smoothness, not correctness. Fix direction: measure a release build first - the debug numbers above are a ceiling, and the shipped artifact is what a user types into - and if it still hurts, render on settle/idle rather than on the coalescing timer, or let a document past a threshold declare its preview stale until asked (a “preview paused - render” state). Not by lowering the render cap again: that is the silent-prefix bug this release just closed. measuring the release build with the new godot/tests/bench/markdown_bench.gd harness found the item’s cost model was missing its dominant term - the shape pass, the frame after the text lands. It was also superlinear, and the cause was this renderer’s own output: Godot lays a [ul]/[ol] list out superlinearly in the item count, and the unit the item was measured with is list-dense. Measured on the Godot 4.7.2 binary (headless, ~1.7 MiB of rendered BBCode): two-item lists 38915 ms, a real 1 MiB note 146705 ms, against 819 ms for that same rendered text with the list tags stripped and 0.5–1.3 s for every other construct (headings, quotes, inline code, links, tables) - so a 256 KiB note in split mode froze for 2.7 s per update and a 1 MiB one for 2.4 minutes, which is not a stutter but a hang. Two changes, both measured: (1) the renderer emits no list tags - each item is a • /N. line inside one [indent] per list (a nested list opens its own), which renders identically (screenshot-verified against the tag form at the same width, fonts and wrapping) and makes the shape linear: 250 / 473 / 948 ms (debug) and 261 / 486 / 973 ms (release) for 256 KiB / 512 KiB / 1 MiB, with the numbering now the renderer’s own - which is also how a list starting at 3 renders 3. instead of 1., since [ol] cannot carry a start; (2) the wiki pane’s preview renders on settle - MarkdownView.set_markdown_settled restarts its timer per edit (settle_delay 0.4 s) so a typing run queues no render at all and one lands when the user stops, while set_markdown’s non-restarting throttle stays for streams (Reasoning, Inspector) and render_now for explicit acts (open, mode switch, save). The item’s second option (a stale-until-asked preview) was not needed once the superlinear term was gone, and the closure’s numbers are the evidence a later decision would use. Verified: 2 rewritten/added markdown.rs tests (bullets and our own numbers, nested indent; the [ol]-pin test replaced), 1 new and 1 adapted GUT case (test_a_settled_preview_waits_for_the_typing_to_stop pins the restart semantics: a render inside the window is exactly what a throttle did), gut-check: OK - 50 scripts, 559 tests, all passed, and the full ci-check. Total per preview update in the shipped release build: 0.32 / 0.55 / 1.10 s for 256 KiB / 512 KiB / 1 MiB, linear.
✅ WIKI A note with frontmatter loses its title in search results - vault::note_title takes the first non-empty line: a heading there becomes the note’s title, anything else falls back to the filename stem, and a YAML block’s opening --- is “anything else” - so an Obsidian-shaped note (--- / title: … / --- / # Real heading) is labelled and ranked by its stem in every vault_search hit, even though the body (frontmatter included) is indexed and searchable and the note itself renders. The block also renders as ordinary Markdown - there is no properties UI. Found 2026-10 while comparing the wiki to Obsidian’s vault format. Fix direction: skip a leading ---…---/... block before scanning for the heading (a small change in note_title with tests), and - if Properties are ever wanted - honour a title: key from that block, which is a line lookup rather than a YAML dependency; whether the block is hidden, dimmed, or shown as properties is a renderer decision that belongs with it. note_title now scans past a leading, closed YAML frontmatter block. The helper (without_frontmatter(body) -> &str) requires the file’s first line to be exactly --- (trailing whitespace and a CRLF \r allowed) and the block to end at the next --- or ... line; every other shape - an unterminated ---, a leading blank line, a first line that is not the delimiter - returns the body whole, so the pre-existing scan runs unchanged and a note that has no frontmatter cannot read differently than before. The scan’s own rule is untouched (first # heading, else the stem), which is what puts the observed Obsidian shape (--- / title: … / --- / # Real heading) back on its heading for both halves of the hit: the label the pane renders (title - snippet) and the notes_fts title column bm25 weights. No title: key is read - the item conditioned that on a Properties feature, none exists, and a label rule should not grow a frontmatter parser - and the block stays ordinary visible Markdown inside the indexed body, so a property’s value is still searchable (pinned). One consequence recorded rather than hidden: a heading inside the skipped block is the block’s, not the note’s, which is the reading that makes the convention work at all. Verified: a_frontmatter_block_is_not_the_title (7 cases - the observed shape; a ... close with CRLF delimiters; a heading inside the block; no heading after a closed block → stem; unterminated → the old behaviour; a non-delimiter first line; a leading blank line) and a_frontmatter_note_is_found_and_labelled_by_its_heading (a real index_vault pass, then search_user_text asserting the hit’s title and that the properties text is still indexed) - gpty-core’s 34 vault tests (2 new), the full ci-check green, gut-check: OK - 50 scripts, 559 tests (no GDScript changed), and the live pane-API smoke.
✅ WIKI Wikilinks & backlinks - the pane half - the data half landed 2026-10 (the record is at the end of this item), so what remains is the wiki pane’s view of it: a backlink panel that lists each mention with the line it was written on (vault_links already answers src/line), the unresolved-target display (each entry carries the candidates an ambiguous stem could not choose between), and following a link in place behind the existing Save/Discard/Cancel guard - no history stack in v1 (the notes list is the escape). Rendering a mention’s alias, or following a fragment, stays a presentation decision for that half; the store keeps the edge only. [[note-name]] linking, per-note backlink panel, unresolved-link detection. Data half (settled 2026-10): links are parsed in the index pass and stored in vaults.db, never derived on the fly from the vault - the pass already reads every changed note’s body to fill body/title, so a link parse there is ~free, sits in the same transaction as the note row, and cannot drift from the text the FTS index holds; “a link changed” is exactly the note’s existing (mtime, size) staleness trigger, so there is no separate link-writer and no per-link CRUD path. Schema: a links(vault_id, src, target, resolved NULL, seq) table beside notes, indexed on (vault_id, resolved) for the backlink query, with the bump handled by rebuilding the derived tables (user_version 1→2) rather than migrating - the vault is the source of truth, the store is a cache. Unresolved-link detection is then resolved IS NULL, and the graph canvas above queries the same table, so the two features share one definition of what a link is. Settled 2026-10 (owner): a bare [[name]] resolves by stem first and by relative path when the target contains /; a stem shared by two notes is never silently picked - the link stays unresolved with a hint naming both candidates (the autocomplete, when it exists, always writes a path for an ambiguous stem); matching is exact-case first, then a unique case-insensitive match, else ambiguous (correct on a case-sensitive filesystem without breaking [[Note]] on Windows/macOS); [[target|alias]] ships in v1 with the alias as the display label; [[target#heading]] parses in v1 with the fragment dropped - the edge is to the note, and following a heading is a later half; caps are 512 occurrences per note and 1024 bytes per target under the standard regex crate (worst case ≈1M rows at the 2000-note cap); following a link navigates in place behind the existing Save/Discard/Cancel guard, with no history stack in v1 (the notes list is the escape). Multi-edges and parse rules (settled 2026-10): a backlink is not syntax - it is the same edge read in reverse (WHERE resolved = ?), so nothing is authored twice and there is no second thing to parse. One row per occurrence, not per pair: links(vault_id, src, seq, line, target, resolved) with primary key (vault_id, src, seq), so a note mentioning [[B]] three times has three rows, the backlink panel lists each mention with its line, and the graph aggregates them into one edge carrying the count. Links are parsed per line, which gives the occurrence’s line while the body is already in memory. Many-to-many is then free (a note links many targets, a target is linked from many notes, self-links included). Not every [[…]] is an edge: a code span or fenced code block is literal text - a note documenting the syntax must not create edges - so the parser skips them, and an escaped \[\[…\]\] is literal too. An alias (|…) is display-only and a #fragment belongs to navigation; the edge is to the note. resolved is derived, so it must be maintained when the note set moves: recomputed for a changed note’s rows at parse time, and cleared in one UPDATE for rows whose target note disappeared (the same path a future rename needs) - an edge left pointing at a deleted note is the trap of a derived-edge store. Landed (data half, 2026-10): gpty_core::links holds the parser and the resolver as pure functions (a fenced block, an inline code span and a backslash-escaped opener are literal text; 512 occurrences per note, 1024 bytes per target; alias/fragment parsed but never stored), and VaultStore owns when they run: the links table ((vault_id, src, seq) primary key, indexed on (vault_id, resolved)) is written in the same transaction as the note row it came from, reresolve_links re-derives the vault’s edges when the note set actually moves (added/removed) - a stub that appears resolves its linkers, a vanished target loses its resolution, a stem that is now shared stops resolving - a removed note’s rows are deleted with it, clear_vault/prune_dead_vaults drop a vault’s links, and the user_version=2 bump rebuilds the derived tables instead of migrating them (a v1 store’s notes were never link-parsed). GptyTerminal.vault_links(vault, note) answers outgoing occurrences, backlinks (the same edges read in reverse) and unresolved targets with derived candidates - a read of the derived store, never a vault scan. Verified: 11 links unit tests (occurrence shapes with their lines, fences/spans/escapes, both caps, stem-vs-path resolution, the case rules, ambiguity) and 6 store tests (edges written with the row and rewritten on edit, ambiguous candidates, stub-appears/target-vanishes, a shared stem stopping a resolved edge, removal + clear_vault/prune_dead_vaults, the v1→v2 rebuild), the GUT wire test test_vault_links_answer_shaped_json, and the full ci-check. Pane half (closed 2026-10): mentions render from the same parser the index uses - links::Link gained the occurrence’s byte span, extract_mentions replaces each accepted occurrence with a sentinel before Markdown parsing (so surrounding emphasis/headings/tables are untouched and the label is never re-parsed), and GptyMarkdown.render(text, wikilinks) substitutes [url=gpty-note:<percent-encoded target>]label[/url]; the wikilinks flag is set only by the wiki pane (MarkdownView.wikilinks_enabled), fences/spans/escaped openers stay literal, and a document’s own Markdown link cannot name the scheme (safe_url unchanged). MarkdownView emits note_link_clicked(target) instead of opening anything; following asks GptyTerminal.vault_link_resolve → VaultStore::resolve_target, which runs the index’s own NoteIndex over the index’s note set (note_paths, shared with links_for/reresolve_links), so a shared stem answers its candidates and is never picked, and an unsaved mention resolves too. The pane opens the answer in place behind _request_leave (Save/Discard/Cancel; no history stack). The new link panel lists every outgoing occurrence (target · line N; unresolved rows name the target and its candidates and are not clickable) and every backlink (src · line N, clickable), capped at 50 rows per section with the remainder counted, rebuilt on open and after a save’s re-index. Verified: 4 renderer unit tests (on/off, code/escape, encoded targets, impersonation refused), the links byte-span test, the resolve_target store test, 10 new test_wiki_pane.gd cases (rows + lines, ambiguity candidates, follow by row, backlink and preview click, dirty-buffer ask with Discard and Save, missing-target handling, no panel when empty), the extended test_gdext_ffi.gd wire test for vault_link_resolve and the render flag, and ci-check. A missing row is the create-from-link action; an ambiguous one is information only and never guessed.
✅ WIKI Create, rename, and delete notes - the editor edits existing notes only: write_vault_note refuses a path with no file behind it (deliberately - editing must not silently create), so the pane cannot add a note, and a vault maintained elsewhere is the only way to get one. Found while building the editor (2026-10); pairs with wikilinks, whose unresolved-link detection wants a create-from-link action. Fix direction: extend the write helper with the missing file verbs under the same containment rules - a New-note action whose name follows the walk’s vocabulary (.md, no hidden components, collision handling instead of an overwrite) and a confirm-then-delete - each re-indexing the vault like a save does. gpty_core::vault gained create_vault_note, rename_vault_note and delete_vault_note, and the FFI exposes them as vault_note_create/vault_note_rename/vault_note_delete. The rules: the walk’s vocabulary for every path; a create/rename destination’s directory must already exist and canonicalize inside the vault (no directory creation, nothing through a directory symlink out); the destination is claimed with create_new (and rename replaces only that claim, removing it on failure) so an existing note, symlink or directory answers NoteError::Exists (code: exists) - no overwrite path, no check-then-write race; and create/rename/delete act on the vault entry, so a symlinked note is moved or unlinked as a link with its target untouched, while reads/writes keep following links with the Outside refusal. A rename preserves mtime/size (the open buffer’s stamp stays valid) and each verb re-indexes, so a create is searchable at once and a rename’s removed+added re-derive the link table. The pane: New note on the list (Untitled.md default, bare names gain .md), Rename/Delete on the open note (delete confirms and says unsaved work will be lost), create-from-link from a missing mention or panel row (ambiguous stems are never created), recovery copy re-keyed on rename and dropped on delete, and _has_open_ask now counts only visible dialogs because an answered one runs an action that may ask the leave question. Verified: 5 new gpty-core tests (create/refuse/collision/vocabulary, delete entry semantics, rename + existing destination, directory-symlink escape, symlinked entry moved/unlinked), the FFI wire test test_vault_note_file_verbs_answer_shaped_json, 9 new test_wiki_pane.gd cases (create, collision retry, rename, dirty rename copy move, delete + cancel, dirty warning, missing-mention create, ambiguous never created), and ci-check.
✅ WIKI Renaming a note leaves [[old-stem]] mentions unresolved - the rename verb re-indexes and lets the derived resolved column do its job, so a mention that named the note by stem (or by its old path) silently becomes unresolved: the referring notes are untouched, the link panel shows the target as missing, and the create-from-link action would now offer to recreate the old name - a plausible way to fork a note by accident. Found while building the verbs (2026-10). Fix direction: a rename-time pass over the vault’s notes that rewrites only the target spellings that resolved to this note (preserving aliases and fragments, never touching fenced code or code spans), offered as an explicit “Update references?” choice beside the rename rather than done silently - it writes to other notes, so it must be confirmed and report a count - with the store’s re-index as the confirmation. Until then a rename leaves the old mentions as the user’s to fix. the pass is gpty_core::vault’s preview_link_rewrite/rewrite_links_after_rename, behind the statics GptyTerminal.vault_link_rewrite_preview/vault_link_rewrite, and gpty_core::links::retarget answers the one spelling question (a path target keeps its .md style, a bare target becomes the new stem, and a [, ], |, # or control character in the new name makes it unspellable - the parser would re-read it as syntax). The walk runs after the rename and the old note set is that walk with the new path swapped back, so only occurrences that resolved to the old name before the move are candidates: a stem two notes shared was ambiguous and stays untouched, while [[OLD]] (a unique case-insensitive match) is respelled exactly. The editor’s own file is not a candidate - its buffer belongs to the pane - and CRLF, aliases, fragments and every other byte survive; a note over the read cap is skipped rather than written back as a prefix. The pane offers it after a successful rename (UpdateReferencesConfirm: “N mention(s) in M note(s) still name OLD”, Update / Not now), writes nothing before the answer, reports what landed (plus notes that could not be updated), and re-indexes so the new backlinks appear. Every write is write_vault_note’s and the apply re-plans in the same call, so an edit made while the question was open is read fresh; only a note that changes between that plan and its write answers changed_on_disk and counts as skipped. Tests: five core cases (five spelling styles with code literals; ambiguous stem plus self-exclusion; CRLF; unspellable name; read cap) and two GUT cases (offer/apply with alias and fragment preserved and code untouched; decline leaves the files alone), plus the existing rename test now asserts that a vault with no mentions is not asked.
✅ WIKI Link graph canvas - our version of a canvas, with no new file type - Obsidian’s .canvas is a second format living inside the vault; ours is the vault’s own link structure, rendered. Nodes are notes (the index’s list), edges are wikilinks (the links table the wikilinks item adds), queried from vaults.db when the pane opens and re-queried after every mutation - a save already re-indexes, and so will create/rename/delete - so there is nothing to keep in sync and nothing new written into the vault. v1: pan/zoom, a deterministic auto-layout, drag to rearrange (geometry in RAM for the session, since the view is generated on the fly), click a node to open the note, and the selected note’s in/out links highlighted; unresolved targets render as ghost nodes, which is the unresolved-link view the wikilinks item wants. Persistent arrangement, if wanted later, is gpty’s own store keyed (vault, rel_path, …) - never a file in the vault. Layout is UI/UX, so it belongs in a pure, unit-testable GDScript model beside concept_graph_model.gd (the v0.5.4 editor is the precedent for canvas UX and closed-schema parsing; the terminal grid is the precedent for batched drawing once node counts grow). Scale: the walk caps at 2000 notes, and that ceiling is where a GraphEdit-per-node canvas stops being the right renderer - pick the draw path with it in view. Multi-edges aggregate: one drawn edge per note pair, carrying the count (thickness or label) - N parallel curves would be noise, and the per-occurrence detail lives in the backlink panel; direction stays in the data (it is what makes the backlink query work) and is cosmetic in the layout. Weights and direction (settled 2026-10, owner): the measured weight is mentions - COUNT(*) per (src, resolved), derived on read with no column, since occurrences are already rows; COUNT(DISTINCT line) (contexts) stays a knob if a dense vault’s layout gets noisy. Drawing clamps thickness (1→3 px) and damps layout stiffness (log or clamp, never linear), so one chatty note cannot dominate the springs, and a filter that hides edges by weight is never the default - a view may dim, the data does not disappear. Direction is data-only: no arrows by default (an optional toggle), mutual pairs (both rows present) drawn double-headed, layout undirected. the data half is VaultStore::graph/graph_limited - one aggregated row per ordered (src, resolved) pair, ghosts for unresolved targets, deterministic order (count DESC, then names), MAX_GRAPH_EDGES (10k per class) and MAX_GRAPH_GHOSTS (2k) setting truncated - behind the static GptyTerminal.vault_graph. VaultGraphModel (pure, unit-tested) normalizes it into one undirected pair per note pair (ab/ba/mutual, self-references dropped), clamps stroke width and layout springs logarithmically (1→3 px, MAX_SPRING), and lays out deterministically: a sunflower seed with FNV-1a jitter (never hash()), then a size-bounded Fruchterman–Reingold relaxation with grid-approximated repulsion, because the 2000-note walk makes all-pairs repulsion 4M pairs per sweep. VaultGraphView is one custom-drawn Control (not a GraphEdit per node) with pan/zoom/drag, off-screen culling, hover/selected labels, optional arrows (mutual pairs double-headed), ghost nodes as outline circles, and session geometry a re-query preserves - unseen ids are placed near a neighbour and Relayout returns to the deterministic arrangement. The wiki pane’s Graph button queries the index on every press (every mutation already re-indexes), a node click opens the note behind the same leave guard as the list, and a ghost click runs the preview’s own _follow_target (create-from-link when missing, report when ambiguous). Tests: gpty-core graph tests (aggregation, ghosts, self-reference, caps, empty/cleared vault), test_vault_graph_model.gd, test_vault_graph_view.gd, and seven test_wiki_pane.gd cases (contents, click, dirty guard, re-query after save, Back, ghost create, invalid vault).
✅ IPC Agent access - vault search/read exposed via CLI and MCP so agents can query the knowledge base through the public pane-API primitives. the knowledge base is now a primitive pair over the control socket - vaultSearch / vaultRead, reached by gpty vault search <vault> <pattern> [--limit] and gpty vault read <vault> <path>, and advertised as the vault-search / vault-read MCP tools (21 tools total). Both are answered natively in Rust (crates/gpty-gdext/src/vault_ipc.rs, registered in ipc.rs beside version/shutdown) rather than through ipc_handlers.gd: a vault is a directory, not workspace state, so no handler has a workspace to consult, and the walk plus SQLite run on the blocking pool (spawn_blocking) so an agent’s query cannot stall the frame that polls IPC - the wiki pane’s own synchronous calls stay as they were, user-driven and bounded. Search re-indexes before it queries (index_vault; only notes whose mtime/size changed are re-read), so a note edited outside gPTY - Obsidian, an editor, another agent - is searchable without any pane reopening the vault; its answer is {results: [{path, title, snippet}], total, truncated}, and truncated reports the 2000-note walk cap so a partial index never looks complete. Read reuses read_vault_note unchanged: the vault-relative .md vocabulary, the regular-file gate and the 1 MiB cut with the torn UTF-8 tail dropped, and neither method writes to the vault (the only write is the derived index in gpty’s state directory). Refusals are JSON-RPC errors - -32602 for a malformed request (missing param, relative/non-directory vault), -32000 with NoteError::code() in data.code for a refusal - so not_found and bad_path are distinguishable without parsing prose. A vault command is a query, so may_autospawn returns false (the v0.5.6 rule). Tests: five Rust unit tests in vault_ipc.rs (index-on-search, externally edited note, title/snippet, string limit from MCP, read containment, malformed params - all on unique temp vaults, no shared state), a CLI roundtrip pinning both parameter spellings, schema assertions for the two tools, the existing seam contract tests (native methods parse as local registrations; the CLI literals cover them), and a live smoke step that seeds a temp vault, searches, edits a note outside gPTY and searches again, reads a note, and asserts the not_found code - run by ci-check/CI on Linux and windows-smoke on Windows. Docs updated: AGENTS.md (tool count/list + the native-API bullet), README, skills/gpty/SKILL.md, the CLI, IPC and gdext crate READMEs, the testing doc and manual checklist, and the CHANGELOG.
✅ IPC The 30 s connection timeout caps every deferred-answer method - IpcServer::serve wrapped the whole handle_connection future - read, dispatch, handler, response write - in CONNECTION_TIMEOUT (server.rs, 30 s), so a method whose answer is deliberately held could never outlive it: paneWait publishes a 60 s ceiling (PANE_WAIT_TIMEOUT is 70 s so the fallback covers it) and pluginInstall waits up to 300 s for a human reading the review dialog, yet the server dropped both connections at 30 s. The client then read an empty socket while the fallback still believed the request was alive. Found 2026-10 while giving vaultSearch a read budget (its re-index pass can outlast a default 5 s call). the deadline is split. IpcServer keeps a 30 s IO_TIMEOUT on the two phases a client controls - the request read and the response write, shared through one write_response helper so a stalled client still cannot hold one of the 16 slots - and each registration now carries its own dispatch budget (register_with_timeout, with register defaulting to the new DEFAULT_DISPATCH_TIMEOUT of 30 s). The GUI registers every GDScript-routed method at fallback_timeout(method) + DISPATCH_SLACK (dispatch_timeout in gdext/src/ipc.rs): 75 s for paneWait and 305 s for pluginInstall, so the handler’s own fallback answers a clean JSON-RPC error first and the server budget only frees a connection from a wedged handler. An overrun is now -32000 handler timed out after N ms rather than a dropped socket. Tests: a new gpty-ipc case runs a 2 s handler under a 50 ms budget and asserts the client receives the timeout error (and that a held handler inside its budget still succeeds), and deferred_methods_get_contract_covering_fallbacks asserts every routed method’s dispatch budget outlives its fallback, with paneWait’s past the published 60 s and pluginInstall’s past the 300 s review window. Verified live against a headless GUI: gpty pane-wait … --timeout-ms 33000 (never matching) answered {matched: false, timed_out: true} at 33.0 s; before the fix the server dropped the connection at 30 s. Docs updated: the gpty-ipc README connection-limits bullet, AGENTS.md’s IPC-hardening bullet, and the CHANGELOG.
✅ CLI --json reports a failed command as exit 0 - format_response printed the JSON-RPC envelope and returned Ok whenever --json was set, so gpty vault read <vault> missing.md --json exited 0 with {"error": {…}} on stdout while the same command without the flag exited 1. Every IPC-backed command inherited it, which defeats the machine-readable mode for a script that branches on the exit status, and gave the CLI two answers: v0.5.6’s daemon status --json decision was shaped JSON and exit 1. Measured 2026-10 while adding the vault commands - the smoke’s first assertion was a nonzero-exit check and failed. format_response prints the envelope first (stdout stays parseable) and then fails on resp.error in both modes, and the rule lives once in check_error, called by the three custom-output paths (list-panes, layout list, the all-panes pane-status) instead of each re-deciding it. The all-panes path also reports the RPC refusal itself rather than its “listPanes returned no result” symptom. Tests: two commands::mod unit tests (both modes, the shared message), two in-crate roundtrips (a vault read error and a layout list error both fail under --json), and the live smoke now uses a new cli_run helper that parses stdout on a failed exit and asserts both a nonzero status and data.code == "not_found" - its cli(check=False) also returns the parsed envelope now instead of {}, so no probe loses the answer. Docs: the CLI README’s --json flag, the manual checklist, AGENTS, and the CHANGELOG. The MCP server is untouched: it builds its own in-band errors.
✅ CLI pane-status --json (no pane) answers the pane list, not the statuses - the command’s contract is “omit the pane for every pane’s status”, and the non-JSON path performed exactly that (a listPanes, then a paneStatus per pane, printed as {panes: [{pane_id, label, type, status}]}), but the JSON branch printed the raw listPanes envelope and returned: gpty pane-status --json carried no status at all, so the machine-readable mode was less useful than the human one, and the non-terminal panes the per-pane path skipped were silently absent rather than reported as having no status. Found 2026-10 while auditing every if json branch for the exit-status fix above. the aggregate moved into the API rather than being rebuilt in the CLI. paneStatus with no pane_id now answers {panes: [{pane_id, label, type, status}]} for every tile - terminal rows carry the same status document the single-pane form answers, non-terminals carry status: null - so one definition serves the CLI’s no-argument mode and the MCP pane-status tool (which dispatches straight to paneStatus and previously could not answer its own no-argument form at all; workspace.gd already normalizes null params to {}). The CLI is now a single call_and_format(client, "paneStatus", {}), so --json prints the usual response envelope and the non-JSON mode prints the same payload; the old N+1 fan-out is gone. The single-pane contract is otherwise unchanged, with one honesty fix: targeting a real non-terminal pane answers “has no status (not a terminal)” instead of “not found”, which sent an agent looking for a pane listPanes had just named. Tests: a GUT case drives the real WorkspaceIpcHandlers.handle on a workspace with a terminal and a code_viewer (aggregate covers every tile, the terminal carries running, the viewer is present with status: null, and both single-pane shapes still hold), and a CLI roundtrip pins that the no-pane mode sends one empty request (a fan-out would call the unregistered listPanes and fail). Docs: the CLI and IPC READMEs, skills/gpty/SKILL.md, and the CHANGELOG.
✅ WIKI Declared edge weights - the authored layer - measured weights (mentions) are a crude proxy: two notes can be deeply related and mention each other once, or name-drop ten times and share nothing. This lets a declared weight override the measured one for display, authored by a human (a set/clear control on an edge in the canvas above) or by an agent (the LLM-curation path: the read/search access above plus one narrow write verb). Rules, settled 2026-10: (1) never in vaults.db - that store is a cache we reserve the right to rebuild (a schema bump drops and re-indexes the derived tables), and authored work must not live where a rebuild erases it; the map is user-owned in the PaneEnvStore/TrustedStore pattern (user://link_weights.json, atomic temp+rename, 0600, capped per vault), keyed (vault, src, target) on relative paths; (2) display-only - a declared weight changes thickness, layout stiffness and backlink ordering, and can never hide an edge, gate navigation, trigger a write, or count as consent (the Tier 2 declaration rule: spoofable input feeds no decision); (3) rebuild- and rename-safe by construction - it lives outside the derived store, the queued rename item owns re-keying it (a rename that does not re-key silently loses the curation), and entries whose endpoint vanished are pruned lazily; (4) the write surface is one number and nothing else - per-edge set/clear first, later a single IPC method (and only then a possible MCP tool). Phasing: the store and the canvas control can land with the canvas; the agent verb wants the read/search surface above, so it comes after it. the store is user://link_weights.json (LinkWeightsStore, the PaneEnvStore shape: typed reads, weights 1–100, 32 vaults × 1024 directed entries, atomic temp+rename 0600), keyed (vault, src, target) on normalized absolute vault roots and the walk’s own note-path vocabulary; LinkWeights is the pure vocabulary (range, path shape, nested lookup, normalization), and VaultGraphModel.apply_weights merges it into the parsed graph as declared/weight without touching count. The canvas offers Set/Change declared weight… and Clear on a right-clicked edge (edge hit-testing is point-to-segment in the model), styles a declared edge with the accent and counts it in the status line, and the pane writes both directions of the drawn pair; the link panel orders each section by the mention’s directed declaration before applying its 50-row cap. An untruncated graph show prunes declarations whose endpoint is no longer indexed (a truncated one never does). The agent verb is the one write: vaultWeight over the control socket, gpty vault weight <vault> <src> <target> --weight N|--clear, MCP vault-weight (22 tools), answered by the GUI because user:// is Godot’s and never auto-spawning one. Display-only end to end: nothing outside the canvas/panel reads it. Tests: test_link_weights.gd (vocabulary, caps, reload, prune), the model’s declared-weight and edge hit-test cases, the view’s menu/scale cases, and the pane’s re-query, set/clear, prune and panel-ordering cases.
✅ WIKI A note rename does not re-key its declared link weights - user://link_weights.json keys (vault, src, target) on relative paths, so a rename (or an outside move) leaves every authored weight naming the old path; the next complete graph prunes those entries and the curation is gone. Found while closing the declared-weights item (2026-10), whose rule (3) already names this as its own job. The rename verb re-indexes and already offers the mention rewrite as a step, so the same step should re-key both endpoints (the pane knows its vault) before the pruned graph is ever shown; a refused or failed rename must leave the store untouched. Acceptance: declare a weight in both directions, rename one note, reopen the graph - the pair is still declared; a cancelled rename changes nothing; tests seed through LinkWeightsStore and assert the rename path’s re-key and the cancelled path’s no-op. Closed (2026-10-18): LinkWeightsStore.rekey_note(vault, old_rel, new_rel) rebuilds one vault’s map in a single pass and saves once: a row whose source is the old path moves to the new one, every target naming it is respelled, a self-declaration moves with the note, and unrelated declarations are untouched. A row already stored at the destination (stale - a rename requires that note to be absent) merges with the moved row, and the moved declaration wins each shared target; a store with nothing to move returns false without a write, an invalid path changes nothing, and another vault is never touched. The wiki pane calls it immediately after vault_note_rename answers without an error, before _refresh_links() and the optional mention-update question, so the authored layer follows the file before any complete graph could prune the old name - and declining to respell other notes cannot discard it, because a declaration is not a mention. A refused or cancelled rename never reaches the call and leaves the store as it was. An outside move is indistinguishable from a delete to the index and still prunes (queued in Future). Tests: test_link_weights.gd (both endpoints plus a self-declaration, stale-destination merge, no-op/invalid-path immunity, vault isolation) and test_wiki_pane.gd (a real rename re-keys both directions; a cancelled rename and a destination collision change nothing). ./scripts/gut-check: 56 scripts, 717 tests, all passed.
✅ GUI Open supported files in a code-viewer pane instead of the system application - the file tree’s only open path is _confirm_open → OS.shell_open(file://…), so double-clicking a .md, a source file, or a log leaves gPTY for whatever desktop handler owns the type - even though a read-only code_viewer is already in the pane set and already renders Markdown. The system path is also the sharper sink (a downloaded .desktop is run by the handler), so the in-app route is both more useful and safer for the types it covers. Design: (1) one closed, unit-tested table - a new pure ViewableFiles module beside pane_types.gd - maps a path to its viewer: a curated extension set (source, config, docs, md/markdown, txt/log/diff/patch) plus a small exact-name set for extensionless text files (Makefile, Dockerfile, LICENSE, Justfile). Everything it does not list stays on the existing confirm-then-system path, so binaries, images and .desktop files behave exactly as they do now; a text sniff is explicitly not v1 (an allowlist is predictable and testable, and the external route is the fallback). (2) A tree activation on a supported file asks the workspace for a new code_viewer pane carrying file_path; if a viewer in the active workspace already shows that exact path it is focused instead of duplicated - a viewer’s scroll, selection and rendered/source mode belong to its document, so silently repointing an existing viewer at another file is rejected, while side-by-side is the reason the grid exists. A deliberate second view of one file is not a use case v1 serves; the same-path focus keeps the layout bounded and makes a double-click idempotent. (3) The viewer gains a small Reload action, because “open” focuses an existing same-path pane without re-reading it - without the button a file changed on disk could never be refreshed in place. (4) Panes still never spawn panes: the request is a new PaneBody.request_pane(type, settings) signal, connected where every body is attached (Workspace._attach_pane_into) and handled by the workspace through _spawn_pane (the only spawner, which already refuses a full grid) followed by activate_pane, so layout, activation and the grid ceiling stay in one place. (5) Configuration is per file-tree pane - Open files: [Viewer pane | System application], default Viewer, persisted in _get_layout_state like root_path - not a global setting: the tree is the origin of the action, several trees may want different behavior, and a global default can wait for a second origin (CLI/IPC). Opening Markdown here is read-only (the existing rendered toggle; editing stays the wiki pane’s job on a known vault - “Edit this note” from a viewer whose path is inside a configured vault is a later item). Security: the path is user-clicked, not restored; the read is TextRead.read_prefix (absolute, regular-file gate, 1 MiB cap); no handler runs; the unsupported-file confirmation is unchanged. Acceptance: a GUT unit test for the table (case-insensitive extensions, exact names, unsupported); a file-tree test that a supported activation emits the request and never calls OS.shell_open while an unsupported one still confirms; a workspace test that the request spawns a code_viewer with file_path and focuses a same-path viewer; a code-viewer test for Reload; and the pane-settings round trip for open_in. Closed (2026-10-18): the closed table is godot/scenes/panes/viewable_files.gd (ViewableFiles.viewer_for: a lowercased extension set for source/config/docs, exact extensionless names, no content sniff), and the file tree routes an absolute activation through it - a named type emits PaneBody.request_pane("code_viewer", {"file_path": …}), while anything else (or any file when the tile’s open_in is system) keeps the confirm-then-file:// path behind the open_external seam. The request is connected once in Workspace._attach_pane_into, the route every spawn, restore and swap takes, so panes still never spawn panes: _request_pane_into holds the type to PaneTypes.ALL, spawns through _spawn_pane_into, activates the body, and focuses an existing viewer with the same file_path instead of duplicating it. open_in is per-tree state saved in the tile like root_path (closed vocabulary viewer/system, default viewer, an unknown stored value falls back), and the code viewer gained Reload (re-read through load_file’s gate, scroll and caret preserved) because a same-path open focuses rather than re-reads; a routed capture now clears file_path, so a pane showing capture text can no longer be focused as that file’s viewer. Tests: test_viewable_files.gd (5 - extensions, case folding, exact names, unsupported types, nameless paths), test_pane_file_sinks.gd (supported activation requests a viewer and never reaches the system handler, unsupported still confirms, system mode confirms a supported file, Reload re-reads, a capture drops the file identity), test_pane_open_request.gd (4 - spawn+activate, same-path focus with no second tile, a second file gets its own viewer, an unknown type is refused), and test_pane_settings.gd (the settings UI gathers open_in, the tile state round-trips, an unknown mode falls back). Full suite green: 56 scripts, 710 tests.
✅ WIKI Wikilink navigation polish - heading fragments, line jumps, and nested labels - Three presentation pieces, no new read or write path. (1) Fragments follow. The renderer emits [url=gpty-note:<target>#<fragment>]label[/url] (each part percent-encoded, the literal # the only separator) and MarkdownView splits it into note_link_clicked(target, fragment) - a click resolves the note through the existing vault_link_resolve and then lands inside it. vault_links outgoing rows carry the fragment too: links_for re-parses the stored body and attaches it by seq, so the table still stores no fragment. (2) Line jumps. A backlink row opens the linking note at the mention’s line; a mention into the note already open only moves the view, so it asks nothing. The landing is two-step because scroll_to_line counts rendered lines: a new pure MarkdownAnchors scans the source for ATX headings (leading frontmatter and fenced blocks skipped; exact text then case-insensitive; a source line maps to its nearest heading above), and MarkdownView.scroll_to_heading searches the rendered parsed text for that heading and maps it with get_character_line. The scroll is queued one frame for the label’s layout pass (get_character_line is -1 before it, measured on 4.7.2) and an identical re-render is skipped - the editor’s settled render 0.4 s after opening used to reset the scroll; edit mode scrolls the source directly. A missing fragment still opens the note and is toasted. (3) Nested labels. A mention inside a Markdown link label or an image alt yields to its container and renders as plain label text: the event loop resolves a sentinel while inside Tag::Link/Tag::Image, so no nested [url] is emitted and the outer link - what the author wrote - stays the click target. Tests: markdown::tests::*fragment*, *inside_a_markdown_link*, *image_alt*; vault::tests::a_note_row_writes_its_edges_and_their_backlink (fragment by seq); test_markdown_anchors.gd (heading scan); test_wiki_pane.gd (test_a_fragment_mention_lands_on_the_heading_in_the_editor, test_the_panel_row_names_the_fragment_and_follows_it, test_a_backlink_row_jumps_to_the_mention_line, test_a_missing_fragment_still_opens_the_note_and_says_so, test_a_mention_inside_a_markdown_link_yields_to_it). Display only: nothing here executes, resolves a note from a fragment, or widens a read.
✅ WIKI A renamed note’s own [[old-name]] mentions stay unresolved - the reference pass deliberately skips the note at its new path: its buffer is the editor’s, and rewriting the file under an unsaved buffer would turn the user’s work into an outside edit. A note that mentions itself therefore keeps that one mention unresolved after a rename (the outgoing panel shows it as missing, and create-from-link offers the old name back). Low reachability - a note has to name itself - found while closing the rename item above (2026-10). Fix direction: include the note when its buffer is clean and have the pane adopt the rewritten text and stamp from the answer (the apply already writes only through write_vault_note), with an explicit skip for a dirty buffer; never write under one. the pass takes the buffer state as an include_renamed flag on preview_link_rewrite/rewrite_links_after_rename (and on both FFI verbs), so the preview’s counts and the apply cannot disagree about the note in the editor - clean includes it, dirty skips it and still updates every other note. The apply returns LinkRewriteResult::renamed_note ({text, crlf, mtime, size} over FFI) when it wrote that note, and the pane adopts the text (LF-normalized, keeping its caret line and scroll), moves _saved_text with it so the buffer stays clean, and takes the fresh stamp - no second read, and no spurious changed_on_disk on the next save. Never writes under a dirty buffer: the pane passes not _is_dirty() at both preview and apply (recomputed there, since the dialog can outlive an edit). Tests: a_rename_rewrites_the_notes_own_mentions_when_the_caller_includes_it (CRLF body + stamp), a_renamed_note_without_self_mentions_answers_no_body, a_renamed_note_past_the_read_cap_is_skipped_even_when_included; GUT test_renaming_a_clean_self_linked_note_updates_its_own_text and test_a_dirty_self_linked_note_is_never_rewritten_under_the_buffer.
✅ DOCS Wiki security notes in AGENTS.md - vault content is user data but untrusted input to the renderer: sanitized Markdown pipeline only; wikilinks resolve within the vault (no arbitrary file reads). AGENTS.md now states the rule where the other security constraints live (the “Layout restore trust” bullet) and beside the link vocabulary (the wikilinks bullet): note text reaches only the sanitized Markdown pipeline, a mention is rendered by that renderer from the note’s own text as a percent-encoded gpty-note: URL (label escaped, scheme emitted by no other path - a document’s Markdown link still admits only http/https/mailto), MarkdownView announces the click instead of opening anything, vault_link_resolve maps the target to a note in the index rather than a path, and the pane opens it through vault_note_read - the same containment, 1 MiB cap and regular-file gate as any other note read. The pane bullets carry the operational half (the parser’s fence/span/escape rules, ambiguity never silently picked, the panel’s display caps) so a later change has one place to read before touching it.
✅ BUILD A GUT run collides with a live gpty instance and dies without a summary - the tests spawn real GptyTerminal panes, each of which binds the shared control and event sockets, so running the gate while a GUI is up makes the bind fail: every pane-spawning test logs another gpty instance is already listening on /run/user/1000/gpty-events.sock (and the same for gpty.sock), the instance’s shutdown flag is set, and the process quits mid-suite - measured 2026-10-14, gut-check’s command against the default runtime dir stopped after 12 scripts with no Run Summary (exit 1, which reads as a crash, not as “close the app”), while the identical command with XDG_RUNTIME_DIR pointed at a 0700 temp dir ran all 27 unit scripts / 346 tests. Hit again 2026-10-15 on a single-file run (-gtest=res://tests/unit/test_wiki_pane.gd): same bind failure, same mid-suite exit, and the sandboxed runtime dir was again the only way through (50 scripts / 555 tests). Fix direction: sandbox the runtime dir (and/or GPTY_SOCKET) for the test process in scripts/gut-check and the GUT command, so a developer’s running workspace can never collide with the suite. scripts/gut-check hands the run a private endpoint and profile - one 0700 temp directory the trap removes, with GPTY_SOCKET aimed into it, XDG_RUNTIME_DIR/XDG_DATA_HOME/XDG_STATE_HOME set to it, and GPTY_SECRET cleared. GPTY_SOCKET alone moves both listeners, because the event path is derived from the control one (default_event_socket_path()), and on Windows that same variable carries the pipe name - so the fix is not Unix-only. Verified against the reported failure itself (2026-10-16: the developer’s GUI held /run/user/1000/gpty{,-events}.sock): ./scripts/gut-check ran green - 50 scripts, 560 tests - where the same command had stopped after 12 scripts with two bind refusals and no Run Summary, and ./scripts/ci-check (what the pre-push hook runs) passed end to end with no hand-set environment, the live GUI still answering the CLI. AGENTS.md records the rule on the gate bullet.
✅ BUILD A GUT run on Windows still touches the real user profile - (the Unix half landed 2026-10-16; the record is at the end of this item) the same class of leak one level down: a bare Workspace built in a test runs _init_workspaces, which reads the real user data directory (WorkspaceStore is mocked through _read_file/_write_file, but the legacy import_legacy_layout() path reaches the file directly), so the pane set a test sees depends on the machine it runs on - measured 2026-10 while writing test_pane_close_guard.gd: a fresh workspace came up with one terminal tile restored from a real layout.json, and the same assertions saw zero on a machine without one, which is why that file asserts only on the panes it spawns. Fix direction: sandbox XDG_DATA_HOME/APPDATA and XDG_STATE_HOME/LOCALAPPDATA for the test process in scripts/gut-check (the smoke already does exactly this), or make the legacy import go through the same mocked readers. The state directory belongs in that sandbox for the same reason: vault_index has always written probe-vault rows into the developer’s real vaults.db, and the recovery-copy tests (2026-10) now write state_dir()/scratch files there too - they clean up in after_each and their keys carry the run’s ticks so a leaked copy is unreachable, but a crash mid-test still leaves a file in the developer’s own state directory (the 2026-10 prune narrows this: a copy whose probe vault is gone is reclaimed by the next write anywhere, and the vaults are removed in after_each - but a run that dies before its teardown leaves the vault and its copy together, so the sandbox is still what closes this), and on Windows XDG_STATE_HOME does nothing at all (state_dir() reads %LOCALAPPDATA%). Narrowed (2026-10-16): scripts/gut-check now sandboxes XDG_DATA_HOME/XDG_STATE_HOME alongside the endpoint (XDG_RUNTIME_DIR + GPTY_SOCKET), so on Linux/macOS a suite run writes no probe vault, no vault-index rows, no recovery copies and reads no layout.json from the real profile - the machine-dependent Workspace restore this item was found with is gone there (verified: a full run left the real data directory’s entries and vaults.db’s mtime unchanged, and the queued half this item’s own note mentions - the recovery copies - rides the same sandbox). Windows keeps the whole problem for Godot’s user://: it resolves through the Known Folder API, which no environment variable redirects (the live smoke documents the same limit and works around it with a backup/restore of the real files - practical for the two stores it seeds, not for a suite that writes a probe vault per test). The Rust state dir is env-based (state_dir() reads %LOCALAPPDATA%), so a Windows run can take that half too. What remains: (a) route the legacy import_legacy_layout() read through the mocked _read_file/_write_file, so a bare Workspace in a test never sees a real layout.json on any platform, and (b) give the gdext-level user:// writes (probe vaults, settings churn) either an FFI-level test root or the smoke’s backup/restore discipline. the FFI-level test root, with one seam instead of two. BasePersistenceManager.resolve_store_path translates every user:// store path (_read_file/_write_file) into GPTY_TEST_USER_DIR when that variable names an absolute directory (empty/relative values are ignored, so a typo cannot send stores somewhere the app cannot find again), and the gdext history_db_path resolves the scrollback database through the same variable - pinned by history_db_path_honors_only_an_absolute_test_root and by test_the_test_user_root_redirects_the_history_store, which drives the real FFI. scripts/gut-check exports it on every platform (on Unix it overlaps the XDG sandbox on purpose: the normal Linux gate exercises the seam), adds LOCALAPPDATA/APPDATA on Windows (via cygpath -w; the Rust state/data dirs read them directly); the smoke passes --log-file for its engine log, while GUT leaves Godot’s file logging off (so there is none to move). Test-created paths go through the new TestPaths.user_path helper (which asks the same user_root_override()), replacing every ProjectSettings.globalize_path("user://…"); the live smoke uses the same seam, so its concept/plugin backup-and-restore code is gone. The (a) half needed no change: import_legacy_layout() has read through the mocked _read_file since the storage refactor (test_legacy_layout_import pins it), and the seam means even an unmocked read reaches only the sandbox. Verified: a pane-spawning GUT subset left history.db in the sandbox and no store outside it; the full gate green (57 scripts / 737 tests); cargo test -p gpty-core --lib 259 passed and the gdext history test passed; ci-check --fast green; the live smoke PASS on Linux with the real concepts.json/workspaces.json/layout.json/settings.json mtimes unchanged. The Windows half is the same code path the windows-smoke job now runs (GUT under Git Bash + the smoke).
✅ WIKI The vault index cannot open its database on a fresh Windows profile - gpty_ipc::transport::state_dir() returns %LOCALAPPDATA%\gpty on Windows and the Unix private_state_dir creates/tightens its directory, but the Windows arm created nothing; VaultStore::open (and the recovery copies / plugin store) only open files inside it. A Windows GUT run with the new sandbox measured the consequence: vault index open failed (…\localappdata\gpty\vaults.db): unable to open database file, the wiki note list stayed empty, test_lists_markdown_notes_recursively_and_skips_hidden failed, and its follow-up [0] index hit Godot’s interactive debugger (-d), flooding 1.7 GB of Debugger Break and stalling the suite to the 600 s cap. The live smoke had masked the directory half because it seeds a plugin record there first. local_app_data() now calls the shared local_app_dir, which create_dir_alls %LOCALAPPDATA%\gpty before returning it - the same creation contract as the Unix arm, owned by the state-dir function rather than by whichever store opens first. Pinned by local_app_dir_is_created_on_first_use (runs on every platform). The GUT gate also stops passing -d (Godot parses it as --debug; a runtime test error now fails the test instead of entering the debugger) and caps the run at GUT_TIMEOUT (600 s), so a future failure cannot become a 1.7 GB flood that holds the runner. Verified: cargo test -p gpty-ipc local_app_dir green; the local gate green; the Windows GUT leg is the CI proof.