Skip to content
v0.5 - ADE Foundation

v0.5 - ADE Foundation

This chain turned gPTY from a multi-terminal emulator into a graphical PTY foundation with a public, agent-facing API. v0.5.0 set the architecture boundary and the pane read/status/run/wait API; v0.5.1 added SQLite-backed persistence; v0.5.2 agent state and adapters; v0.5.3 terminal performance, mouse reporting and Workspace Trust; v0.5.4 the visual concept graph and a large hardening pass; v0.5.5 the plugin ecosystem; and v0.5.6 CLI semantics and single-instance behaviour. Everything in this chain shipped.

v0.5.0 - ADE Foundation

Done Area Item
✅ PROJECT Rebranding & positioning - README, docs landing, and CLI copy reposition gPTY as an ADE (“graphical ADE: a PTY foundation with a public API”). De-OMP the shipped defaults: “OMP Workspace” profile renamed to “Agent Workspace”; @gpty/omp-events remains the first adapter, not the identity. Name stays gpty (positioning, not renaming); display name is gPTY. Same commit: updated every “OMP Workspace” reference in AGENTS.md (structure comment + Inspector/Reasoning section) and the docs site.
✅ AGENT Ecosystem presets - shipped profiles for herdr, lazygit, nvim, claude, and OMP in profiles.default.json, backed by per-tile command support in profile restore (command > legacy shell > default, all through sanitize_shell).
✅ IPC Stable public pane IDs - every pane auto-generates a persisted attachment_id (pane-XXXXXXXX via PaneTypes.generate_attachment_id(), PaneBody.apply_settings choke point); newPane returns it and listPanes reports id alongside the display label. AGENTS.md attachment-id bullet documents the public-id semantics.
✅ IPC Pane env markers - GPTY_ENV=1 + GPTY_PANE_ID injected as trusted runtime vars at spawn (start_shell pane_id param; GPTY_PANE_ID falls back to the per-PTY session id); both keys blocked from untrusted env. AGENTS.md env-sanitization bullet updated with the new trusted vars.
✅ IPC Pane read/status/run/wait IPC - paneRead (plain text from grid + scrollback, capped), paneStatus (pid / running / exit_code / idle_ms primitives), paneRun (command spawn; exit via paneStatus), waitForOutput (paneWait: server-held response, Rust regex scan of a 512-line ring buffer, 60 s deadline). Substrate primitives only - no agent state machine in core.
✅ IPC Event subscription - subscribe / eventsPoll on the event socket (never the control socket); bounded per-subscriber queues fed by GptyTerminal.emit_event from concept matches and pane spawn/kill. Push transport deferred.
✅ AGENT Agent skill - ship skills/gpty/SKILL.md + gpty --skill printing the release-matched copy (include_str!), with a GPTY_ENV=1 guardrail; install locations documented for Claude Code, codex, opencode, and OMP. MCP schema verified free of a skill tool.
✅ IPC IPC version sourcing - the IPC version response now comes from the crate via the static GptyTerminal.get_app_version() (env!("CARGO_PKG_VERSION"), crates/gpty-gdext/src/lib.rs); workspace.gd no longer hardcodes “0.3.0”.
✅ IPC MCP expansion - pane-read, pane-status (no-arg = agent-status-list), pane-run, pane-wait, broadcast (tagged fan-out) auto-generated from the new CLI commands; pane tags persist and sanitize like attachment_id. AGENTS.md MCP list updated (14 → 19).
✅ DOCS ADE boundary & security rules in AGENTS.md - layer model, non-goals, and OSC/plugin/broadcast constraints (the “why not” record).

v0.5.1 - Persistence

Done Area Item
✅ CORE SQLite + FTS5 history backend - wire the existing HistoryStore (SQLite + FTS5, tested but unused in production) into pane lifecycle and session restore. Scrollback is currently lost on restart; this makes it persistent and full-text searchable, and backs paneRead across restarts.
✅ GUI Workspace switching - switch between independent pane sets within the same window (sidebar Workspaces section, keep-alive PTYs). Each workspace has its own layout, profile, and scrollback. Deferred from v0.3.0; prerequisite for the daemon-era workspace model.
✅ TERM Scrollback restore on restart - reload persisted history lines by attachment_id when a pane reopens, so scrollback survives restarts (builds on the v0.5.0 stable public pane IDs).
✅ TERM History full-text search - surface the FTS5 store’s search() in the terminal search UI so old output stays findable after restart.
✅ TERM History retention setting - cfg_history_lines (default 10 000) clamping the per-pane cap.
✅ BUILD Live pane-API smoke - exercise the v0.5.0 surface end-to-end against a running GUI: new-pane → inject → pane-read → pane-wait → broadcast over tagged panes, plus pane-status exit codes from pane-run (Unit/GUT covered, but not smoke-tested live).
✅ BUILD Event-socket smoke - extend the live smoke to cover subscribe/eventsPoll on gpty-events.sock (pane spawn/kill events). The control-socket pane API is smoke-covered; the event socket is not.
✅ BUILD Palette test dedup - test_palette.gd now asserts against PaneTypes.build_palette_commands(); the command list lives in one place.

v0.5.2 - Agent State & Adapters

Done Area Item
✅ AGENT AgentState model - AgentState enum (Idle / Working / NeedsAttention / Completed / Failed) in gpty-core, with tiered detection: Tier 1 capability-authenticated events (authoritative), Tier 2 OSC state declaration (published standard; AGENTS.md constraints), Tier 3 regex/idle/exit heuristics (display-only). No ToolRunning-via-exit-code - foreground-command exits are not reliably attributable in a PTY.
✅ GUI Titlebar state badges - Phosphor status badges on pane titlebars in terminal_pane.gd (pulsing amber = NeedsAttention, spinner = Working, check = Completed, warning = Failed). Display only; badge state never feeds decisions.
✅ IPC Generic event vocabulary - adapter-neutral event names (agent.started, turn.started, tool.call, thinking.delta) mapped from the OMP extension’s allowlist at the event-socket boundary; Reasoning and Tier 1 agent-state detection consume the generic contract. Same commit: update the AGENTS.md OMP-only data-flow diagram.
✅ IPC Windows event listener - named-pipe event listener closes the Unix-only gap (omp_events.rs); Reasoning stops being fail-closed on Windows.
✅ AGENT Generic CLI backend - CliBackend in gpty-ai (subprocess NDJSON bridge) beside Mock/Omp, with a backend/model picker in Inspector pane settings. Adapters use only each CLI’s documented hooks - never tokens, never TUI scraping.
✅ GUI In-app update checker - checks GitHub releases on startup and toasts when an update is available. Fixed the placeholder repo owner, sourced the current version from GptyTerminal.get_app_version(), gated on OS.has_feature("editor") (headless/test-safe), added the check_updates setting, and covered _is_newer with GUT tests. (Deferred from v0.5.0.)
✅ BUILD App version & build info - Settings gains an About tab showing gpty v<get_app_version()>, the pinned IPC protocol version, and the repo URL; the status bar shows the live version as the rightmost entry. (Deferred from v0.5.0.)

v0.5.3 - Terminal Performance & Mouse

Done Area Item
✅ TERM Render batching - the text pass issues one draw_string per run of consecutive cells sharing font, color, and underline state instead of one per non-space cell (backgrounds were already merged). A run stops at a space, so an underlined space keeps its own segment - the old pass underlined every attributed cell, spaces included, and a first cut of the batching silently dropped that segment until a pixel diff caught it. Measured with the new godot/tests/bench/draw_bench.gd harness (forced full repaint vs idle frame, X11 + --disable-vsync): 90×30 1813 µs → 1388 µs, 200×60 4482 µs → 3867 µs (idle frame ≈ 300 µs, so ~20 % off the repaint at a realistic pane size), and both sizes render pixel-identical to the per-cell pass. Baseline from the v0.5.3 prep work: idle panes no longer repaint at all (the repaint is gated on the grid generation - measured 48–49 gate ticks/s per pane against 0 generation changes), so this item is only about cost under load.
✅ TERM UI Thread DoS mitigation - a pane whose grid work (fetch + repaint) exceeds PANE_WORK_BUDGET_MS (4 ms, a quarter of a 60 Hz frame) now looks at the grid less often: the cadence stretches geometrically to MAX_SYNC_INTERVAL (0.1 s ≈ 10 Hz) and walks back as soon as a sync finds nothing new, so nothing is lost - the damage tracker coalesces everything into the newest grid state, and a flooding pane is unreadable at 60 Hz anyway. View changes the user drives (wheel, PageUp/Down, search jump) are exempt for 500 ms, so scrolling a large pane is never pushed into the flood cadence. Measured with BENCH_FLOOD=1 in godot/tests/bench/draw_bench.gd (a pane repainting its whole screen in a loop, median of 3 runs): 200×60 costs 2.47 ms/frame → 1.46 ms/frame (≈400 → ≈690 fps in a 2100×1300 window), while a pane that fits its budget (90×30, ~1.5 ms/frame) is not throttled at all. 5 GUT cases pin growth, the budget floor, the exemption, the ceiling, and recovery. (Deferred from v0.5.0.)
✅ TERM Terminal mouse reporting - TermGrid::mouse_mode() exposes alacritty’s DECSET 1000/1002/1003 tracking bits plus 1006 (SGR) as a bitmask over the FFI (get_mouse_mode), and the pane forwards only what the enabled mode covers: clicks always, drags under 1002, buttonless motion under 1003, wheels whenever the app tracks at all. Reports are SGR when 1006 is on and legacy X10 otherwise, encoded from the same cell mapping selection uses, and the report is dropped (never clamped) past cell 223 where the legacy form cannot address it. Selection and scrollback are untouched while the bits are zero, and Shift bypasses reporting so text selection stays reachable in an app that grabbed the mouse (the manual checklist covers nvim/lazygit/htop). Verified end to end: a real child that enables 1000+1006 receives a synthesized click at cell (5,3) and its own echo of ESC[<0;5;3M lands in the grid; 12 GUT cases cover routing and encoding, plus a grid test for the mode bits.
✅ SEC Concept command execution removed - a concept definition was data that could act: a regex trigger over untrusted terminal output drove a write_line into a target pane (event path) and a Ctrl+click handler ran the first matching template in the pane itself. Both paths are gone (command_template, substitute_template, shell_quote, matching_commands, match_concepts_on_line, _check_click_concept), legacy cmd keys are ignored on parse and stripped on save, and the settings dialog no longer collects one. Shipped concepts were already capture-only; user-authored ones now can’t be anything else.
✅ SEC Workspace Trust covers everything that executes - the gate examined only the legacy shell key while restore consumed command > shell and passed shell_args/shell_env through untouched, so a downloaded profile could run an arbitrary command (["-c", "curl … | sh"]) or a PROMPT_COMMAND payload with no prompt. PaneTypes.tile_spawns_untrusted now checks program, argv, and environment (compared against the user’s own defaults), and the sidebar profile-activation and workspace-restore paths use it. Correction (second pass): the layoutLoad IPC handler calls _do_activate directly, so CLI/MCP-driven loads bypass the gate entirely, and the env clause turned out to gate a path that cannot execute - _restore_into applies tile settings before apply_to_terminal, which overwrites shell_env with the user’s global, so a restored tile’s env never reaches a child. The program/argv coverage is the part that was live; the IPC hole and the ordering bug are tracked below.
✅ SEC Startup-evaluation env keys blocked - sanitize_envs grew a second class: BASH_ENV, ENV, PROMPT_COMMAND, SHELLOPTS, PS4, ZDOTDIR, PERL5OPT, PERL5LIB, PYTHONSTARTUP, NODE_OPTIONS, RUBYOPT, LESSOPEN, GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF, GIT_PAGER, PAGER. These make a shell or the next tool run what the value contains, so a layout could get code execution without ever naming a command. Users can still set them inside a shell.
✅ TERM Grid geometry ceiling - start_shell/resize_grid clamped dimensions only from below, and PaneTypes.sanitize_tile never bounded a stored rows/cols, so a crafted tile allocated a 10¹²-cell grid at spawn. Both boundaries now clamp (500 × 2000).
✅ SEC Control credentials quarantined from Inspector children - the OMP/adapter child inherited the GUI’s whole environment, including GPTY_SECRET/GPTY_SOCKET (and, for a GUI started inside a pane, that pane’s event capability). SessionOpenRequest::strip_env now carries gpty_core::pty::STRIPPED_INHERITED_ENV_KEYS, applied by both backends.
✅ TERM Live-search result cap - a one-character pattern over 100k scrollback lines materialised every match on every keystroke and walked them each frame; TermGrid::search stops at MAX_SEARCH_RESULTS (10 000).
✅ IPC MCP tool allowlist - tools/call mapped any name to an IPC method, reaching methods that were never published as tools (e.g. shutdown). It now rejects anything outside the advertised schema.
✅ BUILD CI least privilege - every job inherited the repository default token scope; ci.yml now declares permissions: contents: read.
✅ SEC Security policy published - SECURITY.md records the threat model, the reporting process, the hardening that must not be weakened, and the known limitations.
✅ SEC Workspace Trust shows what it will run - PaneTypes.untrusted_plan renders the program, the argv, and each environment: entry it is asking the user to approve (control characters escaped, env lines capped at 8/tile and 24 dialog lines, every env line keeping its label so a value cannot forge a program: line), and _untrusted_details() feeds it into both the profile-activation and workspace-restore dialogs. 4 unit tests cover content, trusted→empty, labelling, escaping, and the caps.
✅ SEC Trust gate on the IPC restore path - ipc_handlers.gd layoutLoad called ws._do_activate(profile) directly while the gate lived in _activate_profile, so gpty layout load <name> and the layout-load MCP tool restore an untrusted profile with no consent step. An untrusted profile is now refused over IPC with an error pointing at the GUI; verified live against a running GUI.
✅ AGENT Inspector prompt fence - gpty-ai/src/prompt.rs wrapped untrusted capture in a literal ``` fence with no escaping and interpolates concept_name/source_pane raw, so a capture containing a fence escapes the data block (omp-only, tool-free backend, so display-level). The fence now outgrows the longest backtick run in the capture (capped at 32) and metadata is flattened to one line.
✅ CONCEPT A notify-only match must not shadow a capture - concept::match_line returned the first match and apply_match starts nothing for SingleLine, so a broad notify-only concept placed ahead of a capture concept silently suppresses that capture. Precedence is now explicit: capture outranks notify, first match wins within a class.
✅ CONCEPT Finalize an in-flight capture when a pane is dropped - SpawnedTerminal::drop called task.abort(), so the documented finalize-on-close path never ran: closing, swapping, or resetting a pane mid-capture silently lost the capture instead of routing it (and a capture the task had already finalized was stranded, because nothing polls a dead pane’s queue). The capture state machine is now shared (Arc<Mutex<..>> behind the same CaptureSession API) so Drop can finalize what the task still held, and both it and any finalized-but-undrained event go to an engine-level orphan queue that GDScript drains each frame and routes through ConceptRouter.route_orphaned_capture (delivery only - the source terminal, its grid, and its raw-byte store are gone). The dead spawn_pty_terminal path is removed with it.
✅ CONCEPT Post-resize suppression while capturing - the post-SIGWINCH window was consulted only in the non-capturing branch, so redraw bytes were buffered into the capture, routed as output, and consumed the 4 MiB budget. An active capture now drops bytes printed inside the window (feeding the frozen grid instead would paint the repaint onto content it never had); output after the window is captured normally. post_resize_window_is_not_buffered_into_a_capture pins both sides of the boundary.
✅ GUI Position-swap popup re-resolves its targets - show_position_swap_popup captured tile indices when it opened, so a pane killed meanwhile (IPC killPane) swapped the wrong pair or indexed past the end with no indication. Rows now carry the pane itself and are resolved by identity against the current tile list at click time; a pane that is gone cancels the swap with a toast. _tile_index_of replaces the three duplicated index lookups, and 2 GUT cases cover the shifted pair and the closed target.
✅ SEC Restored-executable parent-directory check - pty::validate_executable stats the program file only. A binary whose own mode is safe (0755) can still be swapped by another user when it sits in a group/other-writable directory without the sticky bit; /tmp is protected by default, other shared directories are not. Walk the parent chain and reject when any directory is writable by others without +t, then extend the existing test.
✅ BUILD Parallel test job in CI - scripts/ci-check ran only cargo test --test-threads=1, and that serialisation hid a shared-static isolation bug in crates/gpty-gdext/src/ipc.rs until a multi-threaded run surfaced it; step 4.2 now runs the workspace suite with default parallelism as well. The intermittent failures it was left open for turned out to be five distinct causes, all fixed here: (1) a pane’s exit code was probed once when the pty read side closed, and a child that closes its fds a moment before it is reapable answers that probe with None - paneStatus/paneRun then reported no exit code at all (now polled briefly); (2) the OMP session’s liveness probe has the same window, so the next prompt could land in a closed pipe and fail the turn (now re-spawned and re-served); (3) two OMP tests share the process-global GPTY_OMP and clobbered each other under the default parallel runner (now serialised by a test lock - found by the new test added with this work); (4) fake_adapter reused temp-script paths across runs, so a leftover child from a killed run made exec fail with ETXTBSY (paths now carry a run-unique suffix); (5) the pty tests’ wall-clock budgets assumed an idle machine and reported product bugs under load (now a shared wait_until / longer drains). Result: 12/12 clean cargo test --workspace runs on the development machine, where the suite previously failed roughly one run in eight.

v0.5.4 - Visual Concept Graph

Done Area Item
✅ CONCEPT Visual Concept Graph - a full-window GraphEdit overlay (Settings → Concepts → “Open Visual Editor”) authors rules as chains: trigger → condition* → action (capture & route, or notify only); ports hold one wire, so a rule cannot fork or merge. New vocabulary: conditions: [regex, …] - ANDed with the trigger on the same line, capped at 8 × 1024 bytes, and a malformed condition rejects the whole concept (dropping one would widen matching); GptyTerminal.validate_regex() exposes the engine dialect so the editor cannot author a regex GDScript’s PCRE2 accepts but the Rust engine silently discards. ConceptGraphModel (pure, unit-tested) builds the canvas from merged concepts, compiles it back, writes a rule equal to its shipped default as no user entry (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. The graph sibling key in user://concepts.json holds the canvas’s own state only (sanitized positions + draft nodes, plus the rule order the rule-priority item below added), so concepts stays the single content store and a manual-dialog edit cannot drift; the compiled key set is closed (entry_for_path), so a node cannot smuggle a legacy cmd back in. Verified: 9 new engine condition tests, 23 model unit tests (canvas/validation/compile/tombstones/drafts/layout), 394/394 GUT, full ci-check; live smoke against the real GUI + GDExtension (open → splice a condition through the connection handlers → save → only the edited rule written → engine readback holds the condition; 4/4 edges rendered as connections, zero auto-layout overlaps, screenshot inspected). Two defects found and fixed by that live pass: rebuilding the canvas removed GraphEdit’s internal children (connections_layer is missing), and auto-layout spacing let the action overlap a condition.
✅ PROJECT Relicensed to GPL-3.0-or-later - the core is GPLv3 with two section-7 additional permissions covering the parts that must stay permissive (bundled plugins and extensions ship MIT, and the shipped godot/*.json data defaults are Apache-2.0), so third-party plugins, adapters and pane types are not forced to be GPLv3. LICENSE-EXCEPTIONS.md states both permissions, every crate and package declares GPL-3.0-or-later, the release workflow stages both license files into every archive (GPLv3 §4/§6), and the docs site renders the license text and the exceptions. Shipped without a roadmap item - recorded here because the section is the release’s record.
✅ BUILD Bundle layout & build pinning - the release ships the CLI as gpty and the GUI as gpty-gui (with Godot’s generated gpty-gui.sh launcher on Linux, gpty-gui.exe on Windows), so the GUI no longer shadows the CLI on PATH and the CLI can auto-spawn a sibling GUI; every bundle carries the app icon (Godot 4.7 embeds the PE icon itself, application/icon in the Windows preset), and CI and the export preset are pinned to Godot 4.7.2 with a fast-fail check when export templates do not match the editor. Also shipped without a roadmap item.
✅ DOCS Docs site - the security policy is published (threat model, supported versions, reporting), the GPL text and the license exceptions are rendered as pages, and the docs build verifies the mirrored README actually baked into the overview (a wrong mirror fails the deploy instead of shipping silently). Also shipped without a roadmap item.
✅ GUI Pixel-smooth divider drag - shipped the pixel-geometry approach, not the finer-grid one (which still relayouts every step and still snaps the content per step): 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, relayouts once, and the pane’s own RESIZE_DEBOUNCE settles the terminal content. 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: 16 GUT drag tests including the new invariants (motion leaves the tile model untouched, exactly one tiles_resized per drag, the preview clamps at MIN_TILE, backtracking commits nothing, border edges are inert); full ci-check; 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 span 30→31 at 899 px (11 px snap, within half a unit); mid-drag screenshot inspected for gaps/overlap.
✅ GUI Pane file sinks - the code viewer now reads through a type gate and a cap. FileAccess.file_exists is Godot’s regular-file check (measured: false for directories, /dev/zero, FIFOs and even symlinks to FIFOs; true for regular files and symlinks to them) and get_size is -1 for anything non-regular, so non-regular paths are refused before open - the “never-EOF device hang” the first pass asserted is not reachable through this sink (a FIFO open returns null without blocking); the real hazard was the unbounded read, now 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 (scheme allowlist: only an absolute local path becomes the file:// URI the sink constructs itself - a downloaded .desktop file would otherwise be run by the mime handler). Bonus defect found while testing: CodeEdit.add_comment_string does not exist on Godot 4 - 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; now add_comment_delimiter (with a regression assertion on the view toggle). Verified: 7 new GUT tests (full/truncated/torn-UTF-8 loads, directory refusal, tree confirmation, directory expansion without a dialog, non-absolute refusal), full ci-check; live run - a 5 MiB file rendered 1,048,632 chars with the notice, a FIFO path returned in 3 ms and left the pane empty, and the tree’s confirmation dialog was screenshot-verified. (Deferred from v0.5.3.)
✅ BUILD Pin the GDExtension on Windows - pin_library() now has a cfg(windows) branch calling GetModuleHandleExW with GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, naming the module by the address of the function itself (nothing depends on the DLL’s file name) and reporting a failure through the log bridge instead of failing silently; the libc/RTLD_NODELETE branch is unchanged, the call site is unconditional, and a no-op stub covers platforms with neither loader. Raw extern "system" + #[link(name = "kernel32")] rather than a new windows-sys dependency. Verified by compile: cargo check --workspace --target x86_64-pc-windows-msvc under -Dwarnings passes locally through ci-check’s xwin/clang-cl step (the same target and flags as the CI rust-windows job). Runtime is still unverified - the Windows smoke item below is what will exercise the pin at exit.
✅ SEC Supply-chain follow-ups - (a) Actions pinned to commit SHAs across all three workflows (every third-party uses:, version kept in a trailing comment), including the two moving refs, dtolnay/rust-toolchain@stable and rustsec/audit-check@v2 (a branch). (b) CI downloads are checksum-verified: Godot editor + export templates against the release’s SHA512-SUMS.txt, Hugo against hugo_0.164.0_checksums.txt; the Linux editor zip and the Hugo tarball were downloaded and hashed locally to confirm the pinned values, and a swapped artifact now fails the job instead of being installed (the tag is mutable; the hash is the pin). (c) Build-provenance attestations: actions/attest-build-provenance attests every release asset (three archives, gdextension/*, SHA256SUMS) with id-token: write + attestations: write on the export job, so gh attestation verify <asset> --repo godot-pty/gpty binds a download to the workflow, commit and runner that produced it; README, SECURITY.md and the release notes say so, and SECURITY.md no longer claims “no build attestation” (code signing is still absent and still stated). (d) docs/setup.sh hardened: remote release metadata is validated (conservative tag pattern plus a https://github.com/godot-pty/gpty/releases/tag/… URL) and written as JSON-quoted YAML scalars, so a malformed or compromised API response is skipped with a warning instead of injecting dropdown entries or a javascript: href - verified with a stubbed payload carrying a newline-injecting tag and an off-host URL (both skipped, valid entries quoted, site builds). The update-checker body cap landed earlier (MAX_RESPONSE_BYTES, 64 KiB). (Deferred from v0.5.3.)
✅ IPC Event-channel hygiene - (a) subscriptions no longer leak: 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; unit-tested in both directions (a stale slot is released, a freshly-polled one survives) and end-to-end (a subscribe at the cap reuses an abandoned slot instead of failing). (b) reasoning.delta rendering is coalesced: _append_live_text and _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. GUT: a 30-delta burst leaves the view unrendered until the debounce, then shows the whole turn. (c) The OMP translation boundary is a per-event field allowlist (ALLOWED_FIELDS, built field-by-field with caps) instead of a rename-and-pass-through, so prompts, answers, tool arguments/results or any future extension field cannot ride along; unit-tested with a hostile payload (forbidden fields dropped, wrong types and over-cap values dropped, unknown events rejected). Live probe against the real socket: subscribe/poll round-trip, wrong capability → -32001, oversized thinking text → -32602, and a tool.finished carrying prompt/tool_args/answer was accepted, translated and delivered - the allowlisted is_error set the terminal’s Tier 1 state to needs-attention. (Deferred from v0.5.3.)
✅ BUILD GUT aborts can pass CI - the gate is now scripts/gut-check, which owns the GUT command (ci-check and the gut-tests CI job call it, so the run and its guard cannot drift) and judges the output instead of the process exit code: Godot parse/script-error lines fail it, the Run Summary must be present and end in All tests passed!, the script count it reports must equal the test_*.gd files the gdirs hold, and every collected script must contribute at least one test. A test_*.gd nested below a gdir is listed as never-run, because GUT does not recurse (include_subdirectories is off) - a suite that quietly stops covering a directory is the same failure class. Reproduced before the fix: a parse error in one collected script made GUT break into Godot’s debugger, run 4 of 40 scripts, print no Run Summary, and exit 0 in 1.3 s (the live run takes 57 s) - the old step printed PASS off that exit code. Verified after: the clean run reports 40 scripts, 406 tests, all passed and exits 0; the same probe exits 1 with both parse-error lines plus “no Run Summary in the output - the run stopped before it finished”; a test_*.gd that does not extend GutTest and a nested test_*.gd each fail with the offending file named and the file counts it contradicts. CONTRIBUTING and docs/content/docs/overview/testing.md now point at the script; AGENTS.md’s Testing section records why the raw command is not the gate.
✅ BUILD Seam contract tests - each of the three named seams now has a test that reads both sides instead of a copy of one side (the #[func] surface it starts from was already enforced by scripts/check-ffi-surface, which ci-check runs). (a) MCP tool → IPC method → dispatch: crates/gpty-cli/src/commands/mcp.rs no longer carries a hand-written method list; it parses the real registrations out of crates/gpty-gdext/src/ipc.rs, the real dispatch arms out of ipc_handlers.gd, the method == requests workspace.gd intercepts before dispatching (paneWait), and every client.call/call_and_format literal under src/commands/, then asserts one vocabulary with the advertised MCP tools. It found the drift it was written for: paneWait was registered but missing from the dispatcher (handled in the workspace’s per-frame loop, which the test now models) and version/shutdown carried dead GDScript arms for two methods the Rust server answers locally - both arms deleted, so the partition is exact (registered = locally answered ⊎ GDScript-handled) and every registered method must be named by a CLI call site. (b) Pane targeting by id and legacy label: the old godot/tests/unit/test_ipc_routing.gd reimplemented _find_pane_by_label, the listPanes response and the error envelope inside the test and asserted on those copies (its own header admitted it), so it could not fail when the shipped handlers changed; it is deleted, and godot/tests/integration/test_ipc_dispatch_contract.gd drives WorkspaceIpcHandlers.handle against a real Workspace - every id listPanes reports must address the pane IPC resolves, a T<n> label must land on the labelled pane, terminal-only methods (paneRead, inject) must refuse a non-terminal, an unknown method answers -32601, and layoutLoad must refuse an untrusted profile while restoring a trusted one. It caught a real defect: 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. (c) paneRead’s “screen plus scrollback” claim: the smoke step only read back a marker that was still on screen; it now fills the pane with 120 lines (≈5 screens) and requires the oldest marker as well as the newest, so the documented claim is verified through CLI → socket → GDScript → grid where output timing is real (the grid-side half stays pinned by plain_text_includes_scrollback). Docs: docs/content/docs/overview/testing.md no longer claims Workspace cannot be instantiated headless (three test files do) or that real PTY spawning is #[ignore] (nothing is); AGENTS.md records the seam-test rule and activate_pane.
✅ CONCEPT Concept matching under flood - measured first, and the answer was not a cap. The cost is lines × concepts × regex, and 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. The new probe (cargo run --release -p gpty-core --example concept_probe, kept, with its results recorded in the header) flooded a real pane: ~1 KiB lines ran at 79.9 MB/s with no concepts, 79.7 with the shipped set, 53.0 with 128 typical triggers and 5.8 at the parser’s ceiling (128 concepts × 1024-byte trigger × 8 × 1024-byte conditions); 30-byte lines ran at 47.3 MB/s with no concepts and 8.6 with 128 typical triggers - a flooded pane lost up to 90 % of its throughput to matching. Fix: ConceptMatcher puts one combined RegexSet pass in front of the per-concept loop, so the common case (nothing matches) never pays the per-concept multiplier and only a gate hit runs the ordered loop that decides capture-vs-notify - conditions and precedence unchanged, and matcher_agrees_with_match_line_on_every_pair pins the equivalence across condition-fails, disabled, notify and capture cases (a gate built from triggers alone is exactly where a verdict could have changed). After: 81.1 MB/s with the default set, 73.9 with 128 typical triggers, 73.3 at the ceiling (12.6×), and 44.9 on 30-byte lines with 128 typical triggers (5.2×) - the worst case is now within ~10 % of no concepts at all. No match-rate cap: dropping matches under load would silently break the feature’s contract, the parse caps already bound the per-line work, and matching runs inside the pane’s own task (one core, never the UI thread). Per line the gate is 44× cheaper at 30 B/128 concepts and 128× at the ceiling’s worst case; a single-concept set pays ~2 ns more per short line for the combined pass (17 → 19 ns) and compiling the gate costs 0.2/3.6/27 ms for the shipped/typical-128/ceiling sets at set_concepts. Two adjacent fixes in the same path: the engine 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) via lock::read_or_warn/write_or_warn instead of panicking the pane’s task.
✅ BUILD Windows runtime smoke - the windows-smoke job (windows-latest) now boots the GUI headless and drives the CLI against it: the CLI and the GDExtension are built for Windows, Godot 4.7.2’s Windows editor is downloaded with its SHA512 pinned from the release’s SHA512-SUMS.txt, and scripts/smoke_pane_api.py - one harness for every platform, since only the transport differs (Unix socket vs named pipe, open() on both) - runs the full flow: control-pipe round trip, the named-pipe event listener (subscribe/eventsPoll, spawn and killed events), pane lifecycle, inject → pane-wait → pane-read, scrollback, broadcast, pane-run exit code, kill-pane. GUT stayed Linux-only at that point (cfg_shell_command = "/bin/sh" in the suite); it runs on Windows now - see the next item. Writing the job surfaced two real Windows defects, both fixed here: 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 default is now %COMSPEC% (falling back to cmd.exe) through SettingsManager.default_shell_command(); and pane-run always passed -c, which cmd.exe rejects - PaneTypes.shell_run_args() now picks /c for the cmd family and -c otherwise (unit-tested; pane-run on Linux is unaffected). The harness itself is verified on Linux (full PASS, and GODOT=/bin/true reports FAIL [launch] with the log tail, exit 1); the Windows leg is observed by CI, which is the point of the job. ci-check step 5.5 runs the same file with python3, and the bash smoke + its Unix-only event client are gone.
✅ AGENT @gpty/omp-events on Windows - the premise was stale, and that is the finding: the extension’s transport was already platform-neutral, down to the win32 validation branch that shipped with it in 013273c (net.connect(path) opens a Unix socket for a socket path and a named pipe for a pipe path, so the extension never needed a Windows transport - only proof that it had one). Nothing proved it, and both the extension README and AGENTS asserted the opposite, which is exactly how the item stayed on the queue. What shipped: (a) the Windows validation branch is tested - a \\.\pipe\ prefix is accepted from the path alone (the test passes an fsModule.statSync that throws, because a pipe cannot be stated) while a Unix path, a drive path, a bare \\.\pipe, and a non-prefix substring are refused; (b) a second test pins that the platform path reaches net.createConnection unchanged, which is the Windows leg of the transport; (c) the chain is now observed live instead of inferred: extensions/gpty-omp-events/tools/send-event.mjs runs the extension’s real forwarder inside a gpty pane (where the per-PTY GPTY_EVENT_* variables live) and scripts/smoke_pane_api.py asserts the pane reports Tier 1 working, so one assertion spans env injection → transport → capability check → translation → GDScript drain → agent state. That step runs in ci-check, the Linux CI job and now windows-smoke (it skips only where node is absent). The tool lives in tools/ rather than test/ because the Node runner discovers everything under test/ and a tool that needs a pane would fail the suite. Verified locally: the smoke passes end to end with the new step, the tool exits 2 with a clear message when the activation variables are missing, and node --test reports 7 extension tests. Docs corrected in the extension README, in both AGENTS spots that called the extension Unix-only, and in a stale lib.rs comment claiming the event-channel variables were Unix-only (they are injected on every platform, conditional only on registration).
✅ BUILD GUT on Windows - the suite runs on both platforms now. Every test that spawns a pane takes its program from SettingsManager.default_shell_command() (a literal /bin/sh is refused by validate_executable on Windows and the failed spawn is an engine error, which GUT counts as a test failure - so this was the blocker, not cosmetics), 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 gets printf '%b' with octal escapes, Windows gets PowerShell [Console]::Write with [char]N, and the helper holds the child alive where the point of the bytes is a terminal mode that a returned prompt would reset. paneRun’s -c//c assertion and the inspector adapter probe went cross-platform too (OS.get_temp_dir() instead of /tmp; a non-regular file is now refused on every platform, and the world-writable case stays Unix-only inside the same test). The gate learned the difference between the two non-passing kinds: scripts/gut-check prints every [Pending] test with its reason and accepts a pending-only run (counted from GUT’s own summary sentence, because GUT prints each pending test twice), while [Risky] - a test that ran without asserting - fails it, so a Windows-only gap cannot hide a suite that stopped asserting. The windows-smoke job runs the same gate under Git Bash after its import step; the Linux path is unchanged and verified locally (40 scripts, 404 tests, all passed). One test still pends on Windows with its reason in the source: the fake CLI adapter is a POSIX shell script.
✅ SEC Persistence file-mode and symlink hardening - both halves closed. (a) The store write path (BasePersistenceManager._write_file, the choke point every user:// store goes through) built its temp file at a predictable <store>.tmp, and FileAccess.open(..., WRITE) follows a symlink - measured: with the old code a link planted at that name turned a victim file into the store’s JSON. The temp name is now 64 random bits (_temp_suffix() via Crypto.generate_random_bytes), which cannot be pre-planted (GDScript has no O_EXCL, so the name is the protection), and the file is created 0600 (STORE_UNIX_MODE through FileAccess.set_unix_permissions) before the rename; the rename replaces the target itself instead of writing through a link at the store path, and the non-atomic fallback (platforms that refuse rename-over-existing) sets the mode too. (b) The scrollback database was created with the process umask - measured 0644 on the SQLite file and its -wal/-shm siblings - so history::restrict_to_owner chmods all three to 0600 from HistoryStore::open, which also tightens a store written before this change. Both halves are pinned by tests that fail on the old code: test_a_planted_symlink_at_the_temp_path_is_not_followed (mutation back to path + ".tmp" → the victim holds {"payload":true} and the test fails), test_a_written_store_is_owner_only, and the_store_and_its_wal_siblings_are_owner_only (no-op mutation → got 644). test_a_failed_write_leaves_the_previous_file_intact now forces its failure through a _temp_suffix() override, since a random name cannot be occupied in advance. SECURITY.md claimed the scrollback mode follows your umask; AGENTS records the rule and why the suffix must stay random.
✅ SEC Peer-credential gaps - every platform with a peer-credential API now fails closed. The check gained three arms: the BSDs share macOS’s getpeereid, illumos/Solaris use getpeerucred + ucred_geteuid (freed with ucred_free), and Windows - which had no check at all - identifies the client with GetNamedPipeClientProcessId, opens that process, reads its token’s user SID (TOKEN_USER via GetTokenInformation) and compares it with gPTY’s own through EqualSid. Raw extern "system" declarations in a private module rather than a windows-sys dependency, matching pin_library’s precedent. Every arm still fails closed on a lookup error, and the accept loop now rejects a mismatched pipe client before spawning its handler. Verification, since those arms are dead code on a Linux runner: the whole crate compiles for x86_64-unknown-freebsd (now a ci-check step and a CI job step, because nothing else compiles the BSD arm) and the Windows arm compiles through the existing xwin/clang-cl and rust-windows checks; the illumos and NetBSD arms are compile-probed against real libc targets (getpeerucred/ucred_geteuid, getpeereid), which is the only option there because the bundled SQLite fails to build for them. The same-UID acceptance path has an explicit test (peer_credentials_of_this_process_match, a socket pair) instead of being implied by the round-trip tests, and the windows-smoke job exercises it live. SECURITY.md now lists the platforms that check, the ones that still answer “match” (AIX, Haiku, QNX - file permissions remain their only gate), and what the named-pipe ACL does and does not do. The rejection case needs a second user account and remains untested.
✅ SEC Shared-/tmp socket fallback - the fallback chain now prefers a private directory over a shared one: $XDG_RUNTIME_DIR, /run/user/<uid> (Linux) or $TMPDIR (macOS) as before, then $XDG_STATE_HOME/gpty or $HOME/.local/state/gpty - created 0700, or tightened from a lax umask when it is already ours, and validated with the same rule as a runtime directory - and only then /tmp/gpty-<uid>.sock, whose predictable path in a world-writable directory is the squatting vector (a denial of service: the socket’s owner and mode are validated before use, so it can never be read or spoofed). fallback_socket_path() is the one place that choice lives, shared by Linux, macOS and other Unix targets, and the same derivation feeds the event socket. Tests cover it directly - the fallback is exercised through fallback_socket_path() rather than the whole chain, because on any modern Linux /run/user/<uid> legitimately resolves first (the earlier attempt to force the chain hung the whole xdg test module: ENV_LOCK is a plain mutex, so a test taking two EnvVar guards deadlocks - hence the new multi-variable EnvVars::apply guard). Mutating private_state_dir to None fails the two tests that pin it; the lazy-umask case is pinned too. SECURITY.md and AGENTS now say which directory wins and why the shared path is last.
✅ CORE Scrollback store visibility - the size is on screen now: Settings → Terminal carries a read-only “On disk:” row showing what scrollback costs, with the store’s path as its tooltip. The number comes from a new static FFI call, GptyTerminal.history_store_stats() → gpty_core::history::store_size_on_disk(), which sums the SQLite file and the -wal/-shm siblings beside it - SQLite’s own accounting, not a guess. That is the only honest source: the store is bounded by rows and bytes, so the footprint cannot be derived from History lines (it depends on how long the lines actually were). Measured live on this machine: {"bytes":61111896,…} = 56 938 496 (db) + 32 768 (shm) + 4 140 632 (wal) exactly, with the 0600 modes from the previous item visible on the same files. The row refreshes on NOTIFICATION_VISIBILITY_CHANGED, because the panel outlives a visit and scrollback keeps growing; a _history_stats_provider seam keeps the GUT tests deterministic (the real store grows while the suite runs - every pane has a writer thread). Tests: format_bytes across the units, the row’s presence and tooltip against the real provider, and the row’s text tracking the provider’s answer; Rust-side, store_size_on_disk_counts_the_store_and_its_siblings pins the sum (and that a store with no rows yet counts zero).
✅ AGENT Tier 2 agent state on Windows - decided and landed: the OSC is Unix-only by construction and gpty state <value> is the declaration path. The decision rests on what ConPTY is - a re-renderer of its own model rather than a relay - so a sequence its VT engine does not implement never reaches the pane, and that was measured: in the Windows GUT run a DECSET 2004 from the same PowerShell fixture arrives while every gpty_state declaration fails. Recovering the OSC would mean PSEUDOCONSOLE_PASSTHROUGH_MODE (0x8, Windows 11 22H2+, defined-but-unused in portable-pty 0.9.0, which passes INHERIT_CURSOR | RESIZE_QUIRK | WIN32_INPUT_MODE and exposes no flags at all), i.e. gpty owning CreatePseudoConsole plus a Windows fidelity regression - queued in Future rather than smuggled in here. The new command reads the credentials a pane injects (GPTY_EVENT_SOCKET, GPTY_TERMINAL_SESSION_ID, GPTY_EVENT_CAPABILITY - the only credential a pane’s child holds, since GPTY_SOCKET/GPTY_SECRET are stripped) and submits gpty.state.declared → generic state.declared on the event socket: capability-gated instead of spoofable, display-only like every declaration, no new IPC method, and deliberately not an MCP tool (schema.rs excludes it; the MCP contract test fails if that exclusion is dropped, and the call-site seam test now checks literals against both listeners - a fourth event-listener method fails it deliberately). seq became optional in the ompEvent payload: a process born per declaration has no counter to continue, the capability is what authorizes a submission, and next_seq now reports the session’s own counter (identical to seq + 1 for the extension, which always sends one, so its stream is unaffected; a sequenced submission must still advance). Verified: scripts/smoke_pane_api.py injects "<cli>" state needs-attention into a real pane and polls paneStatus for agent_state: needs-attention + agent_state_tier: 1 - PASS locally (Linux, headless GUI), and it now runs in the windows-smoke job, where it is the only program-facing declaration path; 2 new gdext tests (an unsequenced declaration accepted and queued with seq: 0; an unknown value refused before the session lookup), 3 new CLI tests (payload shape, credential mapping, an unknown value refused before any socket), and the GUT mapping case (a declared value is carried, an absent one declares nothing). The three OSC badge tests stay Windows-pended with the pend naming gpty state as the path. AGENTS.md (OSC bullet + data-flow block) and SECURITY.md carry the decision.
✅ CONCEPT Rule priority in the visual editor - the canvas order is the precedence order now, and the canvas says so. ConceptGraphModel.rule_order derives it from the rules’ trigger positions (top-to-bottom, then left-to-right, then by name; a rule with no stored position keeps its compiled place and sorts after the arranged ones) and layout() writes it as the graph block’s order - rule names, no content, so concepts stays the single content store. ConceptManager._merge_concepts puts the rules order names first, in that order, and everything the canvas does not name after them: a newly shipped default can never quietly outrank an arrangement the user made (it shows up unranked on the next canvas), and a store without an order key keeps exactly the shipped merge order (defaults first, then user entries in file order), so nothing changes for anyone who never opened the editor. The sanitizer (_sanitize_order: strings only, non-empty, ≤256 chars, deduplicated, capped at 256) and the omit-empty guard both know the key, so a manual dialog edit (save_concepts → read-modify-write) cannot drop it. Each trigger’s title shows its rank (1. Trigger - name), refreshed on rebuild, on revalidate and during a drag (position_offset_changed → _refresh_ranks/_rewrite_titles, only when the order actually changed), and the editor’s “Fit” button re-rows with tidy_positions instead of Godot’s arrange_nodes() - which laid nodes out by connection shape and would have silently reshuffled precedence; the hint line names the gesture. The one derivation accepts both position shapes it sees (Vector2 on the canvas, [x, y] from the file) - the first CI run of the new test caught exactly that mismatch: every rule read as unarranged and the saved order silently reverted to the file order. Verified: godot/tests/integration/test_concept_graph_editor.gd (new, 4 cases driven through the editor’s real API - open, position_offset, _save, _arrange) reads ranks off the titles rather than hard-coding them, because the shipped defaults are loaded too; test_concept_graph_model.gd covers the derivation, the unarranged-rule fallback, the row tie-break, the rename (order follows the new name) and tidy_positions; test_concept_manager.gd covers the sanitizer, both caps, the merge order, the untouched-store case and the order surviving a manual edit. (The leak the new test surfaced - ClassDB.instantiate("GptyTerminal") in _push_to_rust never freed - is queued in v0.5.5.)
✅ CORE daemon stop skips teardown, and the quit that fixes it crashed - both closed. Root cause of the crash, found under gdb: Godot unloads the GDExtension at exit, and this extension owns threads that live in it (tokio workers, one PTY reader and one history writer per pane); a worker was still executing when the mapping went away, so it faulted on a freed code page (SIGSEGV in tokio-rt-worker, stack pointing at an unmapped address). pin_library() now pins the mapping at InitStage::Core with RTLD_NODELETE, and the shutdown handler answers the client and raises a flag that GDScript polls, so the quit runs through the scene tree. Verified live, 3/3: daemon stop answers {"shutting_down": true} with rc 0, no segfault, and the teardown that process::exit skipped now runs - settings.json and workspaces.json written and the pane’s last output line in history.db. Leaving the library pinned costs nothing (the process is ending) and removes the whole class: no exit path can fault in unmapped code.
✅ CORE Store corruption handling - a store is user-editable and can be half-written, and a value of the wrong type used to raise inside the loader (int([1,2]), assigning an Array to an int _cfg_* variable, calling .get() on a String, passing a String 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), so a bad value costs that value and nothing else: settings.json (all keys, including the window_position/window_size pairs and the hex colours), workspaces.json (container, active, and each entry’s layout), profiles.json (the concept loader was already shape-checked). Two defects fell out of writing the tests: an empty workspace list made clampi(active, 0, size - 1) a (0, -1) range - an error of its own, on exactly the store a fresh install has - and a workspace whose layout is not a list took the whole set down with it at the typed parameter. godot/tests/unit/test_store_corruption.gd (8 cases) covers defaults, floats-as-ints, a pair with one bad member, junk entries beside good ones, and the empty list. Verified live: booting with a hand-corrupted workspaces.json (two good workspaces, one junk entry, one wrong-typed layout) and settings.json (bad cursor_shape/history_lines/window_position beside good keys) loads the two workspaces and every trailing key, logs no script errors, and the save on quit keeps them. (Deferred from v0.5.3.)
✅ TERM Bracketed paste - a paste went to the PTY verbatim, so every newline submitted a line and any control byte in the clipboard was injected raw (a pasted ESC[201~ closed the application’s own paste bracket, ^C interrupted). The grid now reports DECSET 2004 (TermGrid::bracketed_paste, exposed as is_bracketed_paste()), and TerminalPane.build_paste_payload wraps the text 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 (printf\033[?2004h/2004lthenexec cat -v) in godot/tests/integration/test_bracketed_paste.gd, and test_paste_payload.gd` drives the real Ctrl+Shift+V branch through a stub-FFI probe (9 cases: both branches, the guard, bare CR, empty clipboard). One finding from writing that test: an interactive shell’s readline enables 2004 by itself, so panes running a shell already take the bracketed path and the guard is what covers everything else. (Deferred from v0.5.3.)
✅ GUI Core diagnostics are invisible in the GUI - nothing installed a log logger in the GDExtension, so every log::warn!/log::error! from the gpty crates (a failed history append, a PTY read error, a poisoned lock, a refused capture) was dropped; only the CLI saw them, because it installs env_logger itself. diagnostics.rs now installs a bridge at InitStage::Core forwarding warn+ records whose target starts with gpty to godot_warn!/godot_error!, so the message lands in Godot’s console where a user or a bug report can reach it; dependency targets (alacritty_terminal, rusqlite, portable-pty) and info/debug stay out, which is what keeps the console readable. Verified live with a probe: the same log::warn! that printed nothing before now shows up as WARNING: [gpty_gdext] …, and a paired log::info! did not appear. The AGENTS pitfall that said core warnings go nowhere is rewritten (it was written to be rewritten when this landed), and the live-smoke skill’s “grep for godot_warn! instead” gotcha is corrected with it.
✅ CORE Input writes can block the drain loop - the terminal task wrote user input and emulator replies 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 because the task was inside a synchronous write. Input now goes to a per-PTY writer thread through an unbounded FIFO (input is small and user-driven), which coalesces whatever is queued before each write and logs a write error instead of returning it - the task only queues. writing_to_a_child_that_does_not_read_does_not_block writes 1 MiB to a child that never reads (from a helper thread, so a regression fails rather than hangs); verified live too: 64 KiB into a pane flooding yes returned in 7 ms and the pane’s output kept flowing before and after, where the old path froze it. A pane killed while its writer is mid-write leaves the grandchild alive for a couple of seconds until the slave side closes and the write fails; that is self-healing and no worse than the old blocked task, but killing the pane’s process group would be tidier (its own item below).
✅ TERM Killing a pane does not kill its process group - PtyHandle::drop killed only the shell, and a shell’s descendants do not always follow it: the kernel’s SIGHUP on session-leader exit reaches the foreground group, so a background job under job control (what an interactive pane produces) or a process that ignores SIGHUP is orphaned until its own writes fail. 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); dropping_a_pane_kills_its_process_group starts a SIGHUP-immune grandchild, reads its pid off the PTY, and probes it until ESRCH - it fails without the group kill and passes with it. (Found while moving input writes to a writer thread.)
✅ CORE Duplicate attachment_id - 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. godot/tests/unit/test_pane_id_uniqueness.gd drives the panel’s real apply path (_apply_to_target with a gather func returning the other pane’s id) and asserts both halves: the collision is renamed away, and _find_pane_by_label still resolves each pane under the id it now carries. Falsified by removing the connection: the id collides and resolution returns the wrong body. (Deferred from v0.5.3.)
✅ TERM Unbounded PTY-output channel - pty_rx was an mpsc::unbounded_channel, so a pane whose output outran the terminal task grew the process without limit. Measured before the fix: a yes pane added ~70 MiB/s 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). Backpressure, as designed, at pty::OUTPUT_QUEUE_CHUNKS (256 × 4 KiB = 1 MiB per pane): PtyHandle::spawn takes a bounded Sender and the reader thread blocks in blocking_send, so a full queue stops the reads, the kernel PTY buffer fills, and the child’s write blocks - the backpressure a terminal emulator has always applied. Dropping chunks was never an option: the bytes carry ANSI state, not independent records. Measured after: GUI RSS flat at 216 MiB across 15 s of yes (delta 0) and flat at 30 MiB in the harness, with the drain rate unchanged (29.5 k lines/s before, 31.1 k after - the ceiling is the consumer, not the channel), and the pane recovers: seq 1 300000 finished, the prompt returned, kill-pane reaped the child. a_full_output_queue_throttles_the_child_without_losing_bytes pins the invariant that a full queue throttles rather than truncates (it reads 216 of 4000 lines against a try_send reader); crates/gpty-core/examples/flood_probe.rs reproduces the harness numbers.
✅ SEC PATH-resolved programs bypass validate_executable - a bare name was spawned as-is, so a saved tile that also set PATH chose which file the name ran; the check saw nothing because the name had no separator. 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 - the same value execvp searches, since std replaces environ before the exec) and holds the first match to the existing rule: regular file, not group/other-writable, owner check, and no writable ancestor. PtyHandle::spawn runs it before anything is spawned, so it covers every pane whatever the caller. Three behaviours matter and are pinned: PATH order decides which file is judged (an unsafe directory after a safe match is fine, before it is not); a name that resolves nowhere is passed through, because the spawn then fails exactly as it always did rather than for a second, different reason; 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 (that hole was found by the test for it). PATH itself is still allowed - blocking it would break the shipped profiles, which launch omp, lazygit and nvim by name. (Deferred from v0.5.3.)
✅ CORE History store write 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. Now a pane owns a PaneHistory: push queues the line (no lock, no SQLite on the terminal path) and a per-pane writer thread commits batches - a batch grows to FLUSH_ROWS (512) or commits after FLUSH_INTERVAL (200 ms) of quiet, then trims the store. Two caps bound retention: rows (history_lines) and bytes (RETAINED_BYTES_PER_ROW = 1 KiB per retained row, min 1 MiB; the row cap alone let a pane hold hundreds of MB of long lines), and the pending queue is bounded by the same window - a line the queue drops under flood is one enforce_cap deletes on the next commit, so the retained scrollback stays contiguous rather than opening gaps. 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 (219 MiB across 12 s of flood); the store’s own rate measured in isolation is 33 k rows/s per line → 111 k rows/s in 512-row transactions (84 k with trimming; larger transactions do not help - the FTS5 index write is the floor, which is why the writer stays off the terminal path instead of being batched in it). Write-behind is visible only as staleness: a search can miss the last 200 ms of output. The tail is protected on quits - TerminalPane._exit_tree calls a new flush_history FFI (probed on a --quit-after run: it fires for every pane, with pending already 0 because the writer commits on the first notification, so it is a safety net for a burst that lands mid-commit) - but an exit that runs no teardown (SIGKILL, daemon stop’s process::exit) still loses whatever is queued; the GDExtension class’s own exit_tree virtual does NOT fire (probed: 0 firings), so the hook has to live in GDScript. append_batch_commits_every_row_in_order, byte_cap_trims_oldest_rows, a_full_queue_drops_only_past_the_retention_window, pushed_lines_reach_the_store_and_a_flush_waits_for_them, the_writer_commits_on_its_interval, and a_reopened_pane_reads_back_what_the_writer_committed cover it. (Deferred from v0.5.3.)

v0.5.5 - Plugin Ecosystem

Done Area Item
✅ SEC User-owned pane environment - a file must not carry env. Restore ignores a tile’s shell_env, and per-pane env becomes a property the user grants: an app-written map keyed by attachment_id (user://pane_env.json, written only by the pane settings UI, never by profiles/workspaces), so a pane’s env still survives restarts while no file can supply one. Profiles lose the ability to carry env by design (documented; a profile that needs it points at shell rc or asks the user to set it once). Companion fix: _restore_into currently applies tile settings before apply_to_terminal, which overwrites shell_env with the user’s global - so per-pane env is silently discarded on restore (a bug) and no file-supplied env reaches a child (an accident, not a control). Fix the ordering only together with this item, or files regain env authority. Migration: restored tiles’ env is dropped with a notice listing what was dropped - do NOT auto-adopt it into the store, since that would launder a hostile file’s payload into the trusted map. BLOCKED_ENV_KEYS stops growing: it becomes the floor for user-authored, ambient, and plugin-supplied env instead of the primary defense, and the plugin lane may carry env in a manifest only behind the plugin review dialog. Rationale - env is spawn-time authority, not display config: module search paths (PYTHONPATH, NODE_PATH, RUBYLIB, LUA_PATH, CLASSPATH), build-tool override (RUSTFLAGS, GOFLAGS, CC, LD, MAKEFLAGS), git config-by-env (GIT_CONFIG_GLOBAL, GIT_CONFIG_COUNT/_KEY_n → core.pager, alias.*=!cmd, core.sshCommand), credential/agent hijack (SSH_ASKPASS, SSH_AUTH_SOCK), traffic redirection (HTTPS_PROXY, SSL_CERT_FILE, PIP_INDEX_URL, NPM_CONFIG_REGISTRY, KUBECONFIG, DOCKER_HOST), and credential helpers or agent sockets (SSH_ASKPASS, SUDO_ASKPASS, SSH_AUTH_SOCK, KRB5CCNAME, GPG_AGENT_INFO). The second pass also found the sanitizer’s own gaps: LD_DEBUG/GLIBC_TUNABLES, HOME/XDG_CONFIG_HOME (a redirected HOME sources different rc files), GIT_CONFIG_SYSTEM, GIT_DIR/GIT_WORK_TREE, and no cap on the key set past the 64-line window. An enumerated blocklist cannot converge on a class like that. Decided over the cheaper alternative (D: stop persisting per-pane env entirely) because C keeps the user’s own env across restarts. Closed: PaneEnvStore (user://pane_env.json, keyed by attachment_id, loader caps 128 entries × 4 KiB values, id-pattern keys) is the only per-pane env source, written solely by the pane settings UI path (workspace.gd’s settings_applied handler, which re-keys the entry when the pane’s id changes); _restore_into strips a file’s shell_env with a toast naming what was dropped and adopts nothing; _get_layout_state no longer carries env into saved files; TerminalPane._ready overlays the store over the user’s global at spawn - the id is final there, because uniqueness renames run before the wrapper enters the tree. The trust predicates dropped the dead env clause (tile_spawns_untrusted/untrusted_plan lost the default_env parameter and TRUST_MAX_ENV_LINES; the two dialog texts and the layoutLoad refusal message updated). Tests: test_pane_env_store.gd (roundtrip, id pattern, value/entry caps, wrong-typed keys), test_pane_env_restore.gd (a file’s env neither reaches the pane nor the store, the toast fires, the store’s env overlays the global at spawn, saved state carries no env, the UI writes/moves/clears the entry), and test_pane_hardening.gd now pins that the trust gate has no env authority. ./scripts/ci-check green.
✅ PLUGIN Plugin manifest - gpty-plugin.toml (id, name, version, min_gpty_version, platforms, build/startup commands, actions, events, link handlers) plus gpty-native [[concepts]] and [[profiles]] sections so a plugin can be pure JSON - zero code. Closed: the schema and validator shipped as crates/gpty-cli/src/plugin_manifest.rs (parse_manifest → typed Manifest; precise per-path errors for the install review dialog; toml 0.8 + gpty-core added to gpty-cli). The validator’s contract is “the manifest ships only what gpty already accepts”: every [[concepts]] entry is held to the visual editor’s closed key set and round-tripped through the real engine parser (concepts_from_json) - a trigger the Rust regex dialect refuses (a PCRE2 lookbehind) rejects the manifest, which is the seam test’s centerpiece; [[profiles]] tiles mirror PaneTypes.sanitize_tile (five pane types, grid 60, rows/cols 500/2000, attachment_id pattern) with reject-not-clamp (a manifest is authored content); [[actions]].command must be one of schema::mcp_tool_names() (the published 19-tool API); [[events]].type is the closed event-socket vocabulary; argv sections carry the shell_args caps (≤32 entries, ≤4096 chars, no U+FFFD) and nothing executes at parse time. 16 new unit tests (33 total in the crate) cover every cap, the closed vocabularies, and the two seams; cargo fmt/clippy clean. The module is dead-code-allowed until the install item wires it - the allow comment says so. (Deferred from the design: install/lifecycle, the review dialog, and executing actions belong to the next item.)
✅ PLUGIN Plugin install & lifecycle - gpty plugin install <owner>/<repo>@ref (git clone into a staging dir, manifest parsed with the shipped validator - the size/count/path caps live there, item above - review dialog over a new pluginInstall IPC handshake reusing the Workspace Trust pattern, pinned revision, per-plugin config/state/log dirs) and list / enable / disable / uninstall / run / logs. The entire CLI is the plugin API (GPTY_BIN_PATH); commands are argv arrays, never shell-evaluated. A manifest’s build/startup commands are displayed in the review but not executed by this item (declared, not run - the lifecycle item that runs them is its own trust decision). Closed: install clones https://github.com/<owner>/<repo> into <data>/staging (argv-driven git, ref names validated against option-injection/traversal), gates on id-match (the manifest must declare exactly the installed target), platform, and min_gpty_version; the review summary travels over pluginInstall (a GDScript-routed method whose Rust fallback deadline is 300 s, not 5 s) to workspace.gd:_show_plugin_review (paneWait-style deferred response; ≤4 pending; the dialog text is PluginReviewText, a pure unit-tested builder whose field caps and control-char stripping keep a hostile manifest from shaping the dialog); no GUI answers => nothing installs, and the plugin subcommand is excluded from the MCP tool list (NON_TOOL_ROUTED in the contract test). Acceptance moves the clone into <data>/plugins/<id> (same-filesystem rename, old revision swapped with rollback), creates <state>/plugins/<id>/{config,state,logs}, and records {id, revision, enabled, installed_at} in <state>/plugins.json (plugin_store.rs: ≤256 records, atomic random-suffix temp + 0600 rename, strict on corrupt files, upgrade preserves the enabled flag). run spawns GPTY_BIN_PATH (or the current exe) with --key value argv, captures output to the log dir, prints it on exit, refuses disabled plugins. Evidence: 54 CLI tests (12 store + 8 plugin + 3 handshake roundtrips + both contract seams), 62 gpty-ipc, 27 gdext, 440 GUT tests, live smoke of plugin list/enable/help/schema, ./scripts/ci-check green. Not exercised live: the full clone→review→move path needs a hosted plugin repo (the registry item); its pieces are each unit/roundtrip-tested. Follow-up (same session): @ref is documented as accepting any advertised git ref - tag, branch, or commit SHA (bare install = default branch latest, which is the no-@latest keyword answer) - and the review summary now carries requested_ref, so the dialog shows the ref the user named resolving to the pinned revision.
✅ PLUGIN Installed content re-prompts forever, and shipped tool profiles are refused over IPC - the Workspace Trust dialog had no memory, so every sidebar activation of a profile whose tiles name a program asked again (all five tool built-ins: command: omp/herdr/lazygit/nvim/claude - workspace.gd:_activate_profile ran the same _tiles_untrusted gate for built-ins, deliberately without an exemption), and layoutLoad refused those profiles outright because a caller cannot answer a dialog and naming a profile is not consent. Closed: consent made once now counts - TrustedStore (user://trusted.json, a new autoload on the BasePersistenceManager pattern: atomic temp+rename 0600 writes, typed accessors, caps of 128 entries per namespace, 64 plans per builtin, name 128/version 32/revision 7–64 chars) records two namespaces, both keyed by content identity + version: builtins (profile name → app version + spawn plans) and plugins (plugin id → pinned revision). Writers are exactly the two review-confirm paths: the profile trust dialog records a builtin (name, the app version it shipped with, and every tile’s PaneTypes.tile_plan_key - a new canonical program+argv derivation kept adjacent to the predicate so the two cannot drift), and the plugin install review records id + revision; user-authored profiles and layout files never record consent and keep today’s ask-every-time behavior. Consumers: _activate_profile skips the dialog when _profile_consented (builtin approved at this app version, or every untrusted tile’s exact plan covered by an approval); the workspace-restore loop uses _tiles_untrusted_without_consent (a workspace saved from an approved builtin restores without re-asking - the plans are what carry the approval across, since saved tiles have no profile provenance); and layoutLoad over IPC consults the same check instead of refusing outright - the record is proof a caller cannot forge, so gpty layout load/the MCP tool now work on content the user already approved. A new app version or a new pinned revision re-prompts by construction (the record keys on the version). The plugin record’s profile-side consumption activates when installed-plugin profiles surface (the profiles-migration item); the same record gates build/startup execution later. Evidence: 10 TrustedStore unit tests (version/revision scoping, shape checks on a hand-edited file, plan caps, reload seeding, plan-union coverage, persistence), plan-key seam tests in test_pane_hardening.gd, consent contract tests in test_ipc_dispatch_contract.gd (unapproved/stale/covered builtins, plan-covered and uncovered user profiles, the restore gate), 451 GUT tests total, ./scripts/ci-check green.
✅ GUI cli_view pane - runs a command and streams stdout into a pane body: plugin UI v1 without a Godot SDK. Native third-party GDScript/Rust pane plugins still deferred to a future SDK (PaneTypes.ALL is the layout-trust anchor; see AGENTS.md). Closed: argv-only by construction - the pane’s command + shell_args are the same keys the Workspace Trust gate already reads, so a profile or manifest tile that names a program keeps asking for consent, gpty_core::cli::CliProcess::spawn re-validates the program with pty::validate_program (a name resolving into the working directory is refused), and STRIPPED_INHERITED_ENV_KEYS is removed from the child, which is a third-party CLI. Rust owns the process: stdout+stderr piped and merged into one bounded line queue (512 lines - backpressure, never dropped; CRLF stripped; a trailing partial line flushed at EOF; a line past 16 KiB split, never buffered unboundedly) with Drop killing the child’s process group. GptyCliView (crates/gpty-gdext/src/cli_view.rs) is the #[func] edge - start (caps: command ≤1024, ≤32 args ≤4096 each, U+FFFD refused), poll_lines, status_json, stop - plus cli::idle_status_json() so a never-started pane parses the same shape. CliViewPane keeps a 4000-line ring in a read-only CodeEdit (Restart/Stop, Program/Arguments rows, the inspector’s untouched-field argv rule, never auto-restarts on a debounced settings edit, stops the child in _exit_tree), registered in PaneTypes.ALL + TerminalManager._PaneScripts so the palette, sidebar, swap menu and label prefixes pick it up. The pane API reaches it: gpty new-pane --type cli_view --command <program> --arg <a> (new optional args IPC field; --arg takes hyphen values so --arg -c parses) and pane-read serves its body text; plugin manifests accept cli_view tiles (PANE_TYPES). Evidence: 13 new gpty-core tests (line-assembly boundaries plus real children - stdout, stderr merge, reaped exit code, process-group kill, refused program), 3 FFI cap tests, test_cli_view.gd 7/7 (streamed stdout reaches the ring and the visible view, Stop kills the child, empty command idles, sanitization, layout-state round-trip, widgets fill the pane, display-sink role), test_palette/test_pane_spawning/test_pane_hardening updated (six types; the trust gate covers a cli_view tile), manifest mirror test, and the live smoke’s cli_view step (new-pane --arg → pane-read). ./scripts/ci-check green: fmt/clippy/workspace tests/check-ffi-surface (61 calls), 458 GUT tests, live smoke PASS. Post-push follow-up (the first windows-smoke run): the GUT tests gave the pane the fixture’s cmd.exe command line as argv, so cmd.exe /c re-parsed it - \" is not cmd syntax, PowerShell received the script as a quoted string and printed it, and the pane showed the code with no marker (the pane never echoes its plan, so the failure was the child’s own output). The tests now spawn through the new ShellFixtures.print_text_argv() - PowerShell named directly on Windows, the shell running printf on POSIX - with a 20 s budget, because the child is an interpreter starting cold on a CI runner.
✅ GUI Pane contract extension - on_agent_state_changed(state) in PaneBody for custom panes. Closed: the contract is a pair of PaneBody methods - agent_state_source_id() names the terminal a pane observes by stable attachment_id (a terminal returns its own id: the tiered tracker it reads is that terminal’s state; empty = observes nothing, the base default) and on_agent_state_changed(state) receives that terminal’s state (idle / working / needs-attention / completed / failed). workspace.gd is the only dispatcher: TerminalPane.agent_state_changed, emitted from the slow poll on a real transition, calls _dispatch_agent_state, and a per-frame _prime_new_pane_observers scan delivers the current state once to each new observer - any pane-entry route (spawn, restore, swap, a future pane SDK), a follower whose terminal attaches later (retried until it does), and a pane re-pointed at a different source (the scan stores the source it primed for, not a boolean). Panes are found through the panes group, so hidden workspaces keep observing. The titlebar badge became the first implementation instead of a parallel path - TerminalPane.on_agent_state_changed applies it - so the tracker→display chain is the contract and the existing badge tests cover it end to end. Display-only rule unchanged (Tier 2 declarations are spoofable, Tier 3 is heuristic; a hook may never feed a decision, action, capture, or IPC). Evidence: test_agent_state_contract.gd 5/5 (terminal observes itself and lights its badge through the hook; follower primed then told every change with values held to the published vocabulary; a pane with no source is never called; follower attached before its terminal is primed when it appears; re-pointed follower is primed for the new source), test_pane_body.gd pins the base default, test_agent_badges.gd 6/6 still green through the new path (real child-printed OSC declarations), ./scripts/ci-check green.
✅ PLUGIN A timed-out plugin review leaves an orphan dialog - the pluginInstall fallback deadline (300 s) kills the request while workspace.gd’s review dialog stays open; a click on it answers a dead channel (respond_ipc no-ops) and the user believes the install happened, while the CLI already failed. Close the dialog when the request is gone (the GUI can watch the same deadline or the Rust side can push an expiry notice), so a late click cannot look like consent. Closed: the request’s own deadline now travels with it. IpcRequest carries the Instant its fallback fires at (fallback_timeout stays the single source of truth for the 300 s), ipc::request_document turns it into the time remaining, and drain_ipc_requests hands GDScript [{id, method, params, timeout_ms}] (0 once it has passed). _show_plugin_review opens a dialog only when timeout_ms > 0 - a request that died before the frame drained it has no channel left to answer - arms a one-shot Timer for the remainder, and stores {dialog, timer} per id; _expire_plugin_review closes and frees the dialog, drops the entry, and toasts that the requesting command already timed out, answering nothing because by then the request is gone. The answer path (_finish_plugin_review) frees the same two nodes, so an answered review leaves no timer to fire later. Evidence: crates/gpty-gdext/src/ipc.rs tests (the document reports remaining time and saturates to 0, never wrapping; a queued pluginInstall request carries its method’s fallback deadline), test_plugin_review_expiry.gd 3/3 (150 ms deadline closes+frees the dialog and toasts; timeout_ms == 0 opens nothing; an answered review settles the entry and frees dialog and timer), ./scripts/ci-check green.
✅ CLI gpty pane-wait --socket <path> is silently ignored - pane_wait.rs builds its own client from transport::default_socket_path() (the env override works, the flag does not), unlike every other command, which routes through the Cli.socket/GPTY_SOCKET resolution in main.rs. Found while driving the smoke’s sandbox socket. Closed: the command takes the dispatched IpcClient like every other one and derives its longer budget from it (IpcClient::with_timeout - same endpoint, same secret, the read budget a held response needs), so the endpoint is exactly what --socket/GPTY_SOCKET/the platform default resolved in main.rs; a sweep of the crate’s IpcClient::new sites confirms the two remaining ones are deliberate (the daemon’s liveness probes, and state’s event-socket client built from pane credentials). Evidence: pane_wait_reaches_the_resolved_socket and pane_wait_outlives_the_connection_budget in crates/gpty-cli/src/tests.rs - both fail against the old implementation (connection refused at the default socket) and pass against the fix; the roundtrip harness gained start_server_with for a handler that holds its response 300 ms while the dispatched client’s budget is 50 ms. ./scripts/ci-check green.
✅ CONCEPT Windows concept matching was dead until the ConPTY parser fix - ConPTY renders line breaks as cursor moves (no LF), so the discard-only line parser never committed real command output on Windows; only CRLF-terminated input echoes matched. The parser fix (row-advancing CSI commits) repaired it, but nothing in windows-smoke proves the fixed path: the smoke drives pane-wait (recent_lines) but no concept fires from Windows output. Add a smoke step that registers an UntilStop concept triggered by an output marker (not by the injected line’s echo - build the marker at runtime like the wait markers) and asserts the routed capture event, so a parser regression on Windows fails the smoke instead of shipping. Closed: scripts/smoke_pane_api.py seeds one concept into the user store before launch (seed_concepts - the only registration route that exists, since concept toggle flips a shipped rule rather than adding one; the store is backed up and restored in teardown, which matters on Windows where user:// is the real per-user directory that APPDATA does not redirect) and the trigger SMOKE_CONCEPT_8K3[0-9] demands the digit the shell appends at runtime, so the injected line’s echo cannot match it. The step asserts the seeded concept is loaded and enabled, opens a code_viewer receiver so the capture routes for real, injects the runtime-built printer, and polls the event subscription for the routed {type: concept, name, target: code_viewer} event - which is only emitted after a committed output line reached the matcher, exactly the commit a ConPTY regression removes. Evidence: the step passes live here, twice, inside ./scripts/ci-check (Linux), exercising concept→capture→route→event end to end; the platform it is aimed at runs the same file in the windows-smoke CI job, whose next run is the regression signal (not observable locally - the ConPTY commit path it guards is Windows-only).
✅ PLUGIN Plugin registry - JSON index repo + browse page on the docs site; submission by PR. Policy to settle at build time: plugin ids are immutable and owned by their repo, min_gpty_version is a floor the registry states, and no registry-side deletion while an install is pinned to a revision (the pin keeps working). Closed: the index lives at godot-pty/gpty-plugins (created 2026-09; the name follows the org’s gpty- prefix) - registry.json seeded with the five tool plugins, validate.py, a README carrying the schema and the policies, and a validate workflow (push, PRs, weekly; actions/checkout pinned to a full SHA like every other repo in the org). Validation has two layers: structure (closed key set, owner/repo ids, https://github.com/<id> repo URLs, semver floor, platform subset, category vocabulary, no duplicates) and manifest agreement (--check-repos fetches each entry’s gpty-plugin.toml at HEAD and requires id/name/floor/platforms plus a shipped section to agree), so the catalog cannot drift from the plugin it points at; both layers were mutation-checked (a trailing-slash repo URL and an isolated floor mismatch are each caught). The docs site gets a /plugins/ page and a menu entry: the plugin-registry shortcode fetches the index live at build time and falls back to the committed snapshot docs/assets/registry.json when unreachable (warning in the build log - the failure mode that would otherwise be a silently stale page; docs/data/ is generated by setup.sh, so the snapshot lives in the assets tree the docs build reads); both paths were built with the CI-pinned Hugo 0.164.0 and the page inspected rendered (five entries, correct nav). Policy: ids are immutable and owned by their repo; min_gpty_version is a floor the registry states, not an install gate; removal stops new installs only, since every install is pinned to a commit SHA. Evidence: registry CI run 35036315367 green; docs build clean on both paths; ./scripts/ci-check green.
✅ PLUGIN Move tool-specific shipped profiles to plugin repos - godot/profiles.default.json ships six built-ins, five of which name a program (command: omp/herdr/lazygit/nvim/claude); only “Agent Workspace” is pure geometry. Shipping them couples tool layouts to gpty’s release cadence and makes core endorse L3 tools (Herdr) and agents (Claude) that the ADE boundary says are the market’s. Fix, sequenced after the manifest, install, consent-store, and registry items: move the five into one repo per tool under the godot-pty org - gpty-omp, gpty-herdr, gpty-lazygit, gpty-neovim, gpty-claude (2026-09 decision: no gpty-profiles mixed-bag bundle and no catalog monorepo - a repo is the install/review/consent unit, the manifest’s [[profiles]] array already holds per-tool variants, and one-repo-per-tool is the shape third-party plugins have, so the registry and docs ship one shape from day one; the five repos are created and initialized - manifest, README, Apache-2.0 license, and a minimal validate workflow that pins the TOML parse + id contract until the migration item wires the authoritative validator) - keep “Agent Workspace” built-in (the recommended layout, names no program, keeps first-run onboarding at zero dialogs), seed the registry with them, and point a first-run hint at the catalog page listing all five. Collateral, all deliberate: test_six_builtins_in_default_profiles_file shrinks to one while test_each_preset_tile_passes_sanitize_tile keeps covering it, and the moved profiles get the same sanitizer checks in each repo’s own CI (dogfooding the manifest - which needs a gpty plugin validate <path> command running the shipped parse_manifest, since the repos’ current validate workflow only pins the TOML parse + id contract; the command doubles as the plugin-authoring loop); ProfileManager must surface installed-plugin profiles in the same namespace as user profiles so the sidebar, layoutList and layoutLoad see them (a name collision follows add_profile’s dedupe convention: a (n) suffix, user-named first); removal is safe for existing users (built-ins are read-only templates - saved workspaces/profiles are copies). Decide at migration time whether the tool-specific shipped concept defaults (git_log, cargo_check - Inspector-targeting, shipped disabled) follow the same move, same argument: shipped defaults keep tracking updates, and the visual editor already writes a rule equal to a shipped default as no user entry. Closed: godot/profiles.default.json now ships exactly one built-in (“Agent Workspace”), verified faithful - each plugin manifest runs the same program with the same argv as the built-in it replaces. The read path is: install acceptance records the validated manifest’s profiles in the plugin record ({id, revision, enabled, installed_at, profiles: [{name, tiles}]}; caps 64 profiles × 64 tiles, strict reader, an absent key reading as empty so older records stay valid), GptyTerminal.installed_plugin_profiles() hands the enabled records to GDScript as JSON (malformed entries skipped and the reason logged), and ProfileManager.refresh_plugin_profiles() merges them between built-ins and user profiles - dedupe keeps the bare name for user/builtin content and gives the plugin (n), first plugin wins, and the naming is re-derived whenever a user profile takes or frees a name (_derive_plugin_profiles(), called by the four user-profile mutators) so the winner does not depend on arrival order - a follow-up fix, because with the plugin arriving first the two rows shared one name for the session and find_profile resolved the collision to the plugin’s row (users are listed last); a plugin_profiles_source Callable is the test seam; layoutList/find_profile/layoutLoad see them with no changes. Trust: _profile_consented consults TrustedStore.is_plugin_approved(id, revision) FIRST for a profile carrying plugin_id (approved revision activates without re-asking, a newer revision re-prompts), and the install review now prints each profile’s distinct programs (programs in the summary), so acceptance is informed consent for what its profiles run. The sidebar shows plugin rows with a provenance tooltip and no delete/rename (the plugin owns them), plus a “Get more layouts → catalog” hint row while none is installed. gpty plugin validate <path> (offline, --json, exit 1 on rejection) is the authoring loop and the check the five plugin repos’ CI switches to - their inline TOML+id script stays until a released CLI is installable there (the command cannot exist before the v0.5.5 tag), and all five first-party manifests were verified with it locally. Deliberate collateral: the sidebar’s delete/rename lookups were reading get_all_profiles() with a user-profile index (already off by the built-in count) and now read get_profiles(). Decision on the concept defaults: git_log/cargo_check stay in gpty - generic git/cargo rules rather than tool endorsements, shipped disabled and Inspector-gated. Evidence: cargo test -p gpty 64/64 (store profile shape/caps, plugin validate good/bad/manifest-vs-directory, review programs), gpty-gdext 39/39 (the FFI’s enabled-only/malformed-entry rules), GUT test_profile_manager 20/20, test_sidebar 14/14, test_ipc_dispatch_contract 10/10, test_layout_restore 12/12; the live smoke’s plugin-provided profile step (seeded store record → layout list → layout load → list-panes shows the profile’s tile) passes; ./scripts/ci-check green.
✅ CONCEPT Concept push leaks a GptyTerminal per push - ConceptManager._push_to_rust does ClassDB.instantiate("GptyTerminal") and calls set_global_concepts on it without ever freeing the instance: the FFI method is &self only because it writes the engine’s global store, so the object is a vehicle for the call and nothing keeps or releases it. Every concept save, toggle and editor save adds an ObjectDB entry that is never released - GUT names them as orphans (4 instances for the new editor test’s 4 saves; the full run reports them among its 276), and a session that edits concepts repeatedly accumulates them. Fix: free() the instance after the call (or make the call static so there is nothing to own) and pin it with an assertion on the orphan list - the instance count per push is the observable. Surfaced while writing the rule-priority tests. Closed: set_global_concepts/get_global_concepts are static now (they read/write the process-wide engine store, so the instance was pure ceremony), ConceptManager._push_to_rust calls the accessor directly behind a ClassDB.class_exists guard, and the duplicate workspace.gd._push_concepts_to_engine - which scanned tiles only to borrow a terminal’s instance as the vehicle, and silently skipped the push when no terminal was open - is deleted, with both the concepts_changed subscription and the deferred fallback pointing at the one entry point. The subscription also had to move: connected from workspace.gd the same bound callable was re-connected by every Workspace instance and Godot rejects that (55 cascading GUT failures caught it mid-implementation). Pinned by test_a_push_owns_no_ffi_instance (assert_no_new_orphans after three pushes - GUT counts orphan nodes, which is what the leaked object was); the run’s orphan listing no longer names test_concept_manager.gd at all (it listed two GptyTerminals before) and the suite went 486/486. scripts/check-ffi-surface grew the coverage the move would otherwise have cost: static calls on a GDExtension class name were invisible to it, so GptyTerminal.get_app_version() and 29 other static calls went unchecked - the class name is now a handle and new is an allowlisted member, taking the checked surface from 61 calls to 91 with no mismatches. crates/gpty-gdext/README.md marks the three concept-engine accessors static (validate_regex was one too, undocumented as such).
✅ PLUGIN Plugin admin actions never reach a running GUI - gpty plugin uninstall|enable|disable rewrites state_dir()/plugins.json, but the GUI reads that store only at startup and once after an accepted install review (workspace.gd:_refresh_plugin_profiles_deferred - the deferred 2 s pickup for the accepted clone), because the plugin subcommand is deliberately CLI-only (not routed, not an MCP tool). A running GUI therefore keeps listing an uninstalled plugin’s profiles and keeps activating them from its in-memory copy (tiles plus the programs the user approved at review time, so nothing unsafe runs - but the disable switch visibly does nothing until a restart). Found while answering whether a v0.5.4 profile can be removed by uninstalling a plugin: it cannot - the five tool layouts were never plugin records (the store is new in v0.5.5; uninstall on an unknown id bails with “not installed”), delete_profile operates on user://profiles.json only, and the sidebar deliberately offers no rename/delete on a plugin: true row (“plugin profiles follow the plugin”) - but an installed plugin’s profile does stay after uninstall until the next refresh. Fix one of: an IPC notification the CLI fires after an admin action (the GUI’s dispatch already exists; the method joins the contract test’s NON_TOOL_ROUTED), or a cheap store-mtime check in the GUI’s existing slow poll. Verify live: install → row appears, uninstall → row leaves without a restart. Closed: the CLI now fires pluginsChanged {"action", "id"} over the control socket after a successful uninstall/enable/disable, and ipc_handlers.gd answers it by re-reading the store synchronously - so the CLI’s reply means the GUI’s profile list is already up to date (the install review keeps its deferred re-read, because there the clone lands only after the answer). Best-effort in the one direction that matters: no GUI, a refused connect or a 2 s timeout is dropped, and an admin action never starts a GUI (ensure_running is deliberately absent here, unlike install). The method is routed but not an MCP tool (NON_TOOL_ROUTED, alongside pluginInstall), which the CLI’s contract test enforces. Evidence: plugin_admin_notice_reaches_a_gui_and_is_silent_without_one (payload, absent-GUI silence, and a failed action never notifying), GUT test_an_admin_notification_refreshes_the_profile_list (the row leaves the list and an action outside the closed vocabulary is refused), The notice validates both fields though only action has a consumer today: id is inert and the CLI cannot deliver a malformed one, but validation is a boundary property, not a property of the read fields - and the shape is shared, not re-declared (TrustedStore.valid_plugin_id, made public for its second consumer; the consent records were the first), which the closed item’s own boundary test covers (test_valid_plugin_id_holds_the_manifest_shape). Evidence: the live smoke gained a plugin admin notice step - seeded record → gpty plugin uninstall → layout list no longer offers the profile - which fails under mutation (the call renamed to an unregistered method reproduces the original bug: “a running GUI must drop an uninstalled plugin’s profile (got: [‘Agent Workspace’, ‘Smoke Layout’])”).
✅ TERM Bracketed-paste tests never exercise the child on Unix - measured while fixing windows-smoke: with the fixture’s command line replaced by a bare sleep 30 (it prints nothing at all), godot/tests/integration/test_bracketed_paste.gd still passes 3/3 in 1.3 s, and a probe taken once the shell is up reads mode=true before anything is typed - bash’s readline sets DECSET 2004 by itself (bash -i under a pty emits ?2004h and ?2004l; bash --noediting -i emits neither), so the fixture’s own bytes are only load-bearing on Windows, where cmd.exe has no readline. The file therefore covers the parser and the wrap/guard halves on both platforms, but “the child’s bytes decide the mode” only on Windows - which is also the only place a cold powershell.exe start matters (a fixed 10 s wait here is what reported a cold start as “the mode never arrived” and turned windows-smoke red). Fix: run the paste tests under a shell that does not set the mode itself - the pane’s own shell_args (bash --noediting) on a pane the test spawns through TerminalManager.spawn_pane, since SettingsManager carries no global args - and assert the mode is false once the shell is ready, before the fixture’s 2004h is typed. That assertion is the one that fails today on Unix, so it pins the masking. Closed: the pane is spawned with ShellFixtures.no_line_editing_args() (bash --noediting, nothing on Windows - cmd.exe has no line editor) and the helper asserts the mode is false once the shell is printing, before any fixture bytes; test_resetting_the_mode_reaches_the_pane became a real transition the child drives (on, hold, off in one line - ShellFixtures.print_text_pair(), because a holding fixture cannot be typed into twice), so both directions are now the child’s doing on every platform. Two things had to be measured rather than assumed: spawning through TerminalManager.spawn_pane leaves the wrapper unparented, so _ready never runs and _terminal is null - the pane has to come from Workspace._spawn_pane; and dropping the args reproduces the masking exactly (mode=true against a bare prompt, 3/3 failing), which is what makes the new assertion load-bearing. Verified: integration suite 172/172 with the file at 3/3, and the mutation above.
✅ CORE A locked history store drops the batch it was committing - HistoryStore::open sets a 5 s busy_timeout and the per-pane writer logs history append failed (N rows): {e} and drops the batch when that expires (crates/gpty-core/src/history.rs; the bounded wait itself was added earlier in v0.5.5, after a Windows run dropped rows the same way). It is not theoretical: windows-smoke on f60cbf0 failed with [gpty_core::history] history append failed (2 rows): database is locked raised during test_click_activates_non_terminal_pane - GUT counts the engine error as a test failure, and gut-check then calls the entire run untrustworthy. The trigger was concurrency: that commit’s bracketed-paste file ran two panes per test (startup + the one it configures), doubling the store’s writer/flush burst in its window; the file now leaves one pane, which removes the amplification but not the policy behind it. Every pane owns a connection to the same file (WAL, so writers serialize on the write lock) and SQLite’s busy handler does not queue fairly, so a writer can starve past 5 s on a loaded runner. Fix direction, to be chosen with measurements: a process-wide write mutex around commits (every pane lives in one process, so intra-process collisions disappear by construction and holding it only across a commit cannot deadlock), and/or a bounded retry with jitter before the drop - the drop stays the last resort, because a pane’s writer must never block forever. The writer’s warn line is the signal to re-measure; a dropped batch costs scrollback rows, not integrity. Closed with the first direction and no retry: one process-wide write guard per file (history.rs::write_guard, a static Mutex<()> taken inside append/append_batch/enforce_cap/clear/prune_missing_panes), so this process’s writers never race for SQLite’s write lock at all and the 5 s busy timeout is left as the backstop for another process on the same file. A retry would have been the second layer over a cause that is intra-process by construction - every pane lives in one process - and the measurement says there is nothing to retry for: a throwaway probe over one file (N writer threads, 300×16-row batches each) went from 7 dropped batches at 8 writers and 15 at 16 (database is locked - the same message windows-smoke showed) to zero at both, while committing faster (32.8k rows/s at 8 writers against 26.1k, a dropped batch being wasted work) and within ~5% of a single writer (34.3k). Pinned by in_process_commits_serialize_on_one_file, which fails on a bypassed guard (a commit that must wait for it lands immediately) and passes with it; both stores are pre-opened in the test so only the commit - not open’s DDL - can block. A poisoned guard logs once and proceeds unserialized rather than skipping the write: the alternative loses the rows outright.
✅ CORE PaneType’s serde spelling disagrees with the GUI keys - the enum is #[serde(rename_all = "kebab-case")], so NewPaneParams.type serializes as code-viewer/cli-view while PaneTypes.ALL, new-pane.rs’s VALID_TYPES, the GDScript dispatch and every wire example use code_viewer/cli_view (the pinned wire test in crates/gpty-ipc/src/types.rs asserts the kebab spelling for the struct). Nothing breaks today because the CLI builds its params with serde_json::json! strings and the GUI reads a Dictionary - but a future caller that serializes the struct (the v0.9.0 daemon’s Rust-side param validation is exactly such a caller) produces a pane type the GUI rejects as unknown. Found while adding cli_view to the enum. Fix: align the serde spelling with the GUI keys (and update the pinned test in the same commit), or drop serde from the field if the struct is a test helper only. Closed by removing the second spelling rather than correcting it: PaneType derives no serde naming at all - Serialize writes as_str() and Deserialize goes through parse(), so the emitted set and the accepted set are one definition and the kebab form (or any future attribute) cannot come back. The same sweep removed the other three copies of the vocabulary: pane_types() in the manifest validator and valid_types() in new-pane are derived from the new PaneType::ALL (which also feeds the unknown-value error message), and the seam is now read rather than restated - pane_types_match_the_gui_registry_key_for_key parses PaneTypes.ALL out of pane_types.gd with include_str! and asserts key-for-key equality with the derivation, which the old count-only pair of tests (test_pane_types_all_has_six_entries + a literal list) could not do: a code_viewer → codeviewer rename on either side passed both. Mutating one spelling fails four tests, one of them with the parsed registry as the right-hand side. The legacy observer alias survives where it belongs: FromStr (so saved layouts still parse) and new-pane’s translation with its deprecation warning.
✅ BUILD gpty-ai’s fake-omp fixture collides under parallel tests - the parallel gate (ci-check step 4.2, cargo test --workspace with default threads) failed once with omp_session_respawns_after_child_exits reporting “failed to spawn omp …: Text file busy”: the script path’s uniqueness source was the clock’s nanosecond fraction (gpty_fake_omp_{pid}_{subsec_nanos}.sh), which a coarse clock repeats - exactly the shared-temp-path class the parallel gate exists to catch. Fixed: fake_script_path(kind) builds the path from pid + nanos + a process-global AtomicU64 counter, so two creations in the same process can never collide, and both fake-omp tests use it. Verified: cargo test --workspace (the gate itself) 3/3 green with the fix; five solo -p gpty-ai runs before it were green, so the race only showed under load. Found while the gate blocked the plugin-manifest commit.
✅ BUILD Parallel-safety audit (triggered by the fake-omp flake) - swept the whole workspace for the class the parallel gate catches: process-global env mutation, shared statics, fixed temp paths, panic-path cleanup. Confirmed clean: transport’s EnvVar/EnvVars lock, gdext ipc tests (#[serial] + clear_state()), omp_events QUEUES + reset(), per-test-name socket/db paths (gpty-cli/tests.rs, daemon.rs, history.rs, ipc.rs, server.rs), the smoke harness’s mkdtemp, the extension tests’ mocked fsModule, the GDScript mock-store teardown, read-only statics, engine.rs’s remove-before-use, and the spawn test’s cleanup-before-assert. Two stragglers fixed: (a) client_authenticates_with_secret mutated GPTY_SECRET raw, outside any lock - the guard is now a shared crate::test_env module (gpty-ipc/src/test_env.rs, LazyLock + restore-on-drop, panic-safe) used by both transport.rs and client.rs, and the test drops the guard instead of remove_var; (b) two pty.rs tests left a world-writable 0777 file/dir in /tmp until after their asserts - verdicts are now captured, permissions restored and paths removed first, then asserted (the pattern the sibling test already used). cargo test -p gpty-ipc -p gpty-core green.
✅ TERM A pane whose terminal task died looks like a running one - fixed: liveness is now part of the status document instead of being assumed from the exit code. SpawnedTerminal::task_finished() is is_finished() on the task’s JoinHandle, which nothing used to look at, and it is the only signal that notices a task which ended without recording an exit code - a panic inside it, or a child that closed its pty and outlived the pane. running is now exit_code.is_none() && !task_finished(), and exit_reason (exited / task_ended / null) says which of the two stopped it, so nothing has to guess from a null exit code. The document is built in core (SpawnedTerminal::status_json, following history_store_stats), which makes the bytes the pane API returns the bytes under test: a_task_that_ends_without_an_exit_code_is_reported_as_stopped drives a real pane whose child detaches (exec 0<&- 1>&- 2>&-; sleep 30 - closing the fds ends the read side while the child keeps running, the same state a panicked task leaves) and asserts running: false, exit_code: null, exit_reason: "task_ended"; a_live_pane_reports_running pins the live branch (running: true, no reason); and a_shell_that_exits_while_the_pty_is_held_open_is_still_reported gained the third (exited with its code, which is the case paneRun promises). Reverting the derivation to exit_code.is_none() fails the first test with the pre-fix document printed in the assertion. The item’s second half is closed as well: with_grid/with_grid_mut/with_grid_mut_ret now lock through lock_or_warn(&spawned.grid, "pane grid") - the same name the engine uses for that mutex - so a poisoned grid is reported once per process instead of once per frame from the renderer’s poll.
✅ TERM A failed resize_grid desyncs the pane’s size permanently - fixed in three parts, all of which are needed. (a) The grid+PTY resize is now one all-or-nothing operation in core, SpawnedTerminal::resize: it takes the grid lock once, returns false without touching anything at all when that lock is poisoned, and sends the PTY resize only after the grid took the size - the child can no longer reflow (and take a SIGWINCH) at a width the emulator never applied. (b) GptyTerminal.resize_grid returns bool - the honest half of the contract the pane records from; a pane with no shell answers true, because there is no grid to refuse and a caller that retried would never converge. (c) terminal_pane.gd records cols/rows only after the backend reports it took the size, and re-arms the debounce before the call, so both a refusal (false) and a panic (the VM aborts the calling function at the FFI boundary, so nothing after the call runs) leave the old dimensions in place for the next pass instead of rendering at a size the grid never had. Verified live: a_resize_reaches_the_grid_and_the_child has the child report its own window size through the pty (stty size), so the PTY half is observed rather than assumed, and a_refused_resize_leaves_the_child_where_it_was poisons a real pane’s grid the way a panic does and asserts the child still reports the size it was spawned at (the grid cannot be read through a poisoned lock, so the child reports to a file - the pty is the only channel back, and it is the channel under test). Both were mutation-checked: moving the PTY resize before the lock check fails the first, deleting the success path’s PTY resize fails the second.
✅ DOCS Stale get_grid_rows() references in the FFI docs - closed: the three prose names are the real ones, and the class is now unavailable to the next writer. crates/gpty-gdext/README.md’s tips bullet and the start_shell / get_grid_generation doc comments name get_rows()/get_cols() (ints; 0 when the lock is unavailable or no shell has started) and get_grid_updates_packed(), whose lock-unavailable answer is an empty Dictionary - the old prose said [], which was wrong about the fallback as well as the name. The fix that outlives the typo is scripts/check-ffi-surface’s new docs pass: a backticked name(...) in the crate’s README or in its //////! comments must be an export, a plain fn in the crate, or a DOC_ALLOWED_NAMES entry (the four Godot callbacks, state_dir from gpty-ipc, and IpcServer::serve - each with its reason, the fail-closed shape INHERITED_MEMBERS already had). It found a second name on its first run and reports the mutation that proves it bites: 78 doc names checked, get_grid_rows() fails it with “not an export of this crate and not a plain fn”. The crate README’s mirror under docs/assets/repo/ moved with it, because docs/setup.sh copies that file verbatim at site-build time.
✅ CORE God-object split - closed on measurement: the seams that were real are extracted, and the rest is cohesive-by-topic. (a) The GptyTerminal #[func] surface is filed by facet: lib.rs keeps the class, the grid helpers and the session lifecycle (1690 -> 1003 lines), and grid_api.rs (287), history_api.rs (158), capture_api.rs (183) and bridge_api.rs (109) carry the rest as #[godot_api(secondary)] impl blocks - gdext’s supported way to implement one class across files - moving 305 doc lines and 47 exports losslessly, with scripts/check-ffi-surface re-spined to attribute a #[func] to the class its enclosing impl block names (the old “first struct in the file” rule silently dropped all 36 moved methods the moment the split landed, which is how it was found) and to report a #[func] outside such a block. (b) terminal_manager.gd’s 247-line divider-drag state machine became tile_drag.gd (868 -> 654 lines) behind thin delegators, so the drag tests did not move - a self-contained machine over the tile list, the one block whose extraction removes coupling rather than relocating it. (c) Refused, with reasons: the workspace block-management / persistence / polling / palette regions each read the same _workspaces/_tm/_grid/_sidebar/_active cluster, so a split re-injects half the owner or adds a back-reference that only adds indirection (measured region-by-region, the largest single candidate is 335 lines of pure glue), and TerminalPane’s renderer is a Control virtual - extracting _draw is a delegation layer with no cohesion gain and no automated coverage (manual-only per the testing docs).
✅ BUILD AUR package rework - the bundle now ships the CLI as gpty and the GUI as gpty-gui, so dist/aur/PKGBUILD must install both (/usr/lib/gpty/{gpty,gpty-gui,gpty-gui.pck,libgpty_gdext.linux.x86_64.so}, with gpty and gpty-gui on PATH), point the desktop entry at Exec=gpty-gui, install godot/icon.png as /usr/share/icons/hicolor/256x256/apps/gpty.png with Icon=gpty, bump pkgver/pkgrel, and take the tarball hash from the release’s SHA256SUMS asset. Validate with makepkg before uploading; it cannot be done before the tag because no gpty-gui asset exists until then. (Deferred from v0.5.4.) Closed post-tag: pkgver 0.5.4 with the tarball hash taken from the release’s SHA256SUMS asset (and verified against it by downloading the published tarball: digest identical); the recipe installs both halves - gpty (CLI) and gpty-gui (GUI) with gpty-gui.pck and the GDExtension library - under /usr/lib/gpty/, with absolute symlinks for both on PATH; the desktop entry launches Exec=gpty-gui with Icon=gpty; the shipped godot/icon.png is 494x494, so a 256x256 dist/aur/gpty.png derived from it installs at hicolor/256x256/apps/gpty.png; both license files install under /usr/share/licenses/gpty-bin/ straight from the tarball, so the recipe fetches nothing from the source tree. makepkg -f builds gpty-bin 0.5.4-1 with the source checksums verified, and the packaged CLI reports gpty 0.5.4 run out of the built package.

v0.5.6 - CLI Semantics & Single Instance

Done Area Item
✅ IPC A second GUI instance hijacks the control socket - IpcServer::serve() unlinks an existing socket after checking only that it is a socket owned by this UID (never that anything is still listening), so a second instance silently takes the path and orphans the first: both windows keep running, every CLI command goes to the newest one, and the older instance’s control surface is gone with no message anywhere. Measured on the v0.5.5 download: two gpty-gui processes, two listeners bound to /run/user/1000/gpty.sock, the older one’s inode unlinked (ss -xlp shows both pids; ls shows one path entry). The trigger is on the CLI side - daemon::ensure_running() probes with a one-second version call and spawns a GUI on any failure (_ => spawn), so a transient stall during the probe, not a dead GUI, produces the second instance: ./gpty new-pane from the extracted bundle created a new GUI instead of adding a pane to the running one. Fix: (a) in serve(), distinguish stale from live before replacing - try UnixStream::connect and refuse to bind when a server answers (the existing checks already fail closed for a non-socket or another user’s file; a live one is the same rule); (b) a GUI whose control server refuses to bind must not keep running as a window with no API - log and quit, which makes the spawned-instance case self-correcting, since the CLI’s wait loop then reconnects to the original; (c) in ensure_running(), spawn only when the socket is genuinely dead (refused / no such file) and retry once on a timeout, so a busy GUI is never duplicated. Tests: binding over a live listener must fail and leave that listener reachable; a stale socket must still be replaced (the planted foreign-file case is already covered); a successful probe must never spawn. Also apply the rule to the event socket (gpty-events.sock was stolen the same way - two listeners on that path too): one instance, both sockets, or neither. Recovery is manual until this lands: the orphaned instance cannot rebind (its path entry is gone), and closing the newer one leaves a stale socket the CLI reads as dead and answers by spawning again - close both, start one. Closed: serve() now probes the path and refuses a socket a server answers on (AlreadyExists), replaces only a refused one (a crash leftover - still covered by a test), and fails closed on anything else; the event socket takes the same path and the Windows pipe branch refuses a taken name instead of spinning. An instance that cannot bind records why and sets the existing shutdown flag, so it quits through the scene tree with a toast (OS.alert blocks on zenity - measured - so headless quits at once). Tests: the probe decision table, a refused bind leaving the live server reachable, a stale socket still replaced, the refused-bind flag/reason in gdext, and a live smoke step (a second GUI must exit with the first still answering) that runs on Windows too. That step found a second defect the Unix tests cannot see: the FILE_FLAG_FIRST_PIPE_INSTANCE claim was process-global, so whichever of the two servers created a pipe first consumed it and the other created a second instance of a name another GUI owned (Windows allows that) - it is per endpoint now. Verified: windows-smoke green (GUT 491 + the new step), Linux green.
✅ BUILD test_a_follower_is_primed_on_attach_and_told_every_change flakes on windows-smoke - one run of 7c0f4f4 failed with ["idle"] expected to equal ["needs-attention"] (491 tests, 1 failing) and the same commit passed on the next rerun, while the two runs before it passed that step too. The test sets the state through the FFI (body._terminal.set_agent_state(...)) and waits for the follower’s seen list to grow, so the observed idle was appended after the transition: either the follower was re-primed with the tracker’s current state (a source re-resolve appending a duplicate late), or the transition did not reach it inside _wait_until’s 3 s budget and the last value came from the priming alone. Both live in the dispatch chain (TerminalPane._poll_agent_state on its slow cadence → workspace._dispatch_agent_state → _prime_new_pane_observers), which the test observes only through the follower’s own list. Fix direction: wait for the value rather than the list size, with a budget sized for the slow poll on a loaded runner, and - if the investigation shows a re-prime after a change - pin that separately, because a pane re-pointed at the same source must not receive a stale duplicate. Evidence: windows-smoke in runs 35288934335 and 35289527651, failing assertion in test_agent_state_contract.gd. Closed: the duplicate was the source’s, not the test’s timing: TerminalPane._poll_agent_state compared the tracker against a shadow that started empty, so the first slow poll after the entry prime re-announced the spawn state as a change - every observer (and the badge) heard idle twice, which satisfied a size-based wait and then failed the value assertion (run 35289527651 attempt 1: ["idle"] expected to equal ["needs-attention"], failing at test_agent_state_contract.gd:99; the other cited run’s windows-smoke failure was the second-instance smoke step, not this flake). Fixed at the source: on_agent_state_changed records the delivery in _badge_state, the poll reports only deltas against it, and its first read before any delivery is adopted silently so the entry prime stays the single delivery of the spawn state - true in either order, since the prime scan and the poll can meet in the same frame. The test waits for the value (5 s, many 0.25 s slow polls) and pins the exact sequence ["idle", "needs-attention"] (one entry delivery + one change). Evidence: a forced-ordering repro showed ["idle","idle"] before the fix and ["idle"] after; scripts/gut-check green (48 scripts, 491 tests).
✅ CLI CLI auto-spawn semantics: a query must not start a workspace - reported on the v0.5.5 download: ./gpty list-panes launched a GUI. Not an accident of one command: main.rs routes every command that is not handled early through daemon::ensure_running(), which spawns a GUI whenever its 1 s version probe fails. The harness already knew the rule - scripts/smoke_pane_api.py passes --no-daemon to its daemon status/daemon stop probes with the comment “a probe must not auto-spawn a second GUI” - and the CLI violates it in both directions: daemon status can create the daemon it then reports as running, daemon stop opens a window in order to stop it, pane-wait spawns a GUI and then blocks out its full timeout against a pane that never existed, and list-panes/pane-read/pane-status/pane-run/inject/broadcast/kill-pane/focus-pane/layout list/layout save/concept list/concept toggle all address state a fresh GUI cannot have, so the spawn adds a window and a misleading error. Rule to implement: spawn only when a fresh GUI can satisfy the command. Spawn: daemon start (explicit, in its own arm - today it only prints that the auto-spawn should have handled it), new-pane, layout load <name> (“start this saved workspace”), plugin install (already its own call; the review dialog is the point). Never spawn - fail fast with error: no gpty GUI is running (start one with gpty daemon start), exit 1: every query (list-panes, pane-read, pane-status, pane-wait, layout list, concept list, daemon status), every pane-targeted mutation (kill-pane, focus-pane, inject, broadcast, pane-run, layout save), concept toggle, and daemon stop. Unchanged: version/schema/--skill are local-only, state uses the event socket, the plugin admin actions touch only the store, and mcp never spawns (its tools share one client; an agent bootstraps with the daemon-start tool) - a deliberate difference from the CLI that the docs must state. Edge cases to pin with tests: daemon status never creates a socket; daemon stop with nothing running is a clear no-op; two concurrent new-pane calls still yield one GUI (the loser reconnects to the winner - needs the single-instance bind above); a spawn that cannot start (headless/CI, no display) reports why instead of waiting out the timeout with the child’s stderr discarded; --no-daemon keeps meaning “never spawn” everywhere; and the --no-daemon error path becomes the shared message for the never-spawn set. Closed: the gate is per command and exhaustive (main.rs::may_autospawn): new-pane and layout load may start a GUI (as may plugin install, in its own arm), everything else never does, and --no-daemon refuses even those. The never-spawn set fails with the shared no gpty GUI is running (start one with gpty daemon start), exit 1 - daemon::is_not_running classifies ClientError::Connection(NotFound | ConnectionRefused) anywhere in the error chain, so a refused insecure path keeps its own message. daemon moved to its own arm: start is the explicit spawner (it refuses under --no-daemon), stop with nothing running is an idempotent no-op (exit 0), and status can never create what it checks; MCP never auto-starts a GUI - daemon-start is the bootstrap, and a tool call that finds none answers with the hint plus “call the daemon-start tool first”. Spawn mechanics hardened on the way: a GUI is started only when the probe says the endpoint is genuinely empty (a listener that does not answer is waited out - the single-instance rule makes a duplicate pointless), the child’s stderr goes to a private O_EXCL temp file so a GUI that cannot start reports why immediately, and a GUI that binds while a call waits is adopted (the concurrent-new-pane property). Tests: policy table per command, hint classification (context wrapping + refused-path negative), spawn-once/timeout, dead-GUI stderr, adopt-on-appear, status/stop no-spawn - 75 CLI tests green; scripts/ci-check green (live smoke included); live CLI checks against a sandbox socket for every path above. Docs: README, the manual test table, skills/gpty/SKILL.md, AGENTS.md. Queued from this work: --json status prose on the failure paths, and a same-frame duplicate in agent-state dispatch.
✅ PLUGIN Plugin [[concepts]] never reach the engine - the manifest validator accepts them (round-tripped through concepts_from_json), the install review counts them ("concepts": manifest.concepts.len() in commands/plugin.rs; PluginReviewText prints “Concepts: N”), and docs/content/plugins.md tells authors a plugin can “ship pure data - profiles, concepts, event subscriptions”, but nothing delivers them: the store record persists profiles only (PluginRecord in plugin_store.rs), the GUI’s single store reader shapes profiles only (installed_plugin_profiles → profiles_json), and ConceptManager reads exactly res://concepts.default.json + user://concepts.json - an installed plugin’s concepts are listed to the user and then dropped. [[events]] and [[link_handlers]] are the same shape (validated and summarized in the review; no consumer outside commands/plugin.rs + plugin_manifest.rs). Decide: wire them the way profiles were wired (the CLI is the only manifest reader - carry the validated entries in the record, hand them to GDScript, merge with provenance = plugin id + pinned revision and a defined precedence against shipped defaults, removed on disable/uninstall), or declare the sections review-only, drop them from the vocabulary, and correct the docs sentence. Found 2026-10-09 while checking whether godot/profiles.default.json/godot/concepts.default.json are still needed after the plugin move - they are, and this gap is why concepts.default.json remains the only shipped concept source that reaches the engine. Closed: decided per layer. Concepts are wired the way profiles were: the record carries the validated entries (concepts: [...], cap 128, closed CONCEPT_KEYS, absent reads empty - strict reader and a write-path mirror in plugin_store.rs), the new static GptyTerminal.installed_plugin_concepts() shapes the enabled records as {plugin_id, revision, name, concept} (plugin_concepts_json, tested), and ConceptManager.refresh_plugin_concepts() merges them between the shipped defaults and the user’s own: a plugin name yields to a default or an earlier plugin with (n) (so uninstall and re-install give the bare name back), a user entry naming a rule is that rule’s overlay - which is what makes the Settings toggle/edit/delete work on installed content, delete writing a disabled overlay rather than touching a file the plugin owns - the Settings row carries provenance, and the visual canvas excludes plugin rules (its no-entry/tombstone save semantics are defined for the defaults and the user’s rules only). Refresh fires at init, after an accepted install review (the same deferred re-read as profiles), and on pluginsChanged. The install review names each rule and what it would do ({name, enabled, notify_only, targets}) instead of printing a count. [[events]] and [[link_handlers]] are declared only - decided in the item below: reviewed declarations, never dispatched. Evidence: 499 GUT tests green (merge precedence, name dedupe against defaults and among plugins, toggle overlay, unusable entries skipped, refresh replaces-and-emits, review naming, deferred install pickup, pluginsChanged arm refresh); Rust 80 + 46 (store roundtrip/caps/shape rejection, plugin_concepts_json); the live smoke seeds the record, sees the rule in concept list, fires it from real output (single-line notify → event), and sees it leave on uninstall.
✅ PLUGIN [[events]] and [[link_handlers]] are declared and reviewed but never delivered - the validator accepts and caps both (events ≤64 against the closed event-socket vocabulary, link handlers ≤16 with argv commands) and the install review displays them, but no runtime consumes them: nothing runs a plugin action when a subscribed event fires, and nothing opens a link through a handler. This was decided separately from [[concepts]] (2026-10): concepts are inert data (capture/display), while both of these are execution paths - an event-triggered action runs GPTY_BIN_PATH <action> … as the user on events that terminal output or a pane’s program can provoke, and a link handler runs argv on a user click, which needs its own per-scheme consent boundary. Decide with a trust design: wire events through an explicit subscription review (which action, which event types; events are low-volume metadata, but state.declared is claimable by anything that prints, so authority must be stated) and link handlers behind a Workspace Trust-style consent, or drop the sections from the accepted vocabulary at the next schema break and correct the docs. Until then the docs say “declared, not delivered” and the review shows them as such. Closed (decided 2026-10): keep both sections as reviewed declarations; build neither runtime. Events: gpty ships the primitives - the event socket (subscribe/eventsPoll, open to same-UID processes; a plugin program launched in a pane already receives GPTY_EVENT_SOCKET) - and an event→plugin-action dispatcher stays out of core by the ADE boundary (observe/display, never orchestrate) and by the execution-primitive stance (it would run user-level commands on signals that terminal output or a Tier-2 declaration can provoke). Link handlers: the Markdown view opens only http/https/mailto through OS.shell_open after a confirmation and ignores every other scheme, so nothing consults these entries; a click path would need an env-stripped argv spawn surface (Godot’s own process spawn cannot drop GPTY_SECRET or a pane’s event capability), a reserved-scheme policy, and per-click consent - and no consumer exists. What shipped: the install review renders every declared-only section as such (Listens for (declared; the plugin reads gpty's event socket itself), Link handlers (declared; gpty runs none of these), Build/Startup command (declared, not run by gpty)), docs/content/plugins.md and AGENTS.md state the model, and a review-text test pins the wording so the consent surface cannot drift into implying delivery. Kept in the vocabulary rather than dropped: a 0.5.x patch is no place for a published-schema break, and the declarations carry review value; pruning stays a deliberate schema-break decision. The author-facing event-socket reference is the v1.0.0 docs item - no separate follow-up queued. Evidence: claim checks (markdown_view.gd::_confirm_link’s allowlist; transport::default_event_socket_path; pty.rs’s trusted GPTY_EVENT_SOCKET injection) and 500 GUT tests green.
✅ CLI A stale CLI on PATH fails opaquely - found 2026-09-17 on the v0.5.5 release: gpty plugin install … answered error: unrecognized subcommand 'plugin' and gpty version printed gpty 0.5.0 while the running GUI was the freshly downloaded 0.5.5 bundle. The bundle was correct and the cause was environmental - a cargo install --path crates/gpty-cli from an older checkout (Sep 9, the pre-plugin build) shadowing the bundle’s CLI on PATH - but nothing in the product said so. The CLI already reads the GUI’s version (the IPC version response: daemon status prints “gpty GUI is running (v0.5.5)”) and never compares it with its own CARGO_PKG_VERSION, so the only signal was a clap error for a subcommand that exists in the release the user was running. Fix: after a successful IPC connect, when the GUI’s reported version differs from the CLI’s, print one stderr line naming both and the remedy (reinstall the CLI, or use the one shipped beside the GUI). Keep it to a real mismatch - a warning on every command of a healthy install is noise - and leave gpty version local-only (it cannot probe a GUI, by design). Same pass: a line in CONTRIBUTING/README that a dev-installed CLI must be reinstalled after a version bump; the stale binary here survived three releases because the repo directory moved (/mnt/App/Projects/gpty → …/godot-pty/gpty) and nothing re-ran the install. Closed: every command that talks to a GUI compares it with itself. The check rides on version responses the CLI already reads (the spawn gate’s probe, daemon status), and a command that has not probed runs daemon::warn_on_version_mismatch first - one probe with a 500 ms budget that can never fail or gate the work; the MCP server warns the same way at startup. One stderr line, once per process: IPC protocol first (a GUI from before the field existed falls through to the version comparison), then a direction-aware version line (older CLI → reinstall it; older GUI → update the GUI; unparseable numbers → a generic “same release”). gpty version stays local-only by design. The protocol string now has one definition - gpty_ipc::protocol::PROTOCOL_VERSION, consumed by the server’s version handler, the CLI’s version output and the check, and the About tab via the new static get_ipc_protocol_version() (the tab had a third hardcoded 2.0). Docs: README and CONTRIBUTING carry the dev-install reinstall rule the item asked for. Evidence: the line-builder test (protocol-first, both directions, unparseable, nothing to compare), a live fake-GUI run (newer GUI → the warning; matching pair → silent; other protocol → the protocol line), 501 GUT tests (the About label pinned to the FFI), and scripts/ci-check.
✅ AGENT Agent-state dispatch can duplicate a follower’s entry delivery - _dispatch_agent_state delivers to every pane whose agent_state_source_id() matches, including one the frame’s _prime_new_pane_observers scan has not primed yet: a follower that joins the panes group in the same frame a terminal’s slow poll emits, with the workspace’s _process (the scan) running after that emit, hears the change and then the scan’s entry delivery of the same value (needs that same-frame ordering and the tracker to be unchanged when the scan runs; display-only, so it is not what the flake above observed, but it is the same once-on-entry-plus-every-change contract). Found while fixing that flake. Fix direction: dispatch changes only to panes the scan has already primed (it runs every frame, so an entry delivery lags at most a frame), or record the last-dispatched value per (observer, source); keep the re-point and late-terminal tests green. Closed: _agent_state_primed now stores [source_id, last_state] per observer and every delivery goes through _tell_observer, the one delivery point: the scan’s entry goes to the new pane alone (it used to broadcast, re-telling settled observers whenever any pane attached), a change dispatch reaches only panes already settled for that source (an unprimed follower cannot hear a change first and then its entry as a duplicate), and a repeat of the value an observer holds is dropped - the dispatcher-side mirror of the terminal’s own _badge_state rule. The re-point and late-terminal paths are unchanged (the source half of the record is what re-primes them). Evidence: three new contract tests (a change dispatched before the entry prime → dropped, the entry still delivered once; a second follower attaching → the settled one not re-told; a repeated dispatch → dropped, a real transition lands), the pre-existing exact-sequence, vocabulary, badge, re-point, late-terminal and observes-nothing tests, gut-check, and scripts/ci-check.
✅ CLI gpty daemon status --json answers prose when the GUI is not running - the running path prints the raw version response envelope (parseable JSON), but the not-running, busy, and refused paths print plain sentences (gpty GUI is not running.) with no JSON at all, so a script that probes with --json gets unparseable output exactly when the probe matters. Give those paths a JSON shape (e.g. {"status": "not running"} / {"status": "busy"} / {"status": "error", "error": …}) while keeping exit 1; the MCP daemon-status tool already answers shaped objects. Found while making daemon status spawn-free (v0.5.6). Closed: status_answer + run_status give every failing path a shaped object under --json - {"status": "not running"} / {"status": "busy"} / {"status": "error", "error": …} (refused paths and auth included) - with exit 1, while the running path keeps the raw version response the smoke parses (result.version) and the plain path keeps its sentences. The statuses are the MCP daemon-status vocabulary, so a script can move between transports. Evidence: the shape test (all four failing outcomes, and running → None), a live run (dead socket → not running; a refused path → the error naming it; plain mode unchanged; a fake GUI → the raw envelope with result.version), and scripts/ci-check.
✅ PLUGIN Plugin repos’ CI runs the authoritative validator - the five first-party plugin repos (gpty-omp, gpty-herdr, gpty-lazygit, gpty-neovim, gpty-claude) still check their manifests with an inline tomllib + id script; gpty plugin validate <path> is the shipped validator now (verified against all five manifests locally), and v0.5.5 shipped it, so their CI can install it at a pinned ref now. Switch each repo’s .github/workflows/validate.yml to install the CLI at a pinned ref (cargo install --git https://github.com/godot-pty/gpty --rev <sha> gpty, the git SHA being the pin per the supply-chain rule) and run gpty plugin validate .; the inline script’s whole job was to pin the TOML parse + id contract until this existed. Release-time work, deferred from the profiles-migration item whose closure records the command and the local verification. (Deferred from v0.5.5.) Closed: each repo’s .github/workflows/validate.yml now installs the CLI from the v0.5.5 commit (cargo install --git https://github.com/godot-pty/gpty --rev 2f174598561540d2b0c6836c57d81182e99bfa61 --locked gpty - the SHA is the pin, per the supply-chain rule) and runs gpty plugin validate ., the same parse_manifest the install path runs; the inline script reduces to the one assertion the validator cannot make (the install gate requires id to equal the install target, and only the repo knows which target it is). Verified: the pinned CLI validated all five manifests from the clones, and the new workflow passed on all five pushed commits - gpty-omp c15d625, gpty-herdr 05f9480, gpty-lazygit b24f77c, gpty-neovim df64788, gpty-claude ae3ef84 (cargo checkout cached per revision key).