Skip to content
v0.5.4 - 2026-09-12

v0.5.4 - 2026-09-12

Release on GitHub

Added

  • Visual Concept Graph. Settings → Concepts → “Open Visual Editor” opens a full-window GraphEdit overlay that authors rules as chains: trigger → condition* → action (capture & route, or notify-only). A port holds one wire, so a rule can neither fork nor merge. ConceptGraphModel (pure, unit-tested) builds the canvas from the merged concepts and compiles it back - the compiled key set is closed (entry_for_path), so a node cannot smuggle a legacy cmd back in - writes a rule equal to its shipped default as no user entry (so defaults keep tracking updates), tombstones a deleted shipped rule exactly like the manual dialog, and keeps content-invalid rules as drafts with an error instead of compiling them. Layout lives in the graph sibling key of user://concepts.json (sanitized positions plus draft nodes), so concepts stays the single content store and a manual-dialog edit cannot drift. Verified with 23 model unit tests, 9 engine condition tests, 423/423 GUT and a live smoke against the real GUI + GDExtension (open → splice a condition through the connection handlers → save → only the edited rule written → engine readback; 4/4 edges rendered as connections, zero auto-layout overlaps); that pass caught two defects, a rebuild that removed GraphEdit’s internal children (connections_layer is missing) and auto-layout spacing that let the action overlap a condition.
  • A rule’s rank on the canvas is its precedence. ConceptGraphModel.rule_order derives the order from the triggers’ positions (top-to-bottom, then left-to-right, then by name) and layout() writes it as the graph block’s order - rule names only, no content. ConceptManager._merge_concepts puts the named rules first, in that order, and everything the canvas does not name after them, so a newly shipped default can never quietly outrank an arrangement the user made; a store without an order key keeps the shipped merge order exactly (defaults first, then user entries in file order), so nothing changes for anyone who never opened the editor. Each trigger’s title shows its rank (1. Trigger - name), refreshed on rebuild, on revalidate and during a drag, and the editor’s “Fit” re-rows with tidy_positions instead of Godot’s arrange_nodes(), which laid nodes out by connection shape and would have silently reshuffled precedence.
  • Concept conditions. The concept vocabulary gains conditions: [regex, …] - ANDed with the trigger on the same line, capped at 8 × 1024 bytes; a malformed condition rejects the whole concept rather than being dropped, because dropping one would widen matching. GptyTerminal.validate_regex() exposes the engine’s dialect so the editor cannot author a regex GDScript’s PCRE2 accepts but the Rust engine silently discards.
  • gpty state <value> declares a pane’s agent state. A tier-2 gpty_state OSC cannot survive ConPTY - a sequence its VT engine does not implement never reaches the pane (measured: a DECSET 2004 from the same fixture arrives while every declaration fails) - so the declaration path is the CLI now: it reads the credentials a pane injects (GPTY_EVENT_SOCKET, GPTY_TERMINAL_SESSION_ID, GPTY_EVENT_CAPABILITY, the only credential a pane’s child holds) and submits gpty.state.declared on the event socket, capability-gated instead of spoofable and display-only like the OSC. seq became optional for a producer born per declaration, the new event is allowlisted (state only, with an unknown value refused as -32602 before the session lookup), and it is deliberately not an MCP tool. Verified live through the pane-API smoke (<cli> state needs-attention → paneStatus reports that state at tier 1); it also runs in the Windows smoke job, where it is the only program-facing declaration path.
  • Bracketed paste. The grid now reports DECSET 2004 (TermGrid::bracketed_paste, exposed as is_bracketed_paste()) and TerminalPane.build_paste_payload wraps a paste in ESC[200~/ESC[201~ when the child asked for it, keeping the bytes untouched. When it did not, the guard runs the other way: C0 controls and DEL are dropped - escape sequences above all, and a bare CR, which the PTY turns into Enter - while newlines and tabs survive, because a multi-line paste into a plain shell is a normal thing to want. Verified live: is_bracketed_paste() follows a real child’s own output, and test_paste_payload.gd drives the real Ctrl+Shift+V branch through a stub-FFI probe (9 cases).
  • Scrollback footprint, on screen. Settings → Terminal carries a read-only “On disk:” row showing what scrollback costs, with the store’s path as its tooltip, fed by a new GptyTerminal.history_store_stats() → gpty_core::history::store_size_on_disk() that sums the SQLite file and the -wal/-shm siblings beside it (measured live: 56 938 496 + 32 768 + 4 140 632 = 61 111 896 bytes). The row refreshes on NOTIFICATION_VISIBILITY_CHANGED, because the panel outlives a visit and scrollback keeps growing.
  • Core warnings reach Godot’s console. diagnostics.rs installs a log logger at InitStage::Core that forwards warn-and-above records whose target starts with gpty to godot_warn!/godot_error!; until now nothing installed a logger, so every core log::warn!/log::error! (a failed history append, a PTY read error, a poisoned lock, a refused capture) was dropped in the GUI and only the CLI saw them. Dependency targets (alacritty_terminal, rusqlite, portable-pty) and info/debug stay out, which is what keeps the console readable.
  • The code viewer and file tree are gated. Reads go through a type check and a cap: FileAccess.file_exists is false for directories, /dev/zero, FIFOs and symlinks to FIFOs (and get_size is -1 for anything non-regular), so non-regular paths are refused before open, and the read is capped at MAX_FILE_BYTES (1 MiB) with a visible truncation notice and a hand-rolled UTF-8 cut, so a sequence torn by the cap is dropped instead of rendered as U+FFFD. The file tree asks for confirmation before OS.shell_open, showing the exact path, and refuses non-absolute and non-regular paths - only an absolute local path becomes the file:// URI the sink constructs itself, so a downloaded .desktop file cannot be run by the mime handler.
  • Release bundles ship the CLI beside the GUI. Each platform job now builds the CLI and the export job stages it into the bundle; the export is renamed gpty-gui (gpty-gui.exe on Windows; the macOS preset keeps gPTY.app) while the CLI owns the name gpty, so both are on PATH and auto-spawn finds a sibling named gpty-gui or gpty-editor - an extracted bundle starts its own GUI on demand, and a release build reaches its socket in ~0.4 s, inside the CLI’s 5 s default timeout. The macOS CLI is lipo’d universal to match the app export, and a discovered candidate must be a private regular file. Every bundle also carries the app icon now (godot/icon.png for Linux and res://icon.png, godot/icon.ico for Windows, .icns on macOS) instead of Godot’s default splash.

Changed

  • gPTY is GPL-3.0-or-later, with section-7 exceptions for plugins and data. LICENSE is the verbatim GPLv3 text and the new LICENSE-EXCEPTIONS.md states the additional permissions: plugins, pane types, extensions and adapters may be licensed under any terms (Apache-2.0, MIT, proprietary), and configuration or data files carry no copyleft - user-authored files are the user’s own, and the shipped godot/*.json defaults are Apache-2.0 - with the as-is / no-warranty / no-vetting terms for both. The core stays copyleft. Every crate manifest and the AUR recipe declare license = "GPL-3.0-or-later" (SPDX cannot express the exception, so LICENSE-EXCEPTIONS.md is the canonical statement), the release archives carry LICENSE and LICENSE-EXCEPTIONS.md next to the binaries (GPLv3 sections 4 and 6), and the docs site renders both, with the Docs workflow rebuilding when either file changes. CONTRIBUTING gains the inbound=outbound statement the GPLv3 lacks.
  • The docs site publishes the security policy. SECURITY.md is mirrored into assets/repo and rendered at /docs/security/, and the shortcode gained link rewrites for [SECURITY.md]/[AGENTS.md], which every page carried as broken relative links before. The policy itself reads the supported version as > 0.5, replaces the Pre-x label with the rule that fixes land on main and ship in the next release (with a backport exception for severe issues), moves the technical-detail list (versions, attacker control, reproduction) to the private channel, retitles the public fallback issue “Security Request” with its no-details rule, and targets a first assessment in ~30 days.
  • Concept matching no longer multiplies with the concept library. The per-concept loop paid each regex’s own prologue on every line (~20 ns per concept), so a 128-concept library spent 2.7 µs on a line that matched nothing. ConceptMatcher now puts one combined RegexSet pass in front of the ordered loop, so the common case (nothing matches) never pays the per-concept multiplier and only a gate hit runs the loop that decides capture-vs-notify; conditions and precedence are unchanged, and matcher_agrees_with_match_line_on_every_pair pins the equivalence across condition-fails, disabled, notify and capture cases. On a flooded pane (1 KiB lines) the measured rate was 79.9 MB/s with no concepts, 79.7 with the shipped set and 5.8 at the parser’s ceiling (128 concepts × 1024-byte trigger × 8 × 1024-byte conditions) before, 81.1 / 73.9 / 73.3 after (12.6× at the ceiling); 30-byte lines went from 8.6 to 44.9 MB/s with 128 typical triggers (5.2×), within ~10 % of no concepts at all. The engine also takes the concept read lock once per output batch instead of once per line, and a poisoned RwLock now follows the Mutex policy (skip the work, report the first occurrence) instead of panicking the pane’s task. crates/gpty-core/examples/concept_probe.rs keeps the measurements.
  • Scrollback commits off the terminal path. TermGrid::store_line called HistoryStore::append per output line while holding the grid mutex, so a flooding pane held the lock the UI renders under for ~30 µs per line and was itself capped at 33 k lines/s (a yes pane moved 1 MB/s), and the store had no byte cap - 10 000 rows of 16 KiB lines is 160 MB of SQLite and FTS index. A pane now owns a PaneHistory: push queues the line with no lock and no SQLite on the terminal path, and a per-pane writer thread commits batches (FLUSH_ROWS = 512, or 200 ms of quiet) and trims. Retention is bounded by rows (history_lines) and bytes (RETAINED_BYTES_PER_ROW = 1 KiB per retained row, min 1 MiB). Measured: the pane’s ceiling went from ~1 MB/s to 31 MB/s of yes output (≈900 k lines/s) with GUI RSS flat at 219 MiB across 12 s of flood; the store alone commits 33 k rows/s per-line → 111 k rows/s in 512-row transactions (84 k with trimming - the FTS5 index write is the floor). Write-behind is visible only as staleness (a search can miss the last 200 ms of output), TerminalPane._exit_tree flushes the tail on a normal quit, and an exit that runs no teardown (SIGKILL) still loses whatever is queued.
  • Reasoning deltas are coalesced, and abandoned event subscriptions are released. _append_live_text/_refresh_turn_layout now use MarkdownView.set_markdown - at most one re-parse per 75 ms per view - instead of render_now, which re-parsed the whole accumulated turn and every other turn for every token; a new rendered signal re-syncs fold heights once the coalesced text lands, and a settling turn still finalizes synchronously. On the event socket, SUBSCRIPTION_IDLE (120 s without an eventsPoll) releases the slot and its queued events on the next subscribe/emit, so the 64-slot cap cannot be exhausted by abandoned clients. The OMP translation boundary is a per-event field allowlist built field-by-field with caps instead of a rename-and-pass-through, so prompts, answers, tool arguments/results or a future extension field cannot ride along (unit-tested with a hostile payload; a live probe showed a tool.finished carrying prompt/tool_args/answer accepted with only the allowlisted is_error reaching the terminal’s tier-1 state).
  • Dividers follow the pointer in pixels. While a divider is dragged, the tiles on the line move their wrappers by the raw pixel delta - the far side grows, the near side shifts - with no tile mutation and no tiles_resized per motion, so nothing reflows until the release. The press snapshots the line’s tiles, their committed offsets, one unit in pixels and the unit travel limits, so MIN_TILE and the grid edges are enforced mid-drag in pixels and the preview can never show a position the commit refuses; the release rounds the previewed travel to whole units, applies it to the tile model and relayouts once. The preview is absolute from the press (backtracking restores the layout), and an edge with no tile on its far side - the window border - no longer starts a phantom drag. Verified with 16 GUT drag tests plus a live run at 29 px/unit: a 40 px drag previewed the divider to 910 px (31.38 units, deliberately off a boundary) with the model untouched, then committed the span 30→31 at 899 px, an 11 px snap within half a unit.
  • Windows is a tested platform now, not an inferred one. The GDScript suite runs there: every test that spawns a pane takes its program from SettingsManager.default_shell_command(), and the two tests that need exact bytes from a child build their command line through the new godot/tests/helpers/shell_fixtures.gd (POSIX printf '%b' with octal escapes, Windows PowerShell [Console]::Write with [char]N). The new windows-smoke job (windows-latest) boots the GUI headless and drives the CLI against it over named pipes using scripts/smoke_pane_api.py - one harness for every platform, since only the transport differs - covering the control-pipe round trip, the named-pipe event listener (subscribe/eventsPoll, spawn and killed events), the pane lifecycle, inject → pane-wait → pane-read, scrollback, broadcast, pane-run exit codes and kill-pane. The @gpty/omp-events extension’s named-pipe path is tested (\\.\pipe\ accepted from the path alone) and its real forwarder is observed live inside a pane, so one assertion spans env injection → transport → capability check → translation → GDScript drain → agent state. scripts/gut-check also learned to accept a pending-only run while failing a [Risky] test (one that ran without asserting), so a Windows-only gap cannot hide a suite that stopped asserting; one test still pends there with its reason in the source, because the fake CLI adapter is a POSIX shell script.
  • Peer credentials are checked wherever the platform has an API, and every arm fails closed. The BSDs share macOS’s getpeereid, illumos/Solaris use getpeerucred + ucred_geteuid, and Windows - which had no check at all - identifies the client with GetNamedPipeClientProcessId, reads that process’s token user SID (TOKEN_USER via GetTokenInformation) and compares it with gPTY’s own through EqualSid; a lookup error fails closed and the accept loop rejects a mismatched pipe client before spawning its handler. The BSD arm is compiled in CI (x86_64-unknown-freebsd is now a check), the illumos and NetBSD arms are compile-probed against real libc targets, the same-UID acceptance path has an explicit test instead of being implied by the round-trip tests, and SECURITY.md lists the three platforms that still answer “match” (AIX, Haiku, QNX - file permissions remain their only gate).
  • The control socket prefers a private state directory over the shared /tmp fallback. The chain now tries the per-user runtime directories as before ($XDG_RUNTIME_DIR, /run/user/<uid> on Linux, $TMPDIR on macOS), then a private $XDG_STATE_HOME/gpty or $HOME/.local/state/gpty - created 0700, or tightened from a lax umask when it is already ours - and only then /tmp/gpty-<uid>.sock, whose predictable path in a world-writable directory is a squatting vector (denial of service only: the socket’s owner and mode are validated before use). fallback_socket_path() is the one place that choice lives, shared by Linux, macOS and the other Unix targets, and the event socket derives from it. The fallback’s pre-creation TOCTOU is closed with it: only a stale socket owned by this user is removed, anything else fails the bind closed and is left exactly as found.
  • Store files are owner-only under an unguessable name. The temp file every user:// store writes through was built at a predictable <store>.tmp, and FileAccess.open(..., WRITE) follows a symlink - measured: a link planted at that name turned a victim file into the store’s JSON. The temp name is now 64 random bits (Crypto.generate_random_bytes), which cannot be pre-planted, and the file is created 0600 before the rename (the rename also replaces the target itself instead of writing through a link at the store path); the non-atomic fallback sets the mode too. The scrollback database and its -wal/-shm siblings - created with the process umask, measured 0644 - are chmodded to 0600 from HistoryStore::open, which also tightens a store written before this change.
  • The release supply chain is pinned and verifiable. The release publishes a SHA256SUMS asset generated with flat names so a line matches the published file, with verification steps in the release body for Linux, macOS and Windows, and fail_on_unmatched_files makes a missing artifact fail the job instead of shipping an incomplete release. Every release asset carries a build-provenance attestation (gh attestation verify <asset> --repo godot-pty/gpty); every third-party action across the three workflows is pinned to a commit SHA, including the two moving refs dtolnay/rust-toolchain@stable and rustsec/audit-check@v2; and CI’s Godot editor/templates and Hugo downloads are checksum-verified, so a swapped artifact fails the job. CI and the release export are pinned to Godot 4.7.2 (the engine the local suite was already exercised against), scripts/build refuses to export when the export templates do not match the editor, and docs/setup.sh validates and JSON-quotes remote release metadata so a malformed API response is skipped with a warning instead of injecting dropdown entries or a javascript: href.
  • The test gates read the shipped code, not a copy of it. The GUT gate is now scripts/gut-check, which owns the GUT command (so run and guard cannot drift) and judges the output instead of the exit code: godot parse/script-error lines fail it, the Run Summary must be present and end in All tests passed!, the reported script count must equal the test_*.gd files the gdirs hold, and every collected script must contribute at least one test. It reproduced a parse error that made GUT break into the debugger, run 4 of 40 scripts, print no Run Summary and exit 0 in 1.3 s - the old step printed PASS off that exit code. The three named seams now have tests that read both sides instead of reimplementing one: MCP tool → IPC method → dispatch, pane targeting by id and legacy label (driven against a real Workspace), and paneRead’s “screen plus scrollback” claim, which now fills a pane with 120 lines (≈5 screens) and requires the oldest marker as well as the newest.

Fixed

  • A pane’s output queue is bounded. pty_rx was an unbounded channel, so a pane whose output outran the terminal task grew the process: measured ~70 MiB/s added to the GUI (194 → 1248 MiB in 15 s) and ~110 MiB/s in a bare gpty-core harness (2.2 GiB after 20 s). pty::OUTPUT_QUEUE_CHUNKS (256 × 4 KiB = 1 MiB per pane) applies backpressure - a full queue stops the reads, the kernel PTY buffer fills and the child’s write blocks - and dropping chunks was never an option because the bytes carry ANSI state. After: GUI RSS flat at 216 MiB across 15 s of yes (delta 0), the harness flat at 30 MiB, the drain rate unchanged (29.5 k → 31.1 k lines/s), and the pane recovers - a 300 000-line seq finished, the prompt returned, kill-pane reaped the child.
  • Input no longer blocks the drain loop. Input and emulator replies went straight to the PTY master, so a child that stopped reading stdin (a flood, a tail -f) filled the kernel’s ~4 KiB input buffer and blocked the task inside write_all - which stopped it draining PTY output, so the pane froze and an abort could not land either. Input now goes to a per-PTY writer thread through an unbounded FIFO, which coalesces whatever is queued before each write and logs a write error instead of returning it. Verified live: 64 KiB into a pane flooding yes returned in 7 ms with the pane’s output still flowing, where the old path froze it.
  • Killing a pane kills its process group. PtyHandle::drop killed only the shell, and the kernel’s SIGHUP on session-leader exit reaches only the foreground group, so a background job under job control or a process that ignores SIGHUP was orphaned until its own writes failed. The drop path now SIGKILLs the child’s process group first (the child is a session leader, so pgid == pid, and the group is not ours), pinned by dropping_a_pane_kills_its_process_group, which starts a SIGHUP-immune grandchild and probes it until ESRCH.
  • A bare program name is judged by the file it resolves to. validate_executable never saw a PATH-resolved name, so a saved tile that also set PATH chose which file the name ran. pty::validate_program resolves a bare name through the PATH the child will actually inherit (the tile’s own shell_env value when set, otherwise the user’s) and holds the first match to the existing rule - regular file, not group/other-writable, owner check, no writable ancestor - and it runs before anything is spawned, so it covers every pane. A name that resolves nowhere is passed through, so the spawn fails exactly as it always did, and an empty PATH entry (which execvp reads as the working directory) is refused instead of handing validate_executable a relative path it would treat as another bare name.
  • Store corruption costs one value, not the rest. A store is user-editable and can be half-written, and a value of the wrong type raised inside the loader (int([1,2]), an Array assigned to an int, .get() on a String, a String passed to a typed parameter), which aborted it at that line: every key after the bad one was dropped, and the next save wrote the reduced state back over the file, making the loss permanent. Every loader now reads through typed accessors on BasePersistenceManager (_as_int/_as_float/_as_bool/_as_string/_as_array/_as_dict) across settings.json, workspaces.json and profiles.json. Two defects fell out of writing the tests: an empty workspace list made clampi(active, 0, size - 1) a (0, -1) range - on exactly the store a fresh install has - and a workspace whose layout is not a list took the whole set down at the typed parameter. Verified live by booting with hand-corrupted stores and confirming the trailing keys survive and the save on quit keeps them.
  • Platform shell defaults. The first-run shell default was /bin/bash everywhere, which validate_executable refuses on Windows (not absolute there), so every pane of a fresh Windows install failed to start; the Terminal tab’s “Reset tab to defaults”, the shell field and its placeholder and TerminalPane’s shell export hard-coded it too, so a reset left every later pane with no terminal. All of them now use SettingsManager.default_shell_command() (%COMSPEC%, falling back to cmd.exe), and PaneTypes.shell_run_args() picks /c for the cmd family and -c otherwise instead of always passing -c, which cmd.exe rejects.
  • A pane’s exit is reported even when the pty stays open. The task only learned of an exit from the pty read side closing, and ConPTY keeps that pipe open after the child exits, so the Windows smoke’s pane-run step sat at running: true, exit_code: null for 15 s. The task now also polls the child every 200 ms and, once it has exited, drains until the pty has been silent for 500 ms before ending - output re-arms the window, so ending early cannot truncate what the pty still held. The case is reachable on Linux too (a SIGHUP-immune descendant holding the pty), which is what the new test uses: it fails after a 10 s timeout without the child poll, and passes in 0.63 s with it.
  • daemon stop answers, and quitting no longer crashes. The handler called process::exit(0) before the reply was written, so the CLI and the MCP tool reported invalid response: empty response and exit 1; it now answers {"shutting_down": true} and quits through the scene tree, which also runs the teardown process::exit skipped - verified 3/3 with settings.json and workspaces.json written and the pane’s last output line in history.db. The crash behind it was found under gdb: Godot unloads the GDExtension at exit while this extension owns threads that live in it (tokio workers, one PTY reader and one history writer per pane), and a worker still executing in the unmapped mapping faulted on a freed code page. pin_library() now pins the module at InitStage::Core - RTLD_NODELETE on Unix, GetModuleHandleExW with GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS on Windows, which carried the same class of risk at exit.
  • gpty mcp bounds its input. lines() buffered a request of any length; a line over 65536 bytes is now refused with -32600 “Request exceeds 65536 bytes”, the remainder of that line is discarded so the next request starts at a message boundary, and the stream keeps working.
  • Pane activation and the IPC vocabulary. focusPane called grab_focus() unconditionally, so focusing a read-only pane raised an engine error and activated nothing while still answering success: true; the activation rule (record last_body in the body’s own workspace, take keyboard focus for a terminal, release it otherwise) is now Workspace.activate_pane(), shared by the click path, the sidebar row and IPC. The same seam test found paneWait registered but absent from the dispatcher (it is handled by the workspace’s per-frame loop, which the test now models) and dead GDScript arms for version/shutdown, which the Rust server answers locally.
  • The code viewer’s comment toggle. CodeEdit.add_comment_string does not exist on Godot 4, so the call raised a script error that aborted load_file before _refresh_view() for .rs/.gd/.py/.c files, leaving the pane on the previous file’s view; it is add_comment_delimiter now, with a regression assertion on the view toggle.
  • A pane id stays unique when the pane settings rename it. Every pane enters a workspace through _attach_pane_into, which runs _ensure_unique_attachment_id, but the pane settings apply runs after that: typing an id another pane already had (or restoring it from a file) left two panes sharing a public id, and id-targeted IPC then resolved to whichever the search hit first. The panel now announces settings_applied(body) and the workspace re-checks there, redrawing the pane list because it shows the id.
  • The v0.5.3 macOS bundle’s metadata. CFBundleShortVersionString/CFBundleVersion reported the stale config/version (0.1.0) rather than the release, and the category was Godot’s Games default; the release workflow now stamps config/version from the tag before importing the project, and application/app_category is Utilities (public.app-category.utilities).