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.
- 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.
- 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.
- Riverpod providers MUST use code generation (
riverpod_generator) rather than hand-written provider declarations. - This repository does NOT use a
build_runnerwatcher. Agents MUST NOT runbuild_runner watchor keep any background code-generation process alive. - When a planned batch of edits touches Riverpod, Drift, or
dart_mappablegenerated surfaces, agents MUST finish the planned edits first and then regenerate code once for the whole batch withdart 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.dartfrom the root (ordart ../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.
- 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.pausefor callback-free waits and typedList.unmodifiableOf/Map.unmodifiableOffor 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.
- Alera runs a Rust layer under Flutter through
flutter_rust_bridgev2.rust/is a Cargo workspace whose root package is the FRB git libraryalera_native(cdylib/staticlib) and whose members arealera-cli(the terminal-host sidecar binary; see Process And Terminal Safety) andalera-xtask(makefile developer tooling, never shipped or linked into the app). The Flutter build plugin is atrust_builder/, and the generated Dart bindings atlib/src/rust/(committed, not regenerated in CI).RustLib.init()runs inlib/main.dartbeforerunApp. The FRB native build (cargo build --manifest-path rust/Cargo.toml, no-p) compiles only the rootalera_nativepackage, 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.rsis 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 inalera-cli. - Git operations MUST go through the
GitBackendboundary (lib/src/shared/infra/git/), not by spawning thegitbinary viaProcessRunner. The production implementationRustGitBackendcalls the Rust API; local operations usegit2(libgit2) and the networked ones (clone,fetch,pull,push) are delegated to thegitCLI throughalera_core::git::git_in_dirso the system credential helper keeps working. That helper is the only place those invocations are built: it pipes stdio, setsGIT_TERMINAL_PROMPT=0(nothing can answer a terminal prompt there, and on Windows there is no console to draw one on), and spawns throughalera_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.rsre-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
GitBackendinterface free of generated bridge types:RustGitBackendis the only place that importslib/src/rust/api/git.dart, and it translates the nativeGitErrorinto the domainGitExceptionhierarchy. Services depend onGitBackend; unit tests use the sharedFakeGitBackend(test/unit/fake_git_backend.dart). - After changing the Rust API surface (
rust/src/api), regenerate bindings withmake frb-generate(flutter_rust_bridge_codegen generate) and commit the result. Building the desktop app requires a Rust toolchain (rustup), pinned byrust/rust-toolchain.toml; CI installs it viadtolnay/rust-toolchain. The sharedrust/Cargo.lockis 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
keepawakecrate inalera_native(rust/src/api/keep_alive.rs): idle plus display inhibitors, held on a dedicated thread because WindowsSetThreadExecutionStateis per-thread. That toggle MUST NOT spawncaffeinateorsystemd-inhibit. The agent-working awake path inagent_awake_assertions.dartis separate. - Desktop Whisper on x64 Windows compiles ggml-vulkan through
whisper-rs-sys. Cargo CMake must setCMAKE_GENERATOR=Ninja(not only the rustc target-specific form) so vulkan-shaders-gen's nested cmake inherits Ninja. CI scratch lives atR:\c, native cargo--target-dirisR:\c\n(local fallback%SystemDrive%\c\n), and the sidecar usesR:\c\cli. Windowscmake_installcopies the sidecar from a staged path under the Flutter build tree rather than from that subst drive. Do not appendalera_nativeto that prefix: vulkan-shaders-gen's nested TryCompile object (.../cmTC_XXXXXXXX.dir/testCCompiler.c.obj) then exceedsMAX_PATHandcl.exefails withC1083and an empty generated-file name.cl.exereadsCL/_CL_, notCFLAGS;_CL_=/Z7 /FSavoids extra PDBs.GGML_CCACHEMUST beOFFon Windows cargo builds: ggml auto-enables sccache when it is on PATH, andRULE_LAUNCH_COMPILE=sccachewith Ninja andcl.exedrops object files (LNK1181onggml.c.obj). sccache stays onRUSTC_WRAPPERonly. Flutter's Visual Studio generator stays unchanged.
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.
- 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.
- 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.
- 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.
- 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:
pbcopyandpbpaste. - Windows PowerShell:
Set-ClipboardandGet-Clipboard. - Linux Wayland:
wl-copyandwl-paste. - Linux X11:
xcliporxsel. - WSL:
clip.exewhen copying content into the Windows clipboard is appropriate.
- macOS:
- 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.
- 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, orchore: 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.
- 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.
- 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.lockexists, confirm no git process is active before removing it.
- 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.
- 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.
- 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.dartorupdate_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 values MUST come from
AleraTokensandThemeData. - 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.transparentMAY 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 prefixedAlera. 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
@AleraPreviewannotation (not the bare@Preview). Launch withflutter widget-preview start. - Horizontal-only strips (tab bars, toolbars, chip rows) MUST use
AleraHorizontalScrollViewor wrap a horizontalListViewwithAleraMouseWheelHorizontalScrollso 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/, seedocs/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 updatelanding/src/data/demo/orlanding/src/styles/demo/in the same change:.github/workflows/landing-fidelity.ymlrunslanding/src/data/demo/fidelity.test.tson pull requests that touch those app sources, and it fails when the two disagree.
- Shortcut-able actions live in
lib/src/features/keyboard/domain/keyboard_action.dartas the single source of truth (id, label, group, per-platform defaults, allow-in-terminal flag), with thekeybindingDefinitionslist itself in its part filekeyboard_action_definitions.dart. New shortcut-able actions MUST be added to that registry rather than wired through ad-hocShortcuts/CallbackShortcutswidgets. - Behavior is dispatched from one place:
KeyboardCommandDispatcher. Reuse existing controller methods and the shared dialog launchers inworkbench_dialog_launchers.dart; do not duplicate dialog flows. - Matching is centralized in
KeybindingResolverand consumed by exactly two call sites: the globalKeyboardShortcutsScope(shell-mounted) and theTerminalSurfaceonKeyEventhook (terminal-focused interception). Do not add a third matcher or a globalHardwareKeyboardhandler. - Shortcuts only reach
KeyboardShortcutsScopewhile 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 areFocusScopes rather than plainFocuswidgets: 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 aFocusNode, claim it onautofocusand 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 inWorkbenchRegisteredFocusScope, keyed by the classic group id or the experimental panel key, and the dispatcher's_focusPane/_focusActivePanelook 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 forFocusManagerdescendants by widget type. - A surface that claims focus on the
autofocus: false -> truetransition MUST guard it withworkbenchFocusIsParked(): 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 initialautofocuson 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 onKeyboardShortcutsScopewhere it goes nowhere. Find/Replace in Files publishes aWorkspaceSearchRevealrequest 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
activeWorkspaceIdwhen the user is on another workspace. Watch and Fix follow-up dispatches (timer/onPanelState) MUST passactivate: falseso they never callselectWorkspaceorselectWorkspaceTab. 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 changingactiveWorkspaceIdor 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: trueis for chords a shell never sees or has no legacy meaning for (Ctrl+Tab,Mod+digit,Mod+Shift+W, theMod+Alt+Arrownavigation family). Chords with a classic terminal meaning (Ctrl+Wdeletes a word) stayfalsesoterminalFirstkeeps them for the shell.test/unit/keyboard_action_test.dartasserts 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, andlanding/src/data/keyboard-shortcuts.test.tscompares it withkeyboard_action_definitions.dartaction 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.ymlfails otherwise. - The
Modmodifier 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
TerminalShortcutPolicysetting: underterminalFirst, only bindings withallowInTerminal: truemay intercept while a terminal is focused.
- Alera targets macOS, Windows, and Linux.
- macOS
AppDelegate.applicationDidFinishLaunchingMUST NOT callsuper:FlutterAppDelegatedoes 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 withflutter test integration_test/desktop_presence_macos_test.dart -d macos. - macOS
applicationShouldTerminateAfterLastWindowClosedMUST return false: AppKit can request termination when the last window is hidden, even whenwindowShouldCloseprevented its closure. The Dart lifecycle decides whether to hide or exit; an explicit close without a tray or Quit still reachesNSApp.terminatethroughwindow_manager.destroy. - Windows
wWinMainMUST callwindow.Destroy()after the message loop and beforeCoUninitialize: window_manager'sdestroy()only postsWM_QUIT, so the HWND outlives the loop, and lettingFlutterWindowmembers freeflutter_controller_with the window still alive dispatches messages into a freed view (FlutterWindowsView::GetEngineaccess violation, then WER holds the windowless process for tens of seconds). - Use
Platformchecks or framework abstractions for platform-specific behavior; do not assume POSIX paths or commands. - Use
pathpackage 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 instatus_notifier_menu.ccovercom.canonical.dbusmenuand the composed icon intray_badge_icon.cc), becauselibayatana-appindicatorexports neitherActivatenorIconPixmap: a host that maps the primary click toActivate(Plasma, the GNOME AppIndicator extension) sees the call fail and opens the context menu instead, and neitherXAyatanaLabelnor a customIconThemePathicon renders on Plasma (both measured).IconNameMUST stay empty while pixmaps exist, or a host prefers the themed icon and the badge never shows, andItemIsMenuMUST stay false.IconPixmapis ARGB32 in network byte order and NOT premultiplied, so the pixels go throughgdk_pixbuf_get_from_surfacerather than straight off the cairo surface. linux/runner/appindicator_tray_fallback.cckeeps thelibayatana-appindicatortray for a desktop with noorg.kde.StatusNotifierWatcher, chosen at the firstsetTrayand dropped as soon as a watcher appears. It cannot show a badge. ItsActivateanswer 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=1selects that path from a desktop that does have a watcher.- The application menu uses the global
PlatformMenuBaron macOS and a compact Flutter menu beside the app name on Windows/Linux, so those platforms do not lose client-area height to a nativeGtkMenuBarorHMENU. Menu actions sharelib/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 throughwindows/runner/win32_dark_mode.cpp.
- 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 underintegration_test/rather than assuming. Seedocs/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.closeWorkspaceTabsis the one place that disposes a closed tab's terminal handle and editor document; call sites MUST NOT pairTerminalRuntime.closeTabwith 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 callinvalidateWhenWorkspaceRetiredinbuild, 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 theStophook that normally ends it.
- 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
ProcessRunneror a similarly injectable boundary.ProcessRunner's production implementation isRustProcessRunner, which spawns through the bridge (rust/src/api/process.rs) rather thandart:io, because Dart cannot pass Windows creation flags. It keepsrunInShellsemantics on every platform: the shell is what resolves the.cmd/.batshims thatollama,claudeandnpminstall, andprocess_shell.rsmirrors Dart's_getShellArgumentsquoting so no call site changes meaning. - The app MUST NOT spawn a child with
dart:ioinProcessStartMode.normal, whichProcess.runandProcess.runSyncalso 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 thewaitpidtokio makes for a Rust-side spawn returning ECHILD. That is what surfaced asfailed 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 andalera_core::git_cli::git_in_dir.ProcessStartMode.detachedis exempt and is whyterminal_host_process_launcher.dartmay 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.dartscanslib/and holds the allowlist to that one entry. The boundary the repo settled on is: commands go throughProcessRunner, and a bare syscall goes throughdart:ffi(readinterminal_runtime_posix_io.dart,SetThreadExecutionStateinagent_awake_assertions.dart,chmodinposix_file_mode.dart). Do not spawn a process to reach a syscall:chmodcost 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 throughalera_core::child_process::windowless_command/windowless_async_command, andrust/clippy.tomlrejects 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 agit fetchor a quota poll.CREATE_NO_WINDOWasks for a console without a window instead, and grandchildren inherit it, so marking acmd.exewrapper covers the whole chain.alera-xtaskis a console makefile tool, not those parents: inherited interactive children (make app-debug,make host-debug) MUST usealera_core::child_process::console_commandso they keep the parent console (TTY, Flutter hot-reload keys, Ctrl+C). Captured xtask spawns (ps,taskkill, git) still usewindowless_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, notstd::envalone. The app starts the sidecar detached, so a GUI launch (Finder, Dock, a.desktopentry) 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_variablefor a single value,login_shell_command_environment/apply_login_shell_environmentfor 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-0600Linux fallback), are bound to the canonical provider origin, never enter settings or mobile payloads, and credential requests MUST stay local-client-only. MobileThis DeviceOpenAI transcription is separate:MobileOpenAiDictationProvidercalls the API from the phone and reads an origin-bound token fromMobileAiDictationCredentialStorein platform secure storage;Paired Devicesends audio, URL and model but no token, and the runtime uses its own credential. Codex subscription dictation always goes through the authenticated experimentalcodex app-serverrealtime protocol in an ephemeral read-only thread with one whole-operation deadline, never by extracting Codex credentials or calling ChatGPT backend endpoints directly.aiDictationRemoteProvidersV1and 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 andMOBILE_HELLO_CAPABILITIES. - Tests for command construction should verify Windows, Linux, and macOS variants when behavior differs.
- Cases in
orchestration_review_regressionsfail intermittently under the parallelism of a fullcargo test --workspaceand 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, andpush_on_idle_does_not_duplicate_in_flight_batcheshave all been observed). CI runs that binary with--test-threads=1after the rest of the workspace, still under--workspaceso feature unification does not relink. Locally, re-run the named case on its own before treating a redmake rust-testas a real regression, and do not chase it as fallout from an unrelated change. - Use the lowercase repository
makefilefor 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 throughalera-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 helplists available rules.make init-submodulesinitializes the two required source submodules.make app-debugruns the Flutter app with the development CLI fallback (acargo runof the Rust sidecar),make cli-buildcompiles the RustaleraCLI sidecar (therust/alera-clicrate) with cargo,make app-debug-bundled-cliruns the app against the compiled sidecar,make host-debugruns the Rustalera terminal-hostin the foreground, andmake rust-testruns 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 bealera terminal-host. UseALERA_HOST_EMPTY_SHUTDOWN_SECONDS,ALERA_HOST_DETACHED_SHUTDOWN_SECONDS, andALERA_HOST_SCROLLBACK_BYTESwhen foreground host debugging needs non-default lifecycle or scrollback values. Usemake host-stoponly when intentionally ending the current debug host for this app id. - The shipped
aleraCLI / terminal-host sidecar is the Rust crate underrust/(rust/alera-cli, binaryalera). The native build hooks -linux/CMakeLists.txt,windows/CMakeLists.txt, and the macOS "Build Alera CLI Sidecar" Xcode phase - build it withcargo build --locked(Release app →--release, otherwise debug) and install the single binary intoresources/alera/alera[.exe]. The toolchain is pinned byrust/rust-toolchain.tomlandrust/Cargo.lockis committed; CI and the hooks build reproducibly with--locked. The Dart client-side and shared protocol files underlib/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 advertisesRUNTIME_HOST_BINARY_FRAMES_CAPABILITY; the host answers with what it granted and every later byte on that connection is framed. This MUST NOT bumpaleraTerminalHostProtocolVersion, because a version mismatch makes the app treat a live host as unusable, and because thealeraCLI (runtime_host_client.rs) and older apps must keep getting newline-delimited JSON from the same host. The switch travels in band, as aClientFrame::UpgradeToBinaryqueued 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 inrust/alera-cli/src/terminal_host/frame_codec.rsand its Dart mirrorterminal_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 -> tabIdrelation, and a process-table sweep must stay off the Flutter main isolate.Session.shellMUST 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 reportsmeasured: falseinstead 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 oneclaimedset: every PTY shell is a child of the runtime host, so the opposite order swallows all of them into one unattributed row.resources.snapshotandresourceMonitorV1are additive and MUST NOT bumpaleraTerminalHostProtocolVersion. EverycpuPercenton that payload is per core, the unitsysinforeports, 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, inmachineCpuShare(lib/src/features/resource_manager/domain/machine_cpu_share.dart), which divides bycpuCoreCountso 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 sweepssysinfoneeds before process CPU is meaningful differs per platform (3 on Windows, 2 on Linux and macOS), and CI runscargo test --workspaceon Linux only, so changes to the sampler MUST be re-verified on a real Windows and macOS machine. Every process refresh MUST ask forwithout_tasks():ProcessRefreshKind::nothing()is not nothing, it defaultstaskson, 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>/statis already the thread-group aggregate). Neither theclaimedset 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 withincores * 100) are what fail if it comes back. Session::terminatekills the shell's whole process tree, not just the shell. On Unix,portable-pty's killer reaches the direct child withSIGHUP, and the kernel hangup only reaches the controlling terminal's foreground group, soshell_tree_termination.rscaptures 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 withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.portable-ptycannot attach a Job atomically atCreateProcessW, 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 bySessionuntil 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 hoctokio::spawnloop. 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.snapshotcarriesintervalMsfor 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 bumpaleraTerminalHostProtocolVersion. 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 throughresume_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 fromscrollbackByteson purpose: the first is what a client's emulator will keep, the second is what the host retains soterminal.readand the coordinator tail can page back through it. Both are additive and MUST NOT bumpaleraTerminalHostProtocolVersion; 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.jsonas Alera-managed command entries, never as a per-session plugin orcursor-agentwrapper.install_cursor_user_hooks(rust/alera-cli/src/agent_status/integration_config_cursor.rs) merges those entries fromprepare_enabled_integrationsand removes only Alera-marked definitions on cleanup. Host start still deletes leftoveragent-runtime-overlays/cursordirectories from older versions. Alera MUST NOT write apermissionverdict to stdout forpreToolUse,beforeShellExecutionorbeforeMCPExecution: anallowthere 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 installpreToolUsewhile Antigravity may not installPreToolUse: the difference is whether the agent treats a missing decision as an error. Every definition carries an explicittimeoutbecause Cursor's default is 60s.beforeShellExecution/beforeMCPExecutionmean working, same as theiraftercounterparts: Cursor fires thebeforeevent whether or not the user is asked, so treating it as waiting notifies on every shell or MCP call. Cursor currently omitspreToolUseforAskQuestion; a human-inputpreToolUseis 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.sessionStartMUST stay unregistered - it fires before any prompt and normalizes to working. Which events fire depends on how the CLI was started, verified againstcursor-agent 2026.08.04: an interactive run emitssessionStart,beforeSubmitPrompt,afterAgentResponse,stop,sessionEnd, while-pemits none ofbeforeSubmitPrompt,afterAgentResponseorstop, sosessionEndis 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 whenGROK_HOOK_EVENTis set, because Grok scans this same file. hostIdonworkspace.createManagedand theworkspace.files.list/workspace.files.readverbs are additive (remoteSshWorkspacesV1) and MUST NOT bumpaleraTerminalHostProtocolVersion. Desktop New Workspace and the explorer MUST feature-detect that capability before sendinghostIdor 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 ownruntime-hoston<installDir>/data). The hub reaches it through one persistentssh -Tchild per host runningalera 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 thehellolocally, so the hub never learns its token, and the first stdout line MUST behostLink.attached.hostLink.*verbs,hostLinkChanged,hostLinkEvent,remoteHostLinkV1andremoteSatelliteV1are additive and MUST NOT bumpaleraTerminalHostProtocolVersion; both event names MUST stay inruntimeHostEventNames. Connecting awaits an ssh handshake, so the actor MUST only callHostLinkRegistry::linkfrom a spawned task and answer throughServerCommand::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 reachalera.exe. It runs"<installDir>\bin\alera.cmd" runtime-attach --stdiounder 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 overssh(owner-terminal, precheck, relocation, retirement) is built byremote_owner_terminal_launch::owner_command_scriptand targets that same<installDir>/dataprofile; the per-projectowners/<sha256(projectId)>profiles are legacy and onlyremote_owner_retirement::owning_state_dirmay 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 throughserver/host_link_routing.rs::mirror_workspace(hub.mirror.workspace, idempotent, validated byremote_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 byhost_link_routing::forward_workspace_scoped_requestwhen the workspace has a remotehostId, and file mutation rules live once inalera_core::workspace_files::mutations, shared by the FRB desktop path andserver/workspace_file_mutation_requests.rs. Desktopgit.*verbs are answered byserver/workspace_git_requests.rs, one verb perGitBackendmethod, each calling thealera_core::source_controlfunction the FRB bridge calls, sogit_explorer_status_snapshot,git_diff_blob_bytes,list_remotesand any future git rule MUST live inalera_core, never only inrust/src/api. AGitErrorcrosses the wire as a typedgitErrorconflict witherrorDetails.kind;RuntimeGitBackendrebuilds the sameGitException. On desktop the routing seam isWorkspaceFileService's*Workspace*methods,remoteWorkspaceSearchServiceProvider, andgitBackendProvider, which returnsHostRoutedGitBackend: a path inside a remote workspace (RemoteCheckoutIndex, local checkouts win on a tie) reaches that host'sRuntimeGitBackend, so consumers keep readinggitBackendProviderwith the path they have. Tools that act on a checkout run on its host throughhost.process.run(server/host_process_requests.rs, command built byalera_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 itscwdMUST stay inside the workspace. On desktop the seam isworkspaceProcessRunnerProvider(HostRoutedProcessRunner, routed by working directory through the sameremoteWorkspacePathResolverProvideras git), so a forge call MUST pass the checkout asworkingDirectory,checkAuthincluded, 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 keepprocessRunnerProvider.RemoteProcessRunnersends only the variables the caller names: the hub's environment means nothing on another machine and may hold secrets.mobile.pullRequest.*(exceptsummaries) and the workspace-keyedaiText.*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) andaiAssistSettings.aiAssistSettingscan name a custom command, sorefuse_hub_only_payload_fieldsMUST 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 throughremote_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-onlyagentHookEvent(remoteAgentHookRelayV1) before checking its own settings and the hub feeds it tohandle_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. TheagentPresence.listread on attach exists only to cover the time a link was down.server/remote_resource_relay.rspolls attached satellites on the hub's own resource tick with the hub'sintervalMsand MUST NOT open a link to do so; a relayed row carrieshostIdand the satellite'scpuCoreCount, becausecpuPercentstays per core and the hub's count is not the satellite's. Both are additive and MUST NOT bumpaleraTerminalHostProtocolVersion. One project is registered once and then added to hosts (project.hosts.list/add/remove,server/project_host_requests.rs, capabilityprojectHostsV1); a host is a project checkout row, which is what New Workspace, the branch catalog and the mirror already read.primaryHostIdis derived inproject_hosts::primary_host_idand MUST keep treating a project without a local checkout row as local:project.upsertnever writes one, so reading a missing row as "lives elsewhere" would turn ordinary projects into remote-only ones.addwithout a path sends the host a directory name and the target'sprojectsDiras 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~,$HOMEand%VAR%there and defaults to<home>/alera-projects).removeMUST NOT delete files, and folder projects stay on one host. Code that readsProject.repoPathas a local directory MUST checkprimaryHostId == localfirst; the repositoryalera.tomlof a remote-only project is read over the link byserver/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) readsremote_project_config_cache.rsinstead. A satellite's store holds copies of only the workspaces it serves, so once it has mirrored a hub workspace (satelliteOfHubmetadata) its CLI listings go to the hub over the reverse channel (hub.forward->hub.requestevent ->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 inserver/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.registerandproject.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.getstays local becauseruntime-attachreports it. A phone labels remote workspaces frommobile.hosts.list(id, alias, platform);sshTarget.listsays 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 throughwindows_path_form::canonicalize, neverstd::fs::canonicalize, whose verbatim\\?\C:\...answer is not a valid working directory; identity checks between a stored and an inspected path usewindows_path_form::same_path, because older records hold the verbatim spelling. The Windows default launch MUST NOT put a quote inside thecd /dargument (cmd_launch_directory): the standard library escapes it as\",cmd.execannot read that, and the terminal silently opens in the user's home. And a hub MUST NOT take the basename of a project path withstd::path, which splits by the hub's own rules: useproject_hosts::folder_name, since a remote-only project'srepoPathis a path from another operating system. Presentation code MUST NOT branch onworkspace.isRemoteto refuse a file, search, git, pull request or AI Assist action.remoteGitV1andremoteProcessV1are additive and MUST NOT bumpaleraTerminalHostProtocolVersion. - Watch and Fix sessions (
pullRequestWatch.list/find/start/stop, andalera workspace pr-watch) are additive underpullRequestWatchV1and MUST NOT bumpaleraTerminalHostProtocolVersionoraleraMobileProtocolVersion. The capability is advertised in the control file,status.get, andMOBILE_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 allowedstart/stop.pullRequestWatchChangedMUST stay inruntimeHostEventNames. 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, solastDispatchandlastMergedHeadShastill write. Hand off and hand on MUST broadcastpullRequestWatchChangedwith a wildcard scope so the eye follows the moved workspace. workspace.focusand theworkspaceFocusRequestedevent are additive underworkspaceFocusV1and MUST NOT bumpaleraTerminalHostProtocolVersion. The host delivers the event only to local app connections that announcedworkspaceFocusV1inhello, so an older app is refused with an update message instead of silently ignoring it; keep the event inruntimeHostEventNames. The app selects throughWorkbenchController.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, andissueUrlonworkspace.createManaged) are additive underlinkedIssuesV1and MUST NOT bumpaleraTerminalHostProtocolVersionoraleraMobileProtocolVersion; the capability is advertised in the control file,status.get, andMOBILE_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) viaForgeCliRunner, 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 andprojectCloneJobsChanged(which MUST stay inruntimeHostEventNames) rather than a blocking progress dialog. Workspace-create jobs are client-session state and MUST NOT bumpaleraTerminalHostProtocolVersion. - The desktop and mobile apps defer a project's worktree setup to a terminal tab named
Setupinstead of holding the New Workspace UI open untilpnpm installfinishes.deferSetuponworkspace.createManaged,deferredSetupCommandon its response, and theinitialCommandOncetab payload key are additive and MUST NOT bumpaleraTerminalHostProtocolVersion; 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/son purpose - with it, cmd strips the outer quotes and a script path containing spaces breaks apart - and leavescmdunquoted so PowerShell does not need the&call operator. Copy rules go throughalera workspace setup --copies-onlyrather than being rewritten in shell, socopy_rule_inner's symlink and path-escape validation stays in Rust. That same copy path also expands.worktreeincludeat the project root: gitignore-syntax patterns that select gitignored files from the main checkout, matching Conductor and Claude Code, without copying tracked files.initialCommandOnceexists because agent tabs deliberately re-mint theirinitialCommandon 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_promptinrust/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 reportingdone, and an agent that has been asked nothing never reports that it finished anything, so the prompt hung forever for every agent except Codex.pendingAgentPromptandpendingOrchestrationare 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 forcodex,claude,cursorandgrok; a bare positional forpi, which rejects--outright (Error: Unknown option: --) and so gets a leading space when the prompt opens with a dash, since it reads-anythingas an option; and a single--flag=<prompt>token forcopilot,agy,opencodeandopencode2, 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 shareOPENCODE_CONFIG_DIRbut 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
agentNativeSessionIdandagentNativeSessionAgent, including plain terminals where the user typed the agent themselves. Those keys are host-owned and additive and MUST NOT bumpaleraTerminalHostProtocolVersion. A later remint uses the adapter'ssession_resumeshape fromagent_registry.rsand 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 storesagentNativeCcsProfilefromCLAUDE_CONFIG_DIR(.../instances/<profile>) so a remint isccs <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 inserver/agent_presence_reconciliation.rsremoves presence once the agent's foreground process group is gone and turns aworkingpresence intodoneafter 60 seconds with no PTY output and no hook. That inferreddone(AgentPresence::inferred_idle) MUST NOT accept injection, so readiness checks go throughAgentPresence::accepts_injectionorAgentPresenceRegistry::is_injection_ready, neverAgentPresenceState::accepts_injectionon 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 bySubagentStart/SubagentStopinClaudeSubagentRoster: a finished main turn reportsworkingwhile a child runs andwaitingwhile a child asks, and only reachesdoneonce the roster drains. A session end that names another conversation MUST be ignored, and Claude'sSessionEndforclear/resumeis not an exit. Managedopencode2launches MUST keep--standalone: plugins run in the server, and the shared background service carries another tab's environment. Copilot's Windows hook runs aspwsh -cand MUST contain no double quote and always exit 0, because a failingpreToolUsehook denies the tool. ampis 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 theSetuptab uses. A pipeline typed into the terminal cannot be used: the prompt is free multi-line text and<,echoand quoting all differ across PowerShell 5.1, cmd and nushell. Only stdin is redirected, becauseampswitches 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 itexecs the agent, and the redirect has to outlive that handover - so the startup sweep next toremove_stale_setup_scriptsis 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 throughshowCommandTerminalDialog). The point is the PTY:ProcessRunner.rungives no TTY, so asudopassword 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 aninitialCommand, exactly as theSetuptab 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 bycommandTerminalWorkspaceId, and the dialog owns its whole lifetime: it callsruntime.closeTabon dismissal, which terminates the shell's process tree.terminalRuntimeExitCoordinatorMUST 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
deferredEnterpath as mobile and orchestration: the prompt bytes keep the emulator's live DECSET 2004 decision rather than the hostbracketedPasteflag, 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 uand 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 staysCSI 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.dartholds 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) passesdragOverridesMouseReportingto 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.clipboardOnSelectdefaults 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 inrust/alera-cli/src/terminal_host/server/runtime_change_broadcasts.rsand passNonewhenever 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 bumpaleraTerminalHostProtocolVersion, because a version mismatch makes the app treat a live host as unusable. A host event reaches DartruntimeEventsonly when its name is inruntimeHostEventNames; a new watcher MUST add the name there or the UI will not refresh until reconnect or restart.
- GitHub Watch and Fix execution belongs to the runtime when
pullRequestWatchExecutionV1is advertised. Clients use the shared persisted watch andpullRequestWatchChanged; they MUST NOT also dispatch or merge locally. Keep the capability in the control file,status.get, andmobile.hello. Seedocs/pull-request-watch.mdfor execution and compatibility boundaries.
-
ChatGPT AI Assist uses the public Sign in with ChatGPT OSS flow independently of the Alera cloud account and Codex CLI credentials.
chatgpt_sessionowns registrations, serialized OAuth refresh and account-change cancellation;chatgpt_credentialsstores 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 withstore: false,stream: true, array input, and success only afterresponse.completed. KeepaiAssistChatGptV1additive, without changing strict protocol versions. Seedocs/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(oraleraAccountSignInFailed). Those names MUST stay inruntimeHostEventNamesso 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 ismobile.cloudEnrollment.create, and it uses the stable cloud installation id bound to the authenticated client by additivemobile.hello.cloudDeviceId, never an id supplied in that enrollment request.mobile.cloudSubscriptions.refreshmay 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.
- Alera builds in two flavors selected by the
ALERA_FLAVORenvironment variable:dev(default) andrelease. - The
devflavor usesdev.leynier.alera.devas bundle identifier / GTKAPPLICATION_ID,alera-devas Windows/Linux binary name, andAlera Devas 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 VERSIONINFOCompanyName\ProductName(dev.leynier\Alera Devvsdev.leynier\Alera), whichwindows/runner/CMakeLists.txtdrives fromALERA_APP_NAME;alera-xtaskmirrors that mapping for its runtime paths). - The
releaseflavor keeps bundle identifierdev.leynier.aleraand display nameAlera. Its on-disk executable isAleraon Windows (Alera.exe) and macOS (Alera.app, viaALERA_PRODUCT_NAME), while the Linux binary stays lowercasealeraby POSIX convention.desktop_updatercopies the macOS bundle todist/under the lowercase pubspec package name (alera.app), sorelease-cut.ymlrenames it toAlera.appbefore 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.ymland.github/workflows/release-cut.ymlsetALERA_FLAVOR: releaseat the job env level. - The makefile defaults
ALERA_FLAVORtodevand forwards--alera-flavortoalera-xtask. That tool regeneratesmacos/Runner/Configs/Flavor.xcconfig(git-ignored) before eachflutter runand exportsALERA_FLAVORso the Windows/Linux CMake branches and the Dart-sidekAleraFlavorconstant agree.Flavor.example.xcconfigdocuments the dev-flavor values for reference. A cargo test inalera-xtaskasserts the flavor identity strings stay identical tolib/src/core/build_flavor.dart. - Auto-update MUST remain disabled on dev builds regardless of any other flag. The guard lives in
effectiveAutoInstallEnabled(seelib/src/core/build_flavor.dart) and is applied byAleraUpdateConfig.fromEnvironment(). - The canonical flavor identity strings (
Alera,Alera Dev,dev.leynier.alera,dev.leynier.alera.dev, and therelease/devselectors) live inlib/src/core/build_flavor.dart.alera-xtaskduplicates those literals for the xcconfig generator, andwindows/CMakeLists.txtduplicates them too; thealera-xtaskcargo tests guard both. Do not change one side without the other. - The generated
macos/Runner/Configs/Flavor.xcconfigreflects whatever flavor was most recently prepared byalera-xtask. If a contributor wants to rehearse a release build locally after running a devmaketarget, they MUST re-prepare the release flavor first (e.g.ALERA_FLAVOR=release make app-debug) or deleteFlavor.xcconfig- otherwiseflutter build macos --releasewill inherit the stale dev override.
- 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,packageManagerInstallFromExecutablePathattributes that prefix toPackageInstallMethod.linuxSystemPackage, and those updates keep going through apt or dnf, which is also what resolves GTK and related system libraries a rawdpkgtransaction would not. A tarball installation replaces its own directory, which runs no package transaction and so needs no dependency resolution, but only aftercanReplaceInstallDirectoryproves 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 needssudo, 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 fedPlatform.resolvedExecutableat 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 byUpdateAvailabilityWatch, 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-mobiletag through the GitHub Releases API and offersalera-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 reachesmainbefore 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.
- 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 isdocs/diagnostics.md. - New diagnostics in the sidecar MUST use
tracing::warn!/error!/info!, nevereprintln!. Theprintln!/eprintln!calls inmain.rsand the*_commands.rsfiles 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/registerLogSecretwhere 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/beforeSendrather 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,crashReportingEnabledonstatus.get, thecrashReportingfield onconfigure, andhostDiagnosticsLogsV1are additive and MUST NOT bumpaleraTerminalHostProtocolVersion.- 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/contains non-runtime references for agentic development and orchestration patterns.reference_projects/orcais 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.
- After every feature, refactor, fix, or infrastructure change, explicitly consider whether
AGENTS.md, nestedAGENTS.mdfiles,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.
landing/AGENTS.mdapplies underlanding/.mobile/AGENTS.mdapplies undermobile/.test/AGENTS.mdapplies undertest/..github/AGENTS.mdapplies under.github/.tool/release/AGENTS.mdapplies undertool/release/.