AGENTS.md · diff

git:20261007.49620e0 to git:20261008.21a2f6c

26 added, 1 removed. Audit A to A.

# AGENTS.md
Conventions for AI agents and contributors in this repository. Keep it
factual: paths, commands, invariants — no essays.
## Project layout
- `lib/` — the `flutter_agent_harness` package (pure Dart core). `test/`
mirrors it. `prompts/` — all LLM prompts as Markdown (see rules below).
- `lib/flutter_agent_harness.dart` (the barrel) — gh-1232 policy:
GROUPED APPEND-ONLY export sections, one `// ── <subsystem> ──` comment
header per group, exports sorted by path within a group, new exports
appended to their subsystem group (a new subsystem starts a new group).
No per-subsystem mini-barrels, no re-sorting of existing lines — append
only, so parallel edits almost never touch the same line.
- God-file ceiling (gh-1232): no Dart file over 2800 lines anywhere
(crap4dart `loc` gate, `max_lines: 2800` — issue #1234 migrated the
shell guards; enforced whole-tree in CI and on staged files by the
pre-commit hook. Scope: core `lib bin` via `crap4dart.yaml` +
`test example` via the `crap4dart.loc-scope.yaml` companion config
(0.11.0 has no per-gate sources override), flutter_app/lib via
`flutter_app/crap4dart.yaml`).
Target < 2000 for primary
files; when a file approaches it, split by concern into `part` files
(the established pattern: `agent_cli.dart`, `builtin_tools.dart`,
`bin/fah.dart` → `bin/fah_*.dart`, `agent_service.dart` →
`agent_service_*.dart` mixins) — never by extracting public API.
- `lib/src/approval/` — tool approval gate: tiers (read/write/exec),
session modes (always-ask/write/yolo/autopilot — the CLI label of the
`unattended` enum), per-tool overrides,
critical-pattern
`bash` interceptor, per-turn grants (`grantForTurn(allow:, deny:)` —
skill `allowed-tools`/`disallowed-tools` manifest keys ride these for one
turn; explicit deny > turnDeny > per-tool override > turnAllow >
alwaysAllow > mode (critical bash outranks mode in always-ask/write/yolo,
but is skipped in unattended so the mode never blocks — for runs with no
user present); `clearTurnGrants()`
on every new user
message). Wired via `attachApproval` into `beforeToolCall` (runs
first); prompt UI is an injectable `ApprovalPrompt` (null + prompt policy
= deny).
- `lib/src/tools/ask_tool.dart` — `ask` tool: structured mid-turn questions
via injectable `AskCallback` (null = error, cancel = plain result).
- `lib/src/tools/request_secret_tool.dart` — `request_secret` tool: the agent
asks the USER for a missing credential via injectable
`RequestSecretCallback` (never in chat text); a grant returns
`RequestSecretResult` (host-adjusted `name`, `value`, `persisted` flag),
a decline is a plain result. The app wires it in `AgentService` to
`secretRequestHandler` (chat screen installs
`ui/widgets/secret_request_sheet.dart`): a grant is persisted into
`SessionKeysStore`, injected into the live shell env via
`SecretsExecutionEnv.addSecrets` (the map is runtime-mutable now), and
registered into the same `SecretRedactor` — so the next run's prompt name
list, bash `$NAME`, and redaction all pick it up.
- `lib/src/env/session_vars_execution_env.dart` — `SessionVarsExecutionEnv`:
an `ExecutionEnv` decorator injecting session-correlation env vars
(`FAH_SESSION_ID`/`FAH_SESSION_FILE`/`FAH_PROVIDER`/`FAH_MODEL`, resolved
live per `exec`, never secrets) into bash tool executions. Wired around
`builtinTools` in the CLI (`AgentCli`) and the app (`AgentService`).
`LocalShell` merges `ShellExecOptions.env` OVER `Platform.environment`
(never replaces), so injected vars keep the inherited environment.
- `lib/src/env/io_execution_env.dart` — `LocalShell`: foreground `exec`
runs under `setsid` when the host has it (probe
`ownProcessGroupAvailable`, seam `ownProcessGroupOverride`) and the
timeout/cancel paths reap the whole tree (`killTree` + direct-kill
backstop) with a capped pipe-drain (`_drainGrace`) — timeout/cancel'd
calls complete in ≤ timeout + kill grace + drain grace; a no-timeout
call within `_drainGrace` of the direct child's exit (the drain is
capped unconditionally: after the child is reaped every remaining pipe
byte comes from an orphan). Killed calls are marked `timeout`/`aborted`
and carry the captured partial output on `ExecutionError.stdout`/
`stderr` — tail-capped at the source to the last 64 KiB (`_captureTail`)
— rendered by the bash tool and the skill renderer's `!cmd` failure
branch (every string that branch embeds is tail-capped to ~2 KB)
(gh-1053; the timeout timer stays
armed after the child exits because an orphaned descendant can hold
the pipes — a drain that outlives the deadline marks the call
`timedOut` even when the child exited 0). Background jobs (issue #517)
keep their own group + `stop()` semantics.
- `lib/src/tools/checkpoint_tool.dart` — `checkpoint`/`rewind` tools:
context hygiene for detours. `CheckpointRewindController` wraps
`Agent.prepareNextTurn`, persists via host `CheckpointSessionSink`.
Lifecycle (issue #286): a checkpoint is scoped to its detour — when the
next `checkpoint` call finds it stale (a user turn arrived inside the
detour, or the anchored span was rebuilt away by reload/compaction), it
is auto-closed with a note + `checkpoint_auto_closed` session record
instead of refusing; `rewind` semantics are unchanged.
- `lib/src/compaction/branch_summarization.dart` — `generateBranchSummary` +
`navigateSessionTree` (use instead of `Session.moveTo` for tree
navigation); summary is a `branch_summary` record on the entered branch.
- `lib/src/agent/tool_pairing.dart` — context pair-integrity (issue #85):
`validateToolPairing` checks the wire-equivalent message sequence (orphan
results, unanswered calls, duplicate ids, displaced results after
same-role merges); `repairToolPairing` fixes the outbound payload only
(drop orphan + note, synthesize interrupted results, hoist steering text
after results, uniquify duplicate ids symmetrically on call+result).
`agent_loop.dart` repairs before every request and retries once on a
pairing-shaped provider 400 (`ToolPairingRepairEvent` is the audit
trail); `_finishStreamed` stamps per-run position-counter ids
session-unique; `auto_compactor.dart`'s `_localTrimFallback` routes the
kept region through the same repairer.
- `lib/src/agent/image_registry.dart` — session image registry (issue
#171): unique images ride a provider request exactly once; every other
occurrence becomes a `[Image N]` text ref. `rewriteHistoryImages`
rewrites the OUTBOUND payload only (session JSONL byte-identical);
carriers `[{text:"[Image N]"},{image}]` anchor before the first
referencing user message or after the tool-result run (never inside
call/result pairs); the current user message rides images in place
(I3), each in-place image carrying its `[Image N]` label (issue #195
F3 — the model can cite what it sees); the per-request cap
(`images.maxPerRequest`, default 20) drops current-first-then-newest
with a drop notice (never silent; the app logs drops via `AppLog`,
issue #195 F4); dangling refs (compaction, cap) resolve to `(image no
longer available)`, and once a renumbering boundary exists (compaction
summary, structured marker, local-trim note) AUTHORED history
citations degrade to that note too — after renumbering an `[Image N]`
may name a different image, so silent rebinding is never allowed
(issue #195 F2; generated refs/labels stay content-keyed and fresh).
Stateless per-request rebuild — determinism, compaction eviction and
resume come free. Kill switch `images.registry: false` → byte-for-byte
legacy shape. `agent_loop.dart` applies it in `_buildRequestContext`
after `transformContext`, before tool-pairing repair.
- `lib/src/trajectory/` — the trajectory ledger core (issue #10): finalized
session records project into an immutable `TrajectorySnapshot` through
`TrajectorySnapshotBuilder.append`/`applyEvent` — streamed agent events
land as synthetic rows keyed `turn\0step` (`u\0turn` for prompts) and are
replaced when the real record lands; revision grows per append. Record
kinds (closed set, `TrajectoryCellKind`): system, user, context,
compacted, message, tool, subtool — a subtool is a
`TrajectoryToolRecord.parentCallId != null`, which only hosts build
directly (the builder never nests). Derived state folds once per
snapshot: `deriveTrajectoryLayout` (turns of Message/Step groups +
collapsible turn/assistant-run summaries), `deriveTrajectoryTimeline`
(four modes — `sequence` equal-width slots, `duration` with idle gaps
compressed out, `time` zero-width wall-clock markers, `actual` real gaps;
three fixed lanes: 0 system/user/context, 1 message/compacted, 2 tools),
`trajectoryTimelineFocusIndexes` (selection → visible ledger indexes),
and `ThrottledTrajectorySearchIndex` (full-text; matches are stable
record ids). `formatters.dart` holds the shared duration/token/throughput
formatting; `trajectory_preview.dart` the bounded single-line previews;
`trajectory_export.dart` the whole-snapshot JSON/Markdown exporters the
header's copy menu uses. Tool records carry `startedAt` (real launch
time, distinct from the projected position) so the Gantt/actual
projections plot true spans. Outbound model requests persist for the
ledger as hidden `model_request_summary` `CustomRecord`s — the CLI
event handler (`agent_event_handler.dart`, emitted through
`agent_cli.dart`) and the app's `AgentService` both buffer a
`TrajectoryRequestDetail` per call and flush it onto the session
record chain right before the assistant message, so replays see it
ahead of the reply — and `TrajectorySnapshotBuilder` folds them into
the message record's request detail (the Request tab).
Consumers render, never re-derive: `packages/fa_ui`'s
`lib/src/trajectory/` widgets and the CLI `/trajectory` family.
- `lib/src/cli/trajectory_commands.dart` (a `part of` `agent_cli.dart`) +
`lib/src/cli/trajectory_tui.dart` — the read-only REPL family
`/trajectory [view|cost|tail|inspect <n>]` (bare = `view`, capped at 200
rows; `tail` follows the live session every 500ms) and the headless
`fa trajectory <view|tail|cost|inspect> [sessionId] [--json] [--at N]`
subcommand (`--at N` replays the snapshot as it stood at record N, view
only). Session resolution: exact id > session-name match > most recent.
Rendering is pure Dart in `trajectory_tui.dart` (no `dart:io`), shared by
both entries; `bin/fah.dart` routes the subcommand.
- `lib/src/redact/` — layered secret redaction (issue #24): ten pure layer
functions (registered/path/vendor/prefix/pem/asn1/connection/context/
entropy/pii) merged priority-first into `RedactionPipeline.scan/redact`
with `[REDACTED:<kind>]` idempotent markers, allowlist + data-URL
pass-through, live-mutable `RedactionConfig`, and `RedactionStats`
(per layer + per tool). `redaction_hooks.dart` attaches it to the agent:
afterToolCall masks tool results BEFORE session persist, transformContext
masks the outgoing context, beforeToolCall denies credential-file
read/bash in `blockMode`, `redactPrompt` masks user input — write-side
tools (`write`/`edit`/`checkpoint`/MCP write-ish) are never filtered
(AC7). Config: `redact:` in `~/.fah/config.yaml` or project
`.fah/config.yaml` (tolerant parse). CLI `/redact`; docs/redaction.md.
- `lib/src/hashline/` — hashline patch language: `[path#TAG]` headers
(4-hex xxHash32 of whole file), `SWAP`/`DEL`/`INS.*` ops, all-or-nothing
`HashlinePatcher`, stale tags reject before any write. Wired in
`builtin_tools.dart`: `edit` takes `patch`, `read` takes `hashline` flag.
- `lib/src/tools/read_selector.dart` — `read` trailing selectors:
`:N`/`:A-B`/`:A+C` (`..` alias), comma multi-ranges, `:raw`. A literal
file named `x:1-2` wins over the selector. `offset`/`limit` must not be
combined with a selector.
- `lib/src/tools/archive_reader.dart` — `read` inside archives
(`a.zip:inner/entry`, `.tar`, `.tgz`), 256 MiB cap.
- `lib/src/tools/sqlite/` — `read` SQLite targets (`db.sqlite`,
`:table[:key][?params]`, `?q=SELECT`). FFI engine (`package:sqlite3`)
exported only from `lib/io.dart`, passed via `builtinTools(env, sqlite:)`;
without it a clean "not supported" note.
- `lib/src/tools/shell_jobs.dart` — background shell jobs:
`ShellJobRegistry` over the `BackgroundShell` capability
(`execution_env.dart`; `LocalShell`/`LocalExecutionEnv` implement it,
`SessionVarsExecutionEnv`/`SecretsExecutionEnv`/`MemoryExecutionEnv` and
the app's `ProjectMountEnv`/`SandboxedExecutionEnv`/
`PersistentWebExecutionEnv` forward it — the desktop app runs the host
shell; mobile's `WasiSandboxShell` and web's `MemoryShell` run jobs as
detached script Futures on job-local interpreter clones via
`flutter_app/lib/sandbox/shell_job.dart` — own cwd/vars/capture, shared
fs). `bash background: true` runs detached with output
streaming into `.fah/bash_jobs/<id>.log`; `bash_job {status|output|stop}`
(write tier) manages them. A settle fires `onSettled` on every exit —
the CLI's terminal bookkeeping (`_onShellJobSettled`: job-board Running
count, waiting row) hangs off it (issue #562). The model-facing notice
(steered mid-run or a fresh idle turn; app `sendText` system-notice)
is skipped when a foreground call consumed the result inline
(`suppressSettleNotification`). Job logs on disk are NOT secret-redacted.
- `lib/src/tools/availability.dart` + `availability_gate.dart` —
capability-gated tool availability (issue #19): `resolveToolAvailability`
merges the `tools:` scope stack (global `~/.fah/config.yaml` < project
`.fah/config.yaml` < session `.tools/<sessionId>.yaml` < runtime
`--tools`/`FA_TOOLS`; deepest scope wins per key) over the host's hard
capability floor — absent tools can never be force-enabled, unknown ids
warn. The gate applies a resolution to the live registry idempotently
(hide/restore + prompt rebuild) and tombstones calls to disabled tools.
CLI surface in `lib/src/cli/agent_cli_tools.dart` (`/tools`, live
rebuild); docs/tool-availability.md is the user-facing page.
- `lib/src/tools/load_modes.dart` + `discover_tools_tool.dart` — agent
load modes (issue #680): `--omp` > `FA_AGENT_MODE` > `agent.mode`
(`default|pi|omp`) pick the tool-LOAD preset — `default` loads every
enabled tool; `pi` the 4-tool benchmark base (issue #679, discovery
off); `omp` the essential base with everything else discoverable,
mounted on demand through the omp-only `discover_tools` meta tool (an
explicit `tools: {id: on}` is a standing mount). The gate's
`discoveryEnabled` flag keeps the discoverable tombstone from
dead-ending on `discover_tools` in modes that don't register it. CLI
sync + `/settings` switch in `agent_cli_tools.dart`/`settings_flow.dart`;
user-facing docs: docs/tool-availability.md §Load modes.
- `lib/src/lsp/` — `lsp` tool (diagnostics/definition/references/rename):
pure-Dart JSON-RPC client over `LspTransport`; `.dart` →
`dart language-server --protocol=lsp`, projects merge `.fah/lsp.json`;
lazy start, 5-min idle shutdown, crash respawn with backoff; renames apply
all-or-nothing through the env. IO transport only via `lib/io.dart` +
`builtinTools(env, lsp:)`; 1-indexed line/character; missing server =
clean note, never a crash.
- `lib/src/mcp/` — MCP (Model Context Protocol) servers from the `mcp:`
section of `~/.fah/config.yaml` (strict `ConfigException` parsing; config
file only, no CLI commands). Stdio (`command`/`args`/`env`,
newline-delimited JSON-RPC — NOT LSP's Content-Length framing) and remote
(`url`, streamable-http default or legacy sse, `headers`). Message-level
`McpTransport`; stdio framing glue is pure Dart over `McpByteChannel`
(process impl only via `lib/io.dart`), both HTTP transports are pure Dart
over injectable `package:http` (web gets remote servers; stdio = clean
"not supported" status). `McpManager` connects lazily in the background
(boot never blocks), per-server connecting/connected/failed status,
reconnect with capped backoff; tools register as `mcp__<server>__<tool>`
(exec approval tier, description prefixed with the origin, inputSchema
verbatim) through the manager's `onChanged` (AgentCli re-registers +
rebuilds the prompt's tiny MCP section). Results map MCP content blocks
onto ours (text as-is, images as ImageContent, resources/links as text
placeholders) under a shared 100k-char budget; `isError` throws so the
loop records an error result; timeouts name `mcp.toolCallTimeoutMs`.
Wired via `builtinTools(env, mcp:)`; resources/prompts out of scope.
- `lib/src/model_roles/` — model roles (`default`/`smol`/`slow`/`plan`) with
fallback chains, key rotation (`ApiKeyRing` over `NAME`/`NAME_2`/…), 429
mid-turn take-over (`FallbackStreamFunction`, never silent). Provider
streams carry watchdogs (`provider_common.dart`:
`providerConnectTimeout` 180s for the request, `providerStreamIdleTimeout`
5min between bytes — overridable via the `providerTimeouts:` config
section, `connectTimeoutMs`/`streamIdleTimeoutMs`, published to
`providerTimeoutsOverride` at startup) so a wedged endpoint errors out — and the resolver
can fail over — instead of hanging the turn forever. Streaming adapters
share ONE keep-alive `http.Client` (`sharedProviderHttpClient`) instead of
a fresh client per call: per-turn TCP churn piled TIME_WAIT sockets into
connect stalls. Config:
`roles:`/`modelOverrides:`/`retry:` in `~/.fah/config.yaml` (invalid
schema = `ConfigException`). Also the shared models config:
`media_model_slots.dart` (media slot names/fields shared with the app's
`MediaModelsStore` + strict yaml slot entry) and `models_config.dart`
(the `models:` section — per-slot media overrides + named custom model
definitions `/model <name>` resolves; mutable like the custom-provider
registry, persisted by the host). Owner context override (issues
#273/#729): `agent:`/`contextWindowCap` in `~/.fah/config.yaml` (strict
section, minimum 16384 = the compaction reserve) SETS the EFFECTIVE
context window via `effectiveContextWindow` (model.dart) for the
compaction thresholds, the CLI ctx meter/footer and the loop's over-window
guard — clamped down below the catalog window; raised above it to the
served truth (a custom provider whose catalog entry under-reports, e.g. a
glm endpoint serving ~1M under a 200k catalog id). Uncapped runs are
byte-identical. Threaded: CliConfig → AgentCliConfig (bin/fah.dart) →
Agent → AgentLoopConfig. Compaction payloads (#729): every summarization
request is bounded to `summarizationPayloadBudget(summarizerWindow)` —
chunked summarization (chunk → `<previous-checkpoint>`/`<folded-checkpoint>`
fold) when the dropped region exceeds it, explicit truncation note for a
single oversized message; the smol summarizer's own window governs (never
the main model's); overflow-classified compaction failures route straight
- to the next summarizer/local trim with zero backoff retries.
+ to the next summarizer/local trim with zero backoff retries. Stall
+ surface (gh-1395): a stream the idle watchdog kills is NAMED
+ (`stall_taxonomy.dart`: connect / first-byte / mid-stream idle), traced
+ under `FA_CONN_DEBUG=1` (`conn_trace.dart`: `conn open (fresh, port P)`
+ → `first byte after Ns` → `idle watchdog FIRED after Ns (conn age,
+ port P)` on stderr + the structured `connTraceEvents` board/sink the
+ bench consumes), captured (`stall_sentinel.dart`: the outbound payload
+ dumps to the trial dir before the abort; `scripts/replay_hang.sh`
+ replays it byte-identically), and recovered with bounded cost
+ (`silent_stream_policy.dart`: codex-style backoff 5→60s, `ApiKeyRing`
+ rotation after the 2nd stall of the run, smol-role takeover attempt
+ after the 3rd, counter reset on success — wired into every
+ `ModelRolesResolver` chain entry, pre-commit stalls only, #964 kept).
+ Purity split (gh-1395 review round 1): the four provider-forensics
+ contracts are pure Dart and barrel-exported; every platform surface
+ (env-knob lookup, the `[conn-trace]` stderr lines, the observed dart:io
+ client, the sentinel's disk sink) is an injected seam installed at the
+ host boundary by `installProviderStallForensics()` from
+ `conn_trace_io.dart`/`stall_sentinel_io.dart` — reachable ONLY through
+ `lib/io.dart`. Dumps are allowlist-redacted (credential carriers —
+ `x-goog-api-key`, custom `authHeader` names included — never reach
+ meta.json; only `safeForensicHeaders` survive) and carry a real SHA-256;
+ the dump budget is 8 per process, `FA_STALL_SENTINEL=0` opts out.
+ The shared client also carries `resetSharedProviderHttpClient()` behind
+ the `FA_POOL_EVICTION` flag — pool hygiene, not a correctness fix (the
+ keep-alive pool is test-exonerated for every watchdog-window class).
- `lib/src/ttsr/` — time-traveling stream rules: regex matched against
streaming deltas; on match abort, inject rule bodies as hidden
`<system-interrupt>` message, retry after 50ms. Persisted via
`TtsrSessionSink`; guards: once-per-session, `maxInjectionsPerTurn`.
Config: `ttsr:` in `~/.fah/config.yaml` + project `.fah/rules.yaml`.
- `lib/src/skills/skills.dart` — agent skills: `<root>/<name>/SKILL.md` plus
Claude/Copilot/Codex layouts (`.claude/skills` + `.claude/commands`,
`.github/skills`, `.codex/skills`, user-level equivalents incl.
`~/.copilot/skills`), each root tagged with a `SkillSource`; project >
user, first-name-wins. The package also ships first-party built-ins
(`create-goal`, `self-settings`) compiled from
`prompts/skills/<name>/SKILL.md` by `scripts/gen_prompts.dart` into
`builtin_skills.dart` — merged into discovery LAST (a same-named
project/user/granted skill shadows the built-in), never gated by the
skills-access consent, served to `read` from virtual
`builtin://skills/<name>/SKILL.md` paths. Per-skill on/off toggles
(`skill_availability.dart`: `skills: {<name>: on|off}`, global
`~/.fah/config.yaml` < project `.fah/config.yaml`, deepest mention
wins) gate the prompt/completion/invocation surfaces live via
`/skills on|off <name> [global|project]` and the `/settings` Skills
row. Third-party roots are discovered BY DEFAULT
(opt-out): `skills_access.dart` `SkillsAccess` ask/granted/denied with
`granted` as the zero-config default — `allowedSources` on
`discoverSkills`/`discoverTaskAgents`; CLI `skills:` config section,
the TUI `/skills` management menu + `/skills access`, startup dialog
only for an explicit `ask`; app `SkillsAccessStore` + settings/boot
dialog). Typed frontmatter lives in
`skill_manifest.dart` (`SkillManifest` — allowed-tools, context: fork,
paths, user/model-invocable …; unknown keys = notes). Only metadata
enters the system prompt (path-gated `paths:` skills appear once a
matching file was touched); bodies loaded with `read` or rendered for
invocation by `skill_renderer.dart` (`$ARGUMENTS`/`$N`/`${CLAUDE_*}`,
`` !`cmd` `` shell injections through the env, `allowed-tools` →
per-turn approval grants, `context: fork` → the task tool). Migration
guide: `docs/migrating-from-claude-copilot-codex.md`.
- `lib/src/prompts/project_context.dart` — `AGENTS.md`/`CLAUDE.md`/`GEMINI.md`/
`GOAL.md`/`DESIGN.md` plus per-directory GitHub Copilot files
(`.github/copilot-instructions.md` and `.github/instructions/
*.instructions.md` — `applyTo` globs not evaluated, scoped files get an
`<!-- applies to: ... -->` marker, `excludeAgent` ignored) auto-merged into
the system prompt: cwd → git root, farthest-first, `<!-- From: -->`
annotations, 32 KiB leaf-first budget; optional `~/.fah/AGENTS.md` first.
CLI: `/skill:<name> [args]`, the `/<name>` alias, `/skills` (TUI: a
management menu — picking a skill prefills `/skill:<name> ` into the
composer via `FaTuiController.sendInputText`, plus access/import rows;
line mode: plain list) with `[reload|access [ask|granted|denied]|import]`
subcommands (bare `/skills access` opens an interactive picker, run
detached from the sequential line REPL like the guided provider flows).
- `lib/src/task/` — `task` tool: parallel subagents, batch form
`{context, tasks[]}` + `background` flag; children never get `task` (no
nesting); roles: `explore`→`smol`, `review`→`slow`, `plan`→`plan` — a
role resolves through the roles resolver only when explicitly pinned
(`ModelRolesConfig.pinsRole`); an unpinned role keeps the parent's live
wiring, so the child inherits the parent's runtime-resolved output cap
instead of the default chain's rebuilt catalog default (issue #302);
`outputSchema` with
ONE fix retry; child failure = per-item error, never batch failure.
Agent types: built-ins (`task`/`explore`/`review`/`plan`) plus discovered
`.md`/`.agent.md` files (`agent_discovery.dart` — roots `.fah/agents`,
`.agents/agents`, `.claude/agents`, `.github/agents`, `.codex/agents` +
user-level, gated by the skills access setting; `canonicalTaskAgentName` folds
Claude/Copilot names like `general-purpose`/`Explore`/`Reviewer` onto the
built-ins).
`/tasks` lists jobs (background agents AND background shell jobs),
`/tasks cancel <id>` routes by id; completions re-enter the parent
as async-result messages (`agent://<id>` refs). Subagents are retained &
addressable: real JSONL child sessions, `task_status`/`task_observe`/
`task_send`/`task_cancel` (model-facing job abort), child-only `reply` +
sibling `agent_message` (pending-queue + hop-capped),
`completed_without_reply` notice. Child IDENTITY for tool calls is
zone-scoped (`subagent_scope.dart`, gh-970): `runSpawn`/`resumeChild`
publish the running child's id via `runWithSubagentScope`, and
`TaskExecutor.currentSubagentId` reads the zone FIRST — the executor's
in-flight id stack is shared by all concurrent children, so its head is
"started last", not "executing" (that shift mislabeled every sender
envelope and recorded replies on sibling handles). Hosts also resolve
shared tools' self-mailbox semantics through the zone (`activeSubagentId`
→ `schedule_message`'s `senderMailbox`), and the CLI wires
`SubagentManager.wakeChild` so mail that fires into a finished child's
inbox (a self-scheduled reminder, sibling mail) resumes it in its own
session — the child-side analog of the idle inbox wake (the sweep is
`wakeChildrenWithPendingMail`, status-gated to completed/idle, one wake
per child per settle, a throwing wake retires the child).
- Steer soft-yield: a steering message (user `Ctrl+S`, subagent completion,
inbox mail) arriving DURING a tool-call phase cancels the phase's
zone-scoped yield token — `currentYieldToken()` in `cancel_token.dart`,
published by `_runToolCallPhase` (`agent_loop.dart`), fed by
`AgentLoopConfig.steeringNotifications` (in-process queue) and the
non-draining `hasPendingSteering` probe (external inbox, 2s poll).
Yield-aware tools (`bash`, blocking `task`) finish the tool call early
WITHOUT stopping the work — it continues as a background job and the user
message is delivered at the step boundary. Tools ignoring the token keep
the classic behavior (message waits for the phase).
- `lib/src/messaging/` — the agent messaging fabric: every agent (main,
subagents, other Fa instances sharing the root) owns a file inbox behind
the isolated `MessagingRepository` interface (send/peek/drain/directory —
future DB/network impls drop in without caller changes).
`AgentMessage.kind` distinguishes agent chat from `user` input handed
over by an attached client (the Fa app's attach view; the CLI delivers
it as `[from app] …`, its own words, not agent chat).
`FileMessagingRepository` over `ExecutionEnv`:
`<root>/<agent>/inbox|read/<id>.json`, timestamp-ordered names, sanitized
agent dirs, torn writes skipped. `SubagentManager.messaging` routes
`enqueueMessage`/`drainMessages` through the fabric (in-memory queue is
the no-fabric fallback); `mailboxPrefix` (the session id, set by the host
on every session init/switch) namespaces mailboxes so two instances never
drain each other — ids with `/` are absolute cross-instance addresses
(`<sessionId>/main`). Sessions also carry their display NAME
(`--session goal_builder`): `register(agentId, {sessionName})` persists
it in a `.name` marker + messages-registry.json, `agent_directory`
renders `name (id)`, and `agent_message` resolves `goal_builder` /
`goal_builder/main` through the directory (exact ids/siblings/`main`
pass through untouched; ambiguous names error with candidates).
`agent_message` targets siblings, `main`, a session name, or an
absolute mailbox; `agent_directory` lists LIVE fabric mailboxes (recent
activity via the heartbeat hosts touch on their inbox-watch timers) plus
anything with pending mail — stale mailboxes from finished sessions are
hidden behind the tool's `all: true` (`.id` markers keep real ids despite
dir sanitization; `_scheduled`/dot-dirs are not mailboxes).
`schedule_message` (issue #59): records under `<messagesRoot>/_scheduled/`;
self-addressed records deliver to the host's LIVE mailbox at fire time
(the address pinned at schedule time goes stale on session switch/restart
and would strand the reminder) — EXCEPT self-addressed records naming a
subagent mailbox of this session (`<prefix>/<childId>`, gh-970): those
deliver as recorded (the address rides the same owner prefix and is as
stable; re-addressing stole the child's reminder into main), and the
tool's `senderMailbox` override (host-wired from `activeSubagentId()`)
makes a subagent's no-`to` schedule default to the CHILD's mailbox, not
main's — hosts re-arm on every mailbox change
(CLI `_syncMailboxPrefix`, app `_setMailboxPrefix`) and sweep due records
on their inbox ticks; `dispose()` cancels only the timer, the files stay.
gh-1180: a re-addressed SELF record also delivers FROM the live mailbox
(`from` rewritten alongside `to` in `_deliverDueInner`) — an adopted
reminder keeps the self shape (from == to) the wake policy needs to
classify it exempt, so a chain survives a session-id change. Every
lifecycle step lands in the receipt trail
(`<messagesRoot>/_scheduled/receipts.jsonl`, `ScheduledReceiptLog`,
best-effort): scheduled / delivered / delivery_failed / scan_failed
(queue) and wake_attempted / turn_started / wake_refused (both hosts'
wake paths; refusal receipts are once per refusal EPISODE — the latch
lives in `InboxWakePolicy` next to the streak, so a held gate cannot
spam the trail and a repeat episode after user input is receipted
again).
Sleep resilience (issue #259): all due math rides an injectable wall
clock (`ScheduledMessageQueue(clock:)`), long waits are split into ≤60s
timer legs (`maxTimerLeg`) that recompute the remaining delay from the
wall clock on every fire — never accumulating duration drift — and
hosts run a catch-up sweep at every TURN START (CLI `_beginUserPrompt`,
app `sendText`) in addition to the idle inbox ticks, so the first
post-sleep turn delivers every overdue record immediately. Catch-up is
exactly-once per record (file removed on send + the `_delivering`
in-flight guard) — missed cycles of a recurring self-re-arm are NOT
replayed; the re-armed cycle simply resumes from "now". For true
sleep-proofing (delivery DURING lid sleep) no in-process timer can
help: add an external pinger, e.g. a cron/launchd entry that messages
the session's mailbox — mail to an asleep session already launches a
headless wake run that drains the inbox and sweeps due records.
Failure isolation (issue #270): a throwing `send` is contained per
record (logged via `onError` — CLI `[sched]` line, app `AppLog`), the
record stays for the next sweep, and the post-failure re-arm floors the
leg at `failureBackoff` (60s) so a poison record cannot spin a
zero-delay timer; sweeps deliver in due-time order, and the app's
turn-start sweep is awaited so the fresh turn sees the fired reminder.
A failed SCAN (transient `listDir`/read error on a leg, gh-1180) is
equally loud: `onError` + a `scan_failed` receipt, then a re-arm at
`failureBackoff` — the arming heartbeat never silently disarms
("scan failed" used to be treated as "nothing pending", permanently
disarming the scheduler with zero log). Corrupt record CONTENT is
still skipped, not fatal (issue #59). Every lifecycle step is
receipted to an append-only JSONL trail at
`<messagesRoot>/_scheduled/receipts.jsonl` (`ScheduledReceiptLog`,
gh-1180): `scheduled` / `delivered` (with lag) / `delivery_failed` /
`scan_failed` from the queue, plus host-side `wake_attempted` /
`turn_started` / `wake_refused` (with reason) from the CLI wake path —
a post-mortem can distinguish "timer never fired" (no `delivered`)
from "wake refused" without reading source. The trail is best-effort:
a failing write logs via `onError` and never breaks scheduling.
Records carry the scheduling instance's `owner` (mailbox prefix): a
sweeper re-addresses a self-addressed record only when the stored owner
matches its own prefix, and never deletes another instance's record -
foreign-owned records are left where their owner sweeps (also across a
root change: only own records migrate to an adopted folder's root).
Legacy untagged records drain via the old any-sweeper path; records
whose owner session is deleted (`/reset` mints a new id) stay on disk -
there is no reaper, cleanup is manual. `agent_fabric` resolves the
messages root lazily per sweep (was: pinned to the launch cwd at
construction), and pending records migrate across a root change, so a
session-folder adoption carries the reminders along instead of
stranding them under the launch cwd.
The `## Agent messaging` prompt section (prompts messaging_section.md, CLI +
app variants) tells the model its own mailbox address. Turn-boundary
delivery: `Agent.externalSteeringSource`
merges inbox drains into the steering poll (main in AgentCli/AgentService
as `_mainInboxMessages`, children via the executor) — messages land as
sender-attributed user messages, so they persist in the session and read
like a chat. Idle wake: an inbox watcher (2s CLI / 3s app) starts a turn
when mail arrives while idle — two Fa instances chat live; a 10-run
streak cap without user input breaks agent-to-agent ping-pong loops.
The lanes live in `InboxWakePolicy` (gh-1180): user-kind mail always
wakes and resets the streak (gating it would deadlock:
no run → no reset → no run); a delivered `schedule_message`
self-reminder (`[scheduled] ` prefix, from == to) is EXEMPT from the
cap — a deliberate agent-chosen cadence (night watch, periodic sweep)
wakes forever, with a 30s cadence floor so a zero-delay re-schedule
spin is still bounded like chatter; foreign agent chatter stays
capped. A refused wake is never silent: one dim `[mail] wake refused`
line per episode and a `wake_refused` receipt.
UI: `/agents` rows show a `mail:N` pending marker (the CLI
font has no ✉ glyph), the app's AgentsSection shows `✉N`; observe/detail
views list the pending inbox.
Issue #27 adds the hub-first composition: with `DAP_MASTER_SECRET` set
and the hub plugin enabled, `bin/fah.dart` wraps the hub as
`HubFabricRepository` (bin/, IO-bound) and routes the fabric through
`FallbackMessagingRepository` (lib) — hub-first for hub-resolvable
targets, file inboxes as offline fallback, queued mail forwarded on
reconnect, merged drains deduped by id. `AgentCliConfig.hubFabric`
hosts inject it; the plugin host skips its separate inbox when the
fabric owns delivery (one hub-mail consumer). Subagent drains never
touch the hub. Issue #304 adds the one-step CLI surface over it:
`fa dap start|stop|status` (bin/fah_dap_command.dart —
`DapHubController`, every IO a seam) probes the port (`/healthz` + a
`/ws` presence handshake; foreign server = clear error naming the
port, no enrollment), spawns `fa hub serve` detached, prompts the
master key ONCE (hidden; empty = open hub, the zero-config extension
default; non-interactive starts prompt nothing), enrolls per the
DAP_MASTER_SECRET flow and persists only the hub-issued clientSecret
(0600 `~/.dap/config.json`; wrong key re-prompts 3x then a manual
hint — a rejected secret is never persisted). The hub pid/state file
`~/.dap/hub.pid` (parse in lib/src/hub/dap_local_hub_state.dart) lets
a second CLI attach (no double-spawn) and `fa dap stop` work from ANY
instance exactly once — stop names connected peers (e.g. "Browser")
and SIGTERMs the owning pid; the fabric falls back to file inboxes.
The `/dap` menu's leading row is state-dependent (AC7): "Start DAP
locally (one step)" stopped, "Stop DAP" running
(lib/src/cli/dap_menu_options.dart `dapMenuOptions(hubRunning:)`).
`agent_directory` renders hub roster entries (MailboxEntry.source ==
'hub') with a `[hub]` marker; the `fabric.hub: false` kill switch
(FabricConfig.hub, gate `hubFabricWired` in bin/hub_fabric_
repository.dart, mirrors images.registry) leaves the fabric the bare
file layer — byte-identical legacy listing (REG test
test/messaging/fabric_kill_switch_reg_test.dart).
Issue #27 phase 3 adds the A2A boundary gateway: `agent_message` accepts
`name@machine` for OTHER machines — `A2aMailGateway` (lib/src/a2a/
a2a_mail_gateway.dart) resolves the machine against the `a2a:` config
section (the server named like the machine) and sends the mail as an A2A
`message/send` with a `faMail` metadata envelope (id/from/to/text/sentAt/
hops, sender address stamped `mailbox@machine` for replies); a failed
remote task errors instead of dead-dropping. Inbound: `fa serve --a2a`
mounts a mail sink — envelope-carrying sends deposit into the project's
file inboxes (resolved by mailbox id or session display name) instead of
running a turn; endpoints without a sink fail such sends honestly. The
hub stays the intra-machine transport; A2A stays the interop boundary.
- `lib/src/session/attach/` — attached-session infrastructure (the Fa app
watching a live `fa` CLI session 1:1 and handing it input): three
INTERFACES so a later network impl (`fa serve --attach`, remote headless)
drops in without touching consumers — `SessionPresenceStore`
(register/touch/unregister/list; stale heartbeats auto-drop, crash
coverage), `SessionEventSource.watch` (transcript tail as
`AttachedMessage` rows: user/assistant/tool), `SessionInputChannel.send`
(user input to the owning process). File impls:
`FileSessionPresenceStore` (`<sessionsRoot>/.presence/<id>.json`,
15s staleness) and `FileSessionEventSource` (JSONL tail via
`readTextLines` deltas) + `FileSessionInputChannel` (fabric mail,
`AgentMessageKind.user`, to `<sessionId>/main`). CLI wiring: `run()`
registers presence, the 2s inbox timer touches it every other tick,
`finally` unregisters; `AgentCliConfig.presenceStore` is injectable
(bin/fah.dart builds the file store; tests inject fakes). CLI sessions
carry `agent: cli` metadata (the app writes `fa`). App wiring:
`CliSessionPresence` service (3s poll) + green-dot `live` on
`SessionTile`; a live tap in the chat-sheet drawer opens
`AttachedSessionScreen` (controller `attached_session_controller.dart`)
instead of a second writer on the same JSONL.
- `lib/src/memory/` — long-term memory: `MemoryController` over the
`flutter_agent_memory` package (hosted pub.dev dep),
`execution_env_kb_storage` (KbStorage → ExecutionEnv), `memory_add`/
`memory_search`/`memory_list` tools (`memoryTools(controller, onChanged:)`),
and the `<memory>` prompt section: `formatPromptSection()` (BOTH scopes,
entities parsed via `KBFileParser` — the raw file's first line is the
`---` delimiter, not the fact) is cached by the host and recomposed into
the system prompt asynchronously after startup + on every `memory_add`
(CLI `_refreshMemorySection` in agent_cli.dart, app in AgentService);
phase 2:
`compaction_memory_hook` (compaction-time durable-fact extraction on the
smol role, non-blocking), `maintain()` (levels + consolidate, running
guard, 24h stamp), triggers on session start + `/memory maintain`;
`/memory` shows stats.
- Git-backed memory (the repo IS the memory): storage paths come from the
`memory:` config section — project `.fah/config.yaml` wins over
`~/.fah/config.yaml` (`loadProjectMemoryConfig`); a relative
`projectPath` resolves against the project root. This repo dogfoods it:
`.fah/config.yaml` points at the COMMITTED `memory/` directory — clone
the repo, get its memory. Inside the store dir: `MemoryRepoInit.
ensureGitSupport()` (flutter_agent_memory 0.2.0) keeps `.gitignore`
(derived artifacts: GRAPH.md, MEMORY.revision, INDEX.md,
.last_maintenance — rebuilt on load, never committed) and
`.gitattributes` (`DELETIONS.md merge=union` — the append-only
tombstone ledger merges by union). Note ids are merge-friendly since
0.2.0: `n_0447_a1b2` = sequential index + 4-hex md5 of the normalized
text (parallel branches union-merge; legacy `n_0001` ids stay valid
forever). The `memory_add` description IS the package's
`MemoryPolicy.memoryAddPolicy` (docs/memory/memory_add_policy.md in
the memory repo): durable facts only, solved problems are superseded
via delete+add (never left to rot), project scope is PUBLIC (git) —
no secrets/personal data there, user scope stays machine-local.
`.gitignore` whitelists `.fah/config.yaml` (+rules/lsp/mcp/agents/
skills/packages); logs/sessions/bash_jobs stay local. Commit memory/
changes with the task that produced them (`memory:` prefix for
memory-only commits).
- `lib/src/a2a/` — A2A (Agent2Agent) interop: `a2a_client.dart` (Agent Card
+ JSON-RPC + SSE), `a2a_config.dart` (`a2a:` yaml section, `${NAME}`
env tokens), `a2a_manager.dart` (lazy per-server connect, A2A task state
→ subagent lifecycle mapping), `a2a_server.dart` (request handler).
`task` tool agent type `a2a:<name>` runs remote agents as subagents
(uniform `task_status`/`task_send`); `/a2a` shows server status;
`fa serve --a2a [--port N] [--token T]` mounts this agent as an endpoint
(`bin/serve_a2a.dart`).
- `lib/src/cli/hep.dart` + `agent_cli_hep_io.dart` — HEP v1, the Harness
Event Protocol (`fa --output events[=full]`, issue #155): strict JSONL
on stdout for server-side supervisors, one JSON object per line,
flushed per line. A TURN is one assistant LLM ROUND, not one user
message — a user turn that runs tools yields SEVERAL terminal frames
(`turn_done` per round, ids incrementing). `HepWriter` is
`AgentListener`-shaped (`agent.subscribe(hep.handleEvent)`); the
`HepEventsIO` decorator (a `part of agent_cli.dart`) drops
`CliIO.write` so stdout stays pure while diagnostics keep stderr.
Frame catalog, ordering/flush guarantees and versioning policy:
docs/hep.md; byte shape pinned by golden tests (test/cli/hep_test.dart).
`--attach` sniffs image mimes by magic bytes (png/jpeg/gif/webp ride
as base64 blocks); any other file passes through as an
`[attached file: …]` path reference appended to the prompt (issue
#196) — never an octet-stream image block.
- `bin/fah.dart` — the `fah`/`fa` CLI. REPL (no args) or headless
(`fa "prompt"` / `-p` / `--prompt-file <path>` (alias `-f`), mutually
exclusive). First positional naming an EXISTING file is the prompt source
(`.md`/`.txt` inlined, others attached by reference; `-p` is verbatim;
`--prompt-file` reads ANY path verbatim, missing/unreadable = usage
error exit 64). Args parsed in `lib/src/cli/cli_args.dart`
(pure Dart). Headless: exit 0/1/130; `CliIO` contract — `write` = primary
stream, `writeln` = diagnostics (stderr headless). The TUI captures the
mouse by default (wheel scrolling + click routing — the alternate screen
has no native scrollback): SGR 1002+1006 with a per-frame hit-region
registry (`tui_hit_regions.dart`, topmost-region click/drag/release
routing in `fa_tui_mouse.dart` — composer caret on mousedown, model
picker rows, queue-row drop); legacy X10 payloads (modes 9/1000) decode
too; `/mouse off` degrades to keyboard with a one-time hint. Selection
works via the terminal's bypass modifier (Shift), and `FA_TUI_MOUSE=0`
(`AgentCliConfig.tuiMouseCapture`) hands the mouse back for always-on
native select-to-copy.
Shift+Enter's HID poll is resolved once at startup
(`lib/src/cli/shift_hid.dart`, issue #355): disabled when
`SSH_CONNECTION`/`SSH_TTY` is set, under `FA_TUI_SHIFT_HID=0`, or when
the one-shot CoreGraphics probe (sacrificial isolate, ~300 ms timeout)
hangs — the returned callback is null and a bare CR stays a submit.
- `lib/src/cli/` — REPL machinery: `/provider [name] [baseUrl] [token] |
custom` (guided wizard in `provider_flow.dart` + `provider_commands.dart`;
`/models` fetched for openai-like endpoints), custom providers in the
`customProviders:` section of `~/.fah/config.yaml`. Editing a saved
OAuth-connected entry (OpenRouter) offers the connect-time sign-in choice
at the key step — keep / Browser OAuth re-auth / paste a key
(`CustomProviderFlowConfig.reauth`). The same picker appears for
CodeMie entries in edit mode (matched by the `/code-assistant-api/`
URL marker — works for legacy entries whose `authMethod` was never
recorded): `Browser SSO (CodeMie)` (`_mintCodeMieSsoCookie`); the
wizard applies the minted value through format-aware switch routing
(`isCodeMieJwtToken` decides SSO-cookie vs JWT-Bearer) so the cookie
stays a `cookie:` header (not Bearer) and the JWT keeps its Bearer
slot. Every add/connect flow that saves a
registry entry offers a provider-name step (`_askConnectProviderName`:
typed value, else the endpoint host; a clash with a DIFFERENT endpoint
retries, and so does a name matching a CATALOG provider — `/provider
kimi` routes to the catalog before the registry lookup, so such an
entry would be unreachable) — the custom wizard, DIAL (custom names
scope the store key; the switch stores under the entry's slot via
`tokenKeyName`), Kimi (a typed key becomes a named
entry with a name-scoped key; a resolved key offers "use it / add a
second account"), OpenRouter OAuth + API-key, ChatGPT OAuth (lands a
registry entry too, `apiType: chatgpt`), CodeMie SSO/JWT (the
SSO name step only from the explicit add flows: `offerName` — automatic
re-authorizations on startup/switch/mid-stream expiry stay silent; a
NEW account name re-runs the guided project → model pick, a re-login
to the same entry keeps its model).
Saved-entry lookups are by base URL (`_entryForBaseUrl`), so a renamed
entry is not duplicated on re-connect. Catalog switches RESET
`_activeCustomName` (kimi/openrouter stored-key branches included) —
a leaked marker made `/model` rewrite the previous custom entry's
model memory. `/models` also manages
the `models:` section: `/models config`/`set <slot> <model> [baseUrl]`/
`remove <slot>` for media slot overrides (persisted via
`onModelsConfigChanged`), and `/model <name>` resolves `models.custom`
definitions. Keys: env first, then
secure store (`lib/src/secrets/secure_key_store*.dart` — Keychain /
Secret Service / PasswordVault, IO backends only in `lib/io.dart`),
preloaded into `SecureKeyCache`. Resolution: the spec's DEFAULT endpoint
gets env → `FA_KEY_<HOST>` → `FA_KEY_<HOST>_<NAME>` → legacy; any other
endpoint resolves ONLY its scoped store keys (env names never hijack a
custom endpoint); `/key set` writes store-only, never config.
`/settings` is the interactive settings hub: a TUI picker whose entries
(Provider, Edit/delete provider, Chat model, Model parameters, Media
models, Agent models, Approval mode, Agent mode, API keys, MCP servers)
launch the same flows the dedicated slash commands open; line mode prints
a summary. ALL TUI pickers (generic host pickers included) have
type-to-filter + backspace (generic ones filter their static item list
locally via `menuAllItems`, the models picker rebuilds through
`buildModelMenu`; a no-match filter keeps the title + `(no matches)`).
The `/model` TUI menu is TWO-STEP when several providers exist:
`_buildModelMenu` returns provider rows (`@<name>` keys, the filter
matches provider names AND their model ids) first, and selecting one
opens that provider's model list (generic `modelProvider` picker whose
`provider|model` rows route back through `_tuiSelectModel`'s
cross-provider switch). A single provider goes straight to its models.
Model rows render as a TABLE (`buildModelPickerTable` in
`model_picker_table.dart`): `● id provider ctx cost` with the
context window compacted (1048576 → `1M`) and `$/Mtok` from the catalog
(`—` when pricing is unknown, never 0.00); rows sort recency → cheaper
total → unpriced last; narrow widths elide cost first, then provider,
keeping id + ctx. The rows are click targets (hit-region routing).
The provider→model pick flows live in `lib/src/cli/settings_flow.dart` in the
NAMED `SettingsFlow` extension (public `runProviderModelFlow`/
`startChatModelFlow`/`startMediaSlotFlow`/`startAgentModelFlow`), so
tests can drive them line-mode (see `test/cli/settings_flow_test.dart`).
The model-list fetch dispatches per dialect
(`_fetchProviderModelIds`: CodeMie `/llm_models` by URL marker, DIAL
deployments when the spec is `dial`, else OpenAI `/models`; the saved
entry's own key authenticates; failures fall back to manual entry).
`startAgentModelFlow` pins the `smol`/`subagent` role chains through
`ModelRolesResolver.setRoleChain`/`clearRoleChain` (the resolver is
created on demand when the config had no `roles:` section —
`AgentCliConfig.modelRolesResolver` is mutable) and persists via
`onModelsConfigChanged` (bin/fah.dart's persistConfig reads the live
resolver config).
- `lib/src/cube/` — fa_cube sandbox profiles (issue #8): strict `CubeSpec`
YAML (`.fah/cubes/<name>.yaml`, apiVersion fa/v1 kind Cube) with
tool/network/fs/env/resource/cache policies (deny-wins, empty allowlist =
deny-all, lexical path normalization as traversal guard), `CubeResolver`
(path > name lookup), Dart-layer enforcement (`SandboxedExecutionEnv`
decorator: `CubeFsGuard` gates the agent's own read/edit/write tools,
`CubePolicyEngine` gates bash incl. pipes/`$(...)` subshells and shell
redirect targets (`>`/`>>`/`<>`/`&>`/`2>` writes, `<` reads, `2>&1`
exempt; arguments stay name-gated only), exit 127
denials with per-exec reason), content-addressed `CubeCacheManager` under
`.fah/cube-cache/<md5>/` (manifest + ttl). `spec.backend:` = `policy`
(default, Dart-only) | `kernel` opt-in: every command wrapped in the OS
sandbox via `CubeSandboxBackend.wrapCommand` — macOS `sandbox-exec -f
<profile>`, Linux `unshare --user --map-root-user --mount [--net iff the
spec allows no network]` + `ulimit -v/-t` ceilings, both over a clean
`env -i` environment; profiles staged content-verified under
`<home>/.fah/cube-profiles/<md5>.sb` (outside the guest-writable
workspace; re-verified against the recomputed profile before every
wrapped exec — #790); kernel mode fail-closes (`fa_cube[<name>]:`
error, no exec) when the staging location is not provably
guest-unwritable — a `homeDir` inside/relative-to the workspace or a
spec mount granting rw over it (the refusal names
`spec.allowDegrade: true` as the explicit policy-mode escape hatch;
with it set, the bind degrades and `onDegrade` fires); the staging
dir is swept once per
binding for this profile's own `.tmp` orphans (other bindings' files
untouched); the macOS SBPL profile denies writes blanket outside the
workspace + `rw` mounts (`/dev/null`, `/dev/fd` exempt) and denies reads
of `/etc` + user homes when no root mount grants reads (a blanket read
deny would abort exec — dyld); Windows = Job Object descriptor only
(kernel on a platform without an enforcing backend is REFUSED — clean
`fa_cube[<name>]:` error naming `spec.allowDegrade`, zero commands —
unless the spec opts in via `allowDegrade: true`, which degrades to
policy mode with the loud `fa_cube[<name>]: kernel backend unavailable,
running in policy mode` warning; `effectiveBackend` reports what
actually ran — #793); a wrapper that is missing from
PATH or refuses the sandbox (EPERM, SBPL
rejection) surfaces as a clean `fa_cube[<name>]: kernel backend ...`
spawn error in foreground execs and background jobs alike. CLI:
startup precedence flags > project `.fah/config.yaml` `cube:` > user
config (`resolveStartupCubeSource`), `/settings` Cube sandbox picker
(shared switch logic in `agent_cli_cube.dart`, persists the `cube:`
startup default), `/cube [use <name>|off|reload|list|cache status|cache
clear]`; cache restore at run start + save at end, headless included.
`SharedSetting.cubeSandbox` is cli-only (host process sandboxing; app
shells already confined). Contract + reference: `schema/cube_schema.json`
and `docs/cubes.md`; mock-LLM integration suite
`test/integration/fa_cube_integration_test.dart` (8 scenarios,
`--tags integration`).
- `lib/src/prompts/prompt_overrides.dart` — `prompts:` config section maps
prompt names to file path or inline text; strict validation; flags
`--system-prompt(-file)` > config > built-in.
- `lib/src/cli/cli_help.dart` — full `fah --help` text, guarded by
`test/cli/cli_help_test.dart` (update BOTH).
- `lib/src/web_search/` — `web_search` (DDG keyless → Brave/Tavily keyed)
and `web_fetch` (HTML→markdown, pub.dev handler) via
`builtinTools(env, webSearch:)`.
- `lib/src/model_roles/provider_catalog.dart` — provider table (incl. the
`chatgpt` Codex-backend entry, kind `chatgpt-codex`); specs
default `input: ['text','image']` (vision). The `FA_PROVIDERS`
dart-define / runtime env (`providerFilterEnvOverride`, wired from the
process env in `bin/fah.dart`; the define wins) allowlists the catalog
per build — `enabledProviders`/`providerEnabledInBuild`/`catalogProvider`
all honor it, default is everything on
(`test/build_filter/provider_filter_test.dart`).
Issue #273 token architecture: `buildCatalogModel`/`buildCliDefaultModel`
resolve `maxTokens` as config override > `resolveModelMaxOutputTokens`
(the kimi-code per-family Claude OUTPUT ceiling table with
nearest-lower-minor fallback, 128000 conservative unknown fallback —
scoped to the `anthropic-messages` api family, `anthropicMessagesApi`;
claude ids on other dialects keep the provider default, issue #302) >
provider spec default;
`lib/src/providers/thinking.dart` ports pi's
thinking ladder (budgets 1024/2048/8192/16384, `minAnswerTokens` 1024,
`adjustMaxTokensForThinking` — thinking fits INSIDE `max_tokens`),
wired into the anthropic adapter via `AnthropicOptions.thinkingLevel`.
`lib/src/providers/chatgpt_oauth.dart` + `chatgpt_codex.dart` — ChatGPT
account sign-in (PKCE against auth.openai.com, Codex CLI client id) and
the Responses-API SSE adapter (`store: false` — the backend rejects
server-side storage; `ChatGPT-Account-ID` header; 401 → refresh →
re-persist via `ChatGptCredentialsPersist`). CLI:
`/provider chatgpt oauth [headless]` (callback server in
`lib/src/cli/chatgpt_oauth_server.dart`, exported from `lib/io.dart`);
the credentials blob lives in the secure store as
`CHATGPT_OAUTH_CREDENTIALS`.
`lib/src/providers/codemie_sso.dart` + `lib/src/cli/codemie_sso_server.dart`
(io.dart export) — CodeMie organization sign-in: `/provider codemie sso
[orgUrl]` runs the browser SSO (login URL embeds the RANDOM callback port;
the base64 `token` callback carries session cookies); the
`codemie_access_token` JWT authenticates as Bearer against
`<org>/code-assistant-api/v1` — the org is saved as a custom provider
entry (re-login refreshes the key, keeps the last-used model). Models come
from `/llm_models?include_all=true` (LiteLLM shape), not `/models`;
`_refreshModelCache` branches on the `code-assistant-api` marker. The
catalog also has a `codemie` entry for the manual
`/provider codemie [url] [token]` path (env `CODEMIE_API_KEY`).
`lib/src/providers/dial.dart` — the `dial` catalog entry (EPAM DIAL Core):
OpenAI-completions payload, but chat at `{baseUrl}/openai/deployments/
{model}/chat/completions` (the deployment name lives in the PATH — via the
`OpenAICompletionsOptions.urlBuilder` extension point) with `Api-Key`
header auth (the adapter gets a null key so no `Authorization: Bearer` is
sent); optional `?api-version=` from the `DIAL_API_VERSION` env
(`_catalogStreamFunction` reads it via `config.envVarValue`). Models come
from `{baseUrl}/openai/models` (`fetchDialModels`, the
`_refreshModelCache` `provider == 'dial'` branch). Headless:
`--provider dial --model <deployment> [--base-url …]` (dial has no
default model id — `buildCliDefaultModel` throws without `--model`).
`lib/src/model_roles/vision_models.dart` — the shared vision heuristic
(`modelIdSuggestsVision`, `visionMarker` picker checkmark,
`inputModalitiesFor`): CLI model switches recompute `Model.input` from it
and the model pickers show the ✓/✗ marker; `packages/fa_ui` re-exports it
(one marker list for CLI and app).
`lib/src/providers/models_for_endpoint.dart` — the shared "list this
endpoint's models" dispatch (`fetchModelsForEndpoint`): CodeMie
`/llm_models` by URL marker, DIAL deployments by `provider: 'dial'`,
else OpenAI `/models`; every model picker (CLI flows, app pages) routes
through it so no dialect silently degrades to manual entry.
- `packages/fa_ui/` — reusable Flutter package for hosts embedding the Fa
agent: the Fa theme (`FahPalette`/`FahLightPalette`/`FahColors.of(context)`,
`buildFahTheme()`/`buildFahThemeLight()` + chat themes) with the
`FaUiTheme`/`FaUiThemeProvider` customization layer (accent colors, font
family, radii), the provider/model settings widgets (`ProvidersSection`,
`ProviderEditorPage`, `DefaultChatModelSection` + pickers,
`MediaModelsSection` (the settings media-models section, moved from the
app — store/`mainBaseUrl`/`modelsFetcher` in, host analytics via the
`onSlotEditorOpened`/`onSlotOverrideSaved` hooks),
`MediaSlotProviderPickerPage`/`MediaSlotModelPage`, `ProviderPreset` +
helpers (hosted presets: OpenRouter, Ollama Cloud, Google Gemini), `ModelIdAutocompleteField`, `FaVoicePresetPicker` +
`faVoicePresetsFor` (per-(baseUrl, modelId) TTS voice presets — Gemini /
Kokoro / OpenAI — with inline sample previews), and the stores (`ProviderRegistry`,
`MediaModelsStore`, `SessionKeysStore`, `KeychainStore`,
`modelIdSuggestsVision`). Every post-provider model choice uses ONE
pattern: the fetched list renders immediately with the field text as the
live quick-search, and a `Use "<query>"` row keeps manual entry —
`FaModelListPicker` (`src/widgets/model_list_picker.dart`, the initial /
tap-picked value shows the FULL list with a check, only user typing
filters) for the form pages — media slots, agent roles, and the CodeMie
SSO model step
(`flutter_app/lib/services/codemie_sso_flow.dart`); the same
`Use "<filter>"` row lives inside
`UnifiedModelPickerPage` (applies on the ACTIVE provider, key resolved
via the registry). The `ProvidersSection` rows carry the SAME brand mark
the add-provider picker shows — `providerMarkKey` (preset) /
`providerMarkKeyForBaseUrl` (custom provider, URL-matched: codemie
marker, openrouter.ai, ollama.com, generativelanguage…, else `custom`) in
`provider_marks.dart` — and never a current-provider check; every row
trails a chevron into the editor. The provider editor
(`ProviderEditorPage`) owns
name/URL/key; the model id is a button-style row (icon + current model +
chevron, mirroring the `TaskModelsSection` rows) opening
`MediaSlotModelPage` with `slot: null` DIRECTLY (no provider-picker step)
for a transient provider entry built from the CURRENT form values — the
edited provider keeps its id so the stored key resolves, and a freshly
typed (unsaved) key rides the page's `apiKeyOverride` (preset mode falls
back to the stored preset key). The editor's `modelsFetcher` is a test
seam threaded through `ProvidersSection`/`AddProviderPresetPickerPage`/
`pushProviderEditor`. The media slots AND the agent-role rows
(`TaskModelsSection`: Quick model / Subagents model) share the ONE
two-step flow — `MediaSlotProviderPickerPage` → `MediaSlotModelPage`
with `slot: null` for roles (no voice field, no capability chips,
dial-aware provider kind) — so provider→model picking can never drift
between settings surfaces. The endpoint fetch
goes through the core `fetchModelsForEndpoint` dispatch (DIAL deployments
/ CodeMie marker / OpenAI `/models`); the legacy two-step
`DefaultModelProviderPickerPage`/`DefaultModelPickerPage` and the
text-field `TaskRoleConfigPage` are gone.
The chat leaf widgets live there too (`lib/src/chat/`): the Markdown
style/sandbox image resolver (`markdown_style.dart`), the inline
audio/video players (`media_player.dart`), the approval/ask/
secret-request sheets, the
transcript message tile (`chat_message_tile.dart`), the composer
(`chat_composer.dart` — file/gallery/camera picking through the
`FaChatHost.uploadPicker`/`galleryPicker`/`cameraPicker` hooks, voice
input through `FaChatHost.voiceInput`; desktop keys: Enter sends and
Shift+Enter inserts a newline (issue #973 — the Focus ancestor swallows
plain Enter before the IME turns it into a break; the touch return key
keeps its IME newline path), Cmd/Ctrl+V is smart paste — a clipboard image
(via the `FaChatHost.clipboardImageReader` hook) or long/multi-line text
is staged as an `uploads/` attachment chip, short single-line text pastes
inline), and the single-service chat
screen (`fa_chat_screen.dart` — `FaChatScreen(service:, features:,
title:, settingsBuilder:, fileBrowserBuilder:, composerBuilder:)`),
plus the upload helpers
(`upload_utils.dart`), the media tool-name constants
(`media_tool_names.dart`), and the brand glyphs
(`fa_glyphs.dart` — `FaAttachGlyph`, the SVG attach icon used by the
composer, `FaModelGlyph`, the AI-chip icon leading the provider editor's
model row, and `FaAiAvatar`, the `>_` squircle used as the assistant
avatar in `chat_message_tile.dart`) —
localized via `FaChatStrings` (en/ru defaults, `FaChatStringsScope`
override), analytics via the `FaChatHost.track` hook, backend surface is
the `FaChatService` interface (which `AgentService` implements;
`ApprovalModeSelector` needs only the
`FaApprovalModeController` slice).
The trajectory ledger UI lives in `lib/src/trajectory/` (issue #25
redesign): `TrajectoryController` (view-state owner — debounced
snapshot updates, turn/assistant folds, the four timeline projections
with selection, record selection, throttled search),
`TrajectoryScreen` (the full-screen trajectory page: wide canvases
`>= kWideLayoutBreakpoint` push it as a master-detail route from the
chat app bar; narrow canvases swap it in as the chat screen's body via
the Chat | Trajectory segmented switcher — controller state lives at
screen level so the swap never loses scroll, selection, or filters),
`TrajectoryHeader` (title + session stamp, stat pills, the search
field with match position and prev/next navigation, the ledger filter
chips, and the export overflow menu that copies the session as
JSON/Markdown to the clipboard), `TrajectoryBody` (header above the
ledger; wide splits ledger ~55% / details ~45% into
`TrajectoryDetailsPane`, narrow keeps the tap-for-details sheet),
`TrajectoryView` (control strip + Gantt timeline strip + ledger of
two-line expandable selectable rows with per-row copy and injectable
table/timeline builder seams), the details surface (`showTrajectoryDetails`
sheet narrow / persistent pane wide: tab set per record kind with
empty tabs hidden, a Request tab for outbound model payloads, per-tab
copy, highlighted Raw view; session-scoped last-tab restore,
`resetTrajectoryTabHistory()` test seam), and `FaTrajectoryPanel`
for hosts that feed their own stream via `TrajectoryServiceFeed`
(broadcast snapshot stream that replays `latest` to new listeners).
`TrajectoryTimeline` draws the Gantt: time ruler + lane labels, three
fixed lanes (0 system/user/context, 1 message/compacted, 2 tools), a
live pulse on the in-progress span, and the whole strip hidden when
the session is untimed.
Strings: `TrajectoryStrings` en/ru + `TrajectoryStringsScope`. Golden
baselines: `test/trajectory/golden/` (regenerate with
`flutter test test/trajectory/golden/ --update-goldens`, verify without
the flag) — `golden_test_setup.dart` loads the real `Inter` and
`JetBrainsMono` families from `test/assets/fonts/` (test-only copies
of flutter_app's bundled TTFs; the package itself ships no font
assets) plus the bundled MaterialIcons font, so goldens render real
glyphs; review every changed PNG after regenerating.
Package strings live in `FaUiStrings`
(en/ru defaults resolved from the locale, host-overridable via
`FaUiStringsScope`) — never the app's gen-l10n. App-level concerns are
injected: the active connection is the `FaChatConnection` interface,
on-device engine routes are `FaOnDeviceRoute` builders, the apply
callback carries `FaChatModelConfig`, and named-key resolution goes
through the `FaUiHost.keyResolver` hook. `flutter_app` consumes it via
path dep + thin `export` shims at the old paths; the app's
`lib/ui/screens/providers_section.dart` additionally keeps a
`DefaultChatModelSection` ADAPTER (old constructor) wiring `AgentService`,
`LastConnectionStore`, and the on-device engines into the package flow.
- `flutter_app/` — Flutter chat example. Layout: `lib/main.dart`
(entrypoint + BootstrapScreen: auto-connects the restored last connection
when its key resolves, else SetupScreen), `lib/ui/` (`app_theme.dart` —
shim re-exporting the fa_ui theme; `markdown_style.dart`, `screens/`,
`widgets/`),
`lib/services/` (agent service, stores, upload, secrets, project mount,
vision, `theme_controller.dart` theme-mode persistence + `FahThemeScope`;
`session_keys_store.dart`/`keychain_store.dart`/`provider_registry.dart`/
`media_models_store.dart`/`vision_models.dart` are shim re-exports of the
fa_ui stores), `lib/sandbox/` (env,
shells, wasm, git, fs persistence), feature dirs
`lib/apps|gemma|webllm|transformers_js|l10n/`. All lib-internal imports
are absolute `package:fa/...` — no relative imports.
- `flutter_app/lib/ui/markdown_style.dart` (shim re-export of
`packages/fa_ui/lib/src/chat/markdown_style.dart`) — beyond
`fahMarkdownStyleSheet`: `SandboxImageResolver`/`fahSandboxImageBuilder`
render markdown images with sandbox paths (`![alt](generated/x.png)`,
leading `/` stripped) by loading bytes via `env.readBinaryFile` (memoized
per surface, dim placeholder on failure, tap → `showFahImagePreview`
fullscreen dialog); wired into the chat screen and the Fa chat overlay
markdown. `generate_image` tool tiles also render the saved image inline
(path parsed from the result text, which itself teaches the model the
`![image](<path>)` convention).
- `flutter_app/lib/ui/widgets/media_player.dart` (shim re-export of
`packages/fa_ui/lib/src/chat/media_player.dart`) — inline audio/video
playback of sandbox media: `SandboxAudioPlayer` (play/pause, seek slider,
`m:ss / m:ss`) and `SandboxVideoPlayer` (bounded tap-to-toggle surface,
progress bar, mute) behind injectable `SandboxAudioController`/
`SandboxVideoController` abstractions (tests/goldens inject fakes via
`ChatScreen(audioControllerFactory:/videoControllerFactory:)`, real
defaults wrap `audioplayers` (`BytesSource` — no file needed) and
`video_player` (bytes staged to a temp file; web build shows an honest
"not supported" note). Wired in `chat_screen.dart` for `speak`/
`generate_music` results plus a `.mp3/.wav/.m4a` (audio) / `.mp4/.mov/
.webm` (video) extension fallback for any NON-`read`, non-error tool
result (which is how `generate_video` tiles render); markdown media links open a `showFahMediaDialog` player dialog
(`onTapLink` — flutter_markdown has no custom link renderer) in the chat
screen and the Fa chat overlay. Bytes load through the same memoized
`SandboxImageResolver.load`.
- `flutter_app/lib/ui/widgets/chat_message_tile.dart` (shim re-export of
`packages/fa_ui/lib/src/chat/chat_message_tile.dart`) — the ONE transcript
message renderer, shared by the chat screen (its `flutter_chat_ui`
builders delegate) and the in-app Fa chat overlay (`compact: true` for
tighter panel padding): user/assistant Markdown bubbles (sandbox images
via `SandboxImageResolver`, selectable), tail-collapsed thinking bubble,
styled `[ tool ]` tiles with the private collapsible output block,
`$`-prompt system lines, and inline generated-image/audio/video under
tool tiles. Never fork message rendering — extend this widget.
- `flutter_app/lib/ui/widgets/chat_composer.dart` (ADAPTER over
`packages/fa_ui/lib/src/chat/chat_composer.dart`) — keeps the app's
constructor surface (`AgentService` + injectable `uploadPicker`/`asr`/
`asrTranscriber`/`clipboardImageReader` test fakes) and bridges it to the
shared composer's hooks: the `UploadPicker` becomes a `FaChatUploadPicker`,
gallery/camera come from `image_picker`, the ASR stack is wrapped in a
`FaChatVoiceInput` (transcriber resolved per take via the session's
media gateway, falling back to the active provider), and the smart-paste
clipboard image probe rides `pasteboard` (`Pasteboard.image` → PNG bytes;
native plugin — the desktop/mobile apps need a REBUILD, not a hot reload,
to register it).
- `flutter_app/lib/ui/screens/app_launcher_screen.dart` — THE app home on
every layout (`faHomeScreen` in main.dart always returns it; the classic
sidebar chat home is legacy and `session_sidebar.dart` no longer exists —
sessions are managed by the chat sheet's pager/menus).
iOS-home-screen grid of the JS apps (fsRevision-refreshed like
`AppsGridView`) plus Settings/Files system tiles, laid out on the
icon-unit geometry (`LauncherGridSpec` in
`lib/ui/widgets/span_grid_delegate.dart`: 56px icon square + 20px label,
16px gaps; default 4 columns < 600px, 6 above, clamped 3–8) as a
`SingleChildScrollView` + `Stack` of `AnimatedPositioned` tiles
(`packTileSpans`/`layOutTileRects`; issue #166 made the packer
order-preserving row-wrap: tiles place strictly in list order, a span
that doesn't fit the remaining row cells wraps to the next row and the
trailing cells stay blank — no backfilling earlier rows, reading order
== tile order; spans clamp to the column count), so reorders animate
live while dragging (center-band hover on an app = folder intent, edge
halves = insertion preview; drop persists; hold-release without
movement opens the tile-size menu). Folder tap opens a floating panel
(rename/dissolve buttons, drag-out-to-ungroup onto the barrier). Tile
layout persists via
`LauncherLayoutStore` (`lib/services/launcher_layout_store.dart`,
`launcher_layout.json` v2: ordered keys `app:<id>`/`system:*`/`folder:<id>`
+ `grid.columns` + `tileSizes` {appId: "WxH"} overrides — agent-editable,
re-read live on fsRevision; v1 migrates, corrupt file → defaults;
`syncApps` reconciles with installed apps). Its Stack hosts the
`SessionChatSheet`.
- `flutter_app/lib/apps/session_chat_sheet.dart` — the iMessage-style
session chat overlay over the launcher, three layers above the app grid:
the always-visible docked input bar (the shared `ChatComposer` with
`autofocus: false` — the bar never pops the keyboard at app start;
leading slot = sessions-drawer toggle with the custom `SessionsGlyph`
stacked-bubbles icon, turning into the attach button once a session is
open; trailing = exactly ONE action — the mic while the field is empty
and idle, stop while streaming-empty, send once text is entered, swapped
via AnimatedSwitcher scale+fade (the composer's `hideMicWhenNotEmpty`;
the idle mic sits in the same pill-colored circle the send/stop button
occupies); the composer carries a stable key so the status-row slot
toggling never recreates it (that killed the field's focus after send),
and `_send` restores focus past the IME send action's own unfocus —
the keyboard stays up for the follow-up message. Focusing the field
opens the panel right away via
`onFocusChanged`, `onSent` does too), the sessions drawer sliding in from
the LEFT (New-session tile + live then persisted sessions rendered with
the SAME `SessionTile`/`sessionTileSubtitle`/`sessionTileCwdLabel`/
`showSessionActionsMenu` the wide sidebar exports from
`sidebar_sessions_list.dart`, persisted open lazily via
`manager.openSession`; a tile's folder label always comes from the
session's OWN on-disk metadata cwd — a live session's env reports the
app's CURRENT mount for every session, so reading
`service.env.sessionCwd` would flip the label the moment a persisted
session opens; its header clears the floating macOS traffic
lights via `faIsMacOSDesktop`; a scrim tap on the exposed part dismisses
the top layer, and the drawer carries its OWN scrim ABOVE the panel so an
outside tap still closes it while a session is open), and the session
panel sliding up UNDER
the bar to 92% (drag-handle header: sessions-drawer button with the
`SessionsGlyph`, title via `SessionNamesStore`, 3-dots menu New / Rename /
Open full chat / Copy / Close — Rename opens the shared
`showRenameSessionDialog`; the shared `ChatMessageTile` transcript padded
above the bar by its measured height; pull-down on the header zone or a
scrim tap closes). While streaming with the panel closed a slim
`FaWorkBar(embedded:)` status row sits above the composer. There is no
collapsed/mini state and no pager — session switching is the drawer.
Sizing: the expanded panel height and all drag math come from the sheet's
own `LayoutBuilder` constraints (`panelFraction`, default
`defaultPanelFraction` 0.92 — hosts like the store-inapp shot pass a
smaller fraction to park the panel lower), NEVER from
`MediaQueryData.fromView(View.of(...))` — `View.of` subscribes to
`_ViewScope`, whose `updateShouldNotify` compares only the view instance,
so it never rebuilds on viewInsets changes and the keyboard opening used
to push the panel header off the top edge; the host Scaffold's
`resizeToAvoidBottomInset` shrinks the body constraints around the
keyboard, which DOES rebuild the LayoutBuilder.
- `flutter_app/lib/ui/screens/chat_screen.dart` (ADAPTER over
`packages/fa_ui/lib/src/chat/fa_chat_screen.dart`; also re-exports
`chatImageMessageSource`/`kWideLayoutBreakpoint`) — keeps the app's
multi-session surface: the `FlutterSessionManager` subscription (session
switch hands the shared screen the new active service, which
re-subscribes and re-syncs in place; closing the last session clones a
fresh one via `ensureActiveSession`) plus the fa-specific affordances the
package screen takes as hooks — the settings route (`settingsBuilder` →
`SettingsScreen`), the files panel (`fileBrowserBuilder` → `FileBrowser`
with env/fsRevision), the `open_app` launcher (`service.appLauncher` →
`FaChatHost.appLauncher` when set, else `pushJsApp`), and the composer
test fakes (`composerBuilder` → the app's `ChatComposer` adapter).
Composer changes must keep the chat goldens pixel-identical.
- `flutter_app/lib/l10n/` — gen-l10n: `app_en.arb` + `app_ru.arb` →
`AppLocalizations` (generated, never edit; `flutter gen-l10n`). UI copy
via `context.l10n.<key>` (`l10n_ext.dart`); locale follows system.
`test/l10n_guard_test.dart` hard-fails on hardcoded widget strings, en/ru
key drift, placeholder mismatches, missing keys — opt out per line with
`// l10n:ignore`, per file with `// l10n:ignore-file` (agent-facing/log
strings stay literal).
- `flutter_app/test/golden/` — golden tests (see MANDATORY section below).
- `flutter_app/test/cli_visual/` — CLI visual integration tests
(integration-tagged, excluded from the pre-commit `flutter test`): the
real `dart bin/fah.dart` runs in a PTY (package:pty2) and every step is
screenshotted through the real Flutter `TerminalView` (JetBrainsMono +
Fa palette, `RepaintBoundary.toImage` at 2x) into the repo-root
`test/integration/screenshots/NN_name.png` + a `.txt` twin with the exact
xterm screen text. Run: `flutter test test/cli_visual --tags
integration`. The pure-Dart counterpart harness lives in
`test/integration/pty_harness.dart` (see its README for the PTY/pty2
pitfalls: spawn `dart bin/fah.dart` NOT `dart run`, always cancel the
output subscription in `close()`).
- `flutter_app/lib/ui/screens/model_presets.dart` — settings "Model presets"
section: a swipeable `PageView` of `kModelPresets` cards applying a whole
model combo in one tap (`applyModelPreset` — per-slot `MediaModelsStore`
overrides, unmapped slots cleared, then `service.reconfigure` +
`LastConnectionStore.saveFromConfig`; missing provider key = inline hint +
jump to `ProviderEditorPage`, nothing applied). Add a preset by appending a
`ModelPreset` to `kModelPresets` (doc comment there) plus its
`modelPreset<Id>Name`/`...Description` arb keys; `ModelPresetTarget` is
sealed for future custom/on-device targets. The presets headline the
dedicated **Models** settings page
(`flutter_app/lib/ui/screens/models_settings_page.dart` —
`ModelsSettingsPage`: presets → `DefaultChatModelSection` →
`TaskModelsSection` → `MediaModelsSection`), opened from the "Models"
row on `SettingsScreen` (chip icon + chevron, ABOVE the Agents section)
via `pushFaPage` — a centered dialog on wide canvases, full-screen on
the phone — so the settings top level stays provider-focused; same
layout on phone and macOS. The sections need the `MediaModelsScope`/`TaskModelsScope`
from the app shell (tests: wrap ABOVE the MaterialApp — pushed routes
build inside the Navigator, above `home:`).
- `flutter_app/lib/sandbox/sandbox_registry.dart` — central registry of
sandbox shell commands per platform; the Fa system prompt's `{{commands}}`
renders from it. Never list commands in prompt text or UI by hand.
- `flutter_app/lib/sandbox/python_http_bridge.dart` — the mobile WASM
python HTTP bridge (issue #337): the WASI CPython build has no sockets and
no `ssl` module, so `fa_http.py` + `sitecustomize.py` (materialized into
site-packages by `WasiSandboxShell._ensurePythonBridge`) patch
`http.client`/`urllib`/`urllib3` to print each request to stdout as a
`\x01FAHTTP1` control line and poll `/dev/.fahttp/<id>` for the raw
response. `FaHttpBridge` (wired into `WasiSandboxShell._runStage` for
python stages) strips those lines from captured stdout, performs the
request with the shell's real-TLS HTTP client and writes the response
back; the authority in the control line carries the scheme
(`https://host:port`). Curl `-d` follows real-curl semantics (`@file`,
`@-`, multiple flags join with `&`, 1 MiB cap, `-d` implies POST) and the
mobile prompt advertises the TLS path via `sandbox_registry.dart`.
- `flutter_app/lib/services/project_mount_env.dart` — macOS project-folder
mount (`/project` → user-picked host dir; security-scoped bookmarks in
`project_mount.json`; stale bookmark = "pick again" warning). Sessions on
macOS are stored in the shared App Group container
(`~/Library/Group Containers/group.dev.fa1.shared/fa/sessions`) so the Fa
CLI and the sandboxed Fa macOS app see the same workspace-scoped sessions.
- `flutter_app/lib/apps/` — JS apps platform on `package:js_widget_runtime`
(`^0.4.156` hosted — ships `jsr.openUrl` + the `webView` node and, since
0.4.156 = upstream 9d57570 (gh-1341), the flutter_js bridge registers
EVERY `__jsr_*` channel (`JsWidgetBridge.handledChannels` is the source
of truth), so `jsr.hostCall`/`jsr.capture` no longer hang forever on JSC
builds; the git pin convention is drop-the-override-once-hosted-lands;
`js_app_engine.dart` wires `openUrlHandler` over `url_launcher`
(LaunchMode.externalApplication, {'__error': ...} rejections) and passes a
`JsWebViewHost` (fa_webview_host.dart, `flutter_inappwebview` over the
system default data store — never incognito; iOS/Android/macOS only, other
platforms get the renderer placeholder via `createFaWebViewHost`) to BOTH
the JsRuntimeConfig and the JsonWidgetRenderer in js_app_view.dart —
flutter_inappwebview rides the coherent 6.2.0-beta.3 set because 6.1.x's
android impl evaluates getDefaultProguardFile('proguard-android.txt'),
which AGP 9 removed (APK CI leg); the beta macos impl declares a 10.14
floor while its Swift needs 10.15 (fails on both the SwiftPM and
CocoaPods paths), so it is vendored with only the floor raised to the
app's 14.0 (vendor/flutter_inappwebview_macos, pubspec override); return
to ^6.1.x once 6.2.0 stable ships —
earlier hosted line shipped the queued-callEvent-after-dispose guard +
restart-safe bridge channels that the old git pin carried, plus the M3
nodes/overlays/flChart/pickers/drawer catalog and M3 motion tokens; the
use-after-free SIGSEGV is owned by `js_app_engine.dart`'s process-wide
lifecycle serialization — releases must stay immediate, never deferred;
the `map` node: center/zoom/markers/polylines/
fitBounds, onTap/onMarkerTap). `flutter_js` itself is overridden in
`flutter_app/pubspec.yaml` to IstiN/flutter_js@74a11bf
(fix-jscore-multi-instance: the shared native sendMessage callback routed
to the LAST created runtime, so coexisting engines converted each other's
JSValues with the wrong JSContext — SIGSEGV in JSC::JSLock::lock; the fork
routes by executing context and refuses post-dispose evaluate): apps live in env-shared `apps/<id>/
{manifest.json, widget.js}`; permissions in `apps_permissions.json` (network/
allowedCommands/llm/homekit/health/contacts/calendar/microphone/
notifications/media/keys/theme — default denied);
`jsr.fa.*` bridge over exec (`fa.llm`, `fa.calendar`, `fa.home.*`,
`fa.health.*`, `fa.asr.*`, `fa.notify.*`, `fa.theme` — declarative-only
list/current/apply over installed theme packs, consent dialog per apply,
no install/delete surface (issue #169); `fa.keys` — list/get/request the
host's merged secrets (AgentService.hostSecrets); `request` renders the
shared secret_request sheet from JsAppView and persists via
AgentService.acceptSecretGrant; contacts is a gated "not
available yet" stub); the `js-apps` skill is a package builtin
(gh-1164 retired the `.fah/skills/` seeder — it is compiled into
`builtinSkillFiles` by `scripts/gen_prompts.dart`, and the gh-1275
packaging-content gate is `scripts/verify_builtin_skills.dart`, wired
into the app build workflows). Bundled demos (seeded by
`AppsStore.demoAppIds`, assets in `flutter_app/assets/apps/` — each id
MUST also have its `- assets/apps/<id>/` entry in pubspec.yaml, gated by
`test/apps/demo_assets_declared_test.dart`): calculator,
weather, stocks, crypto, animation-showcase, yolo-hello, calendar
(`jsr.fa.calendar`), map (`map` node), health + homekit (real bridge on
iOS, honest demo-panel fallback elsewhere), fitness-trainer — guided
workout with a 3D animated coach (a realistic human baked from NAVER's
anny body model — Apache-2.0 — into
`assets/apps/fitness-trainer/models/coach_anny.glb` with 10
hand-authored skeletal clips; baker script in
`references/anny/tools/bake_coach_glb.py` — anny `local-bone` deltas in
world axes → glTF: IBMs are column-major, mesh split into ≤16-joint
surfaces for flame_3d, and the skinned node must NOT be parented to a
joint (flame_3d's dependency count then never settles); rendered via
the `scene3d` node + flame_3d; START-driven exercise/rest steps with
per-clip mapping, pause/skip/quit, sessions persisted via jsr.storage;
`integration_test/fitness_coach_screenshot_test.dart` screenshot-verifies
it on the macOS host), english-teacher — the "Language Tutor": Duolingo-style
quiz sessions (hearts/XP/streak, choice + typing modes), a per-language
offline word bank (en/de/es/fr/pl picker persisted via jsr.storage),
LLM-generated extra words via `jsr.fa.llm.chat` (manifest `llm: true`),
3d-game (`scene3d` node +
`jsr.scene3d.*` bridge on the runtime's flutter_cube/flame_3d dispatcher
— the engine's `JsRuntimeConfig` and both renderers (JsAppView,
AppTileHost) wire `js3dHost: createJs3dHost()`, tap picking flows back
via `dispatchHostEvent('scene3d.tap:<id>')`). Demo seeding is
ownership-aware: `apps/.demo_seeds.json` records sha256 of each file as
last seeded — a file whose content no longer matches is user/agent-owned
and never overwritten, UNLESS the on-disk manifest is unparseable (a
half-written skeleton is re-seeded, not protected: it bricks the tile), (`resetDemoApp(id)` force-restores the reference
version, `storage.json` untouched). A demo id whose seeding FAILS
(missing/corrupt asset) never kills the rest: it lands in
`AppsStore.failedSeeds` (id → error text) and gets an error badge on its
launcher tile; tapping the tile shows a dialog with the copyable error
(hand it to Fa for a fix). `open_app_tool.dart` registers
the agent tool `open_app`
(host callback navigates via `js_app_navigation.dart` `pushJsApp`).
Live launcher tiles: a manifest `"widget"` section
(`{entry: 'widget_tile.js', size: 'WxH', refreshSeconds?}` →
`JsAppInfo.tileWidget`; size in icon-slot cells, W 1–4 × H 1–4, default
2x2 — W 1 = icon-only; out-of-range values clamp to the range with an
AppLog note, never a crash) makes the launcher
grid render `app_tile_host.dart`
(a JsAppEngine on the tile entry, display-only — any tap opens the app)
instead of the static icon tile; a WxH tile's edges align exactly with
the WxH block of icon slots it replaces. Users resize tiles via the
hold-release menu (issue #166 preset list: 1×1/1×2/2×1/2×2/1×3/3×1
plus the iOS 4×2/4×4; writes `tileSizes` into `launcher_layout.json`);
the same menu offers demo apps "Restore reference version"
(`AppsStore.resetDemoApp` — force-reseeds bundled code when
ownership-aware seeding skips modified files, `storage.json` untouched).
- `flutter_app/lib/services/home_service.dart` — smart home: `HomeApi` over
the `fah/home` MethodChannel (HomeKit in `AppDelegate.swift`, iOS only;
the macOS channel answers unsupported): the HMHomeManager is created
LAZILY on the first home call — instantiating it is what triggers the OS
home-data prompt, so the channel registration never touches it (the prompt
waits for the home JS app / a home tool, never app start). `listHomes`/
`listRooms`/
`listAccessories` (with the full services/characteristics breakdown +
isOn/brightness/targetTemperature conveniences), `readAccessory`,
`writeCharacteristic` (ANY writable characteristic by HomeKit type
string), `listScenes`/`executeScene`, and the setPower/setBrightness/
setTargetTemperature aliases. The delegate waits for the first
`homeManagerDidUpdateHomes` (5 s cap) when access was granted but homes
have not loaded yet, and polls the authorization status so a denied
prompt answers `false` instead of hanging. All four writes take optional
`name`/`room`: bridge sub-devices (Aqara/Mi) can share ONE
`uniqueIdentifier`, so the native write routing narrows id matches by
name+room (case-insensitive, exact > partial) and falls back to a
name+room match when the id matches nothing; still-ambiguous = clean
error, never a first-match write. JS surface `jsr.fa.home.*`
mirrors it (`docs/js-system-apis.md`); agent tools in `home_tool.dart`
(`home_devices`, `home_turn_on`/`home_turn_off`, `home_set`) — `match`
accepts a name or a full UUID, optional `room`/`home` args narrow
duplicate names (the ambiguity error teaches both escape hatches), and
`home_devices` shows each accessory's short id (first 8 chars).
- `flutter_app/lib/services/app_log.dart` — process-wide debug log: ring
buffer (2000 lines) + best-effort persistence to `logs/app.log` under
`ExecutionEnv.cwd` (rewritten with its tail past 1 MB). `main.dart` tees
`debugPrint` into it; settings has a "Copy debug logs" row.
- `flutter_app/lib/services/background_execution.dart` — iOS extended
background execution (`fah/background` channel →
`UIApplication.beginBackgroundTask`, io/stub pair): the
`AgentService.isStreaming` setter brackets every run so the OS grants
~30 s of execution when the user backgrounds mid-stream instead of
suspending instantly.
- `flutter_app/lib/services/live_activity.dart` — iOS Live Activity
(`fah/live_activity` channel, ActivityKit ≥16.2, io/stub pair): starts
on run start, updates with the FaWorkBar-style status text on tool
start/end and first deltas, final done/error update then `end` after
4 s. The widget extension is `ios/FaLiveActivity/` (bundle id
`dev.fa1.app.FaLiveActivity`, compiled into both targets via
`FaLiveActivityAttributes.swift`); `Info.plist` has
`NSSupportsLiveActivities`. Device/release signing needs an App ID +
provisioning profiles for the extension in the portal.
- `flutter_app/lib/services/calendar_service.dart` — system calendar:
`CalendarApi` over the `fah/calendar` MethodChannel (EventKit in
`MainFlutterWindow.swift`/`AppDelegate.swift` — MIRRORED, edit both;
entitlement `com.apple.security.personal-information.calendars`, both
NSCalendars*UsageDescription plist keys); stub = not-available on web.
Agent tools in `calendar_tool.dart` (registered when
`calendarPlatformSupported`): `calendar_events {date?, days?}` (rows carry
recurrence/alarm/url hints), `calendar_calendars` (title, source account,
writable), and the write-tier `calendar_add` / `calendar_update` /
`calendar_delete`. Writes support `recurrence`
({frequency, interval?, daysOfWeek? MO..SU weekly-only, daysOfMonth?
monthly-only, until|count — at most one end; validated by
`parseCalendarRecurrence` in calendar_service.dart, removed on update via
`'none'`/`{}`), `alarms` (minutes before start; replace-on-update),
`calendar` (target calendar title), `span` (`this`/`future` → `EKSpan`)
on update/delete, and `url`. Denial result points to System Settings →
Privacy → Calendars. The `jsr.fa.calendar` bridge
(`js_app_engine.dart`) passes the same fields through.
- macOS window chrome: `MainFlutterWindow.swift` uses the modern unified
titlebar (`titlebarAppearsTransparent`, hidden title,
`fullSizeContentView`, `toolbarStyle = .unifiedCompact`, window
background `#070A10`; deployment target 14.0 in `project.pbxproj`), so the
compact traffic lights float over Flutter content; `MaterialApp.builder`
in `main.dart` reserves a 28px top strip on macOS so they never overlap
the app header.
- CodeMie SSO in the app: `lib/services/codemie_sso_flow.dart` — macOS uses
the CLI flow (local callback server + system browser), iOS drives
`ASWebAuthenticationSession` through the `fah/web_auth_session` channel in
`ios/Runner/AppDelegate.swift` (Safari-grade WebAuthn/passkey sign-in —
an embedded WKWebView cannot offer Face ID without a `webcredentials`
associated-domain relationship with the IdP; the session intercepts the
`http://localhost:<port>/?token=` redirect by its `http` scheme, every
flow page being https). When the session cannot start, the flow falls back
to `ui/screens/codemie_sso_webview.dart` (in-app `webview_flutter` page
intercepting the same redirect via its `NavigationDelegate`, password
login only).
- `flutter_app/lib/services/theme_controller.dart` — ThemeMode
(system/light/dark) persisted as `theme.json`; the theme itself lives in
`packages/fa_ui` (`buildFahTheme()` dark + `buildFahThemeLight()`,
re-exported by the `lib/ui/app_theme.dart` shim), widgets read colors via
`FahColors.of(context)` (never `FahPalette` directly in widgets).
- `flutter_app/lib/ui/screens/onboarding_screen.dart` — first-launch
onboarding: 4 pages on every platform (third-party skills are discovered
by default, so there is no consent page). Pages:
welcome + AI disclaimer, permissions explainer, privacy
+ policy link via `url_launcher`), page dots, Skip on every page.
`BootstrapScreen` shows it once only when there is NO restorable
connection; the seen flag lives in
`lib/services/onboarding_store.dart` (`onboarding_seen.json`, same
tiny-store pattern as `theme.json`).
- `flutter_app/lib/services/session_keys_store.dart` — shim re-exporting
the fa_ui store; in-app secrets:
on iOS/macOS persisted in the platform Keychain via `keychain_store.dart`
(the `fah/keychain` MethodChannel, service `fa.app`; file-persisted keys
migrate once), elsewhere `session_keys.json` via the env (set/delete,
never displays values); the settings Keys section manages them.
`provider_registry.dart` custom-provider keys ride the same Keychain
backend (host-scoped `FA_KEY_<HOST>` names, like the CLI). The Keys
section's "Add key" dialog saves arbitrary names
(`^[A-Z][A-Z0-9_]*$`, uppercase-normalized, duplicates rejected);
`AgentService.create` merges saved keys into the agent secrets — dotenv
first, saved keys OVERRIDE on conflict (bash env, redactor, system-prompt
name list all flow from the merged map).
- `flutter_app/lib/services/session_names_store.dart` — user-given session
titles (`session_names.json` envelope in `ExecutionEnv.cwd`, keyed by
session id; the session repo has no header-update API, so renames are an
app-side overlay) plus `derivedSessionTitle(context, id:, createdAt:)` —
the fallback title the chat sheet uses: a localized
intl `DateFormat.MMMd(locale).add_Hm()` date+time from the session's
creation time ("Jul 31 12:30" en / "31 июл. 12:30" ru; `main.dart` calls
`initializeDateFormatting` for the app locales), `session <id8>` when the
creation time is unreachable. The rename dialog
(`flutter_app/lib/ui/widgets/rename_session_dialog.dart`,
`showRenameSessionDialog`) is opened from the chat sheet's menu
(Save/Clear; empty clears → the derived name).
- `flutter_app/lib/services/approval_mode_store.dart` — the tool-approval
mode persisted as `approval_mode.json`; `AgentService.create` seeds
`approval` from it, `setApprovalMode` writes through (fire-and-forget),
`clone()` inherits the CURRENT mode (never a fresh read).
- `flutter_app/lib/services/skills_access_store.dart` — the third-party
skills access setting persisted as `skills_access.json` (same tiny-store
pattern); `null` = never chose = **granted by default** (opt-out
discovery — a fresh install reads `.claude`/`.github/skills`/`.codex`
without asking); a persisted `ask` round-trips verbatim (it is the
boot-dialog state), `denied` opts out. `AgentService.create` gates
`discoverSkills` on it
(`allowedSources: {SkillSource.fah, SkillSource.agents}` unless granted),
`setSkillsAccess` writes through (fire-and-forget) AND re-discovers the
prompt's skills section live (no reconfigure needed), `clone()` inherits
the CURRENT setting. UI: the settings `SkillsAccessSection` dropdown
(ask/allowed/denied), and the one-time boot dialog in `main.dart`'s
`BootstrapScreen` (seen-onboarding + explicit `ask` only; Allow persists
granted, Not now persists DENIED so it never re-asks, a dismissed dialog
stays `ask`). Both surfaces are desktop-only:
`skillsConsentSurfacesVisible` (same file, off `defaultTargetPlatform` so
widget tests can flip it) hides them on Android/iOS — the third-party
roots don't exist there; discovery gating itself is unaffected.
- `flutter_app/lib/services/analytics.dart` — `AppAnalytics` facade over
Firebase Analytics (global instance, noop without Firebase; tests install
a recorder sink): app start, bootstrap outcome, setup shown, connect
result (provider kind/custom/on-device, success only), provider
add/edit/delete, models fetch count bucket, suggestion-vs-free-text model
pick, message sent (attachment flag + length bucket — never content),
session new/switch/delete, settings opened, key set/delete (names only),
upload count, screen_opened per screen (the user-path backbone), chat
sheet state, JS app open/reload, launcher folders/tiles/grid, theme +
approval mode, model presets, media slot set/generated, voice input,
secret request outcome, files opened. Privacy rule: never keys, message
text, or file contents. `test/analytics_guard_test.dart` hard-fails on a
screen/composer/sheet file without an `AppAnalytics.instance` call (or a
documented exemption) and on facade events never called from lib/ —
keep both sides wired.
Crashlytics (`firebase_crashlytics`, wired in `main.dart`: fatal Flutter
errors + uncaught async + debugPrint breadcrumbs) NEEDS
`GoogleService-Info.plist` bundled in the Runner target — both
pbxproj files carry the reference (gitignored file, CI writes it from
`GOOGLE_SERVICE_INFO_PLIST_BASE64`) plus a "Crashlytics: upload dSYMs"
build phase and `dwarf-with-dsym` in Release; settings has a
"Send test crash report" row (non-fatal recordError) to verify the
pipeline from a device.
- `flutter_app/lib/services/last_connection.dart` — persists last connection
(never API keys) as `last_connection.json`; at boot `restorableBootConfig`
(main.dart) rebuilds the AgentConfig (custom-provider key → saved hosted
key) for the auto-connect, else it pre-selects the settings form.
- `flutter_app/lib/services/vision_models.dart` — shim re-exporting the
`modelIdSuggestsVision` heuristic (fa_ui shim → the pure-Dart core
`lib/src/model_roles/vision_models.dart`), which fills `Model.input`;
`AgentConfig.supportsImages` overrides;
without `image` the `read` tool notes non-vision and adapters drop image
blocks.
- `flutter_app/lib/services/media_tools.dart` — `MediaGateway` over the
`media_models.json` slots (+ main-connection fallback) backing the
`generate_image`/`speak`/`generate_music`/`generate_video` tools and the
`jsr.fa.media.*` bridge; files land in `generated/`. `generate_video`
rides the `videoGeneration` slot (required — no fallback): async
OpenAI/OpenRouter `/videos` contract (POST job → poll `GET /videos/{id}`
every 3s, 4-min cap, cancel-token aware → `unsigned_urls` or
`GET /videos/{id}/content` mp4). Google (`generativelanguage`) endpoints
switch to the native Gemini protocol by baseUrl: `speak` posts
`/models/{model}:generateContent` (`x-goog-api-key` auth, LINEAR16 PCM
24 kHz mono wrapped into a `.wav`), `generate_music` posts
`/interactions` with `{model, input}` and deep-searches the response for
base64 audio; the image/video slots answer an honest "not supported for
the Google provider yet" error.
- `docs/subagents/` — the subagents-2.0 + long-term-memory master plan
(phased: memory package publish, memory foundation, memory-aware
compaction, session-backed retained subagents, agent-type menu); update
the checklists there as work lands.
- `site/` — static GitHub Pages landing; `.github/workflows/pages.yml`
builds the web demo into `app/` (never committed). `site/privacy.html`
is the published privacy policy (`PRIVACY.md` in the repo root is the
source text — keep both in sync; the onboarding privacy page links to
`https://fa1.dev/privacy.html`).
- `scripts/` — codegen and quality-gate scripts.
- `browser_ext/` — Chrome MV3 extension pairing fa with the browser
(issue #23): `sw/main.js` (entry, pairing storage, panel API,
optional embedded-agent boot), `sw/bridge.js` (wire protocol v1
client), `sw/ops.js` (browserReq op table), `sw/tabs.js` (task tab
groups), `sw/cdp.js` (chrome.debugger trusted path), `sw/agent.js`
(dart2js build artifact, gitignored, absent → bridge-only scaffold),
`content/content.js` (isolated-world DOM ops), `panel/` (side panel).
`dart/` is the `fa_browser_agent` package (path dep) compiled into
`sw/agent.js` by `scripts/build_browser_ext.sh` — agent host,
chrome.storage ExecutionEnv, provider streams (incl. deterministic
`fake:` provider), DAP hub client. Tests: `browser_ext/dart/test`,
`browser_ext/test` (node --test), `test/browser_ext/` (headless
Chrome, integration tag). Docs: docs/browser-extension.md.
- `lib/src/browser/` — pure-Dart browser half: `bridge_protocol.dart`
(wire protocol v1 — frame codec, ops/error codes, one-time
`pairingToken()`, `browser-ext/` mailbox ids, reconnect backoff, mail
dedupe) and `browser_tools.dart` (the eleven exec-tier `browser_*`
tools over the injectable `BrowserController`; failures throw
`BrowserToolException` carrying the wire code). Availability family
`browser` + `browser_eval` in `agent_cli_tools.dart`
(docs/tool-availability.md); CLI surface in
`lib/src/cli/browser_bridge_commands.dart` (`/browser connect|status`).
- `bin/serve_bridge.dart` — `fa serve --bridge [--port N] [--token T]`
loopback WebSocket bridge (binds 127.0.0.1 only, AC15; non-
`chrome-extension://` origins refused): one-time token pairing
(`.fah/bridge/token`, 0600, minted fresh per `/browser connect`),
two-way mail relay over the messaging fabric, ping/pong keepalive,
`browserReq`/`browserRes` correlation for the `browser_*` tools. The
CLI mounts the same server in-process via `bin/fah.dart`'s
`_FaBrowserBridgeHandle` (also the `BrowserController` seam).
## Hard architecture rules
- `lib/` is pure Dart: **no `dart:io`** (must compile for web). The only
`dart:io` entry points are `bin/` and `lib/io.dart`; file/process/network
behind tools goes through the `ExecutionEnv` abstraction.
## Working in parallel: shared checkout + worktrees
The user's primary working directory is this checkout — they may be
running an LLM loop here at the same time as any agent. To avoid
contention and stale-context confusion, agents follow this policy:
- **Do not modify files in this shared checkout unless the user asked for
it.** For review, `gh api` + `git fetch` + `git show` /
`git diff origin/<branch>..HEAD` is enough — no local checkout needed.
- **Do not run heavy commands in the shared checkout** (`dart test` for
the whole suite, full `dart analyze`). They compete with the user's
own LLM loop for CPU/IO and can flip its context mid-task.
- **Do not `git stash` the user's WIP.** Their uncommitted changes are
sacred; never pop, drop, or apply them. If a checkout conflict blocks
you, ask the user before touching `git stash`.
- **If a worktree is genuinely required** (running `dart test`/`analyze`
locally against a non-current branch, exploring a fresh PR head):
- **Ask the user first** whether to proceed — by default, review
should not need a worktree at all.
- If the user agrees, create under `flutter_agent/.worktrees/<name>/`
(the in-repo location they picked — dot-prefix keeps it visually
separate in `ls`, and `rm -rf .worktrees/` cleans everything in one
shot). Never in the parent directory `../<name>/`, which has been
the source of orphan pollution before.
- Use `git worktree add --detach .worktrees/<name> origin/<branch>`
against the remote tracking ref — never the local branch ref, which
the user may have moved on.
- **`git worktree remove` the moment you're done.** Don't leave stale
worktrees behind.
- The `.worktrees/` directory should be in `.gitignore` (so worktrees
never get committed by accident). Add it there **only after
confirming with the user** — silent `.gitignore` edits conflict
with parallel agents who may already have it dirty.
- **Communicate with peer agents through the inbox fabric**, not by
reading or writing their working files. `agent_directory` lists
siblings; `agent_message` to the relevant mailbox id. Their
`memory/note/` files are theirs — leave them alone.
## Memory is part of the system prompt
Before starting any non-trivial task, **search memory first** to pick up
durable facts the user has accumulated across sessions — preferences,
gotchas, conventions, prior decisions. Cheap to query, often the
difference between re-deriving a rule and silently violating it:
- `memory_search <topic>` for keyword search across both scopes
(project = this repo, committed; user = global, private). Read the
top results before acting.
- `memory_list` to skim recent entries when the topic is fuzzy or the
user asks "what do you know about X".
- Project-scope notes under `memory/note/` are **the user's** — read
freely, write only via `memory_add` (which goes through the package's
policy: durable facts, supersede via delete+add, never leave solved
problems to rot).
- User-scope notes are global across projects — facts about the user,
their environment, their habits. Always relevant.
Common things memory already covers for this repo: worktree placement,
shared-checkout rules, agent messaging fabric, known fragile files,
prior false-blame incidents. If the task touches one of those, search
before you start.
## Golden tests are MANDATORY for UI work
Any change to `flutter_app/lib/` UI code is INCOMPLETE until its golden
tests are done right:
1. **Coverage first.** New widget file → tests in
`test/golden/<area>_golden_test.dart` + map entry in
`golden_guard_test.dart` (hard-fails otherwise). Changed visuals →
regenerate affected snapshots.
2. **Real fonts.** `setUpAll(ensureGoldenFonts)` in every golden file;
placeholder-box glyphs = garbage, redo.
3. **Full frames.** Snapshots are marketing material: full app frames
(`goldenSizeDesktop` 1280x800, `goldenSizePhone` 390x844) with realistic
content — never a widget floating on black void.
4. **Determinism.** Fixed data/timestamps, existing-test fakes, no
network/engines/infinite animations.
5. **Eyes on pixels.** After `--update-goldens`, OPEN every changed PNG
(no tofu, no overflow, legible buttons/icons) before committing.
6. **Green gate.** `flutter test test/golden` passes; pre-commit runs it +
`scripts/check_goldens.py` (which also hard-fails on ORPHAN snapshots —
committed PNGs no test references; delete stale files, never let them
rot in git history).
## Prompts live outside Dart code
- Every LLM prompt is a Markdown file under `prompts/**` (example app:
`flutter_app/prompts/`). Never prompt string literals in `.dart` files.
- Format: YAML frontmatter (`name`, `description`) between `---` lines,
then body; runtime placeholders are `{{name}}` tokens.
- After editing a prompt: `dart run scripts/gen_prompts.dart` rewrites
`lib/src/prompts/prompts.g.dart` + `flutter_app/lib/prompts.g.dart`
(generated, never edit by hand); `test/prompts/prompts_sync_test.dart`
gates drift.
- Bundled Codex model catalog
(`lib/src/providers/chatgpt_codex_models_data.dart`) is generated from
`codex-rs/models-manager/models.json` via
`dart run scripts/sync_codex_models.dart`. Re-run the script when
codex-rs ships a new catalog so the picker / OAuth default stay current
without a manual edit.
## Quality gates (pre-commit hook: `scripts/pre-commit`)
Issue #177 restructured CI so a PR pays only for what it touched. The hook
is a thin wrapper over `scripts/ci_fast_gate.sh`; CI is NOT the same
process — it implements the same stages as parallel jobs, and since
issue #194 its PR path classification runs through the SAME script
(`ci_fast_gate.sh --dry-run` in the `changes` job). One classifier, one
set of thresholds; parity is checkable by diffing the hook's
`GATE_STAGE <name> OK|SKIP` markers against the stages the CI changes job
logs for the same diff. Path filters: docs/markdown/prompts-only →
size+analyze; lib/bin/test/example/pubspec → +tests, coverage, CRAP,
jscpd + cross-module dupx; `test/integration/**` → + `integration-mock`
(issue #551: the no-key legs run per-PR); flutter_app/packages → +dupx,
flutter analyze+tests;
scripts/.github/crap4dart.yaml/unknown → everything (safe default, E2 —
holds in CI too). Hook-only extras: dart format self-heal of staged files
and `scripts/check_goldens.py --quick` (skipped for docs-only commits).
- `dart analyze` + dart format clean (explicit dirs — `yoclip/` is a
standalone video workspace with its own toolchain); example app also
`flutter analyze --no-fatal-infos --no-fatal-warnings`.
- `dart test` green (integration-tagged excluded from test-core — issue
#551: `test/integration/**` changes also run the `integration-mock` leg
per-PR: no-key legs via `MockLlmServer`, `--exclude-tags llm,browser-ext,
perf,pty`; child-agent-spawn PTY tests carry `pty` and stay in nightly
(issue #553). Tag-only CI = llm smoke + publish + binaries; nightly keeps
the full real-provider suite (pty-integration).
**The Quality gate is deterministic-only (gh-1199): mock LLM everywhere,
live models never.** Real-provider files carry
`@Tags(['integration', 'llm'])` and run ONLY in the tag-only
provider-smoke job and nightly with secrets — the per-PR integration
shards exclude them at selection (`shard_files.py --exclude-tag llm`) AND
at invocation (`--exclude-tags browser-ext,llm`), so a model's mood can
never block a merge. The boundary is self-enforcing:
`scripts/check_llm_tag_boundary.py` (static-gate step + pinned audit)
fails any integration-tagged file that looks live without the `llm` tag;
`test/scripts/shard_files_llm_boundary_test.dart` proves the shard
selection stays live-free. Real-model verification belongs to the bench
harnesses (terminal-bench / harbor / MLS-Bench) run under supervision,
plus manual `dart test --tags "integration && llm"` for debugging — NO
scheduled-or-gate live runs in the PR path. A proven flake (red in ≥2
distinct-SHA gate runs within 24h) is quarantined via
`scripts/test_quarantine.json` (checked in, reviewed in the fix PR;
`flake-watch.yml` files the issue and opens the PR) — the gate legs pipe
their targets through `scripts/apply_quarantine.py`, while nightly
KEEPS running the file to produce the un-quarantine repeat-run proof.
- `cd flutter_app && flutter test --exclude-tags integration` green
(includes golden suite; integration-tagged `test/cli_visual` runs in the
nightly workflow + on demand).
- Line coverage of `lib/` ≥ 80% (full ratchet on main/nightly; PRs ratchet
CHANGED lines via `scripts/diff_coverage.py`); jscpd duplication < 1%
core `lib/`, < 3.7% `flutter_app/lib/` (ratchet — only tighten).
- Cross-module duplication (issue #487): clones spanning module roots
(`lib`, `bin`, `flutter_app/lib`, `packages/*/lib`, `browser_ext/dart`)
via `scripts/check_dup_cross_module.sh` — stage `dupx`, ONE jscpd
invocation, marker-parity with the CI `guards` job (which also runs it
for app-only PRs — cross-module clones are usually born app-side).
Baseline 0.3013% of the combined ~283k lines, pinned 0.31 in
`ci_fast_gate.sh` (ratchet — only tighten; the per-package gates above
cannot see across module boundaries).
- CRAP ratchet (`crap4dart analyze`, tool pinned as
`dart pub global activate crap4dart ">=0.11.0 <0.12.0"` — gh-1106
superseded the old 0.10 bound; gates/baselines need 0.11), one config
per package, thresholds are the current per-package max — only down
from here:
- core (`crap4dart.yaml`, sources `[lib, bin]`): **12.0** (issue #1234:
the named census offenders moved — CC-12 trio + readPasteboardImage
16.32 split, the 0%-cov CC-3 quartet and the named CC-9 tier covered
or split, `_listModels` covered — but the first whole-tree
measurement showed the 8.0 repin unearned: 54 methods at 12.00
(CC-3@0% tier + covered CC-12 dispatchers) and 83 at 9.00. Threshold
stays at the measured max; the tier is the next ladder card).
- flutter_app (`flutter_app/crap4dart.yaml`, sources `[lib]`,
issue #433/#475): **30.0** — measured by the SAME pipeline the CI gate
uses (the two `flutter-tests` shards emit `--coverage`; the
`app-crap-gate` job merges the lcovs and runs the pinned analyzer —
widget-test coverage differs from the core's dart lcov, E2).
- The app ladder (follow-up cards, NOT this one): 2450 → 870 → 702 → 650
→ 380 → 272 → 210 (done: `JsAppEngine._faCall` 2450→6 via map
dispatch, `patch` 94→19, `_tokenize` 70→18; issue #475:
`wasm_shell_git.run` route-table + 51 golden tests, app `main()`
boot-phase helpers, `wasm_shell` beasts `_grepBuiltin` 812→covered
parse helpers, `_runPipeline` 702→210, `_runStage` 600→380,
`_parsePrimary` 506→210; issue #558: the wasm_shell pipeline engine
(`_runStage` 380→4, `_runPipeline` 210→4, `_evalExpr` 182→3); issue
#559: the wasm_shell builtins (`_xargsBuiltin` 272→7,
`_TestEvaluator._parsePrimary` 210→3, `_trBuiltin` 210→4,
`_testBuiltin` 132→9, `_duBuiltin` 132→3, `_expandTrSet` 110→gone,
`_tacBuiltin` 90→6, `_pipBuiltin` 72→3 — spec/table-driven parse
helpers in `wasm_shell_builtins.dart` + tested exec halves) → next
milestone 210-class (`GemmaService.chatStream`, `_statBuiltin` 156,
wide_layout 156 pair) → … → **40**, each step "fix the code, then
lower the threshold" — never lower first.
- Guards (`scripts/check_crap_guards.py`): README badge == config
threshold for BOTH packages, generated-exclude parity between the
two configs, and `--only-down` (a PR that raises any threshold vs
the merge base is rejected in CI).
- Max 2800 lines per `.dart` file (crap4dart `loc` gate, issue #1234 —
generated output excluded in the gate configs; `*.g.dart` exempt).
- CI layout: `changes` (path filter) → parallel `static`, `test-core`
(3 duration-balanced shards, `scripts/test_shards.json`, rebalanced
weekly from junit durations; shard jobs emit junit-shard-N artifacts and
`shard_files.py` bin-packs manifest-missing new test dirs at runtime),
`coverage-gate`, `guards`, `flutter-tests` (ubuntu; goldens stay
host-locked in build-macos/nightly) → one aggregate `Quality gate`
required check, plus `step-timings` (per-step duration telemetry) and a
`watchdog` (>2x-baseline timeouts; a timed-out PR leg gets ONE
empty-commit retrigger — never `gh run rerun`, which reallocates into
the same degraded runner pool). `perf-gate` (issue #303) folds the #262
DoD `dart test --tags perf` trajectory gate into the aggregate for
trajectory-touching PRs. Issue #551 adds the `integration-mock` leg to
the aggregate for `test/integration/**` PRs (no-key legs via
MockLlmServer); the tag-only `integration` job is just the llm provider
smoke and `publish` waits on it. Issue #1267 N1 adds the
`flutter-app-exzone` leg: it runs the macOS exclusion-zone family
(`flutter_app/test/apps/` — JS-engine tests the ubuntu leg skips via
`native_test_guard.dart`, plus `flutter_app/lib/apps/` + `lib/src/js_ext/`
are the code that surfaces there) on macos pre-merge, closing the
nightly-only gap the #1309 escape rode. Under this repo's dispatch-only
CI the leg is path-blind: ci.yml has no `pull_request` trigger (SM
`workflow_dispatch` validation is the PR gate), so the leg runs in every
SM validation dispatch (gh-1280 binaries-pr idiom); the
`flutter_app/lib/apps/**`/`lib/src/js_ext/**`/`flutter_app/pubspec.yaml`
path set in the `changes` job only narrows a future real `pull_request`
trigger. Main/tag/schedule/release runs skip it — `nightly.yml` keeps
the full-suite macOS backstop there. `nightly.yml` runs the full monolith +
PTY/CLI integration (full real-provider suite with secrets) +
terminal-visual suites; `coverage-gardener.yml` measures the only-up CLI
coverage weekly and files an ai-teammate "raise coverage to X" issue —
the baseline itself only moves through a reviewed PR (issue #927).
## Cross-platform parity
Every shared setting and interactive prompt type must exist on BOTH the CLI
(`lib/src/cli/`) and the Flutter app (`flutter_app/lib/`) unless it is
**fundamentally impossible** on the target platform. When a setting or prompt
type is platform-only (e.g. MCP servers need process spawning — impossible on
web), it MUST be explicitly listed in `cliOnlySettings` or `appOnlySettings`
in `lib/src/parity/settings_registry.dart` with a comment explaining WHY.
**Workflow when adding a new setting or interactive type:**
1. Add it to `SharedSetting` enum in `settings_registry.dart` FIRST
2. Implement on BOTH platforms (CLI + Flutter app)
3. If one platform genuinely cannot support it: add to the exemption set
with a one-line reason comment
4. Run `dart test test/parity/` — the parity guard must pass
**Capability rules (issue #1079, host-wiring SDK layer):** every NEW
host-wiring capability declares its matrix state AT BIRTH — for all seven
hosts (`cli`, `macos`, `ios`, `android`, `web`, `extension`, `outlook`) a
state: `on`, `off(reason)`, or `transport(choice, reason)` for a
partial/different transport. The record types, validation and transport
vocabularies live in `lib/src/hosts/host_capability_profile.dart`; the
seven built-in profiles — where those per-host cells are actually
declared — live in `lib/src/hosts/host_wiring_builder.dart`. The
matrix-completeness test rejects undeclared capabilities, so "CLI got X,
app didn't" cannot happen silently. A capability that is
`off` is INVISIBLE: hidden from the host's UI (menus, settings, slash
commands, tool palettes) AND from everything model-facing (tool schemas,
system-prompt sections, skills listings, help) — the agent never sees
what it cannot use and the user never sees a button that errors; on a
`transport` cell only the available transports surface. Profiles can only
NARROW what the platform floor allows: force-enabling a floored
capability throws `HostProfileViolation` at construction, never a runtime
surprise.
**Host shells wire through the builder (issue #1079 slice 2):** a host
constructs its agent stack via `wireAgentCore` (lib/src/hosts/
host_agent_wiring.dart) — typed `AgentCoreServices` in, `WiredAgentCore`
(env chain + core tools + plan) and `buildAgentStack` (registry + agent)
out. The CLI (`AgentCli`) is the reference shell. Per-run narrowing:
a wired capability whose services the host did not provide turns
`off(reason)` for that run (a null config section is a declared state,
not a silent hand-rolled `if`), and wired transports with absent
per-transport services are dropped. When adding a capability: declare
every host's cell, add its `requiredServices` to the catalog, wire its
tools inside `wireAgentCore`'s canonical order, and extend the
hosts test — never gate tool registration with a bare `if` outside the
builder. The task/subagent complex (fabric → manager → heartbeat →
task/monitoring tools, slice 3) is builder-owned the same way: hosts pass
shell glue as `SubagentServices` callbacks and keep handles from
`WiredAgentCore` — a host never hand-assembles the manager or registers
`task` outside the gated surface. Host EXTENSIONS are declared surface
too (slice 4, `HostExtensionApi`, lib/src/hosts/host_extension_api.dart):
a `HostExtension` carries its tools plus an explicit state for every
built-in profile at birth (E6 — no implicit default; a custom profile
needs its state before wiring), the builder rejects tool-id collisions
across the core/task/extension surfaces at build time naming both
registrants (E7 — an extension id colliding with a core tool would
silently override SDK behavior, so the loud check lives in the builder,
not the child-lenient `ToolRegistry`), and an extension `off` on the
wired profile hides from the registry with its reason surfaced on
`WiredAgentCore.extensions` (E8). The extension surface is additive-only:
memory, context management, compaction, the JSONL tool session and
trajectory are SDK invariants with no override point (AC9).
## Commits and releases
- Commit identity: human/AI contributors commit as `ai.teammate
<agent.ai.native@gmail.com>` (history was rewritten to it — set
`git config user.name ai.teammate` + `git config user.email
agent.ai.native@gmail.com` repo-locally). Release commits from
`scripts/auto_release.sh` are authored `fa-release-bot[bot]
<fa-release-bot[bot]@users.noreply.github.com>` — the gh-1172 ruleset-
bypass App pushes the bump straight to protected main, so the App identity
is what auditability hangs on.
- Commit subjects: `type(scope): ...` (`feat:`, `fix:`, `fix(example):`,
`ci:`, `test(providers):`, `refactor(prompts):`).
- Every push to `main` auto-releases a patch to pub.dev
(`scripts/auto_release.sh` via `ci.yml`) — intended. The version scheme is
`1.0.<counter>` (gh-785: App Store Connect approved 1.0.0 and enforces a
monotonic `CFBundleShortVersionString`, so the pre-promotion `0.1.<counter>`
train can never upload again); the core and app pubspecs bump in lockstep.
- CLI binaries build per tag (`ci.yml` `binaries` job), attach to the
GitHub Release (`fa-<os>-<arch>[.exe]`); `installer-smoke` verifies
installers.
- App builds (`build-mobile.yml`, `build-macos.yml`, manual dispatch):
Android AAB + iOS IPA on `[self-hosted, macOS, ARM64]`, macOS DMG/ZIP
signed + notarized, TestFlight via `flutter_app/fastlane`. Optional
secrets: `APP_STORE_CONNECT_KEY_ID/_ISSUER_ID/_KEY_CONTENT`,
`MACOS_INSTALLER_P12_BASE64/_PASSWORD/MACOS_INSTALLER_CERT_ID`,
`GOOGLE_SERVICE_INFO_PLIST_BASE64`. Both workflows hard-gate the binary
on wasm_run FFI exports (`xcrun dyld_info -exports` must list
`_wire_compile_wasm` — Podfiles force-load + `-exported_symbol` +
`STRIP_STYLE=non-global`, else white screen on TestFlight). Pods cached
keyed by `Podfile.lock`. The gate proves FFI exports ONLY — a Dart boot
crash before the first frame white-screens TestFlight with a green gate
(issue #544: build 160, boot refactor created the platform env before
`WidgetsFlutterBinding.ensureInitialized`); boot stage order is pinned by
`flutter_app/test/boot/app_boot_test.dart`.
- Lockfile policy (gh-1296 NG1): EVERY lockfile is committed and enforced —
one inventory list in `scripts/check_lockfiles.sh` (7 files:
`flutter_app/pubspec.lock`, `ios/Podfile.lock`, `macos/Podfile.lock`,
the vendored `flutter_inappwebview_macos` `Package.resolved`, both e2e
`package-lock.json`s, and `vendor/wasm_run/native/Cargo.lock`); a listed
lockfile that is missing/untracked/gitignored fails the Static gates, and
pod-sync (flutter-analyze + nightly build legs, after `pub get`) fails
when a native iOS/macOS plugin resolved by pubspec.lock has no pod in the
platform's `Podfile.lock` (the gh-1274 class), or its pod is pinned at a
stale version vs the resolved plugin podspec's `s.version` (the
flutter_gemma 1.8.0-vs-1.11.3 skew; the podspec is the comparison target,
not the pubspec version — vendored/beta pods legitimately differ from the
Dart package version). The inventory also enforces the gh-1303 path-pin
sync: `flutter_app/pubspec.lock` pins this repo as a path dependency, so
its `flutter_agent_harness` pin must EXIST and equal the root
`pubspec.yaml` version — a stale OR MISSING pin (e.g. a merge conflict
resolved by dropping the entry) makes every
`flutter pub get --enforce-lockfile` consumer red —
the release bump re-pins the lock itself (`scripts/auto_release.sh`
ships the re-pin inside the `chore(release):` commit), and the Static
gate catches any other path to the drift at PR time. After ANY pubspec.yaml
dependency change that adds/updates a darwin-native plugin: `cd
flutter_app/ios && pod install --repo-update` and the same in `macos/`,
commit both `Podfile.lock`s (CocoaPods runs fine on Linux for
regeneration; the wasm_run `nm` step needs an llvm-nm shim + the
XCFramework staged via `scripts/setup_vendor_ios.sh` or the upstream zip).
Guard red exits are fixture-self-tested (`scripts/
check_lockfiles_selftest.sh`, Static gates).
- TestFlight external distribution (issue #239): the `submit_only` lanes
(driven by build-mobile.yml / build-macos.yml) distribute every build
straight to the EXTERNAL group — REQUIRED repo variables
`TESTFLIGHT_EXTERNAL_GROUP`, `BETA_REVIEW_CONTACT_EMAIL`,
`BETA_REVIEW_CONTACT_PHONE`, `BETA_APP_FEEDBACK_EMAIL` (lanes fail loudly
at start without them). Pilot must WAIT for processing (skipping the wait
silently skips distribution); `verify_external_distribution!` asserts
group membership bounded — beta-review-pending is a neutral notice, a
build never reaching the group fails the leg (self-filed issue).
- Manual App Store review: `release-appstore.yml` (workflow_dispatch:
`version` + `platforms` ios/macos/both + `confirm` repeating the version)
runs the `submit_for_review` lanes — pure pre-flight in
`flutter_app/fastlane/appstore_preflight.rb` (plain-ruby tested) fails
BEFORE any mutation; already-submitted versions are a green no-op;
release-after-approval stays a manual ASC click
(`APP_STORE_AUTOMATIC_RELEASE` repo variable flips it, E5). Grep-guards:
`test/store_automation_guard_test.dart`.
- App Store content pipeline (no binary): store screenshots are COMMITTED
goldens from `flutter_app/test/golden/store_screenshots_test.dart` (frame +
inline en/ru copy in `store_marketing_frame.dart`) at
`flutter_app/test/goldens/store/{en,ru}/{ios,ipad,mac}/` — regenerate with
`flutter test test/golden/store_screenshots_test.dart --update-goldens`,
then open every PNG. Store copy lives in
`flutter_app/fastlane/metadata/{ios,macos}/{en-US,ru-RU}/` (the lanes strip
name.txt/subtitle.txt — App Info is not editable post-release); release
notes come from the latest `## […]` section of
`store_artefacts/metadata/{en,ru}/changelog.md`. Upload: `fastlane ios
app_store` / `fastlane mac app_store` in `flutter_app` (env gates
`IOS_DEPLOY_METADATA`/`IOS_DEPLOY_SCREENSHOTS`/`MACOS_DEPLOY_*`; ASC API key
env secrets) or the `store-metadata.yml` workflow (`ios_content`/
`macos_content` inputs: none/metadata_only/screenshots_only/all).
- Google Play listing pipeline (issue #289, no binary): the Play Console
image rules are repo tests. Listing screenshots are the same story frames
as COMMITTED GOLDENS straight into the supply tree
(`fastlane/metadata/android/{en-US,ru-RU}/images/`): phone 1080×1920
(exact 9:16, en+ru), 10-inch 1440×2560 (exact 9:16 — 1600×2560 is 10:16
and Play rejects it; en only, ru falls back to phone shots), regenerated
with `flutter test test/golden/store_screenshots_test.dart
--update-goldens` then `git checkout -- test/goldens/store` (the run also
refreshes the App Store goldens on a non-canonical host — restore them);
the feature graphic (1024×500) is a widget golden in
`test/golden/play_store_assets_test.dart`; the icon is the iOS master
resized by `tool/generate_android_adaptive_icons.dart` (512×512, mirrored
byte-identical to ru-RU). Sizes/aspects/file caps are enforced by
`flutter_app/test/play_store_listing_guard_test.dart`. Listing texts
(title ≤30, short_description ≤80, full_description) live in
`fastlane/metadata/android/{en-US,ru-RU}/`. Upload: `fastlane android
play_store` in `flutter_app` (env gates `PLAY_DEPLOY_METADATA`/
`PLAY_DEPLOY_IMAGES`, `PLAY_VALIDATE_ONLY=1` dry-run; Play service-account
secret `PLAY_STORE_SERVICE_ACCOUNT_JSON`) or the `store-metadata.yml`
android leg (`android_content`: none/metadata_only/images_only/all). Images
never ride supply (it only APPENDS uploads — issue #947): the `play_store`
lane pushes them through `play_listing_sync.rb`, which replaces the whole
image set per locale + device type (dropped sets are cleared) and verifies
the committed goldens against `edits.images.list`. The daily
`daily-publish.yml` play leg dispatches `build-mobile.yml` with
`android_content=all`, so every release train redeploys the listing.