v0.5.4 - 2026-09-12
Added
- Visual Concept Graph. Settings → Concepts → “Open Visual Editor” opens a full-window
GraphEditoverlay 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 legacycmdback 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 thegraphsibling key ofuser://concepts.json(sanitized positions plus draft nodes), soconceptsstays 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_orderderives the order from the triggers’ positions (top-to-bottom, then left-to-right, then by name) andlayout()writes it as the graph block’sorder- rule names only, no content.ConceptManager._merge_conceptsputs 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 anorderkey 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 withtidy_positionsinstead of Godot’sarrange_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-2gpty_stateOSC 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 submitsgpty.state.declaredon the event socket, capability-gated instead of spoofable and display-only like the OSC.seqbecame optional for a producer born per declaration, the new event is allowlisted (stateonly, 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→paneStatusreports 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 asis_bracketed_paste()) andTerminalPane.build_paste_payloadwraps a paste inESC[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, andtest_paste_payload.gddrives 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/-shmsiblings beside it (measured live: 56 938 496 + 32 768 + 4 140 632 = 61 111 896 bytes). The row refreshes onNOTIFICATION_VISIBILITY_CHANGED, because the panel outlives a visit and scrollback keeps growing. - Core warnings reach Godot’s console.
diagnostics.rsinstalls aloglogger atInitStage::Corethat forwardswarn-and-above records whose target starts withgptytogodot_warn!/godot_error!; until now nothing installed a logger, so every corelog::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) andinfo/debugstay 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_existsis false for directories,/dev/zero, FIFOs and symlinks to FIFOs (andget_sizeis -1 for anything non-regular), so non-regular paths are refused beforeopen, and the read is capped atMAX_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 beforeOS.shell_open, showing the exact path, and refuses non-absolute and non-regular paths - only an absolute local path becomes thefile://URI the sink constructs itself, so a downloaded.desktopfile 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.exeon Windows; the macOS preset keepsgPTY.app) while the CLI owns the namegpty, so both are on PATH and auto-spawn finds a sibling namedgpty-guiorgpty-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.pngfor Linux andres://icon.png,godot/icon.icofor Windows,.icnson macOS) instead of Godot’s default splash.
Changed
- gPTY is GPL-3.0-or-later, with section-7 exceptions for plugins and data.
LICENSEis the verbatim GPLv3 text and the newLICENSE-EXCEPTIONS.mdstates 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 shippedgodot/*.jsondefaults 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 declarelicense = "GPL-3.0-or-later"(SPDX cannot express the exception, soLICENSE-EXCEPTIONS.mdis the canonical statement), the release archives carryLICENSEandLICENSE-EXCEPTIONS.mdnext 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/repoand 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 onmainand 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.
ConceptMatchernow puts one combinedRegexSetpass 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, andmatcher_agrees_with_match_line_on_every_pairpins 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 poisonedRwLocknow follows theMutexpolicy (skip the work, report the first occurrence) instead of panicking the pane’s task.crates/gpty-core/examples/concept_probe.rskeeps the measurements. - Scrollback commits off the terminal path.
TermGrid::store_linecalledHistoryStore::appendper 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 (ayespane 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 aPaneHistory:pushqueues 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 ofyesoutput (≈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_treeflushes 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_layoutnow useMarkdownView.set_markdown- at most one re-parse per 75 ms per view - instead ofrender_now, which re-parsed the whole accumulated turn and every other turn for every token; a newrenderedsignal 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 aneventsPoll) 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 atool.finishedcarryingprompt/tool_args/answeraccepted with only the allowlistedis_errorreaching 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_resizedper 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, soMIN_TILEand 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 newgodot/tests/helpers/shell_fixtures.gd(POSIXprintf '%b'with octal escapes, Windows PowerShell[Console]::Writewith[char]N). The newwindows-smokejob (windows-latest) boots the GUI headless and drives the CLI against it over named pipes usingscripts/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-runexit codes and kill-pane. The@gpty/omp-eventsextension’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-checkalso 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 usegetpeerucred+ucred_geteuid, and Windows - which had no check at all - identifies the client withGetNamedPipeClientProcessId, reads that process’s token user SID (TOKEN_USERviaGetTokenInformation) and compares it with gPTY’s own throughEqualSid; 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-freebsdis 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
/tmpfallback. The chain now tries the per-user runtime directories as before ($XDG_RUNTIME_DIR,/run/user/<uid>on Linux,$TMPDIRon macOS), then a private$XDG_STATE_HOME/gptyor$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, andFileAccess.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/-shmsiblings - created with the process umask, measured 0644 - are chmodded to 0600 fromHistoryStore::open, which also tightens a store written before this change. - The release supply chain is pinned and verifiable. The release publishes a
SHA256SUMSasset generated with flat names so a line matches the published file, with verification steps in the release body for Linux, macOS and Windows, andfail_on_unmatched_filesmakes 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 refsdtolnay/rust-toolchain@stableandrustsec/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/buildrefuses to export when the export templates do not match the editor, anddocs/setup.shvalidates and JSON-quotes remote release metadata so a malformed API response is skipped with a warning instead of injecting dropdown entries or ajavascript: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 inAll tests passed!, the reported script count must equal thetest_*.gdfiles 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 realWorkspace), andpaneRead’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_rxwas 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 baregpty-coreharness (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’swriteblocks - and dropping chunks was never an option because the bytes carry ANSI state. After: GUI RSS flat at 216 MiB across 15 s ofyes(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-lineseqfinished, the prompt returned,kill-panereaped 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 insidewrite_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 floodingyesreturned in 7 ms with the pane’s output still flowing, where the old path froze it. - Killing a pane kills its process group.
PtyHandle::dropkilled 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 bydropping_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_executablenever saw a PATH-resolved name, so a saved tile that also setPATHchose which file the name ran.pty::validate_programresolves a bare name through thePATHthe child will actually inherit (the tile’s ownshell_envvalue 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 emptyPATHentry (whichexecvpreads as the working directory) is refused instead of handingvalidate_executablea 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 anint,.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 onBasePersistenceManager(_as_int/_as_float/_as_bool/_as_string/_as_array/_as_dict) acrosssettings.json,workspaces.jsonandprofiles.json. Two defects fell out of writing the tests: an empty workspace list madeclampi(active, 0, size - 1)a (0, -1) range - on exactly the store a fresh install has - and a workspace whoselayoutis 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/basheverywhere, whichvalidate_executablerefuses 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 andTerminalPane’s shell export hard-coded it too, so a reset left every later pane with no terminal. All of them now useSettingsManager.default_shell_command()(%COMSPEC%, falling back tocmd.exe), andPaneTypes.shell_run_args()picks/cfor the cmd family and-cotherwise instead of always passing-c, whichcmd.exerejects. - 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-runstep sat atrunning: true, exit_code: nullfor 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 stopanswers, and quitting no longer crashes. The handler calledprocess::exit(0)before the reply was written, so the CLI and the MCP tool reportedinvalid response: empty responseand exit 1; it now answers{"shutting_down": true}and quits through the scene tree, which also runs the teardownprocess::exitskipped - verified 3/3 withsettings.jsonandworkspaces.jsonwritten and the pane’s last output line inhistory.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 atInitStage::Core-RTLD_NODELETEon Unix,GetModuleHandleExWwithGET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESSon Windows, which carried the same class of risk at exit.gpty mcpbounds 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.
focusPanecalledgrab_focus()unconditionally, so focusing a read-only pane raised an engine error and activated nothing while still answeringsuccess: true; the activation rule (recordlast_bodyin the body’s own workspace, take keyboard focus for a terminal, release it otherwise) is nowWorkspace.activate_pane(), shared by the click path, the sidebar row and IPC. The same seam test foundpaneWaitregistered but absent from the dispatcher (it is handled by the workspace’s per-frame loop, which the test now models) and dead GDScript arms forversion/shutdown, which the Rust server answers locally. - The code viewer’s comment toggle.
CodeEdit.add_comment_stringdoes not exist on Godot 4, so the call raised a script error that abortedload_filebefore_refresh_view()for.rs/.gd/.py/.cfiles, leaving the pane on the previous file’s view; it isadd_comment_delimiternow, 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 announcessettings_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/CFBundleVersionreported the staleconfig/version(0.1.0) rather than the release, and the category was Godot’sGamesdefault; the release workflow now stampsconfig/versionfrom the tag before importing the project, andapplication/app_categoryisUtilities(public.app-category.utilities).