alera
leynier/alera/AGENTS.md
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.
AGENTS.md28 starsChanged 2 months ago
# 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. ## 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 `FocusScope`s 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 `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. - 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, reopen or close the turn. 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 `exec`s 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 - 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/`.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

