Skip to content
Pitfalls and tool notes

Pitfalls and tool notes

  • Drop impl for external resources: Any Rust struct holding a child process (portable_pty::Child) or I/O thread MUST implement Drop to call .kill(). Otherwise closing a terminal in Godot orphans the shell process and reader thread.

  • tokio::select! None branches: When a channel returns None (closed), select! disables that branch but keeps polling others instantly - causing 100% CPU. Bind to a variable first (msg = rx.recv()), then let Ok(v) = msg else { break; }.

  • Locking: std::sync::Mutex throughout; a poisoned lock means skip this work and keep the app running, so report it through gpty_core::lock::lock_or_warn(mutex, "what") rather than a bare if let Ok(..) = m.lock() - the first occurrence of each lock then shows up in the log (a silently frozen pane used to be indistinguishable from an idle one). Do NOT add parking_lot (the dead gpty-ipc dependency was removed in v0.5.4): with no poisoning at all, “a thread died holding this lock” becomes invisible instead of reported. gpty-ai is the exception - it does not depend on gpty-core and turns poisoning into a Result error at its own boundary.

  • The GDExtension must stay mapped while its threads live: it owns the tokio runtime’s workers, one PTY reader per pane, and one history writer per pane, so Godot’s unload of the library (dlclose at exit) used to free code those threads were executing - SIGSEGV in a tokio-rt-worker on a quit during IPC activity. pin_library() pins the mapping at InitStage::Core with RTLD_NODELETE (a reference dlclose cannot drop) on Unix, and on Windows with GetModuleHandleExW + GET_MODULE_HANDLE_EX_FLAG_PIN | ..._FROM_ADDRESS (Godot calls FreeLibrary there; the address of the function itself names the module, so nothing depends on the DLL’s file name - raw extern "system" + #[link(name = "kernel32")], no windows-sys dependency). Do NOT remove it, and do not “fix” a shutdown crash by going back to process::exit - that skips the teardown the quit exists to run. The Windows branch is compile-verified by ci-check’s xwin/clang-cl step and CI’s rust-windows job; its runtime is covered by the queued Windows smoke item.

  • Pane exit detection needs two signals, not one: pty_rx closing (Unix EOF when the child exits) and a CHILD_POLL_MS (200 ms) try_wait poll whose result starts an EXIT_DRAIN_QUIET_MS (500 ms) quiet window before the task ends. Waiting for EOF alone is not enough - ConPTY keeps the pty pipe open after the child exits, so on Windows every pane whose command finished reported running: true with no exit code (paneStatus/paneRun’s whole contract), and on Unix a SIGHUP-immune descendant holding the pty does the same (the kernel only HUPes the foreground group when the session leader exits). The quiet window is what keeps ending early from truncating output the pty still holds: any output re-arms it. try_wait does not consume the status - std caches it on Unix and Windows reads the exit code - so the teardown’s own poll still returns the same answer. a_shell_that_exits_while_the_pty_is_held_open_is_still_reported pins it (it fails on a no-op mutation, 10 s timeout). A task that ends without recording an exit code is the third case, and the one that used to lie: nothing awaits the JoinHandle, so a panic inside the task left the pane reporting running: true forever and hung every consumer that waits for exit. SpawnedTerminal::task_finished() is the only thing that notices it, running is exit_code.is_none() && !task_finished(), and the document carries exit_reason (exited, task_ended, or null while running). SpawnedTerminal::status_json builds that document in core - the pane API’s promise and the tested artifact are the same bytes. The document also carries foreground_job: the same 200 ms poll samples the PTY’s foreground process group (tcgetpgrp on Unix, exact; null on Windows/ConPTY, which cannot tell), and the GUI’s close confirmation is its only consumer - a background job does not count, so sleep 600 & never blocks a close while a typed sleep 600 does.

  • Core diagnostics do reach the GUI now: diagnostics.rs installs a log bridge at InitStage::Core that forwards warn+ records whose target starts with gpty to godot_warn!/godot_error! (dependency targets stay out, and info/debug are dropped - a diagnostic meant to be seen must be warn or above). Do not call godot_warn! from gpty-core/gpty-ai: those crates must not depend on gdext, so their message goes through log and the bridge carries it.

  • PTY output is backpressured, never dropped: pty_rx is mpsc::channel(pty::OUTPUT_QUEUE_CHUNKS) (256 chunks ≈ 1 MiB per pane) and the reader thread blocks in blocking_send when it fills, so a flooding child (yes, cat /dev/urandom) waits on the kernel PTY buffer instead of growing the process - with an unbounded queue it measured +70 MiB/s in the GUI and 2.2 GiB in 20 s in a bare harness. Do NOT make the channel unbounded again, and never “unstick” a full queue with try_send/drop: these bytes carry ANSI state (SGR, cursor moves, ESC[ sequences), so a dropped chunk desynchronises the grid. The reader thread stops only when the pane is gone (receiver dropped) or the child exits.

  • Scrollback is write-behind, never written from the terminal path: TermGrid::store_line only updates recent_lines (which waitForOutput polls each frame and must see immediately) and calls PaneHistory::push, which queues the line for a per-pane writer thread. Do NOT call into SQLite from the grid (the grid mutex is what the UI renders under) and do NOT make the queue unbounded: it is bounded by the store’s own retention window (row cap + 1 KiB-per-row byte cap), so a line it drops under flood is one enforce_cap deletes on the next commit - the retained scrollback stays contiguous. One writer per history file at a time for the whole process (history.rs::write_guard): every pane’s writer owns its own connection to the same file, and SQLite’s write lock plus a busy handler that does not queue fairly let N panes starve one another - measured with a probe over one file, 8 unserialized writers dropped 7 batches and 16 dropped 15, all with database is locked (the same message windows-smoke showed), while the guarded version dropped none and committed faster (32.8k rows/s at 8 writers against 26.1k, since a dropped batch is wasted work) for ~5% against a single writer. The guard is always the innermost lock (taken inside a store method while a caller may hold the pane’s store mutex), so the order cannot invert, and a poisoned guard proceeds unserialized rather than skipping the write: the busy timeout is then the backstop for another process on the same file. The trade is staleness: a search can miss the last FLUSH_INTERVAL (200 ms) of output, and anything buffered when a process dies without teardown (SIGKILL, daemon stop’s process::exit) is lost. On a normal quit TerminalPane._exit_tree() calls flush_history, which is the only hook that can live there: the GDExtension class’s own exit_tree virtual was probed and never fires, while GDScript’s does.

  • vte Perform::execute CR/LF: PTY output uses CRLF pairs. The vte parser calls execute per byte. If you commit on both \r and \n, every line produces a spurious empty string. Track last_was_cr and skip the \n commit when preceded by \r.

  • alacritty_terminal display_iter: returns negative line numbers for scrollback history rows. Never cast directly to usize - it wraps to a huge value. Always add the grid’s display_offset() to normalize: let line = (indexed.point.line.0 + offset) as usize.

  • GraphEdit owns internal children (the connection layer, minimap container, popups): rebuilding a canvas by clearing get_children() deletes them and every later interaction logs connections_layer is missing. Remove only GraphElement children (ConceptGraphEditor._rebuild_canvas_ui). Its floating chrome (zoom buttons, view menu, minimap toggle) is viewport-anchored at the canvas origin and covers the first node’s title at the default scroll offset - the editor disables that chrome and keeps zoom on ctrl+wheel.

  • GDScript \UXXXXXXXX escape: GDScript only supports \uXXXX (4-hex-digit BMP). \UXXXXXXXX (8-digit) does not exist - the parser mangles it. For non-BMP codepoints, use char(0x10XXXX) in static var initializers. Prefer BMP alternatives; this project’s icons use Phosphor PUA codepoints (U+E000–U+F8FF), all expressible as const with \uXXXX.

  • Godot strings cannot hold NUL: on 4.7.2 a "\u0000" literal (and String.chr(0)) is decoded as U+FFFD and prints Unicode parsing error, some characters were replaced with � (U+FFFD): Unexpected NUL character - six times for vault_graph_model.gd, which used a NUL separator for composite edge keys and so emitted the error on every script load. Never use NUL as a key separator or value sentinel in GDScript; the graph model keys pairs with a nested {a: {b: pair}} map instead (a length-prefixed key would also work). String.contains/find of a NUL can never succeed, and any key built with one is silently a U+FFFD key and can collide with a path that contains that glyph.

  • Typed arrays break Rust FFI: gdext Array<Variant> parameters reject GDScript’s Array[Dictionary] at runtime (“expected array of type Untyped, got Builtin(DICTIONARY)”). Always pass untyped Array across the FFI boundary. Prefer func f(arr: Array) over func f(arr: Array[Dictionary]) when the array originates from or goes to Rust.

  • Multi-line for array colon: for x in [...] with a multi-line array literal requires ]: at the end. Forgetting the colon produces a parse error at an unrelated line. Double-check after replacing inline array content.

  • Godot typed Arrays: Array[T] won’t accept plain Array. If you type a parameter, check all call sites use matching types (var x: Array[Control] = []).

  • GDExtension rebuilds: After changing #[func] signatures or adding methods, cargo build -p gpty-gdext and fully restart Godot. Keep godot/bin/libgpty_gdext.linux.x86_64.so as a symlink to ../../target/debug/libgpty_gdext.so so the editor sees Cargo output. A release build of the editor (e.g. 4.7.2 stable) does not accept linux.debug.* feature keys. ./scripts/build overwrites the symlink with a release copy.

  • GDScript default params: Evaluated at definition time, not call time. func f(x := some_var) captures the value of some_var when the script loads. Use func f(x := -1) and check if x < 0: x = some_var inside the body for runtime-evaluated defaults.

  • extends Node won’t render Control children: Only Control nodes can render child Controls (Labels, Buttons, etc.). If you add a Label to a plain Node, it’s invisible. Use extends Control for UI containers and set z_index for layering.

  • tokio::time::Instant::now() + Duration::MAX panics: The addition overflows. Use a safe large constant like Duration::from_secs(86400 * 365) (1 year) for inactive timeout sleeps.

  • tokio::pin! + reset() for capture timeouts: Use tokio::pin!(sleep) and sleep.as_mut().reset(deadline) to re-arm a timeout without recreating it each iteration. One select! branch, no code duplication.

  • continue in for does not skip trailing code: A continue inside a for loop only skips the current iteration - code BELOW the loop still executes. Use break + a boolean flag to conditionally skip post-loop grid feeding.

  • Concept matching on PTY output: ConceptMatcher::match_line tests every line against the enabled concepts (first match wins) on both the PTY-output path and the typed-input path; concept::match_line is its ungated decision loop (same verdict, no prefilter) and stays the function the precedence/condition tests exercise. The match’s return value MUST be used to session.begin(name, target, deadline) - ignoring it (let _ = match_line(...)) silently disables capture, which looks exactly like “concepts do nothing”.

  • Restore ordering and env authority: the env chain is three deliberate steps, in order - _restore_into strips any shell_env a file carries (legacy tiles; a toast names what was dropped - never adopt it into the store, that would launder a hostile file’s payload into the trusted map), SettingsManager.apply_to_terminal sets the user’s global, and TerminalPane._ready overlays PaneEnvStore.env_for(attachment_id) last (the id is final there: uniqueness renames run before the wrapper enters the tree). Do NOT re-add file env to any step of that chain, and do NOT write PaneEnvStore from anywhere but the pane settings UI path (workspace.gd’s settings_applied handler, which also re-keys the entry when the pane’s id changes).

  • Bare CR must never commit a parser line: LineParser commits only on \n (LF); a bare \r stashes the pending text - an LF after it commits the CRLF line, a printable after it discards the stash (reprint/overwrite). Shells reprint the prompt on SIGWINCH as \r + clear-to-EOL + \r + prompt; committing on bare CR stamps a history row per resize, and restarting restores a growing pile of old prompts. Progress bars (bare-CR updates) likewise only commit their final state once a real line end arrives.

  • Tab completion triggers concept matches: Bash reprints the prompt and partial command when showing autocomplete candidates. This reprinted line has no trailing \n, so LineParser never emits it. Tab completion never triggers concept matching.

  • Raw-byte buffering for grid replay: Never buffer parsed lines for later grid replay - the alacritty_terminal ANSI state machine needs raw bytes with escape sequences intact. Buffer Vec<Vec<u8>> (chunks), replay with feed_grid(board, chunk).

  • Rendering Performance: GDScript _draw is slow when calling draw_rect/draw_string character-by-character. Avoid generating heavy data structures (like Dictionary) per-cell across the FFI boundary. Prefer packing data into flat arrays (PackedByteArray, PackedInt32Array) in Rust, and batch rendering into glyph runs - consecutive same-attribute cells merged into one draw call; per-line batching is the floor, not the ceiling. Do NOT re-propose a custom GPU texture pipeline (fontdue glyph atlas / instanced quads) without new evidence: Godot already GPU-composites canvas items, the bottleneck is CPU-side command generation, and fontdue would duplicate Godot’s existing glyph atlasing. The instanced-quad renderer is evidence-gated in ROADMAP Future - revisit only if render batching plus flood rate-limiting still shows frame-time pain.

  • Resize Rate Limiting: Firing SIGWINCH heavily on every frame during window drag will overwhelm the child PTY process. Always debounce or rate-limit terminal _on_resize events before passing them to the backend.

  • Scrollback vs. PageUp/Down: terminal_pane.gd:_handle_keyboard intercepts PageUp/Down for scrollback navigation. These never reach the PTY, so programs like less or vim cannot receive them. Users must use alternative keys (b/f in less, Ctrl+B/Ctrl+F in vim). The pane search bar is on Ctrl+Shift+F; plain Ctrl+F is never intercepted, so it reaches the PTY (and the documented vim workaround works).

  • Alt key handling: For Alt+letter combos, the Rust keymap returns None, expecting the GDScript layer to prepend \x1b (ESC). _handle_keyboard does this in the _key_to_text fallback path.

  • Printable keys and the evdev keymap: GptyTerminal.key_to_bytes MUST early-return empty for printable ASCII keycodes (0x21–0x7E). Routing them through godot_key_to_evdev fabricates scancodes that collide with special keys (z→55=KP_MULTIPLY, ;→59=F1, `→96=KP_ENTER) and silently swallows the characters. Only Space (→57, for Ctrl+Space→NUL) and true special keys may reach the keymap.

  • Ctrl+V passthrough: Ctrl+V MUST reach the shell as a literal ^V (readline quoted-insert, vim visual-block). Paste is Ctrl+Shift+V only - never bind plain Ctrl+V to paste.

  • PTY Enter key: The Enter key MUST send \r (CR) to the PTY, not \n. pty.rs:write_line appends \r. The PTY terminal driver translates \r → \n in canonical mode; raw-mode programs read \r directly.

  • tokio::task::JoinHandle drop detaches: dropping a JoinHandle does NOT abort the task - it keeps running until it exits naturally. For explicit cleanup (e.g., in a Drop impl), call handle.abort().

  • std::sync::Once poisoning: if the closure passed to Once::call_once panics, the Once is permanently poisoned - all subsequent calls panic too. For lazy init that spawns fallible work, use AtomicBool::swap(true, Relaxed) or Mutex<Option<...>> instead.

  • Resize cascades from layout changes: When panes are added/removed, remaining terminals receive multiple NOTIFICATION_RESIZED events in rapid succession. Even when calculated rows×cols are identical, each resize_grid() call triggers a full grid re-wrap and sync, producing a visible “scrolling through history” animation. Fix: TermGrid::resize() must return early if dimensions are unchanged. Also apply a pixel-level check in the GDScript debounce to avoid redundant calls.

  • Shared static state in integration tests: tests that mutate shared static state (queues, maps) must clean up in ALL exit paths - including timeout, error, and panic branches. A stale queue entry from one test will break the next test. Write a clear_state() helper and call it in every test. The process environment is the same class of shared state, and cargo runs one binary’s tests on parallel threads: gpty-ipc’s transport tests guard GPTY_SOCKET/XDG_RUNTIME_DIR through one shared EnvVar lock that restores the previous value on drop (including on panic); an unlocked set_var/remove_var pair failed a real ci-check parallel run.

  • Do not scrape agent TUIs: PTY bytes are presentation, not structured events. Semantic observability comes only from documented OMP/Claude/Gemini hooks or extensions. Regex concepts remain valid for ordinary shell output (e.g. cat → code viewer).

  • Emulator PTY replies: alacritty_terminal emits Event::PtyWrite for terminal queries (DSR cursor-position ESC[6n → ESC[row;colR, mode reports). gPTY’s event proxy MUST queue these and the engine MUST write them to the child PTY (TermGrid::drain_replies() drained each loop iteration). Dropping them breaks TUIs that query the cursor after SIGWINCH - the OMP TUI re-anchors its transcript and appears to “scroll through history” on every resize, while vim/btop (which never query) are unaffected. Never drop non-Title events.

  • Transient pane sizes collapse the grid: during split/spawn layout churn, panes transiently report ~38×40 px (4×2 cells). Applying that rewraps the grid to 2×4 - the terminal looks dead. terminal_pane.gd must skip resize when size < custom_minimum_size OR computed cols < 8 OR rows < 3 at BOTH event and apply time, and must recompute dims from the CURRENT size when the debounce fires. resize_grid (gdext) must also no-op when dims are unchanged (no redundant SIGWINCH).

  • Resize anchoring surgery: TermGrid::resize deletes the rows alacritty pulls in on row-growth and restores the cursor (xterm behavior). Run it ONLY when display_offset == 0 && rows grew && !is_alt_screen() && history_size > 0. That history_size test is read from the POST-resize grid and grow_lines ends with decrease_scroll_limit(lines_added), so it passes only when scrollback was longer than the growth - the case where every new row came from history and no visible row was pushed off. The deletion therefore removes recovered scrollback rows and can never eat on-screen content; scrollback shorter than the growth is zeroed by that same call, failing the guard and skipping the surgery. It is NOT a “this is a TUI” test: a primary-screen app that paints without smcup (the OMP TUI) is guarded only on a fresh pane - launched after the pane had more scrollback than the row growth, it passes every condition and the injected CUP/DL escapes land in its screen. Only the alternate-screen check catches real full-screen apps.

  • Empty grid cache: _grid_offset()/_mouse_to_cell() run on mouse events before the first grid sync - guard _cell_cache.is_empty() or the editor pauses on a script error and the app appears frozen.

  • SGR 7 swaps BOTH cell colors, not just the glyph: an inverse cell’s text is painted from bg and its background from fg. Do the swap once, at the presentation boundary - CellInfo::paint_colors in gpty-core is what get_grid_updates_packed packs, so the cache a pane draws from is paint-ready and _draw_cells must never re-derive it. Swapping only the text color paints the glyph in its own background, and this is the common case, not an exotic one: bash’s readline wraps the echo of every bracketed paste in ESC[7m/ESC[27m (measured under a real PTY), so pasted text read as blank cells with the cursor advancing, until the next arrow key made readline repaint the line unhighlighted. godot/tests/integration/test_inverse_paint.gd pins the cache’s paint-ready colors (and fails on the raw-color packer); inverse_cells_expose_swapped_paint_colors pins the rule itself.

  • Textless Buttons collapse: Godot does not derive a Button’s minimum size from child Controls. A Button that carries an icon+text HBox instead of its own text needs an explicit custom_minimum_size.y (see _make_icon_text_button) or it squashes to nothing and overlaps the next VBox row.

  • Pressed-state text color: a pressed (toggle-mode) Button resolves text from font_pressed_color, which falls back to the theme default and silently ignores a font_color override - the active-row accent rendered black until all three states were overridden (_apply_row_accent).

  • Container ancestors skip gui_input propagation: clicks on pane bodies never reach a wrapper’s gui_input (proven by probe). Pane activation therefore lives in the workspace’s raw _input with wrapper hit-testing, not in per-wrapper signal wiring.

  • z_index is rendering-only for Control input: Godot 4 GUI picking uses REVERSE TREE ORDER (last sibling first), ignoring z_index. A full-rect overlay with z_index=100 that is NOT the last child draws on top but is dead to clicks - later-added siblings (workspace grids, sidebar, status bar) eat its input. Every overlay (pane settings popup, global settings panel, palette) must move_child(overlay, -1) when opened so it is topmost for both picking and rendering. The z bump is still required for RENDERING when grids are added after the overlay.

  • Bottom-edge overlays need BOTH anchors: a Control with anchor_bottom = 1.0 but default anchor_top = 0.0 stretches to FULL parent height - the offsets only shift it, they don’t shrink it (the terminal search bar briefly covered the whole pane). Bottom strips must set anchor_top = 1.0; anchor_bottom = 1.0 with a negative offset_top.

  • Pane labels: _next_label takes max(existing numeric suffix) + 1 - closing the newest pane reuses its number, middle gaps are never filled (T1,T3 → next is T4). Do not reintroduce monotonic counters.

  • Event vs control sockets: ompEvent lives on gpty-events.sock. Putting it on the control socket would require giving the extension GPTY_SECRET (workspace control) or weakening auth. Keep them separate.

  • Event capability is per-PTY: unregister on spawn failure and Drop. A capability for terminal A must never be accepted for terminal B. Compare capabilities in constant time.

  • Inspector close must tear down its OMP child: call session_cancel + session_close from _exit_tree(). Dropping a Godot node without closing the session leaves omp running.

  • stream=thinking observers are not job owners: after migration they become Reasoning panes and must keep can_receive_content() == false. Acknowledging a capture without starting analysis discards terminal output.

  • Terminal mouse reporting: the grid’s mouse-mode bits (GptyTerminal.get_mouse_mode, gpty_core::term::MOUSE_MODE_*) are the single source of truth for whether the child owns the mouse. The pane forwards only what the enabled modes cover (DECSET 1000 click, 1002 drag, 1003 any-motion), encodes SGR when 1006 is set and the legacy X10 form otherwise, and must leave selection/scrollback untouched when the bits are zero. Shift bypasses reporting (xterm’s convention) so text selection stays reachable - removing that traps the user in any full-screen app that grabs the mouse.

  • Mouse reports are cells, 1-based, mapped through the same _grid_offset()/cell metrics as selection. The legacy X10 form offsets by 32 and cannot address past cell 223: drop such an event rather than clamping, or the app receives a click on a cell the user never touched. The report has no trailing newline, so a child that has mouse tracking on but left the PTY in canonical mode never sees it (that is the app’s bug, not the pane’s - the automated check drives a raw-mode child).

  • Terminal selection lives in the grid, not in viewport rows: GptyTerminal.selection_start/extend/clear drive alacritty_terminal::Term::selection, which alacritty rotates whenever output scrolls the screen (and on resize). A screen-space selection (the pane’s old _sel_start/_sel_end) stayed on whatever text scrolled under it; get_selection_text reads grid rows, so a selection scrolled out of view still copies in full. Do not re-derive the anchor from display_offset arithmetic in GDScript - grid line indices shift with every scroll, so only the term’s own selection keeps its text; get_selection_cells is just the drawing projection for the current offset.

  • Pane edge resize belongs to edge strips, not to the wrapper’s gui_input: a PanelContainer fits every child into its content rect (a Control added directly to the wrapper is stretched over the whole pane and swallows clicks), and the pane body is MOUSE_FILTER_STOP, so the wrapper itself only ever receives events inside its 1 px stylebox border - an edge test there is unreachable by hand. Each wrapper therefore carries an EdgeHost (plain Control, MOUSE_FILTER_IGNORE, added last so it wins Godot’s reverse-order picking) holding four 6 px strips that own mouse_default_cursor_shape (HSIZE/VSIZE; Godot applies it on hover, whereas an imperative DisplayServer.cursor_set_shape is overwritten by the hovered control’s default and only flashes). The drag is driven from workspace._input (drive_edge_drag) because the motion right after a press on the border is already over the pane body, which would otherwise read it as a text selection.

  • Edge-drag arithmetic (the state machine is tile_drag.gd; TerminalManager keeps drag_active/begin_edge_drag/drive_edge_drag/end_edge_drag as thin delegators) previews in pixels and commits in units: drive_edge_drag passes the distance from the press (mouse motion arrives in 1-3 px steps, so a per-event delta would round to zero), and each motion moves the divider line’s wrappers by the raw pixel delta - before tiles grow their far edge, after tiles move their near edge - without touching the tile model and without emitting tiles_resized (that signal drives _apply_layout, and emitting it per motion relayouted the grid and reflowed every terminal on every motion event). begin_edge_drag snapshots everything the preview needs from the press: the tiles on the line, their committed offsets, one unit in pixels, and the unit travel limits - the same clamps the commit uses, so the preview can never show a position the release refuses (MIN_TILE and the grid edges are enforced mid-drag in pixels). end_edge_drag rounds the previewed travel to whole units, rewinds every tile to the press-time spans (normally a no-op; it is what makes the commit idempotent if a spawn or kill moved a tile mid-drag), applies the step, and emits tiles_resized exactly once - the pane’s own RESIZE_DEBOUNCE then settles the terminal content. The preview is absolute from the press, so backtracking is safe, and an edge with no tile on its far side (the window border) never starts a drag. Tiles are addressed by wrapper (_tile_index_for_wrapper), not by body (_tile_index_of resolves bodies and never matches a wrapper).

  • The layout grid has exactly one definition: PaneTypes.GRID/PaneTypes.MIN_TILE, read by the drag math, PaneTypes.sanitize_tile’s default, and workspace.gd._apply_layout. A second copy drifted once (the layout divided by 12 while the tiles were in 60), which clamped every restored pane to a sliver and drew it several times the grid - panes outside the visible area, from a one-line constant change. Never re-declare it locally; the tiles on a divider are the ones whose saved span ends on the line and the ones whose span starts on it (TerminalManager._tiles_on_divider), never a neighbour matched by equal extents - a full-height pane flanked by two stacked ones has no equal-extent neighbour at all.

  • Pane file sinks (code_viewer, file_tree, wiki): FileAccess.file_exists() is a regular-file check on Godot 4.7.2 (measured: false for directories, character devices and FIFOs - even reached through a symlink - true for regular files and symlinks to them) and FileAccess.get_size() returns -1 for anything non-regular, so that pair is the type gate and a FIFO never reaches FileAccess.open() (which would block the GUI thread until a writer appears). The read guard is TextRead.read_prefix - one definition every reading pane calls: absolute paths only, that regular-file gate, a TextRead.DEFAULT_MAX_BYTES (1 MiB) cap (the reader is the GUI thread and the path can come from an untrusted layout/profile), and a torn UTF-8 tail decoded by hand, because cutting at the cap often lands inside a sequence. code_viewer and file_tree call it directly; the wiki pane’s note view calls GptyTerminal.vault_note_read, which applies the same cap, the same regular-file gate and the same torn-tail drop in gpty_core::vault - and its editor is the pane set’s only writer, through GptyTerminal.vault_note_write on an explicit save and vault_note_create/vault_note_rename/vault_note_delete on their explicit actions (the vault bullets above have the rules - including the crash-recovery copy that sits beside the save and never touches a vault). file_tree never hands a bare name to the system: activating a path ViewableFiles names opens it in a code_viewer pane through the panes-never-spawn-panes request (below; the per-tree open_in setting can leave every file on the system path), while anything else validates an absolute path, builds the one file:// URI itself (scheme allowlist - a basename cannot contain /, so no scheme can be injected), and asks for confirmation, because a downloaded .desktop file is run by the mime handler. The table is an allowlist with no content sniff - ViewableFiles.viewer_for decides from the lowercased extension or an exact extensionless name, so a binary is never decoded as text - and the viewer’s Reload action exists because opening the same file again focuses the existing pane instead of re-reading it; a routed capture clears file_path, so a viewer now showing capture text can never be mistaken for the file’s pane. Godot 4’s CodeEdit has add_comment_delimiter(start, end, line_only), NOT add_comment_string - the latter raised a script error that silently aborted load_file before _refresh_view().

  • The media pane (media, MediaFiles + MediaPane): a local-file sink under the same trust stance as the text panes, with one sharper hazard - a path handed to ResourceLoader.load would load whatever a file-supplied tile named (a .gd or .tscn behind a media extension is code), so MediaFiles is a closed extension table and both streams go through GptyMedia (see below): video as RGBA frames, audio as PCM pushed into an AudioStreamGenerator. check is the one path gate (absolute, FileAccess.file_exists’s regular-file check, unknown formats refused before a decoder), and load_media re-runs it every time because the path can come from workspaces.json/a profile; a refusal clears file_path and explains itself in the pane (no toast, no push_error - a bad path is a caller’s question, and GUT counts engine errors). Remote/URL streaming is deliberately absent (its own trust boundary, queued in v0.7.0), and project schemes (res:///user://) are refused with the non-absolute paths. Video is the Rust backend: VIDEO_EXTENSIONS covers ogv/mp4/m4v/mov/mkv/webm/avi/ts/m2ts, a video session opens both streams (the pane drains audio too; an unconsumed queue is backpressure and would stall the other stream), frames are drained only while a small PTS-ordered backlog has room and presented against the media clock below - the backend’s bounded queue is the backpressure, because a fixed drain count followed by “keep the newest four” discarded frames that were due (the decoder decodes a whole clip in milliseconds, so the picture froze on the first frame while audio advanced); stale frames are dropped only when a newer one is waiting, and without audio the clock starts on the first frame’s own PTS (a stream whose timeline does not begin at zero would otherwise be held as “in the future”), and the surface is a TextureRect with aspect-preserving center stretch. Audio is the same backend: AUDIO_EXTENSIONS covers ogg/oga/opus/mp3/wav/flac/m4a/aac, decoded f32 is pushed into an AudioStreamGenerator (48 kHz stereo, 0.2 s buffer), and the generator starts on the first real chunk so get_playback_position() and the media PTS share an origin. Chunks are held in a bounded 32-chunk local lead and drained from the backend even when the generator is full - leaving that queue full would block the worker, which also decodes video. With audio running the video clock is get_playback_position() mapped from the first chunk’s PTS (20 ms tolerance, 150 ms catch-up); without audio it is the monotonic start clock. Volume (-40..6 dB) and mute (-80 dB) are pane settings saved in the tile. Two PCM facts are pinned: pcm_u8 is in the component set, and a frame with an unspecified channel layout must be normalized on the frame, not only on the resampler (swresample otherwise answers InputChanged). Transport: a bottom bar offers Play/Pause, an HSlider seek, elapsed/total and Loop. Pause pauses the generator and freezes the clock (video-only offsets _clock_start_us on resume); seek goes through the backend’s acknowledged seek and then drops local frame/audio queues, stops the audio player and releases the generator playback (its clear_buffer refuses while the playback is active - paused included - and play() instantiates a fresh, empty generator playback, so dropping the reference is what discards the samples), and resets the clock epoch; loop seeks to 0 at EOF (the worker survives it) and is saved in layout state, while Play on a finished non-looping file restarts from zero. The slider marks _seeking so the position update does not fight the drag. The backend’s format policy is this table at the edge plus components.txt/the Rust allowlists (see the Rust media bullet), and it refuses a file it cannot decode before any decoder starts. The file tree routes a supported media file through request_pane exactly as it routes text (ViewableFiles first, then MediaFiles.pane_for), and a same-path request focuses the media pane already holding it (two would double the sound and the decode work) - the viewer’s dedupe rule, not a new policy. The pane accepts no routed content; its transport state is reported through status_document() (see the pane status contract).

  • The preview path has its own cap, and it is the same number: GptyMarkdown.render cuts its input at MAX_MARKDOWN_BYTES, which is TextRead.DEFAULT_MAX_BYTES and gpty_core::vault::MAX_NOTE_BYTES (1 MiB) - crates/gpty-gdext/src/markdown.rs’s the_input_cap_is_the_read_cap_the_panes_apply reads the GDScript declaration and fails if they ever drift, because a pane that shows a note at its read cap has to render all of it, or its own “showing the first 1 MiB” claim is a lie about a view that stopped sooner. The cut is never silent: the render answer is JSON - {text, truncated, shown, size}, the same shape a read answers (shown/size are bytes of the source) - and MarkdownView is the one place a reader is told, as a banner above the body: its own numbers when the renderer’s cap bit (a pasted buffer, a routed capture, a long model turn), and set_source_notice for a cut the caller made. A pane must pass its read-cap notice that way rather than appending it to the source: a note at the read cap is at the renderer’s cap too, so an appended notice is the first thing the cut would drop - which is exactly the case the notice exists for. The text panes keep their own notice at the end of the text they hold (TextRead.truncation_notice, now a bare sentence the caller places).

  • The Markdown renderer’s form is a rule, not a style: it never emits [ul]/[ol], because Godot lays a list out superlinearly in its item count and a preview pays that on every update - measured on the Godot 4.7.2 binary: 38915 ms to shape 1.7 MiB of two-item lists (146705 ms for a real 1 MiB note) against 819 ms for that same rendered text with the tags stripped, and 0.5–1.3 s for every other construct (headings, quotes, inline code, links, tables). Each item is therefore a • /N. line inside one [indent] per list (a nested list opens its own), which renders identically (screenshot-checked side by side) and keeps the layout linear; the numbering is the renderer’s own, which is also how a list starting at 3 renders 3.. Reintroducing a list tag is a freeze measured in minutes, not a fidelity tweak. Cost model, measured with godot/tests/bench/markdown_bench.gd (and cargo test -p gpty-gdext --lib probe_render_cost for the Rust half alone) in release: a preview update is render 48 / 50 / 101 ms plus shape 261 / 486 / 973 ms for 256 KiB / 512 KiB / 1 MiB - the shape pass is the dominant term, so nothing may make it superlinear again.

  • A live preview renders on settle, never on the streaming throttle: MarkdownView.set_markdown_settled restarts its timer on every call (settle_delay, 0.4 s), so a typing run queues no render at all and one lands when the user stops - set_markdown’s non-restarting throttle stays for streams (Reasoning, Inspector) and render_now for explicit acts (opening a note, a mode switch, a save). The wiki pane’s _on_editor_changed must call the settle form; the throttle there was the original item’s bug (a 75 ms cadence re-rendering and re-shaping a large note under the typist’s fingers).

  • Release bundles carry the pinned FFmpeg runtime: the GDExtension has an $ORIGIN (Linux) / @loader_path (macOS) rpath, so scripts/bundle-media is the one packer - linux/windows copy the shared libraries and an ldd check refuses an unresolved bundle, macos-libs lipos the two per-arch builds into universal dylibs and ad-hoc signs them (the macOS FFmpeg build uses --install-name-dir=@rpath so the GDExtension records relocatable names), and macos-zip injects those pre-signed libraries into the exported .app on the Linux export job. scripts/build uses the same helper. FFmpeg’s COPYING.LGPLv3/COPYING.LGPLv2.1 and components.txt ride in licenses/; the AUR recipe installs the libs and licenses under /usr/lib/gpty and /usr/share/licenses/gpty. When real macOS signing lands, sign after the injection. Never copy a distro FFmpeg into the bundle: the pinned soname is the point.

  • Release bundles name the GUI gpty-gui, never gpty: the CLI owns gpty (its crate bin name, mcp.json’s command, the plugin API’s GPTY_BIN_PATH) and find_gui_binary() auto-spawns a sibling called gpty-gui or gpty-editor. Renaming the export back to gpty shadows the CLI on PATH and breaks auto-spawn. Linux bundles also carry Godot’s generated gpty-gui.sh launcher (clears the screen, sets the title, execs the sibling export). On macOS the .app name is preset-owned (gPTY.app), so discovery looks inside the bundle next to the CLI and under /Applications as well.

  • Godot resolves both the data pack and the GDExtension library against the real executable path (/proc/self/exe), so a bundle binary may be symlinked but not renamed behind a wrapper: renaming gpty-gui without renaming gpty-gui.pck yields Couldn't load project data ... Is the .pck file missing?. godot/export_presets.cfg keeps binary_format/embed_pck=false for Linux (true for Windows), so the pack sits beside the binary.

  • App icon and bundle version are project settings, not packaging details: godot/project.godot’s config/icon (godot/icon.png, cropped square from assets/icon-source.jpg) is what macOS turns into Contents/Resources/icon.icns and what Linux uses as the window icon, while the Windows preset needs the explicit application/icon="res://icon.ico" (Godot 4.7 embeds the PE resources itself - no rcedit). config/version feeds macOS CFBundleShortVersionString/CFBundleVersion, so the release workflow overwrites it from the tag and the committed value stays at the last release. Keep application/app_category single-word: Godot lowercases it verbatim, so a multi-word name becomes an invalid UTI (public.app-category.developer tools).

Agent tool notes

  • A renamed or unmarshaled #[func] breaks GDScript at runtime, not at parse time: the call is syntactically valid and fails only in the pane that exercises it. scripts/check-ffi-surface (run by ci-check) cross-checks every call on a GDExtension handle against the exported surface - name and arity. A #[func] is attributed to the class its enclosing #[godot_api] impl block names (primary or #[godot_api(secondary)], which is how one class is implemented across files), and a #[func] outside such a block is reported - the old rule took the first struct in the file, so a class split across files would have quietly lost every moved method from the checked surface. It is fail-closed: a Godot-inherited member it does not know about (has_method is allowlisted) is reported, so add it to INHERITED_MEMBERS deliberately rather than widening the check. The same walk reads the crate’s own prose - crates/gpty-gdext/README.md and its /// comments - where a backticked name(...) must be an export, a plain fn in the crate, or a reasoned DOC_ALLOWED_NAMES entry: the reference is what a caller reads before writing the first call, and a name that exists nowhere (three prose mentions of get_grid_rows(), a method no version of the class ever had) costs the same round trip as a renamed export.
  • gdext #[func] parameter types: ONLY GString, bool, and i64 are reliably marshaled as input parameters. Array<Variant>, Dictionary, and bare Variant all silently fail - the GDScript call succeeds but the Rust body never executes. Workaround: serialize complex data to JSON in GDScript (JSON.stringify()), pass as GString, deserialize in Rust with serde_json::from_str.
  • Concept push startup race: GDExtension classes aren’t registered when autoloads initialize, so ConceptManager._on_init() defers its push via call_deferred("_push_to_rust"), and workspace.gd repeats it after await create_timer(2.0) as the fallback. There is no FFI object to own: set_global_concepts/get_global_concepts are static (the engine’s concept set is process-wide), so the push is a plain GptyTerminal.set_global_concepts(...) guarded by ClassDB.class_exists - the old ClassDB.instantiate("GptyTerminal") vehicle leaked one ObjectDB entry per push, and a GptyTerminal.new() during autoload init would have been a zombie whose #[func] methods silently no-op. ConceptManager is also the single subscriber to its own concepts_changed (the store owns the push; a per-workspace connect both duplicated the push per workspace and collided across Workspace instances with Godot’s “signal is already connected”), and _push_to_rust is the one entry point - the copy in workspace.gd that scanned tiles only to borrow a terminal’s instance is gone.
  • GDScript /// comments: GDScript uses # or ## for comments. Rust-style /// causes a parse error. Always use ## for doc comments in GDScript.
  • Edit tool on structured formats (YAML, TOML, Markdown frontmatter): the line-based edit tool can corrupt delimiter-sensitive files (YAML --- blocks, TOML [sections], frontmatter bounds). When editing config files, workflow YAML, or Hugo content, prefer eval with Python (yaml.safe_load, tomllib) to parse → modify → serialize. Reserve edit for Rust, GDScript, and plain Markdown where line semantics hold.
  • gpty --json prints the JSON-RPC envelope on stdout and exits 1 when the response carries an error: format_response/check_error print first, then fail, so stdout stays parseable and the exit status is the success signal a script branches on (error.data.code carries the stable discriminator). The custom-output commands (list-panes, layout list, the all-panes pane-status) call check_error after their own output rather than re-deciding the rule. The MCP server is separate: it builds its own in-band JSON-RPC errors.
  • CLI gpty version is local-only: it prints the CLI’s own crate version and exits without touching IPC. It cannot probe a running GUI. Use gpty daemon status or list-panes to test connectivity/auth.
  • Font-size changes now auto-recalculate cell metrics via a setter on font_size - no need to recreate terminals.
  • The global tokio runtime is initialized once at GDExtension init and shared across all GptyTerminal nodes.
  • CLI auto-spawn policy: only new-pane and layout load may start a GUI, and --no-daemon refuses even those (main.rs::may_autospawn, exhaustive so a new subcommand must choose a side). Every other command reaches the GUI through its own call and, when nothing is listening, prints no gpty GUI is running (start one with \gpty daemon start`)and exits 1 - the classification isdaemon::is_not_runningoverClientError::Connection(NotFound | ConnectionRefused)in the error chain, so a refused insecure path keeps its own message.daemon startis the explicit spawner (it refuses under–no-daemon), daemon stopwith nothing running is an idempotent no-op, anddaemon statuscan never create what it checks. The MCP server never auto-spawns - itsdaemon-start` tool is the bootstrap, and a tool call that finds no GUI answers with the same hint plus “call the daemon-start tool first”. A GUI that cannot start reports its own stderr (captured to a private temp file, removed once it answers) instead of waiting out the timeout with the reason discarded; a spawn happens only when the probe says the endpoint is genuinely empty (a listener that does not answer is waited out, never duplicated).
  • CLI runtime shape: crates/gpty-cli/src/main.rs runs on #[tokio::main(flavor = "current_thread")]. Every command is one request in a process that exits as soon as it has an answer, and nothing in the CLI or gpty-ipc uses spawn_blocking, block_in_place or tokio::process, so the multi-thread flavor’s workers bought no parallelism - they were the only concurrency around process::exit, the plausible shape behind the single Windows STATUS_HEAP_CORRUPTION (0xC0000374) the smoke saw on an inject that printed nothing (v0.7.0). A worker pool adds startup cost (measured on Linux: 300 gpty version runs went from 1.04 s to 0.60 s) and an exit race to a one-request CLI; do not switch it back without evidence. The smoke’s CLI teardown stress step (100 throwaway pane-status processes ≈ 200 connections) is the amplifier if it ever needs reproducing.