Skip to content
v0.5.5 - 2026-09-17

v0.5.5 - 2026-09-17

Added

  • cli_view panes. A pane type that runs a program plus argv - never a shell string - and streams its merged stdout and stderr into the pane body as plain text: no PTY, no stdin, no ANSI interpretation, so no sequence from the child can move a cursor or change what is drawn. Spawnable from the CLI (gpty new-pane --type cli_view --command prog --arg …), the palette, and profile or plugin tiles (which pass the Workspace Trust gate, because naming a program is what the gate is for). The child is held to the same validate_program rule as a terminal’s and stripped of GPTY_SECRET/GPTY_SOCKET/the event capability; the pane keeps the stream after the child exits and reports the exit through the same status document as a terminal. This is the v1 plugin UI: a plugin’s [[profiles]] can render a status command in a pane without a Godot SDK.
  • Plugin manifests, and a validator that is the shipped parser. gpty-plugin.toml declares id (owner/name, immutable while the repo exists), name, version, min_gpty_version, platforms, build/startup argv, and five array-of-table sections: actions (≤64), events (≤64), link handlers (≤16), concepts (≤128) and profiles (≤64 profiles × ≤64 tiles). Validation keeps the same posture as every other untrusted-input path: size and count caps, a closed key set, argv never a shell string, tiles that mirror PaneTypes.sanitize_tile with rejection instead of clamping, actions limited to the published CLI surface (the same derivation the 19 MCP tools come from), events limited to the event-socket vocabulary, and every concept round-tripped through the engine’s own parser - a trigger the Rust regex crate cannot compile (a PCRE2 lookbehind, say) rejects the manifest rather than being silently dropped. gpty plugin validate <path> runs it offline (--json, exit 1 on rejection), so a plugin repo’s CI can check a manifest before anyone installs it. The first-party tool layouts live one repo per tool under the godot-pty org.
  • Plugin install, review, and lifecycle. gpty plugin install <owner>/<repo>[@ref] clones with git argv into a staging dir, parses and gates the manifest (the id must equal the install target, the platform must be declared, min_gpty_version must not exceed the CLI), and then asks a human: the summary is sent to the GUI, which lists every action, event, link handler, concept and profile the plugin declares - including the programs each profile would run - beside the ref you asked for and the revision it resolved to. Declining, disconnecting, or having no GUI means nothing installs, and the CLI’s deadline for that answer is 300 s rather than the usual 5, carried with the request so the dialog closes itself when the request dies. On acceptance the content moves to <data_dir>/plugins/<id>, per-plugin config/state/logs directories are created, and the record - including the validated profiles - lands in state_dir()/plugins.json with the revision pinned to a commit SHA, so a moved tag is a new revision and re-reviews. list, enable, disable, uninstall, logs and run <id> <action> manage the store; an admin action notifies a running GUI (never starts one) so its profile list has caught up before the command returns.
  • Profile layouts now come from plugins. The shipped tool layouts - herdr, lazygit, nvim, claude, OMP - left godot/profiles.default.json for those plugin repos, so Agent Workspace is the only built-in and a first run opens no dialog. Installed plugins’ profiles appear in the sidebar between the built-ins and the user’s own, with their provenance in the tooltip (plugin id and pinned revision), no delete or rename affordance, and the (n) dedupe re-derived whenever a user profile takes or frees a name - a collision used to resolve to the plugin’s row for the rest of the session, so clicking the user’s row activated the plugin’s profile. layout list and layout load see them, a plugin approved at its pinned revision activates without re-asking (a newer revision re-prompts), and a catalog hint row appears while no plugin profile is installed. Shipped files are never written back: a plugin profile vanishes with its plugin.
  • A plugin registry, and a page that lists it. godot-pty/gpty-plugins is the canonical index - registry.json plus validate.py, a closed key set per entry, checked weekly against every listed repo’s manifest at HEAD so a vanished or renamed repo is noticed - and the docs site renders it at /plugins/, fetched at build time with a committed snapshot as the offline fallback (the build warns when it falls back, so a stale page is visible). The registry is display data: nothing in it executes, and gpty plugin install never consults it.
  • Agent state is part of the pane contract. PaneBody.agent_state_source_id() names the terminal a pane observes by stable attachment_id, and on_agent_state_changed(state) receives that terminal’s state; workspace.gd is the only dispatcher - it delivers on real transitions and primes every new observer, including a follower whose terminal appears later or one re-pointed at a different source, without re-priming settled panes. The titlebar badge is the first implementation and runs through this path rather than beside it. Display only: a Tier 2 declaration is spoofable by anything that prints and Tier 3 is a heuristic, so state never feeds a decision, an action, a capture or IPC.
  • Workspace Trust remembers. TrustedStore (user://trusted.json) short-circuits the gate for what was already approved: a shipped built-in at this app version, a tile whose exact spawn plan (program + argv) an approval covers, or a plugin at its pinned revision. A new version or revision re-prompts, content that arrived any other way (user-authored profiles, layout files) keeps asking, and only the trust dialogs’ confirm paths write the record. layout load over IPC keeps refusing an untrusted profile - a caller cannot answer a dialog, and naming a profile is not consent to what the file asks for.
  • A pane’s environment belongs to the user, not to a file. Restore strips any shell_env a saved tile carries (with a toast naming what was dropped) instead of adopting it, and per-pane env lives in user://pane_env.json, keyed by attachment_id, written only by the pane settings UI and overlaid last at spawn. Profiles lose the ability to carry env by design - a profile that needs one points at the shell rc, or asks the user to set it once - which closes the path where a downloaded layout handed a pane its PATH or a PROMPT_COMMAND.
  • A clock in the status bar, beside the version; the window-mode readout moved off it (the sidebar owns that switcher).

Changed

  • The AUR recipe follows the 0.5.4 bundle layout. It installs the CLI and the GUI side by side (/usr/lib/gpty/{gpty,gpty-gui,gpty-gui.pck,libgpty_gdext.linux.x86_64.so}, both on PATH), points the desktop entry at gpty-gui, installs the icon from godot/icon.png, and takes the tarball hash from the release’s SHA256SUMS asset.

Fixed

  • A locked history store no longer drops the batch it was committing. Two panes committing at once could make SQLite return SQLITE_BUSY past the 5 s busy_timeout, and the writer logged the failure and dropped the batch - scrollback that the store’s own retention window would have kept. The writer now waits for the lock (bounded by the pane’s own task) and commits. Measured before the fix: 7 of 15 batches dropped under contention; 0 of 15 after.
  • gpty new-pane --pane-type code_viewer failed (and so did file_tree and cli_view). PaneType serialized kebab-case (code-viewer) while every other surface - the GUI’s PaneTypes.ALL, the CLI’s accepted names, the manifest validator, the docs - used code_viewer, so a multi-word type reached the GUI as a key it does not know. The enum now has one spelling in both directions (Serialize writes as_str(), Deserialize goes through parse()), the CLI’s and the validator’s lists are derived from PaneType::ALL, and a test parses PaneTypes.ALL out of the GUI source and compares key for key - the previous count-only checks would have passed a rename on either side.
  • Windows concept matching was dead. ConPTY renders line breaks as cursor moves rather than LFs, and the discard-only line parser committed a line only on LF, so no output line ever reached the concept matcher - a shipped or user-defined trigger could not fire in a Windows pane. The parser now commits on the cursor-move breaks ConPTY emits (and still ignores a redraw), and the smoke asserts a routed concept event from real output on whichever platform runs it.
  • gpty pane-wait --socket <path> ignored the flag - which is global on the CLI - and talked to the default socket. It reaches the resolved socket now, and the server’s fallback honors paneWait’s own deadline instead of ending a long wait early.
  • A pane whose terminal task died looked like a running one. Nothing awaited the task’s JoinHandle, so a panic inside it - or a child that closed its pty and outlived the pane - left the status document reporting running: true with no exit code, forever, and every consumer that waits for an exit hung. Liveness is now part of the document (exit_reason is exited, task_ended or null while running), built in core so the pane API’s promise and the tested artifact are the same bytes.
  • A failed resize_grid desynced a pane’s size permanently. The grid and the PTY are now resized as one transaction (false = refused, nothing moved, PTY included), the pane keeps its own dimensions in step with the backend, and a resize is applied from the size at debounce time rather than a stale one.
  • The concept push leaked a GptyTerminal per push. ConceptManager._push_to_rust fabricated an instance through ClassDB.instantiate("GptyTerminal") as a vehicle for a call on a static accessor, growing ObjectDB on every settings save, concept toggle and editor save. The engine-global accessors are called statically now, and scripts/check-ffi-surface covers class-name calls too (61 → 90 checked call sites), which is what had let that call site go unverified.