v0.5.5 - 2026-09-17
Added
cli_viewpanes. 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 samevalidate_programrule as a terminal’s and stripped ofGPTY_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.tomldeclaresid(owner/name, immutable while the repo exists),name,version,min_gpty_version,platforms,build/startupargv, 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 mirrorPaneTypes.sanitize_tilewith 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 Rustregexcrate 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 thegodot-ptyorg. - 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_versionmust 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-pluginconfig/state/logsdirectories are created, and the record - including the validated profiles - lands instate_dir()/plugins.jsonwith the revision pinned to a commit SHA, so a moved tag is a new revision and re-reviews.list,enable,disable,uninstall,logsandrun <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.jsonfor those plugin repos, soAgent Workspaceis 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 listandlayout loadsee 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-pluginsis the canonical index -registry.jsonplusvalidate.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, andgpty plugin installnever consults it. - Agent state is part of the pane contract.
PaneBody.agent_state_source_id()names the terminal a pane observes by stableattachment_id, andon_agent_state_changed(state)receives that terminal’s state;workspace.gdis 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 loadover 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_enva saved tile carries (with a toast naming what was dropped) instead of adopting it, and per-pane env lives inuser://pane_env.json, keyed byattachment_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 itsPATHor aPROMPT_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 onPATH), points the desktop entry atgpty-gui, installs the icon fromgodot/icon.png, and takes the tarball hash from the release’sSHA256SUMSasset.
Fixed
- A locked history store no longer drops the batch it was committing. Two panes committing at once could make SQLite return
SQLITE_BUSYpast the 5 sbusy_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_viewerfailed (and so didfile_treeandcli_view).PaneTypeserialized kebab-case (code-viewer) while every other surface - the GUI’sPaneTypes.ALL, the CLI’s accepted names, the manifest validator, the docs - usedcode_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 (Serializewritesas_str(),Deserializegoes throughparse()), the CLI’s and the validator’s lists are derived fromPaneType::ALL, and a test parsesPaneTypes.ALLout 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
conceptevent 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 honorspaneWait’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 reportingrunning: truewith no exit code, forever, and every consumer that waits for an exit hung. Liveness is now part of the document (exit_reasonisexited,task_endedor null while running), built in core so the pane API’s promise and the tested artifact are the same bytes. - A failed
resize_griddesynced 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
GptyTerminalper push.ConceptManager._push_to_rustfabricated an instance throughClassDB.instantiate("GptyTerminal")as a vehicle for a call on a static accessor, growingObjectDBon every settings save, concept toggle and editor save. The engine-global accessors are called statically now, andscripts/check-ffi-surfacecovers class-name calls too (61 → 90 checked call sites), which is what had let that call site go unverified.