Skip to content

Latest commit

 

History

History
291 lines (229 loc) · 81.8 KB

File metadata and controls

291 lines (229 loc) · 81.8 KB

AGENTS

Scope

This file applies to the entire repository. Nested AGENTS.md files may add rules for a subdirectory; when they do, follow both the root file and the nested file.

This document defines contributor and agent governance only. It does not change runtime APIs, schemas, or protocol types.

Core Operating Principles

  • Prefer clear, traceable work over implicit progress. Keep the user informed about what is being done, what remains, and any relevant blockers.
  • Use these instructions by default. If a specific task requires a different approach, explain the reason clearly before deviating.
  • Keep plans and outputs portable across agent runtimes unless the user asks for behavior tied to a specific tool.
  • Avoid unnecessary complexity. Choose the simplest approach that satisfies the user's stated goal and preserves correctness.

Task Tracking

  • Agents MUST use the available task-tracking tool whenever the work has multiple steps, meaningful uncertainty, or a non-trivial implementation path.
  • Track tasks as pending, in progress, and completed so the current state of the work stays explicit.
  • Update the task list as work progresses, not only at the end.
  • Keep task entries concrete and outcome-oriented. Each task should describe a verifiable unit of work.
  • When new work is discovered, add it to the tracker instead of relying on memory.
  • When a task becomes irrelevant, mark or explain it rather than silently dropping it.
  • Before finishing, reconcile the tracker with the actual work completed and call out anything intentionally left undone.

Code Generation Workflow

  • Riverpod providers MUST use code generation (riverpod_generator) rather than hand-written provider declarations.
  • This repository does NOT use a build_runner watcher. Agents MUST NOT run build_runner watch or keep any background code-generation process alive.
  • When a planned batch of edits touches Riverpod, Drift, or dart_mappable generated surfaces, agents MUST finish the planned edits first and then regenerate code once for the whole batch with dart run build_runner build. Do not regenerate after every individual edit.
  • The one-shot generation MUST run before dart format, flutter analyze, and tests, so formatting, static analysis, and test runs always see the final generated code.
  • After generation, run dart tool/ci/normalize_generated_eof.dart from the root (or dart ../tool/ci/normalize_generated_eof.dart . from mobile) before formatting. This canonicalizes the extra trailing newline emitted by dart_mappable without editing generated bodies.
  • Agents MUST verify the regenerated files are included alongside the source changes that produced them.

Dart Language

  • Own packages use Dart 3.13.2 with Flutter 3.47.2. Follow the source conventions and generator exceptions in docs/dart-3.13-modernization.md; do not modernize vendored sources or manually rewrite generated bindings.
  • Prefer primary or concise constructors when their argument names, annotations, defaults, initialization order and constant behavior remain unchanged. Keep explicit mapped fields when moving them would reorder serialized keys.
  • Use Future.pause for callback-free waits and typed List.unmodifiableOf / Map.unmodifiableOf for compatible inputs. Preserve event-loop scheduling, copying and immutability.
  • Keep CPU-heavy work behind the existing asynchronous isolate and native boundaries. Synchronous isolate APIs are not a replacement for background UI work.

Native Rust Layer

  • Alera runs a Rust layer under Flutter through flutter_rust_bridge v2. rust/ is a Cargo workspace whose root package is the FRB git library alera_native (cdylib/staticlib) and whose members are alera-cli (the terminal-host sidecar binary; see Process And Terminal Safety) and alera-xtask (makefile developer tooling, never shipped or linked into the app). The Flutter build plugin is at rust_builder/, and the generated Dart bindings at lib/src/rust/ (committed, not regenerated in CI). RustLib.init() runs in lib/main.dart before runApp. The FRB native build (cargo build --manifest-path rust/Cargo.toml, no -p) compiles only the root alera_native package, so it never drags in sidecar or xtask dependencies.
  • Workspace text search and replace live in alera_core::workspace_search, shared by the desktop and the terminal-host sidecar that serves the paired phone, so both answer with the same match ids, content tokens, and replacement previews. rust/src/api/workspace_search.rs is only the FRB facade: it keeps its own copies of the types so the generated Dart bindings do not depend on another crate. Do not add search logic there or a second engine in alera-cli.
  • Git operations MUST go through the GitBackend boundary (lib/src/shared/infra/git/), not by spawning the git binary via ProcessRunner. The production implementation RustGitBackend calls the Rust API; local operations use git2 (libgit2) and the networked ones (clone, fetch, pull, push) are delegated to the git CLI through alera_core::git::git_in_dir so the system credential helper keeps working. That helper is the only place those invocations are built: it pipes stdio, sets GIT_TERMINAL_PROMPT=0 (nothing can answer a terminal prompt there, and on Windows there is no console to draw one on), and spawns through alera_core::child_process::suppress_console_window.
  • The git model and its working-tree operations (status, diffs, history, stage, discard, commit, stash, fetch, pull and push) live in alera_core::source_control, so the desktop bridge and the runtime host run the same rules. rust/src/api/git.rs re-exports those types and keeps a #[frb(mirror(...))] stub for each one, which is all the codegen needs to leave the Dart bindings unchanged. A change to a mirrored type MUST update its stub in the same commit, including variant order, because the bridge encodes enums by index.
  • Keep the GitBackend interface free of generated bridge types: RustGitBackend is the only place that imports lib/src/rust/api/git.dart, and it translates the native GitError into the domain GitException hierarchy. Services depend on GitBackend; unit tests use the shared FakeGitBackend (test/unit/fake_git_backend.dart).
  • After changing the Rust API surface (rust/src/api), regenerate bindings with make frb-generate (flutter_rust_bridge_codegen generate) and commit the result. Building the desktop app requires a Rust toolchain (rustup), pinned by rust/rust-toolchain.toml; CI installs it via dtolnay/rust-toolchain. The shared rust/Cargo.lock is committed and the native hooks build with --locked, so a regenerated lock must stay complete for every workspace crate.
  • Session keep-alive (the status-bar Keep Alive toggle) uses the keepawake crate in alera_native (rust/src/api/keep_alive.rs): idle plus display inhibitors, held on a dedicated thread because Windows SetThreadExecutionState is per-thread. That toggle MUST NOT spawn caffeinate or systemd-inhibit. The agent-working awake path in agent_awake_assertions.dart is separate.
  • Desktop Whisper on x64 Windows compiles ggml-vulkan through whisper-rs-sys. Cargo CMake must set CMAKE_GENERATOR=Ninja (not only the rustc target-specific form) so vulkan-shaders-gen's nested cmake inherits Ninja. CI scratch lives at R:\c, native cargo --target-dir is R:\c\n (local fallback %SystemDrive%\c\n), and the sidecar uses R:\c\cli. Windows cmake_install copies the sidecar from a staged path under the Flutter build tree rather than from that subst drive. Do not append alera_native to that prefix: vulkan-shaders-gen's nested TryCompile object (.../cmTC_XXXXXXXX.dir/testCCompiler.c.obj) then exceeds MAX_PATH and cl.exe fails with C1083 and an empty generated-file name. cl.exe reads CL/_CL_, not CFLAGS; _CL_=/Z7 /FS avoids extra PDBs. GGML_CCACHE MUST be OFF on Windows cargo builds: ggml auto-enables sccache when it is on PATH, and RULE_LAUNCH_COMPILE=sccache with Ninja and cl.exe drops object files (LNK1181 on ggml.c.obj). sccache stays on RUSTC_WRAPPER only. Flutter's Visual Studio generator stays unchanged.

Spec-Driven Planning

When planning is needed, use a spec-driven development flow. Do not jump straight from a vague request to implementation if important product or technical decisions are still undefined.

Spec Discovery

  • First clarify the desired behavior, success criteria, audience, inputs, outputs, constraints, and non-goals.
  • Prefer discovering facts from the repository, environment, or existing documentation before asking the user.
  • Ask targeted questions only for decisions that cannot be safely inferred.
  • Convert ambiguous requests into explicit requirements before designing a solution.

Design

  • Define the implementation approach after the spec is stable.
  • Identify affected interfaces, data flow, dependencies, storage, permissions, error handling, and compatibility constraints when relevant.
  • Surface meaningful tradeoffs and choose a default when one option is clearly safer or simpler.
  • Keep the design aligned with existing project conventions.

Tasking

  • Break the design into ordered, concrete tasks that can be implemented and verified.
  • Include validation steps as first-class tasks, not as an afterthought.
  • Present plans using the structure: spec, design, tasks, tests, and assumptions.
  • Make the plan decision complete: another engineer or agent should be able to execute it without inventing missing requirements.

Clipboard Usage

  • Use the clipboard when it helps transfer commands, snippets, paths, reports, or other information to the user efficiently.
  • Prefer native clipboard commands for the user's operating system:
    • macOS: pbcopy and pbpaste.
    • Windows PowerShell: Set-Clipboard and Get-Clipboard.
    • Linux Wayland: wl-copy and wl-paste.
    • Linux X11: xclip or xsel.
    • WSL: clip.exe when copying content into the Windows clipboard is appropriate.
  • Tell the user what was copied, especially when the clipboard content is long or operationally important.
  • Avoid placing secrets, tokens, credentials, personal data, or destructive commands on the clipboard unless the user explicitly asks for it or the task clearly requires it.
  • If clipboard tooling is unavailable or unsafe in the current environment, provide the exact command or content for the user to copy manually.

Git And Pull Requests

  • Use Conventional Commit style for commit messages.
  • Write commit messages and pull request titles in English unless the user explicitly requests another language.
  • Commit messages and pull request titles MUST be lowercase.
  • Prefer concise commit subjects that clearly describe the change, such as fix: handle empty clipboard input, docs: update agent workflow rules, or chore: add repository instructions.
  • Keep pull request descriptions short and useful. Include a brief summary, validation performed, and any important risks or notes when relevant.
  • Never add the agent as a coauthor, assisted-by, generated-by, or equivalent attribution in commits, pull requests, pull request descriptions, or related metadata unless the user explicitly asks for it.
  • Keep changes scoped to the user request. Do not fold unrelated refactors into implementation work.
  • This document SHALL remain organized with non-numbered section headers.

Communication Expectations

  • Be direct and specific. Explain decisions, blockers, and verification results in practical terms.
  • Do not hide uncertainty. If something is assumed, say so.
  • Keep progress updates short but useful during longer work.
  • When implementation is complete, summarize what changed, how it was verified, and any remaining risk or follow-up.

Worktree Safety

  • Always read and edit files from the active working directory.
  • Never follow absolute paths copied from another agent or another worktree unless they are revalidated in the current checkout.
  • Before mutating git state, check for existing local changes and preserve user work.
  • If .git/index.lock exists, confirm no git process is active before removing it.

Code Comments

  • Add comments only when they explain a non-obvious reason: safety, platform behavior, compatibility, release constraints, or a design-system rule.
  • Keep comments brief. Do not narrate what the code already says.

Markdown Style

  • Do not hard-wrap Markdown prose. Keep each paragraph or list item on one line unless the line break is semantically meaningful.
  • Preserve explicit line breaks in tables, code fences, lists, and generated templates where Markdown syntax requires them.
  • Never use em-dashes (—) anywhere in the repository: not in Markdown, code comments, UI copy, CLI output, or tests. Use a plain hyphen (-) instead.

Naming

  • Do not create vague modules named helpers, utils, common, misc, or similar dumping grounds.
  • Name files and types after the domain concept they model, such as workspace_folder_opener.dart or update_archive.dart.
  • If a file name starts feeling generic, split responsibilities before adding more code.
  • Avoid files longer than 500 lines. When a file approaches that size, split it by concrete domain responsibility instead of adding more unrelated code.

Flutter UI Rules

  • Flutter UI values MUST come from AleraTokens and ThemeData.
  • New UI code MUST NOT introduce ad-hoc visual literals for color, spacing, radius, duration, or typography when an existing token/theme value covers the role.
  • Colors.transparent MAY be used only for explicit transparent states.
  • Visible UI copy MUST use title case for actions, buttons, menus, dropdowns, labels, and other controls. Descriptions, explanations, helper text, status messages, errors, notifications, and other prose MUST use sentence case, preserving proper nouns, product names, acronyms, and technical identifiers.
  • The active app theme strategy SHALL remain dark-mode-only in this version.
  • Typography MUST remain fixed to Inter for general text and JetBrains Mono for monospaced text.
  • The canonical design-system reference is docs/ui-styleguide.md.
  • Shared, reusable UI components live in lib/src/design_system/, grouped by role and prefixed Alera. New screens MUST reuse these before introducing ad-hoc widgets; a genuinely new shared component belongs here, with a co-located *.preview.dart.
  • Design-system components MUST be presentational: data and callbacks in via parameters, no Riverpod reads and no native (dart:io/dart:ffi) code, so they stay previewable. Wire providers in a thin feature-level wrapper instead.
  • Preview functions MUST use the @AleraPreview annotation (not the bare @Preview). Launch with flutter widget-preview start.
  • Horizontal-only strips (tab bars, toolbars, chip rows) MUST use AleraHorizontalScrollView or wrap a horizontal ListView with AleraMouseWheelHorizontalScroll so a vertical mouse wheel scrolls them. Do not add a third wheel mapper. Shift+wheel and trackpad horizontal deltas stay on Flutter's built-in path.
  • The landing page demo (landing/src/components/demo/, see docs/landing-demo.md) redraws the desktop and mobile apps from their tokens, icons, agent marks, logo, shortcuts, and copy. A change to any of those that the demo uses MUST update landing/src/data/demo/ or landing/src/styles/demo/ in the same change: .github/workflows/landing-fidelity.yml runs landing/src/data/demo/fidelity.test.ts on pull requests that touch those app sources, and it fails when the two disagree.

Keyboard Shortcuts

  • Shortcut-able actions live in lib/src/features/keyboard/domain/keyboard_action.dart as the single source of truth (id, label, group, per-platform defaults, allow-in-terminal flag), with the keybindingDefinitions list itself in its part file keyboard_action_definitions.dart. New shortcut-able actions MUST be added to that registry rather than wired through ad-hoc Shortcuts/CallbackShortcuts widgets.
  • Behavior is dispatched from one place: KeyboardCommandDispatcher. Reuse existing controller methods and the shared dialog launchers in workbench_dialog_launchers.dart; do not duplicate dialog flows.
  • Matching is centralized in KeybindingResolver and consumed by exactly two call sites: the global KeyboardShortcutsScope (shell-mounted) and the TerminalSurface onKeyEvent hook (terminal-focused interception). Do not add a third matcher or a global HardwareKeyboard handler.
  • Shortcuts only reach KeyboardShortcutsScope while the primary focus is inside it, because Flutter delivers key events to the focused node and its ancestors. That is why the scope, every workbench pane, and every experimental surface are FocusScopes rather than plain Focus widgets: when the focused tab content unmounts (a keyboard tab switch, a terminal replaced by a diff), Flutter hands focus to the enclosing scope's most recently focused child, and without a per-pane scope that child was the terminal in a sibling column, which then stole the active group, or the route scope above the shortcut layer, which killed Ctrl+W and Ctrl+Tab. A tab surface with nothing focusable inside (git diff, image, PDF, Mermaid) MUST own a FocusNode, claim it on autofocus and on pointer down, so clicking it activates its pane.
  • Moving focus between surfaces by keyboard goes through WorkbenchPaneFocusRegistry (workbench_pane_focus_registry.dart, a keepAlive provider): every pane, single-surface panel and experimental tool wraps its content in WorkbenchRegisteredFocusScope, keyed by the classic group id or the experimental panel key, and the dispatcher's _focusPane / _focusActivePane look the target up there. A terminal is focused through its session handle rather than the scope, because the emulator's node has no focus history the first time its pane is entered. Do not add a second registry or reach for FocusManager descendants by widget type.
  • A surface that claims focus on the autofocus: false -> true transition MUST guard it with workbenchFocusIsParked(): the transition also fires when a keyboard command switches the active pane while the user is typing into a composer or panel field, and an unguarded claim would pull the caret out of it. The initial autofocus on mount is unguarded on purpose, since a freshly mounted active tab owns the pane.
  • Actions that collapse whatever held the focus (sidebar toggle, context panel toggle) MUST hand focus back to the active pane via _focusActivePane, otherwise typing lands on KeyboardShortcutsScope where it goes nowhere. Find/Replace in Files publishes a WorkspaceSearchReveal request instead of only showing the panel, so an already-open panel still focuses the query or reveals the replacement field.
  • Injecting a prompt into a running agent MUST write to that tab's PTY without changing activeWorkspaceId when the user is on another workspace. Watch and Fix follow-up dispatches (timer/onPanelState) MUST pass activate: false so they never call selectWorkspace or selectWorkspaceTab. A user-initiated send on the current workspace MAY focus the agent tab in that workspace, but MUST NOT switch workspaces if the user has already navigated away. Opening a new profile tab from a background watch MUST persist the tab without selecting it. Creating a workspace MUST persist its first tabs, panel tools, and Setup without changing activeWorkspaceId or the visible tab. Toasts are allowed; navigation is not. Mobile comment send MUST NOT auto-navigate; an optional SnackBar Open is the only way to jump to the tab. Mobile From Prompt MUST close the form without pushing the new workspace; Open Workspace stays user-initiated.
  • allowInTerminal: true is for chords a shell never sees or has no legacy meaning for (Ctrl+Tab, Mod+digit, Mod+Shift+W, the Mod+Alt+Arrow navigation family). Chords with a classic terminal meaning (Ctrl+W deletes a word) stay false so terminalFirst keeps them for the shell. test/unit/keyboard_action_test.dart asserts every action has one definition and that resolved default chords are unique per platform.
  • The website's Keyboard Shortcuts page renders landing/src/data/keyboard-shortcuts.ts, and landing/src/data/keyboard-shortcuts.test.ts compares it with keyboard_action_definitions.dart action for action (id, label, description, per-platform defaults, and the terminal flag). A change to the registry MUST update that file in the same change; landing-fidelity.yml fails otherwise.
  • The Mod modifier is platform-neutral (⌘ on macOS, Ctrl elsewhere). Use the canonical token form (Mod+Shift+BracketRight) in defaults; symbol aliases (,, [) are accepted at parse time.
  • Mod+click on Explorer, Search, Source Control, Pull Request, Quick Open, and other file-open origins opens a preview in the opposite panel (center vs right), even when that file is already open in the origin panel. Sibling splits inside the same panel are not the opposite target. Sample isModModifierPressed() at gesture start before any await; a late read after fetch or compare usually sees the modifier already released.
  • Respect the TerminalShortcutPolicy setting: under terminalFirst, only bindings with allowInTerminal: true may intercept while a terminal is focused.

Cross-Platform Desktop Rules

  • Alera targets macOS, Windows, and Linux.
  • macOS AppDelegate.applicationDidFinishLaunching MUST NOT call super: FlutterAppDelegate does not implement that optional Objective-C callback. AppKit catches the resulting exception and leaves the app running without its native speech and desktop-presence channels. Validate startup, tray installation, Dock badge updates, and hide-on-close with flutter test integration_test/desktop_presence_macos_test.dart -d macos.
  • macOS applicationShouldTerminateAfterLastWindowClosed MUST return false: AppKit can request termination when the last window is hidden, even when windowShouldClose prevented its closure. The Dart lifecycle decides whether to hide or exit; an explicit close without a tray or Quit still reaches NSApp.terminate through window_manager.destroy.
  • Windows wWinMain MUST call window.Destroy() after the message loop and before CoUninitialize: window_manager's destroy() only posts WM_QUIT, so the HWND outlives the loop, and letting FlutterWindow members free flutter_controller_ with the window still alive dispatches messages into a freed view (FlutterWindowsView::GetEngine access violation, then WER holds the windowless process for tens of seconds).
  • Use Platform checks or framework abstractions for platform-specific behavior; do not assume POSIX paths or commands.
  • Use path package utilities for filesystem paths.
  • Keep terminal, process, workspace, updater, and release code explicit about platform support.
  • UI shortcut labels must match the actual shortcut behavior for the current platform.
  • The tray icon shows the window on a primary click and its menu on a secondary click on all three platforms, and carries the pending-review count as a badge. Linux publishes its own StatusNotifierItem for both (linux/runner/status_notifier_item.cc, with the menu in status_notifier_menu.cc over com.canonical.dbusmenu and the composed icon in tray_badge_icon.cc), because libayatana-appindicator exports neither Activate nor IconPixmap: a host that maps the primary click to Activate (Plasma, the GNOME AppIndicator extension) sees the call fail and opens the context menu instead, and neither XAyatanaLabel nor a custom IconThemePath icon renders on Plasma (both measured). IconName MUST stay empty while pixmaps exist, or a host prefers the themed icon and the badge never shows, and ItemIsMenu MUST stay false. IconPixmap is ARGB32 in network byte order and NOT premultiplied, so the pixels go through gdk_pixbuf_get_from_surface rather than straight off the cairo surface.
  • linux/runner/appindicator_tray_fallback.cc keeps the libayatana-appindicator tray for a desktop with no org.kde.StatusNotifierWatcher, chosen at the first setTray and dropped as soon as a watcher appears. It cannot show a badge. Its Activate answer comes from a filter on the shared session bus, which runs on the GDBus worker thread and MUST hand the event to the main loop before touching the method channel. ALERA_TRAY_FORCE_FALLBACK=1 selects that path from a desktop that does have a watcher.
  • The application menu uses the global PlatformMenuBar on macOS and a compact Flutter menu beside the app name on Windows/Linux, so those platforms do not lose client-area height to a native GtkMenuBar or HMENU. Menu actions share lib/src/features/app_menu/presentation/app_menu_actions.dart; keep their labels and behavior in sync across the platform presentations. Menu controls MUST NOT register keyboard accelerators, so keys like Ctrl+C keep flowing to text fields and the terminal-first shortcut policy. Because Alera is dark-only, Windows enables process-wide dark mode through windows/runner/win32_dark_mode.cpp.

Flutter Performance

  • Performance is a product requirement. UI changes must keep the Flutter frame pipeline responsive and avoid unnecessary rebuilds, layout churn, blocking I/O, and heavy synchronous work.
  • Do not run expensive parsing, filesystem traversal, process output processing, hashing, serialization, or other CPU-heavy work on the main isolate when it can reasonably run in another isolate.
  • Prefer isolate-backed workers, compute, streamed processing, or incremental batching for work that can grow with repository size, terminal output size, release artifact size, or user data size.
  • Keep main-isolate work limited to UI state coordination and small transformations needed for rendering.
  • When a main-isolate implementation is intentionally kept, document the reason in code or PR notes if the workload could plausibly become large.
  • On Linux a frame costs CPU whether or not it changed anything: the GTK3 embedder reads the rendered surface back and composites it in software on the platform thread (gdk_cairo_draw_from_gl), which no GDK setting avoids and which scales with the window's pixels. Reducing how many frames are produced therefore beats making a frame cheaper. Anything that streams (terminal output above all) MUST NOT request a frame per vsync for as long as data keeps arriving; pace it instead, and measure with the benchmarks under integration_test/ rather than assuming. See docs/performance.md.
  • Dart-side per-tab and per-workspace state MUST be freed when its owner disappears, because each live terminal handle keeps a full xterm scrollback buffer. WorkbenchController.closeWorkspaceTabs is the one place that disposes a closed tab's terminal handle and editor document; call sites MUST NOT pair TerminalRuntime.closeTab with the controller close themselves, since the site that forgot was how handles leaked. The workbench sync paths release (never terminate) handles for tabs and workspaces that vanish from persisted state, because the PTY may still belong to whichever client removed the record. A keepAlive Riverpod family keyed by a workspace id MUST call invalidateWhenWorkspaceRetired in build, or its state (search results, review snapshots) outlives the deleted workspace for the rest of the session. The Codex transcript watch is dropped by the terminal-session cleanup on close, because a terminal closed mid-turn never emits the Stop hook that normally ends it.

Process And Terminal Safety

  • Treat shell and terminal behavior as user-visible product behavior.
  • Do not assume a local shell exists when the code path could later support remote or constrained environments.
  • Keep command execution behind ProcessRunner or a similarly injectable boundary. ProcessRunner's production implementation is RustProcessRunner, which spawns through the bridge (rust/src/api/process.rs) rather than dart:io, because Dart cannot pass Windows creation flags. It keeps runInShell semantics on every platform: the shell is what resolves the .cmd/.bat shims that ollama, claude and npm install, and process_shell.rs mirrors Dart's _getShellArguments quoting so no call site changes meaning.
  • The app MUST NOT spawn a child with dart:io in ProcessStartMode.normal, which Process.run and Process.runSync also use. The reason is not style: while such a child is alive, the Dart VM's reaper thread sits in a wait that reaps any child of the process, discards the pids it does not own, and leaves the waitpid tokio makes for a Rust-side spawn returning ECHILD. That is what surfaced as failed to run /opt/alera/resources/alera/alera: No child processes (os error 10) in the Runtime panel after a cold start, and it reached everything the app spawns through Rust: the sidecar version probe, quota polls, model discovery and alera_core::git_cli::git_in_dir. ProcessStartMode.detached is exempt and is why terminal_host_process_launcher.dart may keep its spawn: it double-forks, so the sidecar never becomes a child that can be waited on. test/unit/dart_io_process_spawn_conformance_test.dart scans lib/ and holds the allowlist to that one entry. The boundary the repo settled on is: commands go through ProcessRunner, and a bare syscall goes through dart:ffi (read in terminal_runtime_posix_io.dart, SetThreadExecutionState in agent_awake_assertions.dart, chmod in posix_file_mode.dart). Do not spawn a process to reach a syscall: chmod cost 0.68ms as a process against 0.00085ms as a call, and six of its call sites ran on the main isolate during startup.
  • No process may be spawned with a bare Command::new. GUI and detached parents MUST go through alera_core::child_process::windowless_command / windowless_async_command, and rust/clippy.toml rejects the constructors so a new call site cannot forget. The reason is Windows-only but structural: the Flutter runner is a GUI-subsystem binary and the sidecar starts detached, so neither has a console, and Windows gives a console child launched from such a process a new console with a visible window - the terminal that used to flash during a git fetch or a quota poll. CREATE_NO_WINDOW asks for a console without a window instead, and grandchildren inherit it, so marking a cmd.exe wrapper covers the whole chain. alera-xtask is a console makefile tool, not those parents: inherited interactive children (make app-debug, make host-debug) MUST use alera_core::child_process::console_command so they keep the parent console (TTY, Flutter hot-reload keys, Ctrl+C). Captured xtask spawns (ps, taskkill, git) still use windowless_command.
  • A host-side lookup or spawn that depends on user configuration MUST resolve its environment through rust/alera-cli/src/login_shell_environment.rs, not std::env alone. The app starts the sidecar detached, so a GUI launch (Finder, Dock, a .desktop entry) hands it an environment with none of the user's shell rc exports and a PATH that omits Homebrew and every version manager. A terminal tab does not have this problem because the shell it launches sources those files itself, which is exactly why the two silently disagreed: quota lookups reported accounts as unconfigured that were configured, and CLIs as missing that were installed. login_shell_variable for a single value, login_shell_command_environment / apply_login_shell_environment for a spawn. The process environment always wins, so an explicit override is never masked, and Windows is exempt because user and system variables already reach GUI processes there. The hydrated map may hold API keys: it is memory-only and MUST NOT be logged or persisted.
  • Remote AI Dictation runs in cancellable deferred sidecar jobs so network or Codex work never blocks the server actor. Runtime OpenAI-compatible Bearer tokens live in AiDictationCredentialStore (system keyring with a private mode-0600 Linux fallback), are bound to the canonical provider origin, never enter settings or mobile payloads, and credential requests MUST stay local-client-only. Mobile This Device OpenAI transcription is separate: MobileOpenAiDictationProvider calls the API from the phone and reads an origin-bound token from MobileAiDictationCredentialStore in platform secure storage; Paired Device sends audio, URL and model but no token, and the runtime uses its own credential. Codex subscription dictation always goes through the authenticated experimental codex app-server realtime protocol in an ephemeral read-only thread with one whole-operation deadline, never by extracting Codex credentials or calling ChatGPT backend endpoints directly. aiDictationRemoteProvidersV1 and the related request fields and verbs are additive and MUST NOT bump either strict protocol version; it MUST be advertised in both the desktop control file and MOBILE_HELLO_CAPABILITIES.
  • Tests for command construction should verify Windows, Linux, and macOS variants when behavior differs.
  • Cases in orchestration_review_regressions fail intermittently under the parallelism of a full cargo test --workspace and pass in isolation; they drive real PTYs, and which case flakes varies between runs (coordinator_promotion_waits_for_deferred_delivery, cancelling_active_worker_interrupts_before_idle_banner_delivery, and push_on_idle_does_not_duplicate_in_flight_batches have all been observed). CI runs that binary with --test-threads=1 after the rest of the workspace, still under --workspace so feature unification does not relink. Locally, re-run the named case on its own before treating a red make rust-test as a real regression, and do not chase it as fallout from an unrelated change.
  • Use the lowercase repository makefile for app/CLI debug workflows instead of keeping one-off commands in chat or local notes. Its debug targets must remain shell-neutral and route platform-specific behavior through alera-xtask (cargo run --locked --manifest-path rust/Cargo.toml -p alera-xtask) so they work from PowerShell 7 on Windows as well as Linux and macOS shells, without depending on a matching Dart SDK. make help lists available rules. make init-submodules initializes the two required source submodules. make app-debug runs the Flutter app with the development CLI fallback (a cargo run of the Rust sidecar), make cli-build compiles the Rust alera CLI sidecar (the rust/alera-cli crate) with cargo, make app-debug-bundled-cli runs the app against the compiled sidecar, make host-debug runs the Rust alera terminal-host in the foreground, and make rust-test runs fmt/clippy/test for the workspace.
  • When debugging persistent terminal behavior, inspect process separation with make debug-processes; the UI process should be the Flutter app and the host process should be alera terminal-host. Use ALERA_HOST_EMPTY_SHUTDOWN_SECONDS, ALERA_HOST_DETACHED_SHUTDOWN_SECONDS, and ALERA_HOST_SCROLLBACK_BYTES when foreground host debugging needs non-default lifecycle or scrollback values. Use make host-stop only when intentionally ending the current debug host for this app id.
  • The shipped alera CLI / terminal-host sidecar is the Rust crate under rust/ (rust/alera-cli, binary alera). The native build hooks - linux/CMakeLists.txt, windows/CMakeLists.txt, and the macOS "Build Alera CLI Sidecar" Xcode phase - build it with cargo build --locked (Release app → --release, otherwise debug) and install the single binary into resources/alera/alera[.exe]. The toolchain is pinned by rust/rust-toolchain.toml and rust/Cargo.lock is committed; CI and the hooks build reproducibly with --locked. The Dart client-side and shared protocol files under lib/src/features/workbench/infra/terminal_host/ stay active because they connect the app to the sidecar over the socket.
  • PTY output framing is negotiated per client, never per host. A client asks for length-prefixed binary frames in its hello (binaryFrames: true) only when the control file advertises RUNTIME_HOST_BINARY_FRAMES_CAPABILITY; the host answers with what it granted and every later byte on that connection is framed. This MUST NOT bump aleraTerminalHostProtocolVersion, because a version mismatch makes the app treat a live host as unusable, and because the alera CLI (runtime_host_client.rs) and older apps must keep getting newline-delimited JSON from the same host. The switch travels in band, as a ClientFrame::UpgradeToBinary queued behind the hello response on the same lane: a shared flag could flip before the response was written and frame a response the client is still reading as a line. Frame layout lives in rust/alera-cli/src/terminal_host/frame_codec.rs and its Dart mirror terminal_host_frame_codec.dart, which has a fixed-bytes test so the two cannot drift.
  • Per-session CPU and memory sampling lives in the sidecar, never in the app: the host already owns the PTYs and the sessionId -> workspaceId -> tabId relation, and a process-table sweep must stay off the Flutter main isolate. Session.shell MUST be cleared on exit and terminate, because the OS recycles pids and a stale value silently attributes a stranger's process to a dead session. Clearing alone is not enough: the OS reaps the shell before the reader thread reports the exit, so the field also carries the start time observed at spawn (seal_shell_process), and a sweep attributes a subtree only while the pid still holds that start time (ProcessIndex::holds). A root that fails the check reports measured: false instead of billing a stranger's memory to a terminal. The comparison has second resolution, so it bounds that window rather than closing it. When summing subtrees, claim the session roots before the host root and share one claimed set: every PTY shell is a child of the runtime host, so the opposite order swallows all of them into one unattributed row. resources.snapshot and resourceMonitorV1 are additive and MUST NOT bump aleraTerminalHostProtocolVersion. Every cpuPercent on that payload is per core, the unit sysinfo reports, and it MUST stay that way: the app can attach to an already-running older sidecar, so the meaning cannot depend on which side is newer. Normalization is app-side, in machineCpuShare (lib/src/features/resource_manager/domain/machine_cpu_share.dart), which divides by cpuCoreCount so the panel reads as a share of the machine like the memory column does, and returns absent rather than a raw number when the core count is unknown. How many sweeps sysinfo needs before process CPU is meaningful differs per platform (3 on Windows, 2 on Linux and macOS), and CI runs cargo test --workspace on Linux only, so changes to the sampler MUST be re-verified on a real Windows and macOS machine. Every process refresh MUST ask for without_tasks(): ProcessRefreshKind::nothing() is not nothing, it defaults tasks on, and on Linux that puts every thread in the table as a child process reporting the whole process's RSS, so a subtree total scales with the thread count rather than measuring memory (the app read 26x its real size at 97 threads, and CPU double counts because the leader's /proc/<pid>/stat is already the thread-group aggregate). Neither the claimed set nor the tree arithmetic can catch this, since every tid is a distinct unclaimed pid; the sampler's plausibility tests (attributed memory within the machine's, attributed CPU within cores * 100) are what fail if it comes back.
  • Session::terminate kills the shell's whole process tree, not just the shell. On Unix, portable-pty's killer reaches the direct child with SIGHUP, and the kernel hangup only reaches the controlling terminal's foreground group, so shell_tree_termination.rs captures descendants BEFORE signalling the root. A dead root's children reparent away, and any row still naming its pid may be recycled; therefore an exited or unverified shell MUST NOT be swept. On Windows, every live PTY session owns a Job Object configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. portable-pty cannot attach a Job atomically at CreateProcessW, so its initial ConPTY child is Alera's gated bootstrap: the bootstrap MUST NOT launch the real shell until the host has associated it and signalled the release event. It MUST ignore Ctrl+C locally without setting the inheritable ignore flag, so Ctrl+C reaches the shell without tearing down the session. Association failure MUST terminate and wait for the bootstrap through its native process handle, and the job handle MUST remain owned by Session until termination or child exit so agents, servers, and detached descendants cannot outlive the session.
  • Recurring host work that nobody reads while nobody is asking MUST go through DemandDrivenTicker (rust/alera-cli/src/terminal_host/demand_driven_ticker.rs) rather than an ad hoc tokio::spawn loop. The host is a sidecar and cannot see whether the app's window is visible, and it MUST NOT take a client's word for it either, because a client that reports going away may instead have died mid-report. Silence is the signal that survives both, so the ticker stops on an idle window and restarts on the next request. The idle window MUST be derived from the client's actual polling period rather than hardcoded: when the resource monitor's window was a constant that had to agree with a cadence chosen in Dart, the two drifted, the ticker stopped under a chip that was still polling on time, and the panel appeared to work only while the mouse hovered it. resources.snapshot carries intervalMs for exactly this, and the host sizes both its sampling interval and its idle window from it (resource_idle_stop_for); a request that omits it gets the host's defaults, so an older app is unaffected. The field is additive and MUST NOT bump aleraTerminalHostProtocolVersion. Restarting the ticker for a cadence change MUST NOT reset the sampler's CPU baseline - that reset exists for idle gaps, and doing it on every hover puts the panel back into "measuring".
  • A client that fell behind is resynchronised from the delivery cursor the host keeps per client (Session::delivered_output_cursors), never by resending the scrollback. It falls behind two ways, a visibility pause and a full output queue, and both take the same path through resume_output_for_client (rust/alera-cli/src/terminal_host/server/output_delivery.rs). The cursor MUST advance only when a frame is accepted by that client's queue: advancing it on append makes a dropped frame a silent hole. The missed bytes MUST go out on the terminal lane ahead of the unpause, not inside the request's reply, because the reply travels on the control lane and the writer drains terminal frames first, so bytes returned inline can land after output that came later; the lane also feeds them through the client's per-session UTF-8 decoder, so a code point split at the pause boundary still joins up. A client the host cannot place in the stream gets a full snapshot instead, which is the only correct answer once the ring has dropped the gap. The Dart client MUST NOT discard output between losing visibility and the pause taking effect, because the host already counted those frames as delivered.
  • The snapshot an attach or a resync replays is capped by restoreSnapshotBytes, which is separate from scrollbackBytes on purpose: the first is what a client's emulator will keep, the second is what the host retains so terminal.read and the coordinator tail can page back through it. Both are additive and MUST NOT bump aleraTerminalHostProtocolVersion; a host that receives no cap replays the whole buffer, which is what an older app expects.
  • Cursor's hooks are written into the user's ~/.cursor/hooks.json as Alera-managed command entries, never as a per-session plugin or cursor-agent wrapper. install_cursor_user_hooks (rust/alera-cli/src/agent_status/integration_config_cursor.rs) merges those entries from prepare_enabled_integrations and removes only Alera-marked definitions on cleanup. Host start still deletes leftover agent-runtime-overlays/cursor directories from older versions. Alera MUST NOT write a permission verdict to stdout for preToolUse, beforeShellExecution or beforeMCPExecution: an allow there replaces Cursor's own approval prompt. Silence is the right answer for Cursor and only for Cursor - empty stdout with exit 0 is a documented fail-open there, whereas Antigravity and Copilot owe a JSON reply on every event, which is why the shared script answers for those two agent types and not this one. It is also why Cursor may install preToolUse while Antigravity may not install PreToolUse: the difference is whether the agent treats a missing decision as an error. Every definition carries an explicit timeout because Cursor's default is 60s. beforeShellExecution/beforeMCPExecution mean working, same as their after counterparts: Cursor fires the before event whether or not the user is asked, so treating it as waiting notifies on every shell or MCP call. Cursor currently omits preToolUse for AskQuestion; a human-input preToolUse is still mapped to waiting so a later CLI fix does not require another Alera change. Shell approval prompts in the TUI still do not notify. sessionStart MUST stay unregistered - it fires before any prompt and normalizes to working. Which events fire depends on how the CLI was started, verified against cursor-agent 2026.08.04: an interactive run emits sessionStart, beforeSubmitPrompt, afterAgentResponse, stop, sessionEnd, while -p emits none of beforeSubmitPrompt, afterAgentResponse or stop, so sessionEnd is the only event that ends a headless run and MUST stay registered. Tool events (preToolUse, beforeShellExecution, afterShellExecution, postToolUse, in that order) fire in both. The managed command no-ops when GROK_HOOK_EVENT is set, because Grok scans this same file.
  • hostId on workspace.createManaged and the workspace.files.list / workspace.files.read verbs are additive (remoteSshWorkspacesV1) and MUST NOT bump aleraTerminalHostProtocolVersion. Desktop New Workspace and the explorer MUST feature-detect that capability before sending hostId or switching off the local filesystem API. An older live host that lacks the capability MUST be refused with a user-facing error rather than silently creating a local worktree.
  • Remote hosts follow the hub model in docs/remote-hosts-hub.md: the desktop runtime is the hub and every bootstrapped SSH target runs one satellite runtime (the sidecar's own runtime-host on <installDir>/data). The hub reaches it through one persistent ssh -T child per host running alera runtime-attach --stdio (rust/alera-cli/src/terminal_host/host_link.rs, host_link_registry.rs, rust/alera-cli/src/runtime_attach.rs). The satellite performs the hello locally, so the hub never learns its token, and the first stdout line MUST be hostLink.attached. hostLink.* verbs, hostLinkChanged, hostLinkEvent, remoteHostLinkV1 and remoteSatelliteV1 are additive and MUST NOT bump aleraTerminalHostProtocolVersion; both event names MUST stay in runtimeHostEventNames. Connecting awaits an ssh handshake, so the actor MUST only call HostLinkRegistry::link from a spawned task and answer through ServerCommand::HostLinkRequestFinished. The Windows attach command MUST NOT go through PowerShell: with its own stdin redirected, PowerShell hands a native command an empty pipe unless that command is on the right of |, so the hub's frames never reach alera.exe. It runs "<installDir>\bin\alera.cmd" runtime-attach --stdio under the sshd default shell (cmd.exe) instead. Do not add a second SSH transport or a per-project satellite next to the link. Every owner command the hub runs over ssh (owner-terminal, precheck, relocation, retirement) is built by remote_owner_terminal_launch::owner_command_script and targets that same <installDir>/data profile; the per-project owners/<sha256(projectId)> profiles are legacy and only remote_owner_retirement::owning_state_dir may still read them, to retire a workspace created before the switch. Before a host-scoped verb touches a remote workspace, the hub MUST register it on the satellite through server/host_link_routing.rs::mirror_workspace (hub.mirror.workspace, idempotent, validated by remote_workspace_owner::register), never by writing the satellite database directly. Host-scoped workspace verbs (workspace.files.* including write, create, rename, copy, move and delete, mobile.workspaceSearch.*, mobile.workspaceQuickOpen.*) are forwarded by host_link_routing::forward_workspace_scoped_request when the workspace has a remote hostId, and file mutation rules live once in alera_core::workspace_files::mutations, shared by the FRB desktop path and server/workspace_file_mutation_requests.rs. Desktop git.* verbs are answered by server/workspace_git_requests.rs, one verb per GitBackend method, each calling the alera_core::source_control function the FRB bridge calls, so git_explorer_status_snapshot, git_diff_blob_bytes, list_remotes and any future git rule MUST live in alera_core, never only in rust/src/api. A GitError crosses the wire as a typed gitError conflict with errorDetails.kind; RuntimeGitBackend rebuilds the same GitException. On desktop the routing seam is WorkspaceFileService's *Workspace* methods, remoteWorkspaceSearchServiceProvider, and gitBackendProvider, which returns HostRoutedGitBackend: a path inside a remote workspace (RemoteCheckoutIndex, local checkouts win on a tie) reaches that host's RuntimeGitBackend, so consumers keep reading gitBackendProvider with the path they have. Tools that act on a checkout run on its host through host.process.run (server/host_process_requests.rs, command built by alera_core::shell_command, which the FRB process spawn shares): it is local-client-only and MUST NOT be added to the mobile allowlist, because it executes whatever the caller names, and its cwd MUST stay inside the workspace. On desktop the seam is workspaceProcessRunnerProvider (HostRoutedProcessRunner, routed by working directory through the same remoteWorkspacePathResolverProvider as git), so a forge call MUST pass the checkout as workingDirectory, checkAuth included, or it silently asks the hub's CLI about the hub's credentials. Only workspace-scoped consumers take that runner; the updater, installers, quota polls, keep-awake, the file manager and the browser keep processRunnerProvider. RemoteProcessRunner sends only the variables the caller names: the hub's environment means nothing on another machine and may hold secrets. mobile.pullRequest.* (except summaries) and the workspace-keyed aiText.* generations are forwarded too, and the two pieces of hub-owned state they need travel in the payload rather than being read from the satellite's disposable store: hubLinkedReview (adopted by the satellite, and adopted back by the hub after a link-changing verb) and aiAssistSettings. aiAssistSettings can name a custom command, so refuse_hub_only_payload_fields MUST keep rejecting it from a non-local client, and the hub MUST overwrite it when forwarding rather than pass a client's value along. Watch and Fix keeps ticking on the hub and reads its snapshot and runs its merge through remote_pull_request_routing. A remote terminal shares its session, tab and workspace ids with the satellite's PTY session, and the agent's hooks fire on the satellite, so a satellite re-publishes each hook as the local-only agentHookEvent (remoteAgentHookRelayV1) before checking its own settings and the hub feeds it to handle_agent_hook_event (server/remote_agent_presence_relay.rs). Do not add a second status path next to that: titles, resume ids, orchestration readiness and push all hang off the one handler, and it is the handler's id check that stops a satellite from reporting for sessions the hub does not proxy. The agentPresence.list read on attach exists only to cover the time a link was down. server/remote_resource_relay.rs polls attached satellites on the hub's own resource tick with the hub's intervalMs and MUST NOT open a link to do so; a relayed row carries hostId and the satellite's cpuCoreCount, because cpuPercent stays per core and the hub's count is not the satellite's. Both are additive and MUST NOT bump aleraTerminalHostProtocolVersion. One project is registered once and then added to hosts (project.hosts.list/add/remove, server/project_host_requests.rs, capability projectHostsV1); a host is a project checkout row, which is what New Workspace, the branch catalog and the mirror already read. primaryHostId is derived in project_hosts::primary_host_id and MUST keep treating a project without a local checkout row as local: project.upsert never writes one, so reading a missing row as "lives elsewhere" would turn ordinary projects into remote-only ones. add without a path sends the host a directory name and the target's projectsDir as typed, never a resolved path, because only the host knows its home directory and environment (alera project clone-checkout-folder --name [--projects-dir], which expands ~, $HOME and %VAR% there and defaults to <home>/alera-projects). remove MUST NOT delete files, and folder projects stay on one host. Code that reads Project.repoPath as a local directory MUST check primaryHostId == local first; the repository alera.toml of a remote-only project is read over the link by server/remote_project_config.rs, and its pull request summaries by one forwarded snapshot per workspace (mobile_pull_request_summaries_remote.rs), so a new consumer of the project folder goes through one of those rather than opening the path; code that runs inside the actor and cannot wait on a link (agent launches, prompt composition) reads remote_project_config_cache.rs instead. A satellite's store holds copies of only the workspaces it serves, so once it has mirrored a hub workspace (satelliteOfHub metadata) its CLI listings go to the hub over the reverse channel (hub.forward -> hub.request event -> hub.respond, server/hub_reverse_requests.rs) and MUST fail loudly when no hub is linked rather than answer from the copies. The reverse channel carries mutations, so what a remote host may ask is the default-deny table in server/hub_reverse_policy.rs, enforced on both ends, and it MUST keep refusing every terminal and PTY verb, host.process.run, hostLink.*, hub.*, sshTarget.*, account.*, configure, runtimeSettings.*, mobile.*, aiDictation.*, automation.*, project.register and project.clone.*: a host reached over ssh is less trusted than the hub and that table is what keeps a compromised server from driving the desktop. The hub answers an admitted verb by sending it to itself as a local client (server/hub_self_client.rs), never through a second implementation; the satellite MUST NOT forward requests from the registered hub link, nor owner-side retirement and relocation (expectedInstanceId), nor buffer guards it holds itself. status.get stays local because runtime-attach reports it. A phone labels remote workspaces from mobile.hosts.list (id, alias, platform); sshTarget.list says how to reach a host and MUST stay off the mobile allowlist. Three rules came out of running against a real Windows host and MUST hold. A path a satellite reports or stores goes through windows_path_form::canonicalize, never std::fs::canonicalize, whose verbatim \\?\C:\... answer is not a valid working directory; identity checks between a stored and an inspected path use windows_path_form::same_path, because older records hold the verbatim spelling. The Windows default launch MUST NOT put a quote inside the cd /d argument (cmd_launch_directory): the standard library escapes it as \", cmd.exe cannot read that, and the terminal silently opens in the user's home. And a hub MUST NOT take the basename of a project path with std::path, which splits by the hub's own rules: use project_hosts::folder_name, since a remote-only project's repoPath is a path from another operating system. Presentation code MUST NOT branch on workspace.isRemote to refuse a file, search, git, pull request or AI Assist action. remoteGitV1 and remoteProcessV1 are additive and MUST NOT bump aleraTerminalHostProtocolVersion.
  • Watch and Fix sessions (pullRequestWatch.list / find / start / stop, and alera workspace pr-watch) are additive under pullRequestWatchV1 and MUST NOT bump aleraTerminalHostProtocolVersion or aleraMobileProtocolVersion. The capability is advertised in the control file, status.get, and MOBILE_HELLO_CAPABILITIES. The host stores one session per workspace; desktop hydrates it and keeps running the existing Dart evaluation loop (dispatch and merge). Mobile may list/find for the sidebar eye and MUST NOT be allowed start/stop. pullRequestWatchChanged MUST stay in runtimeHostEventNames. Do not start a watch without a running terminal handle or an agent profile, or with every scope toggle off. A later persist may drop a closed tab when a profile remains, so lastDispatch and lastMergedHeadSha still write. Hand off and hand on MUST broadcast pullRequestWatchChanged with a wildcard scope so the eye follows the moved workspace.
  • workspace.focus and the workspaceFocusRequested event are additive under workspaceFocusV1 and MUST NOT bump aleraTerminalHostProtocolVersion. The host delivers the event only to local app connections that announced workspaceFocusV1 in hello, so an older app is refused with an update message instead of silently ignoring it; keep the event in runtimeHostEventNames. The app selects through WorkbenchController.selectWorkspace, never a separate selection path. Mobile MUST NOT be allowed the verb and the hub reverse policy MUST keep denying it.
  • Linked issues (linkedIssue.*, issue.fetch, and issueUrl on workspace.createManaged) are additive under linkedIssuesV1 and MUST NOT bump aleraTerminalHostProtocolVersion or aleraMobileProtocolVersion; the capability is advertised in the control file, status.get, and MOBILE_HELLO_CAPABILITIES, and desktop and mobile hide the controls without it. Issue fetching lives only in the sidecar (rust/alera-cli/src/issue_tracking/) and goes through each forge's own CLI (gh, glab, az boards) via ForgeCliRunner, so Alera never holds a forge token and the CLI, desktop, and phone share one implementation. The URL is persisted before any fetch, a failed fetch is recorded on the link rather than failing the request, and a refresh that finishes after the link changed MUST NOT write its result. Issue bodies are untrusted text: they may prefill a prompt the user reviews, and MUST NOT be logged.
  • After Create, desktop and mobile New Workspace close the form and run Git worktree creation (and the From Prompt identity plus agent launch) behind a session job card. Dismissing the form MUST NOT cancel that pipeline or skip Setup-tab completion. Completing the job MUST NOT select the new workspace or steal the visible tab; the user opens it from the sidebar. A failure keeps a Retry card that reopens the same form with the submitted fields. Clone From URL uses the existing durable project.clone.* jobs and projectCloneJobsChanged (which MUST stay in runtimeHostEventNames) rather than a blocking progress dialog. Workspace-create jobs are client-session state and MUST NOT bump aleraTerminalHostProtocolVersion.
  • The desktop and mobile apps defer a project's worktree setup to a terminal tab named Setup instead of holding the New Workspace UI open until pnpm install finishes. deferSetup on workspace.createManaged, deferredSetupCommand on its response, and the initialCommandOnce tab payload key are additive and MUST NOT bump aleraTerminalHostProtocolVersion; a host that ignores the flag runs the setup inline and omits the command, which is exactly the old behavior. The tab runs one portable line (/bin/sh "<script>", cmd /d /c "<script>") against a host-generated script, and that indirection is load-bearing. && cannot be used: the terminal hosts whatever interactive shell the user configured, PowerShell 5.1 rejects && at parse time and nushell removed it. Writing one command per line up front cannot be used either, because PTY bytes go to the foreground process, so the second line lands on the first command's stdin. The Windows launcher omits /s on purpose - with it, cmd strips the outer quotes and a script path containing spaces breaks apart - and leaves cmd unquoted so PowerShell does not need the & call operator. Copy rules go through alera workspace setup --copies-only rather than being rewritten in shell, so copy_rule_inner's symlink and path-escape validation stays in Rust. That same copy path also expands .worktreeinclude at the project root: gitignore-syntax patterns that select gitignored files from the main checkout, matching Conductor and Claude Code, without copying tracked files. initialCommandOnce exists because agent tabs deliberately re-mint their initialCommand on every new PTY, so the clearing MUST stay opt-in.
  • A spawnable agent receives its starting prompt at launch, in the shape its own CLI accepts, declared once per adapter as startup_prompt in rust/alera-cli/src/terminal_host/orchestration/agent_registry.rs. Typing the prompt into the running TUI instead is not an option and never was one: that path waits for an agent-status hook reporting done, and an agent that has been asked nothing never reports that it finished anything, so the prompt hung forever for every agent except Codex. pendingAgentPrompt and pendingOrchestration are no longer written, but their delivery paths MUST stay: the app attaches to whichever sidecar is already running, so a newer host has to be able to finish a delivery an older one started. The declared shapes are load-bearing per agent and were each verified against the installed CLI: -- before a positional prompt for codex, claude, cursor and grok; a bare positional for pi, which rejects -- outright (Error: Unknown option: --) and so gets a leading space when the prompt opens with a dash, since it reads -anything as an option; and a single --flag=<prompt> token for copilot, agy, opencode and opencode2, which keeps a dash-prefixed prompt out of the parser without a terminator. OpenCode v1 (opencode) and OpenCode 2 (opencode2) are separate spawnable agent types that can run side by side; they share OPENCODE_CONFIG_DIR but install distinct plugins (alera-agent-status.js / alera-agent-status-v2.js) and hook routes (/hook/opencode / /hook/opencode2). The print/execute flags (-p, --print, -x) MUST NOT be used: they answer once and exit, leaving no agent in the tab. Because the launch line is typed into the user's interactive shell, a very long prompt is bounded by that shell's limit (8191 characters on cmd.exe).
  • When a supported agent hook reports a native conversation, session, or thread id, the host stores it on that tab as agentNativeSessionId and agentNativeSessionAgent, including plain terminals where the user typed the agent themselves. Those keys are host-owned and additive and MUST NOT bump aleraTerminalHostProtocolVersion. A later remint uses the adapter's session_resume shape from agent_registry.rs and MUST NOT replay the original prompt. Missing, empty, parent-session, or unusable ids leave the existing launch unchanged. A tab without an Agent Profile snapshot synthesizes the adapter's default command plus resume tokens. Claude through CCS also stores agentNativeCcsProfile from CLAUDE_CONFIG_DIR (.../instances/<profile>) so a remint is ccs <profile> --resume <id>, even when the user launched via a shell alias. Default Claude (~/.claude) is not an instance and must not get that key.
  • Agent presence MUST NOT depend on the agent's terminating hook alone (see docs/agent-status-hooks.md). The host sweep in server/agent_presence_reconciliation.rs removes presence once the agent's foreground process group is gone and turns a working presence into done after 60 seconds with no PTY output and no hook. That inferred done (AgentPresence::inferred_idle) MUST NOT accept injection, so readiness checks go through AgentPresence::accepts_injection or AgentPresenceRegistry::is_injection_ready, never AgentPresenceState::accepts_injection on a stored entry. A hook from another live agent process (CLAUDE_PID), or, without pids, from a different native conversation id while a turn runs, is a nested agent and MUST NOT change the tab's state or its resume binding. Sub-agent hooks (agent_id, subagentType, a parent id) MAY raise attention but MUST NOT end or close the turn, and outside Claude MUST NOT reopen it. Claude's children are tracked by SubagentStart/SubagentStop in ClaudeSubagentRoster: a finished main turn reports working while a child runs and waiting while a child asks, and only reaches done once the roster drains. A session end that names another conversation MUST be ignored, and Claude's SessionEnd for clear/resume is not an exit. Managed opencode2 launches MUST keep --standalone: plugins run in the server, and the shared background service carries another tab's environment. Copilot's Windows hook runs as pwsh -c and MUST contain no double quote and always exit 0, because a failing preToolUse hook denies the tool.
  • amp is the one agent with no initial-prompt option, so its prompt is written to a plain file and fed on stdin by a generated script, invoked through the same portable /bin/sh "<script>" / cmd /d /c "<script>" line the Setup tab uses. A pipeline typed into the terminal cannot be used: the prompt is free multi-line text and <, echo and quoting all differ across PowerShell 5.1, cmd and nushell. Only stdin is redirected, because amp switches itself into non-interactive execute mode when stdout is redirected, and leaving stdout on the PTY is what keeps the agent in the tab. Neither generated file deletes itself - the script is still being read when it execs the agent, and the redirect has to outlive that handover - so the startup sweep next to remove_stale_setup_scripts is what clears them.
  • An action that would otherwise print a command for the user to paste somewhere runs it in a command terminal instead (lib/src/features/command_terminal/, entered through showCommandTerminalDialog). The point is the PTY: ProcessRunner.run gives no TTY, so a sudo password prompt there hangs with nothing able to answer it, and the copy-the-command path existed because there was nowhere to type. The command is written into the user's own interactive shell as an initialCommand, exactly as the Setup tab does, so it MUST be one portable line and is subject to the same && and one-command-per-line constraints described above. The session is synthetic and unpersisted, keyed by commandTerminalWorkspaceId, and the dialog owns its whole lifetime: it calls runtime.closeTab on dismissal, which terminates the shell's process tree. terminalRuntimeExitCoordinator MUST keep skipping that workspace id, because closing the session when the PTY exits would wipe the output at the exact moment the user wants to read it. Nothing detects when the command finished - the shell outlives it - so closing while the shell is alive asks first rather than guessing.
  • The desktop Terminal Composer submits through the same host deferredEnter path as mobile and orchestration: the prompt bytes keep the emulator's live DECSET 2004 decision rather than the host bracketedPaste flag, so a future change does not collapse the payload and its Enter back into one PTY write.
  • Cursor CLI enables Kitty keyboard disambiguate mode (CSI > 1 u). The emulator MUST NOT emit Kitty private-use key codes (CSI 57358 u and up: modifiers, Caps/Num/Scroll Lock, F13-F24, media keys, context menu, numpad) unless flag 8 (report all keys as escape codes) is also set. Ink inserts those codepoints as prompt text, which shows up as stray glyphs. Shift+Enter itself stays CSI 13;2 u. The emulator MUST NOT emit anything for a key release unless flag 2 (report event types) is set: a release has no encoding of its own without it, so it repeated the press sequence, and every agent that enabled flag 1 alone (Cursor, Gemini, Copilot, OpenCode) received Shift+Enter, Escape and Ctrl+V twice - the doubled newline, and a second paste the agent ran itself on top of the terminal's bracketed paste. Codex, Grok and Pi were unaffected only because they either request flag 2 or never enable the protocol. test/unit/xterm_regression_test.dart holds the press-plus-release case.
  • Selecting and copying MUST feel the same in every agent tab. Agents split into two groups: Codex, Claude Code, Cursor, Pi and Antigravity leave the mouse to the terminal, while OpenCode, Copilot, Amp and Grok enable mouse tracking, which used to hand every drag to the TUI so selection only worked with Shift held. TerminalSettings.dragSelectsInTuis (default on) passes dragOverridesMouseReporting to the emulator: a primary drag stays a local selection, a press that never drags is reported to the TUI as a click on release, and wheel input is untouched. clipboardOnSelect defaults to on so the selection is copied without Ctrl+C. Both are ordinary settings; a stored blob keeps whatever the user chose.
  • Runtime change events carry an optional scope id (workspaceId, projectId) and an absent or empty scope means wildcard: every watcher refreshes. The app can attach to an already-running sidecar, so an older host broadcasting an empty payload MUST keep working. Emit these events through the helpers in rust/alera-cli/src/terminal_host/server/runtime_change_broadcasts.rs and pass None whenever the mutation really is broader than one workspace or project. Never guess a scope: the wildcard is the safe value, a wrong id silently stops watchers from updating. Adding a scope field is additive and MUST NOT bump aleraTerminalHostProtocolVersion, because a version mismatch makes the app treat a live host as unusable. A host event reaches Dart runtimeEvents only when its name is in runtimeHostEventNames; a new watcher MUST add the name there or the UI will not refresh until reconnect or restart.

Pull Request Watch

  • GitHub Watch and Fix execution belongs to the runtime when pullRequestWatchExecutionV1 is advertised. Clients use the shared persisted watch and pullRequestWatchChanged; they MUST NOT also dispatch or merge locally. Keep the capability in the control file, status.get, and mobile.hello. See docs/pull-request-watch.md for execution and compatibility boundaries.

Cloud Accounts And Mobile Push

  • ChatGPT AI Assist uses the public Sign in with ChatGPT OSS flow independently of the Alera cloud account and Codex CLI credentials. chatgpt_session owns registrations, serialized OAuth refresh and account-change cancellation; chatgpt_credentials stores an encrypted runtime file with a keyring-held encryption key and an owner-only Linux fallback. Never put these tokens into settings or mobile payloads. aiAssist.chatgpt.* account administration stays local-client-only. Inference uses the selected account's model catalog and public Responses API with store: false, stream: true, array input, and success only after response.completed. Keep aiAssistChatGptV1 additive, without changing strict protocol versions. See docs/chatgpt-ai-assist.md.

  • Alera accounts remain optional for every local feature. Google and GitHub identity, cloud sessions, mobile enrollment, and push delivery are additive capabilities and MUST NOT bump the strict terminal-host or mobile protocol versions. After OAuth completes, the host broadcasts aleraAccountChanged (or aleraAccountSignInFailed). Those names MUST stay in runtimeHostEventNames so Account settings rebuilds to the signed-in tree without an app restart.

  • The cloud backend is an HTTP control plane only. It MUST NOT parse, proxy, or participate in the Alera terminal-host protocol; any future internet relay carries opaque end-to-end encrypted bytes.

  • Provider client secrets, token-signing private material, refresh tokens, bearer tokens, and FCM registration tokens MUST NOT be committed, logged, or placed in release artifacts. The runtime stores account refresh credentials behind its credential-store boundary, and the mobile app uses platform secure storage.

  • account.* requests remain local-client-only. The only mobile account bootstrap request is mobile.cloudEnrollment.create, and it uses the stable cloud installation id bound to the authenticated client by additive mobile.hello.cloudDeviceId, never an id supplied in that enrollment request. mobile.cloudSubscriptions.refresh may only ask the runtime to re-read its own authoritative count from Cloud; it never accepts a count or subscription claim from the phone.

  • FCM tokens and per-runtime mobile subscriptions live in the cloud backend. A runtime emits idempotent domain events only after explicit runtime opt-in and never stores a phone's FCM token.

  • Push payloads may contain the selected agent state plus project and workspace names. They MUST NOT contain prompts, commands, terminal input or output, source code, repository contents, or arbitrary orchestration text.

  • Attention includes waiting and blocked agents, escalation and decision gates, and coordinator stall gates. Agent done and terminal exit remain separate default-off categories. Replayed snapshots, cooldown repeats, and nearby bursts MUST be damped before cloud delivery.

Build Flavors

  • Alera builds in two flavors selected by the ALERA_FLAVOR environment variable: dev (default) and release.
  • The dev flavor uses dev.leynier.alera.dev as bundle identifier / GTK APPLICATION_ID, alera-dev as Windows/Linux binary name, and Alera Dev as the display name in the Dock, taskbar, and window title. This lets a locally running dev build coexist with an installed release build without sharing user-data directories (which are keyed by bundle id on macOS, by GTK application id on Linux, and on Windows by the runner's VERSIONINFO CompanyName\ProductName (dev.leynier\Alera Dev vs dev.leynier\Alera), which windows/runner/CMakeLists.txt drives from ALERA_APP_NAME; alera-xtask mirrors that mapping for its runtime paths).
  • The release flavor keeps bundle identifier dev.leynier.alera and display name Alera. Its on-disk executable is Alera on Windows (Alera.exe) and macOS (Alera.app, via ALERA_PRODUCT_NAME), while the Linux binary stays lowercase alera by POSIX convention. desktop_updater copies the macOS bundle to dist/ under the lowercase pubspec package name (alera.app), so release-cut.yml renames it to Alera.app before packaging the release tarball. The release flavor MUST be selected by CI for any artifact intended to be installed by an end user. .github/workflows/desktop-build.yml and .github/workflows/release-cut.yml set ALERA_FLAVOR: release at the job env level.
  • The makefile defaults ALERA_FLAVOR to dev and forwards --alera-flavor to alera-xtask. That tool regenerates macos/Runner/Configs/Flavor.xcconfig (git-ignored) before each flutter run and exports ALERA_FLAVOR so the Windows/Linux CMake branches and the Dart-side kAleraFlavor constant agree. Flavor.example.xcconfig documents the dev-flavor values for reference. A cargo test in alera-xtask asserts the flavor identity strings stay identical to lib/src/core/build_flavor.dart.
  • Auto-update MUST remain disabled on dev builds regardless of any other flag. The guard lives in effectiveAutoInstallEnabled (see lib/src/core/build_flavor.dart) and is applied by AleraUpdateConfig.fromEnvironment().
  • The canonical flavor identity strings (Alera, Alera Dev, dev.leynier.alera, dev.leynier.alera.dev, and the release / dev selectors) live in lib/src/core/build_flavor.dart. alera-xtask duplicates those literals for the xcconfig generator, and windows/CMakeLists.txt duplicates them too; the alera-xtask cargo tests guard both. Do not change one side without the other.
  • The generated macos/Runner/Configs/Flavor.xcconfig reflects whatever flavor was most recently prepared by alera-xtask. If a contributor wants to rehearse a release build locally after running a dev make target, they MUST re-prepare the release flavor first (e.g. ALERA_FLAVOR=release make app-debug) or delete Flavor.xcconfig - otherwise flutter build macos --release will inherit the stale dev override.

Release And Update Rules

  • GitHub Actions work must follow .github/AGENTS.md.
  • Release script work must follow tool/release/AGENTS.md.
  • Stable auto-update is enabled on macOS and Windows regardless of platform signing, because update integrity rests on the Ed25519-signed manifest and its per-artifact SHA-256, not on Developer ID or Authenticode. Platform signing governs what the OS shows on first launch, which is a separate concern. Linux is included too, but which installation may be replaced is decided at runtime rather than per platform, and both conditions are load-bearing. An installation a package manager owns MUST NOT be replaced in place: the deb and rpm payload lives under /opt/alera, packageManagerInstallFromExecutablePath attributes that prefix to PackageInstallMethod.linuxSystemPackage, and those updates keep going through apt or dnf, which is also what resolves GTK and related system libraries a raw dpkg transaction would not. A tarball installation replaces its own directory, which runs no package transaction and so needs no dependency resolution, but only after canReplaceInstallDirectory proves Alera can write there: failing part way through the swap is the one outcome that leaves the user with no app at all. The deb and rpm upgrade needs sudo, so it runs in the command terminal where a password prompt has a PTY, never in the detached shell Homebrew and Scoop use after the app has closed.
  • Stable auto-update is additionally disabled whenever a package manager owns the installation. Homebrew, Scoop, and Chocolatey are detected from the resolved executable path in lib/src/features/updater/domain/package_install_method.dart, a pure function fed Platform.resolvedExecutable at the boundary; replacing the bundle behind the manager's back would leave its database naming a version that is no longer on disk. Homebrew and Scoop run their own upgrade through a detached system shell that waits for Alera to exit and reopens it (package_manager_upgrade_script.dart, package_manager_update_launcher.dart). Chocolatey MUST NOT: its upgrade needs elevation, and the UAC prompt would appear after Alera closed, so it keeps the copy-the-command path Linux uses. New package managers belong in that same enum and switch, never in an ad-hoc branch.
  • Release automation must publish drafts first, verify all required assets and update manifests, and only then publish public releases.
  • The desktop re-checks for a release every 15 minutes while the window is visible (AleraUpdateCheckScheduler), and parks while it is hidden: a check nobody can see the result of still costs a request, and on Linux still costs a composited frame. Returning to a visible window checks immediately rather than waiting out a fresh interval. A find is announced once per version by UpdateAvailabilityWatch, because the recurring check runs with nobody looking at Settings and a toast every 15 minutes for a release the user already declined is noise.
  • The mobile app checks once per launch, Android only, and never auto-installs. It resolves the newest vX.Y.Z-mobile tag through the GitHub Releases API and offers alera-X.Y.Z-android.apk, the single arm64 APK. A fat APK that also embeds 32-bit libraries fails to install on 16 KB page-size phones. Drafts and prereleases are skipped, because the release commit reaches main before the draft is published and a draft's assets 404 for everyone else. A failed check is silent: it is not worth interrupting a launch over a rate limit or a dead network.

Diagnostics And Logging

  • All three surfaces write rotating JSON Lines log files: the sidecar under <runtimeDir>/logs/, the desktop and mobile apps under <applicationSupport>/logs/. The canonical reference is docs/diagnostics.md.
  • New diagnostics in the sidecar MUST use tracing::warn!/error!/info!, never eprintln!. The println!/eprintln! calls in main.rs and the *_commands.rs files are user-facing command output and stay as they are.
  • Redaction lives in the sink, never at the call sites, because a diagnostics bundle is meant to be shared and a call site that forgets to mask is indistinguishable from one with nothing to mask. Register a newly minted secret with register_secret / registerLogSecret where it is created rather than trusting the pattern list.
  • Crash reporting is opt-in and off by default, one Sentry project per surface. The switch is read inside before_send/beforeSend rather than by tearing the client down, so turning it off takes effect immediately, including on an already-running sidecar. DSNs are committed on purpose: a DSN is not a secret and ships inside the binary either way.
  • The sidecar's panic hook MUST be installed before sentry::init, whose panic integration chains the previous hook. That ordering is what puts a panic in the local log file even when reporting is disabled or its upload fails.
  • logDirectory, crashReportingEnabled on status.get, the crashReporting field on configure, and hostDiagnosticsLogsV1 are additive and MUST NOT bump aleraTerminalHostProtocolVersion.
  • Logging MUST NOT be able to stop the app from starting: every sink failure degrades to no file instead of throwing, and the settings applier falls back to defaults when settings are unavailable.

Reference Projects

  • reference_projects/ contains non-runtime references for agentic development and orchestration patterns.
  • reference_projects/orca is the primary reference for ADE-style collaboration, contribution workflow, release gates, and agent-facing project guidance.
  • Reference projects MUST NOT become runtime dependencies of Alera.

Documentation Maintenance

  • After every feature, refactor, fix, or infrastructure change, explicitly consider whether AGENTS.md, nested AGENTS.md files, readme.md, docs/, .github/CONTRIBUTING.md, SECURITY.md, or release documentation need updates.
  • If documentation does not need updates, mention that decision in the final summary or PR notes when the change is user-visible, architectural, process-related, release-related, or contributor-facing.
  • Keep documentation aligned with implemented behavior. Do not document planned behavior as active behavior.

Nested Instructions

  • landing/AGENTS.md applies under landing/.
  • mobile/AGENTS.md applies under mobile/.
  • test/AGENTS.md applies under test/.
  • .github/AGENTS.md applies under .github/.
  • tool/release/AGENTS.md applies under tool/release/.