Skip to content
Agent Guide

Agent Guide

Rust + Godot multi PTY emulator with a tiling grid GUI.

Project Structure

  • Stack: Rust (edition 2024) backend + Godot (4.7) with a GDScript frontend via gdext (0.5)
  • Entry point: godot/scenes/main.tscnworkspace.gd (root Control node)
gpty/
├── Cargo.toml                  # Workspace root
├── AGENTS.md
├── LICENSE
├── LICENSE-EXCEPTIONS.md       # GPLv3 §7 permissions: plugins + data stay non-copyleft
├── scripts/                    # CI runner and setup scripts
│   ├── ci-check                # Run all CI checks locally (--fast for quick)
│   ├── install-hooks           # Symlink githooks into .git/hooks/
│   └── build                   # Build standalone app for the host platform
├── githooks/                   # Git hook scripts
│   ├── pre-commit              # Fast checks (fmt, workflow lint, clippy)
│   ├── pre-push                # Full CI suite before push
│   └── commit-msg              # Conventional Commits enforcement
├── crates/
│   ├── gpty-ai/                # Inspector backends (mock, private OMP RPC, CLI NDJSON bridge; no tools)
│   ├── gpty-core/              # PTY spawning, ANSI parsing, alacritty_terminal grid, capture
│   │   └── src/
│   │       ├── lib.rs          # Module map + data-flow diagram
│   │       ├── types.rs        # Concept, Action, CaptureMode, CapturedOutput
│   │       ├── concept.rs      # Trigger matching + capture routing (pure fns)
│   │       ├── engine.rs       # WorkspaceEngine, capture state machine, SpawnedTerminal
│   │       ├── pty.rs          # portable-pty spawn + dedicated I/O thread
│   │       ├── parser.rs       # vte → plain-text LineParser
│   │       ├── term.rs         # alacritty_terminal grid + CellInfo + damage tracking
│   │       ├── color.rs        # ANSI color → RGB
│   │       ├── keymap.rs       # Key event → byte sequence
│   │       └── history.rs      # SQLite scrollback store
│   ├── gpty-cli/               # CLI workspace control over JSON-RPC IPC
│   │   ├── Cargo.toml
│   │   └── src/
│   │       ├── main.rs         # clap CLI entry point
│   │       └── commands/       # Subcommand handlers
│   │           ├── mod.rs
│   │           ├── new_pane.rs
│   │           ├── list_panes.rs
│   │           ├── kill_pane.rs
│   │           ├── focus_pane.rs
│   │           ├── inject.rs
│   │           ├── pane_read.rs
│   │           ├── pane_status.rs
│   │           ├── pane_run.rs
│   │           ├── pane_wait.rs
│   │           ├── broadcast.rs
│   │           ├── schema.rs
│   │           ├── mcp.rs
│   │           ├── concept.rs
│   │           ├── daemon.rs
│   │           └── layout.rs
│   ├── gpty-ipc/               # Shared JSON-RPC 2.0 IPC transport, client, and server
│   │   └── src/
│   │       ├── protocol.rs     # JSON-RPC 2.0 Request, Response, JsonRpcError
│   │       ├── types.rs        # IPC domain types (NewPaneParams, PaneInfo, …)
│   │       ├── transport.rs    # Platform socket connection (Unix, named pipe)
│   │       ├── server.rs       # Async IPC server with handler registry
│   │       └── client.rs       # Async IPC client with connect/call/timeout
│   └── gpty-gdext/             # GDExtension cdylib: GptyTerminal + GptyAi + GptyMarkdown
│       └── src/
│           ├── lib.rs          # GptyTerminal GodotClass
│           ├── ai.rs           # Per-pane GptyAi session bridge
│           ├── ipc.rs          # Workspace-control IPC server
│           ├── omp_events.rs   # Capability-scoped OMP event socket
│           └── markdown.rs     # Markdown → sanitized BBCode
├── extensions/
│   └── gpty-omp-events/        # Explicit OMP plugin; dormant unless GPTY_EVENT_* is set
├── skills/
│   └── gpty/SKILL.md           # Bundled agent skill, printed by `gpty --skill`
└── godot/                      # Godot 4.7 project
    ├── project.godot
    ├── gpty.gdextension
    ├── concepts.default.json   # Shipped default concepts
    ├── profiles.default.json   # Shipped recommended layouts (Agent Workspace)
    ├── fonts/                  # DejaVu Sans Mono + Phosphor icons
    └── scenes/
        ├── main.tscn
        ├── autoloads/          # Singleton managers
        │   ├── base_persistence_manager.gd
        │   ├── settings_manager.gd
        │   ├── profile_manager.gd
        │   ├── concept_manager.gd
        │   ├── workspace_store.gd  # Named workspace (tab set) persistence
        │   ├── focus_manager.gd
        │   ├── toast_manager.gd
        │   ├── shortcut_manager.gd
        │   └── update_checker.gd
        ├── terminal/
        │   ├── workspace.gd        # Root controller, workspace switching, concept routing, profile restore
        │   ├── ipc_handlers.gd     # WorkspaceIpcHandlers — pane-API/IPC method dispatch (extracted)
        │   ├── concept_router.gd   # Pure concept-event routing (extracted, testable)
        │   ├── terminal_pane.gd    # Control-based renderer, keyboard, mouse, selection, history search
        │   └── terminal_manager.gd # Tile lifecycle, split/kill/swap/spawn
        ├── ui/
        │   ├── sidebar.gd
        │   ├── settings_panel.gd
        │   ├── status_bar.gd
        │   ├── toast_overlay.gd
        │   ├── window_chrome.gd    # Titlebar + window-mode/fullscreen handling (extracted)
        │   ├── markdown_view.gd     # Safe, debounced Markdown RichTextLabel
        │   └── icons.gd            # Phosphor icon constants
        └── panes/
            ├── pane_body.gd        # Base class + routed-content contract
            ├── pane_types.gd       # Pane registry, sanitizers, palette command list
            ├── code_viewer.gd
            ├── file_tree.gd
            ├── inspector_pane.gd   # Private read-only OMP Q&A
            └── reasoning_pane.gd   # Passive OMP reasoning projection

Commands

See Contributing for all development, build, test, and CLI commands.

Data Flow

Shell → PTY I/O thread → vte parser → alacritty_terminal grid → Arc<Mutex<TermGrid>> → GptyTerminal (gdext) → GDScript _draw()
CLI → Unix socket (gpty.sock) → IpcServer (tokio) → PENDING_IPC queue → GDScript _poll_ipc_requests() → workspace methods → IPC_RESPONDERS → CLI response
OMP TUI (user-launched in a terminal) → @gpty/omp-events extension → gpty-events.sock (ompEvent) → omp_events.rs translates to the generic vocabulary (session.bound, agent.started, turn.started, tool.call, thinking.delta, …) → GptyTerminal.drain_agent_events() → Reasoning pane + Tier 1 agent-state badges
Inspector prompt → GptyAi.session_open/session_prompt → private omp --mode rpc --no-tools --no-session process → session_poll envelopes

MCP Integration

gPTY ships an MCP server (gpty mcp) that exposes 19 tools for AI agent integration. AI agents and coding harnesses can discover these via the mcp.json that’s at the root of the repository.

// mcp.json — Coding harnesses should discover this automatically
{"mcpServers": {"gpty": {"command": "gpty", "args": ["mcp"]}}}

Tools: new-pane, list-panes, kill-pane, focus-pane, inject, layout-save, layout-load, layout-list, daemon-start, daemon-stop, daemon-status, concept-list, concept-toggle, pane-read, pane-status, pane-run, pane-wait, broadcast, version.

tools/call validates the requested name against schema::mcp_tool_names() before dispatch: the kebab→camel mapping turns any string into an IPC method, so without the gate a client reaches methods that were never published as tools (e.g. shutdown).

The MCP tool schemas are auto-generated from clap command definitions in crates/gpty-cli/src/commands/schema.rs. Nested subcommands (daemon, layout) are flattened into prefixed tools. Self-referential tools (mcp, schema) are excluded.

See skill://gpty-omp-integration for usage patterns.

Testing

  • Tests live in godot/tests/unit/ for pure-logic classes, integration/ for scene-tree tests.
  • Mocking autoloads: Use MockAutoloads.setup() / teardown() in before_each/after_each.
  • Persistence managers (SettingsManager, ProfileManager, WorkspaceStore) are mocked via set_script() on the existing autoload node, redirecting _read_file/_write_file to an in-memory Dictionary - avoids touching disk and preserves Godot 4 global name bindings. (SettingsManager etc. are static constants — never free() autoload nodes).
  • Signal testing: GDScript lambdas cannot capture outer primitives. Use GUT’s watch_signals(node) + assert_signal_emitted(node, "signal_name") instead of node.signal.connect(func(): captured_var = true).
  • Type checks: body is SomeClass requires a compile-time class name. For runtime type discrimination, use body._pane_type() string discriminators.
  • Headless resource leaks: GUT warnings about unfreed children and GDExtension RID/ObjectDB leaks are benign in headless mode — the dummy render server doesn’t track GDExtension resources. Production renderer handles these correctly.

Conventions

Rust

  • Edition: 2024 (requires Rust ≥ 1.85)
  • Format: standard rustfmt
  • Async runtime: tokio (global LazyLock runtime in gdext)
  • Grid sharing: Arc<Mutex<TermGrid>> — lock briefly, clone the grid, release
  • Thread Safety: Godot’s SceneTree is strictly single-threaded. NEVER call Godot methods, mutate nodes, or emit signals directly from background tokio threads. Instead, queue the state changes for GDScript to poll, or use Godot’s thread-safe call_deferred().
  • Lifecycle & Teardown: When a GptyTerminal is destroyed (e.g., queue_free() in Godot), the Rust side MUST ensure the spawned shell and background tokio tasks are cleanly terminated (via the Drop trait) to prevent zombie processes or memory leaks.

GDScript

  • Indentation: tabs
  • Icons: All glyphs live in icons.gd as const strings (Phosphor Regular PUA codepoints via \uXXXX). To add: pick from phosphoricons.com, get the codepoint, add a const. Call Icons.style_button(btn) after setting btn.text.
  • Sidebar layout: the sidebar owns every persistent switcher (workspaces, profiles, panes) as vertical sections in its VBox — never add full-window top strips or overlays, they cover sidebar chrome (the window-mode dropdown sits at the sidebar’s top). Sidebar content lives inside a MarginContainer (8px left/right ≈ scrollbar width, 6px vertical) so rows never touch the sidebar’s edges. Icon+text actions are a single Button with a child HBox (icon Label + text Label), both labels at the same font size, a 6px offset_left breathing gap, plus an explicit custom_minimum_size.y (a textless Button does not size from its children and collapses). Section lists share one pattern: SECTION_MAX_VISIBLE_ROWS (5) caps visible rows; _measured_section_height sizes the scroll container from get_combined_minimum_size() (never hard-coded row heights — they drift from theme metrics and truncate the last row); _apply_row_accent marks the active row (accent ACCENT_COLOR + pressed look) and is used identically by workspace, profile, and pane rows. Active-row state: workspaces by index, profiles by name (cleared on _do_reset and on deleting the active profile), panes by _tm.last_body via set_active_pane (no list rebuild on focus change). The pane list absorbs leftover height via SIZE_EXPAND_FILL.
  • Profiles: named terminal-layout snapshots. User data lives in user://profiles.json; shipped layouts live in res://profiles.default.json and are never written back. ProfileManager.get_all_profiles() returns built-ins first. Save dialog is built inline in workspace.gd. Activation clears the workspace (_reset()) then rebuilds tiles — follows the _restore_into() pattern. Built-in profiles cannot be deleted from the sidebar.
  • Attachment IDs: persist attachment_id ([a-z][a-z0-9_-]{0,31}) on panes. Companion panes (Reasoning) attach by this stable id, not ephemeral labels like T1. pane_label is reassigned on restore and must not be used as a saved link. It is also the public IPC id: every pane has one (auto-generated pane-XXXXXXXX via PaneTypes.generate_attachment_id() when none was saved — PaneBody.apply_settings is the choke point), newPane returns it and listPanes reports it as id alongside the display label. IPC pane targeting accepts either id or the legacy label. With multiple workspaces, attachment_ids remain globally unique; label targeting resolves active-workspace-first (workspace.gd:_find_pane_by_label). IPC methods operate on the active workspace’s pane set unless an id targets another workspace’s pane.
  • JSON → typed arrays: JSON.parse() returns untyped Array. Assignment to Array[Dictionary] fails at runtime. Always iterate and build the typed array element-by-element: for item in raw: if item is Dictionary: typed.append(item).
  • Private members: underscore prefix (_cell_w, _settings_panel)
  • Config vars: _cfg_ prefix (_cfg_cursor_shape)
  • Persistence: Managers extend BasePersistenceManager — provides _read_file(path) / _write_file(path, data), sets PROCESS_MODE_ALWAYS. Subclasses override _on_init() instead of _ready(). Never inline FileAccess.open() — use _read_file/_write_file. Writes go to a sibling .tmp and are renamed into place: FileAccess.WRITE truncates the target as soon as it opens, and _read_file reads a truncated file as empty, so an in-place write that fails costs the entire store.
  • Directory layout: Scripts are grouped by role: autoloads/ (7 managers + 1 base), terminal/ (core terminal), ui/ (sidebar, settings, toast), panes/ (specialty pane types). project.godot autoload paths and preload()/load() calls use the full res://scenes/<dir>/<file>.gd path.
  • Settings pipeline: _cfg_*_save_settings()user://settings.json. To add a new setting: (1) add _cfg_ var, (2) add UI control, (3) add one line to _apply_settings_to(). _build_wrapper() calls it automatically — no other wiring needed. Pane-local settings that are NOT terminal settings (e.g. cfg_reasoning_max_turns, cfg_reasoning_max_turn_bytes) are read by the pane from SettingsManager at _ready with clamps — changes apply to panes created afterward; no _apply_settings_to wiring.
  • Terminal spawning: _build_wrapper() is the sole entry point; all paths go through it
  • Layout Constraints: The tiling grid relies on Godot Control nodes. Prefer using Godot’s built-in Size Flags (Expand/Fill) inside containers (HBoxContainer/VBoxContainer) over manual pixel math. When manual math is absolutely required (like terminal cell reflows), hook into _notification(NOTIFICATION_RESIZED).
  • Pane activation & keyboard routing: clicking any pane (body, titlebar, or inner widget) makes it the active pane — workspace _input hit-tests tile wrappers with event.position (raw input, because inner RichTextLabels/ScrollContainers consume clicks before _unhandled_input ever fires). Only terminals take keyboard focus: activating a non-terminal pane releases the previous focus owner so keystrokes stop flowing to the terminal the user just left (read-only panes swallow keys by design — uniform click-to-activate, no fall-through). A focusable child (Inspector input) re-resolves its owning pane each frame in _refresh_status_bar via _body_of_focus_owner. Profile activation must end with _apply_active_workspace_view() — the same refresh path as workspace switching — or restored wrappers stay unlaid-out and the sidebar pane list stale.
  • Capture Bridge: Rust never calls into Godot. GDScript polls the backend from _process()drain_concept_events() for completed captures and drain_agent_events() for Tier 1 agent state.

Concept Capture System

  • Two modes: UntilStop { stop_timeout_ms, stop_on_input } (buffer output until timeout or user input, then route it to a pane) and SingleLine (notify-only: publish the match on the event socket, capture nothing, route nothing, leave the output in the terminal). A missing or unknown capture_mode is UntilStop; a legacy cmd key is ignored on parse.
  • Matching: concept::match_line tests PTY output lines. Precedence is explicit — a capture outranks a notify: it returns the first UntilStop match, or the first SingleLine match when nothing captures, first-wins within each class. (First-match-only plus “notify starts nothing” let a broad notify-only concept consume every line and silently starve the capture behind it.) On UntilStop the engine enters capture mode — subsequent PTY output is buffered, not fed to the grid; on timeout or user input, finalize_capture() queues a CapturedOutput event. On SingleLine the match goes to the notice queue and nothing else happens.
  • Capture lifecycle:
    • PTY output feeds LineParser → lines flow to concept::match_line (engine.rs PTY output handler).
    • On an UntilStop match → engine enters capture state, buffers raw bytes, suppresses grid feed. A typed line that matches is handled on the stdin path the same way. On a SingleLine match → the concept name is queued (ConceptNotice, bounded at 64, drop-oldest) and nothing else happens; workspace.gd drains it and emits {type: concept, event: matched, mode: single_line, name, source} on the event socket. Metadata only — never the matched line.
    • Timeout or user input → finalize_capture() queues CapturedOutput with plain-text lines and target label.
    • GDScript polls via drain_concept_events() each frame, routes to receiver pane by target_pane_type.
    • Receiver found → acknowledge_capture (bytes discarded). No receiver → flush_capture (bytes replayed to grid) + toast.
  • Prompt restoration: Shell prompts lack trailing \n so LineParser never emits them. On acknowledge, raw bytes after last \n are extracted and fed to grid with \r\n prefix for correct cursor positioning.
  • Default concepts: Shipped in godot/concepts.default.json. ConceptManager._merge_concepts() deep-merges defaults + user concepts (user keys overlay default keys). Trigger migration updates old regex patterns to new ones.
  • Routed-content contract: PaneBody.can_receive_content(event) advertises current capability and receive_content(text, event) returns whether delivery succeeded. Routers must continue past declines and acknowledge source bytes only after true; if all receivers decline, flush the capture back to the terminal.
  • Concepts that target Inspector (git_log, cargo_check) ship disabled. Do not re-enable them in defaults: captured terminal output must not be sent to an AI backend without explicit user opt-in. Inspector additionally gates captures behind its own accept_concept_captures export (default false).
  • Legacy observer-target concepts are NOT migrated to inspector: ConceptManager._migrate_actions_target sets enabled = false on any concept whose action targets observer (at merge and save time), so a stale capture can never start an Inspector job.
  • Alt-screen suppression: the engine skips concept::match_line while the grid is in alternate-screen mode (TermGrid::is_alt_screen()). Full-screen apps repaint themselves; their redraw lines are presentation, not shell output (an OMP TUI repaint re-emitting a transcript line containing cat must not fire the cat concept).
  • Resize is not user input: only StdinInput::Line and StdinInput::Raw may stop an active UntilStop capture. Resize/FlushCapture/AcknowledgeCapture share the stdin channel but are internal plumbing; letting them finalize a capture replays old bytes into the grid and fires spurious “no receiver” toasts. There is also a 750 ms post-resize concept-match suppression window (POST_RESIZE_CONCEPT_SUPPRESS_MS) for the shell’s SIGWINCH redraw.
  • Concept event routing: workspace.gd._process() polls all terminal panes, drains events, and tries matching receivers by _pane_type() until one accepts. No accepting receiver → toast + flush.
  • Concepts capture and display; they never execute. A concept definition is untrusted data (a JSON file a user may have downloaded): Concept carries a trigger, a stop condition, and a routing target — no command, no label, no PTY write. The engine’s only writes to a PTY are user input and resize. Terminal ids stay unique process-wide (start_shell draws from a global counter) because they key capture state, status, and logs.
  • Layout migration: PaneTypes.migrate_pane_settings() runs before type validation. Legacy type: observer + stream: thinking becomes reasoning; other observer tiles become inspector. Removing observer from PaneTypes.ALL without this step silently drops saved layouts.

Inspector, Reasoning, and OMP

gPTY is a terminal multiplexer with an observability layer. It does not recreate agent TUIs, scrape PTY output for agent semantics, or host the user’s interactive OMP/Claude/Gemini session.

Surface Role
Terminal User launches omp (or any CLI) normally. The CLI owns its TUI, auth, tools, and permissions.
Inspector Private, tool-free, iterative Q&A. Owns one in-memory GptyAi session; backends: omp (private omp --mode rpc --no-session --no-tools --no-extensions --no-skills --no-rules), mock, or cli (subprocess NDJSON bridge — a configured adapter command, argv never shell-evaluated, documented hooks only). Does not attach to the terminal OMP process.
Reasoning Passive view of documented reasoning/lifecycle events from one terminal, selected by source_attachment_id. Never starts jobs or accepts concept captures. Turn history is an in-memory accordion for the current OMP session only.
@gpty/omp-events Explicitly installed OMP extension. Dormant unless all four GPTY_EVENT_* vars are present. Forwards bounded session/turn/tool metadata plus thinking_delta text only.
  • Inspector and Reasoning are different sessions. The shipped “Agent Workspace” profile opens Terminal + Inspector + Reasoning; only Reasoning is attached to the terminal.
  • Reasoning history is session-scoped RAM (collapsed previous turns, live turn streaming). Closing the pane, changing source_attachment_id, or binding a new omp_session_id drops it. Do not write thinking text or OMP session IDs into layout/profile JSON.
  • The event listener runs on every platform. Workspace control IPC works on Windows (named pipes); the event listener serves \\.\pipe\gpty-events there and gpty-events.sock on Unix, and per-PTY GPTY_EVENT_* injection happens on both. The shipped @gpty/omp-events extension still connects over a Unix socket only — its Windows transport is an ecosystem follow-up, so Reasoning stays idle on Windows until an adapter with a named-pipe transport appears.
  • GptyAi is instance-owned: session_open / session_prompt / session_poll / session_cancel / session_close. There is no process-global subscriber bus. Polled envelopes carry session_id, turn_id, run_id, sequence, channel.
  • Reasoning caps are user settings (Settings → Reasoning tab): cfg_reasoning_max_turns (clamped 1–64, default 16) and cfg_reasoning_max_turn_bytes (clamped 4 KiB–1 MiB, default 64 KiB). reasoning_pane.gd reads them at _ready into max_turns/max_turn_bytes (not consts). When truncating accumulated raw Markdown at the cap, re-close an odd number of ``` fences (_close_open_fences) — an unclosed fence re-renders the tail as code.
  • Do not add Claude/Gemini/Antigravity adapters that read OAuth tokens, call private endpoints, or parse undocumented TUI output. Future adapters may use only that CLI’s documented hooks/extensions, with a fresh terms review.

ADE Architecture Boundary

gPTY is evolving from a multi-terminal emulator into an ADE — a graphical PTY foundation with a public, agent-facing API:

Layer Owner Content
L4 ecosystem Agent CLIs & apps: claude code, codex, opencode, OMP, vim, btop
L3 ecosystem (open market) Orchestration: agent waits, pane states, plugin workflows (herdr-class)
L2 gPTY Control surface: JSON-RPC socket, CLI, MCP, event socket
L1 gPTY PTY foundation: PTY lifecycle, grid, capture, history, security
L0 OS /dev/ptmx, ConPTY, sockets
  • gPTY owns L0–L2; L3 is an open market. gPTY ships a thin built-in L3 (concept routing, Inspector sessions, profile restore) but never absorbs an orchestrator’s state machine into core. Herdr-class tools are guests (they run in panes today) and substrate consumers (they drive the public API), not competitors to clone.
  • Observe/display, never orchestrate. Core observes and displays agent state (badges, status); it never manages or orchestrates it (no agent runtime, no wait/block state machine). Primitives only: paneStatus, waitForOutput, events.
  • Non-goals (do not re-propose without new evidence):
    • pane-pipe MCP tool — the concept engine already routes pane output; a generic pipe is a broad-trigger concept.
    • layout-apply-preset workflow automation — plugin territory (events + actions).
    • First-party gpty herdr * subcommands — couples gPTY core to a foreign, versioned socket protocol with an auth model gPTY does not control (L3 creep).
    • Native third-party GDScript/Rust pane plugins in v1 — PaneTypes.ALL is the layout-trust anchor; a plugin-registered registry lets profile files instantiate arbitrary code at restore time. cli_view (stdout streaming) is the v1 plugin UI; a native pane SDK needs its own trust design.
    • ToolRunning state via exit codes — foreground-command exits are not reliably attributable in a PTY.
    • Webview/WebSocket panes — a new trust boundary; not v1.
  • Positioning & naming: the wedge is GUI + public-API PTY foundation + concept engine + Windows + hardening. Keep the gpty name (2026-09 decision): rebranding is positioning, not renaming. Two-tier naming convention: the display name is gPTY (UI, docs, prose); machine-facing identifiers stay lowercase/actual — gpty binary, crates, repo, and socket paths; GPTY_* env vars; GptyTerminal; @gpty/omp-events.

Security

Policy (threat model, supported versions, reporting, what gpty does not defend against) lives in SECURITY.md. The rules below are implementation constraints for this repo.

  • No concept execution path: a concept is data (trigger, stop condition, routing target). Never add a field, label match, or click handler that makes a concept write to a PTY — the trigger runs against untrusted terminal output, so that would be remote-triggered execution with no consent step. See the concept bullets above.
  • Concept Engine ReDoS: The gpty-core crate MUST always use the standard Rust regex crate. PCRE or back-tracking engines are strictly prohibited to prevent ReDoS (Regex Denial of Service) attacks when parsing large amounts of terminal output.
  • OSC 52 Clipboard Syncing: parser.rs currently discards all terminal escape sequences, keeping copy/paste safely bound to Godot UI inputs. Do NOT implement OSC 52 clipboard injection/syncing without placing it behind an explicit Godot confirmation dialog to prevent drive-by clipboard hijacking.
  • Restore vs. user intent: PaneTypes.tile_spawns_untrusted(td, default_program, default_env) is the single Workspace Trust predicate — program (command > shell), non-empty shell_args, and a shell_env that differs from the user’s own settings all count as a new decision. Every restore path must consult it: the sidebar profile-activation and workspace-restore paths show the dialog, and layoutLoad over IPC refuses an untrusted profile outright, because a caller cannot answer a dialog and naming a profile is not consent to what the file asks for. Never compare a different key than the spawn path consumes. (Note: the shell_env clause currently gates nothing — see the restore-ordering pitfall below.)
  • Inspector/adapter children: SessionOpenRequest::strip_env carries gpty_core::pty::STRIPPED_INHERITED_ENV_KEYS, applied by both gpty-ai backends. The child is a third-party CLI — it must never inherit GPTY_SECRET, GPTY_SOCKET, the event capability, or the pane markers, because a GUI started from inside a pane carries all of them.
  • Inspector prompt construction: gpty-ai/src/prompt.rs quotes untrusted capture behind a fence one backtick longer than the longest run inside it (capped at 32), and flattens concept_name/source_pane to a single line. A literal ``` fence was closable by any program that printed three backticks, which let the rest of the capture land as instructions rather than quoted data — keep the fence outgrowing its content.
  • Environment reach: a pane inherits the GUI process’s environment (portable-pty snapshots it; only STRIPPED_INHERITED_ENV_KEYS are removed), so launching gPTY from a credential-holding shell hands that context to every pane — and BLOCKED_ENV_KEYS matching is byte-exact, which is correct on Unix but not on Windows, where the PTY layer normalises keys case-insensitively and the GPTY_* credential/marker namespace is therefore not case-sensitive.
  • IPC hardening: the control socket defaults to $XDG_RUNTIME_DIR/gpty.sock (fallbacks /run/user/<uid>/gpty.sock, /tmp/gpty-<uid>.sock; macOS $TMPDIR; GPTY_SOCKET overrides). The server chmods the socket 0600, rejects cross-UID peers on Linux/macOS (fail-closed), caps requests at 64 KiB (-32600) and connections at 16 (30 s timeout). When GPTY_SECRET is set on the GUI, clients MUST present it (mismatch → -32001); tests/scripts probing a secret-configured GUI must set the same env var. Do NOT weaken these gates when editing server.rs/transport.rs.
  • OMP event socket: a second listener (gpty-events.sock / default_event_socket_path(), \\.\pipe\gpty-events on Windows) does not honor GPTY_SOCKET or GPTY_SECRET. It registers three methods: ompEvent (the authenticated submission path), subscribe and eventsPoll (read-only fan-out, bounded at 64 subscriptions, not capability-gated), and nothing else. Read access is therefore open to any same-UID process, which is inside the documented threat model but is a wider surface than the single-method submission path; do not treat the event channel as confidential from same-UID peers. Each PTY gets an ephemeral GPTY_TERMINAL_SESSION_ID + GPTY_EVENT_CAPABILITY injected at spawn on every platform; submitting events requires the capability, and a leaked capability cannot inject text, create panes, or shut down gPTY. Do not register ompEvent on the control socket, and do not expose event submission as an MCP tool. The shipped @gpty/omp-events extension still connects over a Unix socket (its Windows transport is an ecosystem follow-up), so Reasoning stays idle on Windows until an adapter with a named-pipe transport appears.
  • IPC trust model: the IPC channel is local-only and trusts same-UID processes — a process running as the user can already control that user’s session, so this is the accepted threat model for a single-user dev machine. On multi-user/shared hosts, set GPTY_SECRET on the GUI and every client. Anyone controlling a process’s environment controls that process: env-var attacks are mitigated at the socket/binary boundary (validate_socket_path/validate_gui_binary), not by treating the environment itself as trusted.
  • Concepts have no execution path (v0.5.3): command_template, substitute_template, matching_commands, and the click-to-run handler were removed. A trigger is a regex over untrusted output, so any action it could take would be an execution primitive governed by whatever a program printed — a downloaded concepts.json was arbitrary command execution with an attacker-chosen trigger, and it fired silently in whichever pane matched (including a root or ssh pane). Do NOT reintroduce a command/action field, a label-based PTY write, or a click handler that runs anything from a concept without a per-concept consent boundary (plugin trust lane). Concept parsing still caps count (128), trigger length (1024), actions (32), and clamps stop_timeout_ms to ≤ 600 000 ms (concept::concepts_from_json); a legacy cmd key is ignored, never parsed into the vocabulary. Lines over 16 KiB are never regex-matched; capture buffers are capped at 4 MiB. LineParser::feed additionally force-closes an OSC sequence past 64 KiB (MAX_OSC_BYTES): vte’s std build keeps OSC bytes in an unbounded Vec (its 1 KiB cap is no_std-only, and alacritty forces std on), so an unterminated sequence grows memory and leaves the parser stuck inside the string, swallowing every later line.
  • PTY env sanitization: pty::sanitize_envs runs at spawn — the single choke point for settings, per-pane env, layouts, and profiles. Keys must match [A-Za-z_][A-Za-z0-9_]*; dynamic-loader injection vectors (LD_PRELOAD, LD_AUDIT, LD_LIBRARY_PATH, LD_ORIGIN_PATH, DYLD_*), startup-evaluation keys the shell or the next tool runs (PROMPT_COMMAND, BASH_ENV, ENV, SHELLOPTS, PS4, ZDOTDIR, PERL5OPT, PERL5LIB, PYTHONSTARTUP, NODE_OPTIONS, RUBYOPT, LESSOPEN, GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF, GIT_PAGER, PAGER), event-channel keys (GPTY_EVENT_SOCKET, GPTY_EVENT_PROTOCOL, GPTY_TERMINAL_SESSION_ID, GPTY_EVENT_CAPABILITY), and pane-marker keys (GPTY_ENV, GPTY_PANE_ID) are dropped from untrusted env. Trusted runtime vars (GPTY_ENV=1, GPTY_PANE_ID=<attachment_id>, and the event-channel vars) are injected last by GptyTerminal.start_shell(), bypassing the blocklist. GPTY_PANE_ID is the pane’s stable attachment_id; if unset at spawn it falls back to the per-PTY session id. Inherited control credentials (GPTY_SECRET, GPTY_SOCKET, GPTY_GUI) are stripped via CommandBuilder::env_remove so child shells cannot control the workspace. Do NOT remove entries from BLOCKED_ENV_KEYS without a security review.
  • Agent observability allowlist: the OMP extension may forward session/turn/tool metadata and bounded thinking_delta text. Never forward prompts, answer text, tool arguments/results, provider payloads, credentials, or session file paths. Never persist GPTY_EVENT_*, OMP session IDs, or thinking text in layout/profile JSON. Reasoning accordion records stay in RAM until a later feat(history) SQLite path exists.
  • Third-party AI CLIs: do not read or reuse OAuth tokens, call backing services with harvested credentials, bundle proprietary CLIs, or scrape TUI output. Users launch agents in terminal panes; gPTY only consumes documented hooks/extensions.
  • Env-var hijacking: GPTY_SOCKET must be an absolute path owned by the current UID with mode 0o600-style privacy — transport::validate_socket_path runs client-side before connect and refuses insecure sockets (they would receive commands and GPTY_SECRET). GPTY_GUI binaries are validated by transport::validate_gui_binary (absolute, regular file, own UID, not group/other-writable) before auto-spawn. Do NOT weaken these checks when editing client.rs/daemon.rs/transport.rs.
  • Layout restore trust: saved tiles from workspaces.json/layout.json (legacy)/profiles are untrusted input — PaneTypes.sanitize_tile validates the pane type and settings dict, clamps grid geometry (PANE_MAX_ROWS/PANE_MAX_COLS, mirrored by MAX_ROWS/MAX_COLS in the FFI — an unbounded stored rows/cols allocated the cells before the first frame); PaneBody.apply_settings applies values only when types match; terminal shell_args are sanitized (PaneTypes.sanitize_shell_args: ≤32 args, ≤4096 chars, no U+FFFD); code_viewer/file_tree pane paths must be absolute. Do NOT remove these guards when adding pane settings. Absolute programs restored from those files are validated at spawn (gpty_core::pty::validate_executable): a regular file, not group/other-writable, owned by this user or root. Bare names stay allowed because the shipped profiles launch tools through PATH. The Inspector’s adapter argv is held to the same rule at session_open.
  • OSC agent-state declaration: the published gpty_state=<value> OSC sequence is the Tier 2 agent-state channel, implemented in parser.rs (osc_dispatch) — the FIRST interpreted sequence in a discard-only parser; do not expand the parser further without a security review. Strictly whitelist enum values (idle/working/needs-attention/completed/failed); single-shot per parse; rate-limited (≥500 ms between accepted declarations per terminal, DECLARATION_MIN_INTERVAL_MS). It is spoofable by design — pasted text and cat-ed files can emit it — so it may set UI display state ONLY and must never trigger actions, concepts, layouts, or IPC. The engine applies it only when not alt-screen, not capturing, and outside the post-resize suppression window (same guards as concept matching). Tier 1 (capability-authenticated event socket) remains authoritative; Tier 3 (regex/idle/exit heuristics in agent_state.rs) is display-only and never a decision input. States surface through paneStatus (agent_state, agent_state_tier) and the titlebar badge.
  • broadcast-input: a fan-out of inject over the GPTY_SECRET-gated control socket. Text is written to each PTY verbatim, exactly as inject does (the target shell interprets it — no additional interpolation by gpty). Pane tags used for targeting must be sanitized like attachment_id ([a-z][a-z0-9_-]{0,31}).
  • Plugin trust model: plugins are arbitrary code running as the user — the same trust as any editor extension. Review-then-run (reuse the Workspace Trust confirmation dialog), pin revisions, per-plugin config/state/log dirs, manifest validation with size/count/path caps (reuse concept parse caps and sanitize_tile rules). Never claim sandboxing.
  • Update checker: the startup GitHub-release check is notify-only — it never downloads or executes anything, and a remote tag_name is validated by _is_valid_semver before it reaches the toast. The response body is integrity-untrusted (TLS transport only; GitHub release artifacts are unsigned): treat remote version strings as display data. If a future “Download update” action is added, it MUST verify artifact integrity (pinned checksums or equivalent) before installation — a MITM’d response could otherwise point at a malicious payload.

Licensing

  • Core is GPL-3.0-or-later (License); License exceptions holds the two section 7 additional permissions. Plugins, extensions, adapters, and pane types are not required to be GPLv3, which is why the bundled extensions/gpty-omp-events ships MIT and godot/addons/gut stays MIT. Do NOT “correct” a bundled permissive license into GPLv3, and never add license headers to JSON.
  • Data files (profiles, workspaces, layouts, concepts, settings) carry no copyleft: user-authored ones are the user’s own, and the shipped godot/*.json defaults are Apache-2.0. The engine and pane code that reads them stays GPLv3.
  • First-party code is GPLv3 everywhere it is declared: license = "GPL-3.0-or-later" in every crates/*/Cargo.toml, license=('GPL-3.0-or-later') in dist/aur/PKGBUILD. Keep them in sync when adding a crate or package.
  • Release artifacts must carry LICENSE and LICENSE-EXCEPTIONS.md next to the binaries (GPLv3 §4/§6 requires giving recipients a copy of the license). .github/workflows/release.yml currently packs the export alone — add the copies when that job is next touched.

Commits

  • Format: Conventional Commitsfeat(scope):, fix(scope):, chore(scope):
  • Scopes: settings, terminal, layout, workspace, sidebar, ui, gdext, core, cli, ipc, profiles, concepts, icons, ci, ai, inspector
  • Workflow: Use the commit skill (skill://commit) to discover changes, group them logically, and produce correctly-formatted messages. The git hooks (pre-commit, commit-msg, pre-push) are the enforcement layer that catches bypasses.
  • CI gates: pre-commit runs fast checks (fmt, workflow lint, clippy). pre-push runs the full ./scripts/ci-check suite. Install with ./scripts/install-hooks once per clone.

Commit Discipline

  • Use the commit skill (skill://commit) as the normal workflow — it handles grouping, README freshness, and Conventional Commits.
  • The git hooks are the safety net: pre-commit catches fmt/lint issues, commit-msg enforces message format, pre-push runs the full CI suite.
  • NEVER commit without running ./scripts/ci-check first. If it fails, fix the failures before committing.
  • cargo fmt and cargo clippy run automatically in the pre-commit hook — but run them explicitly before staging to avoid amend churn.
  • If a test fails: fix the SOURCE code, not the test. Only update a test if the behavior change is intentional AND documented in the commit message body.
  • Test-only commits without corresponding source changes are a red flag. If you find yourself tweaking a test “just to make it pass,” STOP — the test is catching a real issue or the test expectations are wrong. Either way, a commit must include both the source fix and the test update together.
  • Push only after the full suite passes. The pre-push hook enforces this; git push --no-verify bypasses it — use ONLY in emergencies, and expect CI to catch what you skipped.
  • If you must bypass: document why in the commit message body.

Pitfalls

  • Drop impl for external resources: Any Rust struct holding a child process (portable_pty::Child) or I/O thread MUST implement Drop to call .kill(). Otherwise closing a terminal in Godot orphans the shell process and reader thread.
  • tokio::select! None branches: When a channel returns None (closed), select! disables that branch but keeps polling others instantly — causing 100% CPU. Bind to a variable first (msg = rx.recv()), then let Ok(v) = msg else { break; }.
  • vte Perform::execute CR/LF: PTY output uses CRLF pairs. The vte parser calls execute per byte. If you commit on both \r and \n, every line produces a spurious empty string. Track last_was_cr and skip the \n commit when preceded by \r.
  • alacritty_terminal display_iter: returns negative line numbers for scrollback history rows. Never cast directly to usize — it wraps to a huge value. Always add the grid’s display_offset() to normalize: let line = (indexed.point.line.0 + offset) as usize.
  • GDScript \UXXXXXXXX escape: GDScript only supports \uXXXX (4-hex-digit BMP). \UXXXXXXXX (8-digit) does not exist — the parser mangles it. For non-BMP codepoints, use char(0x10XXXX) in static var initializers. Prefer BMP alternatives; this project’s icons use Phosphor PUA codepoints (U+E000–U+F8FF), all expressible as const with \uXXXX.
  • Typed arrays break Rust FFI: gdext Array<Variant> parameters reject GDScript’s Array[Dictionary] at runtime (“expected array of type Untyped, got Builtin(DICTIONARY)”). Always pass untyped Array across the FFI boundary. Prefer func f(arr: Array) over func f(arr: Array[Dictionary]) when the array originates from or goes to Rust.
  • Multi-line for array colon: for x in [...] with a multi-line array literal requires ]: at the end. Forgetting the colon produces a parse error at an unrelated line. Double-check after replacing inline array content.
  • Godot typed Arrays: Array[T] won’t accept plain Array. If you type a parameter, check all call sites use matching types (var x: Array[Control] = []).
  • GDExtension rebuilds: After changing #[func] signatures or adding methods, cargo build -p gpty-gdext and fully restart Godot. Keep godot/bin/libgpty_gdext.linux.x86_64.so as a symlink to ../../target/debug/libgpty_gdext.so so the editor sees Cargo output. Godot 4.7.1 stable is a release editor (linux.debug.* keys do not match). ./scripts/build overwrites the symlink with a release copy.
  • GDScript default params: Evaluated at definition time, not call time. func f(x := some_var) captures the value of some_var when the script loads. Use func f(x := -1) and check if x < 0: x = some_var inside the body for runtime-evaluated defaults.
  • extends Node won’t render Control children: Only Control nodes can render child Controls (Labels, Buttons, etc.). If you add a Label to a plain Node, it’s invisible. Use extends Control for UI containers and set z_index for layering.
  • tokio::time::Instant::now() + Duration::MAX panics: The addition overflows. Use a safe large constant like Duration::from_secs(86400 * 365) (1 year) for inactive timeout sleeps.
  • tokio::pin! + reset() for capture timeouts: Use tokio::pin!(sleep) and sleep.as_mut().reset(deadline) to re-arm a timeout without recreating it each iteration. One select! branch, no code duplication.
  • continue in for does not skip trailing code: A continue inside a for loop only skips the current iteration — code BELOW the loop still executes. Use break + a boolean flag to conditionally skip post-loop grid feeding.
  • Concept matching on PTY output: concept::match_line tests every line against the enabled concepts (first match wins) on both the PTY-output path and the typed-input path. Its return value MUST be used to session.begin(name, target, deadline) — ignoring it (let _ = match_line(...)) silently disables capture, which looks exactly like “concepts do nothing”.
  • Restore ordering discards a tile’s environment: _restore_into applies the tile’s settings and then SettingsManager.apply_to_terminal, which sets shell_env from the user’s global — so a restored tile’s env never reaches the child (an accident, not a control), and a user’s own per-pane env is silently discarded on every restore. Do NOT “fix” the order on its own: that hands file-supplied env back to a profile. It is fixed together with the v0.5.5 user-owned env model (ROADMAP).
  • Bare CR must never commit a parser line: LineParser commits only on \n (LF); a bare \r stashes the pending text — an LF after it commits the CRLF line, a printable after it discards the stash (reprint/overwrite). Shells reprint the prompt on SIGWINCH as \r + clear-to-EOL + \r + prompt; committing on bare CR stamps a history row per resize, and restarting restores a growing pile of old prompts. Progress bars (bare-CR updates) likewise only commit their final state once a real line end arrives.
  • Tab completion triggers concept matches: Bash reprints the prompt and partial command when showing autocomplete candidates. This reprinted line has no trailing \n, so LineParser never emits it. Tab completion never triggers concept matching.
  • Raw-byte buffering for grid replay: Never buffer parsed lines for later grid replay — the alacritty_terminal ANSI state machine needs raw bytes with escape sequences intact. Buffer Vec<Vec<u8>> (chunks), replay with feed_grid(board, chunk).
  • Rendering Performance: GDScript _draw is slow when calling draw_rect/draw_string character-by-character. Avoid generating heavy data structures (like Dictionary) per-cell across the FFI boundary. Prefer packing data into flat arrays (PackedByteArray, PackedInt32Array) in Rust, and batch rendering into glyph runs — consecutive same-attribute cells merged into one draw call; per-line batching is the floor, not the ceiling. Do NOT re-propose a custom GPU texture pipeline (fontdue glyph atlas / instanced quads) without new evidence: Godot already GPU-composites canvas items, the bottleneck is CPU-side command generation, and fontdue would duplicate Godot’s existing glyph atlasing. The instanced-quad renderer is evidence-gated in ROADMAP Future — revisit only if render batching plus flood rate-limiting still shows frame-time pain.
  • Resize Rate Limiting: Firing SIGWINCH heavily on every frame during window drag will overwhelm the child PTY process. Always debounce or rate-limit terminal _on_resize events before passing them to the backend.
  • Scrollback vs. PageUp/Down: terminal_pane.gd:_handle_keyboard intercepts PageUp/Down for scrollback navigation. These never reach the PTY, so programs like less or vim cannot receive them. Users must use alternative keys (b/f in less, Ctrl+B/Ctrl+F in vim). The pane search bar is on Ctrl+Shift+F; plain Ctrl+F is never intercepted, so it reaches the PTY (and the documented vim workaround works).
  • Alt key handling: For Alt+letter combos, the Rust keymap returns None, expecting the GDScript layer to prepend \x1b (ESC). _handle_keyboard does this in the _key_to_text fallback path.
  • Printable keys and the evdev keymap: GptyTerminal.key_to_bytes MUST early-return empty for printable ASCII keycodes (0x21–0x7E). Routing them through godot_key_to_evdev fabricates scancodes that collide with special keys (z→55=KP_MULTIPLY, ;→59=F1, `→96=KP_ENTER) and silently swallows the characters. Only Space (→57, for Ctrl+Space→NUL) and true special keys may reach the keymap.
  • Ctrl+V passthrough: Ctrl+V MUST reach the shell as a literal ^V (readline quoted-insert, vim visual-block). Paste is Ctrl+Shift+V only — never bind plain Ctrl+V to paste.
  • PTY Enter key: The Enter key MUST send \r (CR) to the PTY, not \n. pty.rs:write_line appends \r. The PTY terminal driver translates \r\n in canonical mode; raw-mode programs read \r directly.
  • tokio::task::JoinHandle drop detaches: dropping a JoinHandle does NOT abort the task — it keeps running until it exits naturally. For explicit cleanup (e.g., in a Drop impl), call handle.abort().
  • std::sync::Once poisoning: if the closure passed to Once::call_once panics, the Once is permanently poisoned — all subsequent calls panic too. For lazy init that spawns fallible work, use AtomicBool::swap(true, Relaxed) or Mutex<Option<...>> instead.
  • Resize cascades from layout changes: When panes are added/removed, remaining terminals receive multiple NOTIFICATION_RESIZED events in rapid succession. Even when calculated rows×cols are identical, each resize_grid() call triggers a full grid re-wrap and sync, producing a visible “scrolling through history” animation. Fix: TermGrid::resize() must return early if dimensions are unchanged. Also apply a pixel-level check in the GDScript debounce to avoid redundant calls.
  • Shared static state in integration tests: tests that mutate shared static state (queues, maps) must clean up in ALL exit paths — including timeout, error, and panic branches. A stale queue entry from one test will break the next test. Write a clear_state() helper and call it in every test.
  • Do not scrape agent TUIs: PTY bytes are presentation, not structured events. Semantic observability comes only from documented OMP/Claude/Gemini hooks or extensions. Regex concepts remain valid for ordinary shell output (e.g. cat → code viewer).
  • Emulator PTY replies: alacritty_terminal emits Event::PtyWrite for terminal queries (DSR cursor-position ESC[6nESC[row;colR, mode reports). gPTY’s event proxy MUST queue these and the engine MUST write them to the child PTY (TermGrid::drain_replies() drained each loop iteration). Dropping them breaks TUIs that query the cursor after SIGWINCH — the OMP TUI re-anchors its transcript and appears to “scroll through history” on every resize, while vim/btop (which never query) are unaffected. Never drop non-Title events.
  • Transient pane sizes collapse the grid: during split/spawn layout churn, panes transiently report ~38×40 px (4×2 cells). Applying that rewraps the grid to 2×4 — the terminal looks dead. terminal_pane.gd must skip resize when size < custom_minimum_size OR computed cols < 8 OR rows < 3 at BOTH event and apply time, and must recompute dims from the CURRENT size when the debounce fires. resize_grid (gdext) must also no-op when dims are unchanged (no redundant SIGWINCH).
  • Resize anchoring surgery: TermGrid::resize deletes the rows alacritty pulls in on row-growth and restores the cursor (xterm behavior). Run it ONLY when display_offset == 0 && rows grew && !is_alt_screen() && history_size > 0. That history_size test is read from the POST-resize grid and grow_lines ends with decrease_scroll_limit(lines_added), so it passes only when scrollback was longer than the growth — the case where every new row came from history and no visible row was pushed off. The deletion therefore removes recovered scrollback rows and can never eat on-screen content; scrollback shorter than the growth is zeroed by that same call, failing the guard and skipping the surgery. It is NOT a “this is a TUI” test: a primary-screen app that paints without smcup (the OMP TUI) is guarded only on a fresh pane — launched after the pane had more scrollback than the row growth, it passes every condition and the injected CUP/DL escapes land in its screen. Only the alternate-screen check catches real full-screen apps.
  • Empty grid cache: _grid_offset()/_mouse_to_cell() run on mouse events before the first grid sync — guard _cell_cache.is_empty() or the editor pauses on a script error and the app appears frozen.
  • Textless Buttons collapse: Godot does not derive a Button’s minimum size from child Controls. A Button that carries an icon+text HBox instead of its own text needs an explicit custom_minimum_size.y (see _make_icon_text_button) or it squashes to nothing and overlaps the next VBox row.
  • Pressed-state text color: a pressed (toggle-mode) Button resolves text from font_pressed_color, which falls back to the theme default and silently ignores a font_color override — the active-row accent rendered black until all three states were overridden (_apply_row_accent).
  • Container ancestors skip gui_input propagation: clicks on pane bodies never reach a wrapper’s gui_input (proven by probe). Pane activation therefore lives in the workspace’s raw _input with wrapper hit-testing, not in per-wrapper signal wiring.
  • z_index is rendering-only for Control input: Godot 4 GUI picking uses REVERSE TREE ORDER (last sibling first), ignoring z_index. A full-rect overlay with z_index=100 that is NOT the last child draws on top but is dead to clicks — later-added siblings (workspace grids, sidebar, status bar) eat its input. Every overlay (pane settings popup, global settings panel, palette) must move_child(overlay, -1) when opened so it is topmost for both picking and rendering. The z bump is still required for RENDERING when grids are added after the overlay.
  • Bottom-edge overlays need BOTH anchors: a Control with anchor_bottom = 1.0 but default anchor_top = 0.0 stretches to FULL parent height — the offsets only shift it, they don’t shrink it (the terminal search bar briefly covered the whole pane). Bottom strips must set anchor_top = 1.0; anchor_bottom = 1.0 with a negative offset_top.
  • Pane labels: _next_label takes max(existing numeric suffix) + 1 — closing the newest pane reuses its number, middle gaps are never filled (T1,T3 → next is T4). Do not reintroduce monotonic counters.
  • Event vs control sockets: ompEvent lives on gpty-events.sock. Putting it on the control socket would require giving the extension GPTY_SECRET (workspace control) or weakening auth. Keep them separate.
  • Event capability is per-PTY: unregister on spawn failure and Drop. A capability for terminal A must never be accepted for terminal B. Compare capabilities in constant time.
  • Inspector close must tear down its OMP child: call session_cancel + session_close from _exit_tree(). Dropping a Godot node without closing the session leaves omp running.
  • stream=thinking observers are not job owners: after migration they become Reasoning panes and must keep can_receive_content() == false. Acknowledging a capture without starting analysis discards terminal output.
  • Terminal mouse reporting: the grid’s mouse-mode bits (GptyTerminal.get_mouse_mode, gpty_core::term::MOUSE_MODE_*) are the single source of truth for whether the child owns the mouse. The pane forwards only what the enabled modes cover (DECSET 1000 click, 1002 drag, 1003 any-motion), encodes SGR when 1006 is set and the legacy X10 form otherwise, and must leave selection/scrollback untouched when the bits are zero. Shift bypasses reporting (xterm’s convention) so text selection stays reachable — removing that traps the user in any full-screen app that grabs the mouse.
  • Mouse reports are cells, 1-based, mapped through the same _grid_offset()/cell metrics as selection. The legacy X10 form offsets by 32 and cannot address past cell 223: drop such an event rather than clamping, or the app receives a click on a cell the user never touched. The report has no trailing newline, so a child that has mouse tracking on but left the PTY in canonical mode never sees it (that is the app’s bug, not the pane’s — the automated check drives a raw-mode child).
  • Pane edge resize belongs to edge strips, not to the wrapper’s gui_input: a PanelContainer fits every child into its content rect (a Control added directly to the wrapper is stretched over the whole pane and swallows clicks), and the pane body is MOUSE_FILTER_STOP, so the wrapper itself only ever receives events inside its 1 px stylebox border — an edge test there is unreachable by hand. Each wrapper therefore carries an EdgeHost (plain Control, MOUSE_FILTER_IGNORE, added last so it wins Godot’s reverse-order picking) holding four 6 px strips that own mouse_default_cursor_shape (HSIZE/VSIZE; Godot applies it on hover, whereas an imperative DisplayServer.cursor_set_shape is overwritten by the hovered control’s default and only flashes). The drag is driven from workspace._input (drive_edge_drag) because the motion right after a press on the border is already over the pane body, which would otherwise read it as a text selection.
  • Edge-drag arithmetic is deliberately cumulative and idempotent: drive_edge_drag passes the distance from the press (mouse motion arrives in 1-3 px steps, so a per-event delta rounds to zero cells and a drag would never move) and emits tiles_resized per motion so the divider follows the pointer. _resize_tile then rewinds every tile to the spans saved at the press before resolving the neighbour — a partial move makes the geometry-based neighbour lookup fail, and the old code then left the dragged pane reverted with the neighbour moved, i.e. a grid whose spans no longer added up to GRID. Tiles are addressed by wrapper here (_tile_index_for_wrapper), not by body (_tile_index_of resolves bodies and never matches a wrapper).
  • The layout grid has exactly one definition: PaneTypes.GRID/PaneTypes.MIN_TILE, read by the drag math, PaneTypes.sanitize_tile’s default, and workspace.gd._apply_layout. A second copy drifted once (the layout divided by 12 while the tiles were in 60), which clamped every restored pane to a sliver and drew it several times the grid — panes outside the visible area, from a one-line constant change. Never re-declare it locally; neighbours on a divider are matched by overlap (_neighbours_on), never by equal extents, because a full-height pane flanked by two stacked ones has no equal-extent neighbour at all.

Agent Tool Notes

  • A renamed or unmarshaled #[func] breaks GDScript at runtime, not at parse time: the call is syntactically valid and fails only in the pane that exercises it. scripts/check-ffi-surface (run by ci-check) cross-checks every call on a GDExtension handle against the exported surface — name and arity. It is fail-closed: a Godot-inherited member it does not know about (has_method is allowlisted) is reported, so add it to INHERITED_MEMBERS deliberately rather than widening the check.
  • gdext #[func] parameter types: ONLY GString, bool, and i64 are reliably marshaled as input parameters. Array<Variant>, Dictionary, and bare Variant all silently fail — the GDScript call succeeds but the Rust body never executes. Workaround: serialize complex data to JSON in GDScript (JSON.stringify()), pass as GString, deserialize in Rust with serde_json::from_str.
  • Concept push startup race: GDExtension classes aren’t registered when autoloads initialize. ConceptManager._on_init() must defer its push via call_deferred("_push_to_rust"). A GptyTerminal.new() created during autoload init produces a zombie object whose #[func] methods silently no-op. Use ClassDB.instantiate("GptyTerminal") inside call_deferred — this works once Godot’s class database is ready. A secondary push from workspace.gd via await create_timer(2.0) serves as fallback.
  • GDScript /// comments: GDScript uses # or ## for comments. Rust-style /// causes a parse error. Always use ## for doc comments in GDScript.
  • Edit tool on structured formats (YAML, TOML, Markdown frontmatter): the line-based edit tool can corrupt delimiter-sensitive files (YAML --- blocks, TOML [sections], frontmatter bounds). When editing config files, workflow YAML, or Hugo content, prefer eval with Python (yaml.safe_load, tomllib) to parse → modify → serialize. Reserve edit for Rust, GDScript, and plain Markdown where line semantics hold.
  • CLI gpty version is local-only: it prints the CLI’s own crate version and exits without touching IPC. It cannot probe a running GUI. Use gpty daemon status or list-panes to test connectivity/auth.
  • Font-size changes now auto-recalculate cell metrics via a setter on font_size — no need to recreate terminals.
  • The global tokio runtime is initialized once at GDExtension init and shared across all GptyTerminal nodes.